Skip to content
41 changes: 41 additions & 0 deletions lib/apify/__tests__/apifyWebhookHandler.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { updateApifyScraperRun } from "@/lib/supabase/apify_scraper_runs/updateApifyScraperRun";
import { maybeSendScrapeDigest } from "@/lib/apify/digest/maybeSendScrapeDigest";
import { NextRequest } from "next/server";
import { apifyWebhookHandler } from "../apifyWebhookHandler";
import { handleInstagramProfileScraperResults } from "../instagram/handleInstagramProfileScraperResults";
Expand Down Expand Up @@ -44,6 +46,13 @@ const baseBody = {
resource: { defaultDatasetId: "ds_1" },
};

vi.mock("@/lib/supabase/apify_scraper_runs/updateApifyScraperRun", () => ({
updateApifyScraperRun: vi.fn(async () => null),
}));
vi.mock("@/lib/apify/digest/maybeSendScrapeDigest", () => ({
maybeSendScrapeDigest: vi.fn(async () => null),
}));

describe("apifyWebhookHandler", () => {
beforeEach(() => vi.clearAllMocks());

Expand All @@ -60,6 +69,38 @@ describe("apifyWebhookHandler", () => {
expect(handleInstagramCommentsScraper).not.toHaveBeenCalled();
});

it("records batch completion + triggers the digest check when the payload carries a run id (chat#1855)", async () => {
vi.mocked(handleInstagramProfileScraperResults).mockResolvedValue({
posts: [],
newPostUrls: ["https://instagram.com/p/new1"],
} as never);
vi.mocked(updateApifyScraperRun).mockResolvedValue({
run_id: "run-9",
batch_id: "batch-7",
} as never);

const res = await apifyWebhookHandler(
makeRequest({
...baseBody,
eventData: { actorId: "dSCLg0C3YEZ83HzYX" },
resource: { ...baseBody.resource, id: "run-9" },
}),
);

expect(res.status).toBe(200);
expect(updateApifyScraperRun).toHaveBeenCalledWith("run-9", ["https://instagram.com/p/new1"]);
expect(maybeSendScrapeDigest).toHaveBeenCalledWith("batch-7");
});

it("skips digest bookkeeping when the payload has no run id", async () => {
vi.mocked(handleInstagramProfileScraperResults).mockResolvedValue({ posts: [] } as never);
await apifyWebhookHandler(
makeRequest({ ...baseBody, eventData: { actorId: "dSCLg0C3YEZ83HzYX" } }),
);
expect(updateApifyScraperRun).not.toHaveBeenCalled();
expect(maybeSendScrapeDigest).not.toHaveBeenCalled();
});

it("dispatches comments scraper for the IG comments actor", async () => {
vi.mocked(handleInstagramCommentsScraper).mockResolvedValue({
comments: [],
Expand Down
18 changes: 18 additions & 0 deletions lib/apify/apifyWebhookHandler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { validateApifyWebhookRequest } from "@/lib/apify/validateApifyWebhookRequest";
import { getApifyResultHandler } from "@/lib/apify/getApifyResultHandler";
import { updateApifyScraperRun } from "@/lib/supabase/apify_scraper_runs/updateApifyScraperRun";
import { maybeSendScrapeDigest } from "@/lib/apify/digest/maybeSendScrapeDigest";

/**
* Handler for `POST /api/apify`. Always responds 200 so Apify does not
Expand All @@ -27,6 +29,22 @@ export async function apifyWebhookHandler(request: NextRequest): Promise<NextRes

try {
const result = await handler(validated);

// Digest-batch bookkeeping (chat#1855): record this run's genuinely-new
// posts and, when it was the batch's last completion, send the single
// consolidated digest. Never fails the webhook.
const runId = validated.resource.id;
if (runId) {
try {
const newPostUrls =
(result as { newPostUrls?: string[] } | null | undefined)?.newPostUrls ?? [];
const run = await updateApifyScraperRun(runId, newPostUrls);
await maybeSendScrapeDigest(run?.batch_id);
} catch (digestError) {
console.error("[WARN] scrape digest bookkeeping failed:", digestError);
}
}

return NextResponse.json(result, { status: 200 });
} catch (error) {
console.error("[ERROR] apifyWebhookHandler:", error);
Expand Down
73 changes: 73 additions & 0 deletions lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts
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();
});
});
28 changes: 28 additions & 0 deletions lib/apify/digest/getScrapeDigestRecipients.ts
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 })),

Copy link
Copy Markdown

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
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/getScrapeDigestRecipients.ts, line 16:

<comment>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.) </comment>

<file context>
@@ -0,0 +1,28 @@
+
+  const accountSocials = (
+    await Promise.all(
+      uniqueSocialIds.map(socialId => selectAccountSocials({ socialId, limit: 10000 })),
+    )
+  ).flat();
</file context>

)
).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)));
}
28 changes: 28 additions & 0 deletions lib/apify/digest/maybeSendScrapeDigest.ts
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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 sendScrapeDigestEmail.

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 26:

<comment>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 `sendScrapeDigestEmail`.</comment>

<file context>
@@ -0,0 +1,27 @@
+  const emails = await getScrapeDigestRecipients(
+    runs.map(r => r.social_id).filter((id): id is string => Boolean(id)),
+  );
+  return await sendScrapeDigestEmail({ emails, sections });
+}
</file context>

Comment on lines +16 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

maybeSendScrapeDigest reads all batch runs, checks completion, and sends the digest in a non-atomic sequence. When two runs in the same batch complete nearly simultaneously (e.g., Instagram and TikTok webhooks arrive within milliseconds), both webhook handlers can observe all runs as completed and both call sendScrapeDigestEmail, producing duplicate emails to recipients.

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 digest_sent_at timestamp column to apify_scraper_runs (coordinated with database#47) and atomically claim the send right before emailing:

🔒 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 digest_sent_at to the apify_scraper_runs schema in database#47. The atomic UPDATE ... WHERE digest_sent_at IS NULL ensures only one caller wins the claim.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/apify/digest/maybeSendScrapeDigest.ts` around lines 15 - 26,
maybeSendScrapeDigest is vulnerable to duplicate sends because it checks
completion and sends emails non-atomically; add an idempotency guard using the
new digest_sent_at field on apify_scraper_runs so only one handler can claim the
batch. Update maybeSendScrapeDigest to atomically mark the relevant runs as
claimed before calling getScrapeDigestRecipients and sendScrapeDigestEmail, and
bail out if the claim fails or digest_sent_at is already set.

}
15 changes: 15 additions & 0 deletions lib/apify/digest/parseNewPostUrls.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved to lib/apify/digest/parseNewPostUrls.ts in afc0d9a — agreed, it's a pure JSONB parser with no supabase query, and its only consumer is the digest assembler next door. Suite green after the move (97 tests in lib/apify), eslint + tsc clean.

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 : [];
}
38 changes: 38 additions & 0 deletions lib/apify/digest/sendScrapeDigestEmail.ts
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>`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 <h3> element. A URL containing a " would break the href attribute; a URL or platform with < or > could inject arbitrary HTML into the email. Consider encoding the URL for the href attribute (e.g., wrapping with a helper that escapes &, ", <, >) and optionally encoding visible text too, as a defense-in-depth measure.

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

<comment>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 `<h3>` element. A URL containing a `"` would break the href attribute; a URL or platform with `<` or `>` could inject arbitrary HTML into the email. Consider encoding the URL for the href attribute (e.g., wrapping with a helper that escapes `&`, `"`, `<`, `>`) and optionally encoding visible text too, as a defense-in-depth measure.</comment>

<file context>
@@ -0,0 +1,38 @@
+    .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>`)
+          .join("")}</ul>`,
+    )
</file context>

.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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 artistName/who (line 36) are interpolated directly into HTML without escaping. A scraped URL containing " could break out of the href attribute, and one containing <script> could inject markup. While social platforms validate URLs, this is external data — defense in depth requires escaping.

🛡️ 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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>`)
.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}`,
const escapeHtml = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").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">${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 ${whoEscaped}:</p>${sectionHtml}`,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/apify/digest/sendScrapeDigestEmail.ts` around lines 22 - 36, The digest
email HTML in sendScrapeDigestEmail interpolates untrusted scraped data directly
into attributes and text, so escape all dynamic values before building the
markup. Update the sectionHtml mapping in sendScrapeDigestEmail to HTML-escape
s.platform, each post URL used in both the href and visible link text, and the
who value used in the subject/body so injected quotes or tags cannot break the
email template. Keep the existing sendEmailWithResend flow and apply escaping at
the point where sectionHtml and the html string are constructed.

});
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getAccountArtistIds } from "@/lib/supabase/account_artist_ids/getAccoun
import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails";
import { uploadLinkToArweave } from "@/lib/arweave/uploadLinkToArweave";
import { filterNewPostUrls } from "@/lib/socials/filterNewPostUrls";
import { selectApifyScraperRun } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRun";

vi.mock("@/lib/apify/client", () => ({ default: { dataset: vi.fn() } }));

Expand All @@ -28,6 +29,9 @@ vi.mock("../handleInstagramProfileFollowUpRuns", () => ({
}));
vi.mock("@/lib/apify/sendApifyWebhookEmail", () => ({ sendApifyWebhookEmail: vi.fn() }));
vi.mock("@/lib/socials/filterNewPostUrls", () => ({ filterNewPostUrls: vi.fn() }));
vi.mock("@/lib/supabase/apify_scraper_runs/selectApifyScraperRun", () => ({
selectApifyScraperRun: vi.fn(async () => null),
}));
vi.mock("@/lib/supabase/socials/upsertSocials", () => ({ upsertSocials: vi.fn() }));
vi.mock("@/lib/supabase/socials/selectSocials", () => ({
selectSocials: vi.fn(),
Expand Down Expand Up @@ -128,4 +132,36 @@ describe("handleInstagramProfileScraperResults", () => {
expect(handleInstagramProfileFollowUpRuns).toHaveBeenCalledOnce();
expect(result.social).toEqual({ id: "s1" });
});

it("suppresses the solo email for digest-batch runs (webhook layer sends ONE digest)", async () => {
mockDataset([
{
latestPosts: [{ url: "u-new", timestamp: "t" }],
username: "alice",
url: "instagram.com/alice",
profilePicUrl: "https://a",
fullName: "Alice",
},
]);
vi.mocked(selectApifyScraperRun).mockResolvedValue({
run_id: "run-1",
batch_id: "b1",
} as never);
vi.mocked(upsertPosts).mockResolvedValue({ data: null, error: null } as never);
vi.mocked(getPosts).mockResolvedValue([{ id: "p1", post_url: "u-new" }] as never);
vi.mocked(uploadLinkToArweave).mockResolvedValue(null);
vi.mocked(upsertSocials).mockResolvedValue([] as never);
vi.mocked(selectSocials).mockResolvedValue([{ id: "s1" }] as never);
vi.mocked(selectAccountSocials).mockResolvedValue([{ account_id: "a1" }] as never);
vi.mocked(getAccountArtistIds).mockResolvedValue([{ account_id: "a1" }] as never);
vi.mocked(selectAccountEmails).mockResolvedValue([{ email: "x@y.com" }] as never);

const result = await handleInstagramProfileScraperResults({
...(payload as Record<string, unknown>),
resource: { defaultDatasetId: "ds_1", id: "run-1" },
} as never);

expect(sendApifyWebhookEmail).not.toHaveBeenCalled(); // digest covers it
expect(result.newPostUrls).toEqual(["u-new"]);
});
});
10 changes: 8 additions & 2 deletions lib/apify/instagram/handleInstagramProfileScraperResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { getFetchableUrl } from "@/lib/arweave/getFetchableUrl";
import type { ApifyInstagramProfileResult } from "@/lib/apify/types";
import type { ApifyWebhookPayload } from "@/lib/apify/validateApifyWebhookRequest";
import { filterNewPostUrls } from "@/lib/socials/filterNewPostUrls";
import { selectApifyScraperRun } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRun";
import type { TablesInsert } from "@/types/database.types";

/**
Expand Down Expand Up @@ -98,11 +99,16 @@ export async function handleInstagramProfileScraperResults(parsed: ApifyWebhookP

// Email + follow-up scrape are independent side effects; isolate so a
// mail outage doesn't block comment scraping and vice versa.
// Digest-batch runs get ONE consolidated email from the webhook layer —
// suppress the per-platform solo email for them (chat#1855). Legacy runs
// (no batch registration) keep the immediate alert.
const registeredRun = parsed.resource.id ? await selectApifyScraperRun(parsed.resource.id) : null;

let sentEmails = null;
try {
// Only notify when the scrape actually found posts new to the platform —
// otherwise every scrape re-announces the profile's recent feed.
if (newPostUrls.length > 0) {
if (newPostUrls.length > 0 && !registeredRun?.batch_id) {
sentEmails = await sendApifyWebhookEmail(
firstResult,
accountEmails.map(e => e.email).filter(Boolean),
Expand All @@ -118,5 +124,5 @@ export async function handleInstagramProfileScraperResults(parsed: ApifyWebhookP
console.error("[WARN] follow-up scrape failed:", error);
}

return { posts, social, accountSocials, accountEmails, sentEmails };
return { posts, social, accountSocials, accountEmails, sentEmails, newPostUrls };
}
Loading
Loading