-
Notifications
You must be signed in to change notification settings - Fork 10
feat(apify): one consolidated new-posts digest per scrape batch #760
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
360268b
9a991be
3b435b4
87599e2
e2a35e1
afc0d9a
5f205f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { maybeSendScrapeDigest } from "@/lib/apify/digest/maybeSendScrapeDigest"; | ||
| import { selectApifyScraperRuns } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns"; | ||
| import { getScrapeDigestRecipients } from "@/lib/apify/digest/getScrapeDigestRecipients"; | ||
| import { sendScrapeDigestEmail } from "@/lib/apify/digest/sendScrapeDigestEmail"; | ||
|
|
||
| vi.mock("@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns", () => ({ | ||
| selectApifyScraperRuns: vi.fn(), | ||
| })); | ||
| vi.mock("@/lib/apify/digest/getScrapeDigestRecipients", () => ({ | ||
| getScrapeDigestRecipients: vi.fn(), | ||
| })); | ||
| vi.mock("@/lib/apify/digest/sendScrapeDigestEmail", () => ({ | ||
| sendScrapeDigestEmail: vi.fn(), | ||
| })); | ||
|
|
||
| const run = (over: Record<string, unknown>) => ({ | ||
| run_id: "r1", | ||
| batch_id: "b1", | ||
| account_id: "acct-1", | ||
| social_id: "s1", | ||
| platform: "instagram", | ||
| completed_at: "2026-07-07T00:00:00Z", | ||
| new_post_urls: [], | ||
| ...over, | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| vi.mocked(getScrapeDigestRecipients).mockResolvedValue(["owner@example.com"]); | ||
| vi.mocked(sendScrapeDigestEmail).mockResolvedValue({ id: "email-1" } as never); | ||
| }); | ||
|
|
||
| describe("maybeSendScrapeDigest", () => { | ||
| it("does nothing while sibling runs are still incomplete", async () => { | ||
| vi.mocked(selectApifyScraperRuns).mockResolvedValue([ | ||
| run({}), | ||
| run({ run_id: "r2", platform: "tiktok", completed_at: null }), | ||
| ] as never); | ||
| expect(await maybeSendScrapeDigest("b1")).toBeNull(); | ||
| expect(sendScrapeDigestEmail).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("sends ONE digest with per-platform new posts when the batch completes", async () => { | ||
| vi.mocked(selectApifyScraperRuns).mockResolvedValue([ | ||
| run({ new_post_urls: ["https://instagram.com/p/1"] }), | ||
| run({ run_id: "r2", platform: "tiktok", new_post_urls: ["https://tiktok.com/v/2"] }), | ||
| run({ run_id: "r3", platform: "x", new_post_urls: [] }), | ||
| ] as never); | ||
| await maybeSendScrapeDigest("b1"); | ||
| expect(sendScrapeDigestEmail).toHaveBeenCalledOnce(); | ||
| const arg = vi.mocked(sendScrapeDigestEmail).mock.calls[0][0]; | ||
| expect(arg.sections).toEqual([ | ||
| { platform: "instagram", postUrls: ["https://instagram.com/p/1"] }, | ||
| { platform: "tiktok", postUrls: ["https://tiktok.com/v/2"] }, | ||
| ]); // x omitted — nothing new | ||
| expect(arg.emails).toEqual(["owner@example.com"]); | ||
| }); | ||
|
|
||
| it("sends nothing when the batch completes with zero new posts anywhere", async () => { | ||
| vi.mocked(selectApifyScraperRuns).mockResolvedValue([ | ||
| run({}), | ||
| run({ run_id: "r2", platform: "tiktok" }), | ||
| ] as never); | ||
| expect(await maybeSendScrapeDigest("b1")).toBeNull(); | ||
| expect(sendScrapeDigestEmail).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("no-ops for a null batch id (legacy runs)", async () => { | ||
| expect(await maybeSendScrapeDigest(null)).toBeNull(); | ||
| expect(selectApifyScraperRuns).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials"; | ||
| import { getAccountArtistIds } from "@/lib/supabase/account_artist_ids/getAccountArtistIds"; | ||
| import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; | ||
|
|
||
| /** | ||
| * Resolves the digest recipients for a set of scraped socials: every owner | ||
| * 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[]> { | ||
| const uniqueSocialIds = Array.from(new Set(socialIds.filter(Boolean))); | ||
| if (!uniqueSocialIds.length) return []; | ||
|
|
||
| const accountSocials = ( | ||
| await Promise.all( | ||
| uniqueSocialIds.map(socialId => selectAccountSocials({ socialId, limit: 10000 })), | ||
| ) | ||
| ).flat(); | ||
|
|
||
| const accountArtistIds = await getAccountArtistIds({ | ||
| artistIds: accountSocials.map(a => a.account_id), | ||
| }); | ||
| const uniqueAccountIds = Array.from( | ||
| 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))); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { selectApifyScraperRuns } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns"; | ||
| import { parseNewPostUrls } from "@/lib/apify/digest/parseNewPostUrls"; | ||
| import { getScrapeDigestRecipients } from "@/lib/apify/digest/getScrapeDigestRecipients"; | ||
| import { sendScrapeDigestEmail } from "@/lib/apify/digest/sendScrapeDigestEmail"; | ||
|
|
||
| /** | ||
| * Batch-completion check for the one-digest-per-scrape design (chat#1855): | ||
| * called after each platform run's results are processed. Sends the single | ||
| * consolidated digest when (a) every sibling run in the batch has completed | ||
| * and (b) at least one platform found genuinely new posts. Platforms with | ||
| * nothing new are omitted; a batch with nothing new sends nothing. | ||
| */ | ||
| export async function maybeSendScrapeDigest(batchId: string | null | undefined) { | ||
| if (!batchId) return null; | ||
|
|
||
| const runs = await selectApifyScraperRuns({ batchId }); | ||
| if (!runs.length || runs.some(r => !r.completed_at)) return null; | ||
|
|
||
| const sections = runs | ||
| .map(r => ({ platform: r.platform ?? "other", postUrls: parseNewPostUrls(r.new_post_urls) })) | ||
| .filter(s => s.postUrls.length > 0); | ||
| if (!sections.length) return null; | ||
|
|
||
| const emails = await getScrapeDigestRecipients( | ||
| runs.map(r => r.social_id).filter((id): id is string => Boolean(id)), | ||
| ); | ||
| return await sendScrapeDigestEmail({ emails, sections }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: A completed batch can send the consolidated digest more than once if the webhook for any registered run is delivered or replayed after all siblings are complete. Consider making the send gate idempotent with an atomic persisted marker/claim for the batch before calling Prompt for AI agents
Comment on lines
+16
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift TOCTOU race: concurrent webhook completions can send duplicate digest emails.
The function also lacks an idempotency guard — nothing marks the batch as "digest sent" after a successful send, so a webhook retry would trigger a second digest even without concurrency. Suggested fix: Add a 🔒 Proposed atomic guard export async function maybeSendScrapeDigest(batchId: string | null | undefined) {
if (!batchId) return null;
const runs = await selectApifyScraperRuns({ batchId });
if (!runs.length || runs.some(r => !r.completed_at)) return null;
const sections = runs
.filter(r => (r.new_post_urls?.length ?? 0) > 0)
.map(r => ({ platform: r.platform ?? "other", postUrls: r.new_post_urls ?? [] }));
if (!sections.length) return null;
+ // Atomically claim the digest send — prevents duplicate emails from
+ // concurrent webhook completions (TOCTOU) and idempotency on retry.
+ const { data: claimed } = await supabase
+ .from("apify_scraper_runs" as never)
+ .update({ digest_sent_at: new Date().toISOString() } as never)
+ .eq("batch_id", batchId)
+ .is("digest_sent_at", null)
+ .select()
+ .limit(1);
+ if (!claimed || claimed.length === 0) return null; // another caller already sent
+
const emails = await getScrapeDigestRecipients(
runs.map(r => r.social_id).filter((id): id is string => Boolean(id)),
);
return await sendScrapeDigestEmail({ emails, sections });
}This requires adding 🤖 Prompt for AI Agents |
||
| } | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Libs which do not directly query supabase should not live in the lib/supabase folder.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved to |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { z } from "zod"; | ||
| import type { Json } from "@/types/database.types"; | ||
|
|
||
| const newPostUrlsSchema = z.array(z.string()); | ||
|
|
||
| /** | ||
| * Parses the `new_post_urls` JSONB column into a string array. JSONB is the | ||
| * one column the generated types can't narrow past `Json`, so this is the | ||
| * runtime boundary: anything malformed degrades to "no new posts" instead of | ||
| * crashing the digest assembler. | ||
| */ | ||
| export function parseNewPostUrls(value: Json | null): string[] { | ||
| const result = newPostUrlsSchema.safeParse(value); | ||
| return result.success ? result.data : []; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,38 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { sendEmailWithResend } from "@/lib/emails/sendEmail"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { RECOUP_FROM_EMAIL } from "@/lib/const"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export type ScrapeDigestSection = { platform: string; postUrls: string[] }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export type ScrapeDigestInput = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| emails: string[]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sections: ScrapeDigestSection[]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| artistName?: string | null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Sends the consolidated new-posts digest: one email per scrape batch, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * one section per platform that found genuinely new posts. Deterministic | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * body, direct links to each new post. Recipients span tenants — BCC only | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * (chat#1855). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function sendScrapeDigestEmail({ emails, sections, artistName }: ScrapeDigestInput) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!emails.length || !sections.length) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const total = sections.reduce((n, s) => n + s.postUrls.length, 0); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const who = artistName || "your artist"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const sectionHtml = sections | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| s => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `<h3 style="margin:16px 0 4px">${s.platform}</h3><ul>${s.postUrls | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(u => `<li><a href="${u.startsWith("http") ? u : `https://${u}`}">${u}</a></li>`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Post URLs and platform names are interpolated into the email HTML body without any escaping. Both the href attribute and visible text content for each link use the raw URL directly, and the platform name goes into an Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .join("")}</ul>`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .join(""); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return await sendEmailWithResend({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from: RECOUP_FROM_EMAIL, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| to: [RECOUP_FROM_EMAIL], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| bcc: emails, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| subject: `${who}: ${total} new post${total === 1 ? "" : "s"} found across ${sections.length} platform${sections.length === 1 ? "" : "s"}`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| html: `<p>New posts found for ${who}:</p>${sectionHtml}`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+22
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win HTML-escape dynamic content to prevent XSS in digest emails. Post URLs (line 26) and 🛡️ Proposed fix: add HTML escaping export async function sendScrapeDigestEmail({ emails, sections, artistName }: ScrapeDigestInput) {
if (!emails.length || !sections.length) return null;
+ const escapeHtml = (s: string) =>
+ s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "&`#39`;");
+
const total = sections.reduce((n, s) => n + s.postUrls.length, 0);
const who = artistName || "your artist";
+ const whoEscaped = escapeHtml(who);
const sectionHtml = sections
.map(
s =>
- `<h3 style="margin:16px 0 4px">${s.platform}</h3><ul>${s.postUrls
- .map(u => `<li><a href="${u.startsWith("http") ? u : `https://${u}`}">${u}</a></li>`)
+ `<h3 style="margin:16px 0 4px">${escapeHtml(s.platform)}</h3><ul>${s.postUrls
+ .map(u => {
+ const href = u.startsWith("http") ? u : `https://${u}`;
+ return `<li><a href="${escapeHtml(href)}">${escapeHtml(u)}</a></li>`;
+ })
.join("")}</ul>`,
)
.join("");
return await sendEmailWithResend({
from: RECOUP_FROM_EMAIL,
to: [RECOUP_FROM_EMAIL],
bcc: emails,
subject: `${who}: ${total} new post${total === 1 ? "" : "s"} found across ${sections.length} platform${sections.length === 1 ? "" : "s"}`,
- html: `<p>New posts found for ${who}:</p>${sectionHtml}`,
+ html: `<p>New posts found for ${whoEscaped}:</p>${sectionHtml}`,
});
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Digest recipients can be silently dropped for a social watched by more than 10,000 account links because this query reads only the first page. Consider paginating or moving this lookup into a DB-side joined query rather than encoding a larger fixed cap.
(Based on your team's feedback about DB-side pagination over raised Supabase limits.)
View Feedback
Prompt for AI agents