thinkwork-cli 0.12.15 → 0.12.17

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 (152) hide show
  1. package/dist/{api-client-4VT3FWOH.js → api-client-JUBCQQDF.js} +1 -1
  2. package/dist/{chunk-H7AB42ES.js → chunk-STY44FQO.js} +13 -0
  3. package/dist/cli.js +1107 -10225
  4. package/dist/commands/enterprise/templates/deploy-repo/docs/runbook.md +3 -8
  5. package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +1 -184
  6. package/dist/drizzle/0050_brain_v0_entity_subtype.sql +2 -2
  7. package/dist/drizzle/0051_brain_v0_tenant_entity_pages.sql +6 -6
  8. package/dist/drizzle/0052_brain_v0_external_refs.sql +2 -2
  9. package/dist/drizzle/0053_brain_v0_unresolved_mentions_subtype.sql +2 -2
  10. package/dist/drizzle/0145_knowledge_graph_thread_ingest.sql +14 -14
  11. package/dist/drizzle/0146_knowledge_graph_source_scope.sql +1 -1
  12. package/dist/drizzle/0157_knowledge_graph_observations_source.sql +2 -2
  13. package/dist/drizzle/0158_wiki_tenant_scope.sql +1 -1
  14. package/dist/drizzle/0165_plugin_premium_entitlements.sql +2 -2
  15. package/dist/drizzle/{0166_company_brain_substrate_contract.sql → 0166_brain_substrate_contract.sql} +9 -9
  16. package/dist/drizzle/{0167_company_brain_artifact_manifest_runtime.sql → 0167_brain_artifact_manifest_runtime.sql} +5 -5
  17. package/dist/drizzle/0168_eval_skill_gate.sql +1 -1
  18. package/dist/drizzle/0183_okf_artifact_manifests.sql +1 -1
  19. package/dist/drizzle/0197_rcm_context_columns.sql +28 -0
  20. package/dist/drizzle/0200_eval_results_execution_tier.sql +40 -0
  21. package/dist/drizzle/0201_brain_dream_runs.sql +72 -0
  22. package/dist/drizzle/0202_backfill_thread_participants_from_senders.sql +110 -0
  23. package/dist/drizzle/0203_threads_mode_override.sql +61 -0
  24. package/dist/drizzle/0204_thread_turns_triggering_message_id.sql +48 -0
  25. package/dist/drizzle/0205_routines_git_python.sql +185 -0
  26. package/dist/drizzle/0206_agent_loop_versions_routine_actions.sql +40 -0
  27. package/dist/drizzle/0207_tenant_credentials_github_repo_kind.sql +52 -0
  28. package/dist/drizzle/0208_knowledge_graph_neutral_identifiers.sql +253 -0
  29. package/dist/drizzle/0209_living_artifacts_schema.sql +109 -0
  30. package/dist/drizzle/0210_think137_automations_additive_schema.sql +113 -0
  31. package/dist/drizzle/0211_backfill_agent_loop_versions_target_spec.sql +120 -0
  32. package/dist/drizzle/0212_fold_webhooks_into_automations.sql +312 -0
  33. package/dist/drizzle/0213_agent_loop_versions_judge_evidence_nullable.sql +63 -0
  34. package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +99 -0
  35. package/dist/drizzle/0215_agent_loop_versions_goal_worker_policy_nullable.sql +70 -0
  36. package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +81 -0
  37. package/dist/drizzle/0217_agents_capability_folder_dispatch.sql +12 -0
  38. package/dist/drizzle/0218_document_plates.sql +30 -0
  39. package/dist/drizzle/0219_document_section_waivers.sql +29 -0
  40. package/dist/drizzle/0220_artifacts_created_by_user.sql +21 -0
  41. package/dist/drizzle/0221_artifact_shares.sql +29 -0
  42. package/dist/drizzle/0221_workflow_interpreter_thin_slice.sql +71 -0
  43. package/dist/drizzle/0222_compliance_drainer_select_audit_events.sql +21 -0
  44. package/dist/drizzle/0223_document_conformance_reports.sql +38 -0
  45. package/dist/drizzle/0224_artifact_refresh_state.sql +11 -0
  46. package/dist/drizzle/0225_think216_agent_loop_convergence.sql +21 -0
  47. package/dist/drizzle/0226_drop_n8n_agent_step_runs.sql +6 -0
  48. package/dist/drizzle/0227_analyst_reader_role.sql +1055 -0
  49. package/dist/drizzle/0228_tenant_credentials_rds_iam_kind.sql +62 -0
  50. package/dist/drizzle/0229_analyst_reader_rds_iam_grant.sql +79 -0
  51. package/dist/drizzle/0230_analyst_rls.sql +1613 -0
  52. package/dist/drizzle/0231_cost_events_cache_write_enforcement_exempt.sql +11 -0
  53. package/dist/plugins/catalog/package.json +0 -1
  54. package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +0 -1
  55. package/dist/plugins/catalog/src/__tests__/catalog.test.ts +16 -8
  56. package/dist/plugins/catalog/src/__tests__/contracts.test.ts +56 -0
  57. package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +0 -2
  58. package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -5
  59. package/dist/plugins/catalog/src/contracts.ts +112 -0
  60. package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -6
  61. package/dist/plugins/n8n/README.md +4 -36
  62. package/dist/plugins/n8n/src/deployment/managed-app.ts +0 -17
  63. package/dist/plugins/n8n/src/manifest.ts +0 -5
  64. package/dist/plugins/n8n/terraform/n8n/main.tf +0 -26
  65. package/dist/plugins/n8n/terraform/n8n/outputs.tf +0 -5
  66. package/dist/plugins/n8n/terraform/n8n/variables.tf +0 -6
  67. package/dist/plugins/n8n/test/manifest.test.ts +0 -1
  68. package/dist/plugins/twenty/package.json +5 -0
  69. package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +232 -0
  70. package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +489 -0
  71. package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +199 -0
  72. package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +43 -0
  73. package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +230 -0
  74. package/dist/plugins/twenty/scripts/lib/__tests__/twenty-client.test.ts +207 -0
  75. package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +282 -0
  76. package/dist/plugins/twenty/scripts/lib/load-attachments.ts +220 -0
  77. package/dist/plugins/twenty/scripts/lib/load-records.ts +680 -0
  78. package/dist/plugins/twenty/scripts/lib/mappers.ts +559 -0
  79. package/dist/plugins/twenty/scripts/lib/members-ensure.ts +381 -0
  80. package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +191 -0
  81. package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +425 -0
  82. package/dist/plugins/twenty/scripts/lib/twenty-client.ts +246 -0
  83. package/dist/plugins/twenty/scripts/migrate-lastmile.ts +610 -0
  84. package/dist/plugins/twenty/scripts/provision-twenty-members.ts +214 -0
  85. package/dist/plugins/twenty/src/manifest.ts +99 -0
  86. package/dist/plugins/twenty/terraform/twenty/README.md +1 -1
  87. package/dist/plugins/twenty/test/manifest.test.ts +12 -2
  88. package/dist/terraform/examples/greenfield/main.tf +90 -380
  89. package/dist/terraform/examples/greenfield/terraform.tfvars.example +0 -1
  90. package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
  91. package/dist/terraform/modules/app/agentcore-pi/main.tf +36 -32
  92. package/dist/terraform/modules/app/agentcore-pi/variables.tf +23 -15
  93. package/dist/terraform/modules/app/agentcore-runtime/main.tf +3 -3
  94. package/dist/terraform/modules/app/customer-domain/main.tf +16 -2
  95. package/dist/terraform/modules/app/deployment-control-plane/main.tf +5 -0
  96. package/dist/terraform/modules/app/deployment-control-plane/runner.py +85 -207
  97. package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +56 -2
  98. package/dist/terraform/modules/app/hindsight-memory/main.tf +52 -4
  99. package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +7 -0
  100. package/dist/terraform/modules/app/lambda-api/handlers.tf +493 -89
  101. package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +215 -96
  102. package/dist/terraform/modules/app/lambda-api/main.tf +24 -5
  103. package/dist/terraform/modules/app/lambda-api/outputs.tf +19 -11
  104. package/dist/terraform/modules/app/lambda-api/variables.tf +94 -67
  105. package/dist/terraform/modules/app/ses-email/main.tf +14 -2
  106. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/main.tf +493 -0
  107. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/outputs.tf +34 -0
  108. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/variables.tf +32 -0
  109. package/dist/terraform/modules/data/aurora-postgres/main.tf +55 -0
  110. package/dist/terraform/modules/data/aurora-postgres/outputs.tf +15 -0
  111. package/dist/terraform/modules/data/s3-buckets/main.tf +26 -0
  112. package/dist/terraform/modules/thinkwork/README.md +0 -1
  113. package/dist/terraform/modules/thinkwork/main.tf +167 -446
  114. package/dist/terraform/modules/thinkwork/outputs.tf +2 -142
  115. package/dist/terraform/modules/thinkwork/variables.tf +55 -309
  116. package/dist/terraform/schema.graphql +4 -0
  117. package/dist/workspace-defaults/files/AGENTS.md +45 -31
  118. package/dist/workspace-defaults/files/CONTEXT.md +48 -4
  119. package/dist/workspace-defaults/files/GUARDRAILS.md +18 -0
  120. package/dist/workspace-defaults/files/MEMORY_GUIDE.md +32 -4
  121. package/dist/workspace-defaults/files/SPACE.md +34 -19
  122. package/dist/workspace-defaults/files/TOOLS.md +30 -0
  123. package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +154 -0
  124. package/dist/workspace-defaults/files/skills/document-composer/references/authoring-rules.md +101 -0
  125. package/package.json +2 -1
  126. package/dist/plugins/company-brain/README.md +0 -37
  127. package/dist/plugins/company-brain/package.json +0 -33
  128. package/dist/plugins/company-brain/runtime/cognee/Dockerfile +0 -11
  129. package/dist/plugins/company-brain/smoke/cognee-managed-app-smoke.mjs +0 -458
  130. package/dist/plugins/company-brain/smoke/cognee-memory-cutover-smoke.mjs +0 -539
  131. package/dist/plugins/company-brain/smoke/company-brain-context-engine-smoke.mjs +0 -312
  132. package/dist/plugins/company-brain/smoke/company-brain-operations-smoke.mjs +0 -449
  133. package/dist/plugins/company-brain/smoke/company-brain-plugin-smoke.mjs +0 -655
  134. package/dist/plugins/company-brain/smoke/hindsight-memory-isolation-smoke.mjs +0 -474
  135. package/dist/plugins/company-brain/src/api/cognee-client.ts +0 -896
  136. package/dist/plugins/company-brain/src/api/cognee-cluster-identity.ts +0 -49
  137. package/dist/plugins/company-brain/src/api/cognee-memory-scope.ts +0 -84
  138. package/dist/plugins/company-brain/src/api/context-engine-provider.ts +0 -888
  139. package/dist/plugins/company-brain/src/api/migration.ts +0 -665
  140. package/dist/plugins/company-brain/src/deployment/cognee-managed-app.ts +0 -396
  141. package/dist/plugins/company-brain/src/index.ts +0 -62
  142. package/dist/plugins/company-brain/src/manifest.ts +0 -112
  143. package/dist/plugins/company-brain/terraform/cognee/README.md +0 -263
  144. package/dist/plugins/company-brain/terraform/cognee/main.tf +0 -766
  145. package/dist/plugins/company-brain/terraform/cognee/outputs.tf +0 -144
  146. package/dist/plugins/company-brain/terraform/cognee/variables.tf +0 -402
  147. package/dist/plugins/company-brain/test/api/cognee-client.test.ts +0 -562
  148. package/dist/plugins/company-brain/test/api/cognee-memory-scope.test.ts +0 -74
  149. package/dist/plugins/company-brain/test/api/context-engine-provider.test.ts +0 -447
  150. package/dist/plugins/company-brain/test/api/migration.test.ts +0 -465
  151. package/dist/plugins/company-brain/test/manifest.test.ts +0 -95
  152. package/dist/plugins/company-brain/tsconfig.json +0 -8
@@ -0,0 +1,559 @@
1
+ /**
2
+ * Pure LastMile → Twenty mapping functions (no I/O), unit-tested first per the
3
+ * plan's U5 execution note. Field shapes follow the U1 live-introspection
4
+ * resolutions recorded in the plan: FullName{firstName,lastName},
5
+ * Currency{amountMicros,currencyCode}, Emails{primaryEmail}, etc.
6
+ */
7
+
8
+ import { createHash } from "node:crypto";
9
+
10
+ import type {
11
+ LastmileAccount,
12
+ LastmileContact,
13
+ LastmileCrmComment,
14
+ LastmileCustomerNote,
15
+ LastmileLead,
16
+ LastmileOpportunity,
17
+ } from "./lastmile-reader";
18
+
19
+ /**
20
+ * Bump to force an update sweep after a mapper fix: previously "unchanged"
21
+ * records re-hash differently and get rewritten (plan KTD3 / Risks).
22
+ */
23
+ export const HASH_VERSION = "1";
24
+
25
+ /** sourceId values are namespaced by source table so leads and opportunities
26
+ * (which share Twenty's opportunity object) can never collide. */
27
+ export function sourceId(table: string, id: string): string {
28
+ return `${table}:${id}`;
29
+ }
30
+
31
+ export function stableStringify(value: unknown): string {
32
+ if (Array.isArray(value)) {
33
+ return `[${value.map(stableStringify).join(",")}]`;
34
+ }
35
+ if (value !== null && typeof value === "object") {
36
+ const entries = Object.entries(value as Record<string, unknown>)
37
+ .filter(([, v]) => v !== undefined)
38
+ .sort(([a], [b]) => (a < b ? -1 : 1))
39
+ .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
40
+ return `{${entries.join(",")}}`;
41
+ }
42
+ return JSON.stringify(value);
43
+ }
44
+
45
+ export function contentHash(mapped: Record<string, unknown>): string {
46
+ const { sourceHash: _ignored, ...rest } = mapped;
47
+ return createHash("sha256")
48
+ .update(`v${HASH_VERSION}:${stableStringify(rest)}`)
49
+ .digest("hex");
50
+ }
51
+
52
+ /**
53
+ * LastMile owner references are inconsistent: `rep_*` sales_rep ids, short
54
+ * aliases ("jbake"), and full display names ("Jane Baker") all appear in the
55
+ * same columns (live data, 2026-07-09). buildOwnerIndex expands the
56
+ * provisioned rep→member map with alias and full-name keys, but ONLY when the
57
+ * alias/name is unique across reps — ambiguous keys resolve to nobody and the
58
+ * record is flagged instead (refuse to guess).
59
+ */
60
+ export function buildOwnerIndex(
61
+ reps: ReadonlyArray<{
62
+ id: string;
63
+ alias: string | null;
64
+ firstName: string | null;
65
+ lastName: string | null;
66
+ }>,
67
+ memberIdByRepId: ReadonlyMap<string, string>,
68
+ ): Map<string, string> {
69
+ const index = new Map<string, string>();
70
+ const claim = (key: string | null, repId: string, ambiguous: Set<string>) => {
71
+ if (!key) return;
72
+ const normalized = key.toLowerCase().trim();
73
+ if (!normalized || ambiguous.has(normalized)) return;
74
+ const memberId = memberIdByRepId.get(repId);
75
+ if (!memberId) return;
76
+ if (index.has(normalized) && index.get(normalized) !== memberId) {
77
+ ambiguous.add(normalized);
78
+ index.delete(normalized);
79
+ return;
80
+ }
81
+ index.set(normalized, memberId);
82
+ };
83
+ const ambiguous = new Set<string>();
84
+ for (const rep of reps) {
85
+ claim(rep.id, rep.id, ambiguous);
86
+ claim(rep.alias, rep.id, ambiguous);
87
+ const fullName = [rep.firstName, rep.lastName].filter(Boolean).join(" ");
88
+ claim(fullName, rep.id, ambiguous);
89
+ // LastMile derives usernames as first initial + first 4 letters of the
90
+ // last name ("dembl" = Daniel Emblen); many sales_rep rows leave the alias
91
+ // column empty while lead/opportunity owner refs use the derived form.
92
+ if (rep.firstName && rep.lastName) {
93
+ claim(
94
+ `${rep.firstName[0]}${rep.lastName.slice(0, 4)}`,
95
+ rep.id,
96
+ ambiguous,
97
+ );
98
+ }
99
+ }
100
+ return index;
101
+ }
102
+
103
+ export function resolveOwner(
104
+ ownerRef: string | null,
105
+ ownerIndex: ReadonlyMap<string, string>,
106
+ ): string | null {
107
+ if (!ownerRef) return null;
108
+ return (
109
+ ownerIndex.get(ownerRef) ??
110
+ ownerIndex.get(ownerRef.toLowerCase().trim()) ??
111
+ null
112
+ );
113
+ }
114
+
115
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
116
+
117
+ export function normalizeEmail(raw: string | null | undefined): string | null {
118
+ if (!raw) return null;
119
+ const email = raw.trim().toLowerCase();
120
+ return EMAIL_RE.test(email) ? email : null;
121
+ }
122
+
123
+ /** Non-person sales_rep rows: house/dealer buckets, intercompany ledgers, and
124
+ * placeholders. They must never receive a login. */
125
+ const NON_PERSON_REP_TOKENS = new Set([
126
+ "house",
127
+ "intercompany",
128
+ "tbd",
129
+ "undefined",
130
+ "unknown",
131
+ "unassigned",
132
+ "buyback",
133
+ "asc",
134
+ "salesrep",
135
+ "company",
136
+ "fuel",
137
+ "oil",
138
+ "transport",
139
+ "transportation",
140
+ "hauling",
141
+ ]);
142
+
143
+ /** A person's name part: letters, optionally hyphenated/apostrophed. Rejects
144
+ * multi-word buckets ("Golden West Laredo"), digits, and underscores
145
+ * ("Ervi_2"). */
146
+ const NAME_PART_RE = /^[a-z]+(?:['-][a-z]+)*$/;
147
+
148
+ /**
149
+ * Reps missing an email in LastMile get `<first-initial><lastname>@<domain>`
150
+ * (Eric's rule, 2026-07-10) — but only when both name parts look like a real
151
+ * person's. 60 of 131 active reps have no email; most are house/intercompany
152
+ * rows that must stay unprovisionable. Returns null for those.
153
+ */
154
+ export function deriveRepEmail(
155
+ firstName: string | null,
156
+ lastName: string | null,
157
+ domain: string,
158
+ ): string | null {
159
+ const first = (firstName ?? "").trim().toLowerCase();
160
+ const last = (lastName ?? "").trim().toLowerCase();
161
+ if (!NAME_PART_RE.test(first) || !NAME_PART_RE.test(last)) return null;
162
+ if (NON_PERSON_REP_TOKENS.has(first) || NON_PERSON_REP_TOKENS.has(last)) {
163
+ return null;
164
+ }
165
+ const localPart = `${first[0]}${last.replace(/['-]/g, "")}`;
166
+ return normalizeEmail(`${localPart}@${domain}`);
167
+ }
168
+
169
+ export interface NormalizedPhone {
170
+ primaryPhoneNumber: string;
171
+ primaryPhoneCallingCode: string;
172
+ primaryPhoneCountryCode: string;
173
+ }
174
+
175
+ /**
176
+ * Twenty validates phones with libphonenumber; bare national formats like
177
+ * "512-825-8875" are rejected without a country code (observed live — 17k
178
+ * person creates failed on it). LastMile numbers are US: normalize 10-digit
179
+ * (or 1-prefixed 11-digit) numbers to number + "+1"/US parts; anything else
180
+ * is dropped with a warning rather than failing the whole record.
181
+ */
182
+ export function normalizePhone(
183
+ raw: string | null | undefined,
184
+ ): NormalizedPhone | null {
185
+ if (!raw) return null;
186
+ const digits = raw.replace(/\D/g, "");
187
+ const national =
188
+ digits.length === 10
189
+ ? digits
190
+ : digits.length === 11 && digits.startsWith("1")
191
+ ? digits.slice(1)
192
+ : null;
193
+ // libphonenumber rejects US numbers whose area code starts with 0/1.
194
+ if (!national || national[0] === "0" || national[0] === "1") return null;
195
+ return {
196
+ primaryPhoneNumber: national,
197
+ primaryPhoneCallingCode: "+1",
198
+ primaryPhoneCountryCode: "US",
199
+ };
200
+ }
201
+
202
+ /** Dollars (numeric string or number) → integer micros. Returns null for
203
+ * missing/unparsable amounts. */
204
+ export function toAmountMicros(
205
+ raw: string | number | null | undefined,
206
+ ): number | null {
207
+ if (raw === null || raw === undefined || raw === "") return null;
208
+ const numeric =
209
+ typeof raw === "number" ? raw : Number(String(raw).replace(/[$,\s]/g, ""));
210
+ if (!Number.isFinite(numeric)) return null;
211
+ return Math.round(numeric * 1_000_000);
212
+ }
213
+
214
+ export function toQuantity(raw: string | null | undefined): number | null {
215
+ if (!raw) return null;
216
+ const numeric = Number(String(raw).replace(/[,\s]/g, ""));
217
+ return Number.isFinite(numeric) ? numeric : null;
218
+ }
219
+
220
+ // --- Stage mapping -------------------------------------------------------
221
+
222
+ /** Existing TEI options (NEW/SCREENING/MEETING/PROPOSAL/CUSTOMER) are
223
+ * preserved verbatim — the ThinkWork workflow triggers on CUSTOMER. These are
224
+ * the options the migration adds, merged into the live array (full-replace
225
+ * write semantics confirmed by U1). */
226
+ export const MIGRATION_STAGE_OPTIONS: Array<{
227
+ label: string;
228
+ value: string;
229
+ color: string;
230
+ }> = [
231
+ { label: "Lead", value: "LEAD", color: "gray" },
232
+ { label: "Lead - Working", value: "LEAD_WORKING", color: "gray" },
233
+ { label: "Lead - Qualified", value: "LEAD_QUALIFIED", color: "blue" },
234
+ { label: "Lead - Unqualified", value: "LEAD_UNQUALIFIED", color: "gray" },
235
+ { label: "Prospect", value: "PROSPECT", color: "sky" },
236
+ { label: "Qualifying", value: "QUALIFYING", color: "sky" },
237
+ { label: "Identify Needs", value: "IDENTIFY_NEEDS", color: "turquoise" },
238
+ { label: "Formulate Offer", value: "FORMULATE_OFFER", color: "turquoise" },
239
+ { label: "Negotiate to Close", value: "NEGOTIATE", color: "yellow" },
240
+ {
241
+ label: "Manage Implementation",
242
+ value: "MANAGE_IMPLEMENTATION",
243
+ color: "orange",
244
+ },
245
+ { label: "Won", value: "WON", color: "green" },
246
+ { label: "Lost", value: "LOST", color: "red" },
247
+ ];
248
+
249
+ /** Live lead.status values observed 2026-07-09 (case-insensitive, prefix
250
+ * digits vary). Unknown statuses fall back to LEAD and are reported. */
251
+ export function mapLeadStatusToStage(status: string | null): {
252
+ stage: string;
253
+ unknown: boolean;
254
+ } {
255
+ const normalized = (status ?? "").toLowerCase().replace(/^\d+-/, "").trim();
256
+ switch (normalized) {
257
+ case "":
258
+ case "new":
259
+ return { stage: "LEAD", unknown: false };
260
+ case "working":
261
+ case "contacted":
262
+ case "nurturing":
263
+ return { stage: "LEAD_WORKING", unknown: false };
264
+ case "qualified":
265
+ return { stage: "LEAD_QUALIFIED", unknown: false };
266
+ case "unqualified":
267
+ return { stage: "LEAD_UNQUALIFIED", unknown: false };
268
+ case "converted":
269
+ // Converted leads became opportunities in LastMile; the lead row keeps
270
+ // its history at the qualified end of the lead band.
271
+ return { stage: "LEAD_QUALIFIED", unknown: false };
272
+ default:
273
+ return { stage: "LEAD", unknown: true };
274
+ }
275
+ }
276
+
277
+ export function mapOpportunityStage(opportunity: {
278
+ stage: string | null;
279
+ closed: string | null;
280
+ won: string | null;
281
+ }): { stage: string; unknown: boolean } {
282
+ const normalized = (opportunity.stage ?? "")
283
+ .toLowerCase()
284
+ .replace(/^\d+-/, "")
285
+ .trim();
286
+ switch (normalized) {
287
+ case "new":
288
+ return { stage: "NEW", unknown: false };
289
+ case "prospect":
290
+ return { stage: "PROSPECT", unknown: false };
291
+ case "qualifying":
292
+ return { stage: "QUALIFYING", unknown: false };
293
+ case "identify account needs":
294
+ return { stage: "IDENTIFY_NEEDS", unknown: false };
295
+ case "formulate offer":
296
+ return { stage: "FORMULATE_OFFER", unknown: false };
297
+ case "negotiate to close":
298
+ case "negotiation to close":
299
+ return { stage: "NEGOTIATE", unknown: false };
300
+ case "manage implementation":
301
+ return { stage: "MANAGE_IMPLEMENTATION", unknown: false };
302
+ case "won":
303
+ return { stage: "WON", unknown: false };
304
+ case "lost":
305
+ return { stage: "LOST", unknown: false };
306
+ case "": {
307
+ // Blank stage: fall back to the closed/won flags.
308
+ if (opportunity.closed === "true" && opportunity.won === "true") {
309
+ return { stage: "WON", unknown: false };
310
+ }
311
+ if (opportunity.closed === "true") {
312
+ return { stage: "LOST", unknown: false };
313
+ }
314
+ return { stage: "NEW", unknown: false };
315
+ }
316
+ default:
317
+ return { stage: "NEW", unknown: true };
318
+ }
319
+ }
320
+
321
+ // --- Record mappers ------------------------------------------------------
322
+
323
+ export interface MappedRecord {
324
+ sourceId: string;
325
+ /** Twenty create/update input, custom fields included, sourceHash excluded. */
326
+ input: Record<string, unknown>;
327
+ warnings: string[];
328
+ }
329
+
330
+ export function mapAccount(
331
+ account: LastmileAccount,
332
+ ownerMap: ReadonlyMap<string, string>,
333
+ ): MappedRecord {
334
+ const warnings: string[] = [];
335
+ const accountOwnerId = resolveOwner(account.ownerRepId, ownerMap);
336
+ if (account.ownerRepId && !accountOwnerId) {
337
+ warnings.push(
338
+ `owner ${account.ownerRepId} not provisioned; company has no accountOwner`,
339
+ );
340
+ }
341
+ const input: Record<string, unknown> = {
342
+ name: account.name ?? `Account ${account.id}`,
343
+ ...(accountOwnerId ? { accountOwnerId } : {}),
344
+ sourceId: sourceId("account", account.id),
345
+ };
346
+ return { sourceId: input.sourceId as string, input, warnings };
347
+ }
348
+
349
+ export function mapContact(
350
+ contact: LastmileContact,
351
+ companyIdBySourceId: ReadonlyMap<string, string>,
352
+ ): MappedRecord {
353
+ const warnings: string[] = [];
354
+ const email = normalizeEmail(contact.email);
355
+ if (contact.email && !email)
356
+ warnings.push(`invalid email ${contact.email} dropped`);
357
+ let companyId: string | null = null;
358
+ if (contact.accountId) {
359
+ companyId =
360
+ companyIdBySourceId.get(sourceId("account", contact.accountId)) ?? null;
361
+ if (!companyId)
362
+ warnings.push(
363
+ `account ${contact.accountId} not found; person has no company`,
364
+ );
365
+ }
366
+ const rawPhone = contact.phone ?? contact.phoneCellular;
367
+ const phone = normalizePhone(rawPhone);
368
+ if (rawPhone && !phone)
369
+ warnings.push(`unparseable phone ${rawPhone} dropped`);
370
+ const input: Record<string, unknown> = {
371
+ name: {
372
+ firstName: contact.firstName ?? "",
373
+ lastName: contact.lastName ?? "",
374
+ },
375
+ ...(email ? { emails: { primaryEmail: email } } : {}),
376
+ ...(phone ? { phones: phone } : {}),
377
+ ...(contact.title ? { jobTitle: contact.title } : {}),
378
+ ...(companyId ? { companyId } : {}),
379
+ sourceId: sourceId("contact", contact.id),
380
+ };
381
+ return { sourceId: input.sourceId as string, input, warnings };
382
+ }
383
+
384
+ /** Mobil-branded products, per line. Brands seen live: MOBIL, MOBIL - CVL,
385
+ * GOLDEN WEST, FUEL, DEF, Hotsy. */
386
+ export function isMobilBrand(brand: string | null): boolean {
387
+ return /mobil/i.test(brand ?? "");
388
+ }
389
+
390
+ export interface MappedOpportunityProduct extends MappedRecord {
391
+ /** sourceId of the owning opportunity, resolved to a Twenty id by the loader. */
392
+ opportunitySourceId: string;
393
+ }
394
+
395
+ /**
396
+ * One product line on an opportunity (R1: multiple products per opportunity).
397
+ * Identity is opportunity + position, so re-runs update lines in place rather
398
+ * than duplicating them.
399
+ */
400
+ export function mapOpportunityProduct(item: {
401
+ opportunityId: string;
402
+ index: number;
403
+ brand: string | null;
404
+ quantity: string | null;
405
+ amount: string | null;
406
+ }): MappedOpportunityProduct {
407
+ const warnings: string[] = [];
408
+ const amountMicros = toAmountMicros(item.amount);
409
+ if (item.amount && amountMicros === null) {
410
+ warnings.push(`unparseable line amount ${item.amount} dropped`);
411
+ }
412
+ const quantity = toQuantity(item.quantity);
413
+ if (item.quantity && quantity === null) {
414
+ warnings.push(`unparseable line quantity ${item.quantity} dropped`);
415
+ }
416
+ const lineSourceId = `${sourceId("opportunity_item", item.opportunityId)}#${item.index}`;
417
+ const input: Record<string, unknown> = {
418
+ name: item.brand ?? `Line ${item.index + 1}`,
419
+ ...(item.brand ? { product: item.brand } : {}),
420
+ ...(quantity !== null ? { quantity } : {}),
421
+ ...(amountMicros !== null
422
+ ? { amount: { amountMicros, currencyCode: "USD" } }
423
+ : {}),
424
+ isMobil: isMobilBrand(item.brand),
425
+ lineNumber: item.index + 1,
426
+ sourceId: lineSourceId,
427
+ };
428
+ return {
429
+ sourceId: lineSourceId,
430
+ input,
431
+ warnings,
432
+ opportunitySourceId: sourceId("opportunity", item.opportunityId),
433
+ };
434
+ }
435
+
436
+ function isMobilProduct(opportunity: {
437
+ productType: string | null;
438
+ brand: string | null;
439
+ }): boolean {
440
+ return /mobil/i.test(
441
+ `${opportunity.productType ?? ""} ${opportunity.brand ?? ""}`,
442
+ );
443
+ }
444
+
445
+ export function mapOpportunity(
446
+ opportunity: LastmileOpportunity,
447
+ ownerMap: ReadonlyMap<string, string>,
448
+ companyIdBySourceId: ReadonlyMap<string, string>,
449
+ ): MappedRecord {
450
+ const warnings: string[] = [];
451
+ const { stage, unknown } = mapOpportunityStage(opportunity);
452
+ if (unknown)
453
+ warnings.push(`unknown stage "${opportunity.stage}" mapped to NEW`);
454
+ const ownerId = resolveOwner(opportunity.ownerRepId, ownerMap);
455
+ if (opportunity.ownerRepId && !ownerId) {
456
+ warnings.push(
457
+ `owner ${opportunity.ownerRepId} not provisioned; opportunity has no owner`,
458
+ );
459
+ }
460
+ let companyId: string | null = null;
461
+ if (opportunity.accountId) {
462
+ companyId =
463
+ companyIdBySourceId.get(sourceId("account", opportunity.accountId)) ??
464
+ null;
465
+ if (!companyId) {
466
+ warnings.push(
467
+ `account ${opportunity.accountId} not found; opportunity has no company`,
468
+ );
469
+ }
470
+ }
471
+ const amountMicros = toAmountMicros(opportunity.amount);
472
+ const input: Record<string, unknown> = {
473
+ name: opportunity.name ?? `Opportunity ${opportunity.id}`,
474
+ stage,
475
+ ...(amountMicros !== null
476
+ ? { amount: { amountMicros, currencyCode: "USD" } }
477
+ : {}),
478
+ ...(opportunity.expectedCloseDate
479
+ ? { closeDate: `${opportunity.expectedCloseDate}T00:00:00.000Z` }
480
+ : {}),
481
+ ...(ownerId ? { ownerId } : {}),
482
+ ...(companyId ? { companyId } : {}),
483
+ ...(opportunity.productType ? { product: opportunity.productType } : {}),
484
+ ...(toQuantity(opportunity.quantity) !== null
485
+ ? { quantity: toQuantity(opportunity.quantity) }
486
+ : {}),
487
+ isMobil: isMobilProduct(opportunity),
488
+ sourceId: sourceId("opportunity", opportunity.id),
489
+ };
490
+ return { sourceId: input.sourceId as string, input, warnings };
491
+ }
492
+
493
+ /** Leads land in the same Twenty opportunity pipeline at the lead-band stages
494
+ * (AE3): never as bare person/company records. */
495
+ export function mapLead(
496
+ lead: LastmileLead,
497
+ ownerMap: ReadonlyMap<string, string>,
498
+ ): MappedRecord {
499
+ const warnings: string[] = [];
500
+ const { stage, unknown } = mapLeadStatusToStage(lead.status);
501
+ if (unknown)
502
+ warnings.push(`unknown lead status "${lead.status}" mapped to LEAD`);
503
+ const ownerId = resolveOwner(lead.ownerRepId, ownerMap);
504
+ if (lead.ownerRepId && !ownerId) {
505
+ warnings.push(
506
+ `owner ${lead.ownerRepId} not provisioned; lead has no owner`,
507
+ );
508
+ }
509
+ const personName = [lead.firstName, lead.lastName].filter(Boolean).join(" ");
510
+ const name = lead.companyName ?? (personName || `Lead ${lead.id}`);
511
+ const input: Record<string, unknown> = {
512
+ name,
513
+ stage,
514
+ ...(ownerId ? { ownerId } : {}),
515
+ isMobil: false,
516
+ sourceId: sourceId("lead", lead.id),
517
+ };
518
+ return { sourceId: input.sourceId as string, input, warnings };
519
+ }
520
+
521
+ export interface MappedNote {
522
+ sourceId: string;
523
+ title: string;
524
+ bodyMarkdown: string;
525
+ /** Twenty sourceId of the record the note targets. */
526
+ targetSourceId: string;
527
+ targetKind: "opportunity" | "company";
528
+ isDeleted: boolean;
529
+ }
530
+
531
+ export function mapCrmComment(comment: LastmileCrmComment): MappedNote {
532
+ const text = comment.content ?? "";
533
+ const firstLine = text.split("\n", 1)[0].trim();
534
+ return {
535
+ sourceId: sourceId("task_comment", comment.id),
536
+ title: firstLine.slice(0, 60) || "LastMile note",
537
+ bodyMarkdown: text,
538
+ targetSourceId: sourceId(comment.entityType, comment.entityId),
539
+ targetKind: "opportunity",
540
+ isDeleted: comment.isDeleted,
541
+ };
542
+ }
543
+
544
+ /** Customer notes reach Twenty only when their dispatch customer name matches
545
+ * exactly one CRM account (plan resolution); the caller reports the rest as
546
+ * gaps. */
547
+ export function mapCustomerNote(note: LastmileCustomerNote): MappedNote | null {
548
+ if (!note.matchedAccountId) return null;
549
+ const text = note.noteText ?? "";
550
+ const firstLine = text.split("\n", 1)[0].trim();
551
+ return {
552
+ sourceId: sourceId("note", note.id),
553
+ title: firstLine.slice(0, 60) || "LastMile customer note",
554
+ bodyMarkdown: text,
555
+ targetSourceId: sourceId("account", note.matchedAccountId),
556
+ targetKind: "company",
557
+ isDeleted: false,
558
+ };
559
+ }