thinkwork-cli 0.12.16 → 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 (149) hide show
  1. package/dist/cli.js +517 -10403
  2. package/dist/commands/enterprise/templates/deploy-repo/docs/runbook.md +3 -8
  3. package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +1 -184
  4. package/dist/drizzle/0050_brain_v0_entity_subtype.sql +2 -2
  5. package/dist/drizzle/0051_brain_v0_tenant_entity_pages.sql +6 -6
  6. package/dist/drizzle/0052_brain_v0_external_refs.sql +2 -2
  7. package/dist/drizzle/0053_brain_v0_unresolved_mentions_subtype.sql +2 -2
  8. package/dist/drizzle/0145_knowledge_graph_thread_ingest.sql +14 -14
  9. package/dist/drizzle/0146_knowledge_graph_source_scope.sql +1 -1
  10. package/dist/drizzle/0157_knowledge_graph_observations_source.sql +2 -2
  11. package/dist/drizzle/0158_wiki_tenant_scope.sql +1 -1
  12. package/dist/drizzle/0165_plugin_premium_entitlements.sql +2 -2
  13. package/dist/drizzle/{0166_company_brain_substrate_contract.sql → 0166_brain_substrate_contract.sql} +9 -9
  14. package/dist/drizzle/{0167_company_brain_artifact_manifest_runtime.sql → 0167_brain_artifact_manifest_runtime.sql} +5 -5
  15. package/dist/drizzle/0168_eval_skill_gate.sql +1 -1
  16. package/dist/drizzle/0183_okf_artifact_manifests.sql +1 -1
  17. package/dist/drizzle/0200_eval_results_execution_tier.sql +40 -0
  18. package/dist/drizzle/0201_brain_dream_runs.sql +72 -0
  19. package/dist/drizzle/0202_backfill_thread_participants_from_senders.sql +110 -0
  20. package/dist/drizzle/0203_threads_mode_override.sql +61 -0
  21. package/dist/drizzle/0204_thread_turns_triggering_message_id.sql +48 -0
  22. package/dist/drizzle/0205_routines_git_python.sql +185 -0
  23. package/dist/drizzle/0206_agent_loop_versions_routine_actions.sql +40 -0
  24. package/dist/drizzle/0207_tenant_credentials_github_repo_kind.sql +52 -0
  25. package/dist/drizzle/0208_knowledge_graph_neutral_identifiers.sql +253 -0
  26. package/dist/drizzle/0209_living_artifacts_schema.sql +109 -0
  27. package/dist/drizzle/0210_think137_automations_additive_schema.sql +113 -0
  28. package/dist/drizzle/0211_backfill_agent_loop_versions_target_spec.sql +120 -0
  29. package/dist/drizzle/0212_fold_webhooks_into_automations.sql +312 -0
  30. package/dist/drizzle/0213_agent_loop_versions_judge_evidence_nullable.sql +63 -0
  31. package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +99 -0
  32. package/dist/drizzle/0215_agent_loop_versions_goal_worker_policy_nullable.sql +70 -0
  33. package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +81 -0
  34. package/dist/drizzle/0217_agents_capability_folder_dispatch.sql +12 -0
  35. package/dist/drizzle/0218_document_plates.sql +30 -0
  36. package/dist/drizzle/0219_document_section_waivers.sql +29 -0
  37. package/dist/drizzle/0220_artifacts_created_by_user.sql +21 -0
  38. package/dist/drizzle/0221_artifact_shares.sql +29 -0
  39. package/dist/drizzle/0221_workflow_interpreter_thin_slice.sql +71 -0
  40. package/dist/drizzle/0222_compliance_drainer_select_audit_events.sql +21 -0
  41. package/dist/drizzle/0223_document_conformance_reports.sql +38 -0
  42. package/dist/drizzle/0224_artifact_refresh_state.sql +11 -0
  43. package/dist/drizzle/0225_think216_agent_loop_convergence.sql +21 -0
  44. package/dist/drizzle/0226_drop_n8n_agent_step_runs.sql +6 -0
  45. package/dist/drizzle/0227_analyst_reader_role.sql +1055 -0
  46. package/dist/drizzle/0228_tenant_credentials_rds_iam_kind.sql +62 -0
  47. package/dist/drizzle/0229_analyst_reader_rds_iam_grant.sql +79 -0
  48. package/dist/drizzle/0230_analyst_rls.sql +1613 -0
  49. package/dist/drizzle/0231_cost_events_cache_write_enforcement_exempt.sql +11 -0
  50. package/dist/plugins/catalog/package.json +0 -1
  51. package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +0 -1
  52. package/dist/plugins/catalog/src/__tests__/catalog.test.ts +16 -8
  53. package/dist/plugins/catalog/src/__tests__/contracts.test.ts +56 -0
  54. package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +0 -2
  55. package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -5
  56. package/dist/plugins/catalog/src/contracts.ts +112 -0
  57. package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -6
  58. package/dist/plugins/n8n/README.md +4 -36
  59. package/dist/plugins/n8n/src/deployment/managed-app.ts +0 -17
  60. package/dist/plugins/n8n/src/manifest.ts +0 -5
  61. package/dist/plugins/n8n/terraform/n8n/main.tf +0 -26
  62. package/dist/plugins/n8n/terraform/n8n/outputs.tf +0 -5
  63. package/dist/plugins/n8n/terraform/n8n/variables.tf +0 -6
  64. package/dist/plugins/n8n/test/manifest.test.ts +0 -1
  65. package/dist/plugins/twenty/package.json +5 -0
  66. package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +232 -0
  67. package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +489 -0
  68. package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +199 -0
  69. package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +43 -0
  70. package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +230 -0
  71. package/dist/plugins/twenty/scripts/lib/__tests__/twenty-client.test.ts +207 -0
  72. package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +282 -0
  73. package/dist/plugins/twenty/scripts/lib/load-attachments.ts +220 -0
  74. package/dist/plugins/twenty/scripts/lib/load-records.ts +680 -0
  75. package/dist/plugins/twenty/scripts/lib/mappers.ts +559 -0
  76. package/dist/plugins/twenty/scripts/lib/members-ensure.ts +381 -0
  77. package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +191 -0
  78. package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +425 -0
  79. package/dist/plugins/twenty/scripts/lib/twenty-client.ts +246 -0
  80. package/dist/plugins/twenty/scripts/migrate-lastmile.ts +610 -0
  81. package/dist/plugins/twenty/scripts/provision-twenty-members.ts +214 -0
  82. package/dist/plugins/twenty/src/manifest.ts +99 -0
  83. package/dist/plugins/twenty/terraform/twenty/README.md +1 -1
  84. package/dist/plugins/twenty/test/manifest.test.ts +12 -2
  85. package/dist/terraform/examples/greenfield/main.tf +90 -380
  86. package/dist/terraform/examples/greenfield/terraform.tfvars.example +0 -1
  87. package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
  88. package/dist/terraform/modules/app/agentcore-pi/main.tf +29 -38
  89. package/dist/terraform/modules/app/agentcore-pi/variables.tf +23 -15
  90. package/dist/terraform/modules/app/agentcore-runtime/main.tf +3 -3
  91. package/dist/terraform/modules/app/customer-domain/main.tf +16 -2
  92. package/dist/terraform/modules/app/deployment-control-plane/main.tf +5 -0
  93. package/dist/terraform/modules/app/deployment-control-plane/runner.py +85 -207
  94. package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +56 -2
  95. package/dist/terraform/modules/app/hindsight-memory/main.tf +52 -4
  96. package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +7 -0
  97. package/dist/terraform/modules/app/lambda-api/handlers.tf +493 -89
  98. package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +215 -96
  99. package/dist/terraform/modules/app/lambda-api/main.tf +24 -5
  100. package/dist/terraform/modules/app/lambda-api/outputs.tf +19 -11
  101. package/dist/terraform/modules/app/lambda-api/variables.tf +94 -67
  102. package/dist/terraform/modules/app/ses-email/main.tf +14 -2
  103. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/main.tf +493 -0
  104. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/outputs.tf +34 -0
  105. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/variables.tf +32 -0
  106. package/dist/terraform/modules/data/aurora-postgres/main.tf +55 -0
  107. package/dist/terraform/modules/data/aurora-postgres/outputs.tf +15 -0
  108. package/dist/terraform/modules/data/s3-buckets/main.tf +26 -0
  109. package/dist/terraform/modules/thinkwork/README.md +0 -1
  110. package/dist/terraform/modules/thinkwork/main.tf +167 -446
  111. package/dist/terraform/modules/thinkwork/outputs.tf +2 -142
  112. package/dist/terraform/modules/thinkwork/variables.tf +55 -309
  113. package/dist/terraform/schema.graphql +4 -0
  114. package/dist/workspace-defaults/files/AGENTS.md +45 -31
  115. package/dist/workspace-defaults/files/CONTEXT.md +48 -4
  116. package/dist/workspace-defaults/files/GUARDRAILS.md +18 -0
  117. package/dist/workspace-defaults/files/MEMORY_GUIDE.md +32 -4
  118. package/dist/workspace-defaults/files/SPACE.md +34 -19
  119. package/dist/workspace-defaults/files/TOOLS.md +30 -0
  120. package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +154 -0
  121. package/dist/workspace-defaults/files/skills/document-composer/references/authoring-rules.md +101 -0
  122. package/package.json +1 -1
  123. package/dist/plugins/company-brain/README.md +0 -37
  124. package/dist/plugins/company-brain/package.json +0 -33
  125. package/dist/plugins/company-brain/runtime/cognee/Dockerfile +0 -11
  126. package/dist/plugins/company-brain/smoke/cognee-managed-app-smoke.mjs +0 -458
  127. package/dist/plugins/company-brain/smoke/cognee-memory-cutover-smoke.mjs +0 -539
  128. package/dist/plugins/company-brain/smoke/company-brain-context-engine-smoke.mjs +0 -312
  129. package/dist/plugins/company-brain/smoke/company-brain-operations-smoke.mjs +0 -449
  130. package/dist/plugins/company-brain/smoke/company-brain-plugin-smoke.mjs +0 -655
  131. package/dist/plugins/company-brain/smoke/hindsight-memory-isolation-smoke.mjs +0 -474
  132. package/dist/plugins/company-brain/src/api/cognee-client.ts +0 -896
  133. package/dist/plugins/company-brain/src/api/cognee-cluster-identity.ts +0 -49
  134. package/dist/plugins/company-brain/src/api/cognee-memory-scope.ts +0 -84
  135. package/dist/plugins/company-brain/src/api/context-engine-provider.ts +0 -888
  136. package/dist/plugins/company-brain/src/api/migration.ts +0 -665
  137. package/dist/plugins/company-brain/src/deployment/cognee-managed-app.ts +0 -396
  138. package/dist/plugins/company-brain/src/index.ts +0 -62
  139. package/dist/plugins/company-brain/src/manifest.ts +0 -112
  140. package/dist/plugins/company-brain/terraform/cognee/README.md +0 -263
  141. package/dist/plugins/company-brain/terraform/cognee/main.tf +0 -766
  142. package/dist/plugins/company-brain/terraform/cognee/outputs.tf +0 -144
  143. package/dist/plugins/company-brain/terraform/cognee/variables.tf +0 -402
  144. package/dist/plugins/company-brain/test/api/cognee-client.test.ts +0 -562
  145. package/dist/plugins/company-brain/test/api/cognee-memory-scope.test.ts +0 -74
  146. package/dist/plugins/company-brain/test/api/context-engine-provider.test.ts +0 -447
  147. package/dist/plugins/company-brain/test/api/migration.test.ts +0 -465
  148. package/dist/plugins/company-brain/test/manifest.test.ts +0 -95
  149. 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
+ });