Skip to content
Open
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
26 changes: 25 additions & 1 deletion lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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<string, unknown>) => ({
run_id: "r1",
Expand All @@ -27,7 +33,10 @@ const run = (over: Record<string, unknown>) => ({

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);
});

Expand Down Expand Up @@ -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 () => {
Expand Down
11 changes: 8 additions & 3 deletions lib/apify/digest/getScrapeDigestRecipients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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(
Expand All @@ -24,5 +26,8 @@ export async function getScrapeDigestRecipients(socialIds: string[]): Promise<st
new Set(accountArtistIds.map(a => 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))),
};
}
29 changes: 27 additions & 2 deletions lib/apify/digest/maybeSendScrapeDigest.ts
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Failed email sends are logged as "sent" in the audit trail. sendScrapeDigestEmail calls sendEmailWithResend, which returns a NextResponse error object (truthy, but no id) when Resend returns an error. The if (sent) guard incorrectly treats this as a successful delivery, so logEmailAttempt records status: "sent" even though the email was never sent. The resendId is undefined in this case, so the audit trail has rows claiming a successful send with a null Resend ID — no way to distinguish real deliveries from API failures. Fix: gate on resendId instead of sent, since a successful Resend response always has an id.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/maybeSendScrapeDigest.ts, line 39:

<comment>Failed email sends are logged as "sent" in the audit trail. `sendScrapeDigestEmail` calls `sendEmailWithResend`, which returns a `NextResponse` error object (truthy, but no `id`) when Resend returns an error. The `if (sent)` guard incorrectly treats this as a successful delivery, so `logEmailAttempt` records `status: "sent"` even though the email was never sent. The `resendId` is `undefined` in this case, so the audit trail has rows claiming a successful send with a null Resend ID — no way to distinguish real deliveries from API failures. Fix: gate on `resendId` instead of `sent`, since a successful Resend response always has an `id`.</comment>

<file context>
@@ -20,8 +22,31 @@ export async function maybeSendScrapeDigest(batchId: string | null | undefined)
+  // 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 =>
</file context>
Suggested change
if (sent) {
if (resendId) {

await Promise.all(
artistIds.map(artistId =>
logEmailAttempt({
rawBody: `scrape-digest:${batchId}`,
status: "sent",
accountId: artistId,
resendId,
}),
),
);
}
return sent;
}
20 changes: 20 additions & 0 deletions lib/supabase/email_send_log/selectRecentScrapeDigestLogs.ts
Original file line number Diff line number Diff line change
@@ -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 ?? [];
}