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
|
@@ -5,34 +5,54 @@
|
|
|
5
5
|
*
|
|
6
6
|
* OPERATOR RUNBOOK
|
|
7
7
|
* ================
|
|
8
|
+
* PREREQUISITE — Twenty's stock workflow "Create company when adding a new person"
|
|
9
|
+
* MUST be deactivated (Settings -> Workflows) before any --apply run. It fires on
|
|
10
|
+
* every person insert, invents a company from the email domain, and repoints the
|
|
11
|
+
* person at it; it once mislinked 21,989 of 24,028 migrated people. The workspace
|
|
12
|
+
* API key is forbidden from toggling workflows, so this is a human step.
|
|
13
|
+
*
|
|
14
|
+
* NIGHTLY AUTOMATION — during TEI's cutover this runs unattended via
|
|
15
|
+
* `.github/workflows/tei-lastmile-sync.yml` (scheduled `--apply` delta re-sync).
|
|
16
|
+
* The run is idempotent (upsert by sourceId + deletion mirror), never provisions
|
|
17
|
+
* members, and needs no AWS credentials — the ~4 CRM attachments TEI's Twenty
|
|
18
|
+
* cannot ingest are reported as gaps, not failures. Two invariants the workflow
|
|
19
|
+
* cannot enforce and that a human owns: (1) the person->company workflow above
|
|
20
|
+
* stays deactivated, and (2) the shared TWENTY_REP_PASSWORD is rotated once the
|
|
21
|
+
* validation window closes (see step 5 below).
|
|
22
|
+
*
|
|
8
23
|
* Environment (all required unless noted):
|
|
9
24
|
* TWENTY_PUBLIC_URL TEI's Twenty base URL, e.g. https://crm.tei.thinkwork.ai
|
|
10
|
-
* TWENTY_API_KEY Workspace API key (Settings
|
|
25
|
+
* TWENTY_API_KEY Workspace API key (Settings -> API & Webhooks)
|
|
11
26
|
* LASTMILE_DATABASE_URL LastMile dispatch Postgres connection string (read access)
|
|
12
|
-
*
|
|
13
|
-
* TWENTY_ADMIN_PASSWORD Admin user password — same
|
|
14
|
-
* TWENTY_REP_PASSWORD Shared test password for provisioned reps (R5; value lives
|
|
15
|
-
* outside the repo). ROTATE OR DEACTIVATE every rep account at
|
|
16
|
-
* cutover, on abort, or if the validation window drags on.
|
|
17
|
-
* TWENTY_WORKSPACE_ID Optional override; otherwise resolved from the admin session
|
|
18
|
-
* TWENTY_REP_EMAIL_DOMAIN Domain for reps with no LastMile email
|
|
27
|
+
* TWENTY_REP_EMAIL_DOMAIN Optional; domain for reps with no LastMile email
|
|
19
28
|
* (default texasenterprises.com; <first-initial><lastname>@)
|
|
20
29
|
* AWS_PROFILE / AWS_REGION Needed for attachment binaries (LastMile S3 bucket)
|
|
30
|
+
* NODE_EXTRA_CA_CERTS RDS CA bundle, when the DB rejects the system trust store
|
|
31
|
+
*
|
|
32
|
+
* Members are NOT provisioned by this script: TEI's Twenty does not serve the auth
|
|
33
|
+
* GraphQL schema, so `scripts/provision-twenty-members.ts` writes them directly to
|
|
34
|
+
* Twenty's Postgres (see that file's header). Run it FIRST, then this script, so
|
|
35
|
+
* every record resolves its owner. Reps without a member load ownerless and heal
|
|
36
|
+
* on the next run once their member exists.
|
|
21
37
|
*
|
|
22
38
|
* Invocations (dry-run is the default; nothing is written without --apply):
|
|
23
|
-
* npx tsx scripts/migrate-lastmile.ts
|
|
24
|
-
* npx tsx scripts/migrate-lastmile.ts --apply
|
|
25
|
-
* npx tsx scripts/migrate-lastmile.ts --rollback
|
|
26
|
-
* npx tsx scripts/migrate-lastmile.ts --rollback --apply
|
|
27
|
-
*
|
|
28
|
-
*
|
|
39
|
+
* npx tsx scripts/migrate-lastmile.ts # dry-run + planned mutations
|
|
40
|
+
* npx tsx scripts/migrate-lastmile.ts --apply # seed / delta re-sync
|
|
41
|
+
* npx tsx scripts/migrate-lastmile.ts --rollback # list the sourceId-owned set
|
|
42
|
+
* npx tsx scripts/migrate-lastmile.ts --rollback --apply # soft-delete it (restorable)
|
|
43
|
+
*
|
|
44
|
+
* To start over from an empty workspace: `scripts/purge-lastmile-import.ts --apply`
|
|
45
|
+
* hard-deletes every sourceId-owned record plus workflow-created domain companies.
|
|
29
46
|
*
|
|
30
47
|
* Cutover day, in order:
|
|
31
|
-
* 1.
|
|
32
|
-
* 2.
|
|
33
|
-
* 3.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
48
|
+
* 1. Confirm the person-to-company workflow is still deactivated.
|
|
49
|
+
* 2. Freeze LastMile CRM edits.
|
|
50
|
+
* 3. Re-run with --apply (delta re-sync; upserts by sourceId,
|
|
51
|
+
* mirrors deletions via an id-set diff -- LastMile has no dead-mark columns).
|
|
52
|
+
* 4. Check the parity report + spot checks (this script's stdout JSON).
|
|
53
|
+
* 5. ROTATE every rep password. The shared TWENTY_REP_PASSWORD is set on 89
|
|
54
|
+
* accounts and must not outlive the validation phase -- rotate on cutover, on
|
|
55
|
+
* abort, and on an over-long validation window alike (R5).
|
|
36
56
|
*
|
|
37
57
|
* The parity report prints to stdout as JSON; all other logging goes to stderr.
|
|
38
58
|
* Secrets are never logged. One invocation at a time (KTD3).
|
|
@@ -53,7 +73,9 @@ import {
|
|
|
53
73
|
NOTE,
|
|
54
74
|
OPPORTUNITY,
|
|
55
75
|
OPPORTUNITY_PRODUCT,
|
|
76
|
+
ORGANIZATION,
|
|
56
77
|
PERSON,
|
|
78
|
+
PRODUCT,
|
|
57
79
|
rollbackEntity,
|
|
58
80
|
upsertNotes,
|
|
59
81
|
upsertOpportunityProducts,
|
|
@@ -62,24 +84,27 @@ import {
|
|
|
62
84
|
} from "./lib/load-records";
|
|
63
85
|
import {
|
|
64
86
|
buildOwnerIndex,
|
|
87
|
+
dedupeContactEmails,
|
|
65
88
|
deriveRepEmail,
|
|
66
89
|
mapAccount,
|
|
90
|
+
mapDispatchCustomer,
|
|
67
91
|
mapContact,
|
|
68
92
|
mapCrmComment,
|
|
69
93
|
mapCustomerNote,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
94
|
+
mapCrmTask,
|
|
95
|
+
mapOrganization,
|
|
96
|
+
mapProduct,
|
|
97
|
+
mapTaskProducts,
|
|
98
|
+
mapTaskStatusActivity,
|
|
99
|
+
PRODUCT_CATALOG,
|
|
100
|
+
productSourceId,
|
|
73
101
|
sourceId,
|
|
74
102
|
} from "./lib/mappers";
|
|
75
|
-
import {
|
|
76
|
-
adminSignIn,
|
|
77
|
-
ensureMembers,
|
|
78
|
-
type RepToProvision,
|
|
79
|
-
} from "./lib/members-ensure";
|
|
103
|
+
import { ensureMembers, type RepToProvision } from "./lib/members-ensure";
|
|
80
104
|
import {
|
|
81
105
|
applySchemaEnsure,
|
|
82
106
|
ensureOpportunityProductObject,
|
|
107
|
+
ensureOrganizationObject,
|
|
83
108
|
fetchObjectMetadata,
|
|
84
109
|
planSchemaEnsure,
|
|
85
110
|
} from "./lib/schema-ensure";
|
|
@@ -88,19 +113,17 @@ import { TwentyClient, normalizeBaseUrl } from "./lib/twenty-client";
|
|
|
88
113
|
interface CliArgs {
|
|
89
114
|
apply: boolean;
|
|
90
115
|
rollback: boolean;
|
|
91
|
-
/** Provision no new members this run: match existing members only. Owner
|
|
92
|
-
* refs for unprovisioned reps stay null and heal on a later re-run once the
|
|
93
|
-
* members exist (content hash changes when ownerId resolves). */
|
|
94
|
-
skipInvites: boolean;
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
function parseArgs(argv: string[]): CliArgs {
|
|
98
|
-
const args: CliArgs = { apply: false, rollback: false
|
|
119
|
+
const args: CliArgs = { apply: false, rollback: false };
|
|
99
120
|
for (const arg of argv) {
|
|
100
121
|
if (arg === "--apply") args.apply = true;
|
|
101
122
|
else if (arg === "--dry-run") args.apply = false;
|
|
102
123
|
else if (arg === "--rollback") args.rollback = true;
|
|
103
|
-
|
|
124
|
+
// Accepted and ignored: this script never provisions members, so the flag
|
|
125
|
+
// that once suppressed it is a no-op. Kept so an old invocation still runs.
|
|
126
|
+
else if (arg === "--skip-invites") continue;
|
|
104
127
|
else throw new Error(`Unknown argument: ${arg}`);
|
|
105
128
|
}
|
|
106
129
|
return args;
|
|
@@ -118,20 +141,28 @@ function log(message: string): void {
|
|
|
118
141
|
process.stderr.write(`${message}\n`);
|
|
119
142
|
}
|
|
120
143
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Env-driven entry point shared by the CLI (`main`) and the Lambda handler
|
|
146
|
+
* (`scripts/lambda-handler.ts`). Reads the three required env vars, runs the
|
|
147
|
+
* migration or rollback, and RETURNS the parity report rather than printing it
|
|
148
|
+
* — the caller decides where the report goes (stdout for the CLI, the Lambda
|
|
149
|
+
* response for n8n). Never provisions members; safe to invoke unattended.
|
|
150
|
+
*/
|
|
151
|
+
export async function executeMigration(
|
|
152
|
+
opts: { apply: boolean; rollback?: boolean } = { apply: false },
|
|
153
|
+
): Promise<Record<string, unknown>> {
|
|
154
|
+
const dryRun = !opts.apply;
|
|
155
|
+
const rollback = Boolean(opts.rollback);
|
|
124
156
|
|
|
125
157
|
const baseUrl = normalizeBaseUrl(requireEnv("TWENTY_PUBLIC_URL"));
|
|
126
158
|
const apiKey = requireEnv("TWENTY_API_KEY");
|
|
127
159
|
const lastmileUrl = requireEnv("LASTMILE_DATABASE_URL");
|
|
128
|
-
const repPassword = process.env.TWENTY_REP_PASSWORD ?? "";
|
|
129
160
|
|
|
130
161
|
const client = new TwentyClient({ baseUrl, authToken: apiKey });
|
|
131
162
|
const reader = createLastmileReader(lastmileUrl);
|
|
132
163
|
|
|
133
164
|
const report: Record<string, unknown> = {
|
|
134
|
-
mode:
|
|
165
|
+
mode: rollback
|
|
135
166
|
? dryRun
|
|
136
167
|
? "rollback-dry-run"
|
|
137
168
|
: "rollback"
|
|
@@ -142,23 +173,25 @@ async function main(): Promise<void> {
|
|
|
142
173
|
};
|
|
143
174
|
|
|
144
175
|
try {
|
|
145
|
-
if (
|
|
176
|
+
if (rollback) {
|
|
146
177
|
await runRollback(client, report, dryRun);
|
|
147
|
-
|
|
178
|
+
} else {
|
|
179
|
+
await runMigration({ client, reader, baseUrl, report, dryRun });
|
|
148
180
|
}
|
|
149
|
-
await runMigration({
|
|
150
|
-
client,
|
|
151
|
-
reader,
|
|
152
|
-
baseUrl,
|
|
153
|
-
repPassword,
|
|
154
|
-
report,
|
|
155
|
-
dryRun,
|
|
156
|
-
skipInvites: args.skipInvites,
|
|
157
|
-
});
|
|
158
181
|
} finally {
|
|
159
182
|
await reader.close();
|
|
160
|
-
|
|
183
|
+
report.finishedAt = new Date().toISOString();
|
|
161
184
|
}
|
|
185
|
+
return report;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function main(): Promise<void> {
|
|
189
|
+
const args = parseArgs(process.argv.slice(2));
|
|
190
|
+
const report = await executeMigration({
|
|
191
|
+
apply: args.apply,
|
|
192
|
+
rollback: args.rollback,
|
|
193
|
+
});
|
|
194
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
162
195
|
}
|
|
163
196
|
|
|
164
197
|
async function runRollback(
|
|
@@ -172,6 +205,7 @@ async function runRollback(
|
|
|
172
205
|
{ entity: OPPORTUNITY, prefixes: ["lead:", "opportunity:"] },
|
|
173
206
|
{ entity: PERSON, prefixes: ["contact:"] },
|
|
174
207
|
{ entity: COMPANY, prefixes: ["account:"] },
|
|
208
|
+
{ entity: ORGANIZATION, prefixes: ["organization:"] },
|
|
175
209
|
];
|
|
176
210
|
for (const { entity, prefixes } of entities) {
|
|
177
211
|
const counters = emptyCounters();
|
|
@@ -196,13 +230,10 @@ async function runMigration(options: {
|
|
|
196
230
|
client: TwentyClient;
|
|
197
231
|
reader: LastmileReader;
|
|
198
232
|
baseUrl: string;
|
|
199
|
-
repPassword: string;
|
|
200
233
|
report: Record<string, unknown>;
|
|
201
234
|
dryRun: boolean;
|
|
202
|
-
skipInvites: boolean;
|
|
203
235
|
}): Promise<void> {
|
|
204
|
-
const { client, reader, baseUrl,
|
|
205
|
-
options;
|
|
236
|
+
const { client, reader, baseUrl, report, dryRun } = options;
|
|
206
237
|
|
|
207
238
|
// Phase A: preflight ------------------------------------------------------
|
|
208
239
|
log("preflight: connecting to LastMile and Twenty...");
|
|
@@ -216,16 +247,34 @@ async function runMigration(options: {
|
|
|
216
247
|
// Phase B: schema ensure --------------------------------------------------
|
|
217
248
|
log("schema: ensuring custom fields and stage options...");
|
|
218
249
|
let objects = await fetchObjectMetadata(client);
|
|
219
|
-
const
|
|
250
|
+
const productObjectEnsure = await ensureOpportunityProductObject(
|
|
220
251
|
client,
|
|
221
252
|
objects,
|
|
222
253
|
dryRun,
|
|
223
254
|
);
|
|
224
|
-
if (
|
|
225
|
-
|
|
255
|
+
if (
|
|
256
|
+
!dryRun &&
|
|
257
|
+
(productObjectEnsure.created || productObjectEnsure.relationCreated)
|
|
258
|
+
) {
|
|
226
259
|
objects = await fetchObjectMetadata(client);
|
|
227
260
|
}
|
|
228
|
-
|
|
261
|
+
const organizationObjectEnsure = await ensureOrganizationObject(
|
|
262
|
+
client,
|
|
263
|
+
objects,
|
|
264
|
+
dryRun,
|
|
265
|
+
);
|
|
266
|
+
if (
|
|
267
|
+
!dryRun &&
|
|
268
|
+
(organizationObjectEnsure.created ||
|
|
269
|
+
organizationObjectEnsure.relationCreated)
|
|
270
|
+
) {
|
|
271
|
+
// Re-read metadata so the field plan addresses the real object ids.
|
|
272
|
+
objects = await fetchObjectMetadata(client);
|
|
273
|
+
}
|
|
274
|
+
report.customObjects = {
|
|
275
|
+
opportunityProduct: productObjectEnsure,
|
|
276
|
+
organization: organizationObjectEnsure,
|
|
277
|
+
};
|
|
229
278
|
const schemaPlan = planSchemaEnsure(objects);
|
|
230
279
|
if (dryRun) {
|
|
231
280
|
report.schema = {
|
|
@@ -268,74 +317,25 @@ async function runMigration(options: {
|
|
|
268
317
|
sample: derivedEmails.slice(0, 20),
|
|
269
318
|
};
|
|
270
319
|
|
|
271
|
-
let adminClient: TwentyClient | null = null;
|
|
272
|
-
let workspaceId = process.env.TWENTY_WORKSPACE_ID ?? null;
|
|
273
|
-
if (!dryRun) {
|
|
274
|
-
const adminEmail = process.env.TWENTY_ADMIN_EMAIL;
|
|
275
|
-
const adminPassword = process.env.TWENTY_ADMIN_PASSWORD;
|
|
276
|
-
if (adminEmail && adminPassword) {
|
|
277
|
-
if (!repPassword)
|
|
278
|
-
throw new Error(
|
|
279
|
-
"Missing required environment variable: TWENTY_REP_PASSWORD",
|
|
280
|
-
);
|
|
281
|
-
const session = await adminSignIn({
|
|
282
|
-
baseUrl,
|
|
283
|
-
email: adminEmail,
|
|
284
|
-
password: adminPassword,
|
|
285
|
-
});
|
|
286
|
-
adminClient = new TwentyClient({
|
|
287
|
-
baseUrl,
|
|
288
|
-
authToken: session.accessToken,
|
|
289
|
-
});
|
|
290
|
-
workspaceId = workspaceId ?? session.workspaceId;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
320
|
const members = await ensureMembers({
|
|
294
321
|
dataClient: client,
|
|
295
|
-
adminClient,
|
|
296
322
|
reps: repsToProvision,
|
|
297
|
-
repPassword,
|
|
298
|
-
workspaceId,
|
|
299
|
-
// --skip-invites: match existing members only, invite nobody this run.
|
|
300
|
-
dryRun: dryRun || skipInvites,
|
|
301
323
|
});
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
324
|
+
if (members.missingMembers > 0) {
|
|
325
|
+
log(
|
|
326
|
+
`members: ${members.missingMembers} rep(s) have no Twenty member — run ` +
|
|
327
|
+
`scripts/provision-twenty-members.ts, then re-run to attach their owners`,
|
|
328
|
+
);
|
|
305
329
|
}
|
|
306
330
|
report.members = {
|
|
307
331
|
report: members.report,
|
|
308
|
-
|
|
309
|
-
|
|
332
|
+
resolved: members.ownerMap.size,
|
|
333
|
+
missingMembers: members.missingMembers,
|
|
310
334
|
};
|
|
311
335
|
// Archived reps still resolve for historical ownership when a member with a
|
|
312
336
|
// matching email already exists; unprovisioned owners map to null and are
|
|
313
337
|
// flagged per record (U5 edge case).
|
|
314
|
-
const ownerMap =
|
|
315
|
-
if (dryRun) {
|
|
316
|
-
// Planned members don't have ids yet; placeholders keep the dry-run's
|
|
317
|
-
// owner resolution (and its planned-mutation list) representative. Apply
|
|
318
|
-
// runs always resolve real member ids.
|
|
319
|
-
for (const row of members.report) {
|
|
320
|
-
if (row.action === "planned" && row.email && !ownerMap.has(row.repId)) {
|
|
321
|
-
ownerMap.set(row.repId, `planned-member:${row.email}`);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
for (const row of members.report) {
|
|
325
|
-
if (
|
|
326
|
-
row.action === "merged-duplicate-email" &&
|
|
327
|
-
row.email &&
|
|
328
|
-
!ownerMap.has(row.repId)
|
|
329
|
-
) {
|
|
330
|
-
const primary = ownerMap.get(
|
|
331
|
-
members.report.find(
|
|
332
|
-
(r) => r.email === row.email && r.repId !== row.repId,
|
|
333
|
-
)?.repId ?? "",
|
|
334
|
-
);
|
|
335
|
-
if (primary) ownerMap.set(row.repId, primary);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
338
|
+
const ownerMap = members.ownerMap;
|
|
339
339
|
|
|
340
340
|
// Owner refs in LastMile mix rep ids, aliases, and display names; the index
|
|
341
341
|
// adds unique alias/name keys on top of the provisioned rep→member map.
|
|
@@ -363,9 +363,72 @@ async function runMigration(options: {
|
|
|
363
363
|
}
|
|
364
364
|
report.companies = "pending";
|
|
365
365
|
|
|
366
|
+
// Phase D': dispatch customers as companies (opt-in) ---------------------
|
|
367
|
+
// TEI wants the CRM to carry real, active customers — not just the ~800
|
|
368
|
+
// accounts with opportunity history. Gated by env
|
|
369
|
+
// TWENTY_DISPATCH_CUSTOMER_COHORT_DAYS (e.g. "5"): dispatch customers with
|
|
370
|
+
// an order inside that window become companies keyed customer:<id>.
|
|
371
|
+
// Customers whose name already has an account-sourced company are skipped
|
|
372
|
+
// (the unique-name crosswalk) so no duplicate companies appear.
|
|
373
|
+
const dispatchCustomerCounters = emptyCounters();
|
|
374
|
+
const cohortDaysRaw = process.env.TWENTY_DISPATCH_CUSTOMER_COHORT_DAYS;
|
|
375
|
+
if (cohortDaysRaw) {
|
|
376
|
+
const cohortDays = Number(cohortDaysRaw);
|
|
377
|
+
// TWENTY_DISPATCH_CUSTOMER_OPEN_INVOICES=1 widens the cohort to customers
|
|
378
|
+
// holding an open invoice (any age) — the twin carries ALL open invoices,
|
|
379
|
+
// so their customers must have CRM records even without a recent order.
|
|
380
|
+
const includeOpenInvoices = ["1", "true"].includes(
|
|
381
|
+
(process.env.TWENTY_DISPATCH_CUSTOMER_OPEN_INVOICES ?? "").toLowerCase(),
|
|
382
|
+
);
|
|
383
|
+
log(
|
|
384
|
+
`records: loading dispatch customer cohort (${cohortDays}d` +
|
|
385
|
+
`${includeOpenInvoices ? " + open invoices" : ""})...`,
|
|
386
|
+
);
|
|
387
|
+
const cohort = await reader.readOrderCohortCustomers(
|
|
388
|
+
cohortDays,
|
|
389
|
+
includeOpenInvoices,
|
|
390
|
+
);
|
|
391
|
+
const fresh = cohort.filter((customer) => !customer.hasAccountNameMatch);
|
|
392
|
+
log(
|
|
393
|
+
`records: dispatch cohort ${cohort.length} customers, ` +
|
|
394
|
+
`${cohort.length - fresh.length} already covered by account companies`,
|
|
395
|
+
);
|
|
396
|
+
const mappedCustomers = fresh.map((customer) =>
|
|
397
|
+
mapDispatchCustomer(customer, ownerIndex),
|
|
398
|
+
);
|
|
399
|
+
const customerIdBySourceId = await upsertRecords({
|
|
400
|
+
client,
|
|
401
|
+
entity: COMPANY,
|
|
402
|
+
mapped: mappedCustomers,
|
|
403
|
+
dryRun,
|
|
404
|
+
counters: dispatchCustomerCounters,
|
|
405
|
+
});
|
|
406
|
+
for (const [key, value] of customerIdBySourceId) {
|
|
407
|
+
companyIdBySourceId.set(key, value);
|
|
408
|
+
}
|
|
409
|
+
report.dispatchCustomers = {
|
|
410
|
+
cohort: cohort.length,
|
|
411
|
+
skippedAccountCovered: cohort.length - fresh.length,
|
|
412
|
+
...summarizeCounters(dispatchCustomerCounters),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
366
416
|
log("records: loading people...");
|
|
367
|
-
const
|
|
417
|
+
const rawContacts = await reader.readContacts();
|
|
418
|
+
// Twenty requires a unique person email; LastMile does not. Give the address
|
|
419
|
+
// to the first contact by id and migrate the rest without one, rather than
|
|
420
|
+
// losing them to a duplicate-key error (29 people failed this way on the
|
|
421
|
+
// first seed).
|
|
422
|
+
const contacts = dedupeContactEmails(rawContacts);
|
|
368
423
|
const personCounters = emptyCounters();
|
|
424
|
+
const keptById = new Map(contacts.map((contact) => [contact.id, contact]));
|
|
425
|
+
for (const contact of rawContacts) {
|
|
426
|
+
if (contact.email && !keptById.get(contact.id)?.email) {
|
|
427
|
+
personCounters.warnings.push(
|
|
428
|
+
`duplicate email ${contact.email} dropped from contact ${contact.id}`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
369
432
|
await upsertRecords({
|
|
370
433
|
client,
|
|
371
434
|
entity: PERSON,
|
|
@@ -375,18 +438,40 @@ async function runMigration(options: {
|
|
|
375
438
|
});
|
|
376
439
|
report.people = "pending";
|
|
377
440
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
441
|
+
// Organizations (LastMile branches, e.g. "GWO 300") — opportunities link to
|
|
442
|
+
// them, so they load before the CRM tasks.
|
|
443
|
+
log("records: loading organizations...");
|
|
444
|
+
const organizations = await reader.readOrganizations();
|
|
445
|
+
const organizationCounters = emptyCounters();
|
|
446
|
+
const mappedOrganizations = organizations.map(mapOrganization);
|
|
447
|
+
const organizationIdBySourceId = await upsertRecords({
|
|
448
|
+
client,
|
|
449
|
+
entity: ORGANIZATION,
|
|
450
|
+
mapped: mappedOrganizations,
|
|
451
|
+
dryRun,
|
|
452
|
+
counters: organizationCounters,
|
|
453
|
+
});
|
|
454
|
+
if (dryRun) {
|
|
455
|
+
for (const record of mappedOrganizations) {
|
|
456
|
+
if (!organizationIdBySourceId.has(record.sourceId)) {
|
|
457
|
+
organizationIdBySourceId.set(
|
|
458
|
+
record.sourceId,
|
|
459
|
+
`planned:${record.sourceId}`,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
report.organizations = summarizeCounters(organizationCounters);
|
|
465
|
+
|
|
466
|
+
// The `task` table is the CRM: status, owner, organization, and products all
|
|
467
|
+
// come from it. The `opportunity`/`lead` tables' own stage columns are stale
|
|
468
|
+
// (they disagree with the task status on 889 of 950 opportunities).
|
|
469
|
+
log("records: loading CRM tasks (leads + opportunities)...");
|
|
470
|
+
const crmTasks = await reader.readCrmTasks();
|
|
383
471
|
const opportunityCounters = emptyCounters();
|
|
384
|
-
const mappedOpportunities =
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
mapOpportunity(opportunity, ownerIndex, companyIdBySourceId),
|
|
388
|
-
),
|
|
389
|
-
];
|
|
472
|
+
const mappedOpportunities = crmTasks.map((task) =>
|
|
473
|
+
mapCrmTask(task, ownerIndex, companyIdBySourceId, organizationIdBySourceId),
|
|
474
|
+
);
|
|
390
475
|
const opportunityIdBySourceId = await upsertRecords({
|
|
391
476
|
client,
|
|
392
477
|
entity: OPPORTUNITY,
|
|
@@ -406,13 +491,34 @@ async function runMigration(options: {
|
|
|
406
491
|
}
|
|
407
492
|
report.opportunities = "pending";
|
|
408
493
|
|
|
494
|
+
// The product catalog (7 rows) must exist before the lines that point at it.
|
|
495
|
+
log("records: ensuring the product catalog...");
|
|
496
|
+
const catalogCounters = emptyCounters();
|
|
497
|
+
const productIdBySourceId = await upsertRecords({
|
|
498
|
+
client,
|
|
499
|
+
entity: PRODUCT,
|
|
500
|
+
mapped: PRODUCT_CATALOG.map(mapProduct),
|
|
501
|
+
dryRun,
|
|
502
|
+
counters: catalogCounters,
|
|
503
|
+
});
|
|
504
|
+
if (dryRun) {
|
|
505
|
+
for (const name of PRODUCT_CATALOG) {
|
|
506
|
+
const id = productSourceId(name);
|
|
507
|
+
if (!productIdBySourceId.has(id)) {
|
|
508
|
+
productIdBySourceId.set(id, `planned:${id}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
report.productCatalog = summarizeCounters(catalogCounters);
|
|
513
|
+
|
|
409
514
|
log("records: loading opportunity product lines...");
|
|
410
|
-
const items = await reader.readOpportunityItems();
|
|
411
515
|
const productCounters = emptyCounters();
|
|
516
|
+
const mappedProducts = crmTasks.flatMap(mapTaskProducts);
|
|
412
517
|
await upsertOpportunityProducts({
|
|
413
518
|
client,
|
|
414
|
-
products:
|
|
519
|
+
products: mappedProducts,
|
|
415
520
|
opportunityIdBySourceId,
|
|
521
|
+
productIdBySourceId,
|
|
416
522
|
dryRun,
|
|
417
523
|
counters: productCounters,
|
|
418
524
|
});
|
|
@@ -420,9 +526,10 @@ async function runMigration(options: {
|
|
|
420
526
|
|
|
421
527
|
// Phase E: notes + attachments -------------------------------------------
|
|
422
528
|
log("annexes: loading notes...");
|
|
423
|
-
const [comments, customerNotes] = await Promise.all([
|
|
529
|
+
const [comments, customerNotes, statusChanges] = await Promise.all([
|
|
424
530
|
reader.readCrmComments(),
|
|
425
531
|
reader.readCustomerNotes(),
|
|
532
|
+
reader.readTaskStatusChanges(),
|
|
426
533
|
]);
|
|
427
534
|
const noteCounters = emptyCounters();
|
|
428
535
|
const noteTargets = new Map<string, string>([
|
|
@@ -431,6 +538,10 @@ async function runMigration(options: {
|
|
|
431
538
|
]);
|
|
432
539
|
const mappedNotes = [
|
|
433
540
|
...comments.map(mapCrmComment),
|
|
541
|
+
...statusChanges.flatMap((change) => {
|
|
542
|
+
const mapped = mapTaskStatusActivity(change);
|
|
543
|
+
return mapped ? [mapped] : [];
|
|
544
|
+
}),
|
|
434
545
|
...customerNotes.flatMap((note) => {
|
|
435
546
|
const mapped = mapCustomerNote(note);
|
|
436
547
|
if (!mapped) {
|
|
@@ -492,12 +603,9 @@ async function runMigration(options: {
|
|
|
492
603
|
await mirrorDeletions({
|
|
493
604
|
client,
|
|
494
605
|
entity: OPPORTUNITY,
|
|
495
|
-
liveSourceIds: new Set(
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
sourceId("opportunity", opportunity.id),
|
|
499
|
-
),
|
|
500
|
-
]),
|
|
606
|
+
liveSourceIds: new Set(
|
|
607
|
+
crmTasks.map((task) => sourceId(task.entityType, task.entityId)),
|
|
608
|
+
),
|
|
501
609
|
ownedPrefixes: ["lead:", "opportunity:"],
|
|
502
610
|
dryRun,
|
|
503
611
|
counters: opportunityCounters,
|
|
@@ -510,12 +618,7 @@ async function runMigration(options: {
|
|
|
510
618
|
await mirrorDeletions({
|
|
511
619
|
client,
|
|
512
620
|
entity: OPPORTUNITY_PRODUCT,
|
|
513
|
-
liveSourceIds: new Set(
|
|
514
|
-
items.map(
|
|
515
|
-
(item) =>
|
|
516
|
-
`${sourceId("opportunity_item", item.opportunityId)}#${item.index}`,
|
|
517
|
-
),
|
|
518
|
-
),
|
|
621
|
+
liveSourceIds: new Set(mappedProducts.map((product) => product.sourceId)),
|
|
519
622
|
ownedPrefixes: ["opportunity_item:"],
|
|
520
623
|
dryRun,
|
|
521
624
|
counters: productCounters,
|
|
@@ -537,16 +640,15 @@ async function runMigration(options: {
|
|
|
537
640
|
opportunities: opportunityCounters,
|
|
538
641
|
});
|
|
539
642
|
|
|
540
|
-
const anyFailures =
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
].some((counters) => counters.failed > 0);
|
|
643
|
+
const anyFailures = [
|
|
644
|
+
organizationCounters,
|
|
645
|
+
companyCounters,
|
|
646
|
+
personCounters,
|
|
647
|
+
opportunityCounters,
|
|
648
|
+
productCounters,
|
|
649
|
+
noteCounters,
|
|
650
|
+
attachmentCounters,
|
|
651
|
+
].some((counters) => counters.failed > 0);
|
|
550
652
|
report.ok = !anyFailures;
|
|
551
653
|
if (anyFailures) process.exitCode = 2;
|
|
552
654
|
}
|
|
@@ -18,10 +18,19 @@
|
|
|
18
18
|
* npx tsx scripts/provision-twenty-members.ts --apply # create them
|
|
19
19
|
* npx tsx scripts/provision-twenty-members.ts --apply --only me@x.com # one rep, for proof
|
|
20
20
|
*
|
|
21
|
+
* ROTATION (plan R5) — required at cutover, on abort, and on an over-long
|
|
22
|
+
* validation window. The shared TWENTY_REP_PASSWORD must not outlive the phase
|
|
23
|
+
* that needed it:
|
|
24
|
+
* npx tsx scripts/provision-twenty-members.ts --rotate # dry-run: who rotates
|
|
25
|
+
* npx tsx scripts/provision-twenty-members.ts --rotate --apply # unique random password each
|
|
26
|
+
* Each rep gets an independent random password, printed once to stdout. Hand
|
|
27
|
+
* them out, or have reps use Twenty's password reset.
|
|
28
|
+
*
|
|
21
29
|
* After provisioning, re-run migrate-lastmile.ts --apply so every company and
|
|
22
30
|
* opportunity picks up its owner.
|
|
23
31
|
*/
|
|
24
32
|
|
|
33
|
+
import { randomBytes } from "node:crypto";
|
|
25
34
|
import process from "node:process";
|
|
26
35
|
|
|
27
36
|
import pg from "pg";
|
|
@@ -30,6 +39,7 @@ import { createLastmileReader } from "./lib/lastmile-reader";
|
|
|
30
39
|
import { deriveRepEmail } from "./lib/mappers";
|
|
31
40
|
import {
|
|
32
41
|
provisionMembers,
|
|
42
|
+
rotateMemberPasswords,
|
|
33
43
|
type ProvisionRep,
|
|
34
44
|
} from "./lib/provision-members-db";
|
|
35
45
|
|
|
@@ -114,10 +124,12 @@ async function main(): Promise<void> {
|
|
|
114
124
|
const argv = process.argv.slice(2);
|
|
115
125
|
let apply = false;
|
|
116
126
|
let only: string | null = null;
|
|
127
|
+
let rotate = false;
|
|
117
128
|
for (let index = 0; index < argv.length; index += 1) {
|
|
118
129
|
const arg = argv[index];
|
|
119
130
|
if (arg === "--apply") apply = true;
|
|
120
131
|
else if (arg === "--dry-run") apply = false;
|
|
132
|
+
else if (arg === "--rotate") rotate = true;
|
|
121
133
|
else if (arg === "--only") {
|
|
122
134
|
only = argv[++index] ?? null;
|
|
123
135
|
if (!only) throw new Error("--only requires an email");
|
|
@@ -127,7 +139,8 @@ async function main(): Promise<void> {
|
|
|
127
139
|
|
|
128
140
|
const twentyDbUrl = requireEnv("TWENTY_DATABASE_URL");
|
|
129
141
|
const lastmileUrl = requireEnv("LASTMILE_DATABASE_URL");
|
|
130
|
-
|
|
142
|
+
// Rotation mints its own passwords; provisioning uses the shared one.
|
|
143
|
+
const password = rotate ? "" : requireEnv("TWENTY_REP_PASSWORD");
|
|
131
144
|
const emailDomain =
|
|
132
145
|
process.env.TWENTY_REP_EMAIL_DOMAIN ?? "texasenterprises.com";
|
|
133
146
|
|
|
@@ -177,6 +190,36 @@ async function main(): Promise<void> {
|
|
|
177
190
|
if (reps.length === 0) throw new Error(`--only ${only} matched no rep`);
|
|
178
191
|
}
|
|
179
192
|
|
|
193
|
+
if (rotate) {
|
|
194
|
+
const minted = new Map<string, string>();
|
|
195
|
+
const result = await rotateMemberPasswords({
|
|
196
|
+
databaseUrl: twentyDbUrl,
|
|
197
|
+
emails: reps.map((rep) => rep.email),
|
|
198
|
+
passwordFor: (email: string) => {
|
|
199
|
+
// 18 bytes of base64url ~= 24 chars, well inside bcrypt's 72-byte limit.
|
|
200
|
+
const secret = randomBytes(18).toString("base64url");
|
|
201
|
+
minted.set(email, secret);
|
|
202
|
+
return secret;
|
|
203
|
+
},
|
|
204
|
+
dryRun,
|
|
205
|
+
log,
|
|
206
|
+
});
|
|
207
|
+
process.stdout.write(
|
|
208
|
+
`${JSON.stringify(
|
|
209
|
+
{
|
|
210
|
+
mode: dryRun ? "rotate-dry-run" : "rotate",
|
|
211
|
+
rotated: result.rotated,
|
|
212
|
+
notFound: result.notFound,
|
|
213
|
+
// Printed once, never persisted. Hand these out, then discard.
|
|
214
|
+
passwords: dryRun ? undefined : Object.fromEntries(minted),
|
|
215
|
+
},
|
|
216
|
+
null,
|
|
217
|
+
2,
|
|
218
|
+
)}\n`,
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
180
223
|
const result = await provisionMembers({
|
|
181
224
|
databaseUrl: twentyDbUrl,
|
|
182
225
|
workspaceId: context.workspaceId,
|