thinkwork-cli 0.13.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -14
- package/dist/{api-client-JYZXYQJR.js → api-client-4P4JZOBX.js} +1 -1
- package/dist/{chunk-Z2F6RJFR.js → chunk-4OMDWYZQ.js} +4 -5
- package/dist/{chunk-ILZKG5WV.js → chunk-AD2NIWB5.js} +434 -69
- package/dist/chunk-MWQ7RO76.js +137 -0
- package/dist/{chunk-DVPBI5HM.js → chunk-OLUQ4U2Z.js} +26 -26
- package/dist/{chunk-QOERT44A.js → chunk-YQCYD6NE.js} +57 -44
- package/dist/cli.js +4400 -16431
- package/dist/commands/enterprise/templates/deploy-repo/scripts/apply-release.mjs +0 -1
- package/dist/commands/enterprise/templates/deploy-repo/scripts/smoke.mjs +0 -1
- package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +0 -14
- package/dist/{dist-es-CRV46D5B.js → dist-es-2OTNTL4N.js} +4 -4
- package/dist/{dist-es-ENBUC3JP.js → dist-es-2XVAH4BQ.js} +2 -2
- package/dist/{dist-es-DCY4IC3Y.js → dist-es-3DPDUJAH.js} +2 -2
- package/dist/{dist-es-G7PTG75M.js → dist-es-BNZTBBVC.js} +1 -1
- package/dist/{dist-es-GKDBE7E6.js → dist-es-CD2RBDAC.js} +1 -1
- package/dist/{dist-es-R7FGL2IB.js → dist-es-I4FW57PS.js} +1 -1
- package/dist/{dist-es-USORBXZ5.js → dist-es-KVFWJNDY.js} +4 -4
- package/dist/{dist-es-HY3535OI.js → dist-es-PS73DWNL.js} +2 -2
- package/dist/{dist-es-LDE437AM.js → dist-es-S3NZQFGT.js} +1 -1
- package/dist/{dist-es-G54LBMRN.js → dist-es-SGCA4P32.js} +1 -1
- package/dist/{dist-es-Q57SZXZ7.js → dist-es-WBM6J4RA.js} +2 -2
- package/dist/{dist-es-P26FWFLT.js → dist-es-ZGVADYIF.js} +8 -8
- package/dist/drizzle/0227_analyst_reader_role.sql +17 -26
- package/dist/drizzle/0230_analyst_rls.sql +28 -45
- package/dist/drizzle/0261_native_auth_control_plane.sql +7 -3
- package/dist/drizzle/0270_tool_execution_ledger.sql +115 -0
- package/dist/drizzle/0271_ontology_twin_declarations.sql +47 -0
- package/dist/drizzle/0272_identity_graph_projection_cursor.sql +23 -0
- package/dist/drizzle/0273_twin_materialization_suggestions.sql +29 -0
- package/dist/drizzle/0274_tenant_mcp_twin_keys.sql +59 -0
- package/dist/drizzle/0275_identity_bulk_rebuild_fence.sql +30 -0
- package/dist/drizzle/0276_tool_execution_ledger_allow_cascade_delete.sql +18 -0
- package/dist/drizzle/0277_knowledge_base_sources.sql +107 -0
- package/dist/drizzle/0278_kb_page_transcription.sql +55 -0
- package/dist/drizzle/0279_mcp_servers_off_plugin_provenance.sql +93 -0
- package/dist/drizzle/0280_work_item_external_refs_drop_plane_provider.sql +46 -0
- package/dist/drizzle/0281_tenant_mcp_twin_keys_expiry_suffix.sql +9 -0
- package/dist/drizzle/0282_tenant_mcp_twin_keys_grants.sql +24 -0
- package/dist/drizzle/0283_agents_agentcore_runtime_dispatch.sql +13 -0
- package/dist/drizzle/0284_user_brain_claims.sql +69 -0
- package/dist/drizzle/0285_tenant_mcp_twin_keys_trusted_subsystem.sql +30 -0
- package/dist/drizzle/0286_brain_analytics_key.sql +26 -0
- package/dist/drizzle/0287_brain_subject.sql +25 -0
- package/dist/drizzle/0288_multi_lane_identities.sql +25 -0
- package/dist/{loadSso-DJVB7BXE.js → loadSso-ETCXTR4I.js} +2 -2
- package/dist/{loadSso-JAMNA3WY.js → loadSso-UBD7KUHE.js} +2 -2
- package/dist/plugins/README.md +2 -2
- package/dist/plugins/catalog/package.json +0 -3
- package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +6 -3
- package/dist/plugins/catalog/src/__tests__/catalog.test.ts +21 -21
- package/dist/plugins/catalog/src/__tests__/contracts.test.ts +102 -25
- package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +7 -6
- package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -15
- package/dist/plugins/catalog/src/contracts.ts +15 -0
- package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -18
- package/dist/plugins/n8n/test/integrated-app-contract.test.ts +1 -1
- package/dist/plugins/twenty/package.json +2 -1
- package/dist/plugins/twenty/scripts/LAMBDA_DEPLOY.md +114 -0
- package/dist/plugins/twenty/scripts/build-lambda.sh +29 -0
- package/dist/plugins/twenty/scripts/configure-product-views.ts +195 -0
- package/dist/plugins/twenty/scripts/lambda-handler.ts +49 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/load-attachments.test.ts +38 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +145 -2
- package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +397 -151
- package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +28 -129
- package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +10 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +13 -8
- package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +222 -2
- package/dist/plugins/twenty/scripts/lib/load-attachments.ts +29 -0
- package/dist/plugins/twenty/scripts/lib/load-records.ts +160 -43
- package/dist/plugins/twenty/scripts/lib/mappers.ts +368 -152
- package/dist/plugins/twenty/scripts/lib/members-ensure.ts +43 -268
- package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +57 -0
- package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +136 -31
- package/dist/plugins/twenty/scripts/migrate-lastmile.ts +254 -152
- package/dist/plugins/twenty/scripts/provision-twenty-members.ts +44 -1
- package/dist/plugins/twenty/scripts/purge-lastmile-import.ts +178 -0
- package/dist/plugins/twenty/scripts/restructure-products.ts +312 -0
- package/dist/plugins/twenty/scripts/retag-stage-values.ts +362 -0
- package/dist/plugins/twenty/test/manifest.test.ts +2 -4
- package/dist/scripts/post-deploy.sh +79 -1
- package/dist/{signin-UGDWTGS4.js → signin-5QZNRSOO.js} +2 -2
- package/dist/{sso-oidc-Q4VXANXY.js → sso-oidc-XK673P54.js} +2 -2
- package/dist/{sts-CWLSE3GH.js → sts-OXI2UALF.js} +6 -134
- package/dist/{sts-DNZLNVOV.js → sts-SQW4NBK3.js} +6 -134
- package/dist/terraform/examples/greenfield/main.tf +233 -175
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +12 -8
- package/dist/terraform/modules/app/agentcore-admin/main.tf +47 -0
- package/dist/terraform/modules/app/agentcore-code-interpreter/README.md +39 -47
- package/dist/terraform/modules/app/agentcore-code-interpreter/main.tf +13 -66
- package/dist/terraform/modules/app/agentcore-identity/main.tf +106 -26
- package/dist/terraform/modules/app/agentcore-identity/outputs.tf +6 -6
- package/dist/terraform/modules/app/agentcore-identity/scripts/bootstrap_twenty_oauth_client.sh +216 -99
- package/dist/terraform/modules/app/agentcore-identity/scripts/delete_identity.sh +6 -9
- package/dist/terraform/modules/app/agentcore-identity/scripts/delete_workload_identity.sh +19 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/ensure_workload_identity.sh +29 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/read_identity.sh +22 -7
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_identity.sh +17 -46
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_provider.mjs +65 -3
- package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/after.tf +15 -0
- package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/before.tf +7 -0
- package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/identity-after.tf +8 -0
- package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/identity-before.tf +9 -0
- package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/thinkwork-after.tf +3 -0
- package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/thinkwork-before.tf +3 -0
- package/dist/terraform/modules/app/agentcore-identity/variables.tf +6 -0
- package/dist/terraform/modules/app/agentcore-memory/README.md +46 -9
- package/dist/terraform/modules/app/agentcore-memory/main.tf +27 -10
- package/dist/terraform/modules/app/agentcore-memory/scripts/create_or_find_memory.sh +156 -24
- package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
- package/dist/terraform/modules/app/agentcore-pi/main.tf +42 -52
- package/dist/terraform/modules/app/agentcore-pi/outputs.tf +3 -3
- package/dist/terraform/modules/app/agentcore-pi/scripts/reconcile_pi_runtime.mjs +175 -0
- package/dist/terraform/modules/app/agentcore-pi/variables.tf +35 -28
- package/dist/terraform/modules/app/agentcore-platform/main.tf +68 -0
- package/dist/terraform/modules/app/agentcore-runtime/main.tf +4 -20
- package/dist/terraform/modules/app/capability-broker/main.tf +2 -2
- package/dist/terraform/modules/app/capability-broker/variables.tf +11 -0
- package/dist/terraform/modules/app/deployment-control-plane/README.md +264 -0
- package/dist/terraform/modules/app/deployment-control-plane/buildspec.yml +4 -1
- package/dist/terraform/modules/app/deployment-control-plane/main.tf +94 -0
- package/dist/terraform/modules/app/deployment-control-plane/outputs.tf +5 -0
- package/dist/terraform/modules/app/deployment-control-plane/runner.py +2641 -124
- package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +2034 -154
- package/dist/terraform/modules/app/deployment-control-plane/variables.tf +33 -0
- package/dist/terraform/modules/app/lambda-api/auth-state.tf +72 -0
- package/dist/terraform/modules/app/lambda-api/chat-latency-observability.tf +466 -0
- package/dist/terraform/modules/app/lambda-api/dispatch-dlq.tf +55 -0
- package/dist/terraform/modules/app/lambda-api/handlers.tf +411 -723
- package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +262 -198
- package/dist/terraform/modules/app/lambda-api/main.tf +51 -13
- package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +4 -181
- package/dist/terraform/modules/app/lambda-api/memory-alarms.tf +3 -28
- package/dist/terraform/modules/app/lambda-api/oauth-secrets.tf +1 -2
- package/dist/terraform/modules/app/lambda-api/outputs.tf +17 -45
- package/dist/terraform/modules/app/lambda-api/turn-assertion.tf +45 -0
- package/dist/terraform/modules/app/lambda-api/variables.tf +219 -126
- package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/README.md +1 -1
- package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/main.tf +18 -18
- package/dist/terraform/modules/app/sandbox-log-scrubber/README.md +11 -10
- package/dist/terraform/modules/app/sandbox-log-scrubber/main.tf +5 -5
- package/dist/terraform/modules/app/static-site/main.tf +1 -1
- package/dist/terraform/modules/app/www-dns/main.tf +9 -27
- package/dist/terraform/modules/app/www-dns/variables.tf +4 -10
- package/dist/terraform/modules/data/aurora-postgres/README.md +34 -0
- package/dist/terraform/modules/data/aurora-postgres/alarms.tf +82 -0
- package/dist/terraform/modules/data/aurora-postgres/main.tf +27 -57
- package/dist/terraform/modules/data/aurora-postgres/outputs.tf +0 -9
- package/dist/terraform/modules/data/aurora-postgres/variables.tf +41 -0
- package/dist/terraform/modules/data/s3-buckets/main.tf +5 -6
- package/dist/terraform/modules/foundation/cognito/main.tf +81 -8
- package/dist/terraform/modules/foundation/cognito/outputs.tf +5 -0
- package/dist/terraform/modules/foundation/cognito/variables.tf +24 -12
- package/dist/terraform/modules/thinkwork/README.md +5 -1
- package/dist/terraform/modules/thinkwork/main.tf +376 -374
- package/dist/terraform/modules/thinkwork/outputs.tf +12 -158
- package/dist/terraform/modules/thinkwork/variables.tf +246 -89
- package/dist/workspace-defaults/files/INSTRUCTIONS.md +9 -11
- package/dist/workspace-defaults/files/MEMORY_GUIDE.md +38 -35
- package/dist/workspace-defaults/files/ROUTER.md +1 -1
- package/dist/workspace-defaults/files/TOOLS.md +9 -39
- package/dist/workspace-defaults/files/agents/analyst/INSTRUCTIONS.md +6 -3
- package/dist/workspace-defaults/files/catalog-skills/brain-brief-builder/SKILL.md +165 -0
- package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/SKILL.md +75 -0
- package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/mcp-tooling.md +40 -0
- package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/validation-and-handoff.md +40 -0
- package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/workflow-authoring.md +46 -0
- package/dist/workspace-defaults/files/skills/artifact-builder/references/crm-dashboard.md +1 -1
- package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +22 -3
- package/package.json +1 -1
- package/dist/drizzle/0261_managed_harness_multiplayer.sql +0 -313
- package/dist/drizzle/0262_parallel_harness_threads.sql +0 -9
- package/dist/drizzle/0264_harness_tool_execution_ledger.sql +0 -113
- package/dist/plugins/company-data/README.md +0 -49
- package/dist/plugins/company-data/package.json +0 -21
- package/dist/plugins/company-data/src/index.ts +0 -30
- package/dist/plugins/company-data/src/manifest.ts +0 -26
- package/dist/plugins/company-data/test/manifest.test.ts +0 -116
- package/dist/plugins/company-data/tsconfig.json +0 -8
- package/dist/plugins/company-etl/README.md +0 -46
- package/dist/plugins/company-etl/package.json +0 -21
- package/dist/plugins/company-etl/src/index.ts +0 -29
- package/dist/plugins/company-etl/src/manifest.ts +0 -26
- package/dist/plugins/company-etl/test/manifest.test.ts +0 -108
- package/dist/plugins/company-etl/tsconfig.json +0 -8
- package/dist/plugins/lastmile/README.md +0 -50
- package/dist/plugins/lastmile/package.json +0 -23
- package/dist/plugins/lastmile/smoke/lastmile-plugin-smoke.mjs +0 -661
- package/dist/plugins/lastmile/src/api/tasks-adapter.ts +0 -572
- package/dist/plugins/lastmile/src/discovery.fixture.ts +0 -64
- package/dist/plugins/lastmile/src/index.ts +0 -46
- package/dist/plugins/lastmile/src/manifest.ts +0 -139
- package/dist/plugins/lastmile/test/api/tasks-adapter.test.ts +0 -266
- package/dist/plugins/lastmile/test/discovery.test.ts +0 -88
- package/dist/plugins/lastmile/tsconfig.json +0 -8
- package/dist/plugins/n8n/smoke/n8n-managed-app-smoke.mjs +0 -409
- package/dist/plugins/twenty/smoke/twenty-managed-app-smoke.mjs +0 -359
- package/dist/terraform/modules/app/agentcore-code-interpreter/Dockerfile.sandbox-base +0 -61
- package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/sitecustomize.py +0 -227
- package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/test_sitecustomize.py +0 -277
- package/dist/terraform/modules/app/agentcore-code-interpreter/scripts/build_and_push_sandbox_base.sh +0 -70
- package/dist/terraform/modules/app/agentcore-gateway/main.tf +0 -305
- package/dist/terraform/modules/app/agentcore-gateway/outputs.tf +0 -44
- package/dist/terraform/modules/app/agentcore-gateway/scripts/delete_gateway.sh +0 -93
- package/dist/terraform/modules/app/agentcore-gateway/scripts/read_gateway.sh +0 -33
- package/dist/terraform/modules/app/agentcore-gateway/scripts/reconcile_gateway.sh +0 -543
- package/dist/terraform/modules/app/agentcore-gateway/variables.tf +0 -70
- package/dist/terraform/modules/app/agentcore-harness/main.tf +0 -330
- package/dist/terraform/modules/app/agentcore-harness/outputs.tf +0 -72
- package/dist/terraform/modules/app/agentcore-harness/scripts/delete_harness.sh +0 -5
- package/dist/terraform/modules/app/agentcore-harness/scripts/harness-lifecycle.mjs +0 -430
- package/dist/terraform/modules/app/agentcore-harness/scripts/harness-readback.mjs +0 -180
- package/dist/terraform/modules/app/agentcore-harness/scripts/harness-tool-contract.mjs +0 -193
- package/dist/terraform/modules/app/agentcore-harness/scripts/prune_harness.sh +0 -5
- package/dist/terraform/modules/app/agentcore-harness/scripts/read_harness.sh +0 -5
- package/dist/terraform/modules/app/agentcore-harness/scripts/reconcile_harness.sh +0 -5
- package/dist/terraform/modules/app/agentcore-harness/variables.tf +0 -96
- package/dist/terraform/modules/app/hindsight-memory/README.md +0 -89
- package/dist/terraform/modules/app/hindsight-memory/main.tf +0 -411
- package/dist/terraform/modules/app/lambda-api/agentcore-harness-alarms.tf +0 -88
- package/dist/terraform/modules/app/lambda-api/agentcore-identity-alarms.tf +0 -91
- package/dist/terraform/modules/data/bedrock-knowledge-base/main.tf +0 -102
- /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/outputs.tf +0 -0
- /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/scripts/sync-database.py +0 -0
- /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/scripts/test_sync_database.py +0 -0
- /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/variables.tf +0 -0
- /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/README.md +0 -0
- /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/main.tf +0 -0
- /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/outputs.tf +0 -0
- /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/variables.tf +0 -0
|
@@ -11,9 +11,10 @@ import type {
|
|
|
11
11
|
LastmileAccount,
|
|
12
12
|
LastmileContact,
|
|
13
13
|
LastmileCrmComment,
|
|
14
|
+
LastmileCrmTask,
|
|
14
15
|
LastmileCustomerNote,
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
LastmileOrganization,
|
|
17
|
+
LastmileTaskStatusChange,
|
|
17
18
|
} from "./lastmile-reader";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -219,103 +220,93 @@ export function toQuantity(raw: string | null | undefined): number | null {
|
|
|
219
220
|
|
|
220
221
|
// --- Stage mapping -------------------------------------------------------
|
|
221
222
|
|
|
222
|
-
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
223
|
+
/**
|
|
224
|
+
* Pipeline stages are LastMile's own status names, verbatim (from the `status`
|
|
225
|
+
* table, via `task.status_id`). Opportunity statuses and lead statuses share
|
|
226
|
+
* one Twenty pipeline because leads become early-stage opportunities.
|
|
227
|
+
*
|
|
228
|
+
* Existing TEI options (NEW/SCREENING/MEETING/PROPOSAL/CUSTOMER) are preserved
|
|
229
|
+
* by the merge — the ThinkWork workflow triggers on CUSTOMER — but nothing new
|
|
230
|
+
* maps onto them.
|
|
231
|
+
*/
|
|
226
232
|
export const MIGRATION_STAGE_OPTIONS: Array<{
|
|
227
233
|
label: string;
|
|
228
234
|
value: string;
|
|
229
235
|
color: string;
|
|
230
236
|
}> = [
|
|
231
|
-
|
|
232
|
-
{ label: "
|
|
233
|
-
{ label: "
|
|
234
|
-
{ label: "
|
|
235
|
-
{ label: "
|
|
236
|
-
{ label: "
|
|
237
|
-
{ label: "
|
|
238
|
-
{ label: "
|
|
239
|
-
|
|
240
|
-
{
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
},
|
|
245
|
-
{ label: "Won", value: "WON", color: "green" },
|
|
246
|
-
{ label: "Lost", value: "LOST", color: "red" },
|
|
237
|
+
// Lead band
|
|
238
|
+
{ label: "00-New", value: "NEW", color: "gray" },
|
|
239
|
+
{ label: "10-Working", value: "WORKING", color: "gray" },
|
|
240
|
+
{ label: "20-Contacted", value: "CONTACTED", color: "gray" },
|
|
241
|
+
{ label: "30-Nurturing", value: "NURTURING", color: "blue" },
|
|
242
|
+
{ label: "50-Qualified", value: "QUALIFIED", color: "blue" },
|
|
243
|
+
{ label: "90-Unqualified", value: "UNQUALIFIED", color: "gray" },
|
|
244
|
+
{ label: "Converted", value: "CONVERTED", color: "turquoise" },
|
|
245
|
+
// Opportunity band
|
|
246
|
+
{ label: "10-Prospect", value: "PROSPECT", color: "sky" },
|
|
247
|
+
{ label: "20-Account Needs", value: "ACCOUNT_NEEDS", color: "sky" },
|
|
248
|
+
{ label: "30-Formulate Offer", value: "FORMULATE_OFFER", color: "turquoise" },
|
|
249
|
+
{ label: "40-Negotiation", value: "NEGOTIATION", color: "yellow" },
|
|
250
|
+
{ label: "50-Implementation", value: "IMPLEMENTATION", color: "orange" },
|
|
251
|
+
{ label: "60-Won", value: "WON", color: "green" },
|
|
252
|
+
{ label: "90-Lost", value: "LOST", color: "red" },
|
|
247
253
|
];
|
|
248
254
|
|
|
249
|
-
/**
|
|
250
|
-
*
|
|
251
|
-
|
|
255
|
+
/** Retag map for the one-off `retag-stage-values.ts` migration: the original
|
|
256
|
+
* import wrote LM_-prefixed values, which surface raw in the API, filters, and
|
|
257
|
+
* reports. Labels never changed. */
|
|
258
|
+
export const LEGACY_STAGE_VALUE_MAP: Readonly<Record<string, string>> = {
|
|
259
|
+
LM_00_NEW: "NEW",
|
|
260
|
+
LM_10_WORKING: "WORKING",
|
|
261
|
+
LM_20_CONTACTED: "CONTACTED",
|
|
262
|
+
LM_30_NURTURING: "NURTURING",
|
|
263
|
+
LM_50_QUALIFIED: "QUALIFIED",
|
|
264
|
+
LM_90_UNQUALIFIED: "UNQUALIFIED",
|
|
265
|
+
LM_CONVERTED: "CONVERTED",
|
|
266
|
+
LM_10_PROSPECT: "PROSPECT",
|
|
267
|
+
LM_20_ACCOUNT_NEEDS: "ACCOUNT_NEEDS",
|
|
268
|
+
LM_30_FORMULATE_OFFER: "FORMULATE_OFFER",
|
|
269
|
+
LM_40_NEGOTIATION: "NEGOTIATION",
|
|
270
|
+
LM_50_IMPLEMENTATION: "IMPLEMENTATION",
|
|
271
|
+
LM_60_WON: "WON",
|
|
272
|
+
LM_90_LOST: "LOST",
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** Options from the abandoned first model, plus TEI's unused defaults, that no
|
|
276
|
+
* record references. Removed by the retag so the picker is not a graveyard.
|
|
277
|
+
* CUSTOMER is deliberately kept — the ThinkWork workflow triggers on it. */
|
|
278
|
+
export const OBSOLETE_STAGE_VALUES: readonly string[] = [
|
|
279
|
+
"LEAD",
|
|
280
|
+
"LEAD_WORKING",
|
|
281
|
+
"LEAD_QUALIFIED",
|
|
282
|
+
"LEAD_UNQUALIFIED",
|
|
283
|
+
"QUALIFYING",
|
|
284
|
+
"IDENTIFY_NEEDS",
|
|
285
|
+
"NEGOTIATE",
|
|
286
|
+
"MANAGE_IMPLEMENTATION",
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
const STAGE_VALUE_BY_STATUS_NAME = new Map(
|
|
290
|
+
MIGRATION_STAGE_OPTIONS.map((option) => [
|
|
291
|
+
option.label.toLowerCase(),
|
|
292
|
+
option.value,
|
|
293
|
+
]),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* `task.status_id` -> status name -> stage value. Lead and opportunity status
|
|
298
|
+
* names collide only on "00-New", which both bands share intentionally.
|
|
299
|
+
* An unmapped status is reported rather than silently bucketed.
|
|
300
|
+
*/
|
|
301
|
+
export function mapTaskStatusToStage(statusName: string | null): {
|
|
252
302
|
stage: string;
|
|
253
303
|
unknown: boolean;
|
|
254
304
|
} {
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
case "working":
|
|
261
|
-
case "contacted":
|
|
262
|
-
case "nurturing":
|
|
263
|
-
return { stage: "LEAD_WORKING", unknown: false };
|
|
264
|
-
case "qualified":
|
|
265
|
-
return { stage: "LEAD_QUALIFIED", unknown: false };
|
|
266
|
-
case "unqualified":
|
|
267
|
-
return { stage: "LEAD_UNQUALIFIED", unknown: false };
|
|
268
|
-
case "converted":
|
|
269
|
-
// Converted leads became opportunities in LastMile; the lead row keeps
|
|
270
|
-
// its history at the qualified end of the lead band.
|
|
271
|
-
return { stage: "LEAD_QUALIFIED", unknown: false };
|
|
272
|
-
default:
|
|
273
|
-
return { stage: "LEAD", unknown: true };
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
export function mapOpportunityStage(opportunity: {
|
|
278
|
-
stage: string | null;
|
|
279
|
-
closed: string | null;
|
|
280
|
-
won: string | null;
|
|
281
|
-
}): { stage: string; unknown: boolean } {
|
|
282
|
-
const normalized = (opportunity.stage ?? "")
|
|
283
|
-
.toLowerCase()
|
|
284
|
-
.replace(/^\d+-/, "")
|
|
285
|
-
.trim();
|
|
286
|
-
switch (normalized) {
|
|
287
|
-
case "new":
|
|
288
|
-
return { stage: "NEW", unknown: false };
|
|
289
|
-
case "prospect":
|
|
290
|
-
return { stage: "PROSPECT", unknown: false };
|
|
291
|
-
case "qualifying":
|
|
292
|
-
return { stage: "QUALIFYING", unknown: false };
|
|
293
|
-
case "identify account needs":
|
|
294
|
-
return { stage: "IDENTIFY_NEEDS", unknown: false };
|
|
295
|
-
case "formulate offer":
|
|
296
|
-
return { stage: "FORMULATE_OFFER", unknown: false };
|
|
297
|
-
case "negotiate to close":
|
|
298
|
-
case "negotiation to close":
|
|
299
|
-
return { stage: "NEGOTIATE", unknown: false };
|
|
300
|
-
case "manage implementation":
|
|
301
|
-
return { stage: "MANAGE_IMPLEMENTATION", unknown: false };
|
|
302
|
-
case "won":
|
|
303
|
-
return { stage: "WON", unknown: false };
|
|
304
|
-
case "lost":
|
|
305
|
-
return { stage: "LOST", unknown: false };
|
|
306
|
-
case "": {
|
|
307
|
-
// Blank stage: fall back to the closed/won flags.
|
|
308
|
-
if (opportunity.closed === "true" && opportunity.won === "true") {
|
|
309
|
-
return { stage: "WON", unknown: false };
|
|
310
|
-
}
|
|
311
|
-
if (opportunity.closed === "true") {
|
|
312
|
-
return { stage: "LOST", unknown: false };
|
|
313
|
-
}
|
|
314
|
-
return { stage: "NEW", unknown: false };
|
|
315
|
-
}
|
|
316
|
-
default:
|
|
317
|
-
return { stage: "NEW", unknown: true };
|
|
318
|
-
}
|
|
305
|
+
const value = STAGE_VALUE_BY_STATUS_NAME.get(
|
|
306
|
+
(statusName ?? "").toLowerCase().trim(),
|
|
307
|
+
);
|
|
308
|
+
if (value) return { stage: value, unknown: false };
|
|
309
|
+
return { stage: "NEW", unknown: true };
|
|
319
310
|
}
|
|
320
311
|
|
|
321
312
|
// --- Record mappers ------------------------------------------------------
|
|
@@ -327,6 +318,37 @@ export interface MappedRecord {
|
|
|
327
318
|
warnings: string[];
|
|
328
319
|
}
|
|
329
320
|
|
|
321
|
+
/**
|
|
322
|
+
* A dispatch customer becomes a company exactly like an account does, keyed
|
|
323
|
+
* `customer:<id>` — the id spaces never collide, and the caller drops
|
|
324
|
+
* customers whose name already has an account-sourced company (unique-name
|
|
325
|
+
* crosswalk) before mapping.
|
|
326
|
+
*/
|
|
327
|
+
export function mapDispatchCustomer(
|
|
328
|
+
customer: {
|
|
329
|
+
id: string;
|
|
330
|
+
name: string | null;
|
|
331
|
+
p21Code?: string | null;
|
|
332
|
+
ownerRepId: string | null;
|
|
333
|
+
},
|
|
334
|
+
ownerMap: ReadonlyMap<string, string>,
|
|
335
|
+
): MappedRecord {
|
|
336
|
+
const warnings: string[] = [];
|
|
337
|
+
const accountOwnerId = resolveOwner(customer.ownerRepId, ownerMap);
|
|
338
|
+
if (customer.ownerRepId && !accountOwnerId) {
|
|
339
|
+
warnings.push(
|
|
340
|
+
`owner ${customer.ownerRepId} not provisioned; company has no accountOwner`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const input: Record<string, unknown> = {
|
|
344
|
+
name: customer.name ?? `Customer ${customer.id}`,
|
|
345
|
+
...(accountOwnerId ? { accountOwnerId } : {}),
|
|
346
|
+
...(customer.p21Code ? { p21Code: customer.p21Code } : {}),
|
|
347
|
+
sourceId: sourceId("customer", customer.id),
|
|
348
|
+
};
|
|
349
|
+
return { sourceId: input.sourceId as string, input, warnings };
|
|
350
|
+
}
|
|
351
|
+
|
|
330
352
|
export function mapAccount(
|
|
331
353
|
account: LastmileAccount,
|
|
332
354
|
ownerMap: ReadonlyMap<string, string>,
|
|
@@ -346,6 +368,28 @@ export function mapAccount(
|
|
|
346
368
|
return { sourceId: input.sourceId as string, input, warnings };
|
|
347
369
|
}
|
|
348
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Twenty enforces a unique primary email on Person, but LastMile does not:
|
|
373
|
+
* among the scoped contacts, 17 addresses are shared by 2-8 people
|
|
374
|
+
* ("test@test.com" x8, a rep's own address x8). The first contact by id keeps
|
|
375
|
+
* the address; the rest migrate without one, so no person is lost to a
|
|
376
|
+
* duplicate-key error. The caller reports each dropped address.
|
|
377
|
+
*/
|
|
378
|
+
export function dedupeContactEmails<
|
|
379
|
+
T extends { id: string; email: string | null },
|
|
380
|
+
>(contacts: readonly T[]): T[] {
|
|
381
|
+
const claimed = new Set<string>();
|
|
382
|
+
return [...contacts]
|
|
383
|
+
.sort((left, right) => (left.id < right.id ? -1 : 1))
|
|
384
|
+
.map((contact) => {
|
|
385
|
+
const email = normalizeEmail(contact.email);
|
|
386
|
+
if (!email) return contact;
|
|
387
|
+
if (claimed.has(email)) return { ...contact, email: null };
|
|
388
|
+
claimed.add(email);
|
|
389
|
+
return contact;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
349
393
|
export function mapContact(
|
|
350
394
|
contact: LastmileContact,
|
|
351
395
|
companyIdBySourceId: ReadonlyMap<string, string>,
|
|
@@ -381,15 +425,80 @@ export function mapContact(
|
|
|
381
425
|
return { sourceId: input.sourceId as string, input, warnings };
|
|
382
426
|
}
|
|
383
427
|
|
|
384
|
-
/**
|
|
385
|
-
*
|
|
428
|
+
/**
|
|
429
|
+
* TEI's seven product lines, exactly as LastMile's "Product Line" picker shows
|
|
430
|
+
* them. The `items[].brand` free-text field holds 19 variants of these seven
|
|
431
|
+
* (MOBIL, Mobil, "MOBIL - CVL", "GWO - PVL", ...), which is why products became
|
|
432
|
+
* a catalog object rather than a text field.
|
|
433
|
+
*/
|
|
434
|
+
export const PRODUCT_CATALOG: readonly string[] = [
|
|
435
|
+
"Ancillary",
|
|
436
|
+
"DEF",
|
|
437
|
+
"Fuel",
|
|
438
|
+
"Golden West",
|
|
439
|
+
"Hotsy",
|
|
440
|
+
"Mighty",
|
|
441
|
+
"Mobil",
|
|
442
|
+
];
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Collapse a LastMile brand string onto a catalog product. Sub-line suffixes
|
|
446
|
+
* (CVL, PVL, INDUSTRIAL) denote the channel, not the product, so they fold into
|
|
447
|
+
* the parent. Returns null for "UNKNOWN" and blanks (173 lines): the line still
|
|
448
|
+
* migrates with its quantity and amount, but carries no product, and every one
|
|
449
|
+
* is listed in the report rather than silently bucketed.
|
|
450
|
+
*/
|
|
451
|
+
export function normalizeProductName(brand: string | null): string | null {
|
|
452
|
+
const raw = (brand ?? "").trim();
|
|
453
|
+
if (!raw) return null;
|
|
454
|
+
const base = raw.split("-")[0].trim().toLowerCase();
|
|
455
|
+
switch (base) {
|
|
456
|
+
case "mobil":
|
|
457
|
+
return "Mobil";
|
|
458
|
+
case "golden west":
|
|
459
|
+
case "gwo":
|
|
460
|
+
return "Golden West";
|
|
461
|
+
case "fuel":
|
|
462
|
+
return "Fuel";
|
|
463
|
+
case "def":
|
|
464
|
+
return "DEF";
|
|
465
|
+
case "mighty":
|
|
466
|
+
return "Mighty";
|
|
467
|
+
case "ancillary":
|
|
468
|
+
return "Ancillary";
|
|
469
|
+
case "hotsy":
|
|
470
|
+
return "Hotsy";
|
|
471
|
+
default:
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Mobil-branded products, per line — driven by the catalog name, not the raw
|
|
477
|
+
* brand string, so "MOBIL - CVL" and "Mobil" agree. */
|
|
386
478
|
export function isMobilBrand(brand: string | null): boolean {
|
|
387
|
-
return
|
|
479
|
+
return normalizeProductName(brand) === "Mobil";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** A catalog product record. Name is the identity; the sourceId keeps re-runs
|
|
483
|
+
* idempotent. */
|
|
484
|
+
export function mapProduct(name: string): MappedRecord {
|
|
485
|
+
const key = name.toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
486
|
+
return {
|
|
487
|
+
sourceId: sourceId("product", key),
|
|
488
|
+
input: { name, sourceId: sourceId("product", key) },
|
|
489
|
+
warnings: [],
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function productSourceId(name: string): string {
|
|
494
|
+
return sourceId("product", name.toLowerCase().replace(/[^a-z0-9]+/g, "_"));
|
|
388
495
|
}
|
|
389
496
|
|
|
390
497
|
export interface MappedOpportunityProduct extends MappedRecord {
|
|
391
498
|
/** sourceId of the owning opportunity, resolved to a Twenty id by the loader. */
|
|
392
499
|
opportunitySourceId: string;
|
|
500
|
+
/** sourceId of the catalog product, or null when the brand did not map. */
|
|
501
|
+
productSourceId: string | null;
|
|
393
502
|
}
|
|
394
503
|
|
|
395
504
|
/**
|
|
@@ -413,15 +522,23 @@ export function mapOpportunityProduct(item: {
|
|
|
413
522
|
if (item.quantity && quantity === null) {
|
|
414
523
|
warnings.push(`unparseable line quantity ${item.quantity} dropped`);
|
|
415
524
|
}
|
|
525
|
+
const productName = normalizeProductName(item.brand);
|
|
526
|
+
if (!productName) {
|
|
527
|
+
warnings.push(
|
|
528
|
+
`line ${item.index + 1}: brand ${JSON.stringify(item.brand ?? "")} does not map to a product`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
416
532
|
const lineSourceId = `${sourceId("opportunity_item", item.opportunityId)}#${item.index}`;
|
|
417
533
|
const input: Record<string, unknown> = {
|
|
418
|
-
name
|
|
419
|
-
|
|
534
|
+
// The chip in Twenty's UI reads the name, so an unmapped line says "Line 2",
|
|
535
|
+
// never "Untitled".
|
|
536
|
+
name: productName ?? `Line ${item.index + 1}`,
|
|
420
537
|
...(quantity !== null ? { quantity } : {}),
|
|
421
538
|
...(amountMicros !== null
|
|
422
539
|
? { amount: { amountMicros, currencyCode: "USD" } }
|
|
423
540
|
: {}),
|
|
424
|
-
isMobil:
|
|
541
|
+
isMobil: productName === "Mobil",
|
|
425
542
|
lineNumber: item.index + 1,
|
|
426
543
|
sourceId: lineSourceId,
|
|
427
544
|
};
|
|
@@ -430,92 +547,147 @@ export function mapOpportunityProduct(item: {
|
|
|
430
547
|
input,
|
|
431
548
|
warnings,
|
|
432
549
|
opportunitySourceId: sourceId("opportunity", item.opportunityId),
|
|
550
|
+
productSourceId: productName ? productSourceId(productName) : null,
|
|
433
551
|
};
|
|
434
552
|
}
|
|
435
553
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
export function mapOpportunity(
|
|
446
|
-
opportunity: LastmileOpportunity,
|
|
447
|
-
ownerMap: ReadonlyMap<string, string>,
|
|
554
|
+
/**
|
|
555
|
+
* A CRM record as LastMile's `task` table sees it — the authority for status,
|
|
556
|
+
* owner, organization, and products. Leads and opportunities both land in
|
|
557
|
+
* Twenty's opportunity pipeline (AE3).
|
|
558
|
+
*/
|
|
559
|
+
export function mapCrmTask(
|
|
560
|
+
task: LastmileCrmTask,
|
|
561
|
+
ownerIndex: ReadonlyMap<string, string>,
|
|
448
562
|
companyIdBySourceId: ReadonlyMap<string, string>,
|
|
563
|
+
organizationIdBySourceId: ReadonlyMap<string, string>,
|
|
449
564
|
): MappedRecord {
|
|
450
565
|
const warnings: string[] = [];
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
566
|
+
|
|
567
|
+
const { stage, unknown } = mapTaskStatusToStage(task.statusName);
|
|
568
|
+
if (unknown) {
|
|
569
|
+
warnings.push(`unknown task status "${task.statusName}" mapped to 00-New`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const ownerId = resolveOwner(task.assigneeRepId, ownerIndex);
|
|
573
|
+
if (task.assigneeRepId && !ownerId) {
|
|
456
574
|
warnings.push(
|
|
457
|
-
`
|
|
575
|
+
`assignee rep ${task.assigneeRepId} not provisioned; no owner`,
|
|
458
576
|
);
|
|
577
|
+
} else if (!task.assigneeRepId) {
|
|
578
|
+
warnings.push("task has no assignee; no owner");
|
|
459
579
|
}
|
|
580
|
+
|
|
581
|
+
// Leads pre-date an account: they carry only a typed company name.
|
|
460
582
|
let companyId: string | null = null;
|
|
461
|
-
if (
|
|
583
|
+
if (task.accountId) {
|
|
462
584
|
companyId =
|
|
463
|
-
companyIdBySourceId.get(sourceId("account",
|
|
464
|
-
null;
|
|
585
|
+
companyIdBySourceId.get(sourceId("account", task.accountId)) ?? null;
|
|
465
586
|
if (!companyId) {
|
|
466
|
-
warnings.push(
|
|
467
|
-
`account ${opportunity.accountId} not found; opportunity has no company`,
|
|
468
|
-
);
|
|
587
|
+
warnings.push(`account ${task.accountId} not migrated; no company link`);
|
|
469
588
|
}
|
|
470
589
|
}
|
|
471
|
-
|
|
590
|
+
|
|
591
|
+
let organizationId: string | null = null;
|
|
592
|
+
if (task.organizationId) {
|
|
593
|
+
organizationId =
|
|
594
|
+
organizationIdBySourceId.get(
|
|
595
|
+
sourceId("organization", task.organizationId),
|
|
596
|
+
) ?? null;
|
|
597
|
+
if (!organizationId) {
|
|
598
|
+
warnings.push(`organization ${task.organizationId} not migrated`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// The deal total is the sum of its product lines when they exist; otherwise
|
|
603
|
+
// there is no reliable amount on the task.
|
|
604
|
+
const amountMicros = sumLineAmountsMicros(task.items);
|
|
605
|
+
|
|
606
|
+
const name =
|
|
607
|
+
task.title ??
|
|
608
|
+
task.leadCompanyName ??
|
|
609
|
+
`${task.entityType === "lead" ? "Lead" : "Opportunity"} ${task.entityId}`;
|
|
610
|
+
|
|
472
611
|
const input: Record<string, unknown> = {
|
|
473
|
-
name
|
|
612
|
+
name,
|
|
474
613
|
stage,
|
|
475
614
|
...(amountMicros !== null
|
|
476
615
|
? { amount: { amountMicros, currencyCode: "USD" } }
|
|
477
616
|
: {}),
|
|
478
|
-
...(
|
|
479
|
-
? { closeDate:
|
|
617
|
+
...(task.dueDate
|
|
618
|
+
? { closeDate: new Date(task.dueDate).toISOString() }
|
|
480
619
|
: {}),
|
|
481
620
|
...(ownerId ? { ownerId } : {}),
|
|
482
621
|
...(companyId ? { companyId } : {}),
|
|
483
|
-
...(
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
622
|
+
...(organizationId ? { organizationId } : {}),
|
|
623
|
+
isMobil: (task.items ?? []).some((item) =>
|
|
624
|
+
isMobilBrand(item.brand ?? null),
|
|
625
|
+
),
|
|
626
|
+
// Replay LastMile's true creation time onto Twenty so "created" reads
|
|
627
|
+
// correctly and the record sorts by real age, not import time. Twenty
|
|
628
|
+
// accepts createdAt on create and update (proven by the notes path), so
|
|
629
|
+
// records seeded before this behaviour existed heal on the next sweep.
|
|
630
|
+
...(task.createdAt ? { createdAt: toIsoTimestamp(task.createdAt) } : {}),
|
|
631
|
+
sourceId: sourceId(task.entityType, task.entityId),
|
|
489
632
|
};
|
|
490
633
|
return { sourceId: input.sourceId as string, input, warnings };
|
|
491
634
|
}
|
|
492
635
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
if (lead.ownerRepId && !ownerId) {
|
|
505
|
-
warnings.push(
|
|
506
|
-
`owner ${lead.ownerRepId} not provisioned; lead has no owner`,
|
|
636
|
+
export function sumLineAmountsMicros(
|
|
637
|
+
items: LastmileCrmTask["items"],
|
|
638
|
+
): number | null {
|
|
639
|
+
if (!items || items.length === 0) return null;
|
|
640
|
+
let total = 0;
|
|
641
|
+
let sawAny = false;
|
|
642
|
+
for (const item of items) {
|
|
643
|
+
const micros = toAmountMicros(
|
|
644
|
+
item.amount === null || item.amount === undefined
|
|
645
|
+
? null
|
|
646
|
+
: String(item.amount),
|
|
507
647
|
);
|
|
648
|
+
if (micros !== null) {
|
|
649
|
+
total += micros;
|
|
650
|
+
sawAny = true;
|
|
651
|
+
}
|
|
508
652
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
653
|
+
return sawAny ? total : null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** LastMile branch/business unit ("Golden West Oil Co..San Antonio (300)",
|
|
657
|
+
* shown in the UI as its `abbv`, "GWO 300"). */
|
|
658
|
+
export function mapOrganization(
|
|
659
|
+
organization: LastmileOrganization,
|
|
660
|
+
): MappedRecord {
|
|
661
|
+
return {
|
|
662
|
+
sourceId: sourceId("organization", organization.id),
|
|
663
|
+
input: {
|
|
664
|
+
name: organization.abbv ?? organization.name ?? organization.id,
|
|
665
|
+
fullName: organization.name ?? "",
|
|
666
|
+
sourceId: sourceId("organization", organization.id),
|
|
667
|
+
},
|
|
668
|
+
warnings: [],
|
|
517
669
|
};
|
|
518
|
-
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Product lines belonging to a CRM task, keyed by the task's entity id. */
|
|
673
|
+
export function mapTaskProducts(
|
|
674
|
+
task: LastmileCrmTask,
|
|
675
|
+
): MappedOpportunityProduct[] {
|
|
676
|
+
return (task.items ?? []).map((item, index) =>
|
|
677
|
+
mapOpportunityProduct({
|
|
678
|
+
opportunityId: task.entityId,
|
|
679
|
+
index,
|
|
680
|
+
brand: item.brand ?? null,
|
|
681
|
+
quantity:
|
|
682
|
+
item.quantity === null || item.quantity === undefined
|
|
683
|
+
? null
|
|
684
|
+
: String(item.quantity),
|
|
685
|
+
amount:
|
|
686
|
+
item.amount === null || item.amount === undefined
|
|
687
|
+
? null
|
|
688
|
+
: String(item.amount),
|
|
689
|
+
}),
|
|
690
|
+
);
|
|
519
691
|
}
|
|
520
692
|
|
|
521
693
|
export interface MappedNote {
|
|
@@ -526,6 +698,11 @@ export interface MappedNote {
|
|
|
526
698
|
targetSourceId: string;
|
|
527
699
|
targetKind: "opportunity" | "company";
|
|
528
700
|
isDeleted: boolean;
|
|
701
|
+
/** When the note was written in LastMile — replayed onto Twenty's createdAt
|
|
702
|
+
* so the activity timeline is chronologically correct. */
|
|
703
|
+
createdAt: string | null;
|
|
704
|
+
/** LastMile author; Twenty's actor is not settable via the API. */
|
|
705
|
+
authorName: string | null;
|
|
529
706
|
}
|
|
530
707
|
|
|
531
708
|
export function mapCrmComment(comment: LastmileCrmComment): MappedNote {
|
|
@@ -538,9 +715,46 @@ export function mapCrmComment(comment: LastmileCrmComment): MappedNote {
|
|
|
538
715
|
targetSourceId: sourceId(comment.entityType, comment.entityId),
|
|
539
716
|
targetKind: "opportunity",
|
|
540
717
|
isDeleted: comment.isDeleted,
|
|
718
|
+
createdAt: toIsoTimestamp(comment.createdAt),
|
|
719
|
+
authorName: comment.authorName,
|
|
541
720
|
};
|
|
542
721
|
}
|
|
543
722
|
|
|
723
|
+
/**
|
|
724
|
+
* A LastMile pipeline transition ("00-New" -> "10-Prospect") reconstructed as a
|
|
725
|
+
* dated Note on the Twenty opportunity, so the activity feed shows when each
|
|
726
|
+
* status change actually happened. Returns null when the event carries no
|
|
727
|
+
* destination stage name (nothing meaningful to record). Stage names arrive
|
|
728
|
+
* already clean ("10-Prospect", "60-Won") — the same labels Twenty uses — so no
|
|
729
|
+
* LM_ stripping is needed. The `task_activity.id` gives a stable sourceId, so
|
|
730
|
+
* re-runs upsert the same note rather than duplicating it.
|
|
731
|
+
*/
|
|
732
|
+
export function mapTaskStatusActivity(
|
|
733
|
+
activity: LastmileTaskStatusChange,
|
|
734
|
+
): MappedNote | null {
|
|
735
|
+
if (!activity.newStatusName) return null;
|
|
736
|
+
const body = activity.oldStatusName
|
|
737
|
+
? `${activity.oldStatusName} → ${activity.newStatusName}`
|
|
738
|
+
: `Set to ${activity.newStatusName}`;
|
|
739
|
+
return {
|
|
740
|
+
sourceId: sourceId("task_activity", activity.id),
|
|
741
|
+
title: `Stage → ${activity.newStatusName}`,
|
|
742
|
+
bodyMarkdown: body,
|
|
743
|
+
targetSourceId: sourceId(activity.entityType, activity.entityId),
|
|
744
|
+
targetKind: "opportunity",
|
|
745
|
+
isDeleted: false,
|
|
746
|
+
createdAt: toIsoTimestamp(activity.createdAt),
|
|
747
|
+
authorName: activity.authorName,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Twenty wants an ISO-8601 DateTime; LastMile hands us a Date or null. */
|
|
752
|
+
export function toIsoTimestamp(value: Date | string | null): string | null {
|
|
753
|
+
if (!value) return null;
|
|
754
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
755
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
756
|
+
}
|
|
757
|
+
|
|
544
758
|
/** Customer notes reach Twenty only when their dispatch customer name matches
|
|
545
759
|
* exactly one CRM account (plan resolution); the caller reports the rest as
|
|
546
760
|
* gaps. */
|
|
@@ -555,5 +769,7 @@ export function mapCustomerNote(note: LastmileCustomerNote): MappedNote | null {
|
|
|
555
769
|
targetSourceId: sourceId("account", note.matchedAccountId),
|
|
556
770
|
targetKind: "company",
|
|
557
771
|
isDeleted: false,
|
|
772
|
+
createdAt: toIsoTimestamp(note.dateCreated),
|
|
773
|
+
authorName: null,
|
|
558
774
|
};
|
|
559
775
|
}
|