@sellable/mcp 0.1.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/.claude-plugin/plugin.json +12 -0
- package/.mcp.json +9 -0
- package/README.md +355 -0
- package/dist/api.d.ts +21 -0
- package/dist/api.js +73 -0
- package/dist/auth.d.ts +60 -0
- package/dist/auth.js +246 -0
- package/dist/engage-memory.d.ts +63 -0
- package/dist/engage-memory.js +354 -0
- package/dist/index-dev.d.ts +2 -0
- package/dist/index-dev.js +17 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +499 -0
- package/dist/skills.d.ts +11 -0
- package/dist/skills.js +97 -0
- package/dist/tools/auth.d.ts +30 -0
- package/dist/tools/auth.js +124 -0
- package/dist/tools/blueprint-commit.d.ts +174 -0
- package/dist/tools/blueprint-commit.js +286 -0
- package/dist/tools/bootstrap.d.ts +64 -0
- package/dist/tools/bootstrap.js +246 -0
- package/dist/tools/campaigns.d.ts +589 -0
- package/dist/tools/campaigns.js +892 -0
- package/dist/tools/cells.d.ts +58 -0
- package/dist/tools/cells.js +48 -0
- package/dist/tools/context.d.ts +88 -0
- package/dist/tools/context.js +271 -0
- package/dist/tools/csv-domains.d.ts +73 -0
- package/dist/tools/csv-domains.js +464 -0
- package/dist/tools/csv-linkedin.d.ts +102 -0
- package/dist/tools/csv-linkedin.js +712 -0
- package/dist/tools/direct-campaigns.d.ts +240 -0
- package/dist/tools/direct-campaigns.js +250 -0
- package/dist/tools/engage-bootstrap.d.ts +94 -0
- package/dist/tools/engage-bootstrap.js +205 -0
- package/dist/tools/engage-discovery.d.ts +78 -0
- package/dist/tools/engage-discovery.js +150 -0
- package/dist/tools/engage-memory.d.ts +181 -0
- package/dist/tools/engage-memory.js +143 -0
- package/dist/tools/engage-state.d.ts +72 -0
- package/dist/tools/engage-state.js +62 -0
- package/dist/tools/enrichment.d.ts +167 -0
- package/dist/tools/enrichment.js +174 -0
- package/dist/tools/flow-preflight.d.ts +68 -0
- package/dist/tools/flow-preflight.js +138 -0
- package/dist/tools/framework.d.ts +44 -0
- package/dist/tools/framework.js +153 -0
- package/dist/tools/interaction-mode.d.ts +27 -0
- package/dist/tools/interaction-mode.js +102 -0
- package/dist/tools/leads.d.ts +2417 -0
- package/dist/tools/leads.js +2307 -0
- package/dist/tools/linkedin.d.ts +210 -0
- package/dist/tools/linkedin.js +229 -0
- package/dist/tools/navigation.d.ts +91 -0
- package/dist/tools/navigation.js +381 -0
- package/dist/tools/one-off.d.ts +229 -0
- package/dist/tools/one-off.js +273 -0
- package/dist/tools/processing.d.ts +70 -0
- package/dist/tools/processing.js +56 -0
- package/dist/tools/prompts.d.ts +211 -0
- package/dist/tools/prompts.js +210 -0
- package/dist/tools/provider-preflight.d.ts +21 -0
- package/dist/tools/provider-preflight.js +59 -0
- package/dist/tools/readiness.d.ts +261 -0
- package/dist/tools/readiness.js +510 -0
- package/dist/tools/rows.d.ts +126 -0
- package/dist/tools/rows.js +105 -0
- package/dist/tools/rubrics.d.ts +497 -0
- package/dist/tools/rubrics.js +681 -0
- package/dist/tools/senders.d.ts +44 -0
- package/dist/tools/senders.js +69 -0
- package/dist/tools/sequencer.d.ts +127 -0
- package/dist/tools/sequencer.js +194 -0
- package/dist/tools/tables.d.ts +35 -0
- package/dist/tools/tables.js +36 -0
- package/dist/tools/verify-row.d.ts +36 -0
- package/dist/tools/verify-row.js +38 -0
- package/dist/tools/workspaces.d.ts +140 -0
- package/dist/tools/workspaces.js +139 -0
- package/dist/utils/workspace-root.d.ts +1 -0
- package/dist/utils/workspace-root.js +39 -0
- package/package.json +46 -0
- package/skills/building-gtm-tables/SKILL.md +216 -0
- package/skills/building-gtm-tables/core/auto-execute.yaml +19 -0
- package/skills/building-gtm-tables/core/blueprint-schema.json +72 -0
- package/skills/building-gtm-tables/references/brief-to-blueprint.md +334 -0
- package/skills/building-gtm-tables/references/column-type-catalog.md +318 -0
- package/skills/building-gtm-tables/references/common-blueprints.fixtures.ts +199 -0
- package/skills/building-gtm-tables/references/common-blueprints.md +44 -0
- package/skills/building-gtm-tables/references/failure-taxonomy.md +197 -0
- package/skills/building-gtm-tables/references/uat-seed-prompts.md +37 -0
- package/skills/building-gtm-tables/references/verify-loop.md +74 -0
- package/skills/campaign-messages/SKILL.md +173 -0
- package/skills/campaign-messages/flow.v1.json +75 -0
- package/skills/craft-message/SKILL.md +401 -0
- package/skills/create-campaign/ARCHITECTURE.md +232 -0
- package/skills/create-campaign/DISCUSS.md +296 -0
- package/skills/create-campaign/FLOW_ASCII.md +240 -0
- package/skills/create-campaign/HOST-PARITY-CHECKLIST.md +49 -0
- package/skills/create-campaign/README.md +142 -0
- package/skills/create-campaign/SKILL.md +286 -0
- package/skills/create-campaign/context/README.md +67 -0
- package/skills/create-campaign/context/_TEMPLATE.md +12 -0
- package/skills/create-campaign/context/context.md +35 -0
- package/skills/create-campaign/context/learnings.md +16 -0
- package/skills/create-campaign/context/registry.json +19 -0
- package/skills/create-campaign/core/flow.v1.json +217 -0
- package/skills/create-campaign/core/policy.md +191 -0
- package/skills/create-campaign/core/providers/apollo.json +35 -0
- package/skills/create-campaign/core/providers/prospeo.json +34 -0
- package/skills/create-campaign/core/providers/registry.json +31 -0
- package/skills/create-campaign/core/providers/sales-nav.json +37 -0
- package/skills/create-campaign/core/providers/signal-discovery.json +42 -0
- package/skills/create-campaign/references/brief-template.md +64 -0
- package/skills/create-campaign/references/campaign-quality.md +84 -0
- package/skills/create-campaign/references/copy-calibration-examples.md +120 -0
- package/skills/create-campaign/references/offer-patterns.md +108 -0
- package/skills/create-campaign/references/provider-selection-strategy.md +212 -0
- package/skills/create-campaign/references/question-examples.md +167 -0
- package/skills/create-campaign/references/token-fill-examples.md +81 -0
- package/skills/create-campaign-brief/ARCHITECTURE.md +72 -0
- package/skills/create-campaign-brief/DISCUSS.md +64 -0
- package/skills/create-campaign-brief/README.md +176 -0
- package/skills/create-campaign-brief/SKILL.md +537 -0
- package/skills/create-campaign-brief/references/brief-synthesis-rules.md +100 -0
- package/skills/create-campaign-brief/references/brief-template.md +220 -0
- package/skills/create-campaign-brief/references/campaign-idea-options.md +30 -0
- package/skills/create-campaign-brief/references/copy-appendix-template.md +62 -0
- package/skills/create-campaign-brief/references/draft-lifecycle.md +23 -0
- package/skills/create-campaign-brief/references/examples/MANIFEST.json +89 -0
- package/skills/create-campaign-brief/references/examples/briefs/clover.md +223 -0
- package/skills/create-campaign-brief/references/examples/briefs/galley.md +222 -0
- package/skills/create-campaign-brief/references/examples/briefs/gelee.md +220 -0
- package/skills/create-campaign-brief/references/examples/briefs/hey-digital.md +234 -0
- package/skills/create-campaign-brief/references/examples/briefs/persona.md +231 -0
- package/skills/create-campaign-brief/references/examples/briefs/revvix.md +220 -0
- package/skills/create-campaign-brief/references/examples/briefs/sellable-dev.md +220 -0
- package/skills/create-campaign-brief/references/examples/briefs/superposition.md +233 -0
- package/skills/create-campaign-brief/references/examples/briefs/superpower.md +219 -0
- package/skills/create-campaign-brief/references/examples/briefs/westpark-villas.md +220 -0
- package/skills/create-campaign-brief/references/icp-lock-question-bank.md +43 -0
- package/skills/create-campaign-brief/references/messaging-inputs.md +58 -0
- package/skills/create-campaign-brief/references/output-acceptance-rubric.md +62 -0
- package/skills/create-campaign-brief/references/phase75-active-runtime-message-pack.md +248 -0
- package/skills/create-campaign-brief/references/phase75-canonical-brief-template.md +319 -0
- package/skills/create-campaign-brief/references/phase75-good-brief-and-messaging-examples.md +445 -0
- package/skills/create-campaign-brief/references/quick-research-protocol.md +39 -0
- package/skills/create-campaign-brief/references/reference-sheet-protocol.md +60 -0
- package/skills/create-campaign-brief/references/zero-shot-iteration-rules.md +66 -0
- package/skills/create-campaign-v2/SKILL.md +1619 -0
- package/skills/create-campaign-v2/core/auto-execute.README.md +219 -0
- package/skills/create-campaign-v2/core/auto-execute.yaml +121 -0
- package/skills/create-campaign-v2/core/flow.v2.json +1643 -0
- package/skills/create-campaign-v2/core/policy.md +82 -0
- package/skills/create-campaign-v2/references/ai-tells.md +253 -0
- package/skills/create-campaign-v2/references/approval-gate-framing.md +346 -0
- package/skills/create-campaign-v2/references/draft-lifecycle.md +110 -0
- package/skills/create-campaign-v2/references/escalation-ladder.md +119 -0
- package/skills/create-campaign-v2/references/filter-leads.md +495 -0
- package/skills/create-campaign-v2/references/final-handoff-contract.md +176 -0
- package/skills/create-campaign-v2/references/gold-standard-message-examples.md +394 -0
- package/skills/create-campaign-v2/references/gold-standard-message-patterns.md +314 -0
- package/skills/create-campaign-v2/references/gold-standard-message-validation-example.md +212 -0
- package/skills/create-campaign-v2/references/lead-validation-preview.md +172 -0
- package/skills/create-campaign-v2/references/parallel-critique-protocol.md +368 -0
- package/skills/create-campaign-v2/references/sample-validation-loop.md +289 -0
- package/skills/create-campaign-v2/references/step-13-import-leads.md +151 -0
- package/skills/create-campaign-v2/references/step-15-re-cascade.md +90 -0
- package/skills/create-campaign-v2/references/thomas-revision-filters.md +521 -0
- package/skills/create-campaign-v2/references/thomas-variant-selection.md +202 -0
- package/skills/create-campaign-v2/references/tier-routing-matrix.md +66 -0
- package/skills/create-campaign-v2/references/validation-criteria.md +367 -0
- package/skills/create-campaign-v2/references/watch-link-handoff.md +106 -0
- package/skills/create-campaign-v2-validation/SKILL.md +296 -0
- package/skills/create-post/SKILL.md +1308 -0
- package/skills/create-rubric/SKILL.md +251 -0
- package/skills/engage/SKILL.md +549 -0
- package/skills/engage/core/README.md +23 -0
- package/skills/engage/core/proven-searches.json +11 -0
- package/skills/engage/core/style-guide.template.md +47 -0
- package/skills/engage/core/tracked-people.json +10 -0
- package/skills/enrich-prospects/SKILL.md +97 -0
- package/skills/find-leads/SKILL.md +467 -0
- package/skills/generate-messages/SKILL.md +2361 -0
- package/skills/interview/SKILL.md +132 -0
- package/skills/interview/core/ENGAGE_STYLE_GUIDE.template.md +54 -0
- package/skills/interview/core/ICP.template.md +54 -0
- package/skills/interview/core/VOICE_PROFILE.template.md +101 -0
- package/skills/providers/apollo.md +520 -0
- package/skills/providers/prospeo.md +398 -0
- package/skills/providers/sales-nav.md +372 -0
- package/skills/providers/signal-discovery.md +495 -0
- package/skills/research/SKILL.md +258 -0
- package/skills/research/config.json +9 -0
- package/skills/research/override.md +13 -0
- package/skills/research-prospect/SKILL.md +99 -0
- package/skills/research-sender/SKILL.md +158 -0
- package/skills/workflow-sequences/SKILL.md +85 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import { parse } from "csv-parse/sync";
|
|
2
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
3
|
+
import { readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
|
|
5
|
+
import { resolveWorkspaceRoot } from "../utils/workspace-root.js";
|
|
6
|
+
const entryPath = process.argv[1] ? resolve(process.argv[1]) : process.cwd();
|
|
7
|
+
const entryDir = dirname(entryPath);
|
|
8
|
+
const defaultWorkspaceRoot = resolveWorkspaceRoot(entryDir);
|
|
9
|
+
export const MAX_CSV_LINKEDIN_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
10
|
+
export const MAX_CSV_LINKEDIN_UPLOAD_ROWS = 7500;
|
|
11
|
+
export const MAX_CSV_LINKEDIN_UPLOAD_CARRY_COLUMNS = 20;
|
|
12
|
+
const CONFIRMATION_TOKEN_VERSION = "csv-linkedin-preview-v1";
|
|
13
|
+
const confirmationSecret = randomBytes(32).toString("hex");
|
|
14
|
+
const DOMAIN_REGEX = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i;
|
|
15
|
+
const STRONG_LINKEDIN_HEADER_KEYS = new Set([
|
|
16
|
+
"linkedin",
|
|
17
|
+
"linkedinurl",
|
|
18
|
+
"linkedinprofile",
|
|
19
|
+
"linkedinprofileurl",
|
|
20
|
+
"profileurl",
|
|
21
|
+
"personlinkedin",
|
|
22
|
+
]);
|
|
23
|
+
const CANDIDATE_LINKEDIN_HEADER_TOKENS = ["linkedin", "profile", "public"];
|
|
24
|
+
const CARRYOVER_JUNK_KEYS = new Set([
|
|
25
|
+
"id",
|
|
26
|
+
"_id",
|
|
27
|
+
"rowid",
|
|
28
|
+
"row_id",
|
|
29
|
+
"createdat",
|
|
30
|
+
"updatedat",
|
|
31
|
+
"timestamp",
|
|
32
|
+
"created",
|
|
33
|
+
"updated",
|
|
34
|
+
]);
|
|
35
|
+
const RESERVED_TARGET_COLUMNS = new Set([
|
|
36
|
+
"Name",
|
|
37
|
+
"Title",
|
|
38
|
+
"Company Website",
|
|
39
|
+
"Location",
|
|
40
|
+
"LinkedIn URL",
|
|
41
|
+
"Open Link",
|
|
42
|
+
"Enrich Prospect",
|
|
43
|
+
]);
|
|
44
|
+
const CSV_LINKEDIN_LIMITS = {
|
|
45
|
+
maxBytes: MAX_CSV_LINKEDIN_UPLOAD_BYTES,
|
|
46
|
+
maxRows: MAX_CSV_LINKEDIN_UPLOAD_ROWS,
|
|
47
|
+
maxCarryColumns: MAX_CSV_LINKEDIN_UPLOAD_CARRY_COLUMNS,
|
|
48
|
+
};
|
|
49
|
+
function normalizeHeaderKey(value) {
|
|
50
|
+
return value
|
|
51
|
+
.trim()
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.replace(/[^a-z0-9]+/g, "");
|
|
54
|
+
}
|
|
55
|
+
function compareStringArrays(left, right) {
|
|
56
|
+
if (left.length !== right.length) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return left.every((value, index) => value === right[index]);
|
|
60
|
+
}
|
|
61
|
+
function getLimits() {
|
|
62
|
+
return { ...CSV_LINKEDIN_LIMITS };
|
|
63
|
+
}
|
|
64
|
+
function resolveCsvLinkedinPath(filePath, workspaceRoot = defaultWorkspaceRoot) {
|
|
65
|
+
if (!filePath || typeof filePath !== "string") {
|
|
66
|
+
throw new Error("load_csv_linkedin_leads requires a non-empty filePath.");
|
|
67
|
+
}
|
|
68
|
+
return isAbsolute(filePath)
|
|
69
|
+
? resolve(filePath)
|
|
70
|
+
: resolve(workspaceRoot, filePath);
|
|
71
|
+
}
|
|
72
|
+
function getCsvLinkedinContentType(filePath) {
|
|
73
|
+
return extname(filePath).toLowerCase() === ".txt" ? "text/plain" : "text/csv";
|
|
74
|
+
}
|
|
75
|
+
function findDuplicateHeaders(headers) {
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
const duplicates = new Set();
|
|
78
|
+
for (const header of headers) {
|
|
79
|
+
const normalized = header.trim().toLowerCase();
|
|
80
|
+
if (!normalized) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (seen.has(normalized)) {
|
|
84
|
+
duplicates.add(header);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
seen.add(normalized);
|
|
88
|
+
}
|
|
89
|
+
return Array.from(duplicates);
|
|
90
|
+
}
|
|
91
|
+
function buildRowObjects(headers, dataRows) {
|
|
92
|
+
return dataRows.map((row) => {
|
|
93
|
+
const out = {};
|
|
94
|
+
headers.forEach((header, index) => {
|
|
95
|
+
out[header] = String(row[index] ?? "");
|
|
96
|
+
});
|
|
97
|
+
return out;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function resolveHeaderSelection(headers, requested, kind) {
|
|
101
|
+
const trimmed = requested.trim();
|
|
102
|
+
if (!trimmed) {
|
|
103
|
+
throw new Error(`${kind} entries must be non-empty strings.`);
|
|
104
|
+
}
|
|
105
|
+
const exact = headers.find((header) => header === trimmed);
|
|
106
|
+
if (exact) {
|
|
107
|
+
return exact;
|
|
108
|
+
}
|
|
109
|
+
const lowered = trimmed.toLowerCase();
|
|
110
|
+
const matches = headers.filter((header) => header.trim().toLowerCase() === lowered);
|
|
111
|
+
if (matches.length === 1) {
|
|
112
|
+
return matches[0];
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`Unknown CSV column: ${requested}`);
|
|
115
|
+
}
|
|
116
|
+
function getStrongLinkedInHeaderMatches(headers) {
|
|
117
|
+
return headers.filter((header) => STRONG_LINKEDIN_HEADER_KEYS.has(normalizeHeaderKey(header)));
|
|
118
|
+
}
|
|
119
|
+
function looksLikeLinkedInProfileUrl(value) {
|
|
120
|
+
const lowered = value.toLowerCase();
|
|
121
|
+
return (lowered.includes("linkedin.com/in/") && !lowered.includes("/sales/lead/"));
|
|
122
|
+
}
|
|
123
|
+
function detectLinkedInColumnByContent(rows, headers) {
|
|
124
|
+
if (rows.length === 0 || headers.length === 0) {
|
|
125
|
+
return { detectedColumn: null, candidateColumns: [] };
|
|
126
|
+
}
|
|
127
|
+
const sampleRows = rows.slice(0, 5);
|
|
128
|
+
const scores = headers.map((header) => ({
|
|
129
|
+
header,
|
|
130
|
+
score: sampleRows.reduce((count, row) => {
|
|
131
|
+
return looksLikeLinkedInProfileUrl(String(row[header] ?? "").trim())
|
|
132
|
+
? count + 1
|
|
133
|
+
: count;
|
|
134
|
+
}, 0),
|
|
135
|
+
}));
|
|
136
|
+
const maxScore = Math.max(...scores.map((entry) => entry.score));
|
|
137
|
+
if (maxScore <= 0) {
|
|
138
|
+
return { detectedColumn: null, candidateColumns: [] };
|
|
139
|
+
}
|
|
140
|
+
const candidates = scores
|
|
141
|
+
.filter((entry) => entry.score === maxScore)
|
|
142
|
+
.map((entry) => entry.header);
|
|
143
|
+
return {
|
|
144
|
+
detectedColumn: candidates.length === 1 ? candidates[0] : null,
|
|
145
|
+
candidateColumns: candidates,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function getCandidateLinkedInColumns(headers, rows) {
|
|
149
|
+
const strongMatches = getStrongLinkedInHeaderMatches(headers);
|
|
150
|
+
if (strongMatches.length > 0) {
|
|
151
|
+
return strongMatches;
|
|
152
|
+
}
|
|
153
|
+
const contentDetection = detectLinkedInColumnByContent(rows, headers);
|
|
154
|
+
if (contentDetection.candidateColumns.length > 0) {
|
|
155
|
+
return contentDetection.candidateColumns;
|
|
156
|
+
}
|
|
157
|
+
return headers.filter((header) => {
|
|
158
|
+
const normalized = normalizeHeaderKey(header);
|
|
159
|
+
return CANDIDATE_LINKEDIN_HEADER_TOKENS.some((token) => normalized.includes(token));
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function isCarryoverJunkHeader(header) {
|
|
163
|
+
const normalized = normalizeHeaderKey(header);
|
|
164
|
+
if (!normalized) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (CARRYOVER_JUNK_KEYS.has(normalized)) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
return (normalized.endsWith("timestamp") ||
|
|
171
|
+
normalized.endsWith("createdat") ||
|
|
172
|
+
normalized.endsWith("updatedat"));
|
|
173
|
+
}
|
|
174
|
+
function normalizeHeaderForLookup(header) {
|
|
175
|
+
return header.toLowerCase().replace(/[_-]/g, " ").replace(/\s+/g, " ").trim();
|
|
176
|
+
}
|
|
177
|
+
function findHeader(csvHeaders, normalizedHeaders, candidates) {
|
|
178
|
+
const exactMatchIndex = normalizedHeaders.findIndex((normalizedHeader) => candidates.some((candidate) => normalizedHeader === candidate));
|
|
179
|
+
if (exactMatchIndex !== -1) {
|
|
180
|
+
return csvHeaders[exactMatchIndex];
|
|
181
|
+
}
|
|
182
|
+
for (let index = 0; index < normalizedHeaders.length; index += 1) {
|
|
183
|
+
const normalizedHeader = normalizedHeaders[index];
|
|
184
|
+
if (candidates.some((candidate) => candidate.includes(" ") && normalizedHeader.includes(candidate))) {
|
|
185
|
+
return csvHeaders[index];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
function getSuggestedStandardMappings(headers, linkedInColumn) {
|
|
191
|
+
const filteredHeaders = headers.filter((header) => header !== linkedInColumn);
|
|
192
|
+
const normalizedHeaders = filteredHeaders.map(normalizeHeaderForLookup);
|
|
193
|
+
const mappings = {};
|
|
194
|
+
const directNameHeader = findHeader(filteredHeaders, normalizedHeaders, [
|
|
195
|
+
"name",
|
|
196
|
+
"full name",
|
|
197
|
+
"contact name",
|
|
198
|
+
]);
|
|
199
|
+
if (directNameHeader) {
|
|
200
|
+
mappings["Name"] = directNameHeader;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const firstNameIndex = normalizedHeaders.findIndex((header) => header === "first name");
|
|
204
|
+
const lastNameIndex = normalizedHeaders.findIndex((header) => header === "last name");
|
|
205
|
+
if (firstNameIndex !== -1 && lastNameIndex !== -1) {
|
|
206
|
+
mappings["Name"] = [
|
|
207
|
+
filteredHeaders[firstNameIndex],
|
|
208
|
+
filteredHeaders[lastNameIndex],
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const titleHeader = findHeader(filteredHeaders, normalizedHeaders, [
|
|
213
|
+
"title",
|
|
214
|
+
"job title",
|
|
215
|
+
"position",
|
|
216
|
+
"role",
|
|
217
|
+
]);
|
|
218
|
+
if (titleHeader) {
|
|
219
|
+
mappings["Title"] = titleHeader;
|
|
220
|
+
}
|
|
221
|
+
const companyWebsiteHeader = findHeader(filteredHeaders, normalizedHeaders, [
|
|
222
|
+
"company website",
|
|
223
|
+
"website",
|
|
224
|
+
"domain",
|
|
225
|
+
"company domain",
|
|
226
|
+
"website url",
|
|
227
|
+
"company url",
|
|
228
|
+
"organization website",
|
|
229
|
+
]);
|
|
230
|
+
if (companyWebsiteHeader) {
|
|
231
|
+
mappings["Company Website"] = companyWebsiteHeader;
|
|
232
|
+
}
|
|
233
|
+
const locationHeader = findHeader(filteredHeaders, normalizedHeaders, [
|
|
234
|
+
"location",
|
|
235
|
+
"city",
|
|
236
|
+
"geography",
|
|
237
|
+
"region",
|
|
238
|
+
]);
|
|
239
|
+
if (locationHeader) {
|
|
240
|
+
mappings["Location"] = locationHeader;
|
|
241
|
+
}
|
|
242
|
+
return mappings;
|
|
243
|
+
}
|
|
244
|
+
function getSuggestedCarryoverColumns(headers, linkedInColumn, standardMappings) {
|
|
245
|
+
const prioritized = [];
|
|
246
|
+
const seen = new Set();
|
|
247
|
+
const addHeader = (header) => {
|
|
248
|
+
if (!header || header === linkedInColumn || seen.has(header)) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
seen.add(header);
|
|
252
|
+
prioritized.push(header);
|
|
253
|
+
};
|
|
254
|
+
for (const mapping of Object.values(standardMappings)) {
|
|
255
|
+
if (Array.isArray(mapping)) {
|
|
256
|
+
mapping.forEach(addHeader);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (mapping) {
|
|
260
|
+
addHeader(mapping);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
headers
|
|
264
|
+
.filter((header) => header !== linkedInColumn)
|
|
265
|
+
.filter((header) => !isCarryoverJunkHeader(header))
|
|
266
|
+
.forEach(addHeader);
|
|
267
|
+
return prioritized.slice(0, MAX_CSV_LINKEDIN_UPLOAD_CARRY_COLUMNS);
|
|
268
|
+
}
|
|
269
|
+
function buildLeadListNameSuggestion(resolvedFilePath) {
|
|
270
|
+
const fileName = basename(resolvedFilePath);
|
|
271
|
+
const extension = extname(fileName);
|
|
272
|
+
const baseName = extension ? fileName.slice(0, -extension.length) : fileName;
|
|
273
|
+
return baseName.trim() || "LinkedIn Upload";
|
|
274
|
+
}
|
|
275
|
+
function sanitizeCompanyWebsite(value) {
|
|
276
|
+
let cleaned = value.trim().toLowerCase();
|
|
277
|
+
if (!cleaned) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
if (/^https?:\/\//.test(cleaned)) {
|
|
281
|
+
try {
|
|
282
|
+
const parsed = new URL(cleaned);
|
|
283
|
+
cleaned = parsed.host || cleaned;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
cleaned = cleaned.replace(/^https?:\/\//, "");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
cleaned = cleaned.replace(/^www\./, "");
|
|
290
|
+
cleaned = cleaned.split(/[/?#]/)[0] ?? cleaned;
|
|
291
|
+
cleaned = cleaned.split(":")[0] ?? cleaned;
|
|
292
|
+
cleaned = cleaned.replace(/\.$/, "");
|
|
293
|
+
if (!cleaned || cleaned.includes(" ") || cleaned.includes("@")) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
if (!DOMAIN_REGEX.test(cleaned)) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
return cleaned;
|
|
300
|
+
}
|
|
301
|
+
function validateAndNormalizeLinkedInUrl(value) {
|
|
302
|
+
const trimmed = value.trim();
|
|
303
|
+
if (!trimmed) {
|
|
304
|
+
return { valid: false, reason: "Empty LinkedIn URL" };
|
|
305
|
+
}
|
|
306
|
+
if (!trimmed.toLowerCase().includes("linkedin.com")) {
|
|
307
|
+
return { valid: false, reason: "Not a LinkedIn URL" };
|
|
308
|
+
}
|
|
309
|
+
if (trimmed.toLowerCase().includes("/sales/lead/")) {
|
|
310
|
+
return {
|
|
311
|
+
valid: false,
|
|
312
|
+
reason: "Sales Navigator lead URLs are not supported",
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
let parsed;
|
|
316
|
+
try {
|
|
317
|
+
parsed = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return { valid: false, reason: "Invalid URL" };
|
|
321
|
+
}
|
|
322
|
+
const host = parsed.hostname.toLowerCase();
|
|
323
|
+
if (host !== "linkedin.com" && host !== "www.linkedin.com") {
|
|
324
|
+
return {
|
|
325
|
+
valid: false,
|
|
326
|
+
reason: "LinkedIn profile URLs must use linkedin.com",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const path = parsed.pathname.replace(/\/+$/, "");
|
|
330
|
+
const match = path.match(/^\/in\/([^/?#]+)$/i);
|
|
331
|
+
if (!match?.[1]) {
|
|
332
|
+
return { valid: false, reason: "LinkedIn profile URLs must use /in/" };
|
|
333
|
+
}
|
|
334
|
+
const profileId = decodeURIComponent(match[1]).trim();
|
|
335
|
+
if (!profileId) {
|
|
336
|
+
return { valid: false, reason: "LinkedIn profile ID is empty" };
|
|
337
|
+
}
|
|
338
|
+
if (!/^[\p{L}\p{N}_.~\-]+$/u.test(profileId)) {
|
|
339
|
+
return {
|
|
340
|
+
valid: false,
|
|
341
|
+
reason: "LinkedIn profile ID contains unsupported characters",
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
valid: true,
|
|
346
|
+
normalizedUrl: `https://linkedin.com/in/${profileId}`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function makeSignature(payload) {
|
|
350
|
+
return createHmac("sha256", confirmationSecret)
|
|
351
|
+
.update(JSON.stringify(payload))
|
|
352
|
+
.digest("base64url");
|
|
353
|
+
}
|
|
354
|
+
function makeConfirmationEnvelope(payload) {
|
|
355
|
+
return {
|
|
356
|
+
payload,
|
|
357
|
+
signature: makeSignature(payload),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
export function makeLinkedinConfirmationToken(payload) {
|
|
361
|
+
return Buffer.from(JSON.stringify(makeConfirmationEnvelope(payload)), "utf8").toString("base64url");
|
|
362
|
+
}
|
|
363
|
+
export function parseLinkedinConfirmationToken(token) {
|
|
364
|
+
let decoded;
|
|
365
|
+
try {
|
|
366
|
+
decoded = JSON.parse(Buffer.from(token, "base64url").toString("utf8"));
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
throw new Error("Invalid confirmationToken. Run load_csv_linkedin_leads preview again.");
|
|
370
|
+
}
|
|
371
|
+
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
|
|
372
|
+
throw new Error("Invalid confirmationToken. Run load_csv_linkedin_leads preview again.");
|
|
373
|
+
}
|
|
374
|
+
const envelope = decoded;
|
|
375
|
+
if (!envelope.payload ||
|
|
376
|
+
envelope.payload.version !== CONFIRMATION_TOKEN_VERSION ||
|
|
377
|
+
typeof envelope.signature !== "string") {
|
|
378
|
+
throw new Error("Invalid confirmationToken. Run load_csv_linkedin_leads preview again.");
|
|
379
|
+
}
|
|
380
|
+
const expectedSignature = makeSignature(envelope.payload);
|
|
381
|
+
if (expectedSignature !== envelope.signature) {
|
|
382
|
+
throw new Error("Preview token is no longer valid. Re-run load_csv_linkedin_leads preview.");
|
|
383
|
+
}
|
|
384
|
+
return envelope.payload;
|
|
385
|
+
}
|
|
386
|
+
export function matchesLinkedinConfirmationToken(token, payload) {
|
|
387
|
+
const parsed = parseLinkedinConfirmationToken(token);
|
|
388
|
+
return (parsed.filePath === payload.filePath &&
|
|
389
|
+
parsed.fileSizeBytes === payload.fileSizeBytes &&
|
|
390
|
+
parsed.fileMtimeMs === payload.fileMtimeMs &&
|
|
391
|
+
parsed.linkedInColumn === payload.linkedInColumn &&
|
|
392
|
+
compareStringArrays(parsed.selectedColumns, payload.selectedColumns));
|
|
393
|
+
}
|
|
394
|
+
export function parseCsvLinkedinFile(input) {
|
|
395
|
+
const resolvedFilePath = resolveCsvLinkedinPath(input.filePath, input.workspaceRoot);
|
|
396
|
+
const blockingErrors = [];
|
|
397
|
+
const warnings = [];
|
|
398
|
+
let stats;
|
|
399
|
+
try {
|
|
400
|
+
stats = statSync(resolvedFilePath);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
throw new Error(`Could not read CSV file at ${resolvedFilePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
404
|
+
}
|
|
405
|
+
if (!stats.isFile()) {
|
|
406
|
+
throw new Error(`CSV path must point to a file: ${resolvedFilePath}`);
|
|
407
|
+
}
|
|
408
|
+
if (stats.size > MAX_CSV_LINKEDIN_UPLOAD_BYTES) {
|
|
409
|
+
blockingErrors.push(`CSV file exceeds the ${MAX_CSV_LINKEDIN_UPLOAD_BYTES} byte limit.`);
|
|
410
|
+
}
|
|
411
|
+
let rawRows;
|
|
412
|
+
try {
|
|
413
|
+
const content = readFileSync(resolvedFilePath, "utf8");
|
|
414
|
+
rawRows = parse(content, {
|
|
415
|
+
bom: true,
|
|
416
|
+
columns: false,
|
|
417
|
+
skip_empty_lines: true,
|
|
418
|
+
trim: true,
|
|
419
|
+
relax_column_count: true,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
throw new Error(`Could not parse CSV file at ${resolvedFilePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
424
|
+
}
|
|
425
|
+
if (rawRows.length === 0) {
|
|
426
|
+
blockingErrors.push("CSV file must include a header row.");
|
|
427
|
+
return {
|
|
428
|
+
resolvedFilePath,
|
|
429
|
+
suggestedLeadListName: buildLeadListNameSuggestion(resolvedFilePath),
|
|
430
|
+
fileSizeBytes: stats.size,
|
|
431
|
+
fileMtimeMs: stats.mtimeMs,
|
|
432
|
+
headers: [],
|
|
433
|
+
rows: [],
|
|
434
|
+
totalRows: 0,
|
|
435
|
+
blockingErrors,
|
|
436
|
+
warnings,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
const headers = rawRows[0].map((value) => String(value ?? "").trim());
|
|
440
|
+
const dataRows = rawRows
|
|
441
|
+
.slice(1)
|
|
442
|
+
.map((row) => row.map((value) => String(value ?? "")));
|
|
443
|
+
if (headers.length === 0) {
|
|
444
|
+
blockingErrors.push("CSV file must include at least one header column.");
|
|
445
|
+
}
|
|
446
|
+
const blankHeaderCount = headers.filter((header) => header.length === 0).length;
|
|
447
|
+
if (blankHeaderCount > 0) {
|
|
448
|
+
blockingErrors.push("CSV header row contains blank column names.");
|
|
449
|
+
}
|
|
450
|
+
const duplicateHeaders = findDuplicateHeaders(headers);
|
|
451
|
+
if (duplicateHeaders.length > 0) {
|
|
452
|
+
blockingErrors.push(`CSV header row contains duplicate columns: ${duplicateHeaders.join(", ")}`);
|
|
453
|
+
}
|
|
454
|
+
const mismatchedRows = dataRows.filter((row) => row.length !== headers.length);
|
|
455
|
+
if (mismatchedRows.length > 0) {
|
|
456
|
+
blockingErrors.push(`CSV contains ${mismatchedRows.length} row(s) whose column count does not match the header row.`);
|
|
457
|
+
}
|
|
458
|
+
if (dataRows.length > MAX_CSV_LINKEDIN_UPLOAD_ROWS) {
|
|
459
|
+
blockingErrors.push(`CSV contains ${dataRows.length} data rows, which exceeds the ${MAX_CSV_LINKEDIN_UPLOAD_ROWS} row limit.`);
|
|
460
|
+
}
|
|
461
|
+
const rows = blockingErrors.length > 0 ? [] : buildRowObjects(headers, dataRows);
|
|
462
|
+
if (rows.length === 0 && dataRows.length === 0) {
|
|
463
|
+
warnings.push("CSV contains headers but no data rows.");
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
resolvedFilePath,
|
|
467
|
+
suggestedLeadListName: buildLeadListNameSuggestion(resolvedFilePath),
|
|
468
|
+
fileSizeBytes: stats.size,
|
|
469
|
+
fileMtimeMs: stats.mtimeMs,
|
|
470
|
+
headers,
|
|
471
|
+
rows,
|
|
472
|
+
totalRows: dataRows.length,
|
|
473
|
+
blockingErrors,
|
|
474
|
+
warnings,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function filterStandardMappingsToSelection(selectedColumns, standardMappings) {
|
|
478
|
+
const selectedSet = new Set(selectedColumns);
|
|
479
|
+
const filtered = {};
|
|
480
|
+
for (const [target, mapping] of Object.entries(standardMappings)) {
|
|
481
|
+
if (Array.isArray(mapping)) {
|
|
482
|
+
if (mapping.every((header) => selectedSet.has(header))) {
|
|
483
|
+
filtered[target] = mapping;
|
|
484
|
+
}
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (selectedSet.has(mapping)) {
|
|
488
|
+
filtered[target] = mapping;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return filtered;
|
|
492
|
+
}
|
|
493
|
+
function prepareLeadRows(params) {
|
|
494
|
+
const effectiveStandardMappings = filterStandardMappingsToSelection(params.selectedColumns, params.standardMappings);
|
|
495
|
+
const consumedSources = new Set();
|
|
496
|
+
for (const mapping of Object.values(effectiveStandardMappings)) {
|
|
497
|
+
if (Array.isArray(mapping)) {
|
|
498
|
+
mapping.forEach((header) => consumedSources.add(header));
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
consumedSources.add(mapping);
|
|
502
|
+
}
|
|
503
|
+
const customColumns = params.selectedColumns.filter((header) => !consumedSources.has(header) && !RESERVED_TARGET_COLUMNS.has(header));
|
|
504
|
+
const seenLinkedInUrls = new Set();
|
|
505
|
+
const preparedRows = [];
|
|
506
|
+
let invalidCount = 0;
|
|
507
|
+
let duplicateCount = 0;
|
|
508
|
+
let emptyCount = 0;
|
|
509
|
+
for (const sourceRow of params.rows) {
|
|
510
|
+
const linkedInValue = String(sourceRow[params.linkedInColumn] ?? "").trim();
|
|
511
|
+
if (!linkedInValue) {
|
|
512
|
+
emptyCount += 1;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const validation = validateAndNormalizeLinkedInUrl(linkedInValue);
|
|
516
|
+
if (!validation.valid) {
|
|
517
|
+
invalidCount += 1;
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const dedupeKey = validation.normalizedUrl.toLowerCase();
|
|
521
|
+
if (seenLinkedInUrls.has(dedupeKey)) {
|
|
522
|
+
duplicateCount += 1;
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
seenLinkedInUrls.add(dedupeKey);
|
|
526
|
+
const row = {
|
|
527
|
+
"LinkedIn URL": validation.normalizedUrl,
|
|
528
|
+
};
|
|
529
|
+
for (const [target, mapping] of Object.entries(effectiveStandardMappings)) {
|
|
530
|
+
if (Array.isArray(mapping)) {
|
|
531
|
+
const parts = mapping
|
|
532
|
+
.map((header) => String(sourceRow[header] ?? "").trim())
|
|
533
|
+
.filter(Boolean);
|
|
534
|
+
if (parts.length > 0) {
|
|
535
|
+
row[target] = parts.join(" ");
|
|
536
|
+
}
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const value = String(sourceRow[mapping] ?? "").trim();
|
|
540
|
+
if (!value) {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (target === "Company Website") {
|
|
544
|
+
const normalizedWebsite = sanitizeCompanyWebsite(value);
|
|
545
|
+
if (normalizedWebsite) {
|
|
546
|
+
row[target] = normalizedWebsite;
|
|
547
|
+
}
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
row[target] = value;
|
|
551
|
+
}
|
|
552
|
+
for (const customColumn of customColumns) {
|
|
553
|
+
const value = String(sourceRow[customColumn] ?? "").trim();
|
|
554
|
+
if (value) {
|
|
555
|
+
row[customColumn] = value;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
preparedRows.push({
|
|
559
|
+
row,
|
|
560
|
+
normalizedLinkedInUrl: validation.normalizedUrl,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
rows: preparedRows,
|
|
565
|
+
standardMappings: effectiveStandardMappings,
|
|
566
|
+
customColumnTargets: customColumns,
|
|
567
|
+
invalidCount,
|
|
568
|
+
duplicateCount,
|
|
569
|
+
emptyCount,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
export function buildCsvLinkedinPreview(input) {
|
|
573
|
+
const parsed = parseCsvLinkedinFile(input);
|
|
574
|
+
const blockingErrors = [...parsed.blockingErrors];
|
|
575
|
+
const warnings = [...parsed.warnings];
|
|
576
|
+
const headers = parsed.headers;
|
|
577
|
+
let resolvedLinkedInColumn = null;
|
|
578
|
+
const strongMatches = getStrongLinkedInHeaderMatches(headers);
|
|
579
|
+
if (input.linkedInColumn) {
|
|
580
|
+
resolvedLinkedInColumn = resolveHeaderSelection(headers, input.linkedInColumn, "linkedInColumn");
|
|
581
|
+
}
|
|
582
|
+
else if (strongMatches.length === 1) {
|
|
583
|
+
resolvedLinkedInColumn = strongMatches[0];
|
|
584
|
+
}
|
|
585
|
+
else if (strongMatches.length > 1) {
|
|
586
|
+
warnings.push(`Multiple LinkedIn-looking headers found (${strongMatches.join(", ")}). Set linkedInColumn explicitly.`);
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
const contentDetection = detectLinkedInColumnByContent(parsed.rows, headers);
|
|
590
|
+
if (contentDetection.detectedColumn) {
|
|
591
|
+
resolvedLinkedInColumn = contentDetection.detectedColumn;
|
|
592
|
+
}
|
|
593
|
+
else if (contentDetection.candidateColumns.length > 1) {
|
|
594
|
+
warnings.push(`Multiple columns look like LinkedIn profile URLs (${contentDetection.candidateColumns.join(", ")}). Set linkedInColumn explicitly.`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const candidateLinkedInColumns = getCandidateLinkedInColumns(headers, parsed.rows);
|
|
598
|
+
const standardMappings = getSuggestedStandardMappings(headers, resolvedLinkedInColumn);
|
|
599
|
+
let selectedColumns = getSuggestedCarryoverColumns(headers, resolvedLinkedInColumn, standardMappings);
|
|
600
|
+
if (input.selectedColumns) {
|
|
601
|
+
if (input.selectedColumns.length > MAX_CSV_LINKEDIN_UPLOAD_CARRY_COLUMNS) {
|
|
602
|
+
blockingErrors.push(`selectedColumns exceeds the ${MAX_CSV_LINKEDIN_UPLOAD_CARRY_COLUMNS} column limit.`);
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
selectedColumns = input.selectedColumns.map((column) => resolveHeaderSelection(headers, column, "selectedColumns"));
|
|
606
|
+
if (resolvedLinkedInColumn &&
|
|
607
|
+
selectedColumns.includes(resolvedLinkedInColumn)) {
|
|
608
|
+
blockingErrors.push("selectedColumns cannot include the chosen LinkedIn URL column.");
|
|
609
|
+
}
|
|
610
|
+
selectedColumns = selectedColumns.filter((column, index, array) => array.indexOf(column) === index);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const prepared = resolvedLinkedInColumn
|
|
614
|
+
? prepareLeadRows({
|
|
615
|
+
rows: parsed.rows,
|
|
616
|
+
linkedInColumn: resolvedLinkedInColumn,
|
|
617
|
+
selectedColumns,
|
|
618
|
+
standardMappings,
|
|
619
|
+
})
|
|
620
|
+
: {
|
|
621
|
+
rows: [],
|
|
622
|
+
standardMappings: {},
|
|
623
|
+
customColumnTargets: [],
|
|
624
|
+
invalidCount: 0,
|
|
625
|
+
duplicateCount: 0,
|
|
626
|
+
emptyCount: 0,
|
|
627
|
+
};
|
|
628
|
+
if (resolvedLinkedInColumn && prepared.rows.length === 0) {
|
|
629
|
+
blockingErrors.push("CSV did not produce any valid LinkedIn profile URLs after validation.");
|
|
630
|
+
}
|
|
631
|
+
if (prepared.invalidCount > 0) {
|
|
632
|
+
warnings.push(`Skipped ${prepared.invalidCount} invalid LinkedIn URL row(s).`);
|
|
633
|
+
}
|
|
634
|
+
if (prepared.duplicateCount > 0) {
|
|
635
|
+
warnings.push(`Skipped ${prepared.duplicateCount} duplicate LinkedIn URL row(s) using first_wins.`);
|
|
636
|
+
}
|
|
637
|
+
if (prepared.emptyCount > 0) {
|
|
638
|
+
warnings.push(`Skipped ${prepared.emptyCount} row(s) with an empty LinkedIn URL value.`);
|
|
639
|
+
}
|
|
640
|
+
const payload = blockingErrors.length === 0 && resolvedLinkedInColumn
|
|
641
|
+
? {
|
|
642
|
+
version: CONFIRMATION_TOKEN_VERSION,
|
|
643
|
+
filePath: parsed.resolvedFilePath,
|
|
644
|
+
fileSizeBytes: parsed.fileSizeBytes,
|
|
645
|
+
fileMtimeMs: parsed.fileMtimeMs,
|
|
646
|
+
linkedInColumn: resolvedLinkedInColumn,
|
|
647
|
+
selectedColumns,
|
|
648
|
+
}
|
|
649
|
+
: null;
|
|
650
|
+
return {
|
|
651
|
+
preview: {
|
|
652
|
+
resolvedFilePath: parsed.resolvedFilePath,
|
|
653
|
+
suggestedLeadListName: parsed.suggestedLeadListName,
|
|
654
|
+
headers,
|
|
655
|
+
detectedLinkedInColumn: resolvedLinkedInColumn,
|
|
656
|
+
candidateLinkedInColumns,
|
|
657
|
+
needsLinkedInColumnSelection: !resolvedLinkedInColumn,
|
|
658
|
+
standardMappings: prepared.standardMappings,
|
|
659
|
+
customColumnTargets: prepared.customColumnTargets,
|
|
660
|
+
suggestedCarryoverColumns: getSuggestedCarryoverColumns(headers, resolvedLinkedInColumn, standardMappings),
|
|
661
|
+
selectedColumns,
|
|
662
|
+
totalRows: parsed.totalRows,
|
|
663
|
+
validLinkedInRowCount: prepared.rows.length,
|
|
664
|
+
invalidLinkedInRowCount: prepared.invalidCount,
|
|
665
|
+
duplicateLinkedInCount: prepared.duplicateCount,
|
|
666
|
+
emptyLinkedInRowCount: prepared.emptyCount,
|
|
667
|
+
duplicatePolicy: "first_wins",
|
|
668
|
+
limits: getLimits(),
|
|
669
|
+
warnings,
|
|
670
|
+
blockingErrors,
|
|
671
|
+
confirmationToken: payload
|
|
672
|
+
? makeLinkedinConfirmationToken(payload)
|
|
673
|
+
: null,
|
|
674
|
+
},
|
|
675
|
+
fileSizeBytes: parsed.fileSizeBytes,
|
|
676
|
+
fileMtimeMs: parsed.fileMtimeMs,
|
|
677
|
+
resolvedLinkedInColumn,
|
|
678
|
+
parsedRows: parsed.rows,
|
|
679
|
+
preparedRows: prepared.rows,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
export async function uploadCsvLinkedinFile(params) {
|
|
683
|
+
const resolvedFilePath = resolveCsvLinkedinPath(params.filePath, params.workspaceRoot);
|
|
684
|
+
const originalFilename = basename(resolvedFilePath);
|
|
685
|
+
const fileBytes = readFileSync(resolvedFilePath);
|
|
686
|
+
const fileSizeBytes = params.fileSizeBytes ?? fileBytes.byteLength;
|
|
687
|
+
const contentType = getCsvLinkedinContentType(resolvedFilePath);
|
|
688
|
+
const uploadContract = await params.api.post(`/api/v3/lead-lists/${params.leadListId}/csv-import/upload-url`, {
|
|
689
|
+
filename: originalFilename,
|
|
690
|
+
contentType,
|
|
691
|
+
fileSizeBytes,
|
|
692
|
+
});
|
|
693
|
+
const uploadResponse = await fetch(uploadContract.signedUrl, {
|
|
694
|
+
method: "PUT",
|
|
695
|
+
headers: {
|
|
696
|
+
"content-type": contentType,
|
|
697
|
+
"cache-control": "max-age=3600",
|
|
698
|
+
"x-upsert": "false",
|
|
699
|
+
},
|
|
700
|
+
body: fileBytes,
|
|
701
|
+
});
|
|
702
|
+
if (!uploadResponse.ok) {
|
|
703
|
+
const errorText = await uploadResponse.text();
|
|
704
|
+
throw new Error(`Signed CSV upload failed (${uploadResponse.status}): ${errorText || uploadResponse.statusText}`);
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
...uploadContract,
|
|
708
|
+
originalFilename,
|
|
709
|
+
contentType,
|
|
710
|
+
fileSizeBytes,
|
|
711
|
+
};
|
|
712
|
+
}
|