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,489 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type {
4
+ LastmileContact,
5
+ LastmileCustomerNote,
6
+ LastmileLead,
7
+ LastmileOpportunity,
8
+ } from "../lastmile-reader";
9
+ import {
10
+ buildOwnerIndex,
11
+ contentHash,
12
+ deriveRepEmail,
13
+ isMobilBrand,
14
+ mapAccount,
15
+ mapContact,
16
+ mapCrmComment,
17
+ mapCustomerNote,
18
+ mapLead,
19
+ mapLeadStatusToStage,
20
+ mapOpportunity,
21
+ mapOpportunityProduct,
22
+ mapOpportunityStage,
23
+ normalizeEmail,
24
+ normalizePhone,
25
+ resolveOwner,
26
+ sourceId,
27
+ stableStringify,
28
+ toAmountMicros,
29
+ toQuantity,
30
+ } from "../mappers";
31
+
32
+ const ownerMap = new Map([["rep_jane", "member-jane-uuid"]]);
33
+ const companyMap = new Map([[sourceId("account", "acct_1"), "company-uuid-1"]]);
34
+
35
+ function lead(overrides: Partial<LastmileLead> = {}): LastmileLead {
36
+ return {
37
+ id: "lead_1",
38
+ status: "00-New",
39
+ companyName: "Acme Fuel",
40
+ firstName: "Ann",
41
+ lastName: "Lee",
42
+ email: null,
43
+ phone: null,
44
+ source: null,
45
+ description: null,
46
+ ownerRepId: null,
47
+ dateCreated: null,
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ function opportunity(
53
+ overrides: Partial<LastmileOpportunity> = {},
54
+ ): LastmileOpportunity {
55
+ return {
56
+ id: "opp_1",
57
+ name: "Bulk diesel contract",
58
+ stage: "10-Prospect",
59
+ amount: "1234.56",
60
+ quantity: "100",
61
+ productType: "Diesel",
62
+ brand: "Mobil",
63
+ closed: null,
64
+ won: null,
65
+ accountId: "acct_1",
66
+ ownerRepId: "rep_jane",
67
+ expectedCloseDate: "2026-08-01",
68
+ description: null,
69
+ dateCreated: null,
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ describe("toAmountMicros", () => {
75
+ it("converts $1,234.56 to 1234560000 micros", () => {
76
+ expect(toAmountMicros("$1,234.56")).toBe(1_234_560_000);
77
+ expect(toAmountMicros("1234.56")).toBe(1_234_560_000);
78
+ });
79
+
80
+ it("returns null for missing or garbage amounts", () => {
81
+ expect(toAmountMicros(null)).toBeNull();
82
+ expect(toAmountMicros("")).toBeNull();
83
+ expect(toAmountMicros("n/a")).toBeNull();
84
+ });
85
+ });
86
+
87
+ describe("toQuantity", () => {
88
+ it("parses numeric text and rejects garbage", () => {
89
+ expect(toQuantity("1,500")).toBe(1500);
90
+ expect(toQuantity("about 12")).toBeNull();
91
+ expect(toQuantity(null)).toBeNull();
92
+ });
93
+ });
94
+
95
+ describe("normalizePhone", () => {
96
+ it("normalizes US national formats to number + calling/country code", () => {
97
+ expect(normalizePhone("512-825-8875")).toEqual({
98
+ primaryPhoneNumber: "5128258875",
99
+ primaryPhoneCallingCode: "+1",
100
+ primaryPhoneCountryCode: "US",
101
+ });
102
+ expect(normalizePhone("(361) 664-9106")?.primaryPhoneNumber).toBe(
103
+ "3616649106",
104
+ );
105
+ expect(normalizePhone("1-512-825-8875")?.primaryPhoneNumber).toBe(
106
+ "5128258875",
107
+ );
108
+ });
109
+
110
+ it("drops unparseable or non-US-shaped numbers", () => {
111
+ expect(normalizePhone("call the office")).toBeNull();
112
+ expect(normalizePhone("12345")).toBeNull();
113
+ expect(normalizePhone("011-44-20-7946-0958")).toBeNull();
114
+ expect(normalizePhone(null)).toBeNull();
115
+ });
116
+ });
117
+
118
+ describe("normalizeEmail", () => {
119
+ it("lowercases and validates", () => {
120
+ expect(normalizeEmail(" Jane@TEI.com ")).toBe("jane@tei.com");
121
+ expect(normalizeEmail("not-an-email")).toBeNull();
122
+ });
123
+ });
124
+
125
+ describe("stage mapping", () => {
126
+ it("maps messy lead statuses into the lead band", () => {
127
+ expect(mapLeadStatusToStage("00-New").stage).toBe("LEAD");
128
+ expect(mapLeadStatusToStage("new").stage).toBe("LEAD");
129
+ expect(mapLeadStatusToStage(null).stage).toBe("LEAD");
130
+ expect(mapLeadStatusToStage("20-Working").stage).toBe("LEAD_WORKING");
131
+ expect(mapLeadStatusToStage("30-Nurturing").stage).toBe("LEAD_WORKING");
132
+ expect(mapLeadStatusToStage("50-Qualified").stage).toBe("LEAD_QUALIFIED");
133
+ expect(mapLeadStatusToStage("90-Unqualified").stage).toBe(
134
+ "LEAD_UNQUALIFIED",
135
+ );
136
+ expect(mapLeadStatusToStage("Converted").stage).toBe("LEAD_QUALIFIED");
137
+ });
138
+
139
+ it("flags unknown lead statuses", () => {
140
+ expect(mapLeadStatusToStage("13-Mystery")).toEqual({
141
+ stage: "LEAD",
142
+ unknown: true,
143
+ });
144
+ });
145
+
146
+ it("maps opportunity stages including both Negotiate spellings", () => {
147
+ expect(
148
+ mapOpportunityStage({ stage: "60-Won", closed: null, won: null }).stage,
149
+ ).toBe("WON");
150
+ expect(
151
+ mapOpportunityStage({ stage: "90-Lost", closed: null, won: null }).stage,
152
+ ).toBe("LOST");
153
+ expect(
154
+ mapOpportunityStage({
155
+ stage: "40-Negotiate to Close",
156
+ closed: null,
157
+ won: null,
158
+ }).stage,
159
+ ).toBe("NEGOTIATE");
160
+ expect(
161
+ mapOpportunityStage({
162
+ stage: "40-Negotiation to Close",
163
+ closed: null,
164
+ won: null,
165
+ }).stage,
166
+ ).toBe("NEGOTIATE");
167
+ });
168
+
169
+ it("falls back to closed/won flags for blank stages", () => {
170
+ expect(
171
+ mapOpportunityStage({ stage: null, closed: "true", won: "true" }).stage,
172
+ ).toBe("WON");
173
+ expect(
174
+ mapOpportunityStage({ stage: null, closed: "true", won: "false" }).stage,
175
+ ).toBe("LOST");
176
+ expect(
177
+ mapOpportunityStage({ stage: null, closed: null, won: null }).stage,
178
+ ).toBe("NEW");
179
+ });
180
+ });
181
+
182
+ describe("mapAccount", () => {
183
+ it("maps to a company with owner and namespaced sourceId", () => {
184
+ const mapped = mapAccount(
185
+ { id: "acct_1", name: "Acme", ownerRepId: "rep_jane" },
186
+ ownerMap,
187
+ );
188
+ expect(mapped.input).toMatchObject({
189
+ name: "Acme",
190
+ accountOwnerId: "member-jane-uuid",
191
+ sourceId: "account:acct_1",
192
+ });
193
+ expect(mapped.warnings).toEqual([]);
194
+ });
195
+
196
+ it("warns when the owner is unprovisioned", () => {
197
+ const mapped = mapAccount(
198
+ { id: "acct_2", name: "Beta", ownerRepId: "rep_gone" },
199
+ ownerMap,
200
+ );
201
+ expect(mapped.input).not.toHaveProperty("accountOwnerId");
202
+ expect(mapped.warnings[0]).toMatch(/rep_gone/);
203
+ });
204
+ });
205
+
206
+ describe("mapContact", () => {
207
+ function contact(overrides: Partial<LastmileContact> = {}): LastmileContact {
208
+ return {
209
+ id: "ct_1",
210
+ accountId: "acct_1",
211
+ firstName: "Ann",
212
+ lastName: "Lee",
213
+ email: "Ann@Acme.com",
214
+ phone: "210-555-0101",
215
+ phoneCellular: null,
216
+ title: "Buyer",
217
+ ...overrides,
218
+ };
219
+ }
220
+
221
+ it("maps FullName split and company link", () => {
222
+ const mapped = mapContact(contact(), companyMap);
223
+ expect(mapped.input).toMatchObject({
224
+ name: { firstName: "Ann", lastName: "Lee" },
225
+ emails: { primaryEmail: "ann@acme.com" },
226
+ phones: {
227
+ primaryPhoneNumber: "2105550101",
228
+ primaryPhoneCallingCode: "+1",
229
+ primaryPhoneCountryCode: "US",
230
+ },
231
+ jobTitle: "Buyer",
232
+ companyId: "company-uuid-1",
233
+ sourceId: "contact:ct_1",
234
+ });
235
+ });
236
+
237
+ it("keeps a contact without an account as a person with no company", () => {
238
+ const mapped = mapContact(contact({ accountId: null }), companyMap);
239
+ expect(mapped.input).not.toHaveProperty("companyId");
240
+ expect(mapped.warnings).toEqual([]);
241
+ });
242
+
243
+ it("drops invalid emails with a warning", () => {
244
+ const mapped = mapContact(contact({ email: "garbage" }), companyMap);
245
+ expect(mapped.input).not.toHaveProperty("emails");
246
+ expect(mapped.warnings[0]).toMatch(/invalid email/);
247
+ });
248
+ });
249
+
250
+ describe("mapOpportunity", () => {
251
+ it("maps stage, micros, custom fields, links, and owner (AE1 shape)", () => {
252
+ const mapped = mapOpportunity(opportunity(), ownerMap, companyMap);
253
+ expect(mapped.input).toMatchObject({
254
+ name: "Bulk diesel contract",
255
+ stage: "PROSPECT",
256
+ amount: { amountMicros: 1_234_560_000, currencyCode: "USD" },
257
+ ownerId: "member-jane-uuid",
258
+ companyId: "company-uuid-1",
259
+ product: "Diesel",
260
+ quantity: 100,
261
+ isMobil: true,
262
+ sourceId: "opportunity:opp_1",
263
+ });
264
+ });
265
+
266
+ it("is not Mobil when neither product nor brand mentions Mobil", () => {
267
+ const mapped = mapOpportunity(
268
+ opportunity({ brand: "Shell", productType: "Unleaded" }),
269
+ ownerMap,
270
+ companyMap,
271
+ );
272
+ expect(mapped.input.isMobil).toBe(false);
273
+ });
274
+
275
+ it("flags an unresolvable company and unprovisioned owner", () => {
276
+ const mapped = mapOpportunity(
277
+ opportunity({ accountId: "acct_missing", ownerRepId: "rep_gone" }),
278
+ ownerMap,
279
+ companyMap,
280
+ );
281
+ expect(mapped.input).not.toHaveProperty("companyId");
282
+ expect(mapped.input).not.toHaveProperty("ownerId");
283
+ expect(mapped.warnings).toHaveLength(2);
284
+ });
285
+ });
286
+
287
+ describe("mapLead", () => {
288
+ it("maps a lead row to the early Lead stage in the opportunity pipeline (AE3)", () => {
289
+ const mapped = mapLead(lead(), ownerMap);
290
+ expect(mapped.input).toMatchObject({
291
+ name: "Acme Fuel",
292
+ stage: "LEAD",
293
+ sourceId: "lead:lead_1",
294
+ });
295
+ });
296
+
297
+ it("names person-only leads by their person name", () => {
298
+ const mapped = mapLead(lead({ companyName: null }), ownerMap);
299
+ expect(mapped.input.name).toBe("Ann Lee");
300
+ });
301
+ });
302
+
303
+ describe("notes mapping", () => {
304
+ it("maps a CRM comment onto its lead/opportunity target", () => {
305
+ const mapped = mapCrmComment({
306
+ id: "tc_1",
307
+ entityType: "opportunity",
308
+ entityId: "opp_1",
309
+ content: "Called the buyer.\nFollow up Friday.",
310
+ isDeleted: false,
311
+ createdAt: null,
312
+ });
313
+ expect(mapped).toMatchObject({
314
+ sourceId: "task_comment:tc_1",
315
+ title: "Called the buyer.",
316
+ targetSourceId: "opportunity:opp_1",
317
+ targetKind: "opportunity",
318
+ });
319
+ });
320
+
321
+ it("maps a customer note only when the customer uniquely matches an account", () => {
322
+ const base: LastmileCustomerNote = {
323
+ id: "n_1",
324
+ noteText: "Tank inspection notes",
325
+ customerId: "cust_1",
326
+ customerName: "Acme",
327
+ matchedAccountId: "acct_1",
328
+ dateCreated: null,
329
+ };
330
+ expect(mapCustomerNote(base)?.targetSourceId).toBe("account:acct_1");
331
+ expect(mapCustomerNote({ ...base, matchedAccountId: null })).toBeNull();
332
+ });
333
+ });
334
+
335
+ describe("contentHash", () => {
336
+ it("is stable across key order and ignores sourceHash itself", () => {
337
+ const a = contentHash({ name: "X", stage: "LEAD" });
338
+ const b = contentHash({ stage: "LEAD", name: "X" });
339
+ const c = contentHash({ stage: "LEAD", name: "X", sourceHash: "whatever" });
340
+ expect(a).toBe(b);
341
+ expect(a).toBe(c);
342
+ });
343
+
344
+ it("changes when content changes (AE1: source edit → update)", () => {
345
+ expect(contentHash({ amount: 1 })).not.toBe(contentHash({ amount: 2 }));
346
+ });
347
+
348
+ it("stableStringify sorts keys deterministically", () => {
349
+ expect(stableStringify({ b: 1, a: [{ d: 2, c: 3 }] })).toBe(
350
+ '{"a":[{"c":3,"d":2}],"b":1}',
351
+ );
352
+ });
353
+ });
354
+
355
+ describe("buildOwnerIndex / resolveOwner", () => {
356
+ const reps = [
357
+ { id: "rep_1", alias: "jbake", firstName: "Jane", lastName: "Baker" },
358
+ { id: "rep_2", alias: null, firstName: "Daniel", lastName: "Emblen" },
359
+ { id: "rep_3", alias: null, firstName: "Jose", lastName: "Vazquez" },
360
+ { id: "rep_4", alias: null, firstName: "Jose", lastName: "Vazquez" },
361
+ ];
362
+ const memberIds = new Map([
363
+ ["rep_1", "wm-1"],
364
+ ["rep_2", "wm-2"],
365
+ ["rep_3", "wm-3"],
366
+ ["rep_4", "wm-4"],
367
+ ]);
368
+ const index = buildOwnerIndex(reps, memberIds);
369
+
370
+ it("resolves rep ids, aliases, full names, and derived aliases", () => {
371
+ expect(resolveOwner("rep_1", index)).toBe("wm-1");
372
+ expect(resolveOwner("jbake", index)).toBe("wm-1");
373
+ expect(resolveOwner("Jane Baker", index)).toBe("wm-1");
374
+ expect(resolveOwner("dembl", index)).toBe("wm-2"); // derived: D + embl
375
+ });
376
+
377
+ it("refuses ambiguous names shared by two reps", () => {
378
+ expect(resolveOwner("Jose Vazquez", index)).toBeNull();
379
+ expect(resolveOwner("rep_3", index)).toBe("wm-3"); // ids stay exact
380
+ });
381
+
382
+ it("unknown refs resolve to null", () => {
383
+ expect(resolveOwner("Data Migration", index)).toBeNull();
384
+ expect(resolveOwner(null, index)).toBeNull();
385
+ });
386
+ });
387
+
388
+ describe("deriveRepEmail", () => {
389
+ const domain = "texasenterprises.com";
390
+
391
+ it("derives first-initial + lastname for real people", () => {
392
+ expect(deriveRepEmail("Daniel", "Emblen", domain)).toBe(
393
+ "demblen@texasenterprises.com",
394
+ );
395
+ expect(deriveRepEmail("Sal", "Carrizales", domain)).toBe(
396
+ "scarrizales@texasenterprises.com",
397
+ );
398
+ expect(deriveRepEmail("Aaron", "Anderson", domain)).toBe(
399
+ "aanderson@texasenterprises.com",
400
+ );
401
+ });
402
+
403
+ it("strips hyphens and apostrophes from the last name", () => {
404
+ expect(deriveRepEmail("Mary", "O'Brien", domain)).toBe(
405
+ "mobrien@texasenterprises.com",
406
+ );
407
+ expect(deriveRepEmail("Jean", "Smith-Jones", domain)).toBe(
408
+ "jsmithjones@texasenterprises.com",
409
+ );
410
+ });
411
+
412
+ it("refuses house, intercompany, and placeholder rows", () => {
413
+ expect(deriveRepEmail("House", "Mighty", domain)).toBeNull();
414
+ expect(deriveRepEmail("House", "Golden West Laredo", domain)).toBeNull();
415
+ expect(deriveRepEmail("Hotsy Austin", "House", domain)).toBeNull();
416
+ expect(deriveRepEmail("Intercompany", "GWPP", domain)).toBeNull();
417
+ expect(deriveRepEmail("TBD", "TBD", domain)).toBeNull();
418
+ expect(deriveRepEmail("undefined", "undefined", domain)).toBeNull();
419
+ expect(deriveRepEmail("UNKNOWN", "SALES REP", domain)).toBeNull();
420
+ expect(deriveRepEmail("Buyback", "Unassigned", domain)).toBeNull();
421
+ expect(deriveRepEmail("Oil", "Hauling", domain)).toBeNull();
422
+ });
423
+
424
+ it("refuses names with digits or underscores", () => {
425
+ expect(deriveRepEmail("Chelsea", "Ervi_2", domain)).toBeNull();
426
+ expect(deriveRepEmail("Bob", "Loa2", domain)).toBeNull();
427
+ expect(deriveRepEmail(null, "Smith", domain)).toBeNull();
428
+ });
429
+ });
430
+
431
+ describe("mapOpportunityProduct (multiple products per opportunity)", () => {
432
+ const item = (overrides = {}) => ({
433
+ opportunityId: "opp_1",
434
+ index: 0,
435
+ brand: "MOBIL",
436
+ quantity: "6500",
437
+ amount: "50000",
438
+ ...overrides,
439
+ });
440
+
441
+ it("maps a line to product, quantity, currency micros, and its opportunity", () => {
442
+ const mapped = mapOpportunityProduct(item());
443
+ expect(mapped.sourceId).toBe("opportunity_item:opp_1#0");
444
+ expect(mapped.opportunitySourceId).toBe("opportunity:opp_1");
445
+ expect(mapped.input).toMatchObject({
446
+ name: "MOBIL",
447
+ product: "MOBIL",
448
+ quantity: 6500,
449
+ amount: { amountMicros: 50_000_000_000, currencyCode: "USD" },
450
+ isMobil: true,
451
+ lineNumber: 1,
452
+ });
453
+ });
454
+
455
+ it("gives each line on one opportunity a distinct, stable sourceId", () => {
456
+ const lines = [
457
+ mapOpportunityProduct(item({ index: 0, brand: "DEF" })),
458
+ mapOpportunityProduct(item({ index: 1, brand: "GOLDEN WEST" })),
459
+ mapOpportunityProduct(item({ index: 2, brand: "MOBIL" })),
460
+ ];
461
+ expect(lines.map((l) => l.sourceId)).toEqual([
462
+ "opportunity_item:opp_1#0",
463
+ "opportunity_item:opp_1#1",
464
+ "opportunity_item:opp_1#2",
465
+ ]);
466
+ expect(new Set(lines.map((l) => l.opportunitySourceId)).size).toBe(1);
467
+ expect(lines.map((l) => l.input.isMobil)).toEqual([false, false, true]);
468
+ expect(lines.map((l) => l.input.lineNumber)).toEqual([1, 2, 3]);
469
+ });
470
+
471
+ it("names an unbranded line by its position and flags unparseable numbers", () => {
472
+ const mapped = mapOpportunityProduct(
473
+ item({ brand: null, quantity: "n/a", amount: "" }),
474
+ );
475
+ expect(mapped.input.name).toBe("Line 1");
476
+ expect(mapped.input).not.toHaveProperty("product");
477
+ expect(mapped.input).not.toHaveProperty("quantity");
478
+ expect(mapped.input).not.toHaveProperty("amount");
479
+ expect(mapped.input.isMobil).toBe(false);
480
+ expect(mapped.warnings).toContain("unparseable line quantity n/a dropped");
481
+ });
482
+
483
+ it("treats MOBIL - CVL as Mobil, GOLDEN WEST as not", () => {
484
+ expect(isMobilBrand("MOBIL - CVL")).toBe(true);
485
+ expect(isMobilBrand("Mobil")).toBe(true);
486
+ expect(isMobilBrand("GOLDEN WEST")).toBe(false);
487
+ expect(isMobilBrand(null)).toBe(false);
488
+ });
489
+ });
@@ -0,0 +1,199 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { ensureMembers, type RepToProvision } from "../members-ensure";
4
+ import type { TwentyClient } from "../twenty-client";
5
+
6
+ function rep(overrides: Partial<RepToProvision> = {}): RepToProvision {
7
+ return {
8
+ repId: "rep_1",
9
+ email: "jane@tei.com",
10
+ firstName: "Jane",
11
+ lastName: "Doe",
12
+ archived: false,
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ function dataClientWithMembers(
18
+ members: Array<{ id: string; userEmail: string }>,
19
+ ): TwentyClient {
20
+ return {
21
+ requestWithRetry: vi.fn(async () => ({
22
+ workspaceMembers: {
23
+ edges: members.map((member) => ({ node: member })),
24
+ pageInfo: { hasNextPage: false, endCursor: null },
25
+ },
26
+ })),
27
+ endpoint: (path: string) => `https://crm.example.com${path}`,
28
+ } as unknown as TwentyClient;
29
+ }
30
+
31
+ describe("ensureMembers (dry run)", () => {
32
+ it("three reps, one already a member → one existing + two planned, map holds the existing", async () => {
33
+ const dataClient = dataClientWithMembers([
34
+ { id: "wm-1", userEmail: "Jane@TEI.com" },
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
+ });
48
+ 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);
53
+ });
54
+
55
+ it("reps without an email are unprovisionable and the run continues", async () => {
56
+ const dataClient = dataClientWithMembers([]);
57
+ const result = await ensureMembers({
58
+ dataClient,
59
+ adminClient: null,
60
+ reps: [
61
+ rep({ email: null }),
62
+ rep({ repId: "rep_2", email: "bob@tei.com" }),
63
+ ],
64
+ repPassword: "test",
65
+ workspaceId: null,
66
+ dryRun: true,
67
+ });
68
+ expect(result.report[0]).toMatchObject({
69
+ repId: "rep_1",
70
+ action: "unprovisionable",
71
+ });
72
+ expect(result.report[1]).toMatchObject({
73
+ repId: "rep_2",
74
+ action: "planned",
75
+ });
76
+ });
77
+
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.
82
+ const dataClient = dataClientWithMembers([
83
+ { id: "wm-jane", userEmail: "jane@tei.com" },
84
+ ]);
85
+ const result = await ensureMembers({
86
+ dataClient,
87
+ adminClient: null,
88
+ reps: [
89
+ rep(),
90
+ rep({ repId: "rep_2", firstName: "Employee", lastName: "Accounts" }),
91
+ ],
92
+ repPassword: "test",
93
+ workspaceId: null,
94
+ dryRun: true,
95
+ });
96
+ expect(result.ownerMap.get("rep_1")).toBe("wm-jane");
97
+ expect(result.ownerMap.get("rep_2")).toBe("wm-jane");
98
+ expect(result.report.find((row) => row.repId === "rep_2")).toMatchObject({
99
+ action: "merged-duplicate-email",
100
+ });
101
+ });
102
+ });
103
+
104
+ describe("ensureMembers (apply)", () => {
105
+ it("requires the admin client when new members must be provisioned", async () => {
106
+ 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
+ }
198
+ });
199
+ });