indiecrm-cli 0.1.0 → 0.2.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/CODE_OF_CONDUCT.md +35 -0
  3. package/CONTRIBUTING.md +7 -0
  4. package/README.md +14 -2
  5. package/RESEARCH.md +346 -0
  6. package/SECURITY.md +35 -0
  7. package/dist/affiliate-copy.js +49 -0
  8. package/dist/auth.js +453 -0
  9. package/dist/bigquery.js +199 -0
  10. package/dist/chrome-browser.js +231 -0
  11. package/dist/cli.js +19652 -0
  12. package/dist/company-identity-review.js +78 -0
  13. package/dist/company-leads.js +422 -0
  14. package/dist/company-recovery.js +84 -0
  15. package/dist/deel-outreach.js +469 -0
  16. package/dist/deel-salesnav.js +368 -0
  17. package/dist/direct-path.js +326 -0
  18. package/dist/domain.js +53 -0
  19. package/dist/domainfinder.js +764 -0
  20. package/dist/engine.js +216 -0
  21. package/dist/historical-queries.js +189 -0
  22. package/dist/hunter-emailfinder.js +252 -0
  23. package/dist/icp-templates.js +171 -0
  24. package/dist/indiecrm/commands.js +108 -0
  25. package/dist/indiecrm-cli.js +2 -104
  26. package/dist/instantly.js +136 -0
  27. package/dist/io.js +21 -0
  28. package/dist/leadlists-funnel.js +148 -0
  29. package/dist/linkedin-companies.js +562 -0
  30. package/dist/linkedin-product-details.js +1203 -0
  31. package/dist/linkedin-product-search.js +1081 -0
  32. package/dist/linkedin-products.js +786 -0
  33. package/dist/linkedin-session-contracts.js +3 -0
  34. package/dist/linkedin-session.js +846 -0
  35. package/dist/providers.js +1 -0
  36. package/dist/research-browser-preference.js +37 -0
  37. package/dist/sales-navigator.js +1231 -0
  38. package/dist/salesnav-backfill.js +710 -0
  39. package/dist/sample-data.js +34 -0
  40. package/dist/session-recovery.js +62 -0
  41. package/dist/vendor/salesprompter-shared/extension-session-contracts.js +29 -0
  42. package/dist/vendor/salesprompter-shared/linkedin-session.js +22 -0
  43. package/dist/vendor/salesprompter-shared/phantombuster-contracts.js +16 -0
  44. package/dist/vendor/salesprompter-shared/session-vault-contracts.js +17 -0
  45. package/package.json +73 -14
package/dist/auth.js ADDED
@@ -0,0 +1,453 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
+ import { z } from "zod";
7
+ const DEFAULT_API_BASE_URL = "https://salesprompter.ai";
8
+ const CLIENT_HEADER = "salesprompter-cli/0.2";
9
+ const DEFAULT_DEVICE_POLL_INTERVAL_SECONDS = 3;
10
+ const DEFAULT_DEVICE_TIMEOUT_SECONDS = 180;
11
+ const nullableOptionalString = z.string().min(1).nullish().transform((value) => value ?? undefined);
12
+ const UserSchema = z.object({
13
+ id: z.string().min(1),
14
+ email: z.string().email(),
15
+ name: nullableOptionalString,
16
+ orgId: nullableOptionalString,
17
+ orgName: nullableOptionalString,
18
+ orgSlug: nullableOptionalString,
19
+ workspaceClientId: nullableOptionalString,
20
+ workspaceClientName: nullableOptionalString
21
+ });
22
+ const AuthSessionSchema = z.object({
23
+ accessToken: z.string().min(1),
24
+ refreshToken: z.string().min(1).optional(),
25
+ apiBaseUrl: z.string().url(),
26
+ user: UserSchema,
27
+ expiresAt: z.string().datetime().optional(),
28
+ createdAt: z.string().datetime()
29
+ });
30
+ function buildBrowserCallbackSuccessHtml() {
31
+ return [
32
+ "<!doctype html>",
33
+ '<html lang="en">',
34
+ "<head>",
35
+ '<meta charset="utf-8">',
36
+ "<title>Connected to Salesprompter</title>",
37
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
38
+ '<meta name="color-scheme" content="light dark">',
39
+ "<style>",
40
+ ":root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;color-scheme:light dark}",
41
+ "*{box-sizing:border-box}",
42
+ "body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f7f8fa;color:#172033;padding:24px}",
43
+ ".card{width:min(420px,100%);background:#fff;border:1px solid #e4e8ef;border-radius:20px;padding:36px;box-shadow:0 20px 60px rgba(23,32,51,.10);text-align:center}",
44
+ ".mark{width:52px;height:52px;margin:0 auto 20px;display:grid;place-items:center;border-radius:50%;background:#e9f7ee;color:#18753c;font-size:26px;font-weight:800}",
45
+ "h1{margin:0 0 10px;font-size:24px;line-height:1.2;letter-spacing:-.02em}",
46
+ "p{margin:0;color:#667085;font-size:15px;line-height:1.55}",
47
+ ".hint{margin-top:18px;font-size:13px;color:#98a2b3}",
48
+ "@media(prefers-color-scheme:dark){body{background:#111318;color:#f7f8fa}.card{background:#1b1f27;border-color:#303643;box-shadow:none}p{color:#b7c0ce}.hint{color:#8791a2}.mark{background:#153b27;color:#78d89a}}",
49
+ "</style>",
50
+ "<script>",
51
+ "if (window.history && typeof window.history.replaceState === 'function') {",
52
+ " window.history.replaceState(null, document.title, window.location.pathname);",
53
+ "}",
54
+ "window.addEventListener('load', function () {",
55
+ " window.setTimeout(function () { window.close(); }, 700);",
56
+ "});",
57
+ "</script>",
58
+ "</head>",
59
+ "<body>",
60
+ '<main class="card" aria-live="polite">',
61
+ '<div class="mark" aria-hidden="true">&#10003;</div>',
62
+ "<h1>You're connected</h1>",
63
+ "<p>Salesprompter is ready in your terminal.</p>",
64
+ '<p class="hint">This tab will close automatically.</p>',
65
+ "</main>",
66
+ "</body>",
67
+ "</html>"
68
+ ].join("");
69
+ }
70
+ function isSpeculativeBrowserRequest(request) {
71
+ const purposeHeaders = [
72
+ request.headers.purpose,
73
+ request.headers["sec-purpose"],
74
+ request.headers["x-purpose"]
75
+ ]
76
+ .flatMap((value) => (Array.isArray(value) ? value : [value]))
77
+ .filter((value) => typeof value === "string")
78
+ .join(" ")
79
+ .toLowerCase();
80
+ return purposeHeaders.includes("prefetch") || request.headers["next-router-prefetch"] === "1";
81
+ }
82
+ const DeviceStartResponseSchema = z.object({
83
+ deviceCode: z.string().min(1),
84
+ userCode: z.string().min(1),
85
+ verificationUrl: z.string().url().optional(),
86
+ verificationUri: z.string().url().optional(),
87
+ intervalSeconds: z.number().int().min(1).optional(),
88
+ expiresInSeconds: z.number().int().min(30).optional()
89
+ });
90
+ const DevicePollPendingSchema = z.object({
91
+ status: z.literal("pending")
92
+ });
93
+ const DevicePollDeniedSchema = z.object({
94
+ status: z.enum(["denied", "expired"])
95
+ });
96
+ const DevicePollAuthorizedSchema = z.object({
97
+ status: z.literal("authorized"),
98
+ accessToken: z.string().min(1),
99
+ refreshToken: z.string().min(1).optional(),
100
+ expiresAt: z.string().datetime().optional(),
101
+ user: UserSchema
102
+ });
103
+ const DevicePollResponseSchema = z.union([
104
+ DevicePollPendingSchema,
105
+ DevicePollDeniedSchema,
106
+ DevicePollAuthorizedSchema
107
+ ]);
108
+ const WhoAmIResponseSchema = z
109
+ .union([
110
+ z.object({
111
+ user: UserSchema,
112
+ expiresAt: z.string().datetime().optional()
113
+ }),
114
+ z.object({
115
+ id: z.string().min(1),
116
+ email: z.string().email(),
117
+ name: nullableOptionalString,
118
+ orgId: nullableOptionalString,
119
+ orgName: nullableOptionalString,
120
+ orgSlug: nullableOptionalString,
121
+ workspaceClientId: nullableOptionalString,
122
+ workspaceClientName: nullableOptionalString,
123
+ expiresAt: z.string().datetime().optional()
124
+ }),
125
+ z.object({
126
+ data: z.object({
127
+ user: UserSchema,
128
+ expiresAt: z.string().datetime().optional()
129
+ })
130
+ })
131
+ ])
132
+ .transform((value) => {
133
+ if ("data" in value) {
134
+ return value.data;
135
+ }
136
+ if ("user" in value) {
137
+ return value;
138
+ }
139
+ return {
140
+ user: {
141
+ id: value.id,
142
+ email: value.email,
143
+ name: value.name,
144
+ orgId: value.orgId,
145
+ orgName: value.orgName,
146
+ orgSlug: value.orgSlug,
147
+ workspaceClientId: value.workspaceClientId,
148
+ workspaceClientName: value.workspaceClientName
149
+ },
150
+ expiresAt: value.expiresAt
151
+ };
152
+ });
153
+ function getConfigDir() {
154
+ const override = process.env.SALESPROMPTER_CONFIG_DIR?.trim();
155
+ if (override !== undefined && override.length > 0) {
156
+ return override;
157
+ }
158
+ return path.join(os.homedir(), ".config", "salesprompter");
159
+ }
160
+ function getSessionPath() {
161
+ return path.join(getConfigDir(), "auth-session.json");
162
+ }
163
+ function normalizeApiBaseUrl(apiBaseUrl) {
164
+ const value = (apiBaseUrl ?? process.env.SALESPROMPTER_API_BASE_URL ?? DEFAULT_API_BASE_URL).trim();
165
+ return value.replace(/\/+$/, "");
166
+ }
167
+ function isDeviceFlowUnavailableError(message) {
168
+ if (message.includes("invalid JSON response")) {
169
+ return true;
170
+ }
171
+ return /request failed \((401|403|404|405|500|501|502|503|504)\)/.test(message);
172
+ }
173
+ function buildDeviceFlowUnavailableMessage(apiBaseUrl) {
174
+ return [
175
+ "device login is not configured on this Salesprompter app.",
176
+ `Generate a CLI token in the app and run \`indiecrm auth:login --token <token> --api-url "${apiBaseUrl}"\`.`
177
+ ].join(" ");
178
+ }
179
+ function isBrowserConnectUnavailableError(message) {
180
+ if (/request failed \((401|403|404|405|500|501|502|503|504)\)/.test(message)) {
181
+ return true;
182
+ }
183
+ return message.includes("invalid localhost callback response");
184
+ }
185
+ async function hasSessionFile() {
186
+ try {
187
+ await access(getSessionPath());
188
+ return true;
189
+ }
190
+ catch {
191
+ return false;
192
+ }
193
+ }
194
+ async function httpJson(url, init, schema) {
195
+ const response = await fetch(url, init);
196
+ const text = await response.text();
197
+ let payload = {};
198
+ if (text.length > 0) {
199
+ try {
200
+ payload = JSON.parse(text);
201
+ }
202
+ catch {
203
+ if (!response.ok) {
204
+ throw new Error(`request failed (${response.status}) for ${url}`);
205
+ }
206
+ throw new Error(`invalid JSON response for ${url}`);
207
+ }
208
+ }
209
+ if (!response.ok) {
210
+ throw new Error(`request failed (${response.status}) for ${url}`);
211
+ }
212
+ return schema.parse(payload);
213
+ }
214
+ function hasExpired(expiresAt) {
215
+ if (expiresAt === undefined) {
216
+ return false;
217
+ }
218
+ return Date.now() >= Date.parse(expiresAt);
219
+ }
220
+ export async function readAuthSession() {
221
+ if (!(await hasSessionFile())) {
222
+ return null;
223
+ }
224
+ const content = await readFile(getSessionPath(), "utf8");
225
+ const parsed = JSON.parse(content);
226
+ return AuthSessionSchema.parse(parsed);
227
+ }
228
+ export async function writeAuthSession(session) {
229
+ const sessionPath = getSessionPath();
230
+ await mkdir(path.dirname(sessionPath), { recursive: true });
231
+ await writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`, "utf8");
232
+ }
233
+ export async function clearAuthSession() {
234
+ await rm(getSessionPath(), { force: true });
235
+ }
236
+ export async function requireAuthSession() {
237
+ const session = await readAuthSession();
238
+ if (session === null) {
239
+ throw new Error("not logged in. Run `indiecrm auth:login` or set SALESPROMPTER_TOKEN for non-interactive runs.");
240
+ }
241
+ if (hasExpired(session.expiresAt)) {
242
+ throw new Error("session expired. Run `indiecrm auth:login` or refresh SALESPROMPTER_TOKEN for non-interactive runs.");
243
+ }
244
+ return session;
245
+ }
246
+ export async function verifySession(session) {
247
+ const apiBaseUrl = normalizeApiBaseUrl(session.apiBaseUrl);
248
+ const response = await httpJson(`${apiBaseUrl}/api/cli/auth/me`, {
249
+ method: "GET",
250
+ headers: {
251
+ Authorization: `Bearer ${session.accessToken}`,
252
+ "X-Salesprompter-Client": CLIENT_HEADER
253
+ }
254
+ }, WhoAmIResponseSchema);
255
+ return AuthSessionSchema.parse({
256
+ ...session,
257
+ apiBaseUrl,
258
+ user: response.user,
259
+ expiresAt: response.expiresAt ?? session.expiresAt
260
+ });
261
+ }
262
+ export async function loginWithToken(token, apiBaseUrl) {
263
+ const normalizedApiBaseUrl = normalizeApiBaseUrl(apiBaseUrl);
264
+ const response = await httpJson(`${normalizedApiBaseUrl}/api/cli/auth/me`, {
265
+ method: "GET",
266
+ headers: {
267
+ Authorization: `Bearer ${token}`,
268
+ "X-Salesprompter-Client": CLIENT_HEADER
269
+ }
270
+ }, WhoAmIResponseSchema);
271
+ const session = AuthSessionSchema.parse({
272
+ accessToken: token,
273
+ apiBaseUrl: normalizedApiBaseUrl,
274
+ user: response.user,
275
+ expiresAt: response.expiresAt,
276
+ createdAt: new Date().toISOString()
277
+ });
278
+ await writeAuthSession(session);
279
+ return session;
280
+ }
281
+ export async function loginWithBrowserConnect(options) {
282
+ const apiBaseUrl = normalizeApiBaseUrl(options?.apiBaseUrl);
283
+ const timeoutSeconds = options?.timeoutSeconds ?? DEFAULT_DEVICE_TIMEOUT_SECONDS;
284
+ const state = randomBytes(16).toString("hex");
285
+ let resolveToken;
286
+ let rejectToken;
287
+ const tokenPromise = new Promise((resolve, reject) => {
288
+ resolveToken = resolve;
289
+ rejectToken = reject;
290
+ });
291
+ const server = createServer((request, response) => {
292
+ try {
293
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
294
+ if (requestUrl.pathname !== "/callback") {
295
+ response.statusCode = 404;
296
+ response.end("Not found");
297
+ return;
298
+ }
299
+ response.setHeader("Cache-Control", "no-store, max-age=0");
300
+ response.setHeader("Pragma", "no-cache");
301
+ response.setHeader("Referrer-Policy", "no-referrer");
302
+ response.setHeader("X-Content-Type-Options", "nosniff");
303
+ if (request.method !== "GET" || isSpeculativeBrowserRequest(request)) {
304
+ response.statusCode = 204;
305
+ response.end();
306
+ return;
307
+ }
308
+ const accessToken = requestUrl.searchParams.get("access_token") ?? "";
309
+ const responseState = requestUrl.searchParams.get("state") ?? "";
310
+ if (accessToken.trim().length === 0 || responseState.trim().length === 0 || responseState !== state) {
311
+ response.statusCode = 400;
312
+ response.end("Invalid login response");
313
+ return;
314
+ }
315
+ response.statusCode = 200;
316
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
317
+ response.setHeader("Connection", "close");
318
+ response.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
319
+ response.once("finish", () => {
320
+ resolveToken?.({ accessToken, state: responseState });
321
+ });
322
+ response.end(buildBrowserCallbackSuccessHtml());
323
+ }
324
+ catch (error) {
325
+ response.statusCode = 500;
326
+ response.end("Login failed");
327
+ rejectToken?.(error);
328
+ }
329
+ });
330
+ server.once("error", (error) => {
331
+ rejectToken?.(error);
332
+ });
333
+ await new Promise((resolve, reject) => {
334
+ server.listen(0, "127.0.0.1", () => resolve());
335
+ server.once("error", reject);
336
+ });
337
+ const address = server.address();
338
+ if (!address || typeof address === "string") {
339
+ server.close();
340
+ throw new Error("failed to bind localhost callback server");
341
+ }
342
+ const redirectUri = `http://127.0.0.1:${address.port}/callback`;
343
+ const closeServer = async () => await new Promise((resolve) => {
344
+ server.close(() => resolve());
345
+ });
346
+ const browserUrl = new URL(`${apiBaseUrl}/api/cli/auth/connect`);
347
+ browserUrl.searchParams.set("redirect_uri", redirectUri);
348
+ browserUrl.searchParams.set("state", state);
349
+ let preflightResponse;
350
+ try {
351
+ preflightResponse = await fetch(browserUrl, {
352
+ method: "GET",
353
+ redirect: "manual",
354
+ headers: {
355
+ "X-Salesprompter-Client": CLIENT_HEADER
356
+ }
357
+ });
358
+ }
359
+ catch (error) {
360
+ await closeServer();
361
+ throw error;
362
+ }
363
+ if (preflightResponse.status < 300 || preflightResponse.status >= 400) {
364
+ await closeServer();
365
+ throw new Error(`request failed (${preflightResponse.status}) for ${browserUrl}`);
366
+ }
367
+ await options?.onConnectStart?.({
368
+ browserUrl: browserUrl.toString(),
369
+ redirectUri
370
+ });
371
+ const timeoutHandle = setTimeout(() => {
372
+ rejectToken?.(new Error("timed out waiting for browser login"));
373
+ }, Math.max(timeoutSeconds, 30) * 1000);
374
+ let result;
375
+ try {
376
+ result = await tokenPromise;
377
+ }
378
+ finally {
379
+ clearTimeout(timeoutHandle);
380
+ await closeServer();
381
+ }
382
+ if (result.state !== state) {
383
+ throw new Error("invalid localhost callback response");
384
+ }
385
+ return await loginWithToken(result.accessToken, apiBaseUrl);
386
+ }
387
+ export async function loginWithDeviceFlow(options) {
388
+ const apiBaseUrl = normalizeApiBaseUrl(options?.apiBaseUrl);
389
+ const timeoutSeconds = options?.timeoutSeconds ?? DEFAULT_DEVICE_TIMEOUT_SECONDS;
390
+ let start;
391
+ try {
392
+ start = await httpJson(`${apiBaseUrl}/api/cli/auth/device/start`, {
393
+ method: "POST",
394
+ headers: {
395
+ "Content-Type": "application/json",
396
+ "X-Salesprompter-Client": CLIENT_HEADER
397
+ },
398
+ body: JSON.stringify({ client: "salesprompter-cli" })
399
+ }, DeviceStartResponseSchema);
400
+ }
401
+ catch (error) {
402
+ const message = error instanceof Error ? error.message : String(error);
403
+ if (isDeviceFlowUnavailableError(message)) {
404
+ throw new Error(buildDeviceFlowUnavailableMessage(apiBaseUrl));
405
+ }
406
+ throw error;
407
+ }
408
+ const verificationUrl = start.verificationUrl ?? start.verificationUri;
409
+ if (verificationUrl === undefined) {
410
+ throw new Error("device start response missing verification url");
411
+ }
412
+ await options?.onDeviceStart?.({
413
+ verificationUrl,
414
+ userCode: start.userCode,
415
+ intervalSeconds: start.intervalSeconds,
416
+ expiresInSeconds: start.expiresInSeconds
417
+ });
418
+ const pollIntervalMs = (start.intervalSeconds ?? DEFAULT_DEVICE_POLL_INTERVAL_SECONDS) * 1000;
419
+ const deadline = Date.now() + Math.max(timeoutSeconds, 30) * 1000;
420
+ while (Date.now() < deadline) {
421
+ const poll = await httpJson(`${apiBaseUrl}/api/cli/auth/device/poll`, {
422
+ method: "POST",
423
+ headers: {
424
+ "Content-Type": "application/json",
425
+ "X-Salesprompter-Client": CLIENT_HEADER
426
+ },
427
+ body: JSON.stringify({ deviceCode: start.deviceCode })
428
+ }, DevicePollResponseSchema);
429
+ if (poll.status === "authorized") {
430
+ const session = AuthSessionSchema.parse({
431
+ accessToken: poll.accessToken,
432
+ refreshToken: poll.refreshToken,
433
+ apiBaseUrl,
434
+ user: poll.user,
435
+ expiresAt: poll.expiresAt,
436
+ createdAt: new Date().toISOString()
437
+ });
438
+ await writeAuthSession(session);
439
+ return { session, verificationUrl, userCode: start.userCode };
440
+ }
441
+ if (poll.status === "denied") {
442
+ throw new Error("login denied");
443
+ }
444
+ if (poll.status === "expired") {
445
+ throw new Error("device login expired");
446
+ }
447
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
448
+ }
449
+ throw new Error("timed out waiting for device login");
450
+ }
451
+ export function shouldBypassAuth() {
452
+ return process.env.SALESPROMPTER_SKIP_AUTH === "1";
453
+ }
@@ -0,0 +1,199 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ const DEFAULT_BQ_PROJECT_ID = process.env.BQ_PROJECT_ID ?? process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCLOUD_PROJECT ?? "icpidentifier";
5
+ const BQ_EXEC_MAX_BUFFER = 256 * 1024 * 1024;
6
+ function escapeSqlString(value) {
7
+ return value.replaceAll("\\", "\\\\").replaceAll("'", "\\'");
8
+ }
9
+ function lowerQuoted(value) {
10
+ return `'${escapeSqlString(value.trim().toLowerCase())}'`;
11
+ }
12
+ function upperQuoted(value) {
13
+ return `'${escapeSqlString(value.trim().toUpperCase())}'`;
14
+ }
15
+ function buildContainsClause(field, values) {
16
+ const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0);
17
+ if (normalized.length === 0) {
18
+ return null;
19
+ }
20
+ const clauses = normalized.map((value) => `LOWER(CAST(${field} AS STRING)) LIKE ${lowerQuoted(`%${value}%`)}`);
21
+ return `(${clauses.join(" OR ")})`;
22
+ }
23
+ function buildInClause(field, values) {
24
+ const normalized = values.map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0);
25
+ if (normalized.length === 0) {
26
+ return null;
27
+ }
28
+ return `LOWER(CAST(${field} AS STRING)) IN (${normalized.map(lowerQuoted).join(", ")})`;
29
+ }
30
+ function buildCountryClause(field, values) {
31
+ const normalized = values.map((value) => value.trim().toUpperCase()).filter((value) => value.length > 0);
32
+ if (normalized.length === 0) {
33
+ return null;
34
+ }
35
+ return `UPPER(CAST(${field} AS STRING)) IN (${normalized.map(upperQuoted).join(", ")})`;
36
+ }
37
+ function buildCompanySizeClause(field, buckets) {
38
+ const normalized = buckets.map((value) => value.trim()).filter((value) => value.length > 0);
39
+ if (normalized.length === 0) {
40
+ return null;
41
+ }
42
+ const bucketMap = {
43
+ "1-49": ["1-10", "11-50"],
44
+ "50-199": ["51-200"],
45
+ "200-499": ["201-500"],
46
+ "500+": ["501-1000", "1001-5000", "5001-10.000", "10.000+"]
47
+ };
48
+ const expanded = normalized.flatMap((bucket) => bucketMap[bucket] ?? [bucket]);
49
+ const clauses = expanded.map((bucket) => `LOWER(CAST(${field} AS STRING)) = ${lowerQuoted(bucket)}`);
50
+ return `(${clauses.join(" OR ")})`;
51
+ }
52
+ function requireString(row, field) {
53
+ const value = String(row[field] ?? "").trim();
54
+ if (value.length === 0) {
55
+ throw new Error(`BigQuery lead row missing required field: ${field}`);
56
+ }
57
+ return value;
58
+ }
59
+ function employeeCountFromBucket(bucket) {
60
+ switch (bucket.trim()) {
61
+ case "1-10":
62
+ return 10;
63
+ case "11-50":
64
+ return 30;
65
+ case "51-200":
66
+ return 125;
67
+ case "201-500":
68
+ return 350;
69
+ case "501-1000":
70
+ return 750;
71
+ case "1001-5000":
72
+ return 2500;
73
+ case "5001-10.000":
74
+ return 7500;
75
+ case "10.000+":
76
+ return 10000;
77
+ default:
78
+ return 250;
79
+ }
80
+ }
81
+ function deriveRegion(country, region) {
82
+ const normalizedRegion = region.trim();
83
+ if (normalizedRegion.length > 0) {
84
+ return normalizedRegion;
85
+ }
86
+ const normalizedCountry = country.trim().toUpperCase();
87
+ if (["DE", "AT", "CH"].includes(normalizedCountry)) {
88
+ return "DACH";
89
+ }
90
+ return normalizedCountry.length > 0 ? normalizedCountry : "unknown";
91
+ }
92
+ function getOptionalString(row, field) {
93
+ const value = String(row[field] ?? "").trim();
94
+ return value.length > 0 ? value : null;
95
+ }
96
+ export function normalizeBigQueryLeadRows(rows) {
97
+ return rows.map((row) => {
98
+ const firstName = String(row.firstName ?? "").trim();
99
+ const lastName = String(row.lastName ?? "").trim();
100
+ const contactName = [firstName, lastName].filter((value) => value.length > 0).join(" ");
101
+ if (contactName.length === 0) {
102
+ throw new Error("BigQuery lead row missing required name fields");
103
+ }
104
+ const companySize = getOptionalString(row, "companySize");
105
+ const country = String(row.country ?? "").trim();
106
+ const region = String(row.region ?? "").trim();
107
+ return {
108
+ companyName: requireString(row, "companyName"),
109
+ domain: requireString(row, "domain"),
110
+ industry: requireString(row, "industry"),
111
+ region: deriveRegion(country, region),
112
+ employeeCount: employeeCountFromBucket(companySize ?? ""),
113
+ contactName,
114
+ title: requireString(row, "title"),
115
+ email: requireString(row, "email"),
116
+ source: "bigquery-leadpool",
117
+ signals: []
118
+ };
119
+ });
120
+ }
121
+ export function buildBigQueryLeadLookupSql(icp, options) {
122
+ const filters = [
123
+ buildContainsClause(options.titleField, icp.titles),
124
+ buildInClause(options.industryField, icp.industries),
125
+ options.regionField ? buildInClause(options.regionField, icp.regions) : null,
126
+ buildCountryClause(options.countryField, icp.countries),
127
+ buildCompanySizeClause(options.companySizeField, icp.companySizes)
128
+ ].filter((value) => value !== null);
129
+ if (options.useSalesprompterGuards) {
130
+ filters.push(`${options.emailField} IS NOT NULL`, `COALESCE(email_invalid, FALSE) = FALSE`, `COALESCE(company_blacklisted, FALSE) = FALSE`, `COALESCE(company_inSequence, FALSE) = FALSE`, `COALESCE(contact_replied, FALSE) = FALSE`, `COALESCE(contact_bounced, FALSE) = FALSE`, `COALESCE(jobTitle_blacklisted, FALSE) = FALSE`);
131
+ }
132
+ const keywordSearchExpression = options.keywordFields.length > 0
133
+ ? `CONCAT(${options.keywordFields.map((field) => `COALESCE(CAST(${field} AS STRING), '')`).join(", ' ', ")})`
134
+ : null;
135
+ const keywordClause = keywordSearchExpression ? buildContainsClause(keywordSearchExpression, icp.keywords) : null;
136
+ if (keywordClause !== null) {
137
+ filters.push(keywordClause);
138
+ }
139
+ const excludedKeywordClause = keywordSearchExpression ? buildContainsClause(keywordSearchExpression, icp.excludedKeywords) : null;
140
+ if (excludedKeywordClause !== null) {
141
+ filters.push(`NOT ${excludedKeywordClause}`);
142
+ }
143
+ if (options.additionalWhere !== undefined && options.additionalWhere.trim().length > 0) {
144
+ filters.push(`(${options.additionalWhere.trim()})`);
145
+ }
146
+ const whereClause = filters.length > 0 ? filters.join("\n AND ") : "TRUE";
147
+ return [
148
+ "SELECT",
149
+ ` ${options.companyField} AS companyName,`,
150
+ ` ${options.domainField} AS domain,`,
151
+ ` ${options.titleField} AS title,`,
152
+ ` ${options.firstNameField} AS firstName,`,
153
+ ` ${options.lastNameField} AS lastName,`,
154
+ ` ${options.emailField} AS email,`,
155
+ ` ${options.industryField} AS industry,`,
156
+ ` ${options.companySizeField} AS companySize,`,
157
+ ` ${options.countryField} AS country,`,
158
+ options.regionField ? ` ${options.regionField} AS region` : " CAST(NULL AS STRING) AS region",
159
+ `FROM \`${options.table}\``,
160
+ "WHERE",
161
+ ` ${whereClause}`,
162
+ `LIMIT ${options.limit}`
163
+ ].join("\n");
164
+ }
165
+ export async function runBigQueryQuery(sql, options = {}) {
166
+ const args = [
167
+ "query",
168
+ "--use_legacy_sql=false",
169
+ "--format=prettyjson",
170
+ `--project_id=${DEFAULT_BQ_PROJECT_ID}`
171
+ ];
172
+ if (options.maxRows !== undefined) {
173
+ args.push(`--max_rows=${options.maxRows}`);
174
+ }
175
+ args.push(sql);
176
+ const { stdout } = await execFileAsync("bq", args, { maxBuffer: BQ_EXEC_MAX_BUFFER });
177
+ return JSON.parse(stdout);
178
+ }
179
+ export async function executeBigQuerySql(sql, options = {}) {
180
+ const args = [
181
+ "query",
182
+ "--use_legacy_sql=false",
183
+ "--format=prettyjson",
184
+ `--project_id=${DEFAULT_BQ_PROJECT_ID}`
185
+ ];
186
+ if (options.maxRows !== undefined) {
187
+ args.push(`--max_rows=${options.maxRows}`);
188
+ }
189
+ args.push(sql);
190
+ const { stdout } = await execFileAsync("bq", args, { maxBuffer: BQ_EXEC_MAX_BUFFER });
191
+ return stdout.trim();
192
+ }
193
+ export async function runBigQueryRows(sql, options = {}) {
194
+ const result = await runBigQueryQuery(sql, options);
195
+ if (!Array.isArray(result)) {
196
+ throw new Error("expected BigQuery query result to be an array");
197
+ }
198
+ return result;
199
+ }