thinkwork-cli 0.12.16 → 0.13.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/dist/cli.js +517 -10403
- package/dist/commands/enterprise/templates/deploy-repo/docs/runbook.md +3 -8
- package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +1 -184
- package/dist/drizzle/0050_brain_v0_entity_subtype.sql +2 -2
- package/dist/drizzle/0051_brain_v0_tenant_entity_pages.sql +6 -6
- package/dist/drizzle/0052_brain_v0_external_refs.sql +2 -2
- package/dist/drizzle/0053_brain_v0_unresolved_mentions_subtype.sql +2 -2
- package/dist/drizzle/0145_knowledge_graph_thread_ingest.sql +14 -14
- package/dist/drizzle/0146_knowledge_graph_source_scope.sql +1 -1
- package/dist/drizzle/0157_knowledge_graph_observations_source.sql +2 -2
- package/dist/drizzle/0158_wiki_tenant_scope.sql +1 -1
- package/dist/drizzle/0165_plugin_premium_entitlements.sql +2 -2
- package/dist/drizzle/{0166_company_brain_substrate_contract.sql → 0166_brain_substrate_contract.sql} +9 -9
- package/dist/drizzle/{0167_company_brain_artifact_manifest_runtime.sql → 0167_brain_artifact_manifest_runtime.sql} +5 -5
- package/dist/drizzle/0168_eval_skill_gate.sql +1 -1
- package/dist/drizzle/0183_okf_artifact_manifests.sql +1 -1
- package/dist/drizzle/0200_eval_results_execution_tier.sql +40 -0
- package/dist/drizzle/0201_brain_dream_runs.sql +72 -0
- package/dist/drizzle/0202_backfill_thread_participants_from_senders.sql +110 -0
- package/dist/drizzle/0203_threads_mode_override.sql +61 -0
- package/dist/drizzle/0204_thread_turns_triggering_message_id.sql +48 -0
- package/dist/drizzle/0205_routines_git_python.sql +185 -0
- package/dist/drizzle/0206_agent_loop_versions_routine_actions.sql +40 -0
- package/dist/drizzle/0207_tenant_credentials_github_repo_kind.sql +52 -0
- package/dist/drizzle/0208_knowledge_graph_neutral_identifiers.sql +253 -0
- package/dist/drizzle/0209_living_artifacts_schema.sql +109 -0
- package/dist/drizzle/0210_think137_automations_additive_schema.sql +113 -0
- package/dist/drizzle/0211_backfill_agent_loop_versions_target_spec.sql +120 -0
- package/dist/drizzle/0212_fold_webhooks_into_automations.sql +312 -0
- package/dist/drizzle/0213_agent_loop_versions_judge_evidence_nullable.sql +63 -0
- package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +99 -0
- package/dist/drizzle/0215_agent_loop_versions_goal_worker_policy_nullable.sql +70 -0
- package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +81 -0
- package/dist/drizzle/0217_agents_capability_folder_dispatch.sql +12 -0
- package/dist/drizzle/0218_document_plates.sql +30 -0
- package/dist/drizzle/0219_document_section_waivers.sql +29 -0
- package/dist/drizzle/0220_artifacts_created_by_user.sql +21 -0
- package/dist/drizzle/0221_artifact_shares.sql +29 -0
- package/dist/drizzle/0221_workflow_interpreter_thin_slice.sql +71 -0
- package/dist/drizzle/0222_compliance_drainer_select_audit_events.sql +21 -0
- package/dist/drizzle/0223_document_conformance_reports.sql +38 -0
- package/dist/drizzle/0224_artifact_refresh_state.sql +11 -0
- package/dist/drizzle/0225_think216_agent_loop_convergence.sql +21 -0
- package/dist/drizzle/0226_drop_n8n_agent_step_runs.sql +6 -0
- package/dist/drizzle/0227_analyst_reader_role.sql +1055 -0
- package/dist/drizzle/0228_tenant_credentials_rds_iam_kind.sql +62 -0
- package/dist/drizzle/0229_analyst_reader_rds_iam_grant.sql +79 -0
- package/dist/drizzle/0230_analyst_rls.sql +1613 -0
- package/dist/drizzle/0231_cost_events_cache_write_enforcement_exempt.sql +11 -0
- package/dist/plugins/catalog/package.json +0 -1
- package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +0 -1
- package/dist/plugins/catalog/src/__tests__/catalog.test.ts +16 -8
- package/dist/plugins/catalog/src/__tests__/contracts.test.ts +56 -0
- package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +0 -2
- package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -5
- package/dist/plugins/catalog/src/contracts.ts +112 -0
- package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -6
- package/dist/plugins/n8n/README.md +4 -36
- package/dist/plugins/n8n/src/deployment/managed-app.ts +0 -17
- package/dist/plugins/n8n/src/manifest.ts +0 -5
- package/dist/plugins/n8n/terraform/n8n/main.tf +0 -26
- package/dist/plugins/n8n/terraform/n8n/outputs.tf +0 -5
- package/dist/plugins/n8n/terraform/n8n/variables.tf +0 -6
- package/dist/plugins/n8n/test/manifest.test.ts +0 -1
- package/dist/plugins/twenty/package.json +5 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +232 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +489 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +199 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +43 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +230 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/twenty-client.test.ts +207 -0
- package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +282 -0
- package/dist/plugins/twenty/scripts/lib/load-attachments.ts +220 -0
- package/dist/plugins/twenty/scripts/lib/load-records.ts +680 -0
- package/dist/plugins/twenty/scripts/lib/mappers.ts +559 -0
- package/dist/plugins/twenty/scripts/lib/members-ensure.ts +381 -0
- package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +191 -0
- package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +425 -0
- package/dist/plugins/twenty/scripts/lib/twenty-client.ts +246 -0
- package/dist/plugins/twenty/scripts/migrate-lastmile.ts +610 -0
- package/dist/plugins/twenty/scripts/provision-twenty-members.ts +214 -0
- package/dist/plugins/twenty/src/manifest.ts +99 -0
- package/dist/plugins/twenty/terraform/twenty/README.md +1 -1
- package/dist/plugins/twenty/test/manifest.test.ts +12 -2
- package/dist/terraform/examples/greenfield/main.tf +90 -380
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +0 -1
- package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
- package/dist/terraform/modules/app/agentcore-pi/main.tf +29 -38
- package/dist/terraform/modules/app/agentcore-pi/variables.tf +23 -15
- package/dist/terraform/modules/app/agentcore-runtime/main.tf +3 -3
- package/dist/terraform/modules/app/customer-domain/main.tf +16 -2
- package/dist/terraform/modules/app/deployment-control-plane/main.tf +5 -0
- package/dist/terraform/modules/app/deployment-control-plane/runner.py +85 -207
- package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +56 -2
- package/dist/terraform/modules/app/hindsight-memory/main.tf +52 -4
- package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +7 -0
- package/dist/terraform/modules/app/lambda-api/handlers.tf +493 -89
- package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +215 -96
- package/dist/terraform/modules/app/lambda-api/main.tf +24 -5
- package/dist/terraform/modules/app/lambda-api/outputs.tf +19 -11
- package/dist/terraform/modules/app/lambda-api/variables.tf +94 -67
- package/dist/terraform/modules/app/ses-email/main.tf +14 -2
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/main.tf +493 -0
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/outputs.tf +34 -0
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/variables.tf +32 -0
- package/dist/terraform/modules/data/aurora-postgres/main.tf +55 -0
- package/dist/terraform/modules/data/aurora-postgres/outputs.tf +15 -0
- package/dist/terraform/modules/data/s3-buckets/main.tf +26 -0
- package/dist/terraform/modules/thinkwork/README.md +0 -1
- package/dist/terraform/modules/thinkwork/main.tf +167 -446
- package/dist/terraform/modules/thinkwork/outputs.tf +2 -142
- package/dist/terraform/modules/thinkwork/variables.tf +55 -309
- package/dist/terraform/schema.graphql +4 -0
- package/dist/workspace-defaults/files/AGENTS.md +45 -31
- package/dist/workspace-defaults/files/CONTEXT.md +48 -4
- package/dist/workspace-defaults/files/GUARDRAILS.md +18 -0
- package/dist/workspace-defaults/files/MEMORY_GUIDE.md +32 -4
- package/dist/workspace-defaults/files/SPACE.md +34 -19
- package/dist/workspace-defaults/files/TOOLS.md +30 -0
- package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +154 -0
- package/dist/workspace-defaults/files/skills/document-composer/references/authoring-rules.md +101 -0
- package/package.json +1 -1
- package/dist/plugins/company-brain/README.md +0 -37
- package/dist/plugins/company-brain/package.json +0 -33
- package/dist/plugins/company-brain/runtime/cognee/Dockerfile +0 -11
- package/dist/plugins/company-brain/smoke/cognee-managed-app-smoke.mjs +0 -458
- package/dist/plugins/company-brain/smoke/cognee-memory-cutover-smoke.mjs +0 -539
- package/dist/plugins/company-brain/smoke/company-brain-context-engine-smoke.mjs +0 -312
- package/dist/plugins/company-brain/smoke/company-brain-operations-smoke.mjs +0 -449
- package/dist/plugins/company-brain/smoke/company-brain-plugin-smoke.mjs +0 -655
- package/dist/plugins/company-brain/smoke/hindsight-memory-isolation-smoke.mjs +0 -474
- package/dist/plugins/company-brain/src/api/cognee-client.ts +0 -896
- package/dist/plugins/company-brain/src/api/cognee-cluster-identity.ts +0 -49
- package/dist/plugins/company-brain/src/api/cognee-memory-scope.ts +0 -84
- package/dist/plugins/company-brain/src/api/context-engine-provider.ts +0 -888
- package/dist/plugins/company-brain/src/api/migration.ts +0 -665
- package/dist/plugins/company-brain/src/deployment/cognee-managed-app.ts +0 -396
- package/dist/plugins/company-brain/src/index.ts +0 -62
- package/dist/plugins/company-brain/src/manifest.ts +0 -112
- package/dist/plugins/company-brain/terraform/cognee/README.md +0 -263
- package/dist/plugins/company-brain/terraform/cognee/main.tf +0 -766
- package/dist/plugins/company-brain/terraform/cognee/outputs.tf +0 -144
- package/dist/plugins/company-brain/terraform/cognee/variables.tf +0 -402
- package/dist/plugins/company-brain/test/api/cognee-client.test.ts +0 -562
- package/dist/plugins/company-brain/test/api/cognee-memory-scope.test.ts +0 -74
- package/dist/plugins/company-brain/test/api/context-engine-provider.test.ts +0 -447
- package/dist/plugins/company-brain/test/api/migration.test.ts +0 -465
- package/dist/plugins/company-brain/test/manifest.test.ts +0 -95
- package/dist/plugins/company-brain/tsconfig.json +0 -8
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure LastMile → Twenty mapping functions (no I/O), unit-tested first per the
|
|
3
|
+
* plan's U5 execution note. Field shapes follow the U1 live-introspection
|
|
4
|
+
* resolutions recorded in the plan: FullName{firstName,lastName},
|
|
5
|
+
* Currency{amountMicros,currencyCode}, Emails{primaryEmail}, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
LastmileAccount,
|
|
12
|
+
LastmileContact,
|
|
13
|
+
LastmileCrmComment,
|
|
14
|
+
LastmileCustomerNote,
|
|
15
|
+
LastmileLead,
|
|
16
|
+
LastmileOpportunity,
|
|
17
|
+
} from "./lastmile-reader";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Bump to force an update sweep after a mapper fix: previously "unchanged"
|
|
21
|
+
* records re-hash differently and get rewritten (plan KTD3 / Risks).
|
|
22
|
+
*/
|
|
23
|
+
export const HASH_VERSION = "1";
|
|
24
|
+
|
|
25
|
+
/** sourceId values are namespaced by source table so leads and opportunities
|
|
26
|
+
* (which share Twenty's opportunity object) can never collide. */
|
|
27
|
+
export function sourceId(table: string, id: string): string {
|
|
28
|
+
return `${table}:${id}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function stableStringify(value: unknown): string {
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
34
|
+
}
|
|
35
|
+
if (value !== null && typeof value === "object") {
|
|
36
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
37
|
+
.filter(([, v]) => v !== undefined)
|
|
38
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
39
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
|
|
40
|
+
return `{${entries.join(",")}}`;
|
|
41
|
+
}
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function contentHash(mapped: Record<string, unknown>): string {
|
|
46
|
+
const { sourceHash: _ignored, ...rest } = mapped;
|
|
47
|
+
return createHash("sha256")
|
|
48
|
+
.update(`v${HASH_VERSION}:${stableStringify(rest)}`)
|
|
49
|
+
.digest("hex");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* LastMile owner references are inconsistent: `rep_*` sales_rep ids, short
|
|
54
|
+
* aliases ("jbake"), and full display names ("Jane Baker") all appear in the
|
|
55
|
+
* same columns (live data, 2026-07-09). buildOwnerIndex expands the
|
|
56
|
+
* provisioned rep→member map with alias and full-name keys, but ONLY when the
|
|
57
|
+
* alias/name is unique across reps — ambiguous keys resolve to nobody and the
|
|
58
|
+
* record is flagged instead (refuse to guess).
|
|
59
|
+
*/
|
|
60
|
+
export function buildOwnerIndex(
|
|
61
|
+
reps: ReadonlyArray<{
|
|
62
|
+
id: string;
|
|
63
|
+
alias: string | null;
|
|
64
|
+
firstName: string | null;
|
|
65
|
+
lastName: string | null;
|
|
66
|
+
}>,
|
|
67
|
+
memberIdByRepId: ReadonlyMap<string, string>,
|
|
68
|
+
): Map<string, string> {
|
|
69
|
+
const index = new Map<string, string>();
|
|
70
|
+
const claim = (key: string | null, repId: string, ambiguous: Set<string>) => {
|
|
71
|
+
if (!key) return;
|
|
72
|
+
const normalized = key.toLowerCase().trim();
|
|
73
|
+
if (!normalized || ambiguous.has(normalized)) return;
|
|
74
|
+
const memberId = memberIdByRepId.get(repId);
|
|
75
|
+
if (!memberId) return;
|
|
76
|
+
if (index.has(normalized) && index.get(normalized) !== memberId) {
|
|
77
|
+
ambiguous.add(normalized);
|
|
78
|
+
index.delete(normalized);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
index.set(normalized, memberId);
|
|
82
|
+
};
|
|
83
|
+
const ambiguous = new Set<string>();
|
|
84
|
+
for (const rep of reps) {
|
|
85
|
+
claim(rep.id, rep.id, ambiguous);
|
|
86
|
+
claim(rep.alias, rep.id, ambiguous);
|
|
87
|
+
const fullName = [rep.firstName, rep.lastName].filter(Boolean).join(" ");
|
|
88
|
+
claim(fullName, rep.id, ambiguous);
|
|
89
|
+
// LastMile derives usernames as first initial + first 4 letters of the
|
|
90
|
+
// last name ("dembl" = Daniel Emblen); many sales_rep rows leave the alias
|
|
91
|
+
// column empty while lead/opportunity owner refs use the derived form.
|
|
92
|
+
if (rep.firstName && rep.lastName) {
|
|
93
|
+
claim(
|
|
94
|
+
`${rep.firstName[0]}${rep.lastName.slice(0, 4)}`,
|
|
95
|
+
rep.id,
|
|
96
|
+
ambiguous,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return index;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function resolveOwner(
|
|
104
|
+
ownerRef: string | null,
|
|
105
|
+
ownerIndex: ReadonlyMap<string, string>,
|
|
106
|
+
): string | null {
|
|
107
|
+
if (!ownerRef) return null;
|
|
108
|
+
return (
|
|
109
|
+
ownerIndex.get(ownerRef) ??
|
|
110
|
+
ownerIndex.get(ownerRef.toLowerCase().trim()) ??
|
|
111
|
+
null
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
116
|
+
|
|
117
|
+
export function normalizeEmail(raw: string | null | undefined): string | null {
|
|
118
|
+
if (!raw) return null;
|
|
119
|
+
const email = raw.trim().toLowerCase();
|
|
120
|
+
return EMAIL_RE.test(email) ? email : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Non-person sales_rep rows: house/dealer buckets, intercompany ledgers, and
|
|
124
|
+
* placeholders. They must never receive a login. */
|
|
125
|
+
const NON_PERSON_REP_TOKENS = new Set([
|
|
126
|
+
"house",
|
|
127
|
+
"intercompany",
|
|
128
|
+
"tbd",
|
|
129
|
+
"undefined",
|
|
130
|
+
"unknown",
|
|
131
|
+
"unassigned",
|
|
132
|
+
"buyback",
|
|
133
|
+
"asc",
|
|
134
|
+
"salesrep",
|
|
135
|
+
"company",
|
|
136
|
+
"fuel",
|
|
137
|
+
"oil",
|
|
138
|
+
"transport",
|
|
139
|
+
"transportation",
|
|
140
|
+
"hauling",
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
/** A person's name part: letters, optionally hyphenated/apostrophed. Rejects
|
|
144
|
+
* multi-word buckets ("Golden West Laredo"), digits, and underscores
|
|
145
|
+
* ("Ervi_2"). */
|
|
146
|
+
const NAME_PART_RE = /^[a-z]+(?:['-][a-z]+)*$/;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reps missing an email in LastMile get `<first-initial><lastname>@<domain>`
|
|
150
|
+
* (Eric's rule, 2026-07-10) — but only when both name parts look like a real
|
|
151
|
+
* person's. 60 of 131 active reps have no email; most are house/intercompany
|
|
152
|
+
* rows that must stay unprovisionable. Returns null for those.
|
|
153
|
+
*/
|
|
154
|
+
export function deriveRepEmail(
|
|
155
|
+
firstName: string | null,
|
|
156
|
+
lastName: string | null,
|
|
157
|
+
domain: string,
|
|
158
|
+
): string | null {
|
|
159
|
+
const first = (firstName ?? "").trim().toLowerCase();
|
|
160
|
+
const last = (lastName ?? "").trim().toLowerCase();
|
|
161
|
+
if (!NAME_PART_RE.test(first) || !NAME_PART_RE.test(last)) return null;
|
|
162
|
+
if (NON_PERSON_REP_TOKENS.has(first) || NON_PERSON_REP_TOKENS.has(last)) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const localPart = `${first[0]}${last.replace(/['-]/g, "")}`;
|
|
166
|
+
return normalizeEmail(`${localPart}@${domain}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface NormalizedPhone {
|
|
170
|
+
primaryPhoneNumber: string;
|
|
171
|
+
primaryPhoneCallingCode: string;
|
|
172
|
+
primaryPhoneCountryCode: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Twenty validates phones with libphonenumber; bare national formats like
|
|
177
|
+
* "512-825-8875" are rejected without a country code (observed live — 17k
|
|
178
|
+
* person creates failed on it). LastMile numbers are US: normalize 10-digit
|
|
179
|
+
* (or 1-prefixed 11-digit) numbers to number + "+1"/US parts; anything else
|
|
180
|
+
* is dropped with a warning rather than failing the whole record.
|
|
181
|
+
*/
|
|
182
|
+
export function normalizePhone(
|
|
183
|
+
raw: string | null | undefined,
|
|
184
|
+
): NormalizedPhone | null {
|
|
185
|
+
if (!raw) return null;
|
|
186
|
+
const digits = raw.replace(/\D/g, "");
|
|
187
|
+
const national =
|
|
188
|
+
digits.length === 10
|
|
189
|
+
? digits
|
|
190
|
+
: digits.length === 11 && digits.startsWith("1")
|
|
191
|
+
? digits.slice(1)
|
|
192
|
+
: null;
|
|
193
|
+
// libphonenumber rejects US numbers whose area code starts with 0/1.
|
|
194
|
+
if (!national || national[0] === "0" || national[0] === "1") return null;
|
|
195
|
+
return {
|
|
196
|
+
primaryPhoneNumber: national,
|
|
197
|
+
primaryPhoneCallingCode: "+1",
|
|
198
|
+
primaryPhoneCountryCode: "US",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Dollars (numeric string or number) → integer micros. Returns null for
|
|
203
|
+
* missing/unparsable amounts. */
|
|
204
|
+
export function toAmountMicros(
|
|
205
|
+
raw: string | number | null | undefined,
|
|
206
|
+
): number | null {
|
|
207
|
+
if (raw === null || raw === undefined || raw === "") return null;
|
|
208
|
+
const numeric =
|
|
209
|
+
typeof raw === "number" ? raw : Number(String(raw).replace(/[$,\s]/g, ""));
|
|
210
|
+
if (!Number.isFinite(numeric)) return null;
|
|
211
|
+
return Math.round(numeric * 1_000_000);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function toQuantity(raw: string | null | undefined): number | null {
|
|
215
|
+
if (!raw) return null;
|
|
216
|
+
const numeric = Number(String(raw).replace(/[,\s]/g, ""));
|
|
217
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- Stage mapping -------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/** Existing TEI options (NEW/SCREENING/MEETING/PROPOSAL/CUSTOMER) are
|
|
223
|
+
* preserved verbatim — the ThinkWork workflow triggers on CUSTOMER. These are
|
|
224
|
+
* the options the migration adds, merged into the live array (full-replace
|
|
225
|
+
* write semantics confirmed by U1). */
|
|
226
|
+
export const MIGRATION_STAGE_OPTIONS: Array<{
|
|
227
|
+
label: string;
|
|
228
|
+
value: string;
|
|
229
|
+
color: string;
|
|
230
|
+
}> = [
|
|
231
|
+
{ label: "Lead", value: "LEAD", color: "gray" },
|
|
232
|
+
{ label: "Lead - Working", value: "LEAD_WORKING", color: "gray" },
|
|
233
|
+
{ label: "Lead - Qualified", value: "LEAD_QUALIFIED", color: "blue" },
|
|
234
|
+
{ label: "Lead - Unqualified", value: "LEAD_UNQUALIFIED", color: "gray" },
|
|
235
|
+
{ label: "Prospect", value: "PROSPECT", color: "sky" },
|
|
236
|
+
{ label: "Qualifying", value: "QUALIFYING", color: "sky" },
|
|
237
|
+
{ label: "Identify Needs", value: "IDENTIFY_NEEDS", color: "turquoise" },
|
|
238
|
+
{ label: "Formulate Offer", value: "FORMULATE_OFFER", color: "turquoise" },
|
|
239
|
+
{ label: "Negotiate to Close", value: "NEGOTIATE", color: "yellow" },
|
|
240
|
+
{
|
|
241
|
+
label: "Manage Implementation",
|
|
242
|
+
value: "MANAGE_IMPLEMENTATION",
|
|
243
|
+
color: "orange",
|
|
244
|
+
},
|
|
245
|
+
{ label: "Won", value: "WON", color: "green" },
|
|
246
|
+
{ label: "Lost", value: "LOST", color: "red" },
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
/** Live lead.status values observed 2026-07-09 (case-insensitive, prefix
|
|
250
|
+
* digits vary). Unknown statuses fall back to LEAD and are reported. */
|
|
251
|
+
export function mapLeadStatusToStage(status: string | null): {
|
|
252
|
+
stage: string;
|
|
253
|
+
unknown: boolean;
|
|
254
|
+
} {
|
|
255
|
+
const normalized = (status ?? "").toLowerCase().replace(/^\d+-/, "").trim();
|
|
256
|
+
switch (normalized) {
|
|
257
|
+
case "":
|
|
258
|
+
case "new":
|
|
259
|
+
return { stage: "LEAD", unknown: false };
|
|
260
|
+
case "working":
|
|
261
|
+
case "contacted":
|
|
262
|
+
case "nurturing":
|
|
263
|
+
return { stage: "LEAD_WORKING", unknown: false };
|
|
264
|
+
case "qualified":
|
|
265
|
+
return { stage: "LEAD_QUALIFIED", unknown: false };
|
|
266
|
+
case "unqualified":
|
|
267
|
+
return { stage: "LEAD_UNQUALIFIED", unknown: false };
|
|
268
|
+
case "converted":
|
|
269
|
+
// Converted leads became opportunities in LastMile; the lead row keeps
|
|
270
|
+
// its history at the qualified end of the lead band.
|
|
271
|
+
return { stage: "LEAD_QUALIFIED", unknown: false };
|
|
272
|
+
default:
|
|
273
|
+
return { stage: "LEAD", unknown: true };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function mapOpportunityStage(opportunity: {
|
|
278
|
+
stage: string | null;
|
|
279
|
+
closed: string | null;
|
|
280
|
+
won: string | null;
|
|
281
|
+
}): { stage: string; unknown: boolean } {
|
|
282
|
+
const normalized = (opportunity.stage ?? "")
|
|
283
|
+
.toLowerCase()
|
|
284
|
+
.replace(/^\d+-/, "")
|
|
285
|
+
.trim();
|
|
286
|
+
switch (normalized) {
|
|
287
|
+
case "new":
|
|
288
|
+
return { stage: "NEW", unknown: false };
|
|
289
|
+
case "prospect":
|
|
290
|
+
return { stage: "PROSPECT", unknown: false };
|
|
291
|
+
case "qualifying":
|
|
292
|
+
return { stage: "QUALIFYING", unknown: false };
|
|
293
|
+
case "identify account needs":
|
|
294
|
+
return { stage: "IDENTIFY_NEEDS", unknown: false };
|
|
295
|
+
case "formulate offer":
|
|
296
|
+
return { stage: "FORMULATE_OFFER", unknown: false };
|
|
297
|
+
case "negotiate to close":
|
|
298
|
+
case "negotiation to close":
|
|
299
|
+
return { stage: "NEGOTIATE", unknown: false };
|
|
300
|
+
case "manage implementation":
|
|
301
|
+
return { stage: "MANAGE_IMPLEMENTATION", unknown: false };
|
|
302
|
+
case "won":
|
|
303
|
+
return { stage: "WON", unknown: false };
|
|
304
|
+
case "lost":
|
|
305
|
+
return { stage: "LOST", unknown: false };
|
|
306
|
+
case "": {
|
|
307
|
+
// Blank stage: fall back to the closed/won flags.
|
|
308
|
+
if (opportunity.closed === "true" && opportunity.won === "true") {
|
|
309
|
+
return { stage: "WON", unknown: false };
|
|
310
|
+
}
|
|
311
|
+
if (opportunity.closed === "true") {
|
|
312
|
+
return { stage: "LOST", unknown: false };
|
|
313
|
+
}
|
|
314
|
+
return { stage: "NEW", unknown: false };
|
|
315
|
+
}
|
|
316
|
+
default:
|
|
317
|
+
return { stage: "NEW", unknown: true };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- Record mappers ------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
export interface MappedRecord {
|
|
324
|
+
sourceId: string;
|
|
325
|
+
/** Twenty create/update input, custom fields included, sourceHash excluded. */
|
|
326
|
+
input: Record<string, unknown>;
|
|
327
|
+
warnings: string[];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function mapAccount(
|
|
331
|
+
account: LastmileAccount,
|
|
332
|
+
ownerMap: ReadonlyMap<string, string>,
|
|
333
|
+
): MappedRecord {
|
|
334
|
+
const warnings: string[] = [];
|
|
335
|
+
const accountOwnerId = resolveOwner(account.ownerRepId, ownerMap);
|
|
336
|
+
if (account.ownerRepId && !accountOwnerId) {
|
|
337
|
+
warnings.push(
|
|
338
|
+
`owner ${account.ownerRepId} not provisioned; company has no accountOwner`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const input: Record<string, unknown> = {
|
|
342
|
+
name: account.name ?? `Account ${account.id}`,
|
|
343
|
+
...(accountOwnerId ? { accountOwnerId } : {}),
|
|
344
|
+
sourceId: sourceId("account", account.id),
|
|
345
|
+
};
|
|
346
|
+
return { sourceId: input.sourceId as string, input, warnings };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function mapContact(
|
|
350
|
+
contact: LastmileContact,
|
|
351
|
+
companyIdBySourceId: ReadonlyMap<string, string>,
|
|
352
|
+
): MappedRecord {
|
|
353
|
+
const warnings: string[] = [];
|
|
354
|
+
const email = normalizeEmail(contact.email);
|
|
355
|
+
if (contact.email && !email)
|
|
356
|
+
warnings.push(`invalid email ${contact.email} dropped`);
|
|
357
|
+
let companyId: string | null = null;
|
|
358
|
+
if (contact.accountId) {
|
|
359
|
+
companyId =
|
|
360
|
+
companyIdBySourceId.get(sourceId("account", contact.accountId)) ?? null;
|
|
361
|
+
if (!companyId)
|
|
362
|
+
warnings.push(
|
|
363
|
+
`account ${contact.accountId} not found; person has no company`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
const rawPhone = contact.phone ?? contact.phoneCellular;
|
|
367
|
+
const phone = normalizePhone(rawPhone);
|
|
368
|
+
if (rawPhone && !phone)
|
|
369
|
+
warnings.push(`unparseable phone ${rawPhone} dropped`);
|
|
370
|
+
const input: Record<string, unknown> = {
|
|
371
|
+
name: {
|
|
372
|
+
firstName: contact.firstName ?? "",
|
|
373
|
+
lastName: contact.lastName ?? "",
|
|
374
|
+
},
|
|
375
|
+
...(email ? { emails: { primaryEmail: email } } : {}),
|
|
376
|
+
...(phone ? { phones: phone } : {}),
|
|
377
|
+
...(contact.title ? { jobTitle: contact.title } : {}),
|
|
378
|
+
...(companyId ? { companyId } : {}),
|
|
379
|
+
sourceId: sourceId("contact", contact.id),
|
|
380
|
+
};
|
|
381
|
+
return { sourceId: input.sourceId as string, input, warnings };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Mobil-branded products, per line. Brands seen live: MOBIL, MOBIL - CVL,
|
|
385
|
+
* GOLDEN WEST, FUEL, DEF, Hotsy. */
|
|
386
|
+
export function isMobilBrand(brand: string | null): boolean {
|
|
387
|
+
return /mobil/i.test(brand ?? "");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface MappedOpportunityProduct extends MappedRecord {
|
|
391
|
+
/** sourceId of the owning opportunity, resolved to a Twenty id by the loader. */
|
|
392
|
+
opportunitySourceId: string;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* One product line on an opportunity (R1: multiple products per opportunity).
|
|
397
|
+
* Identity is opportunity + position, so re-runs update lines in place rather
|
|
398
|
+
* than duplicating them.
|
|
399
|
+
*/
|
|
400
|
+
export function mapOpportunityProduct(item: {
|
|
401
|
+
opportunityId: string;
|
|
402
|
+
index: number;
|
|
403
|
+
brand: string | null;
|
|
404
|
+
quantity: string | null;
|
|
405
|
+
amount: string | null;
|
|
406
|
+
}): MappedOpportunityProduct {
|
|
407
|
+
const warnings: string[] = [];
|
|
408
|
+
const amountMicros = toAmountMicros(item.amount);
|
|
409
|
+
if (item.amount && amountMicros === null) {
|
|
410
|
+
warnings.push(`unparseable line amount ${item.amount} dropped`);
|
|
411
|
+
}
|
|
412
|
+
const quantity = toQuantity(item.quantity);
|
|
413
|
+
if (item.quantity && quantity === null) {
|
|
414
|
+
warnings.push(`unparseable line quantity ${item.quantity} dropped`);
|
|
415
|
+
}
|
|
416
|
+
const lineSourceId = `${sourceId("opportunity_item", item.opportunityId)}#${item.index}`;
|
|
417
|
+
const input: Record<string, unknown> = {
|
|
418
|
+
name: item.brand ?? `Line ${item.index + 1}`,
|
|
419
|
+
...(item.brand ? { product: item.brand } : {}),
|
|
420
|
+
...(quantity !== null ? { quantity } : {}),
|
|
421
|
+
...(amountMicros !== null
|
|
422
|
+
? { amount: { amountMicros, currencyCode: "USD" } }
|
|
423
|
+
: {}),
|
|
424
|
+
isMobil: isMobilBrand(item.brand),
|
|
425
|
+
lineNumber: item.index + 1,
|
|
426
|
+
sourceId: lineSourceId,
|
|
427
|
+
};
|
|
428
|
+
return {
|
|
429
|
+
sourceId: lineSourceId,
|
|
430
|
+
input,
|
|
431
|
+
warnings,
|
|
432
|
+
opportunitySourceId: sourceId("opportunity", item.opportunityId),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function isMobilProduct(opportunity: {
|
|
437
|
+
productType: string | null;
|
|
438
|
+
brand: string | null;
|
|
439
|
+
}): boolean {
|
|
440
|
+
return /mobil/i.test(
|
|
441
|
+
`${opportunity.productType ?? ""} ${opportunity.brand ?? ""}`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function mapOpportunity(
|
|
446
|
+
opportunity: LastmileOpportunity,
|
|
447
|
+
ownerMap: ReadonlyMap<string, string>,
|
|
448
|
+
companyIdBySourceId: ReadonlyMap<string, string>,
|
|
449
|
+
): MappedRecord {
|
|
450
|
+
const warnings: string[] = [];
|
|
451
|
+
const { stage, unknown } = mapOpportunityStage(opportunity);
|
|
452
|
+
if (unknown)
|
|
453
|
+
warnings.push(`unknown stage "${opportunity.stage}" mapped to NEW`);
|
|
454
|
+
const ownerId = resolveOwner(opportunity.ownerRepId, ownerMap);
|
|
455
|
+
if (opportunity.ownerRepId && !ownerId) {
|
|
456
|
+
warnings.push(
|
|
457
|
+
`owner ${opportunity.ownerRepId} not provisioned; opportunity has no owner`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
let companyId: string | null = null;
|
|
461
|
+
if (opportunity.accountId) {
|
|
462
|
+
companyId =
|
|
463
|
+
companyIdBySourceId.get(sourceId("account", opportunity.accountId)) ??
|
|
464
|
+
null;
|
|
465
|
+
if (!companyId) {
|
|
466
|
+
warnings.push(
|
|
467
|
+
`account ${opportunity.accountId} not found; opportunity has no company`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const amountMicros = toAmountMicros(opportunity.amount);
|
|
472
|
+
const input: Record<string, unknown> = {
|
|
473
|
+
name: opportunity.name ?? `Opportunity ${opportunity.id}`,
|
|
474
|
+
stage,
|
|
475
|
+
...(amountMicros !== null
|
|
476
|
+
? { amount: { amountMicros, currencyCode: "USD" } }
|
|
477
|
+
: {}),
|
|
478
|
+
...(opportunity.expectedCloseDate
|
|
479
|
+
? { closeDate: `${opportunity.expectedCloseDate}T00:00:00.000Z` }
|
|
480
|
+
: {}),
|
|
481
|
+
...(ownerId ? { ownerId } : {}),
|
|
482
|
+
...(companyId ? { companyId } : {}),
|
|
483
|
+
...(opportunity.productType ? { product: opportunity.productType } : {}),
|
|
484
|
+
...(toQuantity(opportunity.quantity) !== null
|
|
485
|
+
? { quantity: toQuantity(opportunity.quantity) }
|
|
486
|
+
: {}),
|
|
487
|
+
isMobil: isMobilProduct(opportunity),
|
|
488
|
+
sourceId: sourceId("opportunity", opportunity.id),
|
|
489
|
+
};
|
|
490
|
+
return { sourceId: input.sourceId as string, input, warnings };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Leads land in the same Twenty opportunity pipeline at the lead-band stages
|
|
494
|
+
* (AE3): never as bare person/company records. */
|
|
495
|
+
export function mapLead(
|
|
496
|
+
lead: LastmileLead,
|
|
497
|
+
ownerMap: ReadonlyMap<string, string>,
|
|
498
|
+
): MappedRecord {
|
|
499
|
+
const warnings: string[] = [];
|
|
500
|
+
const { stage, unknown } = mapLeadStatusToStage(lead.status);
|
|
501
|
+
if (unknown)
|
|
502
|
+
warnings.push(`unknown lead status "${lead.status}" mapped to LEAD`);
|
|
503
|
+
const ownerId = resolveOwner(lead.ownerRepId, ownerMap);
|
|
504
|
+
if (lead.ownerRepId && !ownerId) {
|
|
505
|
+
warnings.push(
|
|
506
|
+
`owner ${lead.ownerRepId} not provisioned; lead has no owner`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
const personName = [lead.firstName, lead.lastName].filter(Boolean).join(" ");
|
|
510
|
+
const name = lead.companyName ?? (personName || `Lead ${lead.id}`);
|
|
511
|
+
const input: Record<string, unknown> = {
|
|
512
|
+
name,
|
|
513
|
+
stage,
|
|
514
|
+
...(ownerId ? { ownerId } : {}),
|
|
515
|
+
isMobil: false,
|
|
516
|
+
sourceId: sourceId("lead", lead.id),
|
|
517
|
+
};
|
|
518
|
+
return { sourceId: input.sourceId as string, input, warnings };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export interface MappedNote {
|
|
522
|
+
sourceId: string;
|
|
523
|
+
title: string;
|
|
524
|
+
bodyMarkdown: string;
|
|
525
|
+
/** Twenty sourceId of the record the note targets. */
|
|
526
|
+
targetSourceId: string;
|
|
527
|
+
targetKind: "opportunity" | "company";
|
|
528
|
+
isDeleted: boolean;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function mapCrmComment(comment: LastmileCrmComment): MappedNote {
|
|
532
|
+
const text = comment.content ?? "";
|
|
533
|
+
const firstLine = text.split("\n", 1)[0].trim();
|
|
534
|
+
return {
|
|
535
|
+
sourceId: sourceId("task_comment", comment.id),
|
|
536
|
+
title: firstLine.slice(0, 60) || "LastMile note",
|
|
537
|
+
bodyMarkdown: text,
|
|
538
|
+
targetSourceId: sourceId(comment.entityType, comment.entityId),
|
|
539
|
+
targetKind: "opportunity",
|
|
540
|
+
isDeleted: comment.isDeleted,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Customer notes reach Twenty only when their dispatch customer name matches
|
|
545
|
+
* exactly one CRM account (plan resolution); the caller reports the rest as
|
|
546
|
+
* gaps. */
|
|
547
|
+
export function mapCustomerNote(note: LastmileCustomerNote): MappedNote | null {
|
|
548
|
+
if (!note.matchedAccountId) return null;
|
|
549
|
+
const text = note.noteText ?? "";
|
|
550
|
+
const firstLine = text.split("\n", 1)[0].trim();
|
|
551
|
+
return {
|
|
552
|
+
sourceId: sourceId("note", note.id),
|
|
553
|
+
title: firstLine.slice(0, 60) || "LastMile customer note",
|
|
554
|
+
bodyMarkdown: text,
|
|
555
|
+
targetSourceId: sourceId("account", note.matchedAccountId),
|
|
556
|
+
targetKind: "company",
|
|
557
|
+
isDeleted: false,
|
|
558
|
+
};
|
|
559
|
+
}
|