security_invoker = true so RLS policies apply based on the calling user.
ads_feed_v2
Primary feed view. Normalizesapify_ads_raw into a clean ad schema with computed niche, network, status, and days running.
Purpose
- Powers the “All Ads” scope in the dashboard
- Applies classification functions (
classify_niche,classify_network) to raw ad text - Computes
days_runningfromstart_dateorupdated_at - Computes
creative_countfrom media arrays and carousel cards - Provides fallback values for missing fields
Columns
Meta Ad Library archive ID.
Coalesced from
page_name, snapshot.page_name, raw.page_name. Defaults to "Unknown Advertiser".Coalesced from
snapshot.title, raw.snapshot.title, raw.ad_title, page_name. Defaults to "Ad {ad_archive_id}".Coalesced from
snapshot.body.text, snapshot.body, raw.snapshot.body.text, raw.ad_creative_body, raw.ad_copy, raw.description. Defaults to empty string.Computed via
classify_niche(classification_text). Values: Health, Wealth, BizOpp, Relationship, Survival, Other.Computed via
classify_network(destination_url, classification_text). Values: ClickBank, Digistore24, BuyGoods, MaxWeb, Other.Computed as
floor((now() - coalesce(start_date, updated_at)) / 86400). Minimum 0."Active" if is_active is true or null, "Inactive" if false.First available image from
snapshot.images[0].original_image_url, snapshot.cards[0].original_image_url, media.imageUrls[0], video thumbnails, or fallback placeholder.First available video from
snapshot.videos[0].video_hd_url, snapshot.cards[0].video_hd_url, media.videoUrls[0], or null.When the ad first went live.
Coalesced from
snapshot.link_url, snapshot.cards[0].link_url, source_url, ad_library_url. Defaults to Meta Ad Library URL.Call-to-action button text. Defaults to
"Learn More".Coalesced from
snapshot.title, raw.headline, page_name. Defaults to "Untitled Ad".Coalesced from
snapshot.link_description, snapshot.cards[0].link_description. Defaults to empty string.Direct link to the ad in Meta Ad Library.
Last update timestamp from
apify_ads_raw.Number of creative variants. Computed as
max(1, imageUrls.length + videoUrls.length, cards.length).Normalization Logic
The view uses cascadingcoalesce() to extract fields from multiple JSON paths:
- Tries
snapshot.title - Falls back to
raw.snapshot.title - Falls back to
raw.ad_title - Defaults to
"Ad {id}"if all are null/empty
Classification Functions
classify_niche(classification_text)
Regex-based niche classification:| Pattern | Niche |
|---|---|
weight|metabolism|supplement|health|wellness|fat burn|prostate|vision|joint|energy|diet | Health |
wealth|crypto|bitcoin|income|money|finance|trading|invest|stock|retirement|cashflow | Wealth |
biz opp|business opportunity|affiliate|make money online|lead gen|agency|ecommerce|dropship | BizOpp |
relationship|dating|marriage|text him|love|obsession|attract|breakup | Relationship |
survival|prepper|off grid|grid down|emergency|collapse|self defense | Survival |
| none match | Other |
classify_network(destination_url, classification_text)
Regex-based affiliate network detection:| Pattern | Network |
|---|---|
clickbank|hop.clickbank.net|clkbank | ClickBank |
digistore24|digistore | Digistore24 |
buygoods | BuyGoods |
maxweb | MaxWeb |
| none match | Other |
destination_url first, then classification_text.
classification_text is a lowercase concatenation of all text fields from page_name, snapshot, raw, used as input to both classifiers.user_saved_ads_feed_v1
Joinsuser_saved_ads with ads_feed_v2 to show only ads the current user has saved.
Purpose
Powers the “Saved” scope in the dashboard.Columns
The user who saved the ad. Scoped by RLS to
auth.uid().All columns from
ads_feed_v2 (ad_archive_id, advertiser, offer_name, ad_copy, niche, network, days_running, status, image_url, video_url, start_date, destination_url, cta_text, headline, link_description, ad_library_url, updated_at, creative_count).SQL Definition
Filtering Behavior
- Only shows ads where
user_saved_ads.user_id = auth.uid()(enforced by RLS onuser_saved_ads) - Inner join ensures only ads still present in
apify_ads_raware returned - If an ad is deleted from
apify_ads_raw, the cascade delete removes theuser_saved_adsrow, so the ad disappears from this view
user_project_ads_feed_v1
Joinsuser_saved_ad_projects with ads_feed_v2 to show ads in a specific project.
Purpose
Powers the “Project” scope in the dashboard when a project is selected.Columns
The user who owns the project. Scoped by RLS to
auth.uid().The project ID. Dashboard filters on this column.
All columns from
ads_feed_v2.SQL Definition
Filtering Behavior
- Only shows ads where
user_saved_ad_projects.user_id = auth.uid()(enforced by RLS) - Dashboard adds
project_id = <selected_project_id>filter - Inner join ensures only ads still in
apify_ads_raware shown - If a project is deleted, cascade delete removes all
user_saved_ad_projectsrows, so ads disappear from this view for that project
Feed Query Contract
The frontend (src/lib/ads.ts) queries these views via fetchAdsPage(params) where params.scope determines the source:
| Scope | View | Additional Filter |
|---|---|---|
all | ads_feed_v2 | None |
saved | user_saved_ads_feed_v1 | None (RLS scopes to current user) |
project | user_project_ads_feed_v1 | project_id = params.projectId |
Common Filters Applied
- Search: OR-matches
offer_name,advertiser,ad_copyviailike - Niche: Exact match on
nichecolumn - Network: Exact match on
networkcolumn - Status: Exact match on
statuscolumn - Performance Bucket: Range filter on
days_running(New: ≤14, Promising: ≥7, Profitable: ≥30, Proven: ≥60)
Fallback Behavior
When timeouts occur, the data layer:- Falls back to
updatedAt descsort ifdaysRunningsort times out - Fetches IDs without expensive filters, then applies niche/network matching client-side in JavaScript
- Uses ID-first retrieval with probe batches to scan and match rows incrementally
src/lib/ads.ts:1556 (fetchAdsPage) for full fallback chain.
Performance Notes
- Indexes: The views themselves are not indexed. Queries rely on indexes on
apify_ads_raw.ad_archive_id,user_saved_ads(user_id, ad_archive_id), anduser_saved_ad_projectscomposite keys. - Statement timeout: PostgREST default is 60s. Broad queries with niche/network filters can exceed this. The app uses client-side filtering fallback to handle timeouts gracefully.
- Pagination: Uses PostgREST
range()for offset-based pagination. Client-side dedup logic insrc/lib/ads.tsremoves duplicate ads by content hash.
creative_count was added in migration 20260226_add_creative_count_to_feed.sql to support performance bucket classification. Downstream views (user_saved_ads_feed_v1, user_project_ads_feed_v1) inherit it via a.*.