Skip to main content

Ads Data Model

Entity Relationship

ads_placements (1)
└── has_many ads_placement_prices (M)
└── referenced by ads_campaign_placements

Org::Company (1)
├── has_many ads_campaigns
└── has_many ads_creatives

ads_campaigns (1)
├── has_many ads_campaign_placements
└── has_many ads_campaign_reviews

ads_campaign_placements (M)
├── belongs_to ads_campaign
├── belongs_to ads_placement
├── belongs_to ads_placement_price
├── belongs_to ads_creative
├── has_many ads_impressions
└── has_many ads_clicks

ads_impressions (1)
└── has_many ads_clicks

Tables

ads_placements

Admin-managed supply of ad slots on the platform. Defines WHERE an ad can appear and what image dimensions are required. Pricing lives in ads_placement_prices, not here.

ColumnTypeNotes
idbigint PK
keystringUnique identifier used by frontend (e.g. home_hero, job_list)
namestringHuman-readable name
descriptiontext (nullable)Optional description
required_widthintegerExpected creative width in pixels
required_heightintegerExpected creative height in pixels
is_activebooleanOnly active placements appear to employers

Key constraints:

  • key is unique — used as the route parameter for serving (GET /marketplace/ads/campaign_placements/:key)
  • is_active = false hides the placement from the employer's browser and prevents new bookings, but does not affect running campaigns

ads_placement_prices

Tiered duration-and-credit deals per placement. Admin creates and manages these. Employers select a price when adding a placement to their campaign.

ColumnTypeNotes
idbigint PK
ads_placement_idbigint FKWhich placement this price belongs to
namestringDisplay label (e.g. "3 Days", "Weekly", "10 Days")
duration_daysintegerHow many days the ad runs
credit_costintegerTotal placement credits charged for this price tier
is_activebooleanOnly active prices are shown to employers
created_atdatetime
updated_atdatetime

Example rows:

placementnameduration_dayscredit_cost
home_hero3 Days33
home_heroWeekly75
home_hero10 Days107
job_listWeekly73
bottom_bannerWeekly72

Key design: Pricing lives here, not on ads_placements. If you want to offer a new deal (e.g. "Monthly"), you add a new price row without touching the placement definition. Admin can add/remove prices independently.


ads_campaigns

The top-level campaign container owned by an employer. Tracks the approval workflow state and the employer's requested start date (shared by all placements in the campaign).

ColumnTypeNotes
idbigint PK
org_company_idbigint FKOrg::Company — campaign owner
created_by_idbigint FKOrg::UserProfile — who created it
namestringCampaign display name
requested_start_datedatetime (nullable)Optional employer-preferred start date for all placements. Stored as 00:00 SGT of the chosen date (employer inputs date only, time is implicit). If set: must be ≥ today + MIN_START_DATE_LEAD_TIME days (2 days, validated at create/update and again at submission). If null: placements go live DEFAULT_START_DAYS_AFTER_APPROVAL day(s) after admin approves at 00:00 SGT.
statusstringSee status table below
submitted_atdatetime (nullable)When employer submitted for review
approved_atdatetime (nullable)When admin approved the campaign. Credit reservation timestamp for billing.
cancelled_atdatetime (nullable)When the campaign was cancelled (employer or admin). Used to calculate pro-rata credit refunds once billing is implemented.
deleted_atdatetime (nullable)Soft delete

Status enum:

StatusMeaningEditable?Credits
draftEmployer building campaignYesNone reserved
pending_reviewSubmitted, awaiting admin reviewNoReserved at submission
approvedAdmin approved, placements runningNoConsumed daily
rejectedAdmin rejected, employer can reviseYesReleased on rejection
cancelledCancelled by employer or adminNoRemaining released
completedAll placements completed or cancelledNoFully consumed or released
note

budget, start_date, end_date, and is_paid from the previous implementation are removed:

  • requested_start_date is optional on the campaign — now stored as datetime (employer inputs date only; stored at 00:00 SGT of that date). All placements share this start time when set; if not set, they go live DEFAULT_START_DAYS_AFTER_APPROVAL day(s) after admin approves at 00:00 SGT.
  • Per-placement start_time / end_time (datetime) are set by admin at approval. For on-time approvals: start_time = requested_start_date or tomorrow at 00:00 SGT; end_time = start_time + duration_days. For late approvals (approval time > requested_start_date): start_time = Time.now, placement goes active immediately.
  • Budget is derivable: SUM(campaign_placements.credit_cost_snapshot)
  • is_paid replaced by ::Billing credit reservation

ads_placement_pricesads_campaign_placements (Credit Cost Snapshot)

When an employer books a CampaignPlacement, ads_placement_prices.credit_cost is copied to ads_campaign_placements.credit_cost_snapshot. This freeze ensures:

  • Future admin price edits do not affect existing campaigns
  • Billing always reserves the exact snapshotted amount

This is the same pattern as billing_invoice_items snapshotting price terms.


ads_campaign_placements

One concrete ad unit within a campaign: a creative running on a placement price. Owns its own schedule and status.

ColumnTypeNotes
idbigint PK
ads_campaign_idbigint FKParent campaign
ads_placement_idbigint FKWhich slot
ads_placement_price_idbigint FKWhich price (duration + credit cost)
ads_creative_idbigint FKWhich image
credit_cost_snapshotintegerSnapshot of ads_placement_prices.credit_cost at booking time
start_timedatetime (nullable)Exact moment the ad goes live — set at admin approval. For future-dated approvals: requested_start_date (or tomorrow) at PLACEMENT_START_HOUR:00 SGT. For late approvals (approval time > requested_start_date): set to Time.now and status transitions directly to active.
end_timedatetime (nullable)start_time + duration_days.days. The ad expires once end_time <= Time.current.
statusstringSee status table below
destination_urltextWhere user lands after clicking the ad
headlinestringAd headline text
descriptiontext (nullable)Optional supporting copy
last_served_atdatetime (nullable)Set each time served — drives fair rotation
deleted_atdatetime (nullable)Soft delete

Status enum:

StatusMeaningBilling
pendingCampaign in draft or pending_reviewHold active (after submission)
scheduledApproved, start_time is in the future — waiting for schedulerHold active
activeRunning — start_time reached (or admin approved late, set immediately)ConsumeEntitlements(units: credit_cost_snapshot / duration_days) daily
completedend_time reached — ad finished runningHold closed
cancelledCancelled mid-campaignReleaseHold on remaining
rejectedCampaign was rejectedReleaseHold

Key constraints:

  • destination_url must belong to an allowed domain (jodapp.com, jodapp.dev)
  • credit_cost_snapshot is set once at creation and never updated
  • Editing is only allowed while the parent campaign is in draft or rejected status
  • start_time / end_time are null until admin approves; the serving layer uses status = active not datetime ranges

Scope: active → filters where deleted_at IS NULL


ads_campaign_reviews

Append-only audit log of every admin action (approve or reject) on a campaign. Provides full history across multiple submission cycles.

ColumnTypeNotes
idbigint PK
ads_campaign_idbigint FKWhich campaign was reviewed
admin_idbigint FKIdentities::Admin who acted
actionstringapproved or rejected
notestext (nullable)Rejection reason or approval notes
created_atdatetimeWhen the action was taken

Why a separate table (not a column on ads_campaigns)?

A single rejection_reason column on the campaign gets overwritten on each submission cycle. The ads_campaign_reviews table preserves the full history — if a campaign is rejected twice before approval, you can see all feedback the employer received.


ads_creatives

Reusable image assets owned by a company.

ColumnTypeNotes
idbigint PK
org_company_idbigint FKOwning company
created_by_idbigint FKOrg::UserProfile who uploaded it
namestringDisplay name (e.g. "February 2025 Banner")
asset_urlstringS3 path: public/org/companies/{uuid}/ads-creatives/...
widthintegerActual image width in pixels
heightintegerActual image height in pixels
deleted_atdatetime (nullable)Soft delete

Upload constraints enforced at creation:

  • Allowed formats: .jpg, .jpeg, .png, .webp
  • Max file size: 2 MB
  • Aspect ratio must match the target placement within 5% tolerance

Soft delete restriction: A creative cannot be soft-deleted if it is referenced by any non-deleted CampaignPlacement.


ads_impressions

One record per ad serving event. Created synchronously when the marketplace serve endpoint is called.

ColumnTypeNotes
idUUID PKAuto-generated via gen_random_uuid() — used as public identifier
ads_campaign_placement_idbigint FKWhich ad was served
viewer_idbigint FK (nullable)Identities::User — set only if viewer is logged in
viewed_atdatetime (nullable)Set when IntersectionObserver beacon fires (ad entered viewport)
created_atdatetimeWhen the ad was served

Why UUID PK? The impression ID is exposed publicly in the click URL (/marketplace/ads/clicks/:uuid). UUIDs prevent enumeration attacks vs sequential integer IDs.

viewed_at is nullable — an impression without viewed_at means the ad was returned to the browser but never scrolled into view.


ads_clicks

One record per click event. Created synchronously when the redirect endpoint is hit.

ColumnTypeNotes
idbigint PK
ads_campaign_placement_idbigint FKWhich ad was clicked
ads_impression_idUUID FK (nullable)Linked impression — nullable for orphaned clicks
clicker_idbigint FK (nullable)Identities::User — set if clicker is logged in
created_atdatetimeWhen the click happened

Deduplication: Multiple clicks can exist per impression. Unique click count is computed at query time: COUNT DISTINCT ads_impression_id.


Key Design Decisions

Prices for Tiered Pricing

Pricing is managed in ads_placement_prices, not on ads_placements. This allows multiple deals per placement (3 days, 7 days, 10 days) with volume discounts in credits, without touching the placement definition. Admin can add/remove prices independently.

One Start Datetime on Campaign; Per-Placement End Datetimes on CampaignPlacement

The employer sets a single requested_start_date (datetime, stored at 00:00 SGT) on the campaign. All placements in the campaign share this start moment. Because each placement has its own duration_days (from the selected price), each placement ends at a different end_time — mixed-duration campaigns are fully supported. The actual start_time / end_time per placement are set by admin at approval and stored on ads_campaign_placements.

For late admin approvals (when approval happens after requested_start_date), start_time is set to the approval moment (Time.now) and the placement goes live immediately (status → active). end_time = start_time + duration_days.days so the employer always receives the full contracted duration.

Credit Cost Snapshotting

credit_cost_snapshot is set once at booking from the price and never updated — the same pattern as billing_invoice_items in Billing. Protects running campaigns from admin price edits.

Reserve at Submission, Not Creation

Credits are reserved when the employer submits the campaign (draft → pending_review), not during draft editing. This prevents noisy ledger entries during the draft phase and only locks credits when the employer formally commits.

Campaign-Level Approval

Admin approves or rejects the whole campaign in one action — not individual placements. If one placement has an issue, admin notes it in the rejection reason and the employer fixes and resubmits everything.

Append-Only Review Log

ads_campaign_reviews is append-only — one row per approve/reject action. Never update or delete rows. This gives a clean audit trail for multi-cycle submissions.

Fair Rotation via last_served_at

Instead of random selection, the serving layer picks the CampaignPlacement with the oldest last_served_at (or NULL first). All active ads get equal exposure over time.

Soft Deletes Everywhere

All major resources use deleted_at. This preserves referential integrity for analytics — impressions and clicks for a deleted campaign placement remain queryable.