thinkwork-cli 0.13.1 → 1.0.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 (262) hide show
  1. package/README.md +3 -14
  2. package/dist/{api-client-4DZ266J5.js → api-client-4P4JZOBX.js} +3 -1
  3. package/dist/bowser-6Z6JFAIR.js +2822 -0
  4. package/dist/{chunk-Y2WHJECR.js → chunk-4OMDWYZQ.js} +5 -44
  5. package/dist/chunk-AD2NIWB5.js +6516 -0
  6. package/dist/chunk-DV3MRBR5.js +43 -0
  7. package/dist/chunk-EI5ZX7CN.js +522 -0
  8. package/dist/chunk-EUJ7X6YD.js +126 -0
  9. package/dist/chunk-GG5WKBOX.js +436 -0
  10. package/dist/chunk-I64EZSU5.js +358 -0
  11. package/dist/chunk-KMIJ5KGT.js +128 -0
  12. package/dist/chunk-MWQ7RO76.js +137 -0
  13. package/dist/chunk-OCPHU34C.js +996 -0
  14. package/dist/chunk-OLUQ4U2Z.js +1728 -0
  15. package/dist/chunk-XYM7PE2A.js +2264 -0
  16. package/dist/chunk-YQCYD6NE.js +3299 -0
  17. package/dist/cli.js +49156 -31257
  18. package/dist/commands/enterprise/templates/deploy-repo/scripts/apply-release.mjs +0 -1
  19. package/dist/commands/enterprise/templates/deploy-repo/scripts/smoke.mjs +0 -1
  20. package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +0 -14
  21. package/dist/dist-es-2OTNTL4N.js +315 -0
  22. package/dist/dist-es-2XVAH4BQ.js +14726 -0
  23. package/dist/dist-es-3DPDUJAH.js +2000 -0
  24. package/dist/dist-es-BNZTBBVC.js +176 -0
  25. package/dist/dist-es-CD2RBDAC.js +88 -0
  26. package/dist/dist-es-I4FW57PS.js +88 -0
  27. package/dist/dist-es-KL7Y5NB2.js +377 -0
  28. package/dist/dist-es-KVFWJNDY.js +318 -0
  29. package/dist/dist-es-PS73DWNL.js +70 -0
  30. package/dist/dist-es-S3NZQFGT.js +170 -0
  31. package/dist/dist-es-SGCA4P32.js +48 -0
  32. package/dist/dist-es-WBM6J4RA.js +70 -0
  33. package/dist/dist-es-ZGVADYIF.js +485 -0
  34. package/dist/dist-es-ZKC2QKD2.js +377 -0
  35. package/dist/drizzle/0076_scheduled_jobs_marco_backfill.sql +2 -1
  36. package/dist/drizzle/0079_seed_tenant_customize_catalog.sql +1 -0
  37. package/dist/drizzle/0090_brain_schema_extraction.sql +10 -0
  38. package/dist/drizzle/0132_drop_computer_tables.sql +3 -0
  39. package/dist/drizzle/0160_compliance_event_types_plugins.sql +2 -0
  40. package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +1 -0
  41. package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +1 -0
  42. package/dist/drizzle/0227_analyst_reader_role.sql +17 -20
  43. package/dist/drizzle/0230_analyst_rls.sql +28 -36
  44. package/dist/drizzle/0250_kg_schema_extraction.sql +27 -0
  45. package/dist/drizzle/0251_brain_stragglers.sql +21 -0
  46. package/dist/drizzle/0253_drop_brain_substrate.sql +2 -0
  47. package/dist/drizzle/0257_agents_runtime_allow_harness.sql +24 -0
  48. package/dist/drizzle/0261_native_auth_control_plane.sql +332 -0
  49. package/dist/drizzle/0262_auth_subscription_tickets.sql +59 -0
  50. package/dist/drizzle/0263_agentcore_runtime_identifier.sql +69 -0
  51. package/dist/drizzle/0263_drop_workos_auth_runtime.sql +236 -0
  52. package/dist/drizzle/0264_auth_enrollment_attempt_limits.sql +15 -0
  53. package/dist/drizzle/0265_eval_runs_requester_identity.sql +28 -0
  54. package/dist/drizzle/0265_require_fully_valid_auth_publication.sql +27 -0
  55. package/dist/drizzle/0266_auth_identity_recovery_grants.sql +9 -0
  56. package/dist/drizzle/0266_eval_profiles_runtime_type.sql +12 -0
  57. package/dist/drizzle/0267_ontology_candidate_rejections.sql +53 -0
  58. package/dist/drizzle/0268_identity_crosswalk_routing.sql +182 -0
  59. package/dist/drizzle/0269_ontology_system_map.sql +31 -0
  60. package/dist/drizzle/0270_tool_execution_ledger.sql +115 -0
  61. package/dist/drizzle/0271_ontology_twin_declarations.sql +47 -0
  62. package/dist/drizzle/0272_identity_graph_projection_cursor.sql +23 -0
  63. package/dist/drizzle/0273_twin_materialization_suggestions.sql +29 -0
  64. package/dist/drizzle/0274_tenant_mcp_twin_keys.sql +59 -0
  65. package/dist/drizzle/0275_identity_bulk_rebuild_fence.sql +30 -0
  66. package/dist/drizzle/0276_tool_execution_ledger_allow_cascade_delete.sql +18 -0
  67. package/dist/drizzle/0277_knowledge_base_sources.sql +107 -0
  68. package/dist/drizzle/0278_kb_page_transcription.sql +55 -0
  69. package/dist/drizzle/0279_mcp_servers_off_plugin_provenance.sql +93 -0
  70. package/dist/drizzle/0280_work_item_external_refs_drop_plane_provider.sql +46 -0
  71. package/dist/drizzle/0281_tenant_mcp_twin_keys_expiry_suffix.sql +9 -0
  72. package/dist/drizzle/0282_tenant_mcp_twin_keys_grants.sql +24 -0
  73. package/dist/drizzle/0283_agents_agentcore_runtime_dispatch.sql +13 -0
  74. package/dist/drizzle/0284_user_brain_claims.sql +69 -0
  75. package/dist/drizzle/0285_tenant_mcp_twin_keys_trusted_subsystem.sql +30 -0
  76. package/dist/drizzle/0286_brain_analytics_key.sql +26 -0
  77. package/dist/drizzle/0287_brain_subject.sql +25 -0
  78. package/dist/drizzle/0288_multi_lane_identities.sql +25 -0
  79. package/dist/event-streams-7JNLJEEL.js +894 -0
  80. package/dist/event-streams-KDF5RWR6.js +1515 -0
  81. package/dist/loadSso-ETCXTR4I.js +576 -0
  82. package/dist/loadSso-UBD7KUHE.js +583 -0
  83. package/dist/plugins/README.md +2 -2
  84. package/dist/plugins/catalog/package.json +1 -5
  85. package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +6 -4
  86. package/dist/plugins/catalog/src/__tests__/catalog.test.ts +21 -21
  87. package/dist/plugins/catalog/src/__tests__/contracts.test.ts +102 -94
  88. package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +7 -8
  89. package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -20
  90. package/dist/plugins/catalog/src/contracts.ts +17 -179
  91. package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -24
  92. package/dist/plugins/n8n/test/integrated-app-contract.test.ts +1 -1
  93. package/dist/plugins/twenty/package.json +2 -1
  94. package/dist/plugins/twenty/scripts/LAMBDA_DEPLOY.md +114 -0
  95. package/dist/plugins/twenty/scripts/build-lambda.sh +29 -0
  96. package/dist/plugins/twenty/scripts/configure-product-views.ts +195 -0
  97. package/dist/plugins/twenty/scripts/lambda-handler.ts +49 -0
  98. package/dist/plugins/twenty/scripts/lib/__tests__/load-attachments.test.ts +38 -0
  99. package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +145 -2
  100. package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +397 -151
  101. package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +28 -129
  102. package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +10 -0
  103. package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +13 -8
  104. package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +222 -2
  105. package/dist/plugins/twenty/scripts/lib/load-attachments.ts +29 -0
  106. package/dist/plugins/twenty/scripts/lib/load-records.ts +160 -43
  107. package/dist/plugins/twenty/scripts/lib/mappers.ts +368 -152
  108. package/dist/plugins/twenty/scripts/lib/members-ensure.ts +43 -268
  109. package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +57 -0
  110. package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +136 -31
  111. package/dist/plugins/twenty/scripts/migrate-lastmile.ts +254 -152
  112. package/dist/plugins/twenty/scripts/provision-twenty-members.ts +44 -1
  113. package/dist/plugins/twenty/scripts/purge-lastmile-import.ts +178 -0
  114. package/dist/plugins/twenty/scripts/restructure-products.ts +312 -0
  115. package/dist/plugins/twenty/scripts/retag-stage-values.ts +362 -0
  116. package/dist/plugins/twenty/test/manifest.test.ts +2 -4
  117. package/dist/scripts/post-deploy.sh +79 -1
  118. package/dist/signin-5QZNRSOO.js +821 -0
  119. package/dist/sso-oidc-XK673P54.js +837 -0
  120. package/dist/sts-OXI2UALF.js +1113 -0
  121. package/dist/sts-SQW4NBK3.js +1117 -0
  122. package/dist/terraform/examples/greenfield/main.tf +391 -176
  123. package/dist/terraform/examples/greenfield/terraform.tfvars.example +26 -8
  124. package/dist/terraform/modules/app/agentcore-admin/main.tf +47 -0
  125. package/dist/terraform/modules/app/agentcore-code-interpreter/README.md +39 -47
  126. package/dist/terraform/modules/app/agentcore-code-interpreter/main.tf +13 -66
  127. package/dist/terraform/modules/app/agentcore-identity/main.tf +253 -0
  128. package/dist/terraform/modules/app/agentcore-identity/outputs.tf +51 -0
  129. package/dist/terraform/modules/app/agentcore-identity/scripts/bootstrap_twenty_oauth_client.sh +239 -0
  130. package/dist/terraform/modules/app/agentcore-identity/scripts/delete_identity.sh +22 -0
  131. package/dist/terraform/modules/app/agentcore-identity/scripts/delete_twenty_identity.sh +16 -0
  132. package/dist/terraform/modules/app/agentcore-identity/scripts/delete_workload_identity.sh +19 -0
  133. package/dist/terraform/modules/app/agentcore-identity/scripts/ensure_workload_identity.sh +29 -0
  134. package/dist/terraform/modules/app/agentcore-identity/scripts/read_identity.sh +55 -0
  135. package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_identity.sh +73 -0
  136. package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_identity.sh +45 -0
  137. package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_provider.mjs +123 -0
  138. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/after.tf +15 -0
  139. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/before.tf +7 -0
  140. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/identity-after.tf +8 -0
  141. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/identity-before.tf +9 -0
  142. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/thinkwork-after.tf +3 -0
  143. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/thinkwork-before.tf +3 -0
  144. package/dist/terraform/modules/app/agentcore-identity/variables.tf +60 -0
  145. package/dist/terraform/modules/app/agentcore-memory/README.md +46 -9
  146. package/dist/terraform/modules/app/agentcore-memory/main.tf +27 -10
  147. package/dist/terraform/modules/app/agentcore-memory/scripts/create_or_find_memory.sh +156 -24
  148. package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
  149. package/dist/terraform/modules/app/agentcore-pi/main.tf +42 -52
  150. package/dist/terraform/modules/app/agentcore-pi/outputs.tf +3 -3
  151. package/dist/terraform/modules/app/agentcore-pi/scripts/reconcile_pi_runtime.mjs +175 -0
  152. package/dist/terraform/modules/app/agentcore-pi/variables.tf +35 -28
  153. package/dist/terraform/modules/app/agentcore-platform/main.tf +68 -0
  154. package/dist/terraform/modules/app/agentcore-runtime/main.tf +4 -20
  155. package/dist/terraform/modules/app/appsync-subscriptions/main.tf +127 -21
  156. package/dist/terraform/modules/app/appsync-subscriptions/outputs.tf +0 -6
  157. package/dist/terraform/modules/app/appsync-subscriptions/variables.tf +5 -0
  158. package/dist/terraform/modules/app/capability-broker/main.tf +2 -2
  159. package/dist/terraform/modules/app/capability-broker/variables.tf +11 -0
  160. package/dist/terraform/modules/app/deployment-control-plane/README.md +264 -0
  161. package/dist/terraform/modules/app/deployment-control-plane/buildspec.yml +4 -1
  162. package/dist/terraform/modules/app/deployment-control-plane/main.tf +94 -0
  163. package/dist/terraform/modules/app/deployment-control-plane/outputs.tf +5 -0
  164. package/dist/terraform/modules/app/deployment-control-plane/runner.py +3689 -233
  165. package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +2579 -140
  166. package/dist/terraform/modules/app/deployment-control-plane/variables.tf +33 -0
  167. package/dist/terraform/modules/app/lambda-api/auth-state.tf +72 -0
  168. package/dist/terraform/modules/app/lambda-api/chat-latency-observability.tf +466 -0
  169. package/dist/terraform/modules/app/lambda-api/dispatch-dlq.tf +55 -0
  170. package/dist/terraform/modules/app/lambda-api/handlers.tf +531 -619
  171. package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +350 -241
  172. package/dist/terraform/modules/app/lambda-api/main.tf +75 -13
  173. package/dist/terraform/modules/app/lambda-api/memory-alarms.tf +3 -28
  174. package/dist/terraform/modules/app/lambda-api/oauth-secrets.tf +1 -2
  175. package/dist/terraform/modules/app/lambda-api/outputs.tf +17 -10
  176. package/dist/terraform/modules/app/lambda-api/runtime-config.tf +3 -21
  177. package/dist/terraform/modules/app/lambda-api/turn-assertion.tf +45 -0
  178. package/dist/terraform/modules/app/lambda-api/variables.tf +307 -121
  179. package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/README.md +1 -1
  180. package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/main.tf +18 -18
  181. package/dist/terraform/modules/app/sandbox-log-scrubber/README.md +11 -10
  182. package/dist/terraform/modules/app/sandbox-log-scrubber/main.tf +5 -5
  183. package/dist/terraform/modules/app/static-site/main.tf +1 -1
  184. package/dist/terraform/modules/app/www-dns/main.tf +9 -27
  185. package/dist/terraform/modules/app/www-dns/variables.tf +4 -10
  186. package/dist/terraform/modules/data/aurora-postgres/README.md +34 -0
  187. package/dist/terraform/modules/data/aurora-postgres/alarms.tf +82 -0
  188. package/dist/terraform/modules/data/aurora-postgres/main.tf +27 -57
  189. package/dist/terraform/modules/data/aurora-postgres/outputs.tf +0 -9
  190. package/dist/terraform/modules/data/aurora-postgres/variables.tf +41 -0
  191. package/dist/terraform/modules/data/s3-buckets/main.tf +5 -6
  192. package/dist/terraform/modules/foundation/cognito/main.tf +367 -72
  193. package/dist/terraform/modules/foundation/cognito/outputs.tf +47 -0
  194. package/dist/terraform/modules/foundation/cognito/variables.tf +131 -9
  195. package/dist/terraform/modules/thinkwork/README.md +5 -1
  196. package/dist/terraform/modules/thinkwork/main.tf +459 -234
  197. package/dist/terraform/modules/thinkwork/outputs.tf +44 -62
  198. package/dist/terraform/modules/thinkwork/variables.tf +353 -89
  199. package/dist/terraform/schema.graphql +69 -36
  200. package/dist/workspace-defaults/files/INSTRUCTIONS.md +9 -11
  201. package/dist/workspace-defaults/files/MEMORY_GUIDE.md +38 -35
  202. package/dist/workspace-defaults/files/ROUTER.md +1 -1
  203. package/dist/workspace-defaults/files/TOOLS.md +9 -39
  204. package/dist/workspace-defaults/files/USER.md +1 -0
  205. package/dist/workspace-defaults/files/agents/analyst/INSTRUCTIONS.md +6 -3
  206. package/dist/workspace-defaults/files/catalog-skills/brain-brief-builder/SKILL.md +165 -0
  207. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/SKILL.md +75 -0
  208. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/mcp-tooling.md +40 -0
  209. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/validation-and-handoff.md +40 -0
  210. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/workflow-authoring.md +46 -0
  211. package/dist/workspace-defaults/files/skills/artifact-builder/references/crm-dashboard.md +1 -1
  212. package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +22 -3
  213. package/package.json +3 -2
  214. package/dist/drizzle/0261_thread_turns_origin_turn_index.sql +0 -11
  215. package/dist/plugins/company-data/README.md +0 -49
  216. package/dist/plugins/company-data/package.json +0 -21
  217. package/dist/plugins/company-data/src/index.ts +0 -30
  218. package/dist/plugins/company-data/src/manifest.ts +0 -26
  219. package/dist/plugins/company-data/test/manifest.test.ts +0 -116
  220. package/dist/plugins/company-data/tsconfig.json +0 -8
  221. package/dist/plugins/company-etl/README.md +0 -46
  222. package/dist/plugins/company-etl/package.json +0 -21
  223. package/dist/plugins/company-etl/src/index.ts +0 -29
  224. package/dist/plugins/company-etl/src/manifest.ts +0 -26
  225. package/dist/plugins/company-etl/test/manifest.test.ts +0 -108
  226. package/dist/plugins/company-etl/tsconfig.json +0 -8
  227. package/dist/plugins/lastmile/README.md +0 -50
  228. package/dist/plugins/lastmile/package.json +0 -23
  229. package/dist/plugins/lastmile/smoke/lastmile-plugin-smoke.mjs +0 -661
  230. package/dist/plugins/lastmile/src/api/tasks-adapter.ts +0 -572
  231. package/dist/plugins/lastmile/src/discovery.fixture.ts +0 -64
  232. package/dist/plugins/lastmile/src/index.ts +0 -46
  233. package/dist/plugins/lastmile/src/manifest.ts +0 -139
  234. package/dist/plugins/lastmile/test/api/tasks-adapter.test.ts +0 -266
  235. package/dist/plugins/lastmile/test/discovery.test.ts +0 -88
  236. package/dist/plugins/lastmile/tsconfig.json +0 -8
  237. package/dist/plugins/n8n/smoke/n8n-managed-app-smoke.mjs +0 -409
  238. package/dist/plugins/twenty/smoke/twenty-managed-app-smoke.mjs +0 -359
  239. package/dist/plugins/workos-auth/package.json +0 -22
  240. package/dist/plugins/workos-auth/src/index.ts +0 -30
  241. package/dist/plugins/workos-auth/src/manifest.ts +0 -40
  242. package/dist/plugins/workos-auth/src/provider-contract.ts +0 -39
  243. package/dist/plugins/workos-auth/test/manifest.test.ts +0 -94
  244. package/dist/plugins/workos-auth/tsconfig.json +0 -8
  245. package/dist/terraform/modules/app/agentcore-code-interpreter/Dockerfile.sandbox-base +0 -61
  246. package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/sitecustomize.py +0 -227
  247. package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/test_sitecustomize.py +0 -277
  248. package/dist/terraform/modules/app/agentcore-code-interpreter/scripts/build_and_push_sandbox_base.sh +0 -70
  249. package/dist/terraform/modules/app/agentcore-harness/main.tf +0 -101
  250. package/dist/terraform/modules/app/agentcore-harness/outputs.tf +0 -9
  251. package/dist/terraform/modules/app/agentcore-harness/variables.tf +0 -25
  252. package/dist/terraform/modules/app/hindsight-memory/README.md +0 -89
  253. package/dist/terraform/modules/app/hindsight-memory/main.tf +0 -411
  254. package/dist/terraform/modules/data/bedrock-knowledge-base/main.tf +0 -102
  255. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/outputs.tf +0 -0
  256. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/scripts/sync-database.py +0 -0
  257. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/scripts/test_sync_database.py +0 -0
  258. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/variables.tf +0 -0
  259. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/README.md +0 -0
  260. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/main.tf +0 -0
  261. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/outputs.tf +0 -0
  262. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/variables.tf +0 -0
@@ -11,9 +11,10 @@ import type {
11
11
  LastmileAccount,
12
12
  LastmileContact,
13
13
  LastmileCrmComment,
14
+ LastmileCrmTask,
14
15
  LastmileCustomerNote,
15
- LastmileLead,
16
- LastmileOpportunity,
16
+ LastmileOrganization,
17
+ LastmileTaskStatusChange,
17
18
  } from "./lastmile-reader";
18
19
 
19
20
  /**
@@ -219,103 +220,93 @@ export function toQuantity(raw: string | null | undefined): number | null {
219
220
 
220
221
  // --- Stage mapping -------------------------------------------------------
221
222
 
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). */
223
+ /**
224
+ * Pipeline stages are LastMile's own status names, verbatim (from the `status`
225
+ * table, via `task.status_id`). Opportunity statuses and lead statuses share
226
+ * one Twenty pipeline because leads become early-stage opportunities.
227
+ *
228
+ * Existing TEI options (NEW/SCREENING/MEETING/PROPOSAL/CUSTOMER) are preserved
229
+ * by the merge — the ThinkWork workflow triggers on CUSTOMER — but nothing new
230
+ * maps onto them.
231
+ */
226
232
  export const MIGRATION_STAGE_OPTIONS: Array<{
227
233
  label: string;
228
234
  value: string;
229
235
  color: string;
230
236
  }> = [
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" },
237
+ // Lead band
238
+ { label: "00-New", value: "NEW", color: "gray" },
239
+ { label: "10-Working", value: "WORKING", color: "gray" },
240
+ { label: "20-Contacted", value: "CONTACTED", color: "gray" },
241
+ { label: "30-Nurturing", value: "NURTURING", color: "blue" },
242
+ { label: "50-Qualified", value: "QUALIFIED", color: "blue" },
243
+ { label: "90-Unqualified", value: "UNQUALIFIED", color: "gray" },
244
+ { label: "Converted", value: "CONVERTED", color: "turquoise" },
245
+ // Opportunity band
246
+ { label: "10-Prospect", value: "PROSPECT", color: "sky" },
247
+ { label: "20-Account Needs", value: "ACCOUNT_NEEDS", color: "sky" },
248
+ { label: "30-Formulate Offer", value: "FORMULATE_OFFER", color: "turquoise" },
249
+ { label: "40-Negotiation", value: "NEGOTIATION", color: "yellow" },
250
+ { label: "50-Implementation", value: "IMPLEMENTATION", color: "orange" },
251
+ { label: "60-Won", value: "WON", color: "green" },
252
+ { label: "90-Lost", value: "LOST", color: "red" },
247
253
  ];
248
254
 
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): {
255
+ /** Retag map for the one-off `retag-stage-values.ts` migration: the original
256
+ * import wrote LM_-prefixed values, which surface raw in the API, filters, and
257
+ * reports. Labels never changed. */
258
+ export const LEGACY_STAGE_VALUE_MAP: Readonly<Record<string, string>> = {
259
+ LM_00_NEW: "NEW",
260
+ LM_10_WORKING: "WORKING",
261
+ LM_20_CONTACTED: "CONTACTED",
262
+ LM_30_NURTURING: "NURTURING",
263
+ LM_50_QUALIFIED: "QUALIFIED",
264
+ LM_90_UNQUALIFIED: "UNQUALIFIED",
265
+ LM_CONVERTED: "CONVERTED",
266
+ LM_10_PROSPECT: "PROSPECT",
267
+ LM_20_ACCOUNT_NEEDS: "ACCOUNT_NEEDS",
268
+ LM_30_FORMULATE_OFFER: "FORMULATE_OFFER",
269
+ LM_40_NEGOTIATION: "NEGOTIATION",
270
+ LM_50_IMPLEMENTATION: "IMPLEMENTATION",
271
+ LM_60_WON: "WON",
272
+ LM_90_LOST: "LOST",
273
+ };
274
+
275
+ /** Options from the abandoned first model, plus TEI's unused defaults, that no
276
+ * record references. Removed by the retag so the picker is not a graveyard.
277
+ * CUSTOMER is deliberately kept — the ThinkWork workflow triggers on it. */
278
+ export const OBSOLETE_STAGE_VALUES: readonly string[] = [
279
+ "LEAD",
280
+ "LEAD_WORKING",
281
+ "LEAD_QUALIFIED",
282
+ "LEAD_UNQUALIFIED",
283
+ "QUALIFYING",
284
+ "IDENTIFY_NEEDS",
285
+ "NEGOTIATE",
286
+ "MANAGE_IMPLEMENTATION",
287
+ ];
288
+
289
+ const STAGE_VALUE_BY_STATUS_NAME = new Map(
290
+ MIGRATION_STAGE_OPTIONS.map((option) => [
291
+ option.label.toLowerCase(),
292
+ option.value,
293
+ ]),
294
+ );
295
+
296
+ /**
297
+ * `task.status_id` -> status name -> stage value. Lead and opportunity status
298
+ * names collide only on "00-New", which both bands share intentionally.
299
+ * An unmapped status is reported rather than silently bucketed.
300
+ */
301
+ export function mapTaskStatusToStage(statusName: string | null): {
252
302
  stage: string;
253
303
  unknown: boolean;
254
304
  } {
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
- }
305
+ const value = STAGE_VALUE_BY_STATUS_NAME.get(
306
+ (statusName ?? "").toLowerCase().trim(),
307
+ );
308
+ if (value) return { stage: value, unknown: false };
309
+ return { stage: "NEW", unknown: true };
319
310
  }
320
311
 
321
312
  // --- Record mappers ------------------------------------------------------
@@ -327,6 +318,37 @@ export interface MappedRecord {
327
318
  warnings: string[];
328
319
  }
329
320
 
321
+ /**
322
+ * A dispatch customer becomes a company exactly like an account does, keyed
323
+ * `customer:<id>` — the id spaces never collide, and the caller drops
324
+ * customers whose name already has an account-sourced company (unique-name
325
+ * crosswalk) before mapping.
326
+ */
327
+ export function mapDispatchCustomer(
328
+ customer: {
329
+ id: string;
330
+ name: string | null;
331
+ p21Code?: string | null;
332
+ ownerRepId: string | null;
333
+ },
334
+ ownerMap: ReadonlyMap<string, string>,
335
+ ): MappedRecord {
336
+ const warnings: string[] = [];
337
+ const accountOwnerId = resolveOwner(customer.ownerRepId, ownerMap);
338
+ if (customer.ownerRepId && !accountOwnerId) {
339
+ warnings.push(
340
+ `owner ${customer.ownerRepId} not provisioned; company has no accountOwner`,
341
+ );
342
+ }
343
+ const input: Record<string, unknown> = {
344
+ name: customer.name ?? `Customer ${customer.id}`,
345
+ ...(accountOwnerId ? { accountOwnerId } : {}),
346
+ ...(customer.p21Code ? { p21Code: customer.p21Code } : {}),
347
+ sourceId: sourceId("customer", customer.id),
348
+ };
349
+ return { sourceId: input.sourceId as string, input, warnings };
350
+ }
351
+
330
352
  export function mapAccount(
331
353
  account: LastmileAccount,
332
354
  ownerMap: ReadonlyMap<string, string>,
@@ -346,6 +368,28 @@ export function mapAccount(
346
368
  return { sourceId: input.sourceId as string, input, warnings };
347
369
  }
348
370
 
371
+ /**
372
+ * Twenty enforces a unique primary email on Person, but LastMile does not:
373
+ * among the scoped contacts, 17 addresses are shared by 2-8 people
374
+ * ("test@test.com" x8, a rep's own address x8). The first contact by id keeps
375
+ * the address; the rest migrate without one, so no person is lost to a
376
+ * duplicate-key error. The caller reports each dropped address.
377
+ */
378
+ export function dedupeContactEmails<
379
+ T extends { id: string; email: string | null },
380
+ >(contacts: readonly T[]): T[] {
381
+ const claimed = new Set<string>();
382
+ return [...contacts]
383
+ .sort((left, right) => (left.id < right.id ? -1 : 1))
384
+ .map((contact) => {
385
+ const email = normalizeEmail(contact.email);
386
+ if (!email) return contact;
387
+ if (claimed.has(email)) return { ...contact, email: null };
388
+ claimed.add(email);
389
+ return contact;
390
+ });
391
+ }
392
+
349
393
  export function mapContact(
350
394
  contact: LastmileContact,
351
395
  companyIdBySourceId: ReadonlyMap<string, string>,
@@ -381,15 +425,80 @@ export function mapContact(
381
425
  return { sourceId: input.sourceId as string, input, warnings };
382
426
  }
383
427
 
384
- /** Mobil-branded products, per line. Brands seen live: MOBIL, MOBIL - CVL,
385
- * GOLDEN WEST, FUEL, DEF, Hotsy. */
428
+ /**
429
+ * TEI's seven product lines, exactly as LastMile's "Product Line" picker shows
430
+ * them. The `items[].brand` free-text field holds 19 variants of these seven
431
+ * (MOBIL, Mobil, "MOBIL - CVL", "GWO - PVL", ...), which is why products became
432
+ * a catalog object rather than a text field.
433
+ */
434
+ export const PRODUCT_CATALOG: readonly string[] = [
435
+ "Ancillary",
436
+ "DEF",
437
+ "Fuel",
438
+ "Golden West",
439
+ "Hotsy",
440
+ "Mighty",
441
+ "Mobil",
442
+ ];
443
+
444
+ /**
445
+ * Collapse a LastMile brand string onto a catalog product. Sub-line suffixes
446
+ * (CVL, PVL, INDUSTRIAL) denote the channel, not the product, so they fold into
447
+ * the parent. Returns null for "UNKNOWN" and blanks (173 lines): the line still
448
+ * migrates with its quantity and amount, but carries no product, and every one
449
+ * is listed in the report rather than silently bucketed.
450
+ */
451
+ export function normalizeProductName(brand: string | null): string | null {
452
+ const raw = (brand ?? "").trim();
453
+ if (!raw) return null;
454
+ const base = raw.split("-")[0].trim().toLowerCase();
455
+ switch (base) {
456
+ case "mobil":
457
+ return "Mobil";
458
+ case "golden west":
459
+ case "gwo":
460
+ return "Golden West";
461
+ case "fuel":
462
+ return "Fuel";
463
+ case "def":
464
+ return "DEF";
465
+ case "mighty":
466
+ return "Mighty";
467
+ case "ancillary":
468
+ return "Ancillary";
469
+ case "hotsy":
470
+ return "Hotsy";
471
+ default:
472
+ return null;
473
+ }
474
+ }
475
+
476
+ /** Mobil-branded products, per line — driven by the catalog name, not the raw
477
+ * brand string, so "MOBIL - CVL" and "Mobil" agree. */
386
478
  export function isMobilBrand(brand: string | null): boolean {
387
- return /mobil/i.test(brand ?? "");
479
+ return normalizeProductName(brand) === "Mobil";
480
+ }
481
+
482
+ /** A catalog product record. Name is the identity; the sourceId keeps re-runs
483
+ * idempotent. */
484
+ export function mapProduct(name: string): MappedRecord {
485
+ const key = name.toLowerCase().replace(/[^a-z0-9]+/g, "_");
486
+ return {
487
+ sourceId: sourceId("product", key),
488
+ input: { name, sourceId: sourceId("product", key) },
489
+ warnings: [],
490
+ };
491
+ }
492
+
493
+ export function productSourceId(name: string): string {
494
+ return sourceId("product", name.toLowerCase().replace(/[^a-z0-9]+/g, "_"));
388
495
  }
389
496
 
390
497
  export interface MappedOpportunityProduct extends MappedRecord {
391
498
  /** sourceId of the owning opportunity, resolved to a Twenty id by the loader. */
392
499
  opportunitySourceId: string;
500
+ /** sourceId of the catalog product, or null when the brand did not map. */
501
+ productSourceId: string | null;
393
502
  }
394
503
 
395
504
  /**
@@ -413,15 +522,23 @@ export function mapOpportunityProduct(item: {
413
522
  if (item.quantity && quantity === null) {
414
523
  warnings.push(`unparseable line quantity ${item.quantity} dropped`);
415
524
  }
525
+ const productName = normalizeProductName(item.brand);
526
+ if (!productName) {
527
+ warnings.push(
528
+ `line ${item.index + 1}: brand ${JSON.stringify(item.brand ?? "")} does not map to a product`,
529
+ );
530
+ }
531
+
416
532
  const lineSourceId = `${sourceId("opportunity_item", item.opportunityId)}#${item.index}`;
417
533
  const input: Record<string, unknown> = {
418
- name: item.brand ?? `Line ${item.index + 1}`,
419
- ...(item.brand ? { product: item.brand } : {}),
534
+ // The chip in Twenty's UI reads the name, so an unmapped line says "Line 2",
535
+ // never "Untitled".
536
+ name: productName ?? `Line ${item.index + 1}`,
420
537
  ...(quantity !== null ? { quantity } : {}),
421
538
  ...(amountMicros !== null
422
539
  ? { amount: { amountMicros, currencyCode: "USD" } }
423
540
  : {}),
424
- isMobil: isMobilBrand(item.brand),
541
+ isMobil: productName === "Mobil",
425
542
  lineNumber: item.index + 1,
426
543
  sourceId: lineSourceId,
427
544
  };
@@ -430,92 +547,147 @@ export function mapOpportunityProduct(item: {
430
547
  input,
431
548
  warnings,
432
549
  opportunitySourceId: sourceId("opportunity", item.opportunityId),
550
+ productSourceId: productName ? productSourceId(productName) : null,
433
551
  };
434
552
  }
435
553
 
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>,
554
+ /**
555
+ * A CRM record as LastMile's `task` table sees it — the authority for status,
556
+ * owner, organization, and products. Leads and opportunities both land in
557
+ * Twenty's opportunity pipeline (AE3).
558
+ */
559
+ export function mapCrmTask(
560
+ task: LastmileCrmTask,
561
+ ownerIndex: ReadonlyMap<string, string>,
448
562
  companyIdBySourceId: ReadonlyMap<string, string>,
563
+ organizationIdBySourceId: ReadonlyMap<string, string>,
449
564
  ): MappedRecord {
450
565
  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) {
566
+
567
+ const { stage, unknown } = mapTaskStatusToStage(task.statusName);
568
+ if (unknown) {
569
+ warnings.push(`unknown task status "${task.statusName}" mapped to 00-New`);
570
+ }
571
+
572
+ const ownerId = resolveOwner(task.assigneeRepId, ownerIndex);
573
+ if (task.assigneeRepId && !ownerId) {
456
574
  warnings.push(
457
- `owner ${opportunity.ownerRepId} not provisioned; opportunity has no owner`,
575
+ `assignee rep ${task.assigneeRepId} not provisioned; no owner`,
458
576
  );
577
+ } else if (!task.assigneeRepId) {
578
+ warnings.push("task has no assignee; no owner");
459
579
  }
580
+
581
+ // Leads pre-date an account: they carry only a typed company name.
460
582
  let companyId: string | null = null;
461
- if (opportunity.accountId) {
583
+ if (task.accountId) {
462
584
  companyId =
463
- companyIdBySourceId.get(sourceId("account", opportunity.accountId)) ??
464
- null;
585
+ companyIdBySourceId.get(sourceId("account", task.accountId)) ?? null;
465
586
  if (!companyId) {
466
- warnings.push(
467
- `account ${opportunity.accountId} not found; opportunity has no company`,
468
- );
587
+ warnings.push(`account ${task.accountId} not migrated; no company link`);
469
588
  }
470
589
  }
471
- const amountMicros = toAmountMicros(opportunity.amount);
590
+
591
+ let organizationId: string | null = null;
592
+ if (task.organizationId) {
593
+ organizationId =
594
+ organizationIdBySourceId.get(
595
+ sourceId("organization", task.organizationId),
596
+ ) ?? null;
597
+ if (!organizationId) {
598
+ warnings.push(`organization ${task.organizationId} not migrated`);
599
+ }
600
+ }
601
+
602
+ // The deal total is the sum of its product lines when they exist; otherwise
603
+ // there is no reliable amount on the task.
604
+ const amountMicros = sumLineAmountsMicros(task.items);
605
+
606
+ const name =
607
+ task.title ??
608
+ task.leadCompanyName ??
609
+ `${task.entityType === "lead" ? "Lead" : "Opportunity"} ${task.entityId}`;
610
+
472
611
  const input: Record<string, unknown> = {
473
- name: opportunity.name ?? `Opportunity ${opportunity.id}`,
612
+ name,
474
613
  stage,
475
614
  ...(amountMicros !== null
476
615
  ? { amount: { amountMicros, currencyCode: "USD" } }
477
616
  : {}),
478
- ...(opportunity.expectedCloseDate
479
- ? { closeDate: `${opportunity.expectedCloseDate}T00:00:00.000Z` }
617
+ ...(task.dueDate
618
+ ? { closeDate: new Date(task.dueDate).toISOString() }
480
619
  : {}),
481
620
  ...(ownerId ? { ownerId } : {}),
482
621
  ...(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),
622
+ ...(organizationId ? { organizationId } : {}),
623
+ isMobil: (task.items ?? []).some((item) =>
624
+ isMobilBrand(item.brand ?? null),
625
+ ),
626
+ // Replay LastMile's true creation time onto Twenty so "created" reads
627
+ // correctly and the record sorts by real age, not import time. Twenty
628
+ // accepts createdAt on create and update (proven by the notes path), so
629
+ // records seeded before this behaviour existed heal on the next sweep.
630
+ ...(task.createdAt ? { createdAt: toIsoTimestamp(task.createdAt) } : {}),
631
+ sourceId: sourceId(task.entityType, task.entityId),
489
632
  };
490
633
  return { sourceId: input.sourceId as string, input, warnings };
491
634
  }
492
635
 
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`,
636
+ export function sumLineAmountsMicros(
637
+ items: LastmileCrmTask["items"],
638
+ ): number | null {
639
+ if (!items || items.length === 0) return null;
640
+ let total = 0;
641
+ let sawAny = false;
642
+ for (const item of items) {
643
+ const micros = toAmountMicros(
644
+ item.amount === null || item.amount === undefined
645
+ ? null
646
+ : String(item.amount),
507
647
  );
648
+ if (micros !== null) {
649
+ total += micros;
650
+ sawAny = true;
651
+ }
508
652
  }
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),
653
+ return sawAny ? total : null;
654
+ }
655
+
656
+ /** LastMile branch/business unit ("Golden West Oil Co..San Antonio (300)",
657
+ * shown in the UI as its `abbv`, "GWO 300"). */
658
+ export function mapOrganization(
659
+ organization: LastmileOrganization,
660
+ ): MappedRecord {
661
+ return {
662
+ sourceId: sourceId("organization", organization.id),
663
+ input: {
664
+ name: organization.abbv ?? organization.name ?? organization.id,
665
+ fullName: organization.name ?? "",
666
+ sourceId: sourceId("organization", organization.id),
667
+ },
668
+ warnings: [],
517
669
  };
518
- return { sourceId: input.sourceId as string, input, warnings };
670
+ }
671
+
672
+ /** Product lines belonging to a CRM task, keyed by the task's entity id. */
673
+ export function mapTaskProducts(
674
+ task: LastmileCrmTask,
675
+ ): MappedOpportunityProduct[] {
676
+ return (task.items ?? []).map((item, index) =>
677
+ mapOpportunityProduct({
678
+ opportunityId: task.entityId,
679
+ index,
680
+ brand: item.brand ?? null,
681
+ quantity:
682
+ item.quantity === null || item.quantity === undefined
683
+ ? null
684
+ : String(item.quantity),
685
+ amount:
686
+ item.amount === null || item.amount === undefined
687
+ ? null
688
+ : String(item.amount),
689
+ }),
690
+ );
519
691
  }
520
692
 
521
693
  export interface MappedNote {
@@ -526,6 +698,11 @@ export interface MappedNote {
526
698
  targetSourceId: string;
527
699
  targetKind: "opportunity" | "company";
528
700
  isDeleted: boolean;
701
+ /** When the note was written in LastMile — replayed onto Twenty's createdAt
702
+ * so the activity timeline is chronologically correct. */
703
+ createdAt: string | null;
704
+ /** LastMile author; Twenty's actor is not settable via the API. */
705
+ authorName: string | null;
529
706
  }
530
707
 
531
708
  export function mapCrmComment(comment: LastmileCrmComment): MappedNote {
@@ -538,9 +715,46 @@ export function mapCrmComment(comment: LastmileCrmComment): MappedNote {
538
715
  targetSourceId: sourceId(comment.entityType, comment.entityId),
539
716
  targetKind: "opportunity",
540
717
  isDeleted: comment.isDeleted,
718
+ createdAt: toIsoTimestamp(comment.createdAt),
719
+ authorName: comment.authorName,
541
720
  };
542
721
  }
543
722
 
723
+ /**
724
+ * A LastMile pipeline transition ("00-New" -> "10-Prospect") reconstructed as a
725
+ * dated Note on the Twenty opportunity, so the activity feed shows when each
726
+ * status change actually happened. Returns null when the event carries no
727
+ * destination stage name (nothing meaningful to record). Stage names arrive
728
+ * already clean ("10-Prospect", "60-Won") — the same labels Twenty uses — so no
729
+ * LM_ stripping is needed. The `task_activity.id` gives a stable sourceId, so
730
+ * re-runs upsert the same note rather than duplicating it.
731
+ */
732
+ export function mapTaskStatusActivity(
733
+ activity: LastmileTaskStatusChange,
734
+ ): MappedNote | null {
735
+ if (!activity.newStatusName) return null;
736
+ const body = activity.oldStatusName
737
+ ? `${activity.oldStatusName} → ${activity.newStatusName}`
738
+ : `Set to ${activity.newStatusName}`;
739
+ return {
740
+ sourceId: sourceId("task_activity", activity.id),
741
+ title: `Stage → ${activity.newStatusName}`,
742
+ bodyMarkdown: body,
743
+ targetSourceId: sourceId(activity.entityType, activity.entityId),
744
+ targetKind: "opportunity",
745
+ isDeleted: false,
746
+ createdAt: toIsoTimestamp(activity.createdAt),
747
+ authorName: activity.authorName,
748
+ };
749
+ }
750
+
751
+ /** Twenty wants an ISO-8601 DateTime; LastMile hands us a Date or null. */
752
+ export function toIsoTimestamp(value: Date | string | null): string | null {
753
+ if (!value) return null;
754
+ const date = value instanceof Date ? value : new Date(value);
755
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
756
+ }
757
+
544
758
  /** Customer notes reach Twenty only when their dispatch customer name matches
545
759
  * exactly one CRM account (plan resolution); the caller reports the rest as
546
760
  * gaps. */
@@ -555,5 +769,7 @@ export function mapCustomerNote(note: LastmileCustomerNote): MappedNote | null {
555
769
  targetSourceId: sourceId("account", note.matchedAccountId),
556
770
  targetKind: "company",
557
771
  isDeleted: false,
772
+ createdAt: toIsoTimestamp(note.dateCreated),
773
+ authorName: null,
558
774
  };
559
775
  }