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
@@ -28,70 +28,56 @@ function dataClientWithMembers(
28
28
  } as unknown as TwentyClient;
29
29
  }
30
30
 
31
- describe("ensureMembers (dry run)", () => {
32
- it("three reps, one already a member → one existing + two planned, map holds the existing", async () => {
31
+ describe("ensureMembers (matches existing members; never creates)", () => {
32
+ it("maps reps onto members by email, case-insensitively", async () => {
33
33
  const dataClient = dataClientWithMembers([
34
34
  { id: "wm-1", userEmail: "Jane@TEI.com" },
35
35
  ]);
36
- const result = await ensureMembers({
37
- dataClient,
38
- adminClient: null,
39
- reps: [
40
- rep(),
41
- rep({ repId: "rep_2", email: "bob@tei.com" }),
42
- rep({ repId: "rep_3", email: "amy@tei.com" }),
43
- ],
44
- repPassword: "test",
45
- workspaceId: null,
46
- dryRun: true,
47
- });
36
+ const result = await ensureMembers({ dataClient, reps: [rep()] });
48
37
  expect(result.ownerMap.get("rep_1")).toBe("wm-1");
49
- expect(
50
- result.report.filter((row) => row.action === "planned"),
51
- ).toHaveLength(2);
52
- expect(result.hadFailures).toBe(false);
38
+ expect(result.report[0]).toMatchObject({ action: "existing" });
39
+ expect(result.missingMembers).toBe(0);
53
40
  });
54
41
 
55
- it("reps without an email are unprovisionable and the run continues", async () => {
42
+ it("reports a rep with no Twenty member instead of failing the run", async () => {
43
+ // provision-twenty-members.ts creates members; this module only matches
44
+ // them. Records for an unmatched rep load ownerless and heal on a re-run.
56
45
  const dataClient = dataClientWithMembers([]);
46
+ const result = await ensureMembers({ dataClient, reps: [rep()] });
47
+ expect(result.ownerMap.size).toBe(0);
48
+ expect(result.report[0]).toMatchObject({ action: "missing-member" });
49
+ expect(result.missingMembers).toBe(1);
50
+ expect(result.hadFailures).toBe(false);
51
+ });
52
+
53
+ it("marks emailless reps unprovisionable and keeps going", async () => {
54
+ const dataClient = dataClientWithMembers([
55
+ { id: "wm-bob", userEmail: "bob@tei.com" },
56
+ ]);
57
57
  const result = await ensureMembers({
58
58
  dataClient,
59
- adminClient: null,
60
59
  reps: [
61
60
  rep({ email: null }),
62
61
  rep({ repId: "rep_2", email: "bob@tei.com" }),
63
62
  ],
64
- repPassword: "test",
65
- workspaceId: null,
66
- dryRun: true,
67
63
  });
68
64
  expect(result.report[0]).toMatchObject({
69
65
  repId: "rep_1",
70
66
  action: "unprovisionable",
71
67
  });
72
- expect(result.report[1]).toMatchObject({
73
- repId: "rep_2",
74
- action: "planned",
75
- });
68
+ expect(result.ownerMap.get("rep_2")).toBe("wm-bob");
76
69
  });
77
70
 
78
- it("duplicate emails collapse to one login; both rep ids map to the same member", async () => {
79
- // Observed live in LastMile: a "house" sales_rep row reusing a real rep's
80
- // email. Email is the login identity, so the reps merge — reported, never
81
- // silent.
71
+ it("collapses reps sharing an email onto one login, and says so", async () => {
82
72
  const dataClient = dataClientWithMembers([
83
73
  { id: "wm-jane", userEmail: "jane@tei.com" },
84
74
  ]);
85
75
  const result = await ensureMembers({
86
76
  dataClient,
87
- adminClient: null,
88
77
  reps: [
89
78
  rep(),
90
79
  rep({ repId: "rep_2", firstName: "Employee", lastName: "Accounts" }),
91
80
  ],
92
- repPassword: "test",
93
- workspaceId: null,
94
- dryRun: true,
95
81
  });
96
82
  expect(result.ownerMap.get("rep_1")).toBe("wm-jane");
97
83
  expect(result.ownerMap.get("rep_2")).toBe("wm-jane");
@@ -99,101 +85,14 @@ describe("ensureMembers (dry run)", () => {
99
85
  action: "merged-duplicate-email",
100
86
  });
101
87
  });
102
- });
103
88
 
104
- describe("ensureMembers (apply)", () => {
105
- it("requires the admin client when new members must be provisioned", async () => {
89
+ it("leaves a merged rep unmapped when its primary has no member either", async () => {
106
90
  const dataClient = dataClientWithMembers([]);
107
- await expect(
108
- ensureMembers({
109
- dataClient,
110
- adminClient: null,
111
- reps: [rep()],
112
- repPassword: "test",
113
- workspaceId: "ws-1",
114
- dryRun: false,
115
- }),
116
- ).rejects.toThrow(/TWENTY_ADMIN_EMAIL/);
117
- });
118
-
119
- it("a signUpInWorkspace rejection is reported per-user and the run continues", async () => {
120
- const membersAfterSignup = [{ id: "wm-bob", userEmail: "bob@tei.com" }];
121
- let listCalls = 0;
122
- const dataClient = {
123
- requestWithRetry: vi.fn(async () => {
124
- listCalls += 1;
125
- return {
126
- workspaceMembers: {
127
- // First list: empty; lists after signup include bob only.
128
- edges: (listCalls === 1 ? [] : membersAfterSignup).map(
129
- (member) => ({
130
- node: member,
131
- }),
132
- ),
133
- pageInfo: { hasNextPage: false, endCursor: null },
134
- },
135
- };
136
- }),
137
- endpoint: (path: string) => `https://crm.example.com${path}`,
138
- } as unknown as TwentyClient;
139
-
140
- const adminClient = {
141
- requestOnce: vi.fn(async () => ({
142
- sendInvitations: { success: true, result: [] },
143
- })),
144
- requestWithRetry: vi.fn(async (_path: string, query: string) => {
145
- if (query.includes("findWorkspaceInvitations")) {
146
- return {
147
- findWorkspaceInvitations: [
148
- { id: "inv-jane", email: "jane@tei.com" },
149
- { id: "inv-bob", email: "bob@tei.com" },
150
- ],
151
- };
152
- }
153
- return { getWorkspaceInvitationToken: "token-123" };
154
- }),
155
- } as unknown as TwentyClient;
156
-
157
- // jane's signup fails (fetch mock), bob's succeeds.
158
- const fetchMock = vi.fn(
159
- async (_url: RequestInfo | URL, init?: RequestInit) => {
160
- const body = JSON.parse(String(init?.body ?? "{}")) as {
161
- variables?: { email?: string };
162
- };
163
- if (body.variables?.email === "jane@tei.com") {
164
- return new Response(
165
- JSON.stringify({ errors: [{ message: "invite token invalid" }] }),
166
- {
167
- status: 200,
168
- },
169
- );
170
- }
171
- return new Response(
172
- JSON.stringify({ data: { signUpInWorkspace: {} } }),
173
- { status: 200 },
174
- );
175
- },
176
- );
177
- vi.stubGlobal("fetch", fetchMock);
178
- try {
179
- const result = await ensureMembers({
180
- dataClient,
181
- adminClient,
182
- reps: [rep(), rep({ repId: "rep_2", email: "bob@tei.com" })],
183
- repPassword: "test",
184
- workspaceId: "ws-1",
185
- dryRun: false,
186
- });
187
- expect(result.hadFailures).toBe(true);
188
- expect(result.report.find((row) => row.repId === "rep_1")).toMatchObject({
189
- action: "failed",
190
- });
191
- expect(result.report.find((row) => row.repId === "rep_2")).toMatchObject({
192
- action: "invited+signedUp",
193
- });
194
- expect(result.ownerMap.get("rep_2")).toBe("wm-bob");
195
- } finally {
196
- vi.unstubAllGlobals();
197
- }
91
+ const result = await ensureMembers({
92
+ dataClient,
93
+ reps: [rep(), rep({ repId: "rep_2" })],
94
+ });
95
+ expect(result.ownerMap.size).toBe(0);
96
+ expect(result.missingMembers).toBe(1);
198
97
  });
199
98
  });
@@ -41,3 +41,13 @@ describe("quoteIdent", () => {
41
41
  expect(() => quoteIdent("public")).toThrow(/Refusing/);
42
42
  });
43
43
  });
44
+
45
+ describe("hashPassword salts per call", () => {
46
+ it("never reuses a salt, so rotation does not share hashes across reps", () => {
47
+ const a = hashPassword("SharedSecret1!", 4);
48
+ const b = hashPassword("SharedSecret1!", 4);
49
+ expect(a).not.toBe(b);
50
+ expect(bcrypt.compareSync("SharedSecret1!", a)).toBe(true);
51
+ expect(bcrypt.compareSync("SharedSecret1!", b)).toBe(true);
52
+ });
53
+ });
@@ -61,9 +61,9 @@ function bareObjects() {
61
61
  opportunity: [stageField],
62
62
  note: [],
63
63
  attachment: [],
64
- // The product-line object exists (ensureOpportunityProductObject ran) but
65
- // has none of its fields yet.
64
+ // Custom objects exist (their ensure* ran) but have none of their fields.
66
65
  opportunityProduct: [],
66
+ organization: [],
67
67
  });
68
68
  }
69
69
 
@@ -115,6 +115,7 @@ function provisionedObjects() {
115
115
  options: null,
116
116
  },
117
117
  ],
118
+ organization: withAll("organization"),
118
119
  });
119
120
  }
120
121
 
@@ -122,15 +123,18 @@ describe("planSchemaEnsure", () => {
122
123
  it("plans every missing field and all stage options on an empty workspace", () => {
123
124
  const plan = planSchemaEnsure(bareObjects());
124
125
  expect(plan.createFields).toHaveLength(FIELD_SPECS.length);
126
+ // NEW already exists on the workspace, so it is merged, not duplicated.
125
127
  expect(plan.stageOptionsToAdd).toEqual(
126
- MIGRATION_STAGE_OPTIONS.map((option) => option.value),
128
+ MIGRATION_STAGE_OPTIONS.filter((option) => option.value !== "NEW").map(
129
+ (option) => option.value,
130
+ ),
127
131
  );
128
132
  // Full-replace semantics (U1): the merged array keeps existing options first.
129
133
  expect(
130
134
  plan.mergedStageOptions?.slice(0, 2).map((option) => option.value),
131
135
  ).toEqual(["NEW", "CUSTOMER"]);
132
136
  expect(plan.mergedStageOptions).toHaveLength(
133
- EXISTING_STAGE_OPTIONS.length + MIGRATION_STAGE_OPTIONS.length,
137
+ EXISTING_STAGE_OPTIONS.length + MIGRATION_STAGE_OPTIONS.length - 1,
134
138
  );
135
139
  });
136
140
 
@@ -143,7 +147,7 @@ describe("planSchemaEnsure", () => {
143
147
 
144
148
  it("creates only the gap on partial presence", () => {
145
149
  const objects = bareObjects();
146
- objects.get("opportunity")!.fields.set("product", {
150
+ objects.get("opportunityProduct")!.fields.set("product", {
147
151
  id: "f-product",
148
152
  name: "product",
149
153
  type: "TEXT",
@@ -153,8 +157,8 @@ describe("planSchemaEnsure", () => {
153
157
  const names = plan.createFields.map(
154
158
  (field) => `${field.object}.${field.name}`,
155
159
  );
156
- expect(names).not.toContain("opportunity.product");
157
- expect(names).toContain("opportunity.quantity");
160
+ expect(names).not.toContain("opportunityProduct.product");
161
+ expect(names).toContain("opportunityProduct.quantity");
158
162
  });
159
163
 
160
164
  it("aborts when the stage field is missing", () => {
@@ -165,6 +169,7 @@ describe("planSchemaEnsure", () => {
165
169
  note: [],
166
170
  attachment: [],
167
171
  opportunityProduct: [],
172
+ organization: [],
168
173
  });
169
174
  expect(() => planSchemaEnsure(objects)).toThrow(/stage field/);
170
175
  });
@@ -202,7 +207,7 @@ describe("applySchemaEnsure", () => {
202
207
  ).toContain("CUSTOMER");
203
208
  expect(
204
209
  lastCall[2].input.update.options.map((option) => option.value),
205
- ).toContain("LEAD");
210
+ ).toContain("PROSPECT");
206
211
  });
207
212
 
208
213
  it("refuses to create a field against a pending object id", async () => {
@@ -43,6 +43,17 @@ export interface LastmileContact {
43
43
  title: string | null;
44
44
  }
45
45
 
46
+ export interface LastmileDispatchCustomer {
47
+ id: string;
48
+ name: string | null;
49
+ /** dispatch customer.external_id — the P21 account key ("P21:12345"). */
50
+ p21Code: string | null;
51
+ ownerRepId: string | null;
52
+ /** An `account` row already carries this exact (case-folded) name — the
53
+ * account-sourced company covers it, so the dispatch source skips it. */
54
+ hasAccountNameMatch: boolean;
55
+ }
56
+
46
57
  export interface LastmileLead {
47
58
  id: string;
48
59
  status: string | null;
@@ -91,13 +102,76 @@ export interface LastmileOpportunityItem {
91
102
  amount: string | null;
92
103
  }
93
104
 
105
+ /**
106
+ * The `task` table is LastMile's live CRM. Everything a rep sees hangs off it:
107
+ * `status_id` (the real pipeline status — the `opportunity.stage` column is
108
+ * stale and disagrees for 889 of 950 rows), `assignee_id` (the owning rep),
109
+ * `organization_id` (the branch, e.g. "GWO 300"), and `entity_data` (name,
110
+ * description, account, and the product `items` array). Task rows only exist
111
+ * from 2025-07 onward; older opportunity/lead rows are legacy and out of scope.
112
+ */
113
+ export interface LastmileCrmTask {
114
+ taskId: string;
115
+ entityType: "lead" | "opportunity";
116
+ /** opportunity/lead row id — the stable identity for the Twenty record. */
117
+ entityId: string;
118
+ title: string | null;
119
+ description: string | null;
120
+ /** Present on opportunity tasks only; leads pre-date an account. */
121
+ accountId: string | null;
122
+ /** Company name typed on a lead before an account exists. */
123
+ leadCompanyName: string | null;
124
+ statusName: string | null;
125
+ organizationId: string | null;
126
+ /** Owning rep, resolved from the task's assignee user. */
127
+ assigneeRepId: string | null;
128
+ dueDate: Date | null;
129
+ createdAt: Date | null;
130
+ /** Product lines: [{ brand, quantity, amount }, ...] */
131
+ items: Array<{
132
+ brand?: string | null;
133
+ quantity?: string | number | null;
134
+ amount?: string | number | null;
135
+ }> | null;
136
+ }
137
+
138
+ export interface LastmileOrganization {
139
+ id: string;
140
+ name: string | null;
141
+ /** Short code shown in the LastMile UI, e.g. "GWO 300". */
142
+ abbv: string | null;
143
+ archived: boolean;
144
+ }
145
+
94
146
  export interface LastmileCrmComment {
95
147
  id: string;
96
148
  entityType: "lead" | "opportunity";
97
149
  entityId: string;
98
150
  content: string | null;
99
151
  isDeleted: boolean;
152
+ /** Authored-at time. Carried onto the Twenty note so the activity feed
153
+ * reflects when the rep actually wrote it, not when the import ran. */
154
+ createdAt: Date | null;
155
+ authorName: string | null;
156
+ }
157
+
158
+ /**
159
+ * A single status-change event from LastMile's `task_activity` log — the row
160
+ * behind "Dean Kittel changed status to 10-Prospect" in the task activity feed.
161
+ * The pipeline transition is reconstructed onto the Twenty opportunity as a
162
+ * dated Note so the activity timeline reads in true order. `activity_data`
163
+ * carries human-readable stage names (`new_status_name` in every prod row; the
164
+ * camelCase `newStatusName` variant is coalesced defensively). `created_at` is
165
+ * the real transition time; `user_id` resolves to the rep who moved it.
166
+ */
167
+ export interface LastmileTaskStatusChange {
168
+ id: string;
169
+ entityType: "lead" | "opportunity";
170
+ entityId: string;
171
+ oldStatusName: string | null;
172
+ newStatusName: string | null;
100
173
  createdAt: Date | null;
174
+ authorName: string | null;
101
175
  }
102
176
 
103
177
  export interface LastmileCrmAttachment {
@@ -120,19 +194,53 @@ export interface LastmileCustomerNote {
120
194
  dateCreated: Date | null;
121
195
  }
122
196
 
197
+ /** TEI's operating company in the dispatch database (the others are demo
198
+ * shells) — dispatch-side reads scope to it; the CRM tables are single-tenant
199
+ * and stay unscoped. */
200
+ const TEI_DISPATCH_COMPANY_ID = "co_y15610tsjbkqz5cqoic8gjla";
201
+
123
202
  export interface LastmileReader {
124
203
  readReps(): Promise<LastmileRep[]>;
125
204
  readAccounts(): Promise<LastmileAccount[]>;
205
+ /** Dispatch customers with an order in the last `days` days (the ThinkWork
206
+ * twin's seed cohort rule) — the "real customers" band for the CRM. With
207
+ * `includeOpenInvoices`, customers holding an open invoice (nonzero
208
+ * amount_remaining, any age) join the cohort regardless of order recency —
209
+ * the twin carries ALL open invoices, so their customers must exist in the
210
+ * CRM too. */
211
+ readOrderCohortCustomers(
212
+ days: number,
213
+ includeOpenInvoices?: boolean,
214
+ ): Promise<LastmileDispatchCustomer[]>;
126
215
  readContacts(): Promise<LastmileContact[]>;
127
216
  readLeads(): Promise<LastmileLead[]>;
128
217
  readOpportunities(): Promise<LastmileOpportunity[]>;
218
+ readCrmTasks(): Promise<LastmileCrmTask[]>;
219
+ readOrganizations(): Promise<LastmileOrganization[]>;
129
220
  readOpportunityItems(): Promise<LastmileOpportunityItem[]>;
130
221
  readCrmComments(): Promise<LastmileCrmComment[]>;
222
+ readTaskStatusChanges(): Promise<LastmileTaskStatusChange[]>;
131
223
  readCrmAttachments(): Promise<LastmileCrmAttachment[]>;
132
224
  readCustomerNotes(): Promise<LastmileCustomerNote[]>;
133
225
  close(): Promise<void>;
134
226
  }
135
227
 
228
+ /**
229
+ * The accounts TEI actually sells to: those referenced by a CRM task (lead or
230
+ * opportunity). Companies and contacts are both scoped to this set — LastMile
231
+ * carries 3,403 accounts and 29,966 contacts, but only 805 accounts have an
232
+ * opportunity, and 443 contacts sit on them. Everything else is old data that
233
+ * stays in LastMile (Eric, 2026-07-10).
234
+ */
235
+ const OPPORTUNITY_ACCOUNTS_CTE = `
236
+ opportunity_accounts as (
237
+ select distinct nullif(trim(t.entity_data ->> 'account_id'), '') as account_id
238
+ from task t
239
+ where t.entity_type in ('lead', 'opportunity')
240
+ and nullif(trim(t.entity_data ->> 'account_id'), '') is not null
241
+ )
242
+ `;
243
+
136
244
  export function createLastmileReader(databaseUrl: string): LastmileReader {
137
245
  const pool = new pg.Pool({
138
246
  connectionString: databaseUrl,
@@ -161,12 +269,57 @@ export function createLastmileReader(databaseUrl: string): LastmileReader {
161
269
  `),
162
270
  readAccounts: () =>
163
271
  rows<LastmileAccount>(`
272
+ with ${OPPORTUNITY_ACCOUNTS_CTE}
164
273
  select id, nullif(trim(name), '') as "name", nullif(owner, '') as "ownerRepId"
165
274
  from account
275
+ where id in (select account_id from opportunity_accounts)
166
276
  order by id
167
277
  `),
278
+ readOrderCohortCustomers: (days: number, includeOpenInvoices?: boolean) => {
279
+ if (!Number.isInteger(days) || days < 1 || days > 365) {
280
+ throw new Error(`cohort days must be an integer 1..365, got ${days}`);
281
+ }
282
+ const openInvoiceUnion = includeOpenInvoices
283
+ ? `
284
+ union
285
+ select distinct i.customer_id as id
286
+ from invoice i
287
+ where i.company_id = '${TEI_DISPATCH_COMPANY_ID}'
288
+ and i.amount_remaining is not null
289
+ and i.amount_remaining <> 0
290
+ and i.customer_id is not null`
291
+ : "";
292
+ return rows<LastmileDispatchCustomer>(`
293
+ with cohort as (
294
+ select distinct o.customer_id as id
295
+ from order_header o
296
+ where o.company_id = '${TEI_DISPATCH_COMPANY_ID}'
297
+ and o.archived is not true
298
+ and o.order_date > now() - interval '${days} days'
299
+ and o.customer_id is not null${openInvoiceUnion}
300
+ )
301
+ select c.id,
302
+ nullif(trim(c.name), '') as "name",
303
+ nullif(trim(c.external_id), '') as "p21Code",
304
+ (
305
+ select s.sales_rep_id from ship_to s
306
+ where s.customer_id = c.id and s.sales_rep_id is not null
307
+ group by s.sales_rep_id order by count(*) desc, s.sales_rep_id
308
+ limit 1
309
+ ) as "ownerRepId",
310
+ exists (
311
+ select 1 from account a
312
+ where lower(trim(a.name)) = lower(trim(c.name))
313
+ ) as "hasAccountNameMatch"
314
+ from customer c
315
+ join cohort on cohort.id = c.id
316
+ where c.archived is not true
317
+ order by c.id
318
+ `);
319
+ },
168
320
  readContacts: () =>
169
321
  rows<LastmileContact>(`
322
+ with ${OPPORTUNITY_ACCOUNTS_CTE}
170
323
  select id,
171
324
  nullif(account_id, '') as "accountId",
172
325
  nullif(trim(first_name), '') as "firstName",
@@ -176,6 +329,7 @@ export function createLastmileReader(databaseUrl: string): LastmileReader {
176
329
  nullif(trim(phone_cellular), '') as "phoneCellular",
177
330
  nullif(trim(title), '') as "title"
178
331
  from contact
332
+ where account_id in (select account_id from opportunity_accounts)
179
333
  order by id
180
334
  `),
181
335
  readLeads: () =>
@@ -213,6 +367,44 @@ export function createLastmileReader(databaseUrl: string): LastmileReader {
213
367
  from opportunity
214
368
  order by id
215
369
  `),
370
+ readCrmTasks: () =>
371
+ rows<LastmileCrmTask>(`
372
+ select t.id as "taskId",
373
+ t.entity_type as "entityType",
374
+ t.entity_id as "entityId",
375
+ coalesce(
376
+ nullif(trim(t.entity_data ->> 'opp_name'), ''),
377
+ nullif(trim(t.title), '')
378
+ ) as "title",
379
+ nullif(trim(t.entity_data ->> 'description'), '') as "description",
380
+ nullif(trim(t.entity_data ->> 'account_id'), '') as "accountId",
381
+ nullif(trim(t.entity_data ->> 'company'), '') as "leadCompanyName",
382
+ nullif(trim(s.name), '') as "statusName",
383
+ nullif(t.organization_id, '') as "organizationId",
384
+ nullif(u.sales_rep_id, '') as "assigneeRepId",
385
+ t.due_date as "dueDate",
386
+ t.created_at as "createdAt",
387
+ case
388
+ when jsonb_typeof(t.entity_data -> 'items') = 'array'
389
+ then t.entity_data -> 'items'
390
+ else null
391
+ end as "items"
392
+ from task t
393
+ left join status s on s.id = t.status_id
394
+ left join users u on u.id = t.assignee_id
395
+ where t.entity_type in ('lead', 'opportunity')
396
+ and t.entity_id is not null
397
+ order by t.entity_type, t.entity_id
398
+ `),
399
+ readOrganizations: () =>
400
+ rows<LastmileOrganization>(`
401
+ select id,
402
+ nullif(trim(name), '') as "name",
403
+ nullif(trim(abbv), '') as "abbv",
404
+ coalesce(archived, false) as "archived"
405
+ from organization
406
+ order by id
407
+ `),
216
408
  readOpportunityItems: () =>
217
409
  rows<LastmileOpportunityItem>(`
218
410
  select t.entity_id as "opportunityId",
@@ -236,13 +428,38 @@ export function createLastmileReader(databaseUrl: string): LastmileReader {
236
428
  t.entity_id as "entityId",
237
429
  nullif(trim(tc.content), '') as "content",
238
430
  coalesce(tc.is_deleted, false) as "isDeleted",
239
- tc.created_at as "createdAt"
431
+ tc.created_at as "createdAt",
432
+ nullif(trim(concat_ws(' ', u.first_name, u.last_name)), '') as "authorName"
240
433
  from task_comment tc
241
434
  join task t on t.id = tc.task_id
435
+ left join users u on u.id = tc.user_id
242
436
  where t.entity_type in ('lead', 'opportunity')
243
437
  and t.entity_id is not null
244
438
  order by tc.id
245
439
  `),
440
+ readTaskStatusChanges: () =>
441
+ rows<LastmileTaskStatusChange>(`
442
+ select ta.id,
443
+ t.entity_type as "entityType",
444
+ t.entity_id as "entityId",
445
+ coalesce(
446
+ nullif(trim(ta.activity_data ->> 'old_status_name'), ''),
447
+ nullif(trim(ta.activity_data ->> 'oldStatusName'), '')
448
+ ) as "oldStatusName",
449
+ coalesce(
450
+ nullif(trim(ta.activity_data ->> 'new_status_name'), ''),
451
+ nullif(trim(ta.activity_data ->> 'newStatusName'), '')
452
+ ) as "newStatusName",
453
+ ta.created_at as "createdAt",
454
+ nullif(trim(concat_ws(' ', u.first_name, u.last_name)), '') as "authorName"
455
+ from task_activity ta
456
+ join task t on t.id = ta.task_id
457
+ left join users u on u.id = ta.user_id
458
+ where t.entity_type in ('lead', 'opportunity')
459
+ and t.entity_id is not null
460
+ and ta.activity_type in ('status_changed', 'status_change')
461
+ order by ta.id
462
+ `),
246
463
  readCrmAttachments: () =>
247
464
  rows<LastmileCrmAttachment>(`
248
465
  select ta.id,
@@ -260,10 +477,13 @@ export function createLastmileReader(databaseUrl: string): LastmileReader {
260
477
  `),
261
478
  readCustomerNotes: () =>
262
479
  rows<LastmileCustomerNote>(`
263
- with account_names as (
480
+ with ${OPPORTUNITY_ACCOUNTS_CTE},
481
+ account_names as (
482
+ -- Only migrated accounts can host a note.
264
483
  select lower(trim(name)) as norm_name, min(id) as account_id, count(*) as n
265
484
  from account
266
485
  where nullif(trim(name), '') is not null
486
+ and id in (select account_id from opportunity_accounts)
267
487
  group by lower(trim(name))
268
488
  )
269
489
  select n.id,
@@ -18,6 +18,27 @@ import { fetchExistingBySourceIds, type EntityCounters } from "./load-records";
18
18
  import { sourceId as makeSourceId } from "./mappers";
19
19
  import { TwentyClient, TwentyGraphqlError } from "./twenty-client";
20
20
 
21
+ /**
22
+ * TEI's Twenty exposes no file-upload mutation to a workspace API key
23
+ * (`uploadFile` and the `Upload` scalar are absent; `/rest/files` is object
24
+ * CRUD). Same root cause as the missing auth schema. Four LastMile attachments
25
+ * hang off CRM records and one of those binaries is already gone from S3, so
26
+ * this is reported as a capability gap rather than a per-record failure —
27
+ * otherwise every future delta run would exit non-zero on the same files.
28
+ */
29
+ export function isUploadUnsupported(error: unknown): boolean {
30
+ const message =
31
+ error instanceof TwentyGraphqlError
32
+ ? error.errors.map((entry) => entry.message).join(" ")
33
+ : error instanceof Error
34
+ ? error.message
35
+ : "";
36
+ return (
37
+ /Unknown type "Upload"/i.test(message) ||
38
+ /Cannot query field "uploadFile"/i.test(message)
39
+ );
40
+ }
41
+
21
42
  const ATTACHMENT_ENTITY = {
22
43
  singular: "attachment",
23
44
  plural: "attachments",
@@ -119,6 +140,14 @@ export async function loadCrmAttachments(options: {
119
140
  );
120
141
  counters.created += 1;
121
142
  } catch (error) {
143
+ if (isUploadUnsupported(error)) {
144
+ counters.skipped += 1;
145
+ counters.gaps.push(
146
+ `attachment ${attachmentSourceId} (${attachment.filename ?? "?"}): ` +
147
+ "Twenty exposes no file-upload API to this credential — upload by hand",
148
+ );
149
+ continue;
150
+ }
122
151
  counters.failed += 1;
123
152
  counters.gaps.push(
124
153
  `attachment ${attachmentSourceId} failed: ${