@dench.com/cli 3.5.8-staging.350 → 3.6.0-staging.356

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.
@@ -0,0 +1,1564 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { open, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import type { ConvexHttpClient } from "convex/browser";
4
+ import { makeFunctionReference } from "convex/server";
5
+ import {
6
+ assertIanaTimezone,
7
+ consumeScheduleFlags,
8
+ consumeSenderPoolFlags,
9
+ formatWeekdays,
10
+ hasScheduleChanges,
11
+ localTimezone,
12
+ MAX_CAMPAIGN_PEOPLE,
13
+ type SendSchedule,
14
+ schedulePatchFromFlags,
15
+ } from "../campaign";
16
+ import { apiHostForDenchHost } from "../host";
17
+ import { splitListValue } from "../leads";
18
+ import { campaignAudienceFiltersSchema } from "./api-schemas";
19
+ import {
20
+ COLUMN_MAP_USAGE,
21
+ type CsvColumn,
22
+ detectDelimiter,
23
+ MAX_CSV_CHARS,
24
+ MAX_CSV_RECIPIENTS,
25
+ parseColumnMap,
26
+ parseRecipientTable,
27
+ type RecipientTable,
28
+ recipientsFromTable,
29
+ withDetectedFullName,
30
+ } from "./campaign-csv";
31
+ import {
32
+ excludeFlagsFor,
33
+ FILTER_KEY_ALIASES,
34
+ SEARCH_LIST_FLAGS,
35
+ SEARCH_RANGE_FLAGS,
36
+ SEARCH_TEXT_FLAGS,
37
+ usesSearchFlags,
38
+ } from "./campaign-filters";
39
+ import {
40
+ assertNoUnknownFlags,
41
+ getFlag,
42
+ hasFlag,
43
+ parseLimit,
44
+ shift,
45
+ } from "./cli-args";
46
+ import { apiOperations } from "./command-registry";
47
+ import { FIND_FILTER_OPTION_CATALOG } from "./find-filter-options.generated";
48
+ import { closestMatch } from "./levenshtein";
49
+ import { backendErrorText } from "./public-error";
50
+ import {
51
+ formatCents,
52
+ SEARCH_RESULT_ROW_CENTS,
53
+ searchRowsCents,
54
+ } from "./search-pricing";
55
+
56
+ type Context = {
57
+ convex: ConvexHttpClient;
58
+ args: string[];
59
+ jsonOutput: boolean;
60
+ sessionToken?: string;
61
+ host?: string;
62
+ resolveCampaign: (reference: string) => Promise<CampaignRef>;
63
+ };
64
+
65
+ type CampaignRef = {
66
+ _id: string;
67
+ name: string;
68
+ status: string;
69
+ totalEnrolled?: number;
70
+ };
71
+
72
+ function value(args: string[], flag: string): string | undefined {
73
+ const present = args.includes(flag);
74
+ const result = getFlag(args, flag);
75
+ if (present && (!result || result.startsWith("--")))
76
+ throw new Error(`${flag} needs a value`);
77
+ return result;
78
+ }
79
+
80
+ function finishArgs(args: string[], command: string) {
81
+ assertNoUnknownFlags(args, `campaign ${command}`);
82
+ if (args.length)
83
+ throw new Error(
84
+ `Unexpected arguments for campaign ${command}: ${args.join(" ")}`,
85
+ );
86
+ }
87
+
88
+ async function json(text: string): Promise<unknown> {
89
+ const raw = text.startsWith("@")
90
+ ? await readFile(text.slice(1), "utf8")
91
+ : text;
92
+ try {
93
+ return JSON.parse(raw);
94
+ } catch {
95
+ throw new Error("Expected valid JSON, or @path/to/file.json");
96
+ }
97
+ }
98
+
99
+ async function objectInput(args: string[]): Promise<Record<string, unknown>> {
100
+ const data = value(args, "--data");
101
+ const file = value(args, "--file");
102
+ if (data && file) throw new Error("Use --data or --file, not both");
103
+ const result = data ? await json(data) : file ? await json(`@${file}`) : {};
104
+ if (!result || typeof result !== "object" || Array.isArray(result))
105
+ throw new Error("Expected a JSON object");
106
+ return result as Record<string, unknown>;
107
+ }
108
+
109
+ function validate(operationId: string, input: Record<string, unknown>) {
110
+ const operation = apiOperations.find(
111
+ (candidate) => candidate.id === operationId,
112
+ );
113
+ if (!operation) throw new Error(`Unknown campaign operation: ${operationId}`);
114
+ const parsed = operation.requestSchema.safeParse(input);
115
+ if (!parsed.success)
116
+ throw new Error(
117
+ parsed.error.issues
118
+ .map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`)
119
+ .join("; "),
120
+ );
121
+ return { operation, input: parsed.data as Record<string, unknown> };
122
+ }
123
+
124
+ async function invoke(
125
+ ctx: Context,
126
+ operationId: string,
127
+ raw: Record<string, unknown>,
128
+ ): Promise<unknown> {
129
+ const { operation, input } = validate(operationId, raw);
130
+ const backend = operation.backend;
131
+ if (backend.kind === "convex") {
132
+ const args = {
133
+ ...(backend.transformArgs ? backend.transformArgs(input) : input),
134
+ ...backend.fixedArgs,
135
+ ...(backend.tokenArg && ctx.sessionToken
136
+ ? { [backend.tokenArg]: ctx.sessionToken }
137
+ : {}),
138
+ };
139
+ if (backend.functionType === "query")
140
+ return ctx.convex.query(
141
+ makeFunctionReference<"query">(backend.functionName),
142
+ args,
143
+ );
144
+ if (backend.functionType === "mutation")
145
+ return ctx.convex.mutation(
146
+ makeFunctionReference<"mutation">(backend.functionName),
147
+ args,
148
+ );
149
+ return ctx.convex.action(
150
+ makeFunctionReference<"action">(backend.functionName),
151
+ args,
152
+ );
153
+ }
154
+ if (backend.kind !== "custom" || backend.handler !== "campaignBuilder")
155
+ throw new Error("Unsupported campaign operation");
156
+ if (!ctx.sessionToken)
157
+ throw new Error(
158
+ "Sign in with dench signin before using the campaign builder",
159
+ );
160
+ const path = operation.path.replace(/\{([^}]+)\}/g, (_, key: string) =>
161
+ encodeURIComponent(String(input[key])),
162
+ );
163
+ const response = await fetch(
164
+ `${apiHostForDenchHost(ctx.host ?? process.env.DENCH_HOST)}/api/v1${path}`,
165
+ {
166
+ method: operation.method,
167
+ headers: {
168
+ "Content-Type": "application/json",
169
+ Authorization: `Bearer ${ctx.sessionToken}`,
170
+ },
171
+ body: JSON.stringify(input),
172
+ redirect: "error",
173
+ signal: AbortSignal.timeout(120_000),
174
+ },
175
+ );
176
+ const body = (await response.json()) as Record<string, unknown>;
177
+ if (!response.ok)
178
+ throw new Error(
179
+ typeof body.error === "string" ? body.error : JSON.stringify(body),
180
+ );
181
+ return body;
182
+ }
183
+
184
+ function print(ctx: Context, result: unknown, summary?: string) {
185
+ if (summary && !ctx.jsonOutput) console.error(summary);
186
+ console.log(JSON.stringify(result, null, 2));
187
+ }
188
+
189
+ function notice(message: string) {
190
+ console.error(`Note: ${message}`);
191
+ }
192
+
193
+ type Recipient = {
194
+ email?: string;
195
+ linkedinProfileUrl?: string;
196
+ displayName?: string;
197
+ personalization?: Record<string, unknown>;
198
+ };
199
+
200
+ type RecipientField = "email" | "linkedinProfileUrl" | "displayName";
201
+
202
+ const RECIPIENT_ALIASES: Record<string, RecipientField> = {
203
+ email: "email",
204
+ emailaddress: "email",
205
+ workemail: "email",
206
+ linkedinprofileurl: "linkedinProfileUrl",
207
+ linkedinurl: "linkedinProfileUrl",
208
+ linkedin: "linkedinProfileUrl",
209
+ linkedinprofile: "linkedinProfileUrl",
210
+ displayname: "displayName",
211
+ name: "displayName",
212
+ fullname: "displayName",
213
+ };
214
+
215
+ const MAX_PEOPLE_FILE_RECIPIENTS = 300;
216
+ const PEOPLE_BATCH_SIZE = 50;
217
+
218
+ function aliasKey(key: string): string {
219
+ return key.toLowerCase().replace(/[^a-z0-9]/g, "");
220
+ }
221
+
222
+ function isScalar(value: unknown): value is string | number | boolean {
223
+ return (
224
+ typeof value === "string" ||
225
+ typeof value === "boolean" ||
226
+ (typeof value === "number" && Number.isFinite(value))
227
+ );
228
+ }
229
+
230
+ function isBlank(value: unknown): boolean {
231
+ return (
232
+ value === undefined ||
233
+ value === null ||
234
+ (typeof value === "string" && !value.trim())
235
+ );
236
+ }
237
+
238
+ export function recipientFromRow(row: unknown, label: string): Recipient {
239
+ if (!row || typeof row !== "object" || Array.isArray(row))
240
+ throw new Error(
241
+ `${label} must be an object like {"email":"ada@example.com","linkedinProfileUrl":"https://linkedin.com/in/ada","displayName":"Ada Lovelace"}`,
242
+ );
243
+ const recipient: Recipient = {};
244
+ const seen: Partial<Record<RecipientField, string>> = {};
245
+ const extra: Record<string, unknown> = {};
246
+ let personalization: Record<string, unknown> | undefined;
247
+ let first = "";
248
+ let last = "";
249
+ for (const [key, value] of Object.entries(row as Record<string, unknown>)) {
250
+ if (key === "personalization") {
251
+ if (isBlank(value)) continue;
252
+ if (!value || typeof value !== "object" || Array.isArray(value))
253
+ throw new Error(
254
+ `${label}.personalization must be an object of merge values, e.g. {"Company":"Acme"}`,
255
+ );
256
+ personalization = {};
257
+ for (const [name, item] of Object.entries(
258
+ value as Record<string, unknown>,
259
+ )) {
260
+ if (isBlank(item)) continue;
261
+ if (!isScalar(item))
262
+ throw new Error(
263
+ `${label}.personalization.${name} must be text, a number or true/false`,
264
+ );
265
+ personalization[name] = item;
266
+ }
267
+ continue;
268
+ }
269
+ const field = RECIPIENT_ALIASES[aliasKey(key)];
270
+ if (field) {
271
+ if (isBlank(value)) continue;
272
+ if (typeof value !== "string")
273
+ throw new Error(`${label}.${key} must be text`);
274
+ const trimmed = value.trim();
275
+ const previous = seen[field];
276
+ if (previous !== undefined && recipient[field] !== trimmed)
277
+ throw new Error(
278
+ `${label} has both ${previous} and ${key} with different values; keep one`,
279
+ );
280
+ seen[field] = key;
281
+ recipient[field] = trimmed;
282
+ continue;
283
+ }
284
+ if (isBlank(value)) continue;
285
+ if (!isScalar(value))
286
+ throw new Error(
287
+ `${label}.${key} is a list or object. Only text, numbers and true/false become merge values; flatten it, or pass the recipients array from dench campaign search`,
288
+ );
289
+ const kept = typeof value === "string" ? value.trim() : value;
290
+ if (typeof kept === "string" && aliasKey(key) === "firstname") first = kept;
291
+ if (typeof kept === "string" && aliasKey(key) === "lastname") last = kept;
292
+ extra[/^(crm|person):./i.test(key) ? key : `crm:${key}`] = kept;
293
+ }
294
+ if (!recipient.displayName) {
295
+ const composed = [first, last].filter(Boolean).join(" ");
296
+ if (composed) recipient.displayName = composed;
297
+ }
298
+ const merged = { ...extra, ...personalization };
299
+ if (Object.keys(merged).length) recipient.personalization = merged;
300
+ return recipient;
301
+ }
302
+
303
+ export function recipientsFromPeopleFile(
304
+ parsed: unknown,
305
+ file: string,
306
+ ): Recipient[] {
307
+ const rows = Array.isArray(parsed)
308
+ ? parsed
309
+ : parsed && typeof parsed === "object" && "recipients" in parsed
310
+ ? (parsed as { recipients: unknown }).recipients
311
+ : null;
312
+ if (!Array.isArray(rows)) {
313
+ const keys =
314
+ parsed && typeof parsed === "object" ? Object.keys(parsed) : [];
315
+ throw new Error(
316
+ `${file} must be a JSON array of people or a dench campaign search result with a recipients array${keys.length ? ` (found keys: ${keys.slice(0, 8).join(", ")})` : ""}`,
317
+ );
318
+ }
319
+ if (!rows.length || rows.length > MAX_PEOPLE_FILE_RECIPIENTS)
320
+ throw new Error(
321
+ `${file} must contain 1-${MAX_PEOPLE_FILE_RECIPIENTS} people (found ${rows.length}). A campaign holds at most ${MAX_PEOPLE_FILE_RECIPIENTS}.`,
322
+ );
323
+ return rows.map((row, index) =>
324
+ recipientFromRow(row, `recipients[${index}]`),
325
+ );
326
+ }
327
+
328
+ const BATCH_COUNTS = [
329
+ "added",
330
+ "requested",
331
+ "duplicates",
332
+ "duplicatesElsewhere",
333
+ "suppressed",
334
+ "invalid",
335
+ "skippedNoIdentity",
336
+ "skippedMissingInfo",
337
+ ] as const;
338
+
339
+ type BatchCounts = Record<(typeof BATCH_COUNTS)[number], number>;
340
+
341
+ function batchCounts(result: unknown): BatchCounts {
342
+ const record = (result ?? {}) as Record<string, unknown>;
343
+ return Object.fromEntries(
344
+ BATCH_COUNTS.map((key) => [
345
+ key,
346
+ typeof record[key] === "number" ? (record[key] as number) : 0,
347
+ ]),
348
+ ) as BatchCounts;
349
+ }
350
+
351
+ function considered(counts: BatchCounts): number {
352
+ return (
353
+ counts.added +
354
+ counts.duplicates +
355
+ counts.duplicatesElsewhere +
356
+ counts.suppressed +
357
+ counts.invalid +
358
+ counts.skippedNoIdentity +
359
+ counts.skippedMissingInfo
360
+ );
361
+ }
362
+
363
+ const FILTER_KEYS = Object.keys(campaignAudienceFiltersSchema.shape);
364
+ const SEARCH_LIMIT = { search: 100, add: 50 } as const;
365
+
366
+ function resolveFilterKey(normalized: string): string | undefined {
367
+ return (
368
+ FILTER_KEYS.find((key) => aliasKey(key) === normalized) ??
369
+ FILTER_KEY_ALIASES[normalized] ??
370
+ (normalized.length >= 4
371
+ ? FILTER_KEYS.find((key) => aliasKey(key).startsWith(normalized))
372
+ : undefined) ??
373
+ closestMatch(normalized, FILTER_KEYS) ??
374
+ undefined
375
+ );
376
+ }
377
+
378
+ function unknownFilterMessage(key: string): string {
379
+ const normalized = aliasKey(key);
380
+ const stem = normalized
381
+ .replace(
382
+ /^(exclude[ds]?|excluding|exclusions?|not|no|without|omit|except)(?=[a-z])/,
383
+ "",
384
+ )
385
+ .replace(/(?<=[a-z])(exclude[ds]?|excluding|exclusions?|not)$/, "");
386
+ const stemTarget = stem !== normalized ? resolveFilterKey(stem) : undefined;
387
+ if (
388
+ stemTarget ||
389
+ /^(exclude[ds]?|excluding|exclusions?|blocklist|blacklist)$/.test(
390
+ normalized,
391
+ )
392
+ )
393
+ return `"${key}" is not a filter. To exclude values, put {"value":"…","exclude":true} inside that filter's list, e.g. {"${stemTarget ?? "industries"}":[{"value":"${stemTarget ? "…" : "Staffing and Recruiting"}","exclude":true}]}.`;
394
+ const target = resolveFilterKey(normalized);
395
+ return `Unknown search filter "${key}"${target ? `. Did you mean "${target}"?` : "."}`;
396
+ }
397
+
398
+ function vocabularyFor(catalogKey: string | undefined): string[] | undefined {
399
+ return catalogKey
400
+ ? FIND_FILTER_OPTION_CATALOG[catalogKey]?.groups.flatMap(
401
+ (group) => group.values,
402
+ )
403
+ : undefined;
404
+ }
405
+
406
+ function splitFlagValue(
407
+ flag: string,
408
+ key: string,
409
+ raw: string,
410
+ catalogKey: string | undefined,
411
+ ): string[] {
412
+ const parts = splitListValue(raw, vocabularyFor(catalogKey));
413
+ if (parts.length < 2 || key === "companyDomains") return parts;
414
+ const entry = catalogKey ? FIND_FILTER_OPTION_CATALOG[catalogKey] : undefined;
415
+ const known = new Set((vocabularyFor(catalogKey) ?? []).map(itemText));
416
+ if (
417
+ entry?.kind === "enum" &&
418
+ parts.every((part) => known.has(itemText(part)))
419
+ )
420
+ return parts;
421
+ notice(
422
+ `${flag} "${raw.trim()}" was split at commas into ${parts.length} values: ${parts.map((part) => `"${part}"`).join(", ")}. If a comma belongs inside one value, wrap it in double quotes: ${flag} '"${parts[0]}, ${parts[1]}"' (or use --filters JSON).`,
423
+ );
424
+ return parts;
425
+ }
426
+
427
+ function itemText(item: unknown): string {
428
+ const text =
429
+ typeof item === "string"
430
+ ? item
431
+ : item && typeof item === "object"
432
+ ? String((item as { value?: unknown }).value ?? "")
433
+ : "";
434
+ return text.trim().toLowerCase();
435
+ }
436
+
437
+ function onlyExclusions(filters: Record<string, unknown>): boolean {
438
+ const entries = Object.entries(filters).filter(
439
+ ([key]) => !["exactMatch", "preferCache"].includes(key),
440
+ );
441
+ if (!entries.length) return false;
442
+ return entries.every(
443
+ ([, list]) =>
444
+ Array.isArray(list) &&
445
+ list.length > 0 &&
446
+ list.every(
447
+ (item) =>
448
+ item &&
449
+ typeof item === "object" &&
450
+ (item as { exclude?: unknown }).exclude === true,
451
+ ),
452
+ );
453
+ }
454
+
455
+ function rangeValue(raw: string, flag: string): { min?: number; max?: number } {
456
+ const [minRaw, maxRaw = ""] = raw.includes(":")
457
+ ? raw.split(":", 2)
458
+ : [raw, ""];
459
+ const part = (text: string, label: string) => {
460
+ const trimmed = text.trim();
461
+ if (!trimmed) return undefined;
462
+ const parsed = Number(trimmed);
463
+ if (!Number.isInteger(parsed) || parsed < 0)
464
+ throw new Error(
465
+ `${flag} ${label} must be a whole number of 0 or more (min:max, e.g. 2:10)`,
466
+ );
467
+ return parsed;
468
+ };
469
+ const min = part(minRaw, "min");
470
+ const max = part(maxRaw, "max");
471
+ if (min === undefined && max === undefined)
472
+ throw new Error(`${flag} needs min:max, e.g. 2:10, 2: or :10`);
473
+ return {
474
+ ...(min !== undefined ? { min } : {}),
475
+ ...(max !== undefined ? { max } : {}),
476
+ };
477
+ }
478
+
479
+ async function searchRequest(args: string[], command: "search" | "add") {
480
+ const filtersRaw = value(args, "--filters");
481
+ const searchRaw = value(args, "--search");
482
+ if (filtersRaw !== undefined && searchRaw !== undefined)
483
+ throw new Error(
484
+ "Use --filters or --search, not both; they take the same JSON",
485
+ );
486
+ const raw = filtersRaw ?? searchRaw;
487
+ const parsed = raw ? await json(raw) : {};
488
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
489
+ throw new Error(
490
+ 'Search filters must be a JSON object, e.g. {"titles":["Founder"],"locations":["United States"]}',
491
+ );
492
+ const filters = { ...parsed } as Record<string, unknown>;
493
+ const unknown = Object.keys(filters).filter(
494
+ (key) => !FILTER_KEYS.includes(key),
495
+ );
496
+ if (unknown.length)
497
+ throw new Error(
498
+ `${unknown.map(unknownFilterMessage).join("\n")}\nAllowed filters: ${FILTER_KEYS.join(", ")}.`,
499
+ );
500
+ for (const [flag, key, catalogKey] of SEARCH_LIST_FLAGS) {
501
+ const values: unknown[] = [];
502
+ const excluded: string[] = [];
503
+ while (args.includes(flag)) {
504
+ const selected = value(args, flag) ?? "";
505
+ values.push(...splitFlagValue(flag, key, selected, catalogKey));
506
+ }
507
+ for (const excludeFlag of excludeFlagsFor(flag)) {
508
+ while (args.includes(excludeFlag)) {
509
+ const selected = value(args, excludeFlag) ?? "";
510
+ excluded.push(
511
+ ...splitFlagValue(excludeFlag, key, selected, catalogKey),
512
+ );
513
+ }
514
+ }
515
+ if (!values.length && !excluded.length) continue;
516
+ const existing = filters[key];
517
+ if (existing !== undefined && !Array.isArray(existing))
518
+ throw new Error(
519
+ `"${key}" in the JSON must be a list to combine with ${flag}`,
520
+ );
521
+ const kept = new Set(
522
+ ((existing as unknown[] | undefined) ?? [])
523
+ .filter(
524
+ (item) =>
525
+ item &&
526
+ typeof item === "object" &&
527
+ (item as { exclude?: unknown }).exclude === true,
528
+ )
529
+ .map((item) => itemText(item)),
530
+ );
531
+ const added = excluded.filter((item) => {
532
+ const text = itemText(item);
533
+ if (kept.has(text)) return false;
534
+ kept.add(text);
535
+ return true;
536
+ });
537
+ const list = [
538
+ ...((existing as unknown[] | undefined) ?? []),
539
+ ...values,
540
+ ...added.map((item) => ({ value: item, exclude: true })),
541
+ ];
542
+ const includedText = new Set(
543
+ list
544
+ .filter(
545
+ (item) =>
546
+ !(
547
+ item &&
548
+ typeof item === "object" &&
549
+ (item as { exclude?: unknown }).exclude === true
550
+ ),
551
+ )
552
+ .map((item) => itemText(item)),
553
+ );
554
+ const clash = [...kept].find((text) => includedText.has(text));
555
+ if (clash)
556
+ throw new Error(
557
+ `"${clash}" is both included and excluded in ${key}; keep one`,
558
+ );
559
+ filters[key] = list;
560
+ }
561
+ const single = (flag: string, key: string, next: unknown) => {
562
+ if (filters[key] !== undefined)
563
+ throw new Error(`"${key}" is set in the JSON and by ${flag}; keep one`);
564
+ filters[key] = next;
565
+ };
566
+ for (const [flag, key] of SEARCH_TEXT_FLAGS) {
567
+ const selected = value(args, flag);
568
+ if (selected !== undefined) single(flag, key, selected);
569
+ }
570
+ for (const [flag, key] of SEARCH_RANGE_FLAGS) {
571
+ const selected = value(args, flag);
572
+ if (selected !== undefined) single(flag, key, rangeValue(selected, flag));
573
+ }
574
+ if (hasFlag(args, "--exact")) filters.exactMatch = true;
575
+ if (!Object.keys(filters).length)
576
+ throw new Error(
577
+ `Provide --filters JSON or at least one filter flag, e.g. dench campaign ${command === "add" ? "add <campaign>" : "search"} --titles Founder --locations "United States"`,
578
+ );
579
+ if (onlyExclusions(filters))
580
+ throw new Error(
581
+ `A search needs something to look for, not only values to leave out (${Object.keys(filters).join(", ")}). Add an include filter, e.g. --titles Founder.`,
582
+ );
583
+ const cap = SEARCH_LIMIT[command];
584
+ let limit: ReturnType<typeof parseLimit>;
585
+ try {
586
+ limit = parseLimit(value(args, "--limit"), {
587
+ defaultValue: 50,
588
+ cap,
589
+ command: `campaign ${command}`,
590
+ });
591
+ } catch (error) {
592
+ throw new Error(error instanceof Error ? error.message : String(error));
593
+ }
594
+ if (limit.clamped)
595
+ notice(
596
+ `--limit ${limit.requested} is above the ${cap} per call maximum for campaign ${command}; using ${cap}. Continue with --page-token for more.`,
597
+ );
598
+ const pageToken = value(args, "--page-token");
599
+ return {
600
+ filters,
601
+ limit: limit.effective,
602
+ ...(pageToken ? { pageToken } : {}),
603
+ };
604
+ }
605
+
606
+ const LIVE_EFFECT: Record<string, string> = {
607
+ active:
608
+ "people added now start its steps right away and real messages go out",
609
+ paused: "people added now start its steps as soon as it resumes",
610
+ completed:
611
+ "adding people restarts it, and their steps and real messages start right away",
612
+ };
613
+
614
+ function liveEffect(status: string): string {
615
+ return LIVE_EFFECT[status] ?? "people added now start its steps";
616
+ }
617
+
618
+ async function addableCampaign(ctx: Context, reference: string) {
619
+ const campaign = await ctx.resolveCampaign(reference);
620
+ if (campaign.status === "archived")
621
+ throw new Error(
622
+ `"${campaign.name}" is archived; people can't be added to it.`,
623
+ );
624
+ return campaign;
625
+ }
626
+
627
+ type SearchPage = Record<string, unknown> & {
628
+ estimatedCents: number | null;
629
+ chargedCents: number | null;
630
+ };
631
+
632
+ function withSearchCharge(result: Record<string, unknown>): SearchPage {
633
+ const { creditsCharged: _providerCredits, ...page } = result;
634
+ const cents = (value: unknown) =>
635
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0
636
+ ? value
637
+ : null;
638
+ return {
639
+ ...page,
640
+ estimatedCents: cents(page.estimatedCents),
641
+ chargedCents: cents(page.chargedCents),
642
+ };
643
+ }
644
+
645
+ function searchChargeLabel(page: SearchPage): string {
646
+ return page.chargedCents === null
647
+ ? "charge not reported"
648
+ : `charged ${formatCents(page.chargedCents)}`;
649
+ }
650
+
651
+ function pageParts(page: SearchPage) {
652
+ const rows = Array.isArray(page.rows) ? page.rows : [];
653
+ const recipients = Array.isArray(page.recipients) ? page.recipients : [];
654
+ const metadata =
655
+ page.metadata && typeof page.metadata === "object"
656
+ ? (page.metadata as Record<string, unknown>)
657
+ : {};
658
+ const nextPageToken =
659
+ typeof metadata.nextPageToken === "string" && metadata.nextPageToken
660
+ ? metadata.nextPageToken
661
+ : null;
662
+ return { rows, recipients, metadata, nextPageToken };
663
+ }
664
+
665
+ function costNotice(rows: number) {
666
+ notice(
667
+ `Search cost: up to ${plural(rows, "row", "rows")} × ${SEARCH_RESULT_ROW_CENTS}¢ = ${formatCents(searchRowsCents(rows))}, billed per row returned.`,
668
+ );
669
+ }
670
+
671
+ function outputOptions(args: string[]) {
672
+ const raw = hasFlag(args, "--raw");
673
+ const out = value(args, "--out");
674
+ const force = hasFlag(args, "--force");
675
+ if (raw && out)
676
+ throw new Error("--out already writes the full result; drop --raw");
677
+ if (force && !out) throw new Error("--force only applies to --out");
678
+ return { raw, out, force };
679
+ }
680
+
681
+ function writeFailure(error: unknown): string {
682
+ const code = (error as { code?: unknown } | null)?.code;
683
+ if (code === "ENOENT") return "its folder does not exist";
684
+ if (code === "ENOTDIR") return "part of the path is not a folder";
685
+ if (code === "EACCES" || code === "EPERM" || code === "EROFS")
686
+ return "no permission to write there";
687
+ if (code === "ENOSPC") return "the disk is full";
688
+ return error instanceof Error ? error.message : String(error);
689
+ }
690
+
691
+ async function claimOut(output: { out?: string; force: boolean }) {
692
+ const file = output.out;
693
+ if (!file) return;
694
+ const existing = await stat(file).catch(() => null);
695
+ if (existing?.isDirectory())
696
+ throw new Error(
697
+ `--out ${file} is a folder; give a file name. Nothing was searched or charged.`,
698
+ );
699
+ if (existing && !output.force)
700
+ throw new Error(
701
+ `${file} already exists and --out would replace it. Use another file name (for the next page, e.g. audience-2.json) or add --force to replace it. Nothing was searched or charged.`,
702
+ );
703
+ try {
704
+ await (await open(file, "a")).close();
705
+ } catch (error) {
706
+ throw new Error(
707
+ `Cannot write ${file}: ${writeFailure(error)}. Nothing was searched or charged.`,
708
+ );
709
+ }
710
+ if (!existing) await rm(file, { force: true });
711
+ }
712
+
713
+ async function saveOut(file: string, payload: unknown): Promise<string | null> {
714
+ try {
715
+ await writeFile(file, `${JSON.stringify(payload, null, 2)}\n`);
716
+ return null;
717
+ } catch (error) {
718
+ return writeFailure(error);
719
+ }
720
+ }
721
+
722
+ function searchOutput(page: SearchPage) {
723
+ const { rows, recipients, metadata, nextPageToken } = pageParts(page);
724
+ return {
725
+ count: rows.length,
726
+ rows,
727
+ recipients,
728
+ metadata,
729
+ nextPageToken,
730
+ estimatedCents: page.estimatedCents,
731
+ chargedCents: page.chargedCents,
732
+ };
733
+ }
734
+
735
+ function searchView(
736
+ output: { raw: boolean; out?: string },
737
+ scope: Record<string, unknown>,
738
+ page: SearchPage,
739
+ full: Record<string, unknown>,
740
+ saveError: string | null,
741
+ ) {
742
+ if (saveError) return { ...full, saveError };
743
+ if (output.raw) return full;
744
+ return {
745
+ ...scope,
746
+ ...(output.out ? savedOutput(output.out, page) : searchOutput(page)),
747
+ };
748
+ }
749
+
750
+ function savedOutput(file: string, page: SearchPage) {
751
+ const { rows, recipients, nextPageToken } = pageParts(page);
752
+ return {
753
+ savedTo: file,
754
+ count: rows.length,
755
+ recipientCount: recipients.length,
756
+ nextPageToken,
757
+ estimatedCents: page.estimatedCents,
758
+ chargedCents: page.chargedCents,
759
+ };
760
+ }
761
+
762
+ function addCounts(result: Record<string, unknown>) {
763
+ return {
764
+ ...batchCounts(result),
765
+ cappedAtLimit:
766
+ typeof result.cappedAtLimit === "number" ? result.cappedAtLimit : null,
767
+ };
768
+ }
769
+
770
+ function searchSummary(page: SearchPage) {
771
+ const { rows, recipients, nextPageToken } = pageParts(page);
772
+ return `Found ${plural(rows.length, "person", "people")} (${recipients.length} with an email or LinkedIn URL) · ${searchChargeLabel(page)}${nextPageToken ? ` · more: --page-token ${nextPageToken}` : ""}`;
773
+ }
774
+
775
+ function allValues(args: string[], flag: string): string[] {
776
+ const found: string[] = [];
777
+ while (args.includes(flag)) found.push(value(args, flag) ?? "");
778
+ return found;
779
+ }
780
+
781
+ async function add(ctx: Context) {
782
+ const file = value(ctx.args, "--people-file");
783
+ const csv = value(ctx.args, "--csv");
784
+ const list = value(ctx.args, "--list");
785
+ const maps = allValues(ctx.args, "--map");
786
+ const allowDuplicates = hasFlag(ctx.args, "--allow-duplicates");
787
+ const allowActive = hasFlag(ctx.args, "--allow-active");
788
+ const preview = hasFlag(ctx.args, "--preview");
789
+ const sources = [
790
+ ["--people-file", file],
791
+ ["--csv", csv],
792
+ ["--list", list],
793
+ ].filter(([, given]) => given !== undefined);
794
+ if (sources.length > 1)
795
+ throw new Error(
796
+ `Use one of ${sources.map(([flag]) => flag).join(", ")}, not several`,
797
+ );
798
+ if (maps.length && csv === undefined)
799
+ throw new Error("--map only applies to --csv");
800
+ if (list === undefined && ctx.args.includes("--enrichment"))
801
+ throw new Error("--enrichment only applies to --list");
802
+ if (list !== undefined) {
803
+ const enrichment = value(ctx.args, "--enrichment");
804
+ const limitRaw = value(ctx.args, "--limit");
805
+ if (usesSearchFlags(ctx.args))
806
+ throw new Error("Use --list or search filters, not both");
807
+ const reference = shift(ctx.args, "campaign id or name");
808
+ finishArgs(ctx.args, "add");
809
+ return addList(ctx, list, reference, {
810
+ allowDuplicates,
811
+ preview,
812
+ enrichment,
813
+ limitRaw,
814
+ });
815
+ }
816
+ if (csv !== undefined) {
817
+ if (usesSearchFlags(ctx.args))
818
+ throw new Error("Use --csv or search filters, not both");
819
+ const reference = shift(ctx.args, "campaign id or name");
820
+ finishArgs(ctx.args, "add");
821
+ return addCsv(ctx, csv, maps, reference, { allowDuplicates, preview });
822
+ }
823
+ if (file && (preview || usesSearchFlags(ctx.args)))
824
+ throw new Error("Use --people-file or search filters, not both");
825
+ const input = file ? null : await searchRequest(ctx.args, "add");
826
+ const output = file ? null : outputOptions(ctx.args);
827
+ const reference = shift(ctx.args, "campaign id or name");
828
+ finishArgs(ctx.args, "add");
829
+ if (input && output) {
830
+ const operation = preview
831
+ ? "campaigns.audience.search"
832
+ : "campaigns.people.addFromSearch";
833
+ const body = {
834
+ ...input,
835
+ ...(!preview ? { checkDuplicatesAcrossWorkspace: !allowDuplicates } : {}),
836
+ };
837
+ validate(operation, { ...body, sequenceId: "pending" });
838
+ await claimOut(output);
839
+ const campaign = await addableCampaign(ctx, reference);
840
+ if (!preview && campaign.status !== "draft" && !allowActive)
841
+ throw new Error(
842
+ `"${campaign.name}" is ${campaign.status}, so ${liveEffect(campaign.status)}. Nothing was searched or charged. Re-run with --allow-active to search and add anyway, or with --preview to only look.`,
843
+ );
844
+ const remaining =
845
+ typeof campaign.totalEnrolled === "number"
846
+ ? Math.max(0, MAX_CAMPAIGN_PEOPLE - campaign.totalEnrolled)
847
+ : undefined;
848
+ if (!preview && remaining === 0)
849
+ throw new Error(
850
+ `"${campaign.name}" is full (${MAX_CAMPAIGN_PEOPLE} people). Nothing was searched or charged.`,
851
+ );
852
+ costNotice(
853
+ preview ? body.limit : Math.min(body.limit, remaining ?? body.limit),
854
+ );
855
+ const page = withSearchCharge(
856
+ (await invoke(ctx, operation, {
857
+ ...body,
858
+ sequenceId: campaign._id,
859
+ })) as Record<string, unknown>,
860
+ );
861
+ const { nextPageToken } = pageParts(page);
862
+ const scope = { sequenceId: campaign._id, campaignStatus: campaign.status };
863
+ const full = { ...scope, ...page, nextPageToken };
864
+ const saveError = output.out ? await saveOut(output.out, full) : null;
865
+ if (output.out && saveError)
866
+ notice(
867
+ `Could not save ${output.out}: ${saveError}. ${preview ? "The search was already billed" : "The people were already added and billed"}, so the full result is printed here instead; keep it. Running this again would search and bill again.`,
868
+ );
869
+ if (preview)
870
+ return print(
871
+ ctx,
872
+ searchView(output, scope, page, full, saveError),
873
+ searchSummary(page),
874
+ );
875
+ const counts = addCounts(page);
876
+ if (counts.cappedAtLimit !== null)
877
+ notice(`The campaign is full at ${counts.cappedAtLimit} people.`);
878
+ const found = typeof page.found === "number" ? page.found : 0;
879
+ const result = {
880
+ ...scope,
881
+ ...counts,
882
+ found,
883
+ skippedSearchProfiles:
884
+ typeof page.skippedSearchProfiles === "number"
885
+ ? page.skippedSearchProfiles
886
+ : 0,
887
+ ...(page.missingInfoSample
888
+ ? { missingInfoSample: page.missingInfoSample }
889
+ : {}),
890
+ nextPageToken,
891
+ estimatedCents: page.estimatedCents,
892
+ chargedCents: page.chargedCents,
893
+ ...(output.out && !saveError ? { savedTo: output.out } : {}),
894
+ };
895
+ return print(
896
+ ctx,
897
+ saveError ? { ...full, saveError } : output.raw ? full : result,
898
+ `Added ${counts.added} of ${plural(found, "person", "people")} found to "${campaign.name}" (${campaign.status}) · ${searchChargeLabel(page)}${nextPageToken ? ` · more: --page-token ${nextPageToken}` : ""}`,
899
+ );
900
+ }
901
+ if (!file) throw new Error("Pass --people-file, --csv or --search");
902
+ return addPeopleFile(ctx, file, reference, allowDuplicates);
903
+ }
904
+
905
+ async function addPeopleFile(
906
+ ctx: Context,
907
+ file: string,
908
+ reference: string,
909
+ allowDuplicates: boolean,
910
+ ) {
911
+ const raw = await readFile(file, "utf8").catch(() => {
912
+ throw new Error(`Cannot read ${file}`);
913
+ });
914
+ let parsed: unknown;
915
+ try {
916
+ parsed = JSON.parse(raw);
917
+ } catch {
918
+ throw new Error(`${file} is not valid JSON`);
919
+ }
920
+ const recipients = recipientsFromPeopleFile(parsed, file);
921
+ return addRecipients(ctx, recipients, file, reference, allowDuplicates);
922
+ }
923
+
924
+ function columnSummary(columns: CsvColumn[]): string[] {
925
+ const names = (role: CsvColumn["role"]) =>
926
+ columns
927
+ .filter((column) => column.role === role)
928
+ .map((column) => column.label);
929
+ const parts: string[] = [];
930
+ const email = names("email");
931
+ if (email.length) parts.push(`email = ${email.join(", ")}`);
932
+ const linkedin = names("linkedin");
933
+ if (linkedin.length) parts.push(`LinkedIn = ${linkedin.join(", ")}`);
934
+ if (!email.length && !linkedin.length)
935
+ parts.push("no email or LinkedIn column");
936
+ const person = [
937
+ ...names("full name"),
938
+ ...names("first name"),
939
+ ...names("last name"),
940
+ ];
941
+ parts.push(
942
+ person.length
943
+ ? `name = ${person.join(" + ")}`
944
+ : 'no name column found (point one at it with --map name="<column>")',
945
+ );
946
+ return parts;
947
+ }
948
+
949
+ const LIST_ENRICHMENT = [
950
+ "all",
951
+ "enriched",
952
+ "in_progress",
953
+ "no_contacts",
954
+ "failed",
955
+ "not_enriched",
956
+ ] as const;
957
+
958
+ type ListRowsPage = {
959
+ title?: string;
960
+ rowCount?: number;
961
+ page?: Array<{ index: number; row?: unknown }>;
962
+ isDone?: boolean;
963
+ continueCursor?: string | null;
964
+ };
965
+
966
+ function listRowSummary(row: unknown): Record<string, string> {
967
+ if (!row || typeof row !== "object") return {};
968
+ const record = row as Record<string, unknown>;
969
+ const summary: Record<string, string> = {};
970
+ for (const key of ["name", "title", "company", "linkedin", "email"]) {
971
+ const found = record[key];
972
+ if (typeof found === "string" && found.trim()) summary[key] = found.trim();
973
+ }
974
+ return summary;
975
+ }
976
+
977
+ function listRowRecipient(row: unknown, index: number): Recipient {
978
+ if (!row || typeof row !== "object" || Array.isArray(row))
979
+ throw new Error(`List row ${index + 1} is not a person record`);
980
+ const record = row as Record<string, unknown>;
981
+ if (record.record_type && record.record_type !== "person")
982
+ throw new Error(
983
+ "Choose a people list. Use a company list to find its people first.",
984
+ );
985
+ return recipientFromRow(
986
+ {
987
+ name: record.name ?? record.fullName,
988
+ email: record.email,
989
+ linkedinProfileUrl: record.linkedin ?? record.linkedinUrl,
990
+ Company: record.company,
991
+ "Job Title": record.title,
992
+ Website: record.website,
993
+ Location: record.location,
994
+ Photo: record.avatarUrl,
995
+ personalization: record.personalization,
996
+ },
997
+ `List row ${index + 1}`,
998
+ );
999
+ }
1000
+
1001
+ async function addList(
1002
+ ctx: Context,
1003
+ artifactId: string,
1004
+ reference: string,
1005
+ options: {
1006
+ allowDuplicates: boolean;
1007
+ preview: boolean;
1008
+ enrichment?: string;
1009
+ limitRaw?: string;
1010
+ },
1011
+ ) {
1012
+ if (
1013
+ options.enrichment !== undefined &&
1014
+ !(LIST_ENRICHMENT as readonly string[]).includes(options.enrichment)
1015
+ )
1016
+ throw new Error(
1017
+ `--enrichment must be one of ${LIST_ENRICHMENT.join(", ")}, got "${options.enrichment}"`,
1018
+ );
1019
+ const campaign = await addableCampaign(ctx, reference);
1020
+ const room =
1021
+ typeof campaign.totalEnrolled === "number"
1022
+ ? Math.max(0, MAX_CAMPAIGN_PEOPLE - campaign.totalEnrolled)
1023
+ : MAX_CAMPAIGN_PEOPLE;
1024
+ if (!options.preview && room === 0)
1025
+ throw new Error(
1026
+ `"${campaign.name}" is full (${MAX_CAMPAIGN_PEOPLE} people). Nothing was imported.`,
1027
+ );
1028
+ let wanted: number;
1029
+ try {
1030
+ wanted = Math.min(
1031
+ parseLimit(options.limitRaw, {
1032
+ defaultValue: MAX_CAMPAIGN_PEOPLE,
1033
+ cap: MAX_CAMPAIGN_PEOPLE,
1034
+ command: "campaign add --list",
1035
+ }).effective,
1036
+ room || MAX_CAMPAIGN_PEOPLE,
1037
+ );
1038
+ } catch (error) {
1039
+ throw new Error(error instanceof Error ? error.message : String(error));
1040
+ }
1041
+ const picked: Recipient[] = [];
1042
+ const sample: Array<Record<string, string>> = [];
1043
+ let title = "";
1044
+ let rowCount = 0;
1045
+ let cursor: string | undefined;
1046
+ do {
1047
+ const page = (await invoke(ctx, "lists.rows", {
1048
+ artifactId,
1049
+ limit: 100,
1050
+ ...(cursor ? { cursor } : {}),
1051
+ ...(options.enrichment ? { enrichment: options.enrichment } : {}),
1052
+ })) as ListRowsPage;
1053
+ title = page.title ?? title;
1054
+ rowCount = page.rowCount ?? rowCount;
1055
+ for (const entry of page.page ?? []) {
1056
+ if (picked.length >= wanted) break;
1057
+ picked.push(listRowRecipient(entry.row, entry.index));
1058
+ if (sample.length < 5) sample.push(listRowSummary(entry.row));
1059
+ }
1060
+ cursor =
1061
+ page.isDone || !page.continueCursor || picked.length >= wanted
1062
+ ? undefined
1063
+ : page.continueCursor;
1064
+ } while (cursor);
1065
+ const list = { artifactId, title, rowCount, rows: picked.length };
1066
+ if (!picked.length)
1067
+ throw new Error(
1068
+ `${title ? `"${title}"` : artifactId} has no rows${options.enrichment ? ` with enrichment "${options.enrichment}"` : ""} to add (${rowCount} saved). See dench lists rows ${artifactId}.`,
1069
+ );
1070
+ if (options.preview)
1071
+ return print(
1072
+ ctx,
1073
+ {
1074
+ sequenceId: campaign._id,
1075
+ campaignStatus: campaign.status,
1076
+ list,
1077
+ sample,
1078
+ },
1079
+ `${plural(picked.length, "row", "rows")} of "${title}" would be added to "${campaign.name}"; nothing was imported.`,
1080
+ );
1081
+ const totals: BatchCounts & { cappedAtLimit: number | null } = {
1082
+ ...batchCounts({}),
1083
+ cappedAtLimit: null,
1084
+ };
1085
+ const perBatch: Array<Record<string, unknown>> = [];
1086
+ let missingInfoSample: unknown;
1087
+ for (let offset = 0; offset < picked.length; offset += PEOPLE_BATCH_SIZE) {
1088
+ const chunk = picked.slice(offset, offset + PEOPLE_BATCH_SIZE);
1089
+ let result: Record<string, unknown>;
1090
+ try {
1091
+ result = (await invoke(ctx, "campaigns.people.addBatch", {
1092
+ sequenceId: campaign._id,
1093
+ newRecordsObject: "lead",
1094
+ recipients: chunk,
1095
+ checkDuplicatesAcrossWorkspace: !options.allowDuplicates,
1096
+ })) as Record<string, unknown>;
1097
+ } catch (error) {
1098
+ const message = backendErrorText(error);
1099
+ print(ctx, {
1100
+ sequenceId: campaign._id,
1101
+ campaignStatus: campaign.status,
1102
+ ...totals,
1103
+ list,
1104
+ batches: perBatch,
1105
+ failedBatch: { offset, size: chunk.length },
1106
+ error: message,
1107
+ });
1108
+ throw new Error(
1109
+ `Adding people ${offset + 1}-${offset + chunk.length} failed: ${message}. ${totals.added} ${totals.added === 1 ? "person was" : "people were"} added before it. Run the same command again: existing leads are reused and people already added count as duplicates.`,
1110
+ );
1111
+ }
1112
+ const counts = batchCounts(result);
1113
+ for (const key of BATCH_COUNTS) totals[key] += counts[key];
1114
+ missingInfoSample ??= result.missingInfoSample;
1115
+ const capped =
1116
+ typeof result.cappedAtLimit === "number" ? result.cappedAtLimit : null;
1117
+ perBatch.push({
1118
+ batch: perBatch.length + 1,
1119
+ offset,
1120
+ size: chunk.length,
1121
+ ...counts,
1122
+ cappedAtLimit: capped,
1123
+ });
1124
+ if (capped !== null) {
1125
+ totals.cappedAtLimit = capped;
1126
+ break;
1127
+ }
1128
+ }
1129
+ if (totals.cappedAtLimit !== null)
1130
+ notice(`The campaign is full at ${totals.cappedAtLimit} people.`);
1131
+ if (campaign.status !== "draft" && totals.added > 0)
1132
+ notice(
1133
+ `"${campaign.name}" is ${campaign.status}, so ${liveEffect(campaign.status)} for the ${plural(totals.added, "person", "people")} just added.`,
1134
+ );
1135
+ return print(
1136
+ ctx,
1137
+ {
1138
+ sequenceId: campaign._id,
1139
+ campaignStatus: campaign.status,
1140
+ ...totals,
1141
+ ...(missingInfoSample ? { missingInfoSample } : {}),
1142
+ batches: perBatch,
1143
+ list,
1144
+ },
1145
+ `Added ${totals.added} of ${plural(picked.length, "row", "rows")} from "${title}" to "${campaign.name}" (${campaign.status}).`,
1146
+ );
1147
+ }
1148
+
1149
+ function csvFailure(file: string, error: string, table: RecipientTable): Error {
1150
+ const hint = table.columnLabels.length
1151
+ ? ` Columns found: ${table.columnLabels.join(", ")}. Point a column at a field with --map, ${COLUMN_MAP_USAGE}`
1152
+ : "";
1153
+ return new Error(`${file}: ${error}${hint}`);
1154
+ }
1155
+
1156
+ async function addCsv(
1157
+ ctx: Context,
1158
+ file: string,
1159
+ maps: string[],
1160
+ reference: string,
1161
+ options: { allowDuplicates: boolean; preview: boolean },
1162
+ ) {
1163
+ const text = await readFile(file, "utf8").catch(() => {
1164
+ throw new Error(`Cannot read ${file}`);
1165
+ });
1166
+ if (text.length > MAX_CSV_CHARS)
1167
+ throw new Error(
1168
+ `${file} is larger than 5 MB. A campaign holds ${MAX_CAMPAIGN_PEOPLE} people at most: keep the columns you need and split the rows.`,
1169
+ );
1170
+ const table = parseRecipientTable(text, detectDelimiter(text));
1171
+ const mapping = withDetectedFullName(table, parseColumnMap(maps, table));
1172
+ const result = recipientsFromTable(table, mapping);
1173
+ const columns = result.columns.map(
1174
+ ({ index, label, sample, role, mergeKey }) => ({
1175
+ column: label,
1176
+ index,
1177
+ sample,
1178
+ role,
1179
+ ...(mergeKey ? { mergeKey } : {}),
1180
+ }),
1181
+ );
1182
+ if (result.error) throw csvFailure(file, result.error, table);
1183
+ const csv = {
1184
+ file,
1185
+ rows: result.rows,
1186
+ people: result.recipients.length,
1187
+ rowsWithoutEmailOrLinkedin: result.noChannel,
1188
+ repeatedRows: result.duplicates,
1189
+ hasHeader: result.hasHeader,
1190
+ columns,
1191
+ };
1192
+ const read = columnSummary(result.columns);
1193
+ const mergeColumns = result.columns.filter(
1194
+ (column) => column.role === "merge value",
1195
+ );
1196
+ if (result.truncated)
1197
+ notice(
1198
+ `${file} has more than ${MAX_CSV_RECIPIENTS} people; only the first ${MAX_CSV_RECIPIENTS} were read. Split the file to add the rest.`,
1199
+ );
1200
+ if (result.noChannel > 0)
1201
+ notice(
1202
+ `${plural(result.noChannel, "row has", "rows have")} no usable email or LinkedIn profile URL and ${result.noChannel === 1 ? "was" : "were"} left out.`,
1203
+ );
1204
+ if (result.duplicates > 0)
1205
+ notice(
1206
+ `${plural(result.duplicates, "row repeats", "rows repeat")} an earlier person's email or LinkedIn profile URL and ${result.duplicates === 1 ? "was" : "were"} left out.`,
1207
+ );
1208
+ notice(
1209
+ `Read ${file}: ${read.join("; ")}${mergeColumns.length ? `; ${plural(mergeColumns.length, "other column becomes", "other columns become")} merge values (${mergeColumns.map((column) => `{{${column.label}}}`).join(", ")})` : ""}.`,
1210
+ );
1211
+ if (options.preview)
1212
+ return print(
1213
+ ctx,
1214
+ {
1215
+ ...csv,
1216
+ truncated: result.truncated,
1217
+ sample: result.recipients.slice(0, 5),
1218
+ },
1219
+ `${plural(result.recipients.length, "person", "people")} in ${file}; nothing was added.`,
1220
+ );
1221
+ return addRecipients(
1222
+ ctx,
1223
+ result.recipients,
1224
+ file,
1225
+ reference,
1226
+ options.allowDuplicates,
1227
+ { csv },
1228
+ );
1229
+ }
1230
+
1231
+ async function addRecipients(
1232
+ ctx: Context,
1233
+ recipients: Recipient[],
1234
+ file: string,
1235
+ reference: string,
1236
+ allowDuplicates: boolean,
1237
+ extra: Record<string, unknown> = {},
1238
+ ) {
1239
+ const noIdentity = recipients.flatMap((recipient, index) =>
1240
+ recipient.email || recipient.linkedinProfileUrl
1241
+ ? []
1242
+ : [`recipients[${index}]`],
1243
+ );
1244
+ if (noIdentity.length === recipients.length)
1245
+ throw new Error(
1246
+ `Nobody in ${file} has an email or a LinkedIn profile URL, so nobody can be added. Give each person "email" and/or "linkedinProfileUrl" (also read from linkedinUrl or linkedin).`,
1247
+ );
1248
+ if (noIdentity.length)
1249
+ notice(
1250
+ `${noIdentity.length} of ${recipients.length} people have no email or LinkedIn profile URL and will be skipped: ${noIdentity.slice(0, 5).join(", ")}${noIdentity.length > 5 ? ", …" : ""}`,
1251
+ );
1252
+ const batches: Array<Record<string, unknown>> = [];
1253
+ for (
1254
+ let offset = 0;
1255
+ offset < recipients.length;
1256
+ offset += PEOPLE_BATCH_SIZE
1257
+ ) {
1258
+ const body = {
1259
+ recipients: recipients.slice(offset, offset + PEOPLE_BATCH_SIZE),
1260
+ newRecordsObject: "lead",
1261
+ checkDuplicatesAcrossWorkspace: !allowDuplicates,
1262
+ };
1263
+ validate("campaigns.people.addBatch", { ...body, sequenceId: "pending" });
1264
+ batches.push(body);
1265
+ }
1266
+ const campaign = await addableCampaign(ctx, reference);
1267
+ const sequenceId = campaign._id;
1268
+ const campaignStatus = campaign.status;
1269
+ const totals: BatchCounts & { cappedAtLimit: number | null } = {
1270
+ ...batchCounts({}),
1271
+ cappedAtLimit: null,
1272
+ };
1273
+ const perBatch: Array<Record<string, unknown>> = [];
1274
+ let missingInfoSample: unknown;
1275
+ let reached = 0;
1276
+ for (const [index, batch] of batches.entries()) {
1277
+ const offset = index * PEOPLE_BATCH_SIZE;
1278
+ const size = (batch.recipients as unknown[]).length;
1279
+ let result: Record<string, unknown>;
1280
+ try {
1281
+ result = (await invoke(ctx, "campaigns.people.addBatch", {
1282
+ ...batch,
1283
+ sequenceId,
1284
+ })) as Record<string, unknown>;
1285
+ } catch (error) {
1286
+ const message = backendErrorText(error);
1287
+ print(ctx, {
1288
+ sequenceId,
1289
+ campaignStatus,
1290
+ ...totals,
1291
+ batches: perBatch,
1292
+ failedBatch: { batch: index + 1, of: batches.length, offset, size },
1293
+ notAttempted: recipients.length - offset,
1294
+ error: message,
1295
+ });
1296
+ throw new Error(
1297
+ `Batch ${index + 1} of ${batches.length} (people ${offset + 1}-${offset + size}) failed: ${message}. ${totals.added} ${totals.added === 1 ? "person was" : "people were"} added before it; that batch and any after it were not confirmed. Re-running the same file is safe: people already added count as duplicates.`,
1298
+ );
1299
+ }
1300
+ const counts = batchCounts(result);
1301
+ for (const key of BATCH_COUNTS) totals[key] += counts[key];
1302
+ missingInfoSample ??= result.missingInfoSample;
1303
+ reached = offset + considered(counts);
1304
+ const capped =
1305
+ typeof result.cappedAtLimit === "number" ? result.cappedAtLimit : null;
1306
+ perBatch.push({
1307
+ batch: index + 1,
1308
+ offset,
1309
+ size,
1310
+ ...counts,
1311
+ cappedAtLimit: capped,
1312
+ });
1313
+ if (capped !== null) {
1314
+ totals.cappedAtLimit = capped;
1315
+ break;
1316
+ }
1317
+ }
1318
+ const notConsidered =
1319
+ totals.cappedAtLimit === null ? 0 : recipients.length - reached;
1320
+ if (totals.cappedAtLimit !== null)
1321
+ notice(
1322
+ `The campaign is full at ${totals.cappedAtLimit} people; ${notConsidered} from ${file} were not added.`,
1323
+ );
1324
+ if (totals.skippedNoIdentity > noIdentity.length)
1325
+ notice(
1326
+ `${totals.skippedNoIdentity} skipped for having no usable email or LinkedIn profile URL.`,
1327
+ );
1328
+ if (totals.invalid > 0)
1329
+ notice(
1330
+ `${totals.invalid} skipped because their email or LinkedIn profile URL is not valid.`,
1331
+ );
1332
+ if (campaignStatus !== "draft" && totals.added > 0)
1333
+ notice(
1334
+ `"${campaign.name}" is ${campaignStatus}, so ${liveEffect(campaignStatus)} for the ${plural(totals.added, "person", "people")} just added.`,
1335
+ );
1336
+ return print(
1337
+ ctx,
1338
+ {
1339
+ sequenceId,
1340
+ campaignStatus,
1341
+ ...totals,
1342
+ ...(notConsidered ? { notConsidered } : {}),
1343
+ ...(missingInfoSample ? { missingInfoSample } : {}),
1344
+ batches: perBatch,
1345
+ ...extra,
1346
+ },
1347
+ `Added ${totals.added} of ${recipients.length} people from ${file}.`,
1348
+ );
1349
+ }
1350
+
1351
+ type CampaignSettings = {
1352
+ _id: string;
1353
+ name: string;
1354
+ status: string;
1355
+ timezone?: string;
1356
+ sendSchedules?: SendSchedule[];
1357
+ dailySendCap?: number;
1358
+ stopOnReply?: boolean;
1359
+ startsAt?: number;
1360
+ endsAt?: number;
1361
+ emailMailboxAccountIds?: string[];
1362
+ linkedinAccountIds?: string[];
1363
+ };
1364
+
1365
+ async function readCampaign(
1366
+ ctx: Context,
1367
+ sequenceId: string,
1368
+ ): Promise<CampaignSettings> {
1369
+ const detail = (await invoke(ctx, "campaigns.get", { sequenceId })) as {
1370
+ sequence?: CampaignSettings;
1371
+ } | null;
1372
+ if (!detail?.sequence)
1373
+ throw new Error(
1374
+ `Campaign ${sequenceId} could not be read back. Run dench campaign list to find it.`,
1375
+ );
1376
+ return detail.sequence;
1377
+ }
1378
+
1379
+ function plural(count: number, one: string, many: string) {
1380
+ return `${count} ${count === 1 ? one : many}`;
1381
+ }
1382
+
1383
+ async function draft(ctx: Context) {
1384
+ const name = value(ctx.args, "--name");
1385
+ const requestId = value(ctx.args, "--request-id");
1386
+ const flags = consumeScheduleFlags(ctx.args);
1387
+ const senderPools = consumeSenderPoolFlags(ctx.args);
1388
+ finishArgs(ctx.args, "draft");
1389
+ const clientRequestId = (requestId ?? randomUUID()).trim();
1390
+ if (clientRequestId.length < 8 || clientRequestId.length > 64)
1391
+ throw new Error(
1392
+ `--request-id must be 8-64 characters (got ${clientRequestId.length}). Reuse one stable id, e.g. us-founders-2026-09-30, so a retry returns the same draft.`,
1393
+ );
1394
+ const timezone = assertIanaTimezone(flags.timezone ?? localTimezone());
1395
+ schedulePatchFromFlags(flags, { fallbackTimezone: timezone });
1396
+ const { sequenceId } = (await invoke(ctx, "campaigns.drafts.create", {
1397
+ clientRequestId,
1398
+ ...(name ? { name } : {}),
1399
+ timezone,
1400
+ })) as { sequenceId: string };
1401
+ let campaign = await readCampaign(ctx, sequenceId);
1402
+ const schedule = schedulePatchFromFlags(flags, {
1403
+ fallbackTimezone: campaign.timezone ?? timezone,
1404
+ existingSchedules: campaign.sendSchedules,
1405
+ });
1406
+ if (
1407
+ schedule.sendSchedules &&
1408
+ JSON.stringify(schedule.sendSchedules) ===
1409
+ JSON.stringify(campaign.sendSchedules)
1410
+ )
1411
+ delete schedule.sendSchedules;
1412
+ const patch = { ...schedule, ...senderPools };
1413
+ if (hasScheduleChanges(patch)) {
1414
+ if (campaign.status === "draft") {
1415
+ await invoke(ctx, "campaigns.update", { sequenceId, ...patch });
1416
+ campaign = await readCampaign(ctx, sequenceId);
1417
+ } else {
1418
+ notice(
1419
+ `"${campaign.name}" already exists for --request-id ${clientRequestId} and is ${campaign.status}, so its settings were not changed. Change them with dench campaign set.`,
1420
+ );
1421
+ }
1422
+ }
1423
+ const emailMailboxAccountIds = campaign.emailMailboxAccountIds ?? [];
1424
+ const linkedinAccountIds = campaign.linkedinAccountIds ?? [];
1425
+ if (!emailMailboxAccountIds.length && !linkedinAccountIds.length)
1426
+ notice(
1427
+ "No sending account is attached. Connect a mailbox or LinkedIn account in the Dench app, then pick it with dench campaign set <campaign> --email-mailboxes <id> --linkedin-accounts <id> (ids from dench campaign accounts).",
1428
+ );
1429
+ else if (
1430
+ !senderPools.emailMailboxAccountIds &&
1431
+ !senderPools.linkedinAccountIds
1432
+ )
1433
+ notice(
1434
+ `Sending from every connected account you own: ${plural(emailMailboxAccountIds.length, "mailbox", "mailboxes")} and ${plural(linkedinAccountIds.length, "LinkedIn account", "LinkedIn accounts")}. Choose with --email-mailboxes / --linkedin-accounts (ids from dench campaign accounts).`,
1435
+ );
1436
+ const sendSchedules = campaign.sendSchedules ?? [];
1437
+ const windows = sendSchedules
1438
+ .map(
1439
+ (window) =>
1440
+ `${formatWeekdays(window.weekdays)} ${window.startHour}-${window.endHour} ${window.timezone}`,
1441
+ )
1442
+ .join("; ");
1443
+ return print(
1444
+ ctx,
1445
+ {
1446
+ sequenceId,
1447
+ name: campaign.name,
1448
+ status: campaign.status,
1449
+ clientRequestId,
1450
+ timezone: campaign.timezone ?? timezone,
1451
+ sendSchedules,
1452
+ dailySendCap: campaign.dailySendCap ?? null,
1453
+ stopOnReply: campaign.stopOnReply !== false,
1454
+ ...(campaign.startsAt ? { startsAt: campaign.startsAt } : {}),
1455
+ ...(campaign.endsAt ? { endsAt: campaign.endsAt } : {}),
1456
+ emailMailboxAccountIds,
1457
+ linkedinAccountIds,
1458
+ },
1459
+ `Draft "${campaign.name}" [${sequenceId}] · ${windows || "no send window"} · ${plural(emailMailboxAccountIds.length, "mailbox", "mailboxes")}, ${plural(linkedinAccountIds.length, "LinkedIn account", "LinkedIn accounts")} · request id ${clientRequestId}`,
1460
+ );
1461
+ }
1462
+
1463
+ export async function runCampaignAgentCommand(
1464
+ ctx: Context,
1465
+ command: string,
1466
+ ): Promise<void> {
1467
+ if (command === "add") return add(ctx);
1468
+ if (command === "draft") return draft(ctx);
1469
+ if (command === "search") {
1470
+ const input = await searchRequest(ctx.args, "search");
1471
+ const output = outputOptions(ctx.args);
1472
+ assertNoUnknownFlags(ctx.args, "campaign search");
1473
+ const reference = ctx.args.shift();
1474
+ finishArgs(ctx.args, command);
1475
+ validate("campaigns.audience.search", input);
1476
+ await claimOut(output);
1477
+ const sequenceId = reference
1478
+ ? (await ctx.resolveCampaign(reference))._id
1479
+ : undefined;
1480
+ costNotice(input.limit);
1481
+ const page = withSearchCharge(
1482
+ (await invoke(ctx, "campaigns.audience.search", {
1483
+ ...input,
1484
+ ...(sequenceId ? { sequenceId } : {}),
1485
+ })) as Record<string, unknown>,
1486
+ );
1487
+ const scope = sequenceId ? { sequenceId } : {};
1488
+ const full = {
1489
+ ...scope,
1490
+ ...page,
1491
+ nextPageToken: pageParts(page).nextPageToken,
1492
+ };
1493
+ const saveError = output.out ? await saveOut(output.out, full) : null;
1494
+ if (output.out && saveError)
1495
+ notice(
1496
+ `Could not save ${output.out}: ${saveError}. The search was already billed, so the full result is printed here instead; keep it. Running this again would search and bill again.`,
1497
+ );
1498
+ return print(
1499
+ ctx,
1500
+ searchView(output, scope, page, full, saveError),
1501
+ searchSummary(page),
1502
+ );
1503
+ }
1504
+ const verb = shift(ctx.args, `${command} subcommand`);
1505
+ const input = await objectInput(ctx.args);
1506
+ if (command === "sources") {
1507
+ const operations: Record<string, string> = {
1508
+ get: "get",
1509
+ signals: "signals",
1510
+ set: "update",
1511
+ preview: "preview",
1512
+ };
1513
+ const operation = operations[verb];
1514
+ if (!operation)
1515
+ throw new Error("Use campaign sources get|signals|set|preview");
1516
+ const signal = value(ctx.args, "--signal");
1517
+ if (signal) input.signal = signal;
1518
+ const reference = shift(ctx.args, "campaign id or name");
1519
+ finishArgs(ctx.args, "sources");
1520
+ validate(`campaigns.sources.${operation}`, {
1521
+ ...input,
1522
+ sequenceId: "pending",
1523
+ });
1524
+ const sequenceId = (await ctx.resolveCampaign(reference))._id;
1525
+ return print(
1526
+ ctx,
1527
+ await invoke(ctx, `campaigns.sources.${operation}`, {
1528
+ ...input,
1529
+ sequenceId,
1530
+ }),
1531
+ );
1532
+ }
1533
+ const verbs = new Set([
1534
+ "profile",
1535
+ "save",
1536
+ "get",
1537
+ "build",
1538
+ "create",
1539
+ "opener",
1540
+ "autofill",
1541
+ "terms",
1542
+ "estimate",
1543
+ ]);
1544
+ if (!verbs.has(verb))
1545
+ throw new Error(
1546
+ "Use campaign builder profile|save|get|build|create|opener|autofill|terms|estimate",
1547
+ );
1548
+ const name = value(ctx.args, "--name");
1549
+ const timezone = value(ctx.args, "--timezone");
1550
+ const description = value(ctx.args, "--description");
1551
+ const text = value(ctx.args, "--text");
1552
+ if (name) input.name = name;
1553
+ if (timezone) input.timezone = timezone;
1554
+ if (description) input.description = description;
1555
+ if (text !== undefined) input.opener = text;
1556
+ if (["get", "build", "create", "opener"].includes(verb) && !input.draftId)
1557
+ input.draftId = shift(ctx.args, "draft id");
1558
+ if (verb === "opener" && !input.leadId)
1559
+ input.leadId = shift(ctx.args, "draft lead id");
1560
+ if (verb === "save" && !input.clientRequestId)
1561
+ input.clientRequestId = randomUUID();
1562
+ finishArgs(ctx.args, "builder");
1563
+ return print(ctx, await invoke(ctx, `campaigns.builder.${verb}`, input));
1564
+ }