thinkwork-cli 0.13.1 → 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-4DZ266J5.js → api-client-4P4JZOBX.js} +3 -1
- package/dist/bowser-6Z6JFAIR.js +2822 -0
- package/dist/{chunk-Y2WHJECR.js → chunk-4OMDWYZQ.js} +5 -44
- package/dist/chunk-AD2NIWB5.js +6516 -0
- package/dist/chunk-DV3MRBR5.js +43 -0
- package/dist/chunk-EI5ZX7CN.js +522 -0
- package/dist/chunk-EUJ7X6YD.js +126 -0
- package/dist/chunk-GG5WKBOX.js +436 -0
- package/dist/chunk-I64EZSU5.js +358 -0
- package/dist/chunk-KMIJ5KGT.js +128 -0
- package/dist/chunk-MWQ7RO76.js +137 -0
- package/dist/chunk-OCPHU34C.js +996 -0
- package/dist/chunk-OLUQ4U2Z.js +1728 -0
- package/dist/chunk-XYM7PE2A.js +2264 -0
- package/dist/chunk-YQCYD6NE.js +3299 -0
- package/dist/cli.js +49156 -31257
- 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-2OTNTL4N.js +315 -0
- package/dist/dist-es-2XVAH4BQ.js +14726 -0
- package/dist/dist-es-3DPDUJAH.js +2000 -0
- package/dist/dist-es-BNZTBBVC.js +176 -0
- package/dist/dist-es-CD2RBDAC.js +88 -0
- package/dist/dist-es-I4FW57PS.js +88 -0
- package/dist/dist-es-KL7Y5NB2.js +377 -0
- package/dist/dist-es-KVFWJNDY.js +318 -0
- package/dist/dist-es-PS73DWNL.js +70 -0
- package/dist/dist-es-S3NZQFGT.js +170 -0
- package/dist/dist-es-SGCA4P32.js +48 -0
- package/dist/dist-es-WBM6J4RA.js +70 -0
- package/dist/dist-es-ZGVADYIF.js +485 -0
- package/dist/dist-es-ZKC2QKD2.js +377 -0
- package/dist/drizzle/0076_scheduled_jobs_marco_backfill.sql +2 -1
- package/dist/drizzle/0079_seed_tenant_customize_catalog.sql +1 -0
- package/dist/drizzle/0090_brain_schema_extraction.sql +10 -0
- package/dist/drizzle/0132_drop_computer_tables.sql +3 -0
- package/dist/drizzle/0160_compliance_event_types_plugins.sql +2 -0
- package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +1 -0
- package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +1 -0
- package/dist/drizzle/0227_analyst_reader_role.sql +17 -20
- package/dist/drizzle/0230_analyst_rls.sql +28 -36
- package/dist/drizzle/0250_kg_schema_extraction.sql +27 -0
- package/dist/drizzle/0251_brain_stragglers.sql +21 -0
- package/dist/drizzle/0253_drop_brain_substrate.sql +2 -0
- package/dist/drizzle/0257_agents_runtime_allow_harness.sql +24 -0
- package/dist/drizzle/0261_native_auth_control_plane.sql +332 -0
- package/dist/drizzle/0262_auth_subscription_tickets.sql +59 -0
- package/dist/drizzle/0263_agentcore_runtime_identifier.sql +69 -0
- package/dist/drizzle/0263_drop_workos_auth_runtime.sql +236 -0
- package/dist/drizzle/0264_auth_enrollment_attempt_limits.sql +15 -0
- package/dist/drizzle/0265_eval_runs_requester_identity.sql +28 -0
- package/dist/drizzle/0265_require_fully_valid_auth_publication.sql +27 -0
- package/dist/drizzle/0266_auth_identity_recovery_grants.sql +9 -0
- package/dist/drizzle/0266_eval_profiles_runtime_type.sql +12 -0
- package/dist/drizzle/0267_ontology_candidate_rejections.sql +53 -0
- package/dist/drizzle/0268_identity_crosswalk_routing.sql +182 -0
- package/dist/drizzle/0269_ontology_system_map.sql +31 -0
- 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/event-streams-7JNLJEEL.js +894 -0
- package/dist/event-streams-KDF5RWR6.js +1515 -0
- package/dist/loadSso-ETCXTR4I.js +576 -0
- package/dist/loadSso-UBD7KUHE.js +583 -0
- package/dist/plugins/README.md +2 -2
- package/dist/plugins/catalog/package.json +1 -5
- package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +6 -4
- package/dist/plugins/catalog/src/__tests__/catalog.test.ts +21 -21
- package/dist/plugins/catalog/src/__tests__/contracts.test.ts +102 -94
- package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +7 -8
- package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -20
- package/dist/plugins/catalog/src/contracts.ts +17 -179
- package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -24
- 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-5QZNRSOO.js +821 -0
- package/dist/sso-oidc-XK673P54.js +837 -0
- package/dist/sts-OXI2UALF.js +1113 -0
- package/dist/sts-SQW4NBK3.js +1117 -0
- package/dist/terraform/examples/greenfield/main.tf +391 -176
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +26 -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 +253 -0
- package/dist/terraform/modules/app/agentcore-identity/outputs.tf +51 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/bootstrap_twenty_oauth_client.sh +239 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/delete_identity.sh +22 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/delete_twenty_identity.sh +16 -0
- 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 +55 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_identity.sh +73 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_identity.sh +45 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_provider.mjs +123 -0
- 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 +60 -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/appsync-subscriptions/main.tf +127 -21
- package/dist/terraform/modules/app/appsync-subscriptions/outputs.tf +0 -6
- package/dist/terraform/modules/app/appsync-subscriptions/variables.tf +5 -0
- 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 +3689 -233
- package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +2579 -140
- 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 +531 -619
- package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +350 -241
- package/dist/terraform/modules/app/lambda-api/main.tf +75 -13
- 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 -10
- package/dist/terraform/modules/app/lambda-api/runtime-config.tf +3 -21
- package/dist/terraform/modules/app/lambda-api/turn-assertion.tf +45 -0
- package/dist/terraform/modules/app/lambda-api/variables.tf +307 -121
- 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 +367 -72
- package/dist/terraform/modules/foundation/cognito/outputs.tf +47 -0
- package/dist/terraform/modules/foundation/cognito/variables.tf +131 -9
- package/dist/terraform/modules/thinkwork/README.md +5 -1
- package/dist/terraform/modules/thinkwork/main.tf +459 -234
- package/dist/terraform/modules/thinkwork/outputs.tf +44 -62
- package/dist/terraform/modules/thinkwork/variables.tf +353 -89
- package/dist/terraform/schema.graphql +69 -36
- 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/USER.md +1 -0
- 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 +3 -2
- package/dist/drizzle/0261_thread_turns_origin_turn_index.sql +0 -11
- 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/plugins/workos-auth/package.json +0 -22
- package/dist/plugins/workos-auth/src/index.ts +0 -30
- package/dist/plugins/workos-auth/src/manifest.ts +0 -40
- package/dist/plugins/workos-auth/src/provider-contract.ts +0 -39
- package/dist/plugins/workos-auth/test/manifest.test.ts +0 -94
- package/dist/plugins/workos-auth/tsconfig.json +0 -8
- 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-harness/main.tf +0 -101
- package/dist/terraform/modules/app/agentcore-harness/outputs.tf +0 -9
- package/dist/terraform/modules/app/agentcore-harness/variables.tf +0 -25
- 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/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
|
@@ -28,70 +28,56 @@ function dataClientWithMembers(
|
|
|
28
28
|
} as unknown as TwentyClient;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
describe("ensureMembers (
|
|
32
|
-
it("
|
|
31
|
+
describe("ensureMembers (matches existing members; never creates)", () => {
|
|
32
|
+
it("maps reps onto members by email, case-insensitively", async () => {
|
|
33
33
|
const dataClient = dataClientWithMembers([
|
|
34
34
|
{ id: "wm-1", userEmail: "Jane@TEI.com" },
|
|
35
35
|
]);
|
|
36
|
-
const result = await ensureMembers({
|
|
37
|
-
dataClient,
|
|
38
|
-
adminClient: null,
|
|
39
|
-
reps: [
|
|
40
|
-
rep(),
|
|
41
|
-
rep({ repId: "rep_2", email: "bob@tei.com" }),
|
|
42
|
-
rep({ repId: "rep_3", email: "amy@tei.com" }),
|
|
43
|
-
],
|
|
44
|
-
repPassword: "test",
|
|
45
|
-
workspaceId: null,
|
|
46
|
-
dryRun: true,
|
|
47
|
-
});
|
|
36
|
+
const result = await ensureMembers({ dataClient, reps: [rep()] });
|
|
48
37
|
expect(result.ownerMap.get("rep_1")).toBe("wm-1");
|
|
49
|
-
expect(
|
|
50
|
-
|
|
51
|
-
).toHaveLength(2);
|
|
52
|
-
expect(result.hadFailures).toBe(false);
|
|
38
|
+
expect(result.report[0]).toMatchObject({ action: "existing" });
|
|
39
|
+
expect(result.missingMembers).toBe(0);
|
|
53
40
|
});
|
|
54
41
|
|
|
55
|
-
it("
|
|
42
|
+
it("reports a rep with no Twenty member instead of failing the run", async () => {
|
|
43
|
+
// provision-twenty-members.ts creates members; this module only matches
|
|
44
|
+
// them. Records for an unmatched rep load ownerless and heal on a re-run.
|
|
56
45
|
const dataClient = dataClientWithMembers([]);
|
|
46
|
+
const result = await ensureMembers({ dataClient, reps: [rep()] });
|
|
47
|
+
expect(result.ownerMap.size).toBe(0);
|
|
48
|
+
expect(result.report[0]).toMatchObject({ action: "missing-member" });
|
|
49
|
+
expect(result.missingMembers).toBe(1);
|
|
50
|
+
expect(result.hadFailures).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("marks emailless reps unprovisionable and keeps going", async () => {
|
|
54
|
+
const dataClient = dataClientWithMembers([
|
|
55
|
+
{ id: "wm-bob", userEmail: "bob@tei.com" },
|
|
56
|
+
]);
|
|
57
57
|
const result = await ensureMembers({
|
|
58
58
|
dataClient,
|
|
59
|
-
adminClient: null,
|
|
60
59
|
reps: [
|
|
61
60
|
rep({ email: null }),
|
|
62
61
|
rep({ repId: "rep_2", email: "bob@tei.com" }),
|
|
63
62
|
],
|
|
64
|
-
repPassword: "test",
|
|
65
|
-
workspaceId: null,
|
|
66
|
-
dryRun: true,
|
|
67
63
|
});
|
|
68
64
|
expect(result.report[0]).toMatchObject({
|
|
69
65
|
repId: "rep_1",
|
|
70
66
|
action: "unprovisionable",
|
|
71
67
|
});
|
|
72
|
-
expect(result.
|
|
73
|
-
repId: "rep_2",
|
|
74
|
-
action: "planned",
|
|
75
|
-
});
|
|
68
|
+
expect(result.ownerMap.get("rep_2")).toBe("wm-bob");
|
|
76
69
|
});
|
|
77
70
|
|
|
78
|
-
it("
|
|
79
|
-
// Observed live in LastMile: a "house" sales_rep row reusing a real rep's
|
|
80
|
-
// email. Email is the login identity, so the reps merge — reported, never
|
|
81
|
-
// silent.
|
|
71
|
+
it("collapses reps sharing an email onto one login, and says so", async () => {
|
|
82
72
|
const dataClient = dataClientWithMembers([
|
|
83
73
|
{ id: "wm-jane", userEmail: "jane@tei.com" },
|
|
84
74
|
]);
|
|
85
75
|
const result = await ensureMembers({
|
|
86
76
|
dataClient,
|
|
87
|
-
adminClient: null,
|
|
88
77
|
reps: [
|
|
89
78
|
rep(),
|
|
90
79
|
rep({ repId: "rep_2", firstName: "Employee", lastName: "Accounts" }),
|
|
91
80
|
],
|
|
92
|
-
repPassword: "test",
|
|
93
|
-
workspaceId: null,
|
|
94
|
-
dryRun: true,
|
|
95
81
|
});
|
|
96
82
|
expect(result.ownerMap.get("rep_1")).toBe("wm-jane");
|
|
97
83
|
expect(result.ownerMap.get("rep_2")).toBe("wm-jane");
|
|
@@ -99,101 +85,14 @@ describe("ensureMembers (dry run)", () => {
|
|
|
99
85
|
action: "merged-duplicate-email",
|
|
100
86
|
});
|
|
101
87
|
});
|
|
102
|
-
});
|
|
103
88
|
|
|
104
|
-
|
|
105
|
-
it("requires the admin client when new members must be provisioned", async () => {
|
|
89
|
+
it("leaves a merged rep unmapped when its primary has no member either", async () => {
|
|
106
90
|
const dataClient = dataClientWithMembers([]);
|
|
107
|
-
await
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
workspaceId: "ws-1",
|
|
114
|
-
dryRun: false,
|
|
115
|
-
}),
|
|
116
|
-
).rejects.toThrow(/TWENTY_ADMIN_EMAIL/);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("a signUpInWorkspace rejection is reported per-user and the run continues", async () => {
|
|
120
|
-
const membersAfterSignup = [{ id: "wm-bob", userEmail: "bob@tei.com" }];
|
|
121
|
-
let listCalls = 0;
|
|
122
|
-
const dataClient = {
|
|
123
|
-
requestWithRetry: vi.fn(async () => {
|
|
124
|
-
listCalls += 1;
|
|
125
|
-
return {
|
|
126
|
-
workspaceMembers: {
|
|
127
|
-
// First list: empty; lists after signup include bob only.
|
|
128
|
-
edges: (listCalls === 1 ? [] : membersAfterSignup).map(
|
|
129
|
-
(member) => ({
|
|
130
|
-
node: member,
|
|
131
|
-
}),
|
|
132
|
-
),
|
|
133
|
-
pageInfo: { hasNextPage: false, endCursor: null },
|
|
134
|
-
},
|
|
135
|
-
};
|
|
136
|
-
}),
|
|
137
|
-
endpoint: (path: string) => `https://crm.example.com${path}`,
|
|
138
|
-
} as unknown as TwentyClient;
|
|
139
|
-
|
|
140
|
-
const adminClient = {
|
|
141
|
-
requestOnce: vi.fn(async () => ({
|
|
142
|
-
sendInvitations: { success: true, result: [] },
|
|
143
|
-
})),
|
|
144
|
-
requestWithRetry: vi.fn(async (_path: string, query: string) => {
|
|
145
|
-
if (query.includes("findWorkspaceInvitations")) {
|
|
146
|
-
return {
|
|
147
|
-
findWorkspaceInvitations: [
|
|
148
|
-
{ id: "inv-jane", email: "jane@tei.com" },
|
|
149
|
-
{ id: "inv-bob", email: "bob@tei.com" },
|
|
150
|
-
],
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
return { getWorkspaceInvitationToken: "token-123" };
|
|
154
|
-
}),
|
|
155
|
-
} as unknown as TwentyClient;
|
|
156
|
-
|
|
157
|
-
// jane's signup fails (fetch mock), bob's succeeds.
|
|
158
|
-
const fetchMock = vi.fn(
|
|
159
|
-
async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
160
|
-
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
|
161
|
-
variables?: { email?: string };
|
|
162
|
-
};
|
|
163
|
-
if (body.variables?.email === "jane@tei.com") {
|
|
164
|
-
return new Response(
|
|
165
|
-
JSON.stringify({ errors: [{ message: "invite token invalid" }] }),
|
|
166
|
-
{
|
|
167
|
-
status: 200,
|
|
168
|
-
},
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
return new Response(
|
|
172
|
-
JSON.stringify({ data: { signUpInWorkspace: {} } }),
|
|
173
|
-
{ status: 200 },
|
|
174
|
-
);
|
|
175
|
-
},
|
|
176
|
-
);
|
|
177
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
178
|
-
try {
|
|
179
|
-
const result = await ensureMembers({
|
|
180
|
-
dataClient,
|
|
181
|
-
adminClient,
|
|
182
|
-
reps: [rep(), rep({ repId: "rep_2", email: "bob@tei.com" })],
|
|
183
|
-
repPassword: "test",
|
|
184
|
-
workspaceId: "ws-1",
|
|
185
|
-
dryRun: false,
|
|
186
|
-
});
|
|
187
|
-
expect(result.hadFailures).toBe(true);
|
|
188
|
-
expect(result.report.find((row) => row.repId === "rep_1")).toMatchObject({
|
|
189
|
-
action: "failed",
|
|
190
|
-
});
|
|
191
|
-
expect(result.report.find((row) => row.repId === "rep_2")).toMatchObject({
|
|
192
|
-
action: "invited+signedUp",
|
|
193
|
-
});
|
|
194
|
-
expect(result.ownerMap.get("rep_2")).toBe("wm-bob");
|
|
195
|
-
} finally {
|
|
196
|
-
vi.unstubAllGlobals();
|
|
197
|
-
}
|
|
91
|
+
const result = await ensureMembers({
|
|
92
|
+
dataClient,
|
|
93
|
+
reps: [rep(), rep({ repId: "rep_2" })],
|
|
94
|
+
});
|
|
95
|
+
expect(result.ownerMap.size).toBe(0);
|
|
96
|
+
expect(result.missingMembers).toBe(1);
|
|
198
97
|
});
|
|
199
98
|
});
|
|
@@ -41,3 +41,13 @@ describe("quoteIdent", () => {
|
|
|
41
41
|
expect(() => quoteIdent("public")).toThrow(/Refusing/);
|
|
42
42
|
});
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
describe("hashPassword salts per call", () => {
|
|
46
|
+
it("never reuses a salt, so rotation does not share hashes across reps", () => {
|
|
47
|
+
const a = hashPassword("SharedSecret1!", 4);
|
|
48
|
+
const b = hashPassword("SharedSecret1!", 4);
|
|
49
|
+
expect(a).not.toBe(b);
|
|
50
|
+
expect(bcrypt.compareSync("SharedSecret1!", a)).toBe(true);
|
|
51
|
+
expect(bcrypt.compareSync("SharedSecret1!", b)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -61,9 +61,9 @@ function bareObjects() {
|
|
|
61
61
|
opportunity: [stageField],
|
|
62
62
|
note: [],
|
|
63
63
|
attachment: [],
|
|
64
|
-
//
|
|
65
|
-
// has none of its fields yet.
|
|
64
|
+
// Custom objects exist (their ensure* ran) but have none of their fields.
|
|
66
65
|
opportunityProduct: [],
|
|
66
|
+
organization: [],
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -115,6 +115,7 @@ function provisionedObjects() {
|
|
|
115
115
|
options: null,
|
|
116
116
|
},
|
|
117
117
|
],
|
|
118
|
+
organization: withAll("organization"),
|
|
118
119
|
});
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -122,15 +123,18 @@ describe("planSchemaEnsure", () => {
|
|
|
122
123
|
it("plans every missing field and all stage options on an empty workspace", () => {
|
|
123
124
|
const plan = planSchemaEnsure(bareObjects());
|
|
124
125
|
expect(plan.createFields).toHaveLength(FIELD_SPECS.length);
|
|
126
|
+
// NEW already exists on the workspace, so it is merged, not duplicated.
|
|
125
127
|
expect(plan.stageOptionsToAdd).toEqual(
|
|
126
|
-
MIGRATION_STAGE_OPTIONS.
|
|
128
|
+
MIGRATION_STAGE_OPTIONS.filter((option) => option.value !== "NEW").map(
|
|
129
|
+
(option) => option.value,
|
|
130
|
+
),
|
|
127
131
|
);
|
|
128
132
|
// Full-replace semantics (U1): the merged array keeps existing options first.
|
|
129
133
|
expect(
|
|
130
134
|
plan.mergedStageOptions?.slice(0, 2).map((option) => option.value),
|
|
131
135
|
).toEqual(["NEW", "CUSTOMER"]);
|
|
132
136
|
expect(plan.mergedStageOptions).toHaveLength(
|
|
133
|
-
EXISTING_STAGE_OPTIONS.length + MIGRATION_STAGE_OPTIONS.length,
|
|
137
|
+
EXISTING_STAGE_OPTIONS.length + MIGRATION_STAGE_OPTIONS.length - 1,
|
|
134
138
|
);
|
|
135
139
|
});
|
|
136
140
|
|
|
@@ -143,7 +147,7 @@ describe("planSchemaEnsure", () => {
|
|
|
143
147
|
|
|
144
148
|
it("creates only the gap on partial presence", () => {
|
|
145
149
|
const objects = bareObjects();
|
|
146
|
-
objects.get("
|
|
150
|
+
objects.get("opportunityProduct")!.fields.set("product", {
|
|
147
151
|
id: "f-product",
|
|
148
152
|
name: "product",
|
|
149
153
|
type: "TEXT",
|
|
@@ -153,8 +157,8 @@ describe("planSchemaEnsure", () => {
|
|
|
153
157
|
const names = plan.createFields.map(
|
|
154
158
|
(field) => `${field.object}.${field.name}`,
|
|
155
159
|
);
|
|
156
|
-
expect(names).not.toContain("
|
|
157
|
-
expect(names).toContain("
|
|
160
|
+
expect(names).not.toContain("opportunityProduct.product");
|
|
161
|
+
expect(names).toContain("opportunityProduct.quantity");
|
|
158
162
|
});
|
|
159
163
|
|
|
160
164
|
it("aborts when the stage field is missing", () => {
|
|
@@ -165,6 +169,7 @@ describe("planSchemaEnsure", () => {
|
|
|
165
169
|
note: [],
|
|
166
170
|
attachment: [],
|
|
167
171
|
opportunityProduct: [],
|
|
172
|
+
organization: [],
|
|
168
173
|
});
|
|
169
174
|
expect(() => planSchemaEnsure(objects)).toThrow(/stage field/);
|
|
170
175
|
});
|
|
@@ -202,7 +207,7 @@ describe("applySchemaEnsure", () => {
|
|
|
202
207
|
).toContain("CUSTOMER");
|
|
203
208
|
expect(
|
|
204
209
|
lastCall[2].input.update.options.map((option) => option.value),
|
|
205
|
-
).toContain("
|
|
210
|
+
).toContain("PROSPECT");
|
|
206
211
|
});
|
|
207
212
|
|
|
208
213
|
it("refuses to create a field against a pending object id", async () => {
|
|
@@ -43,6 +43,17 @@ export interface LastmileContact {
|
|
|
43
43
|
title: string | null;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export interface LastmileDispatchCustomer {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string | null;
|
|
49
|
+
/** dispatch customer.external_id — the P21 account key ("P21:12345"). */
|
|
50
|
+
p21Code: string | null;
|
|
51
|
+
ownerRepId: string | null;
|
|
52
|
+
/** An `account` row already carries this exact (case-folded) name — the
|
|
53
|
+
* account-sourced company covers it, so the dispatch source skips it. */
|
|
54
|
+
hasAccountNameMatch: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
export interface LastmileLead {
|
|
47
58
|
id: string;
|
|
48
59
|
status: string | null;
|
|
@@ -91,13 +102,76 @@ export interface LastmileOpportunityItem {
|
|
|
91
102
|
amount: string | null;
|
|
92
103
|
}
|
|
93
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The `task` table is LastMile's live CRM. Everything a rep sees hangs off it:
|
|
107
|
+
* `status_id` (the real pipeline status — the `opportunity.stage` column is
|
|
108
|
+
* stale and disagrees for 889 of 950 rows), `assignee_id` (the owning rep),
|
|
109
|
+
* `organization_id` (the branch, e.g. "GWO 300"), and `entity_data` (name,
|
|
110
|
+
* description, account, and the product `items` array). Task rows only exist
|
|
111
|
+
* from 2025-07 onward; older opportunity/lead rows are legacy and out of scope.
|
|
112
|
+
*/
|
|
113
|
+
export interface LastmileCrmTask {
|
|
114
|
+
taskId: string;
|
|
115
|
+
entityType: "lead" | "opportunity";
|
|
116
|
+
/** opportunity/lead row id — the stable identity for the Twenty record. */
|
|
117
|
+
entityId: string;
|
|
118
|
+
title: string | null;
|
|
119
|
+
description: string | null;
|
|
120
|
+
/** Present on opportunity tasks only; leads pre-date an account. */
|
|
121
|
+
accountId: string | null;
|
|
122
|
+
/** Company name typed on a lead before an account exists. */
|
|
123
|
+
leadCompanyName: string | null;
|
|
124
|
+
statusName: string | null;
|
|
125
|
+
organizationId: string | null;
|
|
126
|
+
/** Owning rep, resolved from the task's assignee user. */
|
|
127
|
+
assigneeRepId: string | null;
|
|
128
|
+
dueDate: Date | null;
|
|
129
|
+
createdAt: Date | null;
|
|
130
|
+
/** Product lines: [{ brand, quantity, amount }, ...] */
|
|
131
|
+
items: Array<{
|
|
132
|
+
brand?: string | null;
|
|
133
|
+
quantity?: string | number | null;
|
|
134
|
+
amount?: string | number | null;
|
|
135
|
+
}> | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface LastmileOrganization {
|
|
139
|
+
id: string;
|
|
140
|
+
name: string | null;
|
|
141
|
+
/** Short code shown in the LastMile UI, e.g. "GWO 300". */
|
|
142
|
+
abbv: string | null;
|
|
143
|
+
archived: boolean;
|
|
144
|
+
}
|
|
145
|
+
|
|
94
146
|
export interface LastmileCrmComment {
|
|
95
147
|
id: string;
|
|
96
148
|
entityType: "lead" | "opportunity";
|
|
97
149
|
entityId: string;
|
|
98
150
|
content: string | null;
|
|
99
151
|
isDeleted: boolean;
|
|
152
|
+
/** Authored-at time. Carried onto the Twenty note so the activity feed
|
|
153
|
+
* reflects when the rep actually wrote it, not when the import ran. */
|
|
154
|
+
createdAt: Date | null;
|
|
155
|
+
authorName: string | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A single status-change event from LastMile's `task_activity` log — the row
|
|
160
|
+
* behind "Dean Kittel changed status to 10-Prospect" in the task activity feed.
|
|
161
|
+
* The pipeline transition is reconstructed onto the Twenty opportunity as a
|
|
162
|
+
* dated Note so the activity timeline reads in true order. `activity_data`
|
|
163
|
+
* carries human-readable stage names (`new_status_name` in every prod row; the
|
|
164
|
+
* camelCase `newStatusName` variant is coalesced defensively). `created_at` is
|
|
165
|
+
* the real transition time; `user_id` resolves to the rep who moved it.
|
|
166
|
+
*/
|
|
167
|
+
export interface LastmileTaskStatusChange {
|
|
168
|
+
id: string;
|
|
169
|
+
entityType: "lead" | "opportunity";
|
|
170
|
+
entityId: string;
|
|
171
|
+
oldStatusName: string | null;
|
|
172
|
+
newStatusName: string | null;
|
|
100
173
|
createdAt: Date | null;
|
|
174
|
+
authorName: string | null;
|
|
101
175
|
}
|
|
102
176
|
|
|
103
177
|
export interface LastmileCrmAttachment {
|
|
@@ -120,19 +194,53 @@ export interface LastmileCustomerNote {
|
|
|
120
194
|
dateCreated: Date | null;
|
|
121
195
|
}
|
|
122
196
|
|
|
197
|
+
/** TEI's operating company in the dispatch database (the others are demo
|
|
198
|
+
* shells) — dispatch-side reads scope to it; the CRM tables are single-tenant
|
|
199
|
+
* and stay unscoped. */
|
|
200
|
+
const TEI_DISPATCH_COMPANY_ID = "co_y15610tsjbkqz5cqoic8gjla";
|
|
201
|
+
|
|
123
202
|
export interface LastmileReader {
|
|
124
203
|
readReps(): Promise<LastmileRep[]>;
|
|
125
204
|
readAccounts(): Promise<LastmileAccount[]>;
|
|
205
|
+
/** Dispatch customers with an order in the last `days` days (the ThinkWork
|
|
206
|
+
* twin's seed cohort rule) — the "real customers" band for the CRM. With
|
|
207
|
+
* `includeOpenInvoices`, customers holding an open invoice (nonzero
|
|
208
|
+
* amount_remaining, any age) join the cohort regardless of order recency —
|
|
209
|
+
* the twin carries ALL open invoices, so their customers must exist in the
|
|
210
|
+
* CRM too. */
|
|
211
|
+
readOrderCohortCustomers(
|
|
212
|
+
days: number,
|
|
213
|
+
includeOpenInvoices?: boolean,
|
|
214
|
+
): Promise<LastmileDispatchCustomer[]>;
|
|
126
215
|
readContacts(): Promise<LastmileContact[]>;
|
|
127
216
|
readLeads(): Promise<LastmileLead[]>;
|
|
128
217
|
readOpportunities(): Promise<LastmileOpportunity[]>;
|
|
218
|
+
readCrmTasks(): Promise<LastmileCrmTask[]>;
|
|
219
|
+
readOrganizations(): Promise<LastmileOrganization[]>;
|
|
129
220
|
readOpportunityItems(): Promise<LastmileOpportunityItem[]>;
|
|
130
221
|
readCrmComments(): Promise<LastmileCrmComment[]>;
|
|
222
|
+
readTaskStatusChanges(): Promise<LastmileTaskStatusChange[]>;
|
|
131
223
|
readCrmAttachments(): Promise<LastmileCrmAttachment[]>;
|
|
132
224
|
readCustomerNotes(): Promise<LastmileCustomerNote[]>;
|
|
133
225
|
close(): Promise<void>;
|
|
134
226
|
}
|
|
135
227
|
|
|
228
|
+
/**
|
|
229
|
+
* The accounts TEI actually sells to: those referenced by a CRM task (lead or
|
|
230
|
+
* opportunity). Companies and contacts are both scoped to this set — LastMile
|
|
231
|
+
* carries 3,403 accounts and 29,966 contacts, but only 805 accounts have an
|
|
232
|
+
* opportunity, and 443 contacts sit on them. Everything else is old data that
|
|
233
|
+
* stays in LastMile (Eric, 2026-07-10).
|
|
234
|
+
*/
|
|
235
|
+
const OPPORTUNITY_ACCOUNTS_CTE = `
|
|
236
|
+
opportunity_accounts as (
|
|
237
|
+
select distinct nullif(trim(t.entity_data ->> 'account_id'), '') as account_id
|
|
238
|
+
from task t
|
|
239
|
+
where t.entity_type in ('lead', 'opportunity')
|
|
240
|
+
and nullif(trim(t.entity_data ->> 'account_id'), '') is not null
|
|
241
|
+
)
|
|
242
|
+
`;
|
|
243
|
+
|
|
136
244
|
export function createLastmileReader(databaseUrl: string): LastmileReader {
|
|
137
245
|
const pool = new pg.Pool({
|
|
138
246
|
connectionString: databaseUrl,
|
|
@@ -161,12 +269,57 @@ export function createLastmileReader(databaseUrl: string): LastmileReader {
|
|
|
161
269
|
`),
|
|
162
270
|
readAccounts: () =>
|
|
163
271
|
rows<LastmileAccount>(`
|
|
272
|
+
with ${OPPORTUNITY_ACCOUNTS_CTE}
|
|
164
273
|
select id, nullif(trim(name), '') as "name", nullif(owner, '') as "ownerRepId"
|
|
165
274
|
from account
|
|
275
|
+
where id in (select account_id from opportunity_accounts)
|
|
166
276
|
order by id
|
|
167
277
|
`),
|
|
278
|
+
readOrderCohortCustomers: (days: number, includeOpenInvoices?: boolean) => {
|
|
279
|
+
if (!Number.isInteger(days) || days < 1 || days > 365) {
|
|
280
|
+
throw new Error(`cohort days must be an integer 1..365, got ${days}`);
|
|
281
|
+
}
|
|
282
|
+
const openInvoiceUnion = includeOpenInvoices
|
|
283
|
+
? `
|
|
284
|
+
union
|
|
285
|
+
select distinct i.customer_id as id
|
|
286
|
+
from invoice i
|
|
287
|
+
where i.company_id = '${TEI_DISPATCH_COMPANY_ID}'
|
|
288
|
+
and i.amount_remaining is not null
|
|
289
|
+
and i.amount_remaining <> 0
|
|
290
|
+
and i.customer_id is not null`
|
|
291
|
+
: "";
|
|
292
|
+
return rows<LastmileDispatchCustomer>(`
|
|
293
|
+
with cohort as (
|
|
294
|
+
select distinct o.customer_id as id
|
|
295
|
+
from order_header o
|
|
296
|
+
where o.company_id = '${TEI_DISPATCH_COMPANY_ID}'
|
|
297
|
+
and o.archived is not true
|
|
298
|
+
and o.order_date > now() - interval '${days} days'
|
|
299
|
+
and o.customer_id is not null${openInvoiceUnion}
|
|
300
|
+
)
|
|
301
|
+
select c.id,
|
|
302
|
+
nullif(trim(c.name), '') as "name",
|
|
303
|
+
nullif(trim(c.external_id), '') as "p21Code",
|
|
304
|
+
(
|
|
305
|
+
select s.sales_rep_id from ship_to s
|
|
306
|
+
where s.customer_id = c.id and s.sales_rep_id is not null
|
|
307
|
+
group by s.sales_rep_id order by count(*) desc, s.sales_rep_id
|
|
308
|
+
limit 1
|
|
309
|
+
) as "ownerRepId",
|
|
310
|
+
exists (
|
|
311
|
+
select 1 from account a
|
|
312
|
+
where lower(trim(a.name)) = lower(trim(c.name))
|
|
313
|
+
) as "hasAccountNameMatch"
|
|
314
|
+
from customer c
|
|
315
|
+
join cohort on cohort.id = c.id
|
|
316
|
+
where c.archived is not true
|
|
317
|
+
order by c.id
|
|
318
|
+
`);
|
|
319
|
+
},
|
|
168
320
|
readContacts: () =>
|
|
169
321
|
rows<LastmileContact>(`
|
|
322
|
+
with ${OPPORTUNITY_ACCOUNTS_CTE}
|
|
170
323
|
select id,
|
|
171
324
|
nullif(account_id, '') as "accountId",
|
|
172
325
|
nullif(trim(first_name), '') as "firstName",
|
|
@@ -176,6 +329,7 @@ export function createLastmileReader(databaseUrl: string): LastmileReader {
|
|
|
176
329
|
nullif(trim(phone_cellular), '') as "phoneCellular",
|
|
177
330
|
nullif(trim(title), '') as "title"
|
|
178
331
|
from contact
|
|
332
|
+
where account_id in (select account_id from opportunity_accounts)
|
|
179
333
|
order by id
|
|
180
334
|
`),
|
|
181
335
|
readLeads: () =>
|
|
@@ -213,6 +367,44 @@ export function createLastmileReader(databaseUrl: string): LastmileReader {
|
|
|
213
367
|
from opportunity
|
|
214
368
|
order by id
|
|
215
369
|
`),
|
|
370
|
+
readCrmTasks: () =>
|
|
371
|
+
rows<LastmileCrmTask>(`
|
|
372
|
+
select t.id as "taskId",
|
|
373
|
+
t.entity_type as "entityType",
|
|
374
|
+
t.entity_id as "entityId",
|
|
375
|
+
coalesce(
|
|
376
|
+
nullif(trim(t.entity_data ->> 'opp_name'), ''),
|
|
377
|
+
nullif(trim(t.title), '')
|
|
378
|
+
) as "title",
|
|
379
|
+
nullif(trim(t.entity_data ->> 'description'), '') as "description",
|
|
380
|
+
nullif(trim(t.entity_data ->> 'account_id'), '') as "accountId",
|
|
381
|
+
nullif(trim(t.entity_data ->> 'company'), '') as "leadCompanyName",
|
|
382
|
+
nullif(trim(s.name), '') as "statusName",
|
|
383
|
+
nullif(t.organization_id, '') as "organizationId",
|
|
384
|
+
nullif(u.sales_rep_id, '') as "assigneeRepId",
|
|
385
|
+
t.due_date as "dueDate",
|
|
386
|
+
t.created_at as "createdAt",
|
|
387
|
+
case
|
|
388
|
+
when jsonb_typeof(t.entity_data -> 'items') = 'array'
|
|
389
|
+
then t.entity_data -> 'items'
|
|
390
|
+
else null
|
|
391
|
+
end as "items"
|
|
392
|
+
from task t
|
|
393
|
+
left join status s on s.id = t.status_id
|
|
394
|
+
left join users u on u.id = t.assignee_id
|
|
395
|
+
where t.entity_type in ('lead', 'opportunity')
|
|
396
|
+
and t.entity_id is not null
|
|
397
|
+
order by t.entity_type, t.entity_id
|
|
398
|
+
`),
|
|
399
|
+
readOrganizations: () =>
|
|
400
|
+
rows<LastmileOrganization>(`
|
|
401
|
+
select id,
|
|
402
|
+
nullif(trim(name), '') as "name",
|
|
403
|
+
nullif(trim(abbv), '') as "abbv",
|
|
404
|
+
coalesce(archived, false) as "archived"
|
|
405
|
+
from organization
|
|
406
|
+
order by id
|
|
407
|
+
`),
|
|
216
408
|
readOpportunityItems: () =>
|
|
217
409
|
rows<LastmileOpportunityItem>(`
|
|
218
410
|
select t.entity_id as "opportunityId",
|
|
@@ -236,13 +428,38 @@ export function createLastmileReader(databaseUrl: string): LastmileReader {
|
|
|
236
428
|
t.entity_id as "entityId",
|
|
237
429
|
nullif(trim(tc.content), '') as "content",
|
|
238
430
|
coalesce(tc.is_deleted, false) as "isDeleted",
|
|
239
|
-
tc.created_at as "createdAt"
|
|
431
|
+
tc.created_at as "createdAt",
|
|
432
|
+
nullif(trim(concat_ws(' ', u.first_name, u.last_name)), '') as "authorName"
|
|
240
433
|
from task_comment tc
|
|
241
434
|
join task t on t.id = tc.task_id
|
|
435
|
+
left join users u on u.id = tc.user_id
|
|
242
436
|
where t.entity_type in ('lead', 'opportunity')
|
|
243
437
|
and t.entity_id is not null
|
|
244
438
|
order by tc.id
|
|
245
439
|
`),
|
|
440
|
+
readTaskStatusChanges: () =>
|
|
441
|
+
rows<LastmileTaskStatusChange>(`
|
|
442
|
+
select ta.id,
|
|
443
|
+
t.entity_type as "entityType",
|
|
444
|
+
t.entity_id as "entityId",
|
|
445
|
+
coalesce(
|
|
446
|
+
nullif(trim(ta.activity_data ->> 'old_status_name'), ''),
|
|
447
|
+
nullif(trim(ta.activity_data ->> 'oldStatusName'), '')
|
|
448
|
+
) as "oldStatusName",
|
|
449
|
+
coalesce(
|
|
450
|
+
nullif(trim(ta.activity_data ->> 'new_status_name'), ''),
|
|
451
|
+
nullif(trim(ta.activity_data ->> 'newStatusName'), '')
|
|
452
|
+
) as "newStatusName",
|
|
453
|
+
ta.created_at as "createdAt",
|
|
454
|
+
nullif(trim(concat_ws(' ', u.first_name, u.last_name)), '') as "authorName"
|
|
455
|
+
from task_activity ta
|
|
456
|
+
join task t on t.id = ta.task_id
|
|
457
|
+
left join users u on u.id = ta.user_id
|
|
458
|
+
where t.entity_type in ('lead', 'opportunity')
|
|
459
|
+
and t.entity_id is not null
|
|
460
|
+
and ta.activity_type in ('status_changed', 'status_change')
|
|
461
|
+
order by ta.id
|
|
462
|
+
`),
|
|
246
463
|
readCrmAttachments: () =>
|
|
247
464
|
rows<LastmileCrmAttachment>(`
|
|
248
465
|
select ta.id,
|
|
@@ -260,10 +477,13 @@ export function createLastmileReader(databaseUrl: string): LastmileReader {
|
|
|
260
477
|
`),
|
|
261
478
|
readCustomerNotes: () =>
|
|
262
479
|
rows<LastmileCustomerNote>(`
|
|
263
|
-
with
|
|
480
|
+
with ${OPPORTUNITY_ACCOUNTS_CTE},
|
|
481
|
+
account_names as (
|
|
482
|
+
-- Only migrated accounts can host a note.
|
|
264
483
|
select lower(trim(name)) as norm_name, min(id) as account_id, count(*) as n
|
|
265
484
|
from account
|
|
266
485
|
where nullif(trim(name), '') is not null
|
|
486
|
+
and id in (select account_id from opportunity_accounts)
|
|
267
487
|
group by lower(trim(name))
|
|
268
488
|
)
|
|
269
489
|
select n.id,
|
|
@@ -18,6 +18,27 @@ import { fetchExistingBySourceIds, type EntityCounters } from "./load-records";
|
|
|
18
18
|
import { sourceId as makeSourceId } from "./mappers";
|
|
19
19
|
import { TwentyClient, TwentyGraphqlError } from "./twenty-client";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* TEI's Twenty exposes no file-upload mutation to a workspace API key
|
|
23
|
+
* (`uploadFile` and the `Upload` scalar are absent; `/rest/files` is object
|
|
24
|
+
* CRUD). Same root cause as the missing auth schema. Four LastMile attachments
|
|
25
|
+
* hang off CRM records and one of those binaries is already gone from S3, so
|
|
26
|
+
* this is reported as a capability gap rather than a per-record failure —
|
|
27
|
+
* otherwise every future delta run would exit non-zero on the same files.
|
|
28
|
+
*/
|
|
29
|
+
export function isUploadUnsupported(error: unknown): boolean {
|
|
30
|
+
const message =
|
|
31
|
+
error instanceof TwentyGraphqlError
|
|
32
|
+
? error.errors.map((entry) => entry.message).join(" ")
|
|
33
|
+
: error instanceof Error
|
|
34
|
+
? error.message
|
|
35
|
+
: "";
|
|
36
|
+
return (
|
|
37
|
+
/Unknown type "Upload"/i.test(message) ||
|
|
38
|
+
/Cannot query field "uploadFile"/i.test(message)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
const ATTACHMENT_ENTITY = {
|
|
22
43
|
singular: "attachment",
|
|
23
44
|
plural: "attachments",
|
|
@@ -119,6 +140,14 @@ export async function loadCrmAttachments(options: {
|
|
|
119
140
|
);
|
|
120
141
|
counters.created += 1;
|
|
121
142
|
} catch (error) {
|
|
143
|
+
if (isUploadUnsupported(error)) {
|
|
144
|
+
counters.skipped += 1;
|
|
145
|
+
counters.gaps.push(
|
|
146
|
+
`attachment ${attachmentSourceId} (${attachment.filename ?? "?"}): ` +
|
|
147
|
+
"Twenty exposes no file-upload API to this credential — upload by hand",
|
|
148
|
+
);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
122
151
|
counters.failed += 1;
|
|
123
152
|
counters.gaps.push(
|
|
124
153
|
`attachment ${attachmentSourceId} failed: ${
|