Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added apps/api/.test-autopilot-hold-once-3499025.db
Binary file not shown.
Binary file not shown.
Binary file added apps/api/.test-autopilot-skip-event-3499025.db
Binary file not shown.
Empty file.
Binary file added apps/api/.test-digest-off-3499025.db
Binary file not shown.
Empty file.
Empty file.
Binary file added apps/api/.test-digest-quiet-3499025.db
Binary file not shown.
Binary file added apps/api/.test-digest-window-3499025.db
Binary file not shown.
21 changes: 17 additions & 4 deletions packages/email/src/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ export interface DigestLead {
export interface DailyDigest {
/** The UTC date this digest covers, `YYYY-MM-DD`. */
readonly date: string;
/**
* Whose digest this is. One person can own several workspaces (a test one
* among them), and two digests that differ only in their numbers read as
* one product contradicting itself.
*/
readonly workspaceName?: string;
/** The counts below cover the 24 hours before the digest was sent. */
readonly sitesCrawled: number;
readonly peopleFound: number;
readonly messagesSent: number;
Expand Down Expand Up @@ -190,10 +197,11 @@ export function dailyDigestEmail(to: string, digest: DailyDigest, appUrl: string
const base = appUrl.replace(/\/$/, '');
const quiet = digest.peopleFound === 0 && digest.messagesSent === 0;

const whose = digest.workspaceName ? ` · ${digest.workspaceName}` : '';
const subject = quiet
? `Nothing new today · OutreachGraph`
? `Nothing new today${whose} · OutreachGraph`
: `${digest.peopleFound} new ${digest.peopleFound === 1 ? 'lead' : 'leads'}, ` +
`${digest.messagesSent} sent · OutreachGraph`;
`${digest.messagesSent} sent${whose} · OutreachGraph`;

// "Awaiting approval: 1117" was the line that got this digest replied to
// with "why is it not sending?". Most of those cards were not waiting for
Expand Down Expand Up @@ -231,13 +239,15 @@ export function dailyDigestEmail(to: string, digest: DailyDigest, appUrl: string

const text = [
`OutreachGraph · ${digest.date}`,
digest.workspaceName ? `Workspace: ${digest.workspaceName}` : '',
'Last 24 hours:',
'',
...counts,
'',
quiet
? 'Nothing new came back today. Campaigns are still running.'
: leadLines.length
? `Today's leads:\n${leadLines.join('\n')}`
? `New leads:\n${leadLines.join('\n')}`
: '',
digest.notes?.length ? `\nNotes:\n${digest.notes.map((n) => ` · ${n}`).join('\n')}` : '',
'',
Expand All @@ -248,7 +258,10 @@ export function dailyDigestEmail(to: string, digest: DailyDigest, appUrl: string

const html = [
markHtml(base),
`<p style="font-size:18px;margin:0 0 12px"><strong>${escapeHtml(digest.date)}</strong></p>`,
`<p style="font-size:18px;margin:0 0 4px"><strong>${escapeHtml(digest.date)}</strong></p>`,
`<p style="margin:0 0 12px;color:#555">${
digest.workspaceName ? `${escapeHtml(digest.workspaceName)} · ` : ''
}last 24 hours</p>`,
'<table cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin:0 0 16px">',
row('Sites read', digest.sitesCrawled),
row('New people', digest.peopleFound),
Expand Down
8 changes: 6 additions & 2 deletions packages/pipeline/src/autopilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,15 @@ export class HoldLedger {

/** The holds in force, grouped by reason, largest group first. */
summary(workspaceId: string): readonly HeldGroup[] {
// Grouped by what the reader sees, not by the raw reason: two desks at
// different addresses are one line, not "4 held: a desk" and "1 held: a desk".
const groups = new Map<string, { label: string; count: number }>();

for (const entry of this.held.get(workspaceId)?.values() ?? []) {
const group = groups.get(entry.key);
const label = describeHold(entry.reason);
const group = groups.get(label);
if (group) group.count += 1;
else groups.set(entry.key, { label: describeHold(entry.reason), count: 1 });
else groups.set(label, { label, count: 1 });
}

return [...groups.values()].sort((a, b) => b.count - a.count);
Expand Down Expand Up @@ -322,6 +325,7 @@ export function describeHold(reason: string): string {
if (/no address published/i.test(reason)) return 'no address to write to';
if (/own products/i.test(reason)) return "the address is one of this workspace's own";
if (/desk, not a buyer/i.test(reason)) return 'the address is a support or abuse desk';
if (/outside business hours/i.test(reason)) return "waiting for the recipient's business hours";
if (/no drafted message/i.test(reason)) return 'no message written yet';
if (/quality checks/i.test(reason)) return 'the draft failed its quality checks';
if (/giving up after/i.test(reason)) return 'sending failed repeatedly';
Expand Down
67 changes: 67 additions & 0 deletions packages/pipeline/src/notify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,73 @@ describe('sendDailyDigest', () => {
});
});

describe('what the digest counts', () => {
test('counts the last 24 hours, not since midnight, and names the workspace', async () => {
seeded = await seedDatabase('digest-window');
const { db } = seeded;
await setDigestHour(db, 9);

const sendAt = at(12);
const hoursAgo = (h: number): string =>
new Date(sendAt.getTime() - h * 60 * 60 * 1000).toISOString();

// Two went out yesterday afternoon, after yesterday's digest: a midnight
// window never counts them, in any digest. One is older than a day.
const sends = [
['act_morning', hoursAgo(2)],
['act_afternoon', hoursAgo(20)],
['act_evening', hoursAgo(16)],
['act_stale', hoursAgo(30)],
];
for (const [id, executedAt] of sends) {
await db.execute({
sql: `INSERT INTO actions (id, workspace_id, recommendation_id, person_id, kind, network,
mode, status, created_at, executed_at)
VALUES (?, ?, ?, ?, 'send_email', 'email', 'customer_managed', 'completed', ?, ?)`,
args: [
id!,
SEED.workspaceId,
SEED.recommendationId,
SEED.personId,
executedAt!,
executedAt!,
],
});
}

const { sent, mailer } = recordingMailer();
await sendDailyDigest({ db, mailer, appUrl: APP_URL, now: sendAt }, SEED.workspaceId);

const mail = sent[0];
expect(mail?.text).toContain('Messages sent: 3');
expect(mail?.text).toContain('Last 24 hours');
expect(mail?.subject).toBe('0 new leads, 3 sent · Test · OutreachGraph');
});

test('holds that read the same are one note, not two', async () => {
seeded = await seedDatabase('digest-notes');
const { db } = seeded;
await setDigestHour(db, 9);

const ledger = new HoldLedger();
ledger.observe(SEED.workspaceId, 'rec_a', 'support@a.example is a support desk, not a buyer');
ledger.observe(SEED.workspaceId, 'rec_b', 'support@a.example is a support desk, not a buyer');
ledger.observe(SEED.workspaceId, 'rec_c', 'abuse@b.example is an abuse desk, not a buyer');

const { sent, mailer } = recordingMailer();
await sendDailyDigest(
{ db, mailer, appUrl: APP_URL, now: at(12), holdLedger: ledger },
SEED.workspaceId,
);

const text = sent[0]?.text ?? '';
expect(text).toContain(
'3 held in the autopilot queue: the address is a support or abuse desk.',
);
expect(text.match(/support or abuse desk/g)).toHaveLength(1);
});
});

describe('what the digest says about the queue', () => {
test('splits the queue into what autopilot will send and what needs a human', async () => {
seeded = await seedDatabase('digest-queue');
Expand Down
14 changes: 13 additions & 1 deletion packages/pipeline/src/notify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,16 @@ export async function sendDailyDigest(deps: NotifyDeps, workspaceId: string): Pr

if (!(await claim(deps.db, workspaceId, 'daily_digest', today, to))) return false;

const since = `${today}T00:00:00.000Z`;
// The last 24 hours, not "since midnight UTC". The digest goes out at its
// hour (13:00 by default), so a midnight window only ever saw the morning:
// a day that sent 64 reported 3, and everything sent after the digest hour
// was never counted by any digest at all.
const since = new Date(at.getTime() - DAY_MS).toISOString();
const workspace = await queryOne<{ name: string }>(
deps.db,
`SELECT name FROM workspaces WHERE id = ?`,
[workspaceId],
);

const internal = INTERNAL_ACTION_KINDS.map(() => '?').join(', ');

Expand Down Expand Up @@ -399,6 +408,7 @@ export async function sendDailyDigest(deps: NotifyDeps, workspaceId: string): Pr

const digest: DailyDigest = {
date: today,
...(workspace?.name ? { workspaceName: workspace.name } : {}),
sitesCrawled: crawled,
peopleFound: found,
messagesSent: sent,
Expand Down Expand Up @@ -436,6 +446,8 @@ export async function sendDailyDigest(deps: NotifyDeps, workspaceId: string): Pr
return true;
}

const DAY_MS = 24 * 60 * 60 * 1000;

async function countOne(db: Client, sql: string, args: unknown[]): Promise<number> {
const row = await queryOne<{ n: number }>(db, sql, args as never);
return row?.n ?? 0;
Expand Down
Loading