@sellable/mcp 0.1.253 → 0.1.254
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/server.js +4 -1
- package/dist/tools/csv-dnc.d.ts +131 -0
- package/dist/tools/csv-dnc.js +651 -0
- package/dist/tools/csv-domains.d.ts +7 -0
- package/dist/tools/csv-domains.js +1 -1
- package/dist/tools/csv-linkedin.d.ts +8 -0
- package/dist/tools/csv-linkedin.js +1 -1
- package/dist/tools/leads.d.ts +276 -15
- package/dist/tools/leads.js +46 -0
- package/dist/tools/registry.d.ts +151 -15
- package/package.json +1 -1
- package/skills/create-campaign/SKILL.md +19 -2
- package/skills/create-campaign-v2/core/flow.v2.json +1 -1
- package/skills/create-campaign-v2/references/filter-leads.md +6 -0
- package/skills/create-campaign-v2/references/lead-validation-preview.md +4 -0
- package/skills/create-campaign-v2/references/step-13-import-leads.md +6 -0
- package/skills/find-leads/SKILL.md +23 -9
- package/skills/providers/prospeo.md +6 -0
- package/skills/research/config.json +0 -9
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import { parse } from "csv-parse/sync";
|
|
2
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
3
|
+
import { readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
5
|
+
import { getApi } from "../api.js";
|
|
6
|
+
import { getConfig } from "../auth.js";
|
|
7
|
+
import { resolveWorkspaceRoot } from "../utils/workspace-root.js";
|
|
8
|
+
import { sanitizeCsvDomainCandidate } from "./csv-domains.js";
|
|
9
|
+
import { validateAndNormalizeLinkedInUrl } from "./csv-linkedin.js";
|
|
10
|
+
const entryPath = process.argv[1] ? resolve(process.argv[1]) : process.cwd();
|
|
11
|
+
const entryDir = dirname(entryPath);
|
|
12
|
+
const workspaceRoot = resolveWorkspaceRoot(entryDir);
|
|
13
|
+
const CONFIRMATION_TOKEN_VERSION = "csv-dnc-preview-v1";
|
|
14
|
+
const confirmationSecret = randomBytes(32).toString("hex");
|
|
15
|
+
const MAX_DNC_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
16
|
+
const MAX_DNC_CANDIDATES = 7500;
|
|
17
|
+
const STRONG_DOMAIN_HEADER_KEYS = new Set([
|
|
18
|
+
"domain",
|
|
19
|
+
"website",
|
|
20
|
+
"companydomain",
|
|
21
|
+
"companywebsite",
|
|
22
|
+
"url",
|
|
23
|
+
"homepage",
|
|
24
|
+
]);
|
|
25
|
+
const DOMAIN_HEADER_TOKENS = ["domain", "website", "url", "homepage", "site"];
|
|
26
|
+
const STRONG_LINKEDIN_HEADER_KEYS = new Set([
|
|
27
|
+
"linkedin",
|
|
28
|
+
"linkedinurl",
|
|
29
|
+
"linkedinprofile",
|
|
30
|
+
"linkedinprofileurl",
|
|
31
|
+
"profileurl",
|
|
32
|
+
"personlinkedin",
|
|
33
|
+
]);
|
|
34
|
+
const LINKEDIN_HEADER_TOKENS = ["linkedin", "profile", "public"];
|
|
35
|
+
function normalizeHeaderKey(value) {
|
|
36
|
+
return value
|
|
37
|
+
.trim()
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replace(/[^a-z0-9]+/g, "");
|
|
40
|
+
}
|
|
41
|
+
function hashText(value) {
|
|
42
|
+
return createHash("sha256").update(value).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
function hashEntry(entry) {
|
|
45
|
+
return hashText(`${entry.type}:${entry.value.toLowerCase()}`);
|
|
46
|
+
}
|
|
47
|
+
function compareStringArrays(left, right) {
|
|
48
|
+
if (left.length !== right.length)
|
|
49
|
+
return false;
|
|
50
|
+
return left.every((value, index) => value === right[index]);
|
|
51
|
+
}
|
|
52
|
+
function normalizeListName(value) {
|
|
53
|
+
const trimmed = String(value ?? "").trim();
|
|
54
|
+
return trimmed || "MCP DNC Import";
|
|
55
|
+
}
|
|
56
|
+
function resolveSource(input) {
|
|
57
|
+
const supplied = [input.filePath, input.csvText, input.rawText].filter((value) => typeof value === "string" && value.trim());
|
|
58
|
+
if (supplied.length !== 1) {
|
|
59
|
+
throw new Error("load_csv_dnc_entries requires exactly one of filePath, csvText, or rawText.");
|
|
60
|
+
}
|
|
61
|
+
if (input.filePath?.trim()) {
|
|
62
|
+
const resolvedFilePath = isAbsolute(input.filePath)
|
|
63
|
+
? resolve(input.filePath)
|
|
64
|
+
: resolve(workspaceRoot, input.filePath);
|
|
65
|
+
let stats;
|
|
66
|
+
try {
|
|
67
|
+
stats = statSync(resolvedFilePath);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
throw new Error(`Could not read DNC CSV file at ${resolvedFilePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
71
|
+
}
|
|
72
|
+
if (!stats.isFile()) {
|
|
73
|
+
throw new Error(`DNC CSV path must point to a file: ${resolvedFilePath}`);
|
|
74
|
+
}
|
|
75
|
+
if (stats.size > MAX_DNC_UPLOAD_BYTES) {
|
|
76
|
+
throw new Error(`DNC CSV file exceeds the ${MAX_DNC_UPLOAD_BYTES} byte limit.`);
|
|
77
|
+
}
|
|
78
|
+
const content = readFileSync(resolvedFilePath, "utf8");
|
|
79
|
+
return {
|
|
80
|
+
kind: "file",
|
|
81
|
+
resolvedFilePath,
|
|
82
|
+
fileSizeBytes: stats.size,
|
|
83
|
+
fileMtimeMs: stats.mtimeMs,
|
|
84
|
+
contentHash: hashText(content),
|
|
85
|
+
content,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (input.csvText?.trim()) {
|
|
89
|
+
return {
|
|
90
|
+
kind: "csvText",
|
|
91
|
+
contentHash: hashText(input.csvText),
|
|
92
|
+
content: input.csvText,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
kind: "rawText",
|
|
97
|
+
contentHash: hashText(input.rawText ?? ""),
|
|
98
|
+
content: input.rawText ?? "",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function parseCsvRows(content) {
|
|
102
|
+
const blockingErrors = [];
|
|
103
|
+
let rawRows;
|
|
104
|
+
try {
|
|
105
|
+
rawRows = parse(content, {
|
|
106
|
+
bom: true,
|
|
107
|
+
columns: false,
|
|
108
|
+
skip_empty_lines: true,
|
|
109
|
+
trim: true,
|
|
110
|
+
relax_column_count: true,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
throw new Error(`Could not parse DNC CSV input: ${error instanceof Error ? error.message : String(error)}`);
|
|
115
|
+
}
|
|
116
|
+
if (rawRows.length === 0) {
|
|
117
|
+
return {
|
|
118
|
+
headers: [],
|
|
119
|
+
rows: [],
|
|
120
|
+
blockingErrors: ["CSV input must include a header row."],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const headers = rawRows[0].map((value) => String(value ?? "").trim());
|
|
124
|
+
const dataRows = rawRows
|
|
125
|
+
.slice(1)
|
|
126
|
+
.map((row) => row.map((value) => String(value ?? "")));
|
|
127
|
+
if (headers.some((header) => !header)) {
|
|
128
|
+
blockingErrors.push("CSV header row contains blank column names.");
|
|
129
|
+
}
|
|
130
|
+
const seenHeaders = new Set();
|
|
131
|
+
const duplicateHeaders = new Set();
|
|
132
|
+
for (const header of headers) {
|
|
133
|
+
const normalized = header.trim().toLowerCase();
|
|
134
|
+
if (!normalized)
|
|
135
|
+
continue;
|
|
136
|
+
if (seenHeaders.has(normalized)) {
|
|
137
|
+
duplicateHeaders.add(header);
|
|
138
|
+
}
|
|
139
|
+
seenHeaders.add(normalized);
|
|
140
|
+
}
|
|
141
|
+
if (duplicateHeaders.size > 0) {
|
|
142
|
+
blockingErrors.push(`CSV header row contains duplicate columns: ${Array.from(duplicateHeaders).join(", ")}`);
|
|
143
|
+
}
|
|
144
|
+
const mismatchedRows = dataRows.filter((row) => row.length !== headers.length);
|
|
145
|
+
if (mismatchedRows.length > 0) {
|
|
146
|
+
blockingErrors.push(`CSV contains ${mismatchedRows.length} row(s) whose column count does not match the header row.`);
|
|
147
|
+
}
|
|
148
|
+
if (blockingErrors.length > 0) {
|
|
149
|
+
return { headers, rows: [], blockingErrors };
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
headers,
|
|
153
|
+
rows: dataRows.map((row) => {
|
|
154
|
+
const out = {};
|
|
155
|
+
headers.forEach((header, index) => {
|
|
156
|
+
out[header] = String(row[index] ?? "");
|
|
157
|
+
});
|
|
158
|
+
return out;
|
|
159
|
+
}),
|
|
160
|
+
blockingErrors,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function resolveHeaderSelection(headers, requested) {
|
|
164
|
+
const trimmed = requested.trim();
|
|
165
|
+
if (!trimmed)
|
|
166
|
+
throw new Error("CSV column names must be non-empty strings.");
|
|
167
|
+
const exact = headers.find((header) => header === trimmed);
|
|
168
|
+
if (exact)
|
|
169
|
+
return exact;
|
|
170
|
+
const matches = headers.filter((header) => header.trim().toLowerCase() === trimmed.toLowerCase());
|
|
171
|
+
if (matches.length === 1)
|
|
172
|
+
return matches[0];
|
|
173
|
+
throw new Error(`Unknown CSV column: ${requested}`);
|
|
174
|
+
}
|
|
175
|
+
function matchingColumns(headers, kind) {
|
|
176
|
+
const strongKeys = kind === "domain" ? STRONG_DOMAIN_HEADER_KEYS : STRONG_LINKEDIN_HEADER_KEYS;
|
|
177
|
+
const weakTokens = kind === "domain" ? DOMAIN_HEADER_TOKENS : LINKEDIN_HEADER_TOKENS;
|
|
178
|
+
const strong = headers.filter((header) => strongKeys.has(normalizeHeaderKey(header)));
|
|
179
|
+
if (strong.length > 0)
|
|
180
|
+
return strong;
|
|
181
|
+
return headers.filter((header) => {
|
|
182
|
+
const normalized = normalizeHeaderKey(header);
|
|
183
|
+
if (kind === "domain" &&
|
|
184
|
+
(normalized.includes("linkedin") || normalized.includes("profile"))) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
return weakTokens.some((token) => normalized.includes(token));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
function detectColumns(params) {
|
|
191
|
+
const warnings = [];
|
|
192
|
+
const blockingErrors = [];
|
|
193
|
+
const candidateDomainColumns = matchingColumns(params.headers, "domain");
|
|
194
|
+
const candidateLinkedInColumns = matchingColumns(params.headers, "linkedin");
|
|
195
|
+
let domainColumn = null;
|
|
196
|
+
let linkedInColumn = null;
|
|
197
|
+
if (params.domainColumn) {
|
|
198
|
+
domainColumn = resolveHeaderSelection(params.headers, params.domainColumn);
|
|
199
|
+
}
|
|
200
|
+
else if (candidateDomainColumns.length === 1) {
|
|
201
|
+
domainColumn = candidateDomainColumns[0];
|
|
202
|
+
}
|
|
203
|
+
else if (candidateDomainColumns.length > 1) {
|
|
204
|
+
blockingErrors.push(`Multiple possible domain columns found: ${candidateDomainColumns.join(", ")}. Re-run with domainColumn.`);
|
|
205
|
+
}
|
|
206
|
+
if (params.linkedInColumn) {
|
|
207
|
+
linkedInColumn = resolveHeaderSelection(params.headers, params.linkedInColumn);
|
|
208
|
+
}
|
|
209
|
+
else if (candidateLinkedInColumns.length === 1) {
|
|
210
|
+
linkedInColumn = candidateLinkedInColumns[0];
|
|
211
|
+
}
|
|
212
|
+
else if (candidateLinkedInColumns.length > 1) {
|
|
213
|
+
blockingErrors.push(`Multiple possible LinkedIn profile columns found: ${candidateLinkedInColumns.join(", ")}. Re-run with linkedInColumn.`);
|
|
214
|
+
}
|
|
215
|
+
if (!domainColumn && !linkedInColumn) {
|
|
216
|
+
warnings.push("No DNC domain or LinkedIn profile column could be auto-detected.");
|
|
217
|
+
blockingErrors.push("Choose a domainColumn, linkedInColumn, or paste a raw list of domains and LinkedIn profile URLs.");
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
domainColumn,
|
|
221
|
+
linkedInColumn,
|
|
222
|
+
candidateDomainColumns,
|
|
223
|
+
candidateLinkedInColumns,
|
|
224
|
+
warnings,
|
|
225
|
+
blockingErrors,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function addDomainCandidate(params) {
|
|
229
|
+
const trimmed = params.value.trim();
|
|
230
|
+
if (!trimmed)
|
|
231
|
+
return;
|
|
232
|
+
const sanitized = sanitizeCsvDomainCandidate(trimmed);
|
|
233
|
+
if (!sanitized.valid) {
|
|
234
|
+
params.invalidRows.push({
|
|
235
|
+
row: params.row,
|
|
236
|
+
column: params.column,
|
|
237
|
+
value: trimmed,
|
|
238
|
+
reason: sanitized.reason,
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
params.candidates.push({
|
|
243
|
+
type: "domain",
|
|
244
|
+
value: sanitized.cleaned,
|
|
245
|
+
sourceRow: params.row,
|
|
246
|
+
sourceColumn: params.column,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function addLinkedInCandidate(params) {
|
|
250
|
+
const trimmed = params.value.trim();
|
|
251
|
+
if (!trimmed)
|
|
252
|
+
return;
|
|
253
|
+
const normalized = validateAndNormalizeLinkedInUrl(trimmed);
|
|
254
|
+
if (!normalized.valid) {
|
|
255
|
+
params.invalidRows.push({
|
|
256
|
+
row: params.row,
|
|
257
|
+
column: params.column,
|
|
258
|
+
value: trimmed,
|
|
259
|
+
reason: normalized.reason,
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
params.candidates.push({
|
|
264
|
+
type: "linkedin",
|
|
265
|
+
value: normalized.normalizedUrl,
|
|
266
|
+
sourceRow: params.row,
|
|
267
|
+
sourceColumn: params.column,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function parseRawDncValues(content) {
|
|
271
|
+
const candidates = [];
|
|
272
|
+
const invalidRows = [];
|
|
273
|
+
const values = content
|
|
274
|
+
.split(/[\n,]/)
|
|
275
|
+
.map((value) => value.replace(/^[-*]\s*/, "").trim())
|
|
276
|
+
.filter(Boolean);
|
|
277
|
+
values.forEach((value, index) => {
|
|
278
|
+
const linkedIn = validateAndNormalizeLinkedInUrl(value);
|
|
279
|
+
if (linkedIn.valid) {
|
|
280
|
+
candidates.push({
|
|
281
|
+
type: "linkedin",
|
|
282
|
+
value: linkedIn.normalizedUrl,
|
|
283
|
+
sourceRow: index + 1,
|
|
284
|
+
sourceColumn: null,
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const domain = sanitizeCsvDomainCandidate(value);
|
|
289
|
+
if (domain.valid) {
|
|
290
|
+
candidates.push({
|
|
291
|
+
type: "domain",
|
|
292
|
+
value: domain.cleaned,
|
|
293
|
+
sourceRow: index + 1,
|
|
294
|
+
sourceColumn: null,
|
|
295
|
+
});
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
invalidRows.push({
|
|
299
|
+
row: index + 1,
|
|
300
|
+
column: null,
|
|
301
|
+
value,
|
|
302
|
+
reason: value.toLowerCase().includes("linkedin.com") && !linkedIn.valid
|
|
303
|
+
? linkedIn.reason
|
|
304
|
+
: domain.reason,
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
return { candidates, invalidRows, totalRows: values.length };
|
|
308
|
+
}
|
|
309
|
+
function dedupeCandidates(candidates) {
|
|
310
|
+
const seen = new Set();
|
|
311
|
+
const deduped = [];
|
|
312
|
+
let duplicateCount = 0;
|
|
313
|
+
for (const candidate of candidates) {
|
|
314
|
+
const key = `${candidate.type}:${candidate.value.toLowerCase()}`;
|
|
315
|
+
if (seen.has(key)) {
|
|
316
|
+
duplicateCount += 1;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
seen.add(key);
|
|
320
|
+
deduped.push(candidate);
|
|
321
|
+
}
|
|
322
|
+
return { deduped, duplicateCount };
|
|
323
|
+
}
|
|
324
|
+
async function getVerifiedActiveWorkspace() {
|
|
325
|
+
const config = getConfig();
|
|
326
|
+
const activeWorkspaceId = config.activeWorkspaceId || config.workspaceId;
|
|
327
|
+
if (!activeWorkspaceId) {
|
|
328
|
+
throw new Error("No active workspace selected. Run list_workspaces then set_active_workspace before importing DNC entries.");
|
|
329
|
+
}
|
|
330
|
+
const api = getApi();
|
|
331
|
+
const { workspaces } = await api.get("/api/v3/workspaces");
|
|
332
|
+
const match = workspaces.find((workspace) => workspace.id === activeWorkspaceId);
|
|
333
|
+
if (!match) {
|
|
334
|
+
throw new Error(`Active workspace ${activeWorkspaceId} is not in the server access list. Run list_workspaces then set_active_workspace for the workspace that should receive the DNC import.`);
|
|
335
|
+
}
|
|
336
|
+
return match;
|
|
337
|
+
}
|
|
338
|
+
function makeSignature(payload) {
|
|
339
|
+
return createHmac("sha256", confirmationSecret)
|
|
340
|
+
.update(JSON.stringify(payload))
|
|
341
|
+
.digest("base64url");
|
|
342
|
+
}
|
|
343
|
+
function makeConfirmationToken(payload) {
|
|
344
|
+
return Buffer.from(JSON.stringify({ payload, signature: makeSignature(payload) }), "utf8").toString("base64url");
|
|
345
|
+
}
|
|
346
|
+
function parseConfirmationToken(token) {
|
|
347
|
+
let decoded;
|
|
348
|
+
try {
|
|
349
|
+
decoded = JSON.parse(Buffer.from(token, "base64url").toString("utf8"));
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
throw new Error("Invalid confirmationToken. Run load_csv_dnc_entries preview again.");
|
|
353
|
+
}
|
|
354
|
+
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
|
|
355
|
+
throw new Error("Invalid confirmationToken. Run load_csv_dnc_entries preview again.");
|
|
356
|
+
}
|
|
357
|
+
const envelope = decoded;
|
|
358
|
+
if (!envelope.payload ||
|
|
359
|
+
envelope.payload.version !== CONFIRMATION_TOKEN_VERSION ||
|
|
360
|
+
typeof envelope.signature !== "string") {
|
|
361
|
+
throw new Error("Invalid confirmationToken. Run load_csv_dnc_entries preview again.");
|
|
362
|
+
}
|
|
363
|
+
if (makeSignature(envelope.payload) !== envelope.signature) {
|
|
364
|
+
throw new Error("Preview token is no longer valid. Re-run load_csv_dnc_entries preview before confirming.");
|
|
365
|
+
}
|
|
366
|
+
return envelope.payload;
|
|
367
|
+
}
|
|
368
|
+
function buildDncPreview(params) {
|
|
369
|
+
const source = resolveSource(params.input);
|
|
370
|
+
const name = params.tokenPayload?.name ?? normalizeListName(params.input.name);
|
|
371
|
+
const warnings = [];
|
|
372
|
+
const blockingErrors = [];
|
|
373
|
+
let headers = [];
|
|
374
|
+
let domainColumn = params.tokenPayload?.domainColumn ?? null;
|
|
375
|
+
let linkedInColumn = params.tokenPayload?.linkedInColumn ?? null;
|
|
376
|
+
let candidateDomainColumns = [];
|
|
377
|
+
let candidateLinkedInColumns = [];
|
|
378
|
+
let candidates = [];
|
|
379
|
+
let invalidRows = [];
|
|
380
|
+
let totalRows = 0;
|
|
381
|
+
if (source.kind === "rawText") {
|
|
382
|
+
const parsed = parseRawDncValues(source.content);
|
|
383
|
+
candidates = parsed.candidates;
|
|
384
|
+
invalidRows = parsed.invalidRows;
|
|
385
|
+
totalRows = parsed.totalRows;
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
const parsed = parseCsvRows(source.content);
|
|
389
|
+
headers = parsed.headers;
|
|
390
|
+
blockingErrors.push(...parsed.blockingErrors);
|
|
391
|
+
totalRows = parsed.rows.length;
|
|
392
|
+
if (blockingErrors.length === 0) {
|
|
393
|
+
if (!params.tokenPayload) {
|
|
394
|
+
const detected = detectColumns({
|
|
395
|
+
headers,
|
|
396
|
+
domainColumn: params.input.domainColumn,
|
|
397
|
+
linkedInColumn: params.input.linkedInColumn,
|
|
398
|
+
});
|
|
399
|
+
domainColumn = detected.domainColumn;
|
|
400
|
+
linkedInColumn = detected.linkedInColumn;
|
|
401
|
+
candidateDomainColumns = detected.candidateDomainColumns;
|
|
402
|
+
candidateLinkedInColumns = detected.candidateLinkedInColumns;
|
|
403
|
+
warnings.push(...detected.warnings);
|
|
404
|
+
blockingErrors.push(...detected.blockingErrors);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
candidateDomainColumns = matchingColumns(headers, "domain");
|
|
408
|
+
candidateLinkedInColumns = matchingColumns(headers, "linkedin");
|
|
409
|
+
if (domainColumn &&
|
|
410
|
+
resolveHeaderSelection(headers, domainColumn) !== domainColumn) {
|
|
411
|
+
blockingErrors.push("domainColumn changed after preview. Re-run load_csv_dnc_entries preview.");
|
|
412
|
+
}
|
|
413
|
+
if (linkedInColumn &&
|
|
414
|
+
resolveHeaderSelection(headers, linkedInColumn) !== linkedInColumn) {
|
|
415
|
+
blockingErrors.push("linkedInColumn changed after preview. Re-run load_csv_dnc_entries preview.");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (blockingErrors.length === 0) {
|
|
420
|
+
parsed.rows.forEach((row, index) => {
|
|
421
|
+
if (domainColumn) {
|
|
422
|
+
addDomainCandidate({
|
|
423
|
+
candidates,
|
|
424
|
+
invalidRows,
|
|
425
|
+
value: row[domainColumn] ?? "",
|
|
426
|
+
row: index + 2,
|
|
427
|
+
column: domainColumn,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
if (linkedInColumn) {
|
|
431
|
+
addLinkedInCandidate({
|
|
432
|
+
candidates,
|
|
433
|
+
invalidRows,
|
|
434
|
+
value: row[linkedInColumn] ?? "",
|
|
435
|
+
row: index + 2,
|
|
436
|
+
column: linkedInColumn,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (candidates.length > MAX_DNC_CANDIDATES) {
|
|
443
|
+
warnings.push(`DNC import contained more than ${MAX_DNC_CANDIDATES} valid candidates; only the first ${MAX_DNC_CANDIDATES} will be confirmed.`);
|
|
444
|
+
candidates = candidates.slice(0, MAX_DNC_CANDIDATES);
|
|
445
|
+
}
|
|
446
|
+
const { deduped, duplicateCount } = dedupeCandidates(candidates);
|
|
447
|
+
const domainCount = deduped.filter((entry) => entry.type === "domain").length;
|
|
448
|
+
const linkedInCount = deduped.filter((entry) => entry.type === "linkedin").length;
|
|
449
|
+
if (deduped.length === 0 && blockingErrors.length === 0) {
|
|
450
|
+
blockingErrors.push("No valid DNC entries were found. Provide domains or LinkedIn profile URLs before confirming.");
|
|
451
|
+
}
|
|
452
|
+
const entryHashes = deduped.map(hashEntry);
|
|
453
|
+
const tokenPayload = blockingErrors.length === 0
|
|
454
|
+
? {
|
|
455
|
+
version: CONFIRMATION_TOKEN_VERSION,
|
|
456
|
+
sourceKind: source.kind,
|
|
457
|
+
...(source.kind === "file"
|
|
458
|
+
? {
|
|
459
|
+
resolvedFilePath: source.resolvedFilePath,
|
|
460
|
+
fileSizeBytes: source.fileSizeBytes,
|
|
461
|
+
fileMtimeMs: source.fileMtimeMs,
|
|
462
|
+
}
|
|
463
|
+
: {}),
|
|
464
|
+
contentHash: source.contentHash,
|
|
465
|
+
workspaceId: params.workspace.id,
|
|
466
|
+
workspaceName: params.workspace.name,
|
|
467
|
+
name,
|
|
468
|
+
domainColumn,
|
|
469
|
+
linkedInColumn,
|
|
470
|
+
entryHashes,
|
|
471
|
+
}
|
|
472
|
+
: null;
|
|
473
|
+
return {
|
|
474
|
+
preview: {
|
|
475
|
+
workspaceId: params.workspace.id,
|
|
476
|
+
workspaceName: params.workspace.name,
|
|
477
|
+
name,
|
|
478
|
+
sourceKind: source.kind,
|
|
479
|
+
resolvedFilePath: source.kind === "file" ? source.resolvedFilePath : null,
|
|
480
|
+
headers,
|
|
481
|
+
detectedDomainColumn: domainColumn,
|
|
482
|
+
detectedLinkedInColumn: linkedInColumn,
|
|
483
|
+
candidateDomainColumns,
|
|
484
|
+
candidateLinkedInColumns,
|
|
485
|
+
totalRows,
|
|
486
|
+
validCount: deduped.length,
|
|
487
|
+
domainCount,
|
|
488
|
+
linkedInCount,
|
|
489
|
+
invalidRowCount: invalidRows.length,
|
|
490
|
+
duplicateInputCount: duplicateCount,
|
|
491
|
+
duplicatePolicy: "first_wins",
|
|
492
|
+
invalidRows: invalidRows.slice(0, 50),
|
|
493
|
+
warnings,
|
|
494
|
+
blockingErrors,
|
|
495
|
+
confirmationToken: tokenPayload ? makeConfirmationToken(tokenPayload) : null,
|
|
496
|
+
},
|
|
497
|
+
entries: deduped.map((entry) => ({
|
|
498
|
+
type: entry.type,
|
|
499
|
+
value: entry.value,
|
|
500
|
+
})),
|
|
501
|
+
source,
|
|
502
|
+
tokenPayload,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
function assertTokenMatchesCurrentPreview(tokenPayload, currentPayload) {
|
|
506
|
+
if (!currentPayload) {
|
|
507
|
+
throw new Error("DNC preview is no longer actionable. Re-run load_csv_dnc_entries preview before confirming.");
|
|
508
|
+
}
|
|
509
|
+
if (tokenPayload.sourceKind !== currentPayload.sourceKind ||
|
|
510
|
+
tokenPayload.resolvedFilePath !== currentPayload.resolvedFilePath ||
|
|
511
|
+
tokenPayload.fileSizeBytes !== currentPayload.fileSizeBytes ||
|
|
512
|
+
tokenPayload.fileMtimeMs !== currentPayload.fileMtimeMs ||
|
|
513
|
+
tokenPayload.contentHash !== currentPayload.contentHash ||
|
|
514
|
+
tokenPayload.domainColumn !== currentPayload.domainColumn ||
|
|
515
|
+
tokenPayload.linkedInColumn !== currentPayload.linkedInColumn ||
|
|
516
|
+
tokenPayload.name !== currentPayload.name ||
|
|
517
|
+
!compareStringArrays(tokenPayload.entryHashes, currentPayload.entryHashes)) {
|
|
518
|
+
throw new Error("DNC import source changed after preview. Re-run load_csv_dnc_entries preview before confirming.");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function summarizeDncListResponse(response) {
|
|
522
|
+
const errors = Array.isArray(response.errors) ? response.errors : [];
|
|
523
|
+
const duplicateErrors = errors.filter((error) => String(error.error ?? "").toLowerCase().includes("already exists"));
|
|
524
|
+
const nonDuplicateErrors = errors.filter((error) => !duplicateErrors.includes(error));
|
|
525
|
+
return {
|
|
526
|
+
createdCount: typeof response.created === "number" ? response.created : 0,
|
|
527
|
+
duplicateExistingCount: duplicateErrors.length,
|
|
528
|
+
backendErrorCount: nonDuplicateErrors.length,
|
|
529
|
+
backendErrors: nonDuplicateErrors,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
async function runDncSpotChecks(entries) {
|
|
533
|
+
const api = getApi();
|
|
534
|
+
const checks = [];
|
|
535
|
+
const domain = entries.find((entry) => entry.type === "domain");
|
|
536
|
+
const linkedin = entries.find((entry) => entry.type === "linkedin");
|
|
537
|
+
const targets = [domain, linkedin].filter(Boolean);
|
|
538
|
+
for (const target of targets) {
|
|
539
|
+
const response = await api.post("/api/v3/dnc-check", {
|
|
540
|
+
...(target.type === "domain" ? { domain: target.value } : {}),
|
|
541
|
+
...(target.type === "linkedin" ? { linkedinUrl: target.value } : {}),
|
|
542
|
+
});
|
|
543
|
+
checks.push({
|
|
544
|
+
type: target.type,
|
|
545
|
+
value: target.value,
|
|
546
|
+
ok: response.isDNC === true,
|
|
547
|
+
reason: response.reason,
|
|
548
|
+
matchedValue: response.matchedValue,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
const failed = checks.filter((check) => !check.ok);
|
|
552
|
+
if (failed.length > 0) {
|
|
553
|
+
throw new Error(`DNC import wrote entries but dnc-check did not confirm ${failed
|
|
554
|
+
.map((check) => `${check.type}:${check.value}`)
|
|
555
|
+
.join(", ")}.`);
|
|
556
|
+
}
|
|
557
|
+
return checks;
|
|
558
|
+
}
|
|
559
|
+
export async function loadCsvDncEntries(input) {
|
|
560
|
+
const workspace = await getVerifiedActiveWorkspace();
|
|
561
|
+
const api = getApi();
|
|
562
|
+
if (input.confirmed) {
|
|
563
|
+
if (!input.confirmationToken) {
|
|
564
|
+
throw new Error("load_csv_dnc_entries confirmed execution requires confirmationToken from a preview response.");
|
|
565
|
+
}
|
|
566
|
+
const tokenPayload = parseConfirmationToken(input.confirmationToken);
|
|
567
|
+
if (workspace.id !== tokenPayload.workspaceId) {
|
|
568
|
+
throw new Error(`Active workspace changed after preview. Preview targeted ${tokenPayload.workspaceName} (${tokenPayload.workspaceId}); current active workspace is ${workspace.name} (${workspace.id}). Re-run load_csv_dnc_entries preview for the intended workspace before confirming.`);
|
|
569
|
+
}
|
|
570
|
+
const current = buildDncPreview({
|
|
571
|
+
input: {
|
|
572
|
+
...input,
|
|
573
|
+
name: tokenPayload.name,
|
|
574
|
+
domainColumn: tokenPayload.domainColumn ?? undefined,
|
|
575
|
+
linkedInColumn: tokenPayload.linkedInColumn ?? undefined,
|
|
576
|
+
confirmed: false,
|
|
577
|
+
confirmationToken: undefined,
|
|
578
|
+
},
|
|
579
|
+
workspace,
|
|
580
|
+
tokenPayload,
|
|
581
|
+
});
|
|
582
|
+
assertTokenMatchesCurrentPreview(tokenPayload, current.tokenPayload);
|
|
583
|
+
const response = await api.post("/api/v3/dnc-list", {
|
|
584
|
+
name: tokenPayload.name,
|
|
585
|
+
entries: current.entries,
|
|
586
|
+
});
|
|
587
|
+
const summary = summarizeDncListResponse(response);
|
|
588
|
+
if (summary.backendErrorCount > 0) {
|
|
589
|
+
return {
|
|
590
|
+
ok: false,
|
|
591
|
+
requiresConfirmation: false,
|
|
592
|
+
workspaceId: workspace.id,
|
|
593
|
+
workspaceName: workspace.name,
|
|
594
|
+
name: tokenPayload.name,
|
|
595
|
+
attemptedCount: current.entries.length,
|
|
596
|
+
...summary,
|
|
597
|
+
guidance: "Sellable DNC import partially failed in the existing DNC endpoint. Review backendErrors and re-run preview after fixing the input.",
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
const spotChecks = await runDncSpotChecks(current.entries);
|
|
601
|
+
const ok = summary.createdCount > 0 || summary.duplicateExistingCount > 0;
|
|
602
|
+
return {
|
|
603
|
+
ok,
|
|
604
|
+
requiresConfirmation: false,
|
|
605
|
+
workspaceId: workspace.id,
|
|
606
|
+
workspaceName: workspace.name,
|
|
607
|
+
name: tokenPayload.name,
|
|
608
|
+
attemptedCount: current.entries.length,
|
|
609
|
+
...summary,
|
|
610
|
+
spotChecks,
|
|
611
|
+
guidance: summary.createdCount > 0
|
|
612
|
+
? `Added entries to Sellable's DNC list for workspace ${workspace.name} (${workspace.id}). Campaign creation already includes a DNC Check column that checks domain/profile before message generation.`
|
|
613
|
+
: `No new entries were created because the valid entries are already in Sellable's DNC list for workspace ${workspace.name} (${workspace.id}). Campaign creation already includes a DNC Check column that checks domain/profile before message generation.`,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
const built = buildDncPreview({ input, workspace });
|
|
617
|
+
if (!built.preview.confirmationToken) {
|
|
618
|
+
return {
|
|
619
|
+
ok: false,
|
|
620
|
+
requiresConfirmation: false,
|
|
621
|
+
preview: built.preview,
|
|
622
|
+
suggestedToolCalls: [],
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
ok: true,
|
|
627
|
+
requiresConfirmation: true,
|
|
628
|
+
preview: built.preview,
|
|
629
|
+
confirmationToken: built.preview.confirmationToken,
|
|
630
|
+
suggestedToolCalls: [
|
|
631
|
+
{
|
|
632
|
+
tool: "load_csv_dnc_entries",
|
|
633
|
+
args: {
|
|
634
|
+
...(input.filePath ? { filePath: input.filePath } : {}),
|
|
635
|
+
...(input.csvText ? { csvText: input.csvText } : {}),
|
|
636
|
+
...(input.rawText ? { rawText: input.rawText } : {}),
|
|
637
|
+
...(input.name ? { name: input.name } : {}),
|
|
638
|
+
...(built.preview.detectedDomainColumn
|
|
639
|
+
? { domainColumn: built.preview.detectedDomainColumn }
|
|
640
|
+
: {}),
|
|
641
|
+
...(built.preview.detectedLinkedInColumn
|
|
642
|
+
? { linkedInColumn: built.preview.detectedLinkedInColumn }
|
|
643
|
+
: {}),
|
|
644
|
+
confirmed: true,
|
|
645
|
+
confirmationToken: built.preview.confirmationToken,
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
],
|
|
649
|
+
guidance: `I will add this to Sellable's DNC list for workspace ${workspace.name} (${workspace.id}). Campaign creation already includes a DNC Check column that checks domain/profile before message generation. Confirm before writing.`,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
@@ -64,6 +64,13 @@ type ParsedCsvDomainFile = {
|
|
|
64
64
|
blockingErrors: string[];
|
|
65
65
|
warnings: string[];
|
|
66
66
|
};
|
|
67
|
+
export declare function sanitizeCsvDomainCandidate(value: unknown): {
|
|
68
|
+
valid: true;
|
|
69
|
+
cleaned: string;
|
|
70
|
+
} | {
|
|
71
|
+
valid: false;
|
|
72
|
+
reason: string;
|
|
73
|
+
};
|
|
67
74
|
export declare function makeConfirmationToken(payload: CsvDomainConfirmationPayload): string;
|
|
68
75
|
export declare function parseConfirmationToken(token: string): CsvDomainConfirmationPayload;
|
|
69
76
|
export declare function matchesConfirmationToken(token: string, payload: CsvDomainConfirmationPayload): boolean;
|
|
@@ -70,7 +70,7 @@ function resolveCsvDomainPath(filePath, workspaceRoot = defaultWorkspaceRoot) {
|
|
|
70
70
|
? resolve(filePath)
|
|
71
71
|
: resolve(workspaceRoot, filePath);
|
|
72
72
|
}
|
|
73
|
-
function sanitizeCsvDomainCandidate(value) {
|
|
73
|
+
export function sanitizeCsvDomainCandidate(value) {
|
|
74
74
|
let cleaned = String(value ?? "")
|
|
75
75
|
.trim()
|
|
76
76
|
.toLowerCase();
|
|
@@ -79,6 +79,14 @@ type ParsedCsvLinkedinFile = {
|
|
|
79
79
|
blockingErrors: string[];
|
|
80
80
|
warnings: string[];
|
|
81
81
|
};
|
|
82
|
+
export type LinkedInValidationResult = {
|
|
83
|
+
valid: true;
|
|
84
|
+
normalizedUrl: string;
|
|
85
|
+
} | {
|
|
86
|
+
valid: false;
|
|
87
|
+
reason: string;
|
|
88
|
+
};
|
|
89
|
+
export declare function validateAndNormalizeLinkedInUrl(value: string): LinkedInValidationResult;
|
|
82
90
|
export declare function makeLinkedinConfirmationToken(payload: CsvLinkedinConfirmationPayload): string;
|
|
83
91
|
export declare function parseLinkedinConfirmationToken(token: string): CsvLinkedinConfirmationPayload;
|
|
84
92
|
export declare function matchesLinkedinConfirmationToken(token: string, payload: CsvLinkedinConfirmationPayload): boolean;
|