Skip to content

feat(apify): one consolidated new-posts digest per scrape batch#760

Merged
sweetmantech merged 7 commits into
mainfrom
feat/scrape-digest-aggregation
Jul 9, 2026
Merged

feat(apify): one consolidated new-posts digest per scrape batch#760
sweetmantech merged 7 commits into
mainfrom
feat/scrape-digest-aggregation

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

The digest architecture for recoupable/chat#1855 (PR #4 of 6, decision: one email per scrape, all platforms): registers each platform run under a batch_id at scrape start, records completions + genuinely-new post URLs in the webhook layer, and sends one consolidated digest when the batch's last run completes.

Stacked on api#759 (newness diff — provides filterNewPostUrls); requires database#41 (batch columns) applied before this deploys. Base is #759's branch so the diff shows only this PR's work; GitHub retargets to main when #759 merges.

What changed

  • lib/supabase/apify_scraper_runs/ (new): insertApifyScraperRuns (start-time registration), completeApifyScraperRun (completion + new URLs), selectApifyScraperRunsByBatch, selectApifyScraperRun, local row type (generated types don't carry database#41's columns yet).
  • lib/apify/digest/ (new): maybeSendScrapeDigest (the brain: all-siblings-complete gate → per-platform sections → skip-if-nothing-new), getScrapeDigestRecipients (same watcher chain the solo alert used), sendScrapeDigestEmail (BCC-only, deterministic minimal body — upgraded to the house template in PR Sweetmantech/myc 3550 apiimagegenerate use credits #5).
  • postArtistSocialsScrapeHandler: mints the batch, registers runs (with platform + social mapping via a new additive profileUrl on scrapeProfileUrlBatch results); response contract unchanged (extra field stripped).
  • apifyWebhookHandler: after each result handler, completes the run and runs the digest check; failures never fail the webhook. Schema now passes through the optional resource.id Apify already sends.
  • TikTok/Twitter handlers now compute + return newPostUrls (pre-persist diff); Instagram suppresses its solo email for batch-registered runs (legacy runs keep it).

Tests (RED→GREEN)

  • maybeSendScrapeDigest: 4 cases — waits for incomplete siblings; one send with per-platform sections (empty platforms omitted); nothing-new sends nothing; null batch no-ops.
  • Webhook: completion recorded with the handler's new URLs + digest check with the run's batch id; no-run-id skips bookkeeping.
  • Instagram: batch-registered run with new posts → no solo email, newPostUrls returned.
  • Sweeps: lib/apify + lib/artist + lib/socials272 tests passed; tsc --noEmit at exact baseline parity (200 pre-existing, 0 new); eslint clean.

Merge sequencing

database#41 → api#759 → this. E2E verification (real batch scrape → one digest) planned post-merge on the tracking issue since Apify webhooks call the deployed receiver.

🤖 Generated with Claude Code


Summary by cubic

Send one consolidated new-posts digest per scrape batch across all platforms. Implements chat#1855 by batching runs, diffing genuinely new URLs, and emailing a single BCC digest when the last run completes.

  • New Features

    • Adds apify_scraper_runs helpers: upsertApifyScraperRuns (batch registration), updateApifyScraperRun (completion + new URLs), selectApifyScraperRuns (optional { batchId }), and selectApifyScraperRun; helpers use generated Tables/TablesInsert types.
    • Webhook accepts optional resource.id, records completion with updateApifyScraperRun, and calls maybeSendScrapeDigest; digest errors never fail the webhook.
    • Digest module: getScrapeDigestRecipients, parseNewPostUrls (safe JSONB parser), maybeSendScrapeDigest, sendScrapeDigestEmail (per‑platform sections, BCC‑only; empty platforms omitted).
    • TikTok/Twitter handlers return newPostUrls (pre‑persist diff); Instagram returns newPostUrls and suppresses the solo email for batch runs (legacy runs unchanged).
    • postArtistSocialsScrapeHandler mints a batch_id, registers runs via upsertApifyScraperRuns with platform/social mapping (from profileUrl in scrapeProfileUrlBatch), and returns the same response shape (extra field stripped).
  • Migration

    • Ensure apify_scraper_runs has batch_id, completed_at, and new_post_urls before deploying; types/database.types.ts regenerated (database#47). No API response changes.

Written for commit 5f205f1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added digest-based email notifications for newly scraped posts, including consolidated updates across platforms.
    • Scrape runs now track batch information and newly discovered post links for better follow-up handling.
    • Batch scrape results now include the original profile URL for easier reference.
  • Bug Fixes

    • Improved notification behavior so per-platform emails are suppressed during batch digest runs.
    • Webhook processing now continues successfully even if digest-related follow-up steps fail.

sweetmantech and others added 2 commits July 6, 2026 16:55
… posts first

The Instagram scrape alert fired whenever a scrape returned posts, with no
comparison against posts already stored — every scrape re-announced the
profile's recent feed as new (observed: 6 of 7 alerts in one day announced
posts up to 10 days old). New reusable filterNewPostUrls diffs candidate
URLs against posts BEFORE upsert; the alert is gated on a non-empty result.
Persistence unchanged (recoupable/chat#1855, PR #3 of 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A roster scrape starts one Apify run per platform, each completing
independently — extending per-platform alerts would mean 4+ emails per
scrape. This registers every run under a batch_id at scrape start
(apify_scraper_runs, columns from recoupable/database#41), records each
webhook completion with its genuinely-new post URLs, and when the batch's
last run completes sends ONE digest (per-platform sections, BCC-only)
via the new digest module. Platforms with nothing new are omitted; a
batch with nothing new sends nothing. Instagram's solo alert is
suppressed for batch runs; legacy/non-batch runs keep today's behavior
(recoupable/chat#1855, PR #4 of 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 9, 2026 8:27pm

Request Review

@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c87ad625-16da-4cb1-ac30-b69f6191c23f

📥 Commits

Reviewing files that changed from the base of the PR and between 87599e2 and 5f205f1.

⛔ Files ignored due to path filters (1)
  • types/database.types.ts is excluded by none and included by none
📒 Files selected for processing (6)
  • lib/apify/digest/maybeSendScrapeDigest.ts
  • lib/apify/digest/parseNewPostUrls.ts
  • lib/supabase/apify_scraper_runs/selectApifyScraperRun.ts
  • lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts
  • lib/supabase/apify_scraper_runs/updateApifyScraperRun.ts
  • lib/supabase/apify_scraper_runs/upsertApifyScraperRuns.ts
📝 Walkthrough

Walkthrough

This PR adds batch-based digest emailing for Apify social scrapes. It introduces a new apify_scraper_runs Supabase table layer, registers runs with a shared batchId at scrape kickoff, tracks new post URLs per platform handler, and sends one consolidated digest email per completed batch instead of per-platform solo emails.

Changes

Scrape digest batching feature

Layer / File(s) Summary
apify_scraper_runs data layer
lib/supabase/apify_scraper_runs/types.ts, selectApifyScraperRun.ts, selectApifyScraperRuns.ts, updateApifyScraperRun.ts, upsertApifyScraperRuns.ts
Adds ApifyScraperRunRow type and Supabase helpers to select one or many runs, update completion timestamp/new post URLs, and upsert newly started runs keyed by run_id.
Batch registration at scrape kickoff
lib/artist/postArtistSocialsScrapeHandler.ts, lib/apify/scrapeProfileUrlBatch.ts, lib/apify/validateApifyWebhookRequest.ts
Generates a batchId, upserts apify_scraper_runs rows with derived social_id/platform for each result, includes profileUrl in batch scrape results, and allows an optional resource.id in the webhook payload schema.
Per-platform new-post detection
lib/apify/instagram/..., lib/apify/tiktok/..., lib/apify/twitter/...
Instagram, TikTok, and Twitter result handlers now compute newPostUrls and return it; Instagram also looks up the registered run and suppresses its solo notification email when the run belongs to a batch.
Digest recipients and email assembly
lib/apify/digest/getScrapeDigestRecipients.ts, maybeSendScrapeDigest.ts, sendScrapeDigestEmail.ts
Resolves social IDs to recipient emails, checks batch completion and gathers per-platform new-post sections, and sends one consolidated HTML digest email via Resend.
Webhook handler digest bookkeeping
lib/apify/apifyWebhookHandler.ts
Persists newPostUrls to the scraper run via updateApifyScraperRun and triggers maybeSendScrapeDigest, with failures caught and logged without disrupting the main 200 response.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Apify
  participant apifyWebhookHandler
  participant PlatformHandler as Instagram/TikTok/Twitter Handler
  participant updateApifyScraperRun
  participant maybeSendScrapeDigest
  participant selectApifyScraperRuns
  participant getScrapeDigestRecipients
  participant sendScrapeDigestEmail

  Apify->>apifyWebhookHandler: webhook payload (resource.id)
  apifyWebhookHandler->>PlatformHandler: process scrape results
  PlatformHandler-->>apifyWebhookHandler: newPostUrls, posts, social
  apifyWebhookHandler->>updateApifyScraperRun: update run (newPostUrls, completed_at)
  updateApifyScraperRun-->>apifyWebhookHandler: run with batch_id
  apifyWebhookHandler->>maybeSendScrapeDigest: maybeSendScrapeDigest(batch_id)
  maybeSendScrapeDigest->>selectApifyScraperRuns: fetch runs for batch
  selectApifyScraperRuns-->>maybeSendScrapeDigest: runs list
  maybeSendScrapeDigest->>getScrapeDigestRecipients: resolve socialIds to emails
  getScrapeDigestRecipients-->>maybeSendScrapeDigest: recipient emails
  maybeSendScrapeDigest->>sendScrapeDigestEmail: send consolidated digest
  sendScrapeDigestEmail-->>maybeSendScrapeDigest: send result
  apifyWebhookHandler-->>Apify: 200 OK
Loading

Poem

A batch of socials, hand in hand,
Now wait in queue, all runs unplanned,
One email sent, no spam, no fuss —
"New posts arrived!" says the digest bus. 📬
SOLID little functions, each doing one thing right,
ᕕ( ᐛ )ᕗ shipping digests through the night.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Solid & Clean Code ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/scrape-digest-aggregation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

6 issues found across 21 files

Confidence score: 3/5

  • lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts returns [] on query failure, so maybeSendScrapeDigest can treat DB errors as “no runs” and silently skip a digest; users may miss notifications with no clear signal. Return/propagate an error (or distinguish error vs empty result) before merging.
  • lib/apify/digest/maybeSendScrapeDigest.ts can send the same consolidated digest multiple times if a webhook is replayed after all runs are complete, which can spam recipients and create inconsistent state. Add an idempotent, atomic send guard keyed by batch before merging.
  • lib/apify/digest/sendScrapeDigestEmail.ts interpolates raw URLs and platform names into HTML, so malformed or hostile values could produce unsafe or misleading email content. Escape/sanitize URL/text fields before building the email body.
  • New batch/digest paths in lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts and lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts are not asserted, increasing regression risk in batch registration and newPostUrls digest inputs. Add focused assertions for batchId/social mapping/run insertion and filterNewPostUrls integration before merging.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts">

<violation number="1" location="lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts:5">
P2: The handler now calls `filterNewPostUrls` and includes `newPostUrls` in its return value (drives the digest email), but the tests don't verify that integration. Consider adding an assertion on the `filterNewPostUrls` call args and on `newPostUrls` in the return value so the mock isn't purely ornamental — the digest behavior depends on correct new-post tracking.</violation>
</file>

<file name="lib/apify/digest/getScrapeDigestRecipients.ts">

<violation number="1" location="lib/apify/digest/getScrapeDigestRecipients.ts:16">
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.) [FEEDBACK_USED]</violation>
</file>

<file name="lib/apify/digest/sendScrapeDigestEmail.ts">

<violation number="1" location="lib/apify/digest/sendScrapeDigestEmail.ts:26">
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.</violation>
</file>

<file name="lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts">

<violation number="1" location="lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts:12">
P1: The batch registration logic added to `postArtistSocialsScrapeHandler` (batchId generation, socialByUrl mapping, insertApifyScraperRuns call) has no test coverage in the handler's own test file. The test file was updated with the required mock and matching return shapes, but no `it(...)` block verifies the new behavior. This leaves the digest pipeline's registration step untested, so regressions — wrong account_id, wrong platform derivation, missing social_id, or a broken batchId — would go undetected in CI. Consider adding test cases that assert on the arguments passed to `insertApifyScraperRuns`, verify the response body omits profileUrl, and confirm the call is skipped in early-return paths (auth failure, 403, 402).</violation>
</file>

<file name="lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts">

<violation number="1" location="lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts:8">
P1: Silent error swallowing in batch query can suppress the digest. When the Supabase query fails, the function returns an empty array, making it indistinguishable from an empty batch. The caller treats `runs.length === 0` as "batch not ready" and returns null without re-raising or alerting — so a transient DB error on the last sibling's completion webhook silently drops the consolidated digest. Consider either propagating the error (so the caller can retry) or adding a dedicated recovery path (e.g., a queue retry or alert) for query failures.</violation>
</file>

<file name="lib/apify/digest/maybeSendScrapeDigest.ts">

<violation number="1" location="lib/apify/digest/maybeSendScrapeDigest.ts:26">
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`.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client
    participant ScrapeHandler as postArtistSocialsScrapeHandler
    participant ScrapeBatch as scrapeProfileUrlBatch
    participant DB as Supabase DB
    participant Apify as Apify (external)
    participant WebhookHandler as apifyWebhookHandler
    participant ResultHandler as per‑platform result handler
    participant Digest as maybeSendScrapeDigest
    participant EmailService as digest email service

    rect rgb(240, 240, 240)
        Note over Client,ScrapeHandler: Phase 1 — Batch initialization
    end

    Client->>ScrapeHandler: POST /artist/socials/scrape (artist_id, socials)
    ScrapeHandler->>ScrapeBatch: CHANGED: scrapeProfileUrlBatch(socials)
    ScrapeBatch->>Apify: start scrape per social
    Apify-->>ScrapeBatch: runId, datasetId (per social)
    ScrapeBatch-->>ScrapeHandler: CHANGED: results include profileUrl
    ScrapeHandler->>DB: NEW: insertApifyScraperRuns(runs with batch_id, social_id, platform)
    alt registration succeeds
        DB-->>ScrapeHandler: ok
    else registration fails
        DB-->>ScrapeHandler: error (logged, scrape continues)
    end
    Note over ScrapeHandler: strips profileUrl from response (contract unchanged)
    ScrapeHandler-->>Client: 200 {runId, datasetId, error}[]

    rect rgb(245, 245, 245)
        Note over Apify,WebhookHandler: Phase 2 — Webhook processing (each run)
    end

    Apify->>WebhookHandler: POST /api/apify (webhook for run‑X)
    alt payload has no runId
        Note over WebhookHandler: skip digest bookkeeping (legacy path)
    else runId present
        WebhookHandler->>ResultHandler: dispatch per actor type
        Note over ResultHandler: e.g., handleInstagramProfileScraperResults
        alt Instagram with batch_id
            ResultHandler->>ResultHandler: NEW: suppress solo email (skip sendApifyWebhookEmail)
        end
        ResultHandler->>DB: NEW: filterNewPostUrls (pre‑persist diff)
        DB-->>ResultHandler: newPostUrls[]
        ResultHandler-->>WebhookHandler: CHANGED: {posts, newPostUrls, ...}
        WebhookHandler->>DB: NEW: completeApifyScraperRun(runId, newPostUrls)
        DB-->>WebhookHandler: updated row (with batch_id)
        WebhookHandler->>Digest: NEW: maybeSendScrapeDigest(batch_id)
        Digest->>DB: NEW: selectApifyScraperRunsByBatch(batch_id)
        DB-->>Digest: all runs in batch
        alt any sibling incomplete
            Note over Digest: wait for last run
            Digest-->>WebhookHandler: null (no email)
        else all completed
            alt at least one platform has new posts
                Digest->>EmailService: NEW: getScrapeDigestRecipients(socialIds)
                EmailService->>DB: selectAccountSocials → getAccountArtistIds → selectAccountEmails
                DB-->>EmailService: watcher email addresses
                EmailService-->>Digest: [watcher@...]
                Digest->>EmailService: NEW: sendScrapeDigestEmail(emails, sections)
                Note over EmailService: BCC‑only, per‑platform sections
                EmailService-->>Digest: email id
                Digest-->>WebhookHandler: email id
            else no new posts anywhere
                Note over Digest: skip (sends nothing)
                Digest-->>WebhookHandler: null
            end
        end
    end
    Note over WebhookHandler: Always return 200 (digest errors caught & logged)
    WebhookHandler-->>Apify: 200 OK
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts Outdated
import { ensureSocialScrapeCredits } from "@/lib/socials/ensureSocialScrapeCredits";
import { deductSocialScrapeCredits } from "@/lib/socials/deductSocialScrapeCredits";

vi.mock("@/lib/supabase/apify_scraper_runs/insertApifyScraperRuns", () => ({

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: The batch registration logic added to postArtistSocialsScrapeHandler (batchId generation, socialByUrl mapping, insertApifyScraperRuns call) has no test coverage in the handler's own test file. The test file was updated with the required mock and matching return shapes, but no it(...) block verifies the new behavior. This leaves the digest pipeline's registration step untested, so regressions — wrong account_id, wrong platform derivation, missing social_id, or a broken batchId — would go undetected in CI. Consider adding test cases that assert on the arguments passed to insertApifyScraperRuns, verify the response body omits profileUrl, and confirm the call is skipped in early-return paths (auth failure, 403, 402).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts, line 12:

<comment>The batch registration logic added to `postArtistSocialsScrapeHandler` (batchId generation, socialByUrl mapping, insertApifyScraperRuns call) has no test coverage in the handler's own test file. The test file was updated with the required mock and matching return shapes, but no `it(...)` block verifies the new behavior. This leaves the digest pipeline's registration step untested, so regressions — wrong account_id, wrong platform derivation, missing social_id, or a broken batchId — would go undetected in CI. Consider adding test cases that assert on the arguments passed to `insertApifyScraperRuns`, verify the response body omits profileUrl, and confirm the call is skipped in early-return paths (auth failure, 403, 402).</comment>

<file context>
@@ -9,6 +9,9 @@ import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess
 import { ensureSocialScrapeCredits } from "@/lib/socials/ensureSocialScrapeCredits";
 import { deductSocialScrapeCredits } from "@/lib/socials/deductSocialScrapeCredits";
 
+vi.mock("@/lib/supabase/apify_scraper_runs/insertApifyScraperRuns", () => ({
+  insertApifyScraperRuns: vi.fn(async () => ({ data: null, error: null })),
+}));
</file context>

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>

import { handleTwitterProfileScraperResults } from "@/lib/apify/twitter/handleTwitterProfileScraperResults";
const listItems = vi.fn();
vi.mock("@/lib/socials/filterNewPostUrls", () => ({
filterNewPostUrls: vi.fn(async (urls: string[]) => urls),

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: The handler now calls filterNewPostUrls and includes newPostUrls in its return value (drives the digest email), but the tests don't verify that integration. Consider adding an assertion on the filterNewPostUrls call args and on newPostUrls in the return value so the mock isn't purely ornamental — the digest behavior depends on correct new-post tracking.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts, line 5:

<comment>The handler now calls `filterNewPostUrls` and includes `newPostUrls` in its return value (drives the digest email), but the tests don't verify that integration. Consider adding an assertion on the `filterNewPostUrls` call args and on `newPostUrls` in the return value so the mock isn't purely ornamental — the digest behavior depends on correct new-post tracking.</comment>

<file context>
@@ -1,6 +1,9 @@
 import { handleTwitterProfileScraperResults } from "@/lib/apify/twitter/handleTwitterProfileScraperResults";
 const listItems = vi.fn();
+vi.mock("@/lib/socials/filterNewPostUrls", () => ({
+  filterNewPostUrls: vi.fn(async (urls: string[]) => urls),
+}));
 vi.mock("@/lib/apify/client", () => ({ default: { dataset: vi.fn(() => ({ listItems })) } }));
</file context>


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>

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

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Heads-up 2026-07-09: this PR's dependency changed — apify_scraper_runs does not exist yet (database#39 was closed unmerged per the chat#1840 capability-model decision), so database#41 (an ALTER) failed the pre-merge sanity check (42P01 on prod) and is closed. Its replacement database#47 creates the table, matched to this PR's consumer contract in lib/supabase/apify_scraper_runs/types.ts (run_id PK, account_id, social_id, platform, batch_id, completed_at, new_post_urls, created_at). Merge order is now database#47 → api#759 → this PR. The stale comment in types.ts referencing database#41 could be repointed to #47 in this branch's next push.

@sweetmantech sweetmantech changed the base branch from feat/scrape-alert-newness-diff to main July 9, 2026 17:26
…gregation

# Conflicts:
#	lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts
#	lib/apify/instagram/handleInstagramProfileScraperResults.ts
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — 2026-07-09

Branch prep first: retargeted base feat/scrape-alert-newness-diffmain (that branch squash-merged as #759) and merged main in, resolving the expected conflicts in handleInstagramProfileScraperResults.ts + its test (branch's supersets kept: the selectApifyScraperRun import/mock and the !registeredRun?.batch_id gate on top of #759's newness gate). Suite green after resolution: 146 tests / 35 files in lib/apify + lib/socials; tsc --noEmit clean apart from 3 pre-existing lib/trigger errors that reproduce with main's own copy of those files; eslint clean; full CI green on the new head.

Preview: https://api-j3w1mc7ah-recoup.vercel.app (confirmed built from post-merge head 3b435b4).

Method: two-platform digest batch against real Apify datasets — the existing natgeo Instagram dataset (12 posts) + a fresh clockworks~tiktok-scraper run for tiktok.com/@natgeo (3 videos). Isolated [TEST] artist watching both socials (fan-out audited pre-send: exactly ['sweetmantech@gmail.com']); batch registration simulated by inserting the two apify_scraper_runs rows directly (one batch_id, platform = instagram/tiktok), then webhooks delivered manually to the preview in controlled order. The POST /api/artist/socials/scrape registration path itself couldn't be exercised live (test-session token expired; prod API keys don't authenticate on previews) — it's covered by this PR's unit tests, and I'm happy to run the full scrape-start E2E if a fresh access token is supplied.

# Check Expected Actual on preview Result
1 Solo IG alert suppressed for batch runs no per-platform email despite new posts IG delivery: newPostUrls: 12, sentEmails: null (legacy solo alert would have fired)
2 Completion bookkeeping completed_at + new_post_urls written per run after IG delivery: instagram completed=true, 12 urls; tiktok completed=false
3 No digest while a sibling is pending digest gated on all-complete TikTok row incomplete after IG delivery → maybeSendScrapeDigest returns before sending (gate state verified in DB)
4 Last completion sends ONE digest single consolidated email, per-platform sections, BCC-only TikTok delivery: newPostUrls: 3, both rows complete (12+3 urls) → digest fired (sendScrapeDigestEmail: to: [RECOUP_FROM_EMAIL], bcc recipients, subject your artist: 15 new posts found across 2 platforms)
5 Persistence unaffected posts still written on suppressed sends IG: 12 posts persisted; TikTok: 3 posts + social upserted (tiktok.com/@natgeo)
6 CI on post-merge head all green format/lint/test/CodeRabbit/cubic/Vercel SUCCESS on 3b435b4

Edge documented for api#762 (not this PR's scope): re-delivering the TikTok webhook (an Apify retry) recomputes new_post_urls for that run to 0 (all posts now known) and overwrites the stored 3 — but the batch is still all-complete with Instagram's 12 URLs stored, so maybeSendScrapeDigest sends a second, IG-only digest (12 new posts found across 1 platform). Verified live (overwrite 3→0 confirmed in DB). #762's 24h rate cap is exactly the right fix; its reviewer may also want to consider skipping the digest when the completing run was already completed_at (retry detection).

Email confirmation caveat: the digest sends aren't in the webhook response and aren't in email_send_log (that's #762), and I currently have no live email access — @sweetmantech, your inbox should show exactly two digests from Agent by Recoup: your artist: 15 new posts found across 2 platforms (the real one) and your artist: 12 new posts found across 1 platform (the deliberate retry probe). All state transitions gating those sends were verified in the DB.

Fixture fully cleaned (artist, 2 socials, 15 posts + social_posts + post_comments, both apify_scraper_runs rows; final sweep: 0 remaining).

Verdict: the aggregation core does what it claims — suppresses solo alerts for batch runs, books completions, and assembles one digest on last-complete. Ready in merge order, with the retry double-send noted for #762.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Full scrape-start E2E — 2026-07-09 (closes the gap in the previous verification)

With a fresh access token, the previously-untested registration path was exercised live on the same preview (https://api-j3w1mc7ah-recoup.vercel.app, head 3b435b4): a real POST /api/artist/socials/scrape → two Apify runs (IG + TikTok) with self-registered preview webhooks → automatic completions → digest. Fixture: isolated [TEST] artist (fan-out audited: exactly ['voicefirsttech@gmail.com']), both natgeo socials, all post URLs absent from posts at start.

# Check (issue Done-when) Expected Actual Result
1 Batch registration at scrape start one shared batch_id, one row per platform run with account/social/platform POST /api/artist/socials/scrape returned 2 runs; apify_scraper_runs got 2 rows sharing batch_id 59142846-… with correct account_id, social_id, platform: instagram/tiktok
2 Full artist scrape → exactly one digest webhooks complete the batch unattended; ONE email both runs completed via their own preview-registered webhooks (IG 12 new, TikTok 1 new); digest fired on last completion — subject your artist: 13 new posts found across 2 platforms, BCC-only
3 Re-run → no digest second batch, nothing new, no email immediate re-scrape minted a distinct batch_id d074119e-…; both runs completed with new_post_urls: 0 → no sections → no send
4 Newness diff across batches batch 2 sees batch 1's posts as known IG 12 → 0, TikTok 1 → 0

@sweetmantech: voicefirsttech@gmail.com's inbox should show exactly one digest from this E2E (13 new posts found across 2 platforms) and nothing from the re-run.

Fixture fully cleaned (artist via API, 2 socials, 13 posts + social_posts + post_comments, all 4 apify_scraper_runs rows; final sweep 0 remaining). Two scrapes' credits were deducted from the test account.

With this, every Done-when for the aggregation PR is verified live end-to-end. The retry double-send edge noted previously stands for api#762.

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.

KISS

  • actual: lib/supabase/apify_scraper_runs/completeApifyScraperRun.ts
  • required lib/supabase/apify_scraper_runs/updateApifyScraperRun.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.

Renamed to updateApifyScraperRun.ts (function + all call sites/mocks) in 87599e2 — it's a plain UPDATE, so it now matches the update[TableName] convention.

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.

KISS

  • actual: lib/supabase/apify_scraper_runs/insertApifyScraperRuns.ts
  • required: lib/supabase/apify_scraper_runs/upsertApifyScraperRuns.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.

Renamed to upsertApifyScraperRuns.ts in 87599e2 — accurate too, since it upserts on run_id with ignoreDuplicates.

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.

KISS

  • actual: lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts
  • required: lib/supabase/apify_scraper_runs/selectApifyScraperRuns.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.

Renamed to selectApifyScraperRuns.ts in 87599e2, and the signature now takes the conventional optional filter object (selectApifyScraperRuns({ batchId })) instead of a positional batch arg. Suite green after the rename: 460 tests across lib/apify + lib/socials + lib/artist + lib/supabase; eslint clean; tsc clean on all touched files.

…ention

Review feedback on #760: completeApifyScraperRun -> updateApifyScraperRun,
insertApifyScraperRuns -> upsertApifyScraperRuns (it upserts on run_id),
selectApifyScraperRunsByBatch -> selectApifyScraperRuns with an optional
{batchId} filter object, matching the select* convention. Also repoints
the stale types.ts reference from database#41 to #47.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 11 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts">

<violation number="1" location="lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts:14">
P3: Digest platform sections can appear in nondeterministic order because `selectApifyScraperRuns` does not order the rows before `maybeSendScrapeDigest` maps them into email sections. Consider adding a stable `.order(...)` (for example by platform plus run_id/created_at) so the consolidated digest body is repeatable.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

}: {
batchId?: string;
} = {}): Promise<ApifyScraperRunRow[]> {
let query = supabase.from("apify_scraper_runs" as never).select("*");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Digest platform sections can appear in nondeterministic order because selectApifyScraperRuns does not order the rows before maybeSendScrapeDigest maps them into email sections. Consider adding a stable .order(...) (for example by platform plus run_id/created_at) so the consolidated digest body is repeatable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts, line 14:

<comment>Digest platform sections can appear in nondeterministic order because `selectApifyScraperRuns` does not order the rows before `maybeSendScrapeDigest` maps them into email sections. Consider adding a stable `.order(...)` (for example by platform plus run_id/created_at) so the consolidated digest body is repeatable.</comment>

<file context>
@@ -0,0 +1,26 @@
+}: {
+  batchId?: string;
+} = {}): Promise<ApifyScraperRunRow[]> {
+  let query = supabase.from("apify_scraper_runs" as never).select("*");
+
+  if (batchId) {
</file context>

…parse the JSONB column

Regenerates database.types.ts (table landed in database#47), deletes the
hand-rolled lib/supabase/apify_scraper_runs/types.ts shim, and drops every
'as never' cast — the helpers now use Tables/TablesInsert like every sibling
lib. new_post_urls is the one column codegen can't narrow past Json, so a
zod boundary (parseNewPostUrls) validates it at read time; malformed JSONB
degrades to 'no new posts' instead of crashing the digest assembler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with 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.

Inline comments:
In `@lib/apify/digest/maybeSendScrapeDigest.ts`:
- Around line 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.

In `@lib/apify/digest/sendScrapeDigestEmail.ts`:
- Around line 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.

In `@lib/artist/postArtistSocialsScrapeHandler.ts`:
- Around line 79-93: The batch registration in postArtistSocialsScrapeHandler
still runs inside the main outer try, so a failure in upsertApifyScraperRuns can
incorrectly fail the scrape response. Move only the upsertApifyScraperRuns call
that builds the run rows from results into its own try/catch block, keep the
rest of the handler flow unchanged, and log any registration error with enough
context to identify the batchId and accountId.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5fe8bb75-a762-43a9-ac18-642482469e85

📥 Commits

Reviewing files that changed from the base of the PR and between 1b81272 and 87599e2.

⛔ Files ignored due to path filters (6)
  • lib/apify/__tests__/apifyWebhookHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/tiktok/__tests__/handleTiktokProfileScraperResults.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (15)
  • lib/apify/apifyWebhookHandler.ts
  • lib/apify/digest/getScrapeDigestRecipients.ts
  • lib/apify/digest/maybeSendScrapeDigest.ts
  • lib/apify/digest/sendScrapeDigestEmail.ts
  • lib/apify/instagram/handleInstagramProfileScraperResults.ts
  • lib/apify/scrapeProfileUrlBatch.ts
  • lib/apify/tiktok/handleTiktokProfileScraperResults.ts
  • lib/apify/twitter/handleTwitterProfileScraperResults.ts
  • lib/apify/validateApifyWebhookRequest.ts
  • lib/artist/postArtistSocialsScrapeHandler.ts
  • lib/supabase/apify_scraper_runs/selectApifyScraperRun.ts
  • lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts
  • lib/supabase/apify_scraper_runs/types.ts
  • lib/supabase/apify_scraper_runs/updateApifyScraperRun.ts
  • lib/supabase/apify_scraper_runs/upsertApifyScraperRuns.ts

Comment on lines +15 to +26
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;

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.

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

Comment on lines +22 to +36
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}`,

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.

Comment on lines +79 to +93
const batchId = crypto.randomUUID();
const socialByUrl = new Map(
socials.map(s => [s.social?.profile_url ?? "", s.social?.id ?? null]),
);
await upsertApifyScraperRuns(
results
.filter(r => r.runId)
.map(r => ({
run_id: r.runId as string,
account_id: authResult.accountId,
social_id: r.profileUrl ? (socialByUrl.get(r.profileUrl) ?? null) : null,
platform: r.profileUrl ? getSocialPlatformByLink(r.profileUrl).toLowerCase() : null,
batch_id: batchId,
})),
);

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 | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify getSocialPlatformByLink return type — can it return null?
ast-grep outline lib/artists/getSocialPlatformByLink.ts --items all
# Also check its return type signature
rg -n -A5 'export.*getSocialPlatformByLink' lib/artists/getSocialPlatformByLink.ts

Repository: recoupable/api

Length of output: 609


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the handler around the batch-registration block and its error handling.
wc -l lib/artist/postArtistSocialsScrapeHandler.ts
sed -n '1,220p' lib/artist/postArtistSocialsScrapeHandler.ts

Repository: recoupable/api

Length of output: 4632


Isolate batch registration from the scrape response path
upsertApifyScraperRuns still sits in the handler’s outer try, so a registration failure will return a 500 and fail the scrape even though the comment says it must not. Wrap just this registration step in its own try/catch and log the error.

🤖 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/artist/postArtistSocialsScrapeHandler.ts` around lines 79 - 93, The batch
registration in postArtistSocialsScrapeHandler still runs inside the main outer
try, so a failure in upsertApifyScraperRuns can incorrectly fail the scrape
response. Move only the upsertApifyScraperRuns call that builds the run rows
from results into its own try/catch block, keep the rest of the handler flow
unchanged, and log any registration error with enough context to identify the
batchId and accountId.

…pify/digest

Review feedback: lib/supabase is for direct queries only; this is a pure
JSONB parser consumed by the digest assembler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Final regression pass after review-feedback refactors — 2026-07-09

Preview: https://api-jrranw5m7-recoup.vercel.app (confirmed built from e2a35e16 — the renamed helpers + generated Tables<"apify_scraper_runs"> types + zod parseNewPostUrls boundary). Same controlled two-platform batch as the earlier verification, reusing the real natgeo IG (12 posts) + TikTok (3 videos) datasets; fan-out audited: exactly ['sweetmantech@gmail.com'].

# Check Actual on refactored preview Result
1 Solo IG alert suppressed for batch runs IG delivery: newPostUrls: 12, sentEmails: null
2 Bookkeeping via renamed updateApifyScraperRun instagram completed=true, 12 urls; tiktok pending until its delivery
3 JSONB round-trip through the new zod boundary both rows' new_post_urls stored as proper JSON arrays; digest sections parsed via parseNewPostUrls
4 Last completion → ONE digest TikTok delivery (newPostUrls: 3) completed the batch → digest fired (15 posts / 2 platforms, BCC-only)
5 Persistence 12 + 3 posts persisted, TikTok social upserted

Behavior is identical to the pre-refactor verification — the renames, generated types, and zod parse changed no semantics. Fixture fully cleaned (0 rows remaining across posts/socials/runs/artist).

Latest review comment also addressed: parseNewPostUrls.ts moved out of lib/supabase/ to lib/apify/digest/ (afc0d9a).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 8 files (changes from recent commits).

Requires human review: Auto-approval blocked by 6 unresolved issues from previous reviews.

Re-trigger cubic

@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@sweetmantech sweetmantech merged commit 0e0a305 into main Jul 9, 2026
3 of 5 checks passed
sweetmantech added a commit that referenced this pull request Jul 9, 2026
…orphaned pre-rename supabase files

Review feedback:
- Every helper gets its own lib file: escapeHtml (lib/emails — repo had
  none), formatUtcDateLabel, truncateText, formatCompactCount,
  formatPostStats, getPlatformLabel, extractInstagramPosts,
  extractTiktokPosts, buildPostStats, asRecord/asStringOrNull/
  asNumberOrNull coercers. renderScrapeDigestHtml and
  extractPostsFromDatasetItems now contain only their eponymous functions.
- CHAT_APP_URL / WEBSITE_URL / RECOUP_LOGO_URL move to lib/const.ts as
  shared constants (no existing shared home found for platform labels —
  getPlatformLabel is the new one).
- Deletes 4 orphaned pre-rename files under lib/supabase/apify_scraper_runs
  (completeApifyScraperRun, insertApifyScraperRuns,
  selectApifyScraperRunsByBatch, types): this branch predates #760's
  renames, so the merge of main added the renamed set alongside the
  branch's inherited old set — both landed with zero conflicts and zero
  references to the old files. Also confirms no legacy LLM email
  generation remains in lib/apify (generateText usage gone with the
  template rewrite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request Jul 9, 2026
… post, no LLM body (#761)

* feat(apify): notify only on genuinely new posts — diff against stored posts first

The Instagram scrape alert fired whenever a scrape returned posts, with no
comparison against posts already stored — every scrape re-announced the
profile's recent feed as new (observed: 6 of 7 alerts in one day announced
posts up to 10 days old). New reusable filterNewPostUrls diffs candidate
URLs against posts BEFORE upsert; the alert is gated on a non-empty result.
Persistence unchanged (recoupable/chat#1855, PR #3 of 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(apify): one consolidated new-posts digest per scrape batch

A roster scrape starts one Apify run per platform, each completing
independently — extending per-platform alerts would mean 4+ emails per
scrape. This registers every run under a batch_id at scrape start
(apify_scraper_runs, columns from recoupable/database#41), records each
webhook completion with its genuinely-new post URLs, and when the batch's
last run completes sends ONE digest (per-platform sections, BCC-only)
via the new digest module. Platforms with nothing new are omitted; a
batch with nothing new sends nothing. Instagram's solo alert is
suppressed for batch runs; legacy/non-batch runs keep today's behavior
(recoupable/chat#1855, PR #4 of 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(apify): deterministic digest template with direct links to each new post

Replaces the per-send LLM email body (nondeterministic branding, vendor
jargon reaching customers, no reliable post links) with a shared
deterministic renderer: stable subject/branding, one section per
platform, a direct link to every genuinely-new post, chat CTA secondary.
Used by both the batch digest and the legacy solo Instagram alert, which
now also requires new-post URLs and is BCC-only (recoupable/chat#1855,
PR #5 of 6; supersedes the interim body from PR #4 and, for this file,
the standalone BCC fix in api#758 — same invariant, tests included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(apify): guard sendScrapeDigestEmail's renderer wiring + BCC invariant

The branch imported renderScrapeDigestHtml in sendScrapeDigestEmail but
never called it — the digest still rendered the old inline body (found
during the main-sync conflict resolution; wired in that merge commit).
This test fails against the unwired version: it asserts the send payload
is byte-identical to the renderer's output, plus the BCC-only invariant
and the empty-input no-send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(apify): house-style digest template with post media, captions, and dates

The v1 deterministic template regressed visual quality vs the old LLM
emails (bare h3/ul list). This upgrades the renderer to the DESIGN.md
house style — achromatic chrome, card-per-post with 72px thumbnail,
escaped caption excerpt, date, and a direct link; black CTA button —
while staying deterministic and email-safe (tables + inline styles).

Media plumbing: extractPostsFromDatasetItems maps platform dataset items
(IG latestPosts, TikTok items) to {url, caption, thumbnailUrl, timestamp},
limited to the genuinely-new URLs. The solo alert enriches from the
dataset already in memory; the digest enriches via getRunDigestSection,
which re-reads each run's dataset from Apify (source of truth) and
degrades to URL-only links on any failure — enrichment never blocks a
send. Captions are HTML-escaped so scraped content can't inject markup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: prettier pass on the digest template files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(apify): digest addressed by artist name + per-post engagement stats + fixed chat CTA

Three feedback items on the template:
- The digest header/subject said 'Your artist' — the assembler never passed
  a name. getRunDigestSection now also extracts the profile display name
  from the dataset (IG fullName, TikTok authorMeta.nickName) and
  maybeSendScrapeDigest addresses the email with the first platform's name.
- Post cards now carry compact engagement stats (12.3K likes · 678
  comments · 1.2M views · shares) mapped from platform counts (IG
  likesCount/commentsCount/videoViewCount, TikTok diggCount/commentCount/
  playCount/shareCount); omitted entirely when the scraper returns none.
- The CTA now always points at https://chat.recoupable.dev — previously it
  derived from the deployment base URL, which on previews is the API
  deployment itself. Funnel-tracking landing page tracked as follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(apify): Recoup logo in the email header + artist-named roster footer

Header is now a two-cell row with the brand icon top-right (hosted PNG —
email clients don't render SVG) linking to recoupable.com. Footer names
the artist: 'because {artist} is in your roster on Recoup', falling back
to 'this artist' when no profile name resolved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(apify): strict SRP/DRY pass on the digest renderer + delete orphaned pre-rename supabase files

Review feedback:
- Every helper gets its own lib file: escapeHtml (lib/emails — repo had
  none), formatUtcDateLabel, truncateText, formatCompactCount,
  formatPostStats, getPlatformLabel, extractInstagramPosts,
  extractTiktokPosts, buildPostStats, asRecord/asStringOrNull/
  asNumberOrNull coercers. renderScrapeDigestHtml and
  extractPostsFromDatasetItems now contain only their eponymous functions.
- CHAT_APP_URL / WEBSITE_URL / RECOUP_LOGO_URL move to lib/const.ts as
  shared constants (no existing shared home found for platform labels —
  getPlatformLabel is the new one).
- Deletes 4 orphaned pre-rename files under lib/supabase/apify_scraper_runs
  (completeApifyScraperRun, insertApifyScraperRuns,
  selectApifyScraperRunsByBatch, types): this branch predates #760's
  renames, so the merge of main added the renamed set alongside the
  branch's inherited old set — both landed with zero conflicts and zero
  references to the old files. Also confirms no legacy LLM email
  generation remains in lib/apify (generateText usage gone with the
  template rewrite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(apify): X/Twitter digest extractor + LinkedIn platform label

Review feedback: Twitter's handler already feeds the digest (filterNewPostUrls
-> newPostUrls) but had no extractor, so its sections degraded to URL-only
links. extractTwitterPosts maps apidojo tweet items (fullText, extendedEntities
media, createdAt via toIsoDate, like/reply/view/retweet counts), registered
under both 'twitter' and 'x'. Artist-name extraction gains the same aliases
(author.name ?? userName). LinkedIn added to platform labels.

YouTube and LinkedIn deliberately have no extractors: their results handlers
persist only the social row (no posts, no newPostUrls), so they never
contribute digest sections today — extractors would be dead code until post
persistence ships for them (chat#1833 territory).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(apify): only persist and report the artist's own tweets — no retweets or replies

Feedback from a real-account digest test: an all-retweet X section reported
hundreds of likes the artist never earned (retweet items carry the ORIGINAL
author's stats). isOriginalTweet keeps originals and quote tweets (the
artist's own words and metrics), drops retweets and replies, applied at the
persistence layer in handleTwitterProfileScraperResults so both stored posts
and digest sections stay accurate. Items without flags pass (defensive
default on schema drift).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(apify): fetch 10 X timeline items by default — depth 1 rarely survives the retweet filter

Real-account evidence (2026-07-09): the user's timeline had 7 retweets above
their 2 originals and a quote tweet; a depth-1 (or 3) fetch returned only
retweets, which isOriginalTweet now drops — so no authored post could ever
reach the digest. Fetch deeper, filter after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request Jul 9, 2026
…dit onto the shipped template, add exact non-duplication

Review question (sweetman): why a rate cap instead of properly enforcing
non-duplication? Both, now, as separate concerns:
- Completion claim: updateApifyScraperRun updates WHERE completed_at IS
  NULL — a webhook retry can't re-claim a completed run, re-trigger digest
  assembly, or overwrite the recorded diff (the 3->0 corruption observed
  live during #760 testing).
- Batch idempotency: maybeSendScrapeDigest checks email_send_log for an
  existing scrape-digest:<batchId> row before sending — one digest per
  batch, ever, no time-window heuristics.
- The 24h per-artist cap remains as the product frequency rule from the
  issue spec, reading the same audit rows.
Every send is logged per watched artist (rawBody scrape-digest:<batchId>).
Also deletes 4 orphaned pre-rename supabase files that rode in again via
the merge (same both-added mechanism as #761) and supersedes the branch's
selectRecentScrapeDigestLogs with filter-object selectScrapeDigestLogs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant