diff --git a/lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts b/lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts index aa5e3265..759d9218 100644 --- a/lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts +++ b/lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts @@ -3,6 +3,8 @@ import { maybeSendScrapeDigest } from "@/lib/apify/digest/maybeSendScrapeDigest" import { selectApifyScraperRunsByBatch } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch"; import { getScrapeDigestRecipients } from "@/lib/apify/digest/getScrapeDigestRecipients"; import { sendScrapeDigestEmail } from "@/lib/apify/digest/sendScrapeDigestEmail"; +import { selectRecentScrapeDigestLogs } from "@/lib/supabase/email_send_log/selectRecentScrapeDigestLogs"; +import { logEmailAttempt } from "@/lib/emails/logEmailAttempt"; vi.mock("@/lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch", () => ({ selectApifyScraperRunsByBatch: vi.fn(), @@ -13,6 +15,10 @@ vi.mock("@/lib/apify/digest/getScrapeDigestRecipients", () => ({ vi.mock("@/lib/apify/digest/sendScrapeDigestEmail", () => ({ sendScrapeDigestEmail: vi.fn(), })); +vi.mock("@/lib/supabase/email_send_log/selectRecentScrapeDigestLogs", () => ({ + selectRecentScrapeDigestLogs: vi.fn(async () => []), +})); +vi.mock("@/lib/emails/logEmailAttempt", () => ({ logEmailAttempt: vi.fn(async () => {}) })); const run = (over: Record) => ({ run_id: "r1", @@ -27,7 +33,10 @@ const run = (over: Record) => ({ beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getScrapeDigestRecipients).mockResolvedValue(["owner@example.com"]); + vi.mocked(getScrapeDigestRecipients).mockResolvedValue({ + emails: ["owner@example.com"], + artistIds: ["artist-1"], + } as never); vi.mocked(sendScrapeDigestEmail).mockResolvedValue({ id: "email-1" } as never); }); @@ -55,6 +64,21 @@ describe("maybeSendScrapeDigest", () => { { platform: "tiktok", postUrls: ["https://tiktok.com/v/2"] }, ]); // x omitted — nothing new expect(arg.emails).toEqual(["owner@example.com"]); + // audit trail: the send is recorded per watched artist entity + expect(logEmailAttempt).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "artist-1", status: "sent" }), + ); + }); + + it("suppresses the digest when one was sent for the artist within 24h (rate cap)", async () => { + vi.mocked(selectApifyScraperRunsByBatch).mockResolvedValue([ + run({ new_post_urls: ["https://instagram.com/p/1"] }), + ] as never); + vi.mocked(selectRecentScrapeDigestLogs).mockResolvedValue([ + { account_id: "artist-1", created_at: "2026-07-07T00:00:00Z" }, + ] as never); + expect(await maybeSendScrapeDigest("b1")).toBeNull(); + expect(sendScrapeDigestEmail).not.toHaveBeenCalled(); }); it("sends nothing when the batch completes with zero new posts anywhere", async () => { diff --git a/lib/apify/digest/getScrapeDigestRecipients.ts b/lib/apify/digest/getScrapeDigestRecipients.ts index a3c31cd9..56024982 100644 --- a/lib/apify/digest/getScrapeDigestRecipients.ts +++ b/lib/apify/digest/getScrapeDigestRecipients.ts @@ -7,9 +7,11 @@ import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmai * of every artist watching any of them (same chain the per-platform alert * used). Recipients span tenants — senders must BCC (chat#1855). */ -export async function getScrapeDigestRecipients(socialIds: string[]): Promise { +export async function getScrapeDigestRecipients( + socialIds: string[], +): Promise<{ emails: string[]; artistIds: string[] }> { const uniqueSocialIds = Array.from(new Set(socialIds.filter(Boolean))); - if (!uniqueSocialIds.length) return []; + if (!uniqueSocialIds.length) return { emails: [], artistIds: [] }; const accountSocials = ( await Promise.all( @@ -24,5 +26,8 @@ export async function getScrapeDigestRecipients(socialIds: string[]): Promise a.account_id).filter((id): id is string => Boolean(id))), ); const accountEmails = await selectAccountEmails({ accountIds: uniqueAccountIds }); - return Array.from(new Set(accountEmails.map(e => e.email).filter(Boolean))); + return { + emails: Array.from(new Set(accountEmails.map(e => e.email).filter(Boolean))), + artistIds: Array.from(new Set(accountSocials.map(a => a.account_id).filter(Boolean))), + }; } diff --git a/lib/apify/digest/maybeSendScrapeDigest.ts b/lib/apify/digest/maybeSendScrapeDigest.ts index 9247b9dd..72a4e4e0 100644 --- a/lib/apify/digest/maybeSendScrapeDigest.ts +++ b/lib/apify/digest/maybeSendScrapeDigest.ts @@ -1,6 +1,8 @@ import { selectApifyScraperRunsByBatch } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch"; import { getScrapeDigestRecipients } from "@/lib/apify/digest/getScrapeDigestRecipients"; import { sendScrapeDigestEmail } from "@/lib/apify/digest/sendScrapeDigestEmail"; +import { selectRecentScrapeDigestLogs } from "@/lib/supabase/email_send_log/selectRecentScrapeDigestLogs"; +import { logEmailAttempt } from "@/lib/emails/logEmailAttempt"; /** * Batch-completion check for the one-digest-per-scrape design (chat#1855): @@ -20,8 +22,31 @@ export async function maybeSendScrapeDigest(batchId: string | null | undefined) .map(r => ({ platform: r.platform ?? "other", postUrls: r.new_post_urls ?? [] })); if (!sections.length) return null; - const emails = await getScrapeDigestRecipients( + const { emails, artistIds } = await getScrapeDigestRecipients( runs.map(r => r.social_id).filter((id): id is string => Boolean(id)), ); - return await sendScrapeDigestEmail({ emails, sections }); + + // Rate cap: at most one digest per watched artist entity per 24h (chat#1855). + const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const recent = await selectRecentScrapeDigestLogs(artistIds, since); + if (recent.length > 0) return null; + + const sent = await sendScrapeDigestEmail({ emails, sections }); + + // Audit trail: record the send per artist entity so alerts are debuggable + // and the cap has something to read (the solo path never logged at all). + const resendId = (sent as { id?: string } | null)?.id; + if (sent) { + await Promise.all( + artistIds.map(artistId => + logEmailAttempt({ + rawBody: `scrape-digest:${batchId}`, + status: "sent", + accountId: artistId, + resendId, + }), + ), + ); + } + return sent; } diff --git a/lib/supabase/email_send_log/selectRecentScrapeDigestLogs.ts b/lib/supabase/email_send_log/selectRecentScrapeDigestLogs.ts new file mode 100644 index 00000000..a45631db --- /dev/null +++ b/lib/supabase/email_send_log/selectRecentScrapeDigestLogs.ts @@ -0,0 +1,20 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Returns scrape-digest send-log rows for any of the given artist entity ids + * newer than `since` — the digest rate cap's lookback (chat#1855). + */ +export async function selectRecentScrapeDigestLogs(artistIds: string[], since: string) { + if (!artistIds.length) return []; + const { data, error } = await supabase + .from("email_send_log") + .select("account_id, created_at") + .in("account_id", artistIds) + .like("raw_body", "scrape-digest:%") + .gte("created_at", since); + if (error) { + console.error("[ERROR] selectRecentScrapeDigestLogs:", error); + return []; + } + return data ?? []; +}