diff --git a/apps/api/.test-autopilot-hold-once-3499025.db b/apps/api/.test-autopilot-hold-once-3499025.db new file mode 100644 index 0000000..8b170bb Binary files /dev/null and b/apps/api/.test-autopilot-hold-once-3499025.db differ diff --git a/apps/api/.test-autopilot-imported-address-3499025.db b/apps/api/.test-autopilot-imported-address-3499025.db new file mode 100644 index 0000000..6549eeb Binary files /dev/null and b/apps/api/.test-autopilot-imported-address-3499025.db differ diff --git a/apps/api/.test-autopilot-skip-event-3499025.db b/apps/api/.test-autopilot-skip-event-3499025.db new file mode 100644 index 0000000..5344d95 Binary files /dev/null and b/apps/api/.test-autopilot-skip-event-3499025.db differ diff --git a/apps/api/.test-digest-notes-3499025.db b/apps/api/.test-digest-notes-3499025.db new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/.test-digest-off-3499025.db b/apps/api/.test-digest-off-3499025.db new file mode 100644 index 0000000..d471c50 Binary files /dev/null and b/apps/api/.test-digest-off-3499025.db differ diff --git a/apps/api/.test-digest-plain-3499025.db b/apps/api/.test-digest-plain-3499025.db new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/.test-digest-queue-3499025.db b/apps/api/.test-digest-queue-3499025.db new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/.test-digest-quiet-3499025.db b/apps/api/.test-digest-quiet-3499025.db new file mode 100644 index 0000000..cc770eb Binary files /dev/null and b/apps/api/.test-digest-quiet-3499025.db differ diff --git a/apps/api/.test-digest-window-3499025.db b/apps/api/.test-digest-window-3499025.db new file mode 100644 index 0000000..92888b1 Binary files /dev/null and b/apps/api/.test-digest-window-3499025.db differ diff --git a/packages/email/src/notifications.ts b/packages/email/src/notifications.ts index 25c510c..e394728 100644 --- a/packages/email/src/notifications.ts +++ b/packages/email/src/notifications.ts @@ -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; @@ -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 @@ -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')}` : '', '', @@ -248,7 +258,10 @@ export function dailyDigestEmail(to: string, digest: DailyDigest, appUrl: string const html = [ markHtml(base), - `

${escapeHtml(digest.date)}

`, + `

${escapeHtml(digest.date)}

`, + `

${ + digest.workspaceName ? `${escapeHtml(digest.workspaceName)} · ` : '' + }last 24 hours

`, '', row('Sites read', digest.sitesCrawled), row('New people', digest.peopleFound), diff --git a/packages/pipeline/src/autopilot.ts b/packages/pipeline/src/autopilot.ts index c0c27b7..55bdd40 100644 --- a/packages/pipeline/src/autopilot.ts +++ b/packages/pipeline/src/autopilot.ts @@ -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(); 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); @@ -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'; diff --git a/packages/pipeline/src/notify.test.ts b/packages/pipeline/src/notify.test.ts index 4328970..58b0e17 100644 --- a/packages/pipeline/src/notify.test.ts +++ b/packages/pipeline/src/notify.test.ts @@ -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'); diff --git a/packages/pipeline/src/notify.ts b/packages/pipeline/src/notify.ts index b893f70..f06357e 100644 --- a/packages/pipeline/src/notify.ts +++ b/packages/pipeline/src/notify.ts @@ -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(', '); @@ -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, @@ -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 { const row = await queryOne<{ n: number }>(db, sql, args as never); return row?.n ?? 0;