Skip to content

feat(migrations): digest-batch columns on apify_scraper_runs (batch_id, completed_at, new_post_urls)#41

Closed
sweetmantech wants to merge 1 commit into
mainfrom
feat/apify-runs-digest-batch-columns
Closed

feat(migrations): digest-batch columns on apify_scraper_runs (batch_id, completed_at, new_post_urls)#41
sweetmantech wants to merge 1 commit into
mainfrom
feat/apify-runs-digest-batch-columns

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Schema prerequisite for the one-digest-per-scrape design on recoupable/chat#1855 (PR #2 of 6): a roster scrape starts one Apify run per platform, each completing independently by webhook — to send one consolidated email instead of 4+, completions need to find their siblings (batch_id), know when the batch is done (completed_at), and know what each run found (new_post_urls, diffed against existing posts so only genuinely-new content digests).

Nullable columns, no FKs, no backfill — rows predating the migration never digest, matching the table's loose-ids stance from chat#1840. Partial index on batch_id for the sibling lookup. Additive and safe to apply independently of the api PRs (columns sit unused until the aggregation PR lands).

🤖 Generated with Claude Code


Summary by cubic

Add digest-batch columns on apify scraper runs to group per-scrape runs and send one consolidated “new posts” digest. Adds a partial index for fast sibling lookups.

  • Migration
    • Added columns: batch_id (UUID), completed_at (timestamptz), new_post_urls (JSONB); all nullable.
    • Created partial index on batch_id where not null.
    • No FKs and no backfill; existing rows won’t digest. Additive and safe until aggregation logic ships.

Written for commit b09499a. Summary will update on new commits.

Review in cubic

batch_id + completed_at + new_post_urls so per-platform scrape webhook
completions can find their siblings and assemble one consolidated
new-posts digest per scrape (recoupable/chat#1855, PR #2 of 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@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

Warning

Review limit reached

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

Next review available in: 26 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: e9702934-cbb6-426b-995d-4cef2f4894b1

📥 Commits

Reviewing files that changed from the base of the PR and between dcd0695 and b09499a.

📒 Files selected for processing (1)
  • supabase/migrations/20260707090000_apify_runs_digest_batch_columns.sql
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/apify-runs-digest-batch-columns

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.

@supabase

supabase Bot commented Jul 6, 2026

Copy link
Copy Markdown

Updates to Preview Branch (feat/apify-runs-digest-batch-columns) ↗︎

Deployments Status Updated
Database Mon, 06 Jul 2026 21:57:14 UTC
Services Mon, 06 Jul 2026 21:57:14 UTC
APIs Mon, 06 Jul 2026 21:57:14 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

Tasks Status Updated
Configurations Mon, 06 Jul 2026 21:57:25 UTC
Migrations Mon, 06 Jul 2026 21:57:30 UTC
Seeding ⏸️ Mon, 06 Jul 2026 21:57:07 UTC
Edge Functions ⏸️ Mon, 06 Jul 2026 21:57:07 UTC

❌ Branch Error • Mon, 06 Jul 2026 21:57:31 UTC

ERROR: relation "public.apify_scraper_runs" does not exist (SQLSTATE 42P01)
At statement: 0
-- Digest-batch columns for apify_scraper_runs (recoupable/chat#1855, PR #2 of 6).
--
-- One artist-socials scrape (POST /api/artist/socials/scrape) starts one Apify
-- run per platform; each completes independently via webhook. To send ONE
-- consolidated "new posts" digest per scrape instead of one email per platform,
-- completions must find their sibling runs and know what each found:
--   batch_id       — shared id minted per scrape call; siblings share it.
--   completed_at   — set by the webhook when the run's results are processed;
--                    the batch's last writer assembles and sends the digest.
--   new_post_urls  — post URLs genuinely new to the platform found by this run
--                    (diffed against posts before insert); the digest's content.
--
-- Nullable, no FKs, no backfill: rows predating this migration simply never
-- digest (same loose-ids stance as the table's creation in chat#1840).

ALTER TABLE public.apify_scraper_runs
    ADD COLUMN IF NOT EXISTS batch_id      UUID,
    ADD COLUMN IF NOT EXISTS completed_at  TIMESTAMP WITH TIME ZONE,
    ADD COLUMN IF NOT EXISTS new_post_urls JSONB

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

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

No issues found across 1 file

Re-trigger cubic

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Closing unmerged — this migration ALTERs public.apify_scraper_runs, but that table was never created: #39 (which would have created it for chat#1840) was closed unmerged on 2026-07-03 when run polling moved to the capability model. Applying this to prod fails with 42P01: relation "public.apify_scraper_runs" does not exist (verified live 2026-07-09). It also under-specifies the consumer contract — recoupable/api#760 reads/writes account_id, social_id, and platform in addition to the three columns here.

Superseded by #47, which creates the table matching api#760's contract, scoped to digest bookkeeping only (the chat#1840 capability-model decision stands).

sweetmantech added a commit that referenced this pull request Jul 9, 2026
…1855) (#47)

Supersedes #41, which ALTERed a table that was never created (#39 closed
unmerged per the chat#1840 capability-model decision; 42P01 on prod).
Creates the table matching api#760's consumer contract (run_id PK,
account_id, social_id, platform, batch_id, completed_at, new_post_urls),
scoped to digest bookkeeping only — not an ownership map.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
sweetmantech added a commit to recoupable/api that referenced this pull request Jul 9, 2026
* 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>

* refactor(supabase): align apify_scraper_runs helpers with naming convention

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>

* refactor(supabase): use generated types for apify_scraper_runs + zod-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>

* refactor(apify): move parseNewPostUrls out of lib/supabase into lib/apify/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>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
sweetmantech added a commit to recoupable/api 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>
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