@dench.com/cli 3.11.6-staging.479 → 3.11.7-staging.488
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/app-sdk.d.ts +6 -0
- package/campaign.ts +157 -43
- package/lib/api-schemas.ts +130 -57
- package/lib/campaign-agent.ts +136 -50
- package/lib/campaign-csv.ts +183 -53
- package/lib/enrichment-gateway.ts +9 -0
- package/lib/references.generated.json +4 -4
- package/package.json +1 -1
package/lib/campaign-agent.ts
CHANGED
|
@@ -215,7 +215,15 @@ const RECIPIENT_ALIASES: Record<string, RecipientField> = {
|
|
|
215
215
|
};
|
|
216
216
|
|
|
217
217
|
const MAX_PEOPLE_FILE_RECIPIENTS = 300;
|
|
218
|
-
|
|
218
|
+
/**
|
|
219
|
+
* People per `addEnrollments` call, as the app's Add people sends them
|
|
220
|
+
* (`DIRECT_ENROLL_CHUNK`): a row that matches or creates a Lead is costly,
|
|
221
|
+
* and a 50-person call ran past the mutation limit on staging and was
|
|
222
|
+
* also the long transaction background CRM work kept conflicting with.
|
|
223
|
+
*/
|
|
224
|
+
const PEOPLE_BATCH_SIZE = 20;
|
|
225
|
+
/** Rows `add --list` reads at most looking past duplicates (50 pages). */
|
|
226
|
+
const MAX_LIST_ROWS_READ = 5000;
|
|
219
227
|
|
|
220
228
|
function aliasKey(key: string): string {
|
|
221
229
|
return key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
@@ -455,9 +463,12 @@ function onlyExclusions(filters: Record<string, unknown>): boolean {
|
|
|
455
463
|
}
|
|
456
464
|
|
|
457
465
|
function rangeValue(raw: string, flag: string): { min?: number; max?: number } {
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
466
|
+
const parts = raw.split(":");
|
|
467
|
+
// One min:max pair. A third part ("2:10:99", "2::99") was silently
|
|
468
|
+
// dropped, sometimes with the upper bound.
|
|
469
|
+
if (parts.length > 2)
|
|
470
|
+
throw new Error(`${flag} takes one min:max pair, e.g. 2:10, 2: or :10`);
|
|
471
|
+
const [minRaw = "", maxRaw = ""] = parts;
|
|
461
472
|
const part = (text: string, label: string) => {
|
|
462
473
|
const trimmed = text.trim();
|
|
463
474
|
if (!trimmed) return undefined;
|
|
@@ -472,6 +483,8 @@ function rangeValue(raw: string, flag: string): { min?: number; max?: number } {
|
|
|
472
483
|
const max = part(maxRaw, "max");
|
|
473
484
|
if (min === undefined && max === undefined)
|
|
474
485
|
throw new Error(`${flag} needs min:max, e.g. 2:10, 2: or :10`);
|
|
486
|
+
if (min !== undefined && max !== undefined && min > max)
|
|
487
|
+
throw new Error(`${flag} min (${min}) is greater than max (${max})`);
|
|
475
488
|
return {
|
|
476
489
|
...(min !== undefined ? { min } : {}),
|
|
477
490
|
...(max !== undefined ? { max } : {}),
|
|
@@ -923,17 +936,23 @@ async function addPeopleFile(
|
|
|
923
936
|
return addRecipients(ctx, recipients, file, reference, allowDuplicates);
|
|
924
937
|
}
|
|
925
938
|
|
|
926
|
-
function columnSummary(
|
|
939
|
+
function columnSummary(
|
|
940
|
+
columns: CsvColumn[],
|
|
941
|
+
/** Channels switched off with `--map <field>=none`. */
|
|
942
|
+
off: { email?: boolean; linkedin?: boolean } = {},
|
|
943
|
+
): string[] {
|
|
927
944
|
const names = (role: CsvColumn["role"]) =>
|
|
928
945
|
columns
|
|
929
946
|
.filter((column) => column.role === role)
|
|
930
947
|
.map((column) => column.label);
|
|
931
948
|
const parts: string[] = [];
|
|
932
949
|
const email = names("email");
|
|
933
|
-
if (email
|
|
950
|
+
if (off.email) parts.push("email off");
|
|
951
|
+
else if (email.length) parts.push(`email = ${email.join(", ")}`);
|
|
934
952
|
const linkedin = names("linkedin");
|
|
935
|
-
if (linkedin
|
|
936
|
-
if (
|
|
953
|
+
if (off.linkedin) parts.push("LinkedIn off");
|
|
954
|
+
else if (linkedin.length) parts.push(`LinkedIn = ${linkedin.join(", ")}`);
|
|
955
|
+
if (!email.length && !linkedin.length && !off.email && !off.linkedin)
|
|
937
956
|
parts.push("no email or LinkedIn column");
|
|
938
957
|
const person = [
|
|
939
958
|
...names("full name"),
|
|
@@ -1027,67 +1046,99 @@ async function addList(
|
|
|
1027
1046
|
throw new Error(
|
|
1028
1047
|
`"${campaign.name}" is full (${MAX_CAMPAIGN_PEOPLE} people). Nothing was imported.`,
|
|
1029
1048
|
);
|
|
1030
|
-
let
|
|
1049
|
+
let limit: ReturnType<typeof parseLimit>;
|
|
1031
1050
|
try {
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}).effective,
|
|
1038
|
-
room || MAX_CAMPAIGN_PEOPLE,
|
|
1039
|
-
);
|
|
1051
|
+
limit = parseLimit(options.limitRaw, {
|
|
1052
|
+
defaultValue: MAX_CAMPAIGN_PEOPLE,
|
|
1053
|
+
cap: MAX_CAMPAIGN_PEOPLE,
|
|
1054
|
+
command: "campaign add --list",
|
|
1055
|
+
});
|
|
1040
1056
|
} catch (error) {
|
|
1041
1057
|
throw new Error(error instanceof Error ? error.message : String(error));
|
|
1042
1058
|
}
|
|
1043
|
-
|
|
1059
|
+
// How many people to add: the --limit, the campaign cap and its room.
|
|
1060
|
+
const wanted = Math.min(limit.effective, room || MAX_CAMPAIGN_PEOPLE);
|
|
1061
|
+
const clamped = options.limitRaw !== undefined && wanted < limit.requested;
|
|
1062
|
+
if (clamped)
|
|
1063
|
+
notice(
|
|
1064
|
+
limit.clamped
|
|
1065
|
+
? `--limit ${limit.requested} is above the ${MAX_CAMPAIGN_PEOPLE}-person campaign maximum; adding up to ${wanted}.`
|
|
1066
|
+
: `--limit ${limit.requested} is more than "${campaign.name}" has room for; adding up to ${wanted}.`,
|
|
1067
|
+
);
|
|
1068
|
+
const limitInfo = clamped
|
|
1069
|
+
? {
|
|
1070
|
+
limitClamped: true,
|
|
1071
|
+
limitRequested: limit.requested,
|
|
1072
|
+
limitEffective: wanted,
|
|
1073
|
+
}
|
|
1074
|
+
: {};
|
|
1075
|
+
// Rows are read a page at a time as they're needed. Adding keeps reading
|
|
1076
|
+
// past people who turn out to be duplicates, so a list whose first rows
|
|
1077
|
+
// are already in the campaign still reaches the eligible rows after them.
|
|
1078
|
+
const queue: Recipient[] = [];
|
|
1044
1079
|
const sample: Array<Record<string, string>> = [];
|
|
1045
1080
|
let title = "";
|
|
1046
1081
|
let rowCount = 0;
|
|
1082
|
+
let rowsRead = 0;
|
|
1047
1083
|
let cursor: string | undefined;
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1084
|
+
let exhausted = false;
|
|
1085
|
+
const readUntil = async (count: number) => {
|
|
1086
|
+
while (
|
|
1087
|
+
queue.length < count &&
|
|
1088
|
+
!exhausted &&
|
|
1089
|
+
rowsRead < MAX_LIST_ROWS_READ
|
|
1090
|
+
) {
|
|
1091
|
+
const page = (await invoke(ctx, "lists.rows", {
|
|
1092
|
+
artifactId,
|
|
1093
|
+
limit: 100,
|
|
1094
|
+
...(cursor ? { cursor } : {}),
|
|
1095
|
+
...(options.enrichment ? { enrichment: options.enrichment } : {}),
|
|
1096
|
+
})) as ListRowsPage;
|
|
1097
|
+
title = page.title ?? title;
|
|
1098
|
+
rowCount = page.rowCount ?? rowCount;
|
|
1099
|
+
for (const entry of page.page ?? []) {
|
|
1100
|
+
rowsRead += 1;
|
|
1101
|
+
queue.push(listRowRecipient(entry.row, entry.index));
|
|
1102
|
+
if (sample.length < 5) sample.push(listRowSummary(entry.row));
|
|
1103
|
+
}
|
|
1104
|
+
exhausted = Boolean(page.isDone || !page.continueCursor);
|
|
1105
|
+
cursor = exhausted ? undefined : (page.continueCursor ?? undefined);
|
|
1061
1106
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
: page.continueCursor;
|
|
1066
|
-
} while (cursor);
|
|
1067
|
-
const list = { artifactId, title, rowCount, rows: picked.length };
|
|
1068
|
-
if (!picked.length)
|
|
1107
|
+
};
|
|
1108
|
+
await readUntil(options.preview ? wanted : PEOPLE_BATCH_SIZE);
|
|
1109
|
+
if (!queue.length)
|
|
1069
1110
|
throw new Error(
|
|
1070
1111
|
`${title ? `"${title}"` : artifactId} has no rows${options.enrichment ? ` with enrichment "${options.enrichment}"` : ""} to add (${rowCount} saved). See dench lists rows ${artifactId}.`,
|
|
1071
1112
|
);
|
|
1072
|
-
if (options.preview)
|
|
1113
|
+
if (options.preview) {
|
|
1114
|
+
const previewed = queue.slice(0, wanted);
|
|
1073
1115
|
return print(
|
|
1074
1116
|
ctx,
|
|
1075
1117
|
{
|
|
1076
1118
|
sequenceId: campaign._id,
|
|
1077
1119
|
campaignStatus: campaign.status,
|
|
1078
|
-
|
|
1120
|
+
...limitInfo,
|
|
1121
|
+
list: { artifactId, title, rowCount, rows: previewed.length },
|
|
1079
1122
|
sample,
|
|
1080
1123
|
},
|
|
1081
|
-
`${plural(
|
|
1124
|
+
`${plural(previewed.length, "row", "rows")} of "${title}" would be added to "${campaign.name}"; nothing was imported. People already in the campaign are skipped, and later rows take their places.`,
|
|
1082
1125
|
);
|
|
1126
|
+
}
|
|
1083
1127
|
const totals: BatchCounts & { cappedAtLimit: number | null } = {
|
|
1084
1128
|
...batchCounts({}),
|
|
1085
1129
|
cappedAtLimit: null,
|
|
1086
1130
|
};
|
|
1087
1131
|
const perBatch: Array<Record<string, unknown>> = [];
|
|
1088
1132
|
let missingInfoSample: unknown;
|
|
1089
|
-
|
|
1090
|
-
|
|
1133
|
+
let attempted = 0;
|
|
1134
|
+
const list = () => ({ artifactId, title, rowCount, rows: attempted });
|
|
1135
|
+
while (totals.added < wanted) {
|
|
1136
|
+
const size = Math.min(PEOPLE_BATCH_SIZE, wanted - totals.added);
|
|
1137
|
+
await readUntil(size);
|
|
1138
|
+
const chunk = queue.splice(0, size);
|
|
1139
|
+
if (!chunk.length) break;
|
|
1140
|
+
const offset = attempted;
|
|
1141
|
+
attempted += chunk.length;
|
|
1091
1142
|
let result: Record<string, unknown>;
|
|
1092
1143
|
try {
|
|
1093
1144
|
result = (await invoke(ctx, "campaigns.people.addBatch", {
|
|
@@ -1102,7 +1153,8 @@ async function addList(
|
|
|
1102
1153
|
sequenceId: campaign._id,
|
|
1103
1154
|
campaignStatus: campaign.status,
|
|
1104
1155
|
...totals,
|
|
1105
|
-
|
|
1156
|
+
...limitInfo,
|
|
1157
|
+
list: list(),
|
|
1106
1158
|
batches: perBatch,
|
|
1107
1159
|
failedBatch: { offset, size: chunk.length },
|
|
1108
1160
|
error: message,
|
|
@@ -1141,10 +1193,11 @@ async function addList(
|
|
|
1141
1193
|
campaignStatus: campaign.status,
|
|
1142
1194
|
...totals,
|
|
1143
1195
|
...(missingInfoSample ? { missingInfoSample } : {}),
|
|
1196
|
+
...limitInfo,
|
|
1144
1197
|
batches: perBatch,
|
|
1145
|
-
list,
|
|
1198
|
+
list: list(),
|
|
1146
1199
|
},
|
|
1147
|
-
`Added ${totals.added} of ${plural(
|
|
1200
|
+
`Added ${totals.added} of ${plural(attempted, "row", "rows")} from "${title}" to "${campaign.name}" (${campaign.status}).`,
|
|
1148
1201
|
);
|
|
1149
1202
|
}
|
|
1150
1203
|
|
|
@@ -1191,7 +1244,10 @@ async function addCsv(
|
|
|
1191
1244
|
hasHeader: result.hasHeader,
|
|
1192
1245
|
columns,
|
|
1193
1246
|
};
|
|
1194
|
-
const read = columnSummary(result.columns
|
|
1247
|
+
const read = columnSummary(result.columns, {
|
|
1248
|
+
email: mapping.email === null,
|
|
1249
|
+
linkedin: mapping.linkedin === null,
|
|
1250
|
+
});
|
|
1195
1251
|
const mergeColumns = result.columns.filter(
|
|
1196
1252
|
(column) => column.role === "merge value",
|
|
1197
1253
|
);
|
|
@@ -1205,10 +1261,14 @@ async function addCsv(
|
|
|
1205
1261
|
);
|
|
1206
1262
|
if (result.duplicates > 0)
|
|
1207
1263
|
notice(
|
|
1208
|
-
`${plural(result.duplicates, "row repeats", "rows repeat")} an earlier person's email or LinkedIn profile URL
|
|
1264
|
+
`${plural(result.duplicates, "row repeats", "rows repeat")} an earlier person's email or LinkedIn profile URL; anything new on ${result.duplicates === 1 ? "it" : "them"} was added to that person.`,
|
|
1265
|
+
);
|
|
1266
|
+
if (table.raggedRows)
|
|
1267
|
+
notice(
|
|
1268
|
+
`${plural(table.raggedRows, "row has", "rows have")} more cells than the header row, so the columns may not line up; the extra cells became their own merge values. Check ${file} if that looks wrong.`,
|
|
1209
1269
|
);
|
|
1210
1270
|
notice(
|
|
1211
|
-
`Read ${file}: ${read.join("; ")}${mergeColumns.length ? `; ${plural(mergeColumns.length, "other column becomes", "other columns become")} merge values (${mergeColumns.map((column) => `{{${column.label}}}`).join(", ")})` : ""}.`,
|
|
1271
|
+
`Read ${file}: ${read.join("; ")}${mergeColumns.length ? `; ${plural(mergeColumns.length, "other column becomes", "other columns become")} merge values (${mergeColumns.map((column) => `{{${column.mergeKey?.replace(/^crm:/, "") ?? column.label}}}`).join(", ")})` : ""}.`,
|
|
1212
1272
|
);
|
|
1213
1273
|
if (options.preview)
|
|
1214
1274
|
return print(
|
|
@@ -1399,10 +1459,13 @@ async function draft(ctx: Context) {
|
|
|
1399
1459
|
);
|
|
1400
1460
|
const timezone = assertIanaTimezone(flags.timezone ?? localTimezone());
|
|
1401
1461
|
schedulePatchFromFlags(flags, { fallbackTimezone: timezone });
|
|
1462
|
+
// The chosen senders go in with the draft, so one that can't be used fails
|
|
1463
|
+
// before anything is created.
|
|
1402
1464
|
const { sequenceId } = (await invoke(ctx, "campaigns.drafts.create", {
|
|
1403
1465
|
clientRequestId,
|
|
1404
1466
|
...(name ? { name } : {}),
|
|
1405
1467
|
timezone,
|
|
1468
|
+
...senderPools,
|
|
1406
1469
|
})) as { sequenceId: string };
|
|
1407
1470
|
let campaign = await readCampaign(ctx, sequenceId);
|
|
1408
1471
|
const schedule = schedulePatchFromFlags(flags, {
|
|
@@ -1418,7 +1481,23 @@ async function draft(ctx: Context) {
|
|
|
1418
1481
|
const patch = { ...schedule, ...senderPools };
|
|
1419
1482
|
if (hasScheduleChanges(patch)) {
|
|
1420
1483
|
if (campaign.status === "draft") {
|
|
1421
|
-
|
|
1484
|
+
try {
|
|
1485
|
+
await invoke(ctx, "campaigns.update", { sequenceId, ...patch });
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
// The draft exists: say which, and that the same --request-id
|
|
1488
|
+
// returns it, so a retry fixes it rather than creating another.
|
|
1489
|
+
const message = backendErrorText(error);
|
|
1490
|
+
print(ctx, {
|
|
1491
|
+
sequenceId,
|
|
1492
|
+
clientRequestId,
|
|
1493
|
+
status: campaign.status,
|
|
1494
|
+
settingsSaved: false,
|
|
1495
|
+
error: message,
|
|
1496
|
+
});
|
|
1497
|
+
throw new Error(
|
|
1498
|
+
`Created draft "${campaign.name}" [${sequenceId}], but its settings weren't saved: ${message}. Re-run with --request-id ${clientRequestId} and corrected flags to update the same draft, or use dench campaign set ${sequenceId}.`,
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1422
1501
|
campaign = await readCampaign(ctx, sequenceId);
|
|
1423
1502
|
} else {
|
|
1424
1503
|
notice(
|
|
@@ -1636,6 +1715,13 @@ export async function runCampaignAgentCommand(
|
|
|
1636
1715
|
input.leadId = shift(ctx.args, "draft lead id");
|
|
1637
1716
|
if (verb === "save" && !input.clientRequestId)
|
|
1638
1717
|
input.clientRequestId = randomUUID();
|
|
1718
|
+
// A save from here is deliberate: like the app's "Save and exit" it gets
|
|
1719
|
+
// a screen, so `builder drafts` lists it and it keeps for two weeks.
|
|
1720
|
+
// (Without one the server treats it as a passing autosave: unlisted,
|
|
1721
|
+
// deleted after a day.)
|
|
1722
|
+
if (verb === "save" && input.screen === undefined)
|
|
1723
|
+
input.screen =
|
|
1724
|
+
input.source === "lookalike" || input.lookalike ? "lookalike" : "target";
|
|
1639
1725
|
finishArgs(ctx.args, "builder");
|
|
1640
1726
|
return print(ctx, await invoke(ctx, `campaigns.builder.${verb}`, input));
|
|
1641
1727
|
}
|