Ads Domain Layers
The Ads domain is organised into five layers. Reading them in order gives you a mental model for how an ad goes from admin setup → employer campaign → live on the website → tracked analytics.
| Layer | Actor | Tables |
|---|---|---|
| 1 — Ad Inventory | Admin (team) | ads_placements, ads_placement_prices |
| 2 — Campaign Building | Employer (employers) | ads_campaigns, ads_campaign_placements, ads_creatives |
| 3 — Admin Moderation | Admin (team) | ads_campaign_reviews |
| 4 — Ad Serving | Marketplace / Public | reads ads_campaign_placements |
| 5 — Analytics | Employer + Admin | ads_impressions, ads_clicks |
Overview
We group each table according to the different Ads layers.
Layer 1 — Ad Inventory (Supply)
Who: Admin (team context)
Tables: ads_placements, ads_placement_prices
Admin defines the supply side — which ad placements exist on the website and what deals are available for each. This is split into two levels:
ads_placements— the ad placement itself: itskey, name, required image dimensions. No pricing here.ads_placement_prices— the deals per placement: duration in days and credit cost. Admin manages these prices and can activate/deactivate them independently.
Employers cannot create or modify placements or prices. They only browse what admin has set up.
Example prices for home_hero:
home_hero:
- 3 Days → 3 credits
- Weekly → 5 credits
- 10 Days → 7 credits
Key design point: Pricing lives in ads_placement_prices, not in ads_placements. This allows different time-based deals for the same placement without touching the placement definition. Credit costs can change over time — existing CampaignPlacements are protected by credit cost snapshotting in Layer 2.
Layer 2 — Campaign Building (Demand)
Who: Org::UserProfile (employer)
Tables: ads_campaigns, ads_creatives, ads_campaign_placements
Context: employers
Employers build campaigns through a multi-step flow:
- Create a campaign — give it a name and an optional
requested_start_date. If set, must be ≥ today + 2 days. If left blank, the campaign will go live the day after admin approves. Status starts asdraft. - Upload creatives — upload image assets to S3 (
.jpg,.png,.webp, max 2MB, aspect ratio tolerance 5%). Creatives are reusable — once uploaded, they can be used across multiple campaigns. - Add campaign placements — for each placement they want to book:
- Select a placement (which slot)
- Select a price (which deal: duration + credit cost)
- Select a creative (which image)
- Provide destination URL and headline
- On creation:
credit_costfrom the price is snapshotted ontoads_campaign_placements.credit_cost_snapshot
- Submit for review — campaign status transitions
draft → pending_review. At this point, ifrequested_start_dateis set it is re-validated (>= today + 2) in case the employer spent several days in draft; credits are reserved via Billing.
One Start Date per Campaign
All placements in a campaign share a single start date. requested_start_date on the campaign is optional:
- If set: all placements start on that date (admin can still override with a single alternative date at approval)
- If not set: placements go live the day after admin approves (
Date.today + 1) - Placements still end on different dates because each has its own
duration_daysfrom the selected price - The campaign is an organisational container — it groups placements for a single admin review decision
No Budget on Campaign
There is no budget field on the campaign. Total credit cost is derivable:
campaign.ads_campaign_placements.sum(:credit_cost_snapshot)
If needed for display, compute it at query time — do not store a redundant cached field.
Credit Cost Snapshotting
When an employer adds a CampaignPlacement, the credit_cost from the selected ads_placement_price is copied onto campaign_placements.credit_cost_snapshot. This freeze means:
- Future admin price changes do not affect existing campaigns
- Billing always reserves exactly the snapshotted amount
Draft and Rejected Editing
All mutations (add/edit/delete placements, edit campaign name) are only allowed while the campaign is in draft or rejected status. Once submitted (pending_review), the campaign is frozen.
Layer 3 — Admin Moderation
Who: Identities::Admin
Tables: ads_campaigns, ads_campaign_placements, ads_campaign_reviews
Context: team
Before any campaign goes live, an admin must review and approve it. This is a manual gate for content standards, URL validity, and creative quality.
Campaign Status Flow
On Approval
When admin approves a campaign:
ads_campaign_reviewsrecord created:action = approved- For each
CampaignPlacement, computestart_time:- On-time approval (
requested_start_dateis in the future, or is nil):start_time = requested_start_date(already 00:00 SGT) ortomorrow 00:00 SGTend_time = start_time + duration_days.daysstatustransitions:pending → scheduled(scheduler activates whenstart_timeis reached)
- Late approval (
Time.now >= requested_start_date):start_time = Time.now(go live immediately, employer gets full duration)end_time = start_time + duration_days.daysstatustransitions:pending → active(no scheduler wait needed)
- On-time approval (
- Credits remain reserved (hold stays active)
- Daily job transitions
scheduled → activewhenstart_time <= Time.current
On Rejection
When admin rejects a campaign:
ads_campaign_reviewsrecord created:action = rejected, notes: "..."— preserves rejection reason and audit history- For each
CampaignPlacement:Billing::ReleaseHold— reserved credits return to available - Campaign becomes editable again (
rejectedstatus allows employer to edit and resubmit) - Employer can fix the issues and submit again (
rejected → pending_review)
Review History via ads_campaign_reviews
Each approve/reject action appends a row to ads_campaign_reviews. This gives a full audit trail across multiple submission cycles — useful when a campaign is rejected, revised, and resubmitted multiple times.
Layer 4 — Ad Serving
Who: Anonymous / Identities::User (job seeker browsing the platform)
Tables: ads_campaign_placements, ads_impressions, ads_clicks
Context: marketplace
When a page loads on the frontend, it requests the active ad for a given placement key. The serving layer:
- Finds the placement by
key(e.g.home_hero) - Filters eligible
CampaignPlacementrecords:status = active(campaign placement is currently running)- Not soft-deleted
- Applies fair rotation — orders by
last_served_at ASC NULLS FIRST(least recently served gets priority; never-served ads are first) - Returns the top candidate + creates an
Impressionrecord synchronously - Updates
last_served_aton the servedCampaignPlacement - Returns
204 No Contentif no eligible ads exist — frontend falls back to Google AdSense for that slot
CampaignPlacement Status Flow
Two-Phase Impression Tracking
Impressions are tracked in two phases:
| Phase | Endpoint | Trigger |
|---|---|---|
| Served | GET /marketplace/ads/campaign_placements/:key | Ad HTML is returned to browser |
| Viewed | PATCH /marketplace/ads/impressions/:uuid/viewed | IntersectionObserver fires when ad enters viewport |
This split distinguishes between "the ad was sent to the browser" (served) and "the user actually saw it" (viewed), enabling accurate View Rate calculation.
Click Tracking
Clicks go through a redirect endpoint rather than a direct link on the frontend:
Daily Billing Consume Job
A background scheduler job runs daily and calls Billing::ConsumeEntitlements(units: credit_cost_snapshot / duration_days) for each active CampaignPlacement. This is a billing-ready feature — see billing-ready/04-backend-scheduler-consume-credits.md.
Placement completion (active → completed) is handled by a separate datetime-based job (Ads::CompletePlacementsJob) that runs after ActivatePlacementsJob each day. It transitions active placements where end_time <= Time.current to completed, then transitions the parent campaign to completed when all its placements are in a terminal state.
Layer 5 — Analytics
Who: Org::UserProfile (employer), Identities::Admin
Tables: ads_impressions, ads_clicks
Context: employers (own campaigns), team (all campaigns)
Analytics are not stored in the DB — they are computed on-demand from ads_impressions and ads_clicks and attached to campaign/placement objects as transient data via CampaignService.attach_analytics!.
Metrics computed:
| Metric | Formula |
|---|---|
total_impressions | COUNT of ads_impressions for the campaign |
viewed_impressions | COUNT where viewed_at IS NOT NULL |
view_rate | (viewed_impressions / total_impressions) × 100 |
total_clicks | COUNT of ads_clicks for the campaign |
unique_clicks | COUNT DISTINCT on ads_impression_id (deduplicates multiple clicks per impression) |
ctr | (total_clicks / total_impressions) × 100 |
Analytics are available at both campaign level (rolled up) and per CampaignPlacement (broken down by placement).