Skip to main content

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.

LayerActorTables
1 — Ad InventoryAdmin (team)ads_placements, ads_placement_prices
2 — Campaign BuildingEmployer (employers)ads_campaigns, ads_campaign_placements, ads_creatives
3 — Admin ModerationAdmin (team)ads_campaign_reviews
4 — Ad ServingMarketplace / Publicreads ads_campaign_placements
5 — AnalyticsEmployer + Adminads_impressions, ads_clicks

Overview

We group each table according to the different Ads layers.

Ads Domain Models per Layer


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: its key, 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:

  1. 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 as draft.
  2. 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.
  3. 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_cost from the price is snapshotted onto ads_campaign_placements.credit_cost_snapshot
  4. Submit for review — campaign status transitions draft → pending_review. At this point, if requested_start_date is 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_days from 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

Campaign Status Flow

On Approval

When admin approves a campaign:

  • ads_campaign_reviews record created: action = approved
  • For each CampaignPlacement, compute start_time:
    • On-time approval (requested_start_date is in the future, or is nil):
      • start_time = requested_start_date (already 00:00 SGT) or tomorrow 00:00 SGT
      • end_time = start_time + duration_days.days
      • status transitions: pending → scheduled (scheduler activates when start_time is 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.days
      • status transitions: pending → active (no scheduler wait needed)
  • Credits remain reserved (hold stays active)
  • Daily job transitions scheduled → active when start_time <= Time.current

On Rejection

When admin rejects a campaign:

  • ads_campaign_reviews record created: action = rejected, notes: "..." — preserves rejection reason and audit history
  • For each CampaignPlacement: Billing::ReleaseHold — reserved credits return to available
  • Campaign becomes editable again (rejected status 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:

  1. Finds the placement by key (e.g. home_hero)
  2. Filters eligible CampaignPlacement records:
    • status = active (campaign placement is currently running)
    • Not soft-deleted
  3. Applies fair rotation — orders by last_served_at ASC NULLS FIRST (least recently served gets priority; never-served ads are first)
  4. Returns the top candidate + creates an Impression record synchronously
  5. Updates last_served_at on the served CampaignPlacement
  6. Returns 204 No Content if no eligible ads exist — frontend falls back to Google AdSense for that slot

CampaignPlacement Status Flow

CampaignPlacement Status Flow

Two-Phase Impression Tracking

Impressions are tracked in two phases:

PhaseEndpointTrigger
ServedGET /marketplace/ads/campaign_placements/:keyAd HTML is returned to browser
ViewedPATCH /marketplace/ads/impressions/:uuid/viewedIntersectionObserver 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:

Click Tracking Flow

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:

MetricFormula
total_impressionsCOUNT of ads_impressions for the campaign
viewed_impressionsCOUNT where viewed_at IS NOT NULL
view_rate(viewed_impressions / total_impressions) × 100
total_clicksCOUNT of ads_clicks for the campaign
unique_clicksCOUNT 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).