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,282 @@
1
+ /**
2
+ * Read-only access to LastMile's dispatch Postgres for the Twenty migration.
3
+ *
4
+ * Per plan KTD8: plain `pg` connection from LASTMILE_DATABASE_URL with the
5
+ * session forced read-only and a statement timeout, following
6
+ * docs/solutions/security/analyst-external-postgres-role-provisioning-runbook-2026-07.md.
7
+ * Read queries never mutate LastMile.
8
+ *
9
+ * Entity shapes were read from the live `dispatch` database on 2026-07-09 (see
10
+ * the plan's Outstanding Questions resolutions): leads and opportunities are
11
+ * separate tables; CRM notes are `task_comment` rows on lead/opportunity tasks;
12
+ * the `note` table targets dispatch `customer` rows, which only reach Twenty
13
+ * through an exact-unique-name match to a CRM `account`.
14
+ */
15
+
16
+ import pg from "pg";
17
+
18
+ export interface LastmileRep {
19
+ id: string;
20
+ firstName: string | null;
21
+ lastName: string | null;
22
+ /** LastMile short username, e.g. "jbake" — owner refs sometimes use it. */
23
+ alias: string | null;
24
+ email: string | null;
25
+ archived: boolean;
26
+ userIsActive: boolean | null;
27
+ }
28
+
29
+ export interface LastmileAccount {
30
+ id: string;
31
+ name: string | null;
32
+ ownerRepId: string | null;
33
+ }
34
+
35
+ export interface LastmileContact {
36
+ id: string;
37
+ accountId: string | null;
38
+ firstName: string | null;
39
+ lastName: string | null;
40
+ email: string | null;
41
+ phone: string | null;
42
+ phoneCellular: string | null;
43
+ title: string | null;
44
+ }
45
+
46
+ export interface LastmileLead {
47
+ id: string;
48
+ status: string | null;
49
+ companyName: string | null;
50
+ firstName: string | null;
51
+ lastName: string | null;
52
+ email: string | null;
53
+ phone: string | null;
54
+ source: string | null;
55
+ description: string | null;
56
+ ownerRepId: string | null;
57
+ dateCreated: Date | null;
58
+ }
59
+
60
+ export interface LastmileOpportunity {
61
+ id: string;
62
+ name: string | null;
63
+ stage: string | null;
64
+ amount: string | null;
65
+ quantity: string | null;
66
+ productType: string | null;
67
+ brand: string | null;
68
+ closed: string | null;
69
+ won: string | null;
70
+ accountId: string | null;
71
+ ownerRepId: string | null;
72
+ expectedCloseDate: string | null;
73
+ description: string | null;
74
+ dateCreated: Date | null;
75
+ }
76
+
77
+ /**
78
+ * A product line on an opportunity. LastMile keeps them as a JSON array at
79
+ * `task.entity_data->'items'` on the opportunity's task row — 816 of 2,971
80
+ * opportunities have one, and 82 carry 2-5 lines. `brand` is the product
81
+ * ("MOBIL", "GOLDEN WEST", "DEF"); the opportunity row's own `product_type`
82
+ * is the coarser category ("Fuel", "Equipment").
83
+ */
84
+ export interface LastmileOpportunityItem {
85
+ /** Owning opportunity id (lead/opportunity entity_id on the task row). */
86
+ opportunityId: string;
87
+ /** Position within the items array — part of the line's stable identity. */
88
+ index: number;
89
+ brand: string | null;
90
+ quantity: string | null;
91
+ amount: string | null;
92
+ }
93
+
94
+ export interface LastmileCrmComment {
95
+ id: string;
96
+ entityType: "lead" | "opportunity";
97
+ entityId: string;
98
+ content: string | null;
99
+ isDeleted: boolean;
100
+ createdAt: Date | null;
101
+ }
102
+
103
+ export interface LastmileCrmAttachment {
104
+ id: string;
105
+ entityType: "lead" | "opportunity";
106
+ entityId: string;
107
+ filename: string | null;
108
+ filePath: string | null;
109
+ fileType: string | null;
110
+ bucketName: string | null;
111
+ }
112
+
113
+ export interface LastmileCustomerNote {
114
+ id: string;
115
+ noteText: string | null;
116
+ customerId: string;
117
+ customerName: string | null;
118
+ /** Set only when exactly one account matches the customer name. */
119
+ matchedAccountId: string | null;
120
+ dateCreated: Date | null;
121
+ }
122
+
123
+ export interface LastmileReader {
124
+ readReps(): Promise<LastmileRep[]>;
125
+ readAccounts(): Promise<LastmileAccount[]>;
126
+ readContacts(): Promise<LastmileContact[]>;
127
+ readLeads(): Promise<LastmileLead[]>;
128
+ readOpportunities(): Promise<LastmileOpportunity[]>;
129
+ readOpportunityItems(): Promise<LastmileOpportunityItem[]>;
130
+ readCrmComments(): Promise<LastmileCrmComment[]>;
131
+ readCrmAttachments(): Promise<LastmileCrmAttachment[]>;
132
+ readCustomerNotes(): Promise<LastmileCustomerNote[]>;
133
+ close(): Promise<void>;
134
+ }
135
+
136
+ export function createLastmileReader(databaseUrl: string): LastmileReader {
137
+ const pool = new pg.Pool({
138
+ connectionString: databaseUrl,
139
+ max: 2,
140
+ options: "-c default_transaction_read_only=on -c statement_timeout=60000",
141
+ });
142
+
143
+ async function rows<T>(sql: string): Promise<T[]> {
144
+ const result = await pool.query(sql);
145
+ return result.rows as T[];
146
+ }
147
+
148
+ return {
149
+ readReps: () =>
150
+ rows<LastmileRep>(`
151
+ select sr.id,
152
+ sr.first_name as "firstName",
153
+ sr.last_name as "lastName",
154
+ nullif(trim(sr.alias), '') as "alias",
155
+ lower(trim(coalesce(nullif(sr.email_address, ''), u.email))) as "email",
156
+ coalesce(sr.archived, false) as "archived",
157
+ u.is_active as "userIsActive"
158
+ from sales_rep sr
159
+ left join users u on u.id = sr.user_id
160
+ order by sr.id
161
+ `),
162
+ readAccounts: () =>
163
+ rows<LastmileAccount>(`
164
+ select id, nullif(trim(name), '') as "name", nullif(owner, '') as "ownerRepId"
165
+ from account
166
+ order by id
167
+ `),
168
+ readContacts: () =>
169
+ rows<LastmileContact>(`
170
+ select id,
171
+ nullif(account_id, '') as "accountId",
172
+ nullif(trim(first_name), '') as "firstName",
173
+ nullif(trim(last_name), '') as "lastName",
174
+ nullif(trim(email_address), '') as "email",
175
+ nullif(trim(phone), '') as "phone",
176
+ nullif(trim(phone_cellular), '') as "phoneCellular",
177
+ nullif(trim(title), '') as "title"
178
+ from contact
179
+ order by id
180
+ `),
181
+ readLeads: () =>
182
+ rows<LastmileLead>(`
183
+ select id,
184
+ nullif(trim(status), '') as "status",
185
+ nullif(trim(company), '') as "companyName",
186
+ nullif(trim(first_name), '') as "firstName",
187
+ nullif(trim(last_name), '') as "lastName",
188
+ nullif(trim(email), '') as "email",
189
+ nullif(trim(phone), '') as "phone",
190
+ nullif(trim(source), '') as "source",
191
+ nullif(trim(description), '') as "description",
192
+ coalesce(nullif(sales_rep_id, ''), nullif(owner, '')) as "ownerRepId",
193
+ date_created as "dateCreated"
194
+ from lead
195
+ order by id
196
+ `),
197
+ readOpportunities: () =>
198
+ rows<LastmileOpportunity>(`
199
+ select id,
200
+ coalesce(nullif(trim(opp_name), ''), nullif(trim(description), '')) as "name",
201
+ nullif(trim(stage), '') as "stage",
202
+ amount::text as "amount",
203
+ nullif(trim(quantity), '') as "quantity",
204
+ nullif(trim(product_type), '') as "productType",
205
+ nullif(trim(brand), '') as "brand",
206
+ nullif(trim(closed), '') as "closed",
207
+ nullif(trim(won), '') as "won",
208
+ nullif(account_id, '') as "accountId",
209
+ coalesce(nullif(sales_rep_id, ''), null) as "ownerRepId",
210
+ expected_close_date::text as "expectedCloseDate",
211
+ nullif(trim(description), '') as "description",
212
+ date_created as "dateCreated"
213
+ from opportunity
214
+ order by id
215
+ `),
216
+ readOpportunityItems: () =>
217
+ rows<LastmileOpportunityItem>(`
218
+ select t.entity_id as "opportunityId",
219
+ (it.ordinality - 1)::int as "index",
220
+ nullif(trim(it.value ->> 'brand'), '') as "brand",
221
+ nullif(trim(it.value ->> 'quantity'), '') as "quantity",
222
+ nullif(trim(it.value ->> 'amount'), '') as "amount"
223
+ from task t
224
+ cross join lateral jsonb_array_elements(t.entity_data -> 'items')
225
+ with ordinality as it(value, ordinality)
226
+ where t.entity_type = 'opportunity'
227
+ and t.entity_id is not null
228
+ and t.entity_data ? 'items'
229
+ and jsonb_typeof(t.entity_data -> 'items') = 'array'
230
+ order by t.entity_id, it.ordinality
231
+ `),
232
+ readCrmComments: () =>
233
+ rows<LastmileCrmComment>(`
234
+ select tc.id,
235
+ t.entity_type as "entityType",
236
+ t.entity_id as "entityId",
237
+ nullif(trim(tc.content), '') as "content",
238
+ coalesce(tc.is_deleted, false) as "isDeleted",
239
+ tc.created_at as "createdAt"
240
+ from task_comment tc
241
+ join task t on t.id = tc.task_id
242
+ where t.entity_type in ('lead', 'opportunity')
243
+ and t.entity_id is not null
244
+ order by tc.id
245
+ `),
246
+ readCrmAttachments: () =>
247
+ rows<LastmileCrmAttachment>(`
248
+ select ta.id,
249
+ t.entity_type as "entityType",
250
+ t.entity_id as "entityId",
251
+ nullif(trim(ta.filename), '') as "filename",
252
+ nullif(trim(ta.file_path), '') as "filePath",
253
+ nullif(trim(ta.file_type), '') as "fileType",
254
+ nullif(trim(ta.bucket_name), '') as "bucketName"
255
+ from task_attachment ta
256
+ join task t on t.id = ta.task_id
257
+ where t.entity_type in ('lead', 'opportunity')
258
+ and t.entity_id is not null
259
+ order by ta.id
260
+ `),
261
+ readCustomerNotes: () =>
262
+ rows<LastmileCustomerNote>(`
263
+ with account_names as (
264
+ select lower(trim(name)) as norm_name, min(id) as account_id, count(*) as n
265
+ from account
266
+ where nullif(trim(name), '') is not null
267
+ group by lower(trim(name))
268
+ )
269
+ select n.id,
270
+ nullif(trim(n.note_text), '') as "noteText",
271
+ c.id as "customerId",
272
+ nullif(trim(c.name), '') as "customerName",
273
+ case when an.n = 1 then an.account_id else null end as "matchedAccountId",
274
+ n.date_created as "dateCreated"
275
+ from note n
276
+ join customer c on c.id = n.owner_id
277
+ left join account_names an on an.norm_name = lower(trim(c.name))
278
+ order by n.id
279
+ `),
280
+ close: () => pool.end(),
281
+ };
282
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * CRM attachment migration (plan U6, KTD6): query the Attachment record by
3
+ * sourceId BEFORE uploading (never re-upload orphaned binaries on re-runs);
4
+ * on a miss, fetch the binary from LastMile's S3 bucket, upload via Twenty's
5
+ * multipart `uploadFile` mutation, then create the Attachment record with the
6
+ * file reference and the direct target*Id FK. Missing binaries migrate
7
+ * nothing for that file and are listed in the report.
8
+ *
9
+ * Only a handful of LastMile attachments target CRM records (the 2026-07-09
10
+ * schema read found the other ~12.5k attach to dispatch bills-of-lading and
11
+ * loadsheets, which do not migrate).
12
+ */
13
+
14
+ import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
15
+
16
+ import type { LastmileCrmAttachment } from "./lastmile-reader";
17
+ import { fetchExistingBySourceIds, type EntityCounters } from "./load-records";
18
+ import { sourceId as makeSourceId } from "./mappers";
19
+ import { TwentyClient, TwentyGraphqlError } from "./twenty-client";
20
+
21
+ const ATTACHMENT_ENTITY = {
22
+ singular: "attachment",
23
+ plural: "attachments",
24
+ capSingular: "Attachment",
25
+ capPlural: "Attachments",
26
+ };
27
+
28
+ export interface AttachmentUploadDeps {
29
+ /** Fetches the binary; null when the object is missing. Defaults to S3. */
30
+ fetchBinary?: (
31
+ attachment: LastmileCrmAttachment,
32
+ ) => Promise<Uint8Array | null>;
33
+ fetchImpl?: typeof fetch;
34
+ }
35
+
36
+ export async function loadCrmAttachments(options: {
37
+ client: TwentyClient;
38
+ authToken: string;
39
+ baseUrl: string;
40
+ attachments: LastmileCrmAttachment[];
41
+ /** target sourceId ("lead:…" or "opportunity:…") → Twenty opportunity id. */
42
+ targetIdBySourceId: ReadonlyMap<string, string>;
43
+ dryRun: boolean;
44
+ counters: EntityCounters;
45
+ deps?: AttachmentUploadDeps;
46
+ }): Promise<void> {
47
+ const { client, attachments, targetIdBySourceId, dryRun, counters } = options;
48
+ counters.sourceTotal += attachments.length;
49
+ const fetchBinary = options.deps?.fetchBinary ?? makeS3BinaryFetcher();
50
+ const fetchImpl = options.deps?.fetchImpl ?? fetch;
51
+
52
+ const bySourceId = new Map(
53
+ attachments.map((attachment) => [
54
+ makeSourceId("task_attachment", attachment.id),
55
+ attachment,
56
+ ]),
57
+ );
58
+ const existing = await fetchExistingBySourceIds(client, ATTACHMENT_ENTITY, [
59
+ ...bySourceId.keys(),
60
+ ]);
61
+
62
+ for (const [attachmentSourceId, attachment] of bySourceId) {
63
+ if (existing.has(attachmentSourceId)) {
64
+ counters.skipped += 1; // Crash-healing: record exists → no re-upload.
65
+ continue;
66
+ }
67
+ const targetSourceId = makeSourceId(
68
+ attachment.entityType,
69
+ attachment.entityId,
70
+ );
71
+ const targetId = targetIdBySourceId.get(targetSourceId);
72
+ if (!targetId) {
73
+ counters.skipped += 1;
74
+ counters.gaps.push(
75
+ `attachment ${attachmentSourceId}: target ${targetSourceId} not migrated`,
76
+ );
77
+ continue;
78
+ }
79
+ if (dryRun) {
80
+ counters.created += 1;
81
+ counters.plannedMutations.push(
82
+ `upload+create attachment ${attachmentSourceId} → ${targetSourceId}`,
83
+ );
84
+ continue;
85
+ }
86
+
87
+ try {
88
+ const binary = await fetchBinary(attachment);
89
+ if (!binary) {
90
+ counters.gaps.push(
91
+ `attachment ${attachmentSourceId}: binary missing (${attachment.bucketName ?? "?"}/${attachment.filePath ?? "?"})`,
92
+ );
93
+ continue;
94
+ }
95
+ const fileId = await uploadBinary({
96
+ baseUrl: options.baseUrl,
97
+ authToken: options.authToken,
98
+ filename: attachment.filename ?? "attachment",
99
+ contentType: attachment.fileType ?? "application/octet-stream",
100
+ binary,
101
+ fetchImpl,
102
+ retries: 1, // Upload failure retries once, then reports and continues (U6).
103
+ });
104
+ await client.requestOnce(
105
+ "/graphql",
106
+ `mutation MigrationCreateAttachments($data: [AttachmentCreateInput!]!) {
107
+ createAttachments(data: $data) { id }
108
+ }`,
109
+ {
110
+ data: [
111
+ {
112
+ name: attachment.filename ?? "attachment",
113
+ file: { fileId, label: attachment.filename ?? "attachment" },
114
+ targetOpportunityId: targetId,
115
+ sourceId: attachmentSourceId,
116
+ },
117
+ ],
118
+ },
119
+ );
120
+ counters.created += 1;
121
+ } catch (error) {
122
+ counters.failed += 1;
123
+ counters.gaps.push(
124
+ `attachment ${attachmentSourceId} failed: ${
125
+ error instanceof Error ? error.message.slice(0, 300) : String(error)
126
+ }`,
127
+ );
128
+ }
129
+ }
130
+ }
131
+
132
+ function makeS3BinaryFetcher(): (
133
+ attachment: LastmileCrmAttachment,
134
+ ) => Promise<Uint8Array | null> {
135
+ const s3 = new S3Client({});
136
+ return async (attachment) => {
137
+ if (!attachment.bucketName || !attachment.filePath) return null;
138
+ try {
139
+ const result = await s3.send(
140
+ new GetObjectCommand({
141
+ Bucket: attachment.bucketName,
142
+ Key: attachment.filePath,
143
+ }),
144
+ );
145
+ const bytes = await result.Body?.transformToByteArray();
146
+ return bytes ?? null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ };
151
+ }
152
+
153
+ /**
154
+ * GraphQL multipart upload (graphql-multipart-request-spec) of a single file
155
+ * via Twenty's `uploadFile` mutation. Returns the file id for FileItemInput.
156
+ */
157
+ async function uploadBinary(options: {
158
+ baseUrl: string;
159
+ authToken: string;
160
+ filename: string;
161
+ contentType: string;
162
+ binary: Uint8Array;
163
+ fetchImpl: typeof fetch;
164
+ retries: number;
165
+ }): Promise<string> {
166
+ let lastError: unknown;
167
+ for (let attempt = 0; attempt <= options.retries; attempt += 1) {
168
+ try {
169
+ const form = new FormData();
170
+ form.append(
171
+ "operations",
172
+ JSON.stringify({
173
+ query: `mutation MigrationUpload($file: Upload!) {
174
+ uploadFile(file: $file, fileFolder: Attachment) { path token }
175
+ }`,
176
+ variables: { file: null },
177
+ }),
178
+ );
179
+ form.append("map", JSON.stringify({ "0": ["variables.file"] }));
180
+ form.append(
181
+ "0",
182
+ new Blob([options.binary as BlobPart], { type: options.contentType }),
183
+ options.filename,
184
+ );
185
+ const response = await options.fetchImpl(`${options.baseUrl}/graphql`, {
186
+ method: "POST",
187
+ headers: {
188
+ authorization: `Bearer ${options.authToken}`,
189
+ "apollo-require-preflight": "true",
190
+ },
191
+ body: form,
192
+ });
193
+ const body = (await response.json()) as {
194
+ data?: { uploadFile?: { path?: string; token?: string } };
195
+ errors?: Array<{ message: string }>;
196
+ };
197
+ if (body.errors?.length) {
198
+ throw new TwentyGraphqlError(
199
+ `uploadFile failed: ${JSON.stringify(body.errors)}`,
200
+ {
201
+ errors: body.errors,
202
+ },
203
+ );
204
+ }
205
+ const path = body.data?.uploadFile?.path;
206
+ if (!path) throw new Error("uploadFile returned no path");
207
+ // Twenty file paths embed the file id as the first path segment under
208
+ // the folder: e.g. "attachment/<uuid>/original/<name>". Extract the uuid.
209
+ const match = path.match(
210
+ /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i,
211
+ );
212
+ if (!match)
213
+ throw new Error(`could not extract file id from upload path: ${path}`);
214
+ return match[1];
215
+ } catch (error) {
216
+ lastError = error;
217
+ }
218
+ }
219
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
220
+ }