fullstackgtm 0.21.2 → 0.23.1
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/CHANGELOG.md +80 -0
- package/README.md +15 -0
- package/dist/bulkUpdate.d.ts +16 -1
- package/dist/bulkUpdate.js +88 -5
- package/dist/cli.js +670 -8
- package/dist/dedupe.d.ts +14 -0
- package/dist/dedupe.js +140 -0
- package/dist/enrich.d.ts +220 -0
- package/dist/enrich.js +724 -0
- package/dist/enrichApollo.d.ts +59 -0
- package/dist/enrichApollo.js +192 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/marketReport.js +97 -44
- package/dist/reassign.d.ts +19 -0
- package/dist/reassign.js +87 -0
- package/dist/suggest.js +67 -5
- package/llms.txt +15 -0
- package/package.json +1 -1
- package/src/bulkUpdate.ts +109 -6
- package/src/cli.ts +756 -8
- package/src/dedupe.ts +182 -0
- package/src/enrich.ts +1016 -0
- package/src/enrichApollo.ts +250 -0
- package/src/index.ts +48 -1
- package/src/marketReport.ts +116 -62
- package/src/reassign.ts +117 -0
- package/src/suggest.ts +69 -5
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CanonicalGtmSnapshot } from "./types.ts";
|
|
2
|
+
import type { EnrichObjectType, EnrichSourceRecord, EnrichWorkItem } from "./enrich.ts";
|
|
3
|
+
export type ApolloClientOptions = {
|
|
4
|
+
getApiKey: () => string | Promise<string>;
|
|
5
|
+
apiBaseUrl?: string;
|
|
6
|
+
/** Injectable fetch for testing — tests must never hit the real Apollo API. */
|
|
7
|
+
fetchImpl?: typeof fetch;
|
|
8
|
+
/** Max retries after a 429 (default 3). */
|
|
9
|
+
maxRetries?: number;
|
|
10
|
+
/** Injectable sleep for testing backoff without waiting. */
|
|
11
|
+
sleep?: (ms: number) => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
export type ApolloClient = {
|
|
14
|
+
/** GET /api/v1/organizations/enrich?domain=… → response body, or null on no data. */
|
|
15
|
+
enrichOrganization(domain: string): Promise<Record<string, unknown> | null>;
|
|
16
|
+
/** POST /api/v1/people/match { email } → response body, or null on no data. */
|
|
17
|
+
matchPerson(email: string): Promise<Record<string, unknown> | null>;
|
|
18
|
+
};
|
|
19
|
+
export declare function createApolloClient(options: ApolloClientOptions): ApolloClient;
|
|
20
|
+
export type ApolloPullKey = {
|
|
21
|
+
objectType: EnrichObjectType;
|
|
22
|
+
/** "domain" for companies, "email" for contacts. */
|
|
23
|
+
key: "domain" | "email";
|
|
24
|
+
value: string;
|
|
25
|
+
};
|
|
26
|
+
export type ApolloPullResult = {
|
|
27
|
+
records: EnrichSourceRecord[];
|
|
28
|
+
/** Pull keys Apollo returned no data for. */
|
|
29
|
+
misses: ApolloPullKey[];
|
|
30
|
+
};
|
|
31
|
+
export type ApolloPullProgress = {
|
|
32
|
+
/** The pull key just processed — the run-store cursor value. */
|
|
33
|
+
lastKeyValue: string;
|
|
34
|
+
record?: EnrichSourceRecord;
|
|
35
|
+
miss?: ApolloPullKey;
|
|
36
|
+
};
|
|
37
|
+
export type ApolloPullOptions = {
|
|
38
|
+
/** Resume checkpoint: pull keys at or before this value are skipped. */
|
|
39
|
+
resumeAfter?: string | null;
|
|
40
|
+
/** Checkpoint callback after each pull key is processed. */
|
|
41
|
+
onProgress?: (progress: ApolloPullProgress) => void | Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Pull keys for an append run: records of the requested types where at least
|
|
45
|
+
* one configured field is blank and the pull key (companies: domain,
|
|
46
|
+
* contacts: email) is present. Deduplicated — two CRM companies sharing a
|
|
47
|
+
* domain produce ONE Apollo call, and the matcher then reports the collision.
|
|
48
|
+
*/
|
|
49
|
+
export declare function apolloPullKeysForAppend(snapshot: CanonicalGtmSnapshot, objectTypes: EnrichObjectType[], needsEnrichment: (objectType: EnrichObjectType, record: Record<string, unknown>) => boolean): ApolloPullKey[];
|
|
50
|
+
/** Pull keys for a refresh run: the stale work set mapped back to pull keys. */
|
|
51
|
+
export declare function apolloPullKeysForRefresh(snapshot: CanonicalGtmSnapshot, workSet: EnrichWorkItem[]): ApolloPullKey[];
|
|
52
|
+
/**
|
|
53
|
+
* Execute the pull: one Apollo call per key, normalized into source records.
|
|
54
|
+
* Sequential on purpose (enrichment endpoints rate-limit aggressively); the
|
|
55
|
+
* 429 backoff lives in the client. `onProgress` checkpoints the run-store
|
|
56
|
+
* cursor after every key so an interrupted pull resumes instead of re-paying
|
|
57
|
+
* for completed lookups.
|
|
58
|
+
*/
|
|
59
|
+
export declare function pullApolloRecords(client: ApolloClient, keys: ApolloPullKey[], options?: ApolloPullOptions): Promise<ApolloPullResult>;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apollo source client for the enrich layer: raw fetch against Apollo's REST
|
|
3
|
+
* enrichment endpoints (people match, organization enrich), bring-your-own
|
|
4
|
+
* key (`login apollo`), no SDK.
|
|
5
|
+
*
|
|
6
|
+
* The one genuinely new HTTP behavior lives here and ONLY here: enrichment
|
|
7
|
+
* APIs rate-limit aggressively, so requests retry on 429 with capped
|
|
8
|
+
* exponential backoff (honoring Retry-After when present). The shared
|
|
9
|
+
* connector contract is deliberately untouched.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_API_BASE_URL = "https://api.apollo.io";
|
|
12
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
13
|
+
const BASE_BACKOFF_MS = 500;
|
|
14
|
+
const MAX_BACKOFF_MS = 8_000;
|
|
15
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
function retryDelayMs(response, attempt) {
|
|
17
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
18
|
+
if (retryAfter) {
|
|
19
|
+
const seconds = Number(retryAfter);
|
|
20
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
21
|
+
return Math.min(seconds * 1000, MAX_BACKOFF_MS);
|
|
22
|
+
const at = Date.parse(retryAfter);
|
|
23
|
+
if (Number.isFinite(at))
|
|
24
|
+
return Math.min(Math.max(at - Date.now(), 0), MAX_BACKOFF_MS);
|
|
25
|
+
}
|
|
26
|
+
return Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
|
27
|
+
}
|
|
28
|
+
export function createApolloClient(options) {
|
|
29
|
+
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
30
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
31
|
+
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
32
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
33
|
+
async function request(path, init = {}) {
|
|
34
|
+
const apiKey = await options.getApiKey();
|
|
35
|
+
for (let attempt = 0;; attempt += 1) {
|
|
36
|
+
let response;
|
|
37
|
+
try {
|
|
38
|
+
response = await fetchImpl(`${baseUrl}${path}`, {
|
|
39
|
+
...init,
|
|
40
|
+
headers: {
|
|
41
|
+
"X-Api-Key": apiKey,
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
Accept: "application/json",
|
|
44
|
+
...(init.headers ?? {}),
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
50
|
+
throw new Error(`Cannot reach Apollo at ${baseUrl}${cause}. Check network access.`);
|
|
51
|
+
}
|
|
52
|
+
if (response.status === 429 && attempt < maxRetries) {
|
|
53
|
+
await sleep(retryDelayMs(response, attempt));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (response.status === 404)
|
|
57
|
+
return null;
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const body = await response.text();
|
|
60
|
+
const exhausted = response.status === 429 ? ` (rate limited; ${maxRetries} retries exhausted)` : "";
|
|
61
|
+
throw new Error(`Apollo API error ${response.status}${exhausted}: ${body}`);
|
|
62
|
+
}
|
|
63
|
+
const text = await response.text();
|
|
64
|
+
return text ? JSON.parse(text) : null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
async enrichOrganization(domain) {
|
|
69
|
+
return request(`/api/v1/organizations/enrich?domain=${encodeURIComponent(domain)}`, {
|
|
70
|
+
method: "GET",
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
async matchPerson(email) {
|
|
74
|
+
return request("/api/v1/people/match", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
body: JSON.stringify({ email }),
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function stringAt(payload, path) {
|
|
82
|
+
let current = payload;
|
|
83
|
+
for (const segment of path) {
|
|
84
|
+
if (!current || typeof current !== "object")
|
|
85
|
+
return undefined;
|
|
86
|
+
current = current[segment];
|
|
87
|
+
}
|
|
88
|
+
return typeof current === "string" && current ? current : undefined;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Pull keys for an append run: records of the requested types where at least
|
|
92
|
+
* one configured field is blank and the pull key (companies: domain,
|
|
93
|
+
* contacts: email) is present. Deduplicated — two CRM companies sharing a
|
|
94
|
+
* domain produce ONE Apollo call, and the matcher then reports the collision.
|
|
95
|
+
*/
|
|
96
|
+
export function apolloPullKeysForAppend(snapshot, objectTypes, needsEnrichment) {
|
|
97
|
+
const keys = [];
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
for (const objectType of objectTypes) {
|
|
100
|
+
const records = objectType === "company" ? snapshot.accounts : snapshot.contacts;
|
|
101
|
+
const keyName = objectType === "company" ? "domain" : "email";
|
|
102
|
+
for (const record of records) {
|
|
103
|
+
const value = record[keyName]?.trim();
|
|
104
|
+
if (!value)
|
|
105
|
+
continue;
|
|
106
|
+
if (!needsEnrichment(objectType, record))
|
|
107
|
+
continue;
|
|
108
|
+
const dedupe = `${objectType}|${value.toLowerCase()}`;
|
|
109
|
+
if (seen.has(dedupe))
|
|
110
|
+
continue;
|
|
111
|
+
seen.add(dedupe);
|
|
112
|
+
keys.push({ objectType, key: keyName, value });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return keys;
|
|
116
|
+
}
|
|
117
|
+
/** Pull keys for a refresh run: the stale work set mapped back to pull keys. */
|
|
118
|
+
export function apolloPullKeysForRefresh(snapshot, workSet) {
|
|
119
|
+
const keys = [];
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
for (const item of workSet) {
|
|
122
|
+
const records = item.objectType === "company" ? snapshot.accounts : snapshot.contacts;
|
|
123
|
+
const record = records.find((entry) => entry.id === item.objectId);
|
|
124
|
+
if (!record)
|
|
125
|
+
continue;
|
|
126
|
+
const keyName = item.objectType === "company" ? "domain" : "email";
|
|
127
|
+
const value = record[keyName]?.trim();
|
|
128
|
+
if (!value)
|
|
129
|
+
continue;
|
|
130
|
+
const dedupe = `${item.objectType}|${value.toLowerCase()}`;
|
|
131
|
+
if (seen.has(dedupe))
|
|
132
|
+
continue;
|
|
133
|
+
seen.add(dedupe);
|
|
134
|
+
keys.push({ objectType: item.objectType, key: keyName, value });
|
|
135
|
+
}
|
|
136
|
+
return keys;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Execute the pull: one Apollo call per key, normalized into source records.
|
|
140
|
+
* Sequential on purpose (enrichment endpoints rate-limit aggressively); the
|
|
141
|
+
* 429 backoff lives in the client. `onProgress` checkpoints the run-store
|
|
142
|
+
* cursor after every key so an interrupted pull resumes instead of re-paying
|
|
143
|
+
* for completed lookups.
|
|
144
|
+
*/
|
|
145
|
+
export async function pullApolloRecords(client, keys, options = {}) {
|
|
146
|
+
const records = [];
|
|
147
|
+
const misses = [];
|
|
148
|
+
// Resume: skip keys up to and including the checkpoint. An unknown
|
|
149
|
+
// checkpoint (snapshot changed since the interrupted run) restarts cleanly.
|
|
150
|
+
const resumeIndex = options.resumeAfter
|
|
151
|
+
? keys.findIndex((key) => key.value === options.resumeAfter)
|
|
152
|
+
: -1;
|
|
153
|
+
for (const key of keys.slice(resumeIndex + 1)) {
|
|
154
|
+
let record;
|
|
155
|
+
if (key.objectType === "company") {
|
|
156
|
+
const payload = await client.enrichOrganization(key.value);
|
|
157
|
+
const organization = payload?.organization;
|
|
158
|
+
if (organization) {
|
|
159
|
+
record = {
|
|
160
|
+
id: `apollo:org_${stringAt(payload, ["organization", "id"]) ?? key.value}`,
|
|
161
|
+
objectType: "company",
|
|
162
|
+
keys: {
|
|
163
|
+
domain: stringAt(payload, ["organization", "primary_domain"]) ?? key.value,
|
|
164
|
+
name: stringAt(payload, ["organization", "name"]),
|
|
165
|
+
},
|
|
166
|
+
payload: payload,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const payload = await client.matchPerson(key.value);
|
|
172
|
+
const person = payload?.person;
|
|
173
|
+
if (person) {
|
|
174
|
+
record = {
|
|
175
|
+
id: `apollo:person_${stringAt(payload, ["person", "id"]) ?? key.value}`,
|
|
176
|
+
objectType: "contact",
|
|
177
|
+
keys: {
|
|
178
|
+
email: stringAt(payload, ["person", "email"]) ?? key.value,
|
|
179
|
+
name: stringAt(payload, ["person", "name"]),
|
|
180
|
+
},
|
|
181
|
+
payload: payload,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (record)
|
|
186
|
+
records.push(record);
|
|
187
|
+
else
|
|
188
|
+
misses.push(key);
|
|
189
|
+
await options.onProgress?.({ lastKeyValue: key.value, record, miss: record ? undefined : key });
|
|
190
|
+
}
|
|
191
|
+
return { records, misses };
|
|
192
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { auditSnapshot, defaultPolicy } from "./audit.ts";
|
|
2
|
-
export { buildBulkUpdatePlan, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
|
|
2
|
+
export { buildBulkUpdatePlan, isFilterableField, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
|
|
3
|
+
export { buildDedupePlan, dedupeKey, type DedupeOptions } from "./dedupe.ts";
|
|
4
|
+
export { buildReassignPlans, type ReassignObjectType, type ReassignOptions } from "./reassign.ts";
|
|
3
5
|
export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, type FullstackgtmConfig, type LoadedConfig, } from "./config.ts";
|
|
4
6
|
export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
|
|
5
7
|
export { createHubspotConnector, type HubspotConnectorOptions } from "./connectors/hubspot.ts";
|
|
@@ -9,6 +11,8 @@ export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDevic
|
|
|
9
11
|
export { createStripeConnector, type StripeConnectorOptions } from "./connectors/stripe.ts";
|
|
10
12
|
export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, type HubspotConnection, type StoredCredential, } from "./credentials.ts";
|
|
11
13
|
export { generateDemoSnapshot, type DemoSnapshotOptions } from "./demo.ts";
|
|
14
|
+
export { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, ingestKeyValue, latestStamps, loadEnrichConfig, matchSourceRecord, parseCsv, parseEnrichConfig, resolveCrmField, selectStaleWork, sourceValueAt, stagedSourceRecords, staleDaysFor, type BuildEnrichPlanOptions, type EnrichAmbiguity, type EnrichConfig, type EnrichCounts, type EnrichFieldConfig, type EnrichMatchConfig, type EnrichMode, type EnrichObjectType, type EnrichPlanResult, type EnrichRun, type EnrichRunStore, type EnrichSourceConfig, type EnrichSourceRecord, type EnrichStamp, type EnrichWorkItem, type MatchOutcome, } from "./enrich.ts";
|
|
15
|
+
export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloClient, type ApolloClientOptions, type ApolloPullKey, type ApolloPullResult, } from "./enrichApollo.ts";
|
|
12
16
|
export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type FieldChange, type FindingsDrift, type RecordChange, type SnapshotDiff, } from "./diff.ts";
|
|
13
17
|
export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport, type MergeSuggestion, } from "./merge.ts";
|
|
14
18
|
export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { auditSnapshot, defaultPolicy } from "./audit.js";
|
|
2
|
-
export { buildBulkUpdatePlan, parseWhere } from "./bulkUpdate.js";
|
|
2
|
+
export { buildBulkUpdatePlan, isFilterableField, parseWhere } from "./bulkUpdate.js";
|
|
3
|
+
export { buildDedupePlan, dedupeKey } from "./dedupe.js";
|
|
4
|
+
export { buildReassignPlans } from "./reassign.js";
|
|
3
5
|
export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, } from "./config.js";
|
|
4
6
|
export { applyPatchPlan } from "./connector.js";
|
|
5
7
|
export { createHubspotConnector } from "./connectors/hubspot.js";
|
|
@@ -9,6 +11,8 @@ export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDevic
|
|
|
9
11
|
export { createStripeConnector } from "./connectors/stripe.js";
|
|
10
12
|
export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, } from "./credentials.js";
|
|
11
13
|
export { generateDemoSnapshot } from "./demo.js";
|
|
14
|
+
export { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, ingestKeyValue, latestStamps, loadEnrichConfig, matchSourceRecord, parseCsv, parseEnrichConfig, resolveCrmField, selectStaleWork, sourceValueAt, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
|
|
15
|
+
export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
|
|
12
16
|
export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
|
|
13
17
|
export { mergeSnapshots, } from "./merge.js";
|
|
14
18
|
export { createFilePlanStore } from "./planStore.js";
|
package/dist/marketReport.js
CHANGED
|
@@ -382,8 +382,9 @@ export function marketMapToHtml(config, set) {
|
|
|
382
382
|
const anchor = config.anchorVendor;
|
|
383
383
|
const e = escapeHtml;
|
|
384
384
|
const axisHtml = axisSectionsHtml(config, set);
|
|
385
|
-
const
|
|
386
|
-
|
|
385
|
+
const vendorNamesById = new Map(config.vendors.map((vendor) => [vendor.id, vendor.name]));
|
|
386
|
+
const frontByClaim = new Map(model.fronts.map((front) => [front.claimId, front]));
|
|
387
|
+
const matrixRow = (claimId) => {
|
|
387
388
|
const claim = claimsById.get(claimId);
|
|
388
389
|
if (!claim)
|
|
389
390
|
return "";
|
|
@@ -398,30 +399,90 @@ export function marketMapToHtml(config, set) {
|
|
|
398
399
|
return (`<tr class="front-${state}"><th scope="row"><span class="claim-cap">${e(claim.capability.split(":")[0])}</span>` +
|
|
399
400
|
`<span class="claim-meta">${e(claim.icp)} · ${e(claim.pricingStructure)}</span></th>${cells}` +
|
|
400
401
|
`<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`);
|
|
401
|
-
}
|
|
402
|
+
};
|
|
403
|
+
const vendorHeads = config.vendors
|
|
404
|
+
.map((vendor) => `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`)
|
|
402
405
|
.join("");
|
|
403
|
-
|
|
404
|
-
|
|
406
|
+
// Claims grouped by front state, each group a collapsed <details> whose
|
|
407
|
+
// summary carries the stats a skimmer needs; the full matrix is one click
|
|
408
|
+
// away, not a wall the reader must climb to reach the takeaway.
|
|
409
|
+
const GROUPS = [
|
|
410
|
+
{ states: ["open", "vacant"], title: "Open ground", blurb: "no vendor is loud here" },
|
|
411
|
+
{ states: ["contested"], title: "Contested fronts", blurb: "2–3 vendors loud" },
|
|
412
|
+
{ states: ["owned"], title: "Owned fronts", blurb: "exactly one vendor loud" },
|
|
413
|
+
{ states: ["saturated"], title: "Saturated fronts", blurb: "4+ vendors loud" },
|
|
414
|
+
];
|
|
415
|
+
const groupedMatrix = GROUPS.map((group) => {
|
|
416
|
+
const claimIds = model.orderedClaimIds.filter((claimId) => group.states.includes(stateByClaim.get(claimId) ?? "vacant"));
|
|
417
|
+
if (claimIds.length === 0)
|
|
418
|
+
return "";
|
|
419
|
+
const anchorLoud = anchor
|
|
420
|
+
? claimIds.filter((claimId) => model.cell(anchor, claimId)?.intensity === "loud").length
|
|
421
|
+
: 0;
|
|
422
|
+
const anchorNote = anchor ? ` · ${vendorNamesById.get(anchor) ?? anchor} loud on ${anchorLoud}` : "";
|
|
423
|
+
return `<details class="claim-group"><summary><b>${e(group.title)}</b> — ${claimIds.length} claim${claimIds.length === 1 ? "" : "s"} <span class="sum-soft">(${e(group.blurb)}${anchorNote})</span></summary>
|
|
424
|
+
<table><thead><tr><th></th>${vendorHeads}<th></th></tr></thead><tbody>${claimIds.map(matrixRow).join("")}</tbody></table>
|
|
425
|
+
</details>`;
|
|
426
|
+
}).join("");
|
|
427
|
+
// The closing argument: walk from open ground to a reasoned target list.
|
|
428
|
+
const openFronts = model.orderedClaimIds.filter((claimId) => {
|
|
405
429
|
const state = stateByClaim.get(claimId);
|
|
406
430
|
return state === "open" || state === "vacant";
|
|
407
|
-
})
|
|
431
|
+
});
|
|
432
|
+
const targetItems = openFronts
|
|
408
433
|
.map((claimId) => {
|
|
409
434
|
const claim = claimsById.get(claimId);
|
|
410
|
-
|
|
435
|
+
const front = frontByClaim.get(claimId);
|
|
436
|
+
if (!claim || !front)
|
|
437
|
+
return "";
|
|
438
|
+
const quietNames = front.quietVendorIds.map((id) => vendorNamesById.get(id) ?? id);
|
|
439
|
+
const anchorIntensity = anchor ? model.cell(anchor, claimId)?.intensity ?? "unobservable" : null;
|
|
440
|
+
const nearest = quietNames.length > 0
|
|
441
|
+
? `Closest contenders (quiet): ${quietNames.join(", ")}.`
|
|
442
|
+
: "Nobody even ships it quietly — vacant ground.";
|
|
443
|
+
const move = anchorIntensity === "quiet"
|
|
444
|
+
? `${e(vendorNamesById.get(anchor) ?? "The anchor")} already ships this quietly — a promote-to-loud candidate.`
|
|
445
|
+
: anchorIntensity === "absent"
|
|
446
|
+
? "Unclaimed by the anchor: a first-mover messaging opportunity if the capability is real or buildable."
|
|
447
|
+
: "";
|
|
448
|
+
return `<li><b>${e(claim.capability.split(":")[0])}</b> <span class="sum-soft">(${e(claim.icp)} · ${e(claim.pricingStructure)})</span><br>
|
|
449
|
+
No vendor is loud on this claim. ${e(nearest)} ${move}</li>`;
|
|
411
450
|
})
|
|
412
451
|
.join("");
|
|
413
|
-
const
|
|
414
|
-
.
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
.
|
|
423
|
-
|
|
424
|
-
|
|
452
|
+
const heldFronts = anchor
|
|
453
|
+
? model.fronts.filter((front) => front.state === "owned" && front.loudVendorIds[0] === anchor)
|
|
454
|
+
: [];
|
|
455
|
+
const heldLine = heldFronts.length > 0
|
|
456
|
+
? `<p><b>Held ground:</b> ${e(vendorNamesById.get(anchor) ?? "the anchor")} is the sole loud vendor on ${heldFronts
|
|
457
|
+
.map((front) => `<i>${e(claimsById.get(front.claimId)?.capability.split(":")[0] ?? front.claimId)}</i>`)
|
|
458
|
+
.join(", ")} — positions to defend, not abandon.</p>`
|
|
459
|
+
: "";
|
|
460
|
+
const crowdLine = counts.saturated > 0
|
|
461
|
+
? `<p><b>Crowded ground:</b> ${counts.saturated} claim${counts.saturated === 1 ? " is" : "s are"} saturated (4+ vendors loud) — message budget spent there buys the least differentiation.</p>`
|
|
462
|
+
: "";
|
|
463
|
+
const takeaway = `<section>
|
|
464
|
+
<h2>Where to attack</h2>
|
|
465
|
+
<p class="lede">${openFronts.length === 0 ? "No open fronts this run — every claim has at least one loud vendor. Watch the drift between runs for windows opening." : `${openFronts.length} claim${openFronts.length === 1 ? "" : "s"} in this category ${openFronts.length === 1 ? "is" : "are"} open: buyers can be reached there without out-shouting anyone.`}</p>
|
|
466
|
+
<ul class="targets">${targetItems}</ul>
|
|
467
|
+
${heldLine}
|
|
468
|
+
${crowdLine}
|
|
469
|
+
<p class="sum-soft">These are messaging fronts, not verdicts — join the map to CRM ground truth (\`market overlay\`) for evidence-backed OCCUPY / PROMOTE / URGENT / RETREAT directives with win-rate stats.</p>
|
|
470
|
+
</section>`;
|
|
471
|
+
// Evidence grouped by vendor, collapsed: receipts on demand, not a scroll wall.
|
|
472
|
+
const appendixGroups = config.vendors
|
|
473
|
+
.map((vendor) => {
|
|
474
|
+
const items = model.orderedClaimIds.flatMap((claimId) => {
|
|
475
|
+
const obs = model.cell(vendor.id, claimId);
|
|
476
|
+
if (!obs || obs.evidence.length === 0)
|
|
477
|
+
return [];
|
|
478
|
+
return obs.evidence.map((evidence) => `<div class="ev"><span class="ev-head">${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
|
|
479
|
+
`<blockquote>“${e(evidence.text)}”</blockquote>` +
|
|
480
|
+
`<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`);
|
|
481
|
+
});
|
|
482
|
+
if (items.length === 0)
|
|
483
|
+
return "";
|
|
484
|
+
return `<details class="ev-group"><summary><b>${e(vendor.name)}</b> <span class="sum-soft">— ${items.length} quoted span${items.length === 1 ? "" : "s"}</span></summary>${items.join("")}</details>`;
|
|
485
|
+
})
|
|
425
486
|
.join("");
|
|
426
487
|
return `<!doctype html>
|
|
427
488
|
<html lang="en"><head><meta charset="utf-8">
|
|
@@ -438,14 +499,16 @@ h1 { font-size:27px; font-weight:600; line-height:1.2; }
|
|
|
438
499
|
.meta { font-size:11px; color:var(--soft); display:flex; gap:18px; flex-wrap:wrap; margin-top:8px; }
|
|
439
500
|
section { margin-top:44px; }
|
|
440
501
|
h2 { font-size:17px; font-weight:600; border-bottom:1px solid var(--line); padding-bottom:7px; margin-bottom:4px; }
|
|
441
|
-
.
|
|
442
|
-
.
|
|
443
|
-
.
|
|
444
|
-
.
|
|
445
|
-
.
|
|
446
|
-
.
|
|
447
|
-
.
|
|
448
|
-
.
|
|
502
|
+
details.claim-group, details.ev-group { border:1px solid var(--line); border-radius:3px; margin-top:10px; }
|
|
503
|
+
details.claim-group summary, details.ev-group summary { cursor:pointer; padding:10px 14px; font-size:14px; list-style-position:inside; }
|
|
504
|
+
details.claim-group summary:hover, details.ev-group summary:hover { background:var(--faint); }
|
|
505
|
+
details.claim-group[open] summary, details.ev-group[open] summary { border-bottom:1px solid var(--line); }
|
|
506
|
+
details.claim-group table { margin:4px 12px 12px; width:calc(100% - 24px); }
|
|
507
|
+
details.ev-group .ev { margin:0 14px; }
|
|
508
|
+
.sum-soft { color:var(--soft); font-size:12px; }
|
|
509
|
+
.lede { font-size:16px; margin-top:12px; }
|
|
510
|
+
.targets { margin-top:12px; font-size:14.5px; line-height:1.6; }
|
|
511
|
+
.targets li { margin:10px 0 10px 20px; }
|
|
449
512
|
.key { display:flex; gap:20px; flex-wrap:wrap; margin:14px 0 8px; font-size:10.5px; color:var(--soft); }
|
|
450
513
|
.lg { display:inline-flex; align-items:center; gap:6px; }
|
|
451
514
|
table { border-collapse:collapse; width:100%; margin-top:6px; }
|
|
@@ -506,34 +569,24 @@ footer { margin-top:60px; border-top:1px solid var(--ink); padding-top:12px; fon
|
|
|
506
569
|
<span>extractor: ${e(set.extractor)}</span>
|
|
507
570
|
</div>
|
|
508
571
|
</header>
|
|
572
|
+
${axisHtml.strategicMap}
|
|
509
573
|
<section>
|
|
510
|
-
<h2>
|
|
511
|
-
<div class="fronts">
|
|
512
|
-
<div class="fcard open"><b>${counts.open + counts.vacant}</b><span>Open / vacant</span></div>
|
|
513
|
-
<div class="fcard"><b>${counts.contested}</b><span>Contested</span></div>
|
|
514
|
-
<div class="fcard"><b>${counts.owned}</b><span>Owned</span></div>
|
|
515
|
-
<div class="fcard"><b>${counts.saturated}</b><span>Saturated</span></div>
|
|
516
|
-
</div>
|
|
517
|
-
<ul class="openlist">${openList}</ul>
|
|
518
|
-
</section>
|
|
519
|
-
<section>
|
|
520
|
-
<h2>Claim × vendor intensity matrix</h2>
|
|
574
|
+
<h2>Market Claims</h2>
|
|
521
575
|
<div class="key">
|
|
522
576
|
<span class="lg"><i class="g g-loud"></i>LOUD — hero-level claim</span>
|
|
523
577
|
<span class="lg"><i class="g g-quiet"></i>QUIET — shipped, buried</span>
|
|
524
578
|
<span class="lg"><i class="g g-absent"></i>ABSENT</span>
|
|
525
579
|
<span class="lg"><i class="g g-unobservable"></i>UNOBSERVABLE — capture failed</span>
|
|
526
580
|
</div>
|
|
527
|
-
|
|
528
|
-
<thead><tr><th></th>${vendorHeads}<th></th></tr></thead>
|
|
529
|
-
<tbody>${matrixRows}</tbody>
|
|
530
|
-
</table>
|
|
581
|
+
${groupedMatrix}
|
|
531
582
|
</section>
|
|
532
|
-
${
|
|
583
|
+
${takeaway}
|
|
533
584
|
<section>
|
|
534
585
|
<h2>Evidence appendix</h2>
|
|
535
|
-
|
|
586
|
+
<p class="sum-soft">Every loud/quiet reading is grounded in a verbatim span from a stored page capture; expand a vendor for its receipts.</p>
|
|
587
|
+
${appendixGroups}
|
|
536
588
|
</section>
|
|
589
|
+
<script>window.addEventListener("beforeprint",function(){document.querySelectorAll("details").forEach(function(d){d.open=true;});});</script>
|
|
537
590
|
<footer>
|
|
538
591
|
<span>Generated by fullstackgtm market · deterministic render of ${e(set.runLabel)}</span>
|
|
539
592
|
<span>Front rule v1: 0 loud=open · 1=owned · 2–3=contested · ≥4=saturated</span>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
2
|
+
export type ReassignObjectType = "account" | "contact" | "deal";
|
|
3
|
+
export type ReassignOptions = {
|
|
4
|
+
/** the owner whose records are being handed off */
|
|
5
|
+
fromOwnerId: string;
|
|
6
|
+
/** the receiving owner — must be a known user in the snapshot */
|
|
7
|
+
toOwnerId: string;
|
|
8
|
+
/** which object types to compile plans for (default all three) */
|
|
9
|
+
objects?: ReassignObjectType[];
|
|
10
|
+
/** extra --where scoping, AND-ed into every plan (account fields lifted) */
|
|
11
|
+
where?: string[];
|
|
12
|
+
/** exclude records tied to an open deal in this stage (see module doc) */
|
|
13
|
+
exceptDealStage?: string;
|
|
14
|
+
/** also reassign closed deals (default false: history keeps its owner) */
|
|
15
|
+
includeClosedDeals?: boolean;
|
|
16
|
+
reason?: string;
|
|
17
|
+
maxOperations?: number;
|
|
18
|
+
};
|
|
19
|
+
export declare function buildReassignPlans(snapshot: CanonicalGtmSnapshot, options: ReassignOptions): PatchPlan[];
|
package/dist/reassign.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ownership-handoff playbook: `reassign` compiles one bulk-update-style
|
|
3
|
+
* dry-run plan PER object type (accounts, contacts, deals by default) that
|
|
4
|
+
* moves every record owned by --from to --to. It NEVER writes — each plan
|
|
5
|
+
* flows through the same plans-approve → apply gate, and because every plan
|
|
6
|
+
* carries its full filter, eligibility (extra --where scoping AND the
|
|
7
|
+
* --except-deal-stage exclusion) is re-verified per record against a FRESH
|
|
8
|
+
* snapshot at apply time, with mid-apply rechecks. A record that drifts into
|
|
9
|
+
* the exception set mid-run surfaces as a conflict, not a bad write.
|
|
10
|
+
*
|
|
11
|
+
* --except-deal-stage <stage> excludes in-flight business end to end:
|
|
12
|
+
* - deal plans drop deals in that stage (stage!=<stage>), and
|
|
13
|
+
* - ALL plans drop records whose account has an OPEN deal in that stage
|
|
14
|
+
* (accounts: openDealStages!~<stage>; deals/contacts:
|
|
15
|
+
* account.openDealStages!~<stage>).
|
|
16
|
+
*
|
|
17
|
+
* Deal plans cover OPEN deals only unless includeClosedDeals is set: closed
|
|
18
|
+
* deals keep their historical owner in a handoff.
|
|
19
|
+
*
|
|
20
|
+
* Extra --where clauses are account-scoped where needed: a clause on a field
|
|
21
|
+
* that only exists on accounts (e.g. domain~.de) is lifted to the relational
|
|
22
|
+
* pseudo-field (account.domain~.de) for contact and deal plans, so one
|
|
23
|
+
* invocation scopes all three object types consistently.
|
|
24
|
+
*/
|
|
25
|
+
import { buildBulkUpdatePlan, isFilterableField, parseWhere } from "./bulkUpdate.js";
|
|
26
|
+
const ALL_OBJECTS = ["account", "contact", "deal"];
|
|
27
|
+
/**
|
|
28
|
+
* Scope an extra --where clause to one object type: pass through when the
|
|
29
|
+
* field is valid there, lift account-only fields to account.<field> for
|
|
30
|
+
* contacts and deals. Unknown fields fall through to buildBulkUpdatePlan's
|
|
31
|
+
* strict validation, which throws with the valid-field list.
|
|
32
|
+
*/
|
|
33
|
+
function scopeWhere(objectType, raw) {
|
|
34
|
+
const clause = parseWhere(raw);
|
|
35
|
+
if (isFilterableField(objectType, clause.field))
|
|
36
|
+
return raw;
|
|
37
|
+
if (objectType !== "account" && isFilterableField(objectType, `account.${clause.field}`)) {
|
|
38
|
+
return `account.${raw}`;
|
|
39
|
+
}
|
|
40
|
+
return raw;
|
|
41
|
+
}
|
|
42
|
+
export function buildReassignPlans(snapshot, options) {
|
|
43
|
+
if (!options.fromOwnerId || !options.toOwnerId) {
|
|
44
|
+
throw new Error("reassign requires both --from <ownerId> and --to <ownerId>.");
|
|
45
|
+
}
|
|
46
|
+
if (options.fromOwnerId === options.toOwnerId) {
|
|
47
|
+
throw new Error("reassign --from and --to are the same owner — nothing to hand off.");
|
|
48
|
+
}
|
|
49
|
+
// The receiving owner must exist: a typo'd --to would otherwise write an
|
|
50
|
+
// invalid owner onto every matched record.
|
|
51
|
+
if (!snapshot.users.some((user) => user.id === options.toOwnerId)) {
|
|
52
|
+
throw new Error(`reassign --to ${options.toOwnerId} is not a known user in the snapshot. Known users: ${snapshot.users
|
|
53
|
+
.map((user) => `${user.id} (${user.name})`)
|
|
54
|
+
.join(", ") || "none"}.`);
|
|
55
|
+
}
|
|
56
|
+
const objects = options.objects ?? ALL_OBJECTS;
|
|
57
|
+
for (const objectType of objects) {
|
|
58
|
+
if (!ALL_OBJECTS.includes(objectType)) {
|
|
59
|
+
throw new Error(`reassign --objects supports account, contact, deal — got "${objectType}".`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return objects.map((objectType) => {
|
|
63
|
+
const where = [`ownerId=${options.fromOwnerId}`];
|
|
64
|
+
if (objectType === "deal" && !options.includeClosedDeals) {
|
|
65
|
+
where.push("isClosed=false"); // closed deals keep their historical owner
|
|
66
|
+
}
|
|
67
|
+
for (const extra of options.where ?? []) {
|
|
68
|
+
where.push(scopeWhere(objectType, extra));
|
|
69
|
+
}
|
|
70
|
+
if (options.exceptDealStage) {
|
|
71
|
+
const stage = options.exceptDealStage;
|
|
72
|
+
if (objectType === "deal")
|
|
73
|
+
where.push(`stage!=${stage}`);
|
|
74
|
+
where.push(objectType === "account"
|
|
75
|
+
? `openDealStages!~${stage}`
|
|
76
|
+
: `account.openDealStages!~${stage}`);
|
|
77
|
+
}
|
|
78
|
+
return buildBulkUpdatePlan(snapshot, {
|
|
79
|
+
objectType,
|
|
80
|
+
where,
|
|
81
|
+
set: { ownerId: options.toOwnerId },
|
|
82
|
+
reason: options.reason ??
|
|
83
|
+
`reassign: hand off ${objectType}s from owner ${options.fromOwnerId} to ${options.toOwnerId}`,
|
|
84
|
+
maxOperations: options.maxOperations,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|