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.
- package/dist/{api-client-4VT3FWOH.js → api-client-JUBCQQDF.js} +1 -1
- package/dist/{chunk-H7AB42ES.js → chunk-STY44FQO.js} +13 -0
- package/dist/cli.js +1107 -10225
- package/dist/commands/enterprise/templates/deploy-repo/docs/runbook.md +3 -8
- package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +1 -184
- package/dist/drizzle/0050_brain_v0_entity_subtype.sql +2 -2
- package/dist/drizzle/0051_brain_v0_tenant_entity_pages.sql +6 -6
- package/dist/drizzle/0052_brain_v0_external_refs.sql +2 -2
- package/dist/drizzle/0053_brain_v0_unresolved_mentions_subtype.sql +2 -2
- package/dist/drizzle/0145_knowledge_graph_thread_ingest.sql +14 -14
- package/dist/drizzle/0146_knowledge_graph_source_scope.sql +1 -1
- package/dist/drizzle/0157_knowledge_graph_observations_source.sql +2 -2
- package/dist/drizzle/0158_wiki_tenant_scope.sql +1 -1
- package/dist/drizzle/0165_plugin_premium_entitlements.sql +2 -2
- package/dist/drizzle/{0166_company_brain_substrate_contract.sql → 0166_brain_substrate_contract.sql} +9 -9
- package/dist/drizzle/{0167_company_brain_artifact_manifest_runtime.sql → 0167_brain_artifact_manifest_runtime.sql} +5 -5
- package/dist/drizzle/0168_eval_skill_gate.sql +1 -1
- package/dist/drizzle/0183_okf_artifact_manifests.sql +1 -1
- package/dist/drizzle/0197_rcm_context_columns.sql +28 -0
- package/dist/drizzle/0200_eval_results_execution_tier.sql +40 -0
- package/dist/drizzle/0201_brain_dream_runs.sql +72 -0
- package/dist/drizzle/0202_backfill_thread_participants_from_senders.sql +110 -0
- package/dist/drizzle/0203_threads_mode_override.sql +61 -0
- package/dist/drizzle/0204_thread_turns_triggering_message_id.sql +48 -0
- package/dist/drizzle/0205_routines_git_python.sql +185 -0
- package/dist/drizzle/0206_agent_loop_versions_routine_actions.sql +40 -0
- package/dist/drizzle/0207_tenant_credentials_github_repo_kind.sql +52 -0
- package/dist/drizzle/0208_knowledge_graph_neutral_identifiers.sql +253 -0
- package/dist/drizzle/0209_living_artifacts_schema.sql +109 -0
- package/dist/drizzle/0210_think137_automations_additive_schema.sql +113 -0
- package/dist/drizzle/0211_backfill_agent_loop_versions_target_spec.sql +120 -0
- package/dist/drizzle/0212_fold_webhooks_into_automations.sql +312 -0
- package/dist/drizzle/0213_agent_loop_versions_judge_evidence_nullable.sql +63 -0
- package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +99 -0
- package/dist/drizzle/0215_agent_loop_versions_goal_worker_policy_nullable.sql +70 -0
- package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +81 -0
- package/dist/drizzle/0217_agents_capability_folder_dispatch.sql +12 -0
- package/dist/drizzle/0218_document_plates.sql +30 -0
- package/dist/drizzle/0219_document_section_waivers.sql +29 -0
- package/dist/drizzle/0220_artifacts_created_by_user.sql +21 -0
- package/dist/drizzle/0221_artifact_shares.sql +29 -0
- package/dist/drizzle/0221_workflow_interpreter_thin_slice.sql +71 -0
- package/dist/drizzle/0222_compliance_drainer_select_audit_events.sql +21 -0
- package/dist/drizzle/0223_document_conformance_reports.sql +38 -0
- package/dist/drizzle/0224_artifact_refresh_state.sql +11 -0
- package/dist/drizzle/0225_think216_agent_loop_convergence.sql +21 -0
- package/dist/drizzle/0226_drop_n8n_agent_step_runs.sql +6 -0
- package/dist/drizzle/0227_analyst_reader_role.sql +1055 -0
- package/dist/drizzle/0228_tenant_credentials_rds_iam_kind.sql +62 -0
- package/dist/drizzle/0229_analyst_reader_rds_iam_grant.sql +79 -0
- package/dist/drizzle/0230_analyst_rls.sql +1613 -0
- package/dist/drizzle/0231_cost_events_cache_write_enforcement_exempt.sql +11 -0
- package/dist/plugins/catalog/package.json +0 -1
- package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +0 -1
- package/dist/plugins/catalog/src/__tests__/catalog.test.ts +16 -8
- package/dist/plugins/catalog/src/__tests__/contracts.test.ts +56 -0
- package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +0 -2
- package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -5
- package/dist/plugins/catalog/src/contracts.ts +112 -0
- package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -6
- package/dist/plugins/n8n/README.md +4 -36
- package/dist/plugins/n8n/src/deployment/managed-app.ts +0 -17
- package/dist/plugins/n8n/src/manifest.ts +0 -5
- package/dist/plugins/n8n/terraform/n8n/main.tf +0 -26
- package/dist/plugins/n8n/terraform/n8n/outputs.tf +0 -5
- package/dist/plugins/n8n/terraform/n8n/variables.tf +0 -6
- package/dist/plugins/n8n/test/manifest.test.ts +0 -1
- package/dist/plugins/twenty/package.json +5 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +232 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +489 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +199 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +43 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +230 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/twenty-client.test.ts +207 -0
- package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +282 -0
- package/dist/plugins/twenty/scripts/lib/load-attachments.ts +220 -0
- package/dist/plugins/twenty/scripts/lib/load-records.ts +680 -0
- package/dist/plugins/twenty/scripts/lib/mappers.ts +559 -0
- package/dist/plugins/twenty/scripts/lib/members-ensure.ts +381 -0
- package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +191 -0
- package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +425 -0
- package/dist/plugins/twenty/scripts/lib/twenty-client.ts +246 -0
- package/dist/plugins/twenty/scripts/migrate-lastmile.ts +610 -0
- package/dist/plugins/twenty/scripts/provision-twenty-members.ts +214 -0
- package/dist/plugins/twenty/src/manifest.ts +99 -0
- package/dist/plugins/twenty/terraform/twenty/README.md +1 -1
- package/dist/plugins/twenty/test/manifest.test.ts +12 -2
- package/dist/terraform/examples/greenfield/main.tf +90 -380
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +0 -1
- package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
- package/dist/terraform/modules/app/agentcore-pi/main.tf +36 -32
- package/dist/terraform/modules/app/agentcore-pi/variables.tf +23 -15
- package/dist/terraform/modules/app/agentcore-runtime/main.tf +3 -3
- package/dist/terraform/modules/app/customer-domain/main.tf +16 -2
- package/dist/terraform/modules/app/deployment-control-plane/main.tf +5 -0
- package/dist/terraform/modules/app/deployment-control-plane/runner.py +85 -207
- package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +56 -2
- package/dist/terraform/modules/app/hindsight-memory/main.tf +52 -4
- package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +7 -0
- package/dist/terraform/modules/app/lambda-api/handlers.tf +493 -89
- package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +215 -96
- package/dist/terraform/modules/app/lambda-api/main.tf +24 -5
- package/dist/terraform/modules/app/lambda-api/outputs.tf +19 -11
- package/dist/terraform/modules/app/lambda-api/variables.tf +94 -67
- package/dist/terraform/modules/app/ses-email/main.tf +14 -2
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/main.tf +493 -0
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/outputs.tf +34 -0
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/variables.tf +32 -0
- package/dist/terraform/modules/data/aurora-postgres/main.tf +55 -0
- package/dist/terraform/modules/data/aurora-postgres/outputs.tf +15 -0
- package/dist/terraform/modules/data/s3-buckets/main.tf +26 -0
- package/dist/terraform/modules/thinkwork/README.md +0 -1
- package/dist/terraform/modules/thinkwork/main.tf +167 -446
- package/dist/terraform/modules/thinkwork/outputs.tf +2 -142
- package/dist/terraform/modules/thinkwork/variables.tf +55 -309
- package/dist/terraform/schema.graphql +4 -0
- package/dist/workspace-defaults/files/AGENTS.md +45 -31
- package/dist/workspace-defaults/files/CONTEXT.md +48 -4
- package/dist/workspace-defaults/files/GUARDRAILS.md +18 -0
- package/dist/workspace-defaults/files/MEMORY_GUIDE.md +32 -4
- package/dist/workspace-defaults/files/SPACE.md +34 -19
- package/dist/workspace-defaults/files/TOOLS.md +30 -0
- package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +154 -0
- package/dist/workspace-defaults/files/skills/document-composer/references/authoring-rules.md +101 -0
- package/package.json +2 -1
- package/dist/plugins/company-brain/README.md +0 -37
- package/dist/plugins/company-brain/package.json +0 -33
- package/dist/plugins/company-brain/runtime/cognee/Dockerfile +0 -11
- package/dist/plugins/company-brain/smoke/cognee-managed-app-smoke.mjs +0 -458
- package/dist/plugins/company-brain/smoke/cognee-memory-cutover-smoke.mjs +0 -539
- package/dist/plugins/company-brain/smoke/company-brain-context-engine-smoke.mjs +0 -312
- package/dist/plugins/company-brain/smoke/company-brain-operations-smoke.mjs +0 -449
- package/dist/plugins/company-brain/smoke/company-brain-plugin-smoke.mjs +0 -655
- package/dist/plugins/company-brain/smoke/hindsight-memory-isolation-smoke.mjs +0 -474
- package/dist/plugins/company-brain/src/api/cognee-client.ts +0 -896
- package/dist/plugins/company-brain/src/api/cognee-cluster-identity.ts +0 -49
- package/dist/plugins/company-brain/src/api/cognee-memory-scope.ts +0 -84
- package/dist/plugins/company-brain/src/api/context-engine-provider.ts +0 -888
- package/dist/plugins/company-brain/src/api/migration.ts +0 -665
- package/dist/plugins/company-brain/src/deployment/cognee-managed-app.ts +0 -396
- package/dist/plugins/company-brain/src/index.ts +0 -62
- package/dist/plugins/company-brain/src/manifest.ts +0 -112
- package/dist/plugins/company-brain/terraform/cognee/README.md +0 -263
- package/dist/plugins/company-brain/terraform/cognee/main.tf +0 -766
- package/dist/plugins/company-brain/terraform/cognee/outputs.tf +0 -144
- package/dist/plugins/company-brain/terraform/cognee/variables.tf +0 -402
- package/dist/plugins/company-brain/test/api/cognee-client.test.ts +0 -562
- package/dist/plugins/company-brain/test/api/cognee-memory-scope.test.ts +0 -74
- package/dist/plugins/company-brain/test/api/context-engine-provider.test.ts +0 -447
- package/dist/plugins/company-brain/test/api/migration.test.ts +0 -465
- package/dist/plugins/company-brain/test/manifest.test.ts +0 -95
- 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
|
+
}
|