Skip to content

feat(apify): digest rate cap (1 per artist per 24h) + email_send_log audit trail#762

Open
sweetmantech wants to merge 1 commit into
feat/scrape-digest-templatefrom
feat/scrape-digest-rate-cap-audit
Open

feat(apify): digest rate cap (1 per artist per 24h) + email_send_log audit trail#762
sweetmantech wants to merge 1 commit into
feat/scrape-digest-templatefrom
feat/scrape-digest-rate-cap-audit

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

The last slice of recoupable/chat#1855 (PR #6 of 6, stacked on api#761): even with the newness diff, repeated scrapes of an artist in one day (agent runs, verification, retries) could digest repeatedly — evidence on the issue: 7 alerts to one customer in a day. This caps digests at one per watched artist entity per 24h and gives them an audit trail: the old alert path called Resend directly and left zero rows in email_send_log, so the platform had no record of who was alerted when.

What changed

  • lib/supabase/email_send_log/selectRecentScrapeDigestLogs.ts (new): digest-log lookback by artist ids (raw_body prefix scrape-digest:).
  • getScrapeDigestRecipients now returns { emails, artistIds } — the artist entity ids are the cap key.
  • maybeSendScrapeDigest: skips when any watched artist digested within 24h; on send, records one email_send_log row per artist entity (scrape-digest:<batchId>, resend id) via the existing best-effort logEmailAttempt.

Tests (RED→GREEN)

  • Cap: recent digest row → no send.
  • Audit: successful send → logEmailAttempt with artist id + sent.
  • Digest suite 9/9; full sweep lib/apify + lib/socials + lib/artist284 tests passed; tsc at exact baseline parity (200); eslint clean.

Stack

database#41 → api#759 → api#760 → api#761 → this. Done-when for the chain (from the issue): a full artist scrape produces exactly one digest listing each platform's new posts; re-running back-to-back produces none the second time.

🤖 Generated with Claude Code


Summary by cubic

Cap scrape digests to one per watched artist per 24h and add an audit trail by logging each send to email_send_log. This prevents multi-send bursts (chat#1855) and makes sends traceable.

  • New Features
    • Rate cap in maybeSendScrapeDigest: sends at most once per artist in 24h using selectRecentScrapeDigestLogs.
    • Audit trail: on send, writes one email_send_log row per artist with raw_body scrape-digest:<batchId> via logEmailAttempt.
    • getScrapeDigestRecipients now returns { emails, artistIds } to key the cap; tests updated to cover cap and logging.

Written for commit 68bd515. Summary will update on new commits.

Review in cubic

Caps the digest at one per watched artist entity per 24h and records
every digest send in email_send_log (the solo alert path bypassed the
log entirely — the 7-email burst on chat#1855 was only discoverable via
the Resend admin endpoint). Recipients resolver now also returns the
watched artist entity ids for the cap key (recoupable/chat#1855,
PR #6 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 6, 2026 10:14pm

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

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • main
  • test

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 09ed829a-d2ab-4fee-bf05-6f7750fa5c29

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/scrape-digest-rate-cap-audit

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.

1 issue found across 4 files

Confidence score: 2/5

  • In lib/apify/digest/maybeSendScrapeDigest.ts, sendScrapeDigestEmail can record failed Resend attempts as successfully sent because sendEmailWithResend returns a truthy error NextResponse without an id; merging as-is risks inaccurate audit logs and missed retries/triage for real delivery failures — update the success check to require a valid message id (or explicit success flag) before writing a "sent" audit entry.
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/digest/maybeSendScrapeDigest.ts">

<violation number="1" location="lib/apify/digest/maybeSendScrapeDigest.ts:39">
P1: Failed email sends are logged as "sent" in the audit trail. `sendScrapeDigestEmail` calls `sendEmailWithResend`, which returns a `NextResponse` error object (truthy, but no `id`) when Resend returns an error. The `if (sent)` guard incorrectly treats this as a successful delivery, so `logEmailAttempt` records `status: "sent"` even though the email was never sent. The `resendId` is `undefined` in this case, so the audit trail has rows claiming a successful send with a null Resend ID — no way to distinguish real deliveries from API failures. Fix: gate on `resendId` instead of `sent`, since a successful Resend response always has an `id`.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant M as maybeSendScrapeDigest
    participant R as getScrapeDigestRecipients
    participant C as selectRecentScrapeDigestLogs
    participant E as sendScrapeDigestEmail
    participant L as logEmailAttempt
    participant DB as Supabase (email_send_log)

    Note over M,DB: Scrape digest orchestration with rate cap & audit trail

    M->>M: Fetch batch runs & extract sections of new posts
    alt No new posts in batch
        M->>M: Return null immediately
    else New posts found
        M->>R: getScrapeDigestRecipients(socialIds)
        R-->>M: { emails, artistIds }
        Note over M,C: Rate cap: check if any artist was already alerted in last 24h
        M->>C: selectRecentScrapeDigestLogs(artistIds, since)
        C->>DB: SELECT account_id, created_at FROM email_send_log WHERE account_id IN artistIds AND raw_body LIKE 'scrape-digest:%' AND created_at >= since
        DB-->>C: matching rows (or empty)
        C-->>M: recent log rows
        alt Recent log exists for any artist
            M->>M: Return null (skip send)
        else No recent log
            M->>E: sendScrapeDigestEmail({ emails, sections })
            E-->>M: Resend response (id)
            Note over M,L: Audit trail: record send per watched artist entity
            loop For each artistId
                M->>L: logEmailAttempt({ rawBody: scrape-digest:{batchId}, status: sent, accountId: artistId, resendId})
                L->>DB: INSERT into email_send_log
                DB-->>L: ok
            end
            M->>M: Return sent response
        end
    end
Loading

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

Re-trigger cubic

// Audit trail: record the send per artist entity so alerts are debuggable
// and the cap has something to read (the solo path never logged at all).
const resendId = (sent as { id?: string } | null)?.id;
if (sent) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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

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

<file context>
@@ -20,8 +22,31 @@ export async function maybeSendScrapeDigest(batchId: string | null | undefined)
+  // Audit trail: record the send per artist entity so alerts are debuggable
+  // and the cap has something to read (the solo path never logged at all).
+  const resendId = (sent as { id?: string } | null)?.id;
+  if (sent) {
+    await Promise.all(
+      artistIds.map(artistId =>
</file context>
Suggested change
if (sent) {
if (resendId) {

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