Banners Module Backend Documentation
Banners Module — Backend Documentation
1. Backend Scope and Boundaries
Banners backend owns:
- Admin CRUD for banners, placements, campaigns, campaign-placement links, banner assignments, and targeting rules
- Public serving API (
GET /banners/serve/:slug) with contextual targeting and Redis caching - Public event tracking API (impression + click ingestion via BullMQ)
- BullMQ event processing (
RECORD_IMPRESSION,RECORD_CLICK,SYNC_SCHEDULE_STATUS,ROLLUP_STATS) - Cron-driven schedule synchronization (minutely) and stats rollup (nightly)
Banners backend does NOT own:
- Upload/media management — banners use
mediaVariantsor direct URLs - SEO metadata creation —
seoIdis a soft FK - Payment or billing — campaigns have
budgetTotalPaisa/budgetDailyPaisafor tracking only
2. Module Composition (Aggregate + Leaf)
BannersModule is the aggregate root (apps/api/src/modules/banners/banners.module.ts). It composes 8 leaf modules:
| Leaf Module | Path | Responsibility |
|---|---|---|
BannersAdminModule | admin/banners/ | Banner CRUD, lifecycle, schedule, soft-delete, restore |
PlacementsAdminModule | admin/placements/ | Placement CRUD, slug management |
CampaignsAdminModule | admin/campaigns/ | Campaign CRUD + lifecycle transitions (activate/pause/end/cancel/schedule) |
AssignmentsAdminModule | admin/assignments/ | Two controllers: campaign-placements + banner-assignments under admin/campaigns/... |
TargetingAdminModule | admin/targeting/ | Targeting rule CRUD per campaign |
BannerServingModule | public/serving/ | Public serving with cache + targeting evaluator |
BannerTrackingModule | public/tracking/ | Public event ingestion → BullMQ enqueue |
BannersWorkersModule | workers/ | BullMQ processor (BannerEventsProcessor) + cron scheduler (BannerSchedulerService) |
BannersModule re-exports all 8 leaf modules so other modules can pull in banner services if needed.
3. Data Model (Drizzle / PostgreSQL)
3.1 Schema Source of Truth
packages/db/src/schema/banners/
index.ts ← barrel exports
enums.ts ← pgEnum definitions
banners.ts ← banners
placements.ts ← placements
campaigns.ts ← campaigns
assignments.ts ← campaign_placements + banner_assignments (composite PKs)
targeting.ts ← campaign_targeting_rules
analytics.ts ← banner_impressions + banner_clicks + banner_stats3.2 banners Table
| Column | Type | Notes |
|---|---|---|
id | serial PK | Internal integer PK |
public_id | uuid NOT NULL UNIQUE | uuid7, used in all API paths |
title | varchar(255) NOT NULL | |
description | text | |
source | banner_source enum NOT NULL DEFAULT 'internal' | internal / sponsored / affiliate / system |
is_active | boolean NOT NULL DEFAULT true | |
primary_image_url | text NOT NULL | |
primary_image_alt | varchar(255) | |
mobile_image_url | text | |
video_url | text | |
media_variants | jsonb | Record<string, string> for per-locale/per-size variants |
headline / subheadline | varchar(255) | |
cta_label | varchar(100) | |
cta_url | text | |
cta_open_new_tab | boolean NOT NULL DEFAULT false | |
utm_source / utm_medium / utm_campaign / utm_content | varchar(100) | |
brand_id / category_id / product_id | integer (FKs) | on delete: set null |
tags | text[] | GIN-indexed for tag search |
bg_color | varchar(7) | hex |
priority | integer NOT NULL DEFAULT 0 | CHECK >= 0 |
publish_at / expires_at | timestamptz | |
schedule_timezone | varchar(64) DEFAULT 'UTC' | IANA tz string |
recurrence_rule | text | |
recurrence_window_start / recurrence_window_end | time | HH:MM:SS — string-compared |
schedule_status | banner_schedule_status enum NOT NULL DEFAULT 'evergreen' | evergreen / scheduled / active / expired |
is_evergreen_fallback | boolean NOT NULL DEFAULT false | |
fallback_placeholder_url | text | |
seo_id | uuid (FK) | on delete: set null |
created_by | uuid (FK → admin_users.id) | on delete: set null |
created_at / updated_at / deleted_at | timestamptz |
3.3 placements Table
| Column | Type | Notes |
|---|---|---|
id | serial PK | |
public_id | uuid NOT NULL UNIQUE | uuid7 |
slug | varchar(100) NOT NULL UNIQUE | URL-safe; used in serve endpoint |
label | varchar(255) NOT NULL | |
layout_type | placement_layout enum NOT NULL | full_slider/full_static/half_pair/quarter_grid/sidebar_stack/sidebar_single/interstitial/popup/inline_card/sticky_bar |
page_context | varchar(100) | |
max_banners | integer NOT NULL DEFAULT 1 | CHECK > 0 |
recommended_width / recommended_height / recommended_aspect_ratio | hints | |
notes | text | |
is_active | boolean NOT NULL DEFAULT true | |
sort_order | integer NOT NULL DEFAULT 0 | CHECK >= 0 |
fallback_placeholder_url | text | |
allow_partial_render | boolean NOT NULL DEFAULT false | |
created_at / updated_at | timestamptz |
3.4 campaigns Table
| Column | Type | Notes |
|---|---|---|
id | serial PK | |
public_id | uuid NOT NULL UNIQUE | uuid7 |
name | varchar(255) NOT NULL | |
description | text | |
source | banner_source enum NOT NULL DEFAULT 'internal' | |
status | campaign_status enum NOT NULL | draft / scheduled / active / paused / ended / cancelled |
starts_at / ends_at | timestamptz | startsAt required for schedule transition |
budget_total_paisa / budget_daily_paisa | bigint (paisa, no float) | tracking only |
impression_cap / click_cap | integer | |
advertiser_name / advertiser_contact / contract_ref | varchar(255) | |
priority | integer NOT NULL DEFAULT 0 | |
created_by | uuid (FK → admin_users.id) | |
created_at / updated_at / deleted_at | timestamptz |
3.5 campaign_placements (join table — composite PK)
| Column | Type | Notes |
|---|---|---|
campaign_id | integer NOT NULL (FK → campaigns.id) | Composite PK with placement_id |
placement_id | integer NOT NULL (FK → placements.id) | |
is_active | boolean NOT NULL DEFAULT true | |
created_at | timestamptz |
3.6 banner_assignments (join table — composite PK)
| Column | Type | Notes |
|---|---|---|
campaign_id | integer NOT NULL | Composite PK with placement_id + banner_id |
placement_id | integer NOT NULL | |
banner_id | integer NOT NULL (FK → banners.id) | |
display_order | integer NOT NULL DEFAULT 0 | ASC ordering for slot fill |
weight | integer NOT NULL DEFAULT 100 | DESC tiebreaker |
is_fallback | boolean NOT NULL DEFAULT false | |
fallback_priority | integer | ASC for fallback ordering |
is_active | boolean NOT NULL DEFAULT true | |
override_cta_label / override_cta_url / override_headline / override_subheadline | per-assignment overrides | |
transition_duration_ms | integer | |
created_at / updated_at | timestamptz |
3.7 campaign_targeting_rules
| Column | Type | Notes |
|---|---|---|
id | serial PK | |
public_id | uuid NOT NULL UNIQUE | uuid7 |
campaign_id | integer NOT NULL (FK → campaigns.id) | |
rule_type | targeting_rule_type enum NOT NULL | 9 types: device, country, user_segment, login_state, language, hour_of_day, day_of_week, new_visitor, referrer_domain |
operator | varchar(32) NOT NULL | validated by TargetingAdminService per rule_type |
value | jsonb NOT NULL | shape depends on rule_type |
is_active | boolean NOT NULL DEFAULT true | |
created_at / updated_at | timestamptz |
3.8 banner_impressions (append-only)
| Column | Type | Notes |
|---|---|---|
id | bigserial PK | |
banner_id | integer NOT NULL (FK → banners.id, on delete: cascade) | |
placement_id | integer NOT NULL (FK → placements.id, on delete: cascade) | |
campaign_id | integer (FK → campaigns.id, on delete: set null) | nullable |
session_id | varchar(128) | |
user_id | uuid (FK → customers.id, on delete: set null) | nullable |
device_type | varchar(20) | mobile / tablet / desktop |
country_code | varchar(2) | ISO 3166-1 alpha-2 |
page_url / referrer_url | text | |
occurred_at | timestamptz NOT NULL DEFAULT now() | Client-supplied; converted to Date on insert. |
3.9 banner_clicks (append-only)
Same as banner_impressions except:
- No
referrer_urlcolumn - Adds
destination_url text(the URL clicked)
3.10 banner_stats (daily rollup — overwrite semantics)
| Column | Type | Notes |
|---|---|---|
id | serial PK | |
banner_id / placement_id / campaign_id (nullable) | integer FKs | |
stat_date | date NOT NULL | UTC date of rollup |
impressions | integer NOT NULL DEFAULT 0 | CHECK >= 0 |
clicks | integer NOT NULL DEFAULT 0 | CHECK >= 0 |
spend_paisa | bigint | CHECK >= 0 OR NULL |
created_at / updated_at | timestamptz |
Unique index: (banner_id, placement_id, campaign_id, stat_date). PostgreSQL treats NULL as distinct in unique indexes, so ON CONFLICT DO UPDATE does not fire for null-campaign_id rows. The rollup processor filters out campaign_id IS NULL rows; serving always provides a non-null campaign_id, so analytics rows always have a non-null campaign_id.
4. Runtime Rules and Domain Invariants
4.1 Serving Flow (GET /banners/serve/:slug)
- Placement lookup (outside cache): Query
placementsbyslug+is_active=true. Returns 404 (PLACEMENT_NOT_FOUND) if missing. - Cache check:
banners:serve:<slug>:<ctx_hash>(single prefix). On miss, fetch. - Active campaigns: Query
campaignsjoined tocampaign_placementsfor the placement_id wherecampaigns.status='active'AND not deleted AND within time window. - Targeting rules: Batch-load all
campaign_targeting_rulesfor the active campaign IDs. Group bycampaignId. Evaluate AND logic. - Banner assignments: Load
banner_assignmentsjoined tobanners. Filter byisActive, not deleted,scheduleStatusin('evergreen','active','scheduled'), and recurrence window. - Slot fill: Sort by
displayOrder ASC, weight DESC. Fill primary slots first up tomaxBanners. If unfilled andallowPartialRender=true, fill with fallbacks ordered byfallbackPriority ASC. - Cache write: Store
{ placement, banners }with TTL. - Response: Add
servedAt: new Date().toISOString()AFTER cache retrieval — never cached.
4.2 Schedule Filter
Applied per banner inside slot-fill:
scheduleStatus | Serve? |
|---|---|
expired | Never |
draft | Never served (admin-only state) |
scheduled | Only if publishAt <= now AND (expiresAt IS NULL OR expiresAt > now) |
evergreen | Yes (subject to recurrence window) |
active | publishAt <= now AND (expiresAt IS NULL OR expiresAt > now) |
Recurrence window (applied on top): if recurrenceWindowStart and recurrenceWindowEnd are both set, current time (HH:MM:SS) must satisfy start <= current <= end. String comparison (HH:MM:SS is lexicographically ordered).
4.3 Cache Invalidation
SYNC_SCHEDULE_STATUSjob invalidatesbanners:serve:*pattern viaredisCacheService.invalidatePattern()whentotalChanged > 0.- Invalidation failure is caught and logged as
warn(no rethrow). The 60s TTL ensures eventual consistency. - Admin write endpoints (banners, placements, campaigns) invalidate the
*:admin:list:*cache (separate prefix per resource), not the serving cache.
4.4 inArray Safety Rule
All code paths that call Drizzle's inArray(column, values) must guard against empty arrays:
if (campaignIds.length === 0) return [];
const rows = await db.select().from(banners).where(inArray(banners.campaignId, campaignIds));Drizzle generates invalid SQL for inArray(column, []). The serving path includes this guard.
5. Caching Strategy
| Cache Key Pattern | Cached value | TTL | Source |
|---|---|---|---|
banners:serve:<slug>:<ctx_hash> | { placement, banners[] } | BANNER_SERVE_CACHE_TTL_SECONDS (default 60s) | ConfigService |
banners:admin:list:<hash> | Paginated admin banner list | 300s | BANNERS_ADMIN_LIST_CACHE_PREFIX |
placements:admin:list:<hash> | Paginated admin placement list | 300s | PLACEMENTS_ADMIN_LIST_CACHE_PREFIX |
campaigns:admin:list:<hash> | Paginated admin campaign list | 300s | CAMPAIGNS_ADMIN_LIST_CACHE_PREFIX |
Context hash: 16-character hex prefix of SHA-256 of ${deviceType}|${countryCode}|${isLoggedIn}|${language}|${hourOfDay}|${dayOfWeek}|${isNewVisitor}|${referrerDomain}.
Cache utility: CacheKeyUtil.build(prefix, segments) from @/common/utils/cache-key.util (NOT @nomor/redis).
6. BullMQ Queue Architecture
Queue name: QueueName.BANNERS = "banners". Registered in apps/api/src/services/bullmq/bull.module.ts REGISTERED_QUEUES. Both BannerTrackingModule and BannersWorkersModule register this queue; NestJS BullMQ deduplicates the Redis connection.
| Job | Enum value | Producer | Consumer | Description |
|---|---|---|---|---|
BannerJob.RECORD_IMPRESSION | "banner.record_impression" | BannerTrackingService | BannerEventsProcessor | Insert row into banner_impressions |
BannerJob.RECORD_CLICK | "banner.record_click" | BannerTrackingService | BannerEventsProcessor | Insert row into banner_clicks |
BannerJob.SYNC_SCHEDULE_STATUS | "banner.sync_schedule_status" | BannerSchedulerService (cron) | BannerEventsProcessor | 4-parallel status sync + cache invalidation |
BannerJob.ROLLUP_STATS | "banner.rollup_stats" | BannerSchedulerService (cron) | BannerEventsProcessor | Nightly daily aggregate upsert into banner_stats |
Deduplication: producer sets jobId = correlationId on RECORD_IMPRESSION / RECORD_CLICK. The producer catches "jobid already exists" errors and silently no-ops (idempotent tracking). The scheduler sets jobId = banner-sync-<minuteKey> and jobId = banner-rollup-<statDate> to deduplicate per-minute / per-date.
7. Cron Scheduler
BannerSchedulerService implements OnModuleInit. Uses SchedulerRegistry + CronJob from the cron library (NOT @Cron() decorator — allows dynamic registration).
| Job Name | Env Var | Default | Action |
|---|---|---|---|
banner_schedule_sync_cron | BANNER_SCHEDULE_SYNC_CRON | * * * * * | Enqueues SYNC_SCHEDULE_STATUS with jobId = banner-sync-${YYYY-MM-DDTHH:MM} |
banner_stats_rollup_cron | BANNER_STATS_ROLLUP_CRON | 0 1 * * * | Enqueues ROLLUP_STATS with jobId = banner-rollup-${YYYY-MM-DD} |
SYNC_SCHEDULE_STATUS runs 4 parallel updates:
bannersscheduled → active (scheduleStatus='scheduled',publishAt <= now, not expired, not deleted)banners→ expired (scheduleStatus IN ('active','scheduled'),expiresAt < now, not deleted)campaignsscheduled → active (status='scheduled',startsAt <= now, not ended, not deleted)campaigns→ ended (status IN ('active','scheduled'),endsAt < now, not deleted)
ROLLUP_STATS queries impression + click counts grouped by (banner_id, placement_id, campaign_id) for the given statDate, filtered by campaign_id IS NOT NULL (PostgreSQL unique-index limitation), and upserts with overwrite semantics: impressions = EXCLUDED.impressions, clicks = EXCLUDED.clicks, updatedAt = NOW(). Processed in batches of 100 (ROLLUP_BATCH_SIZE).
8. Error Codes
All error codes defined in apps/api/src/common/types/error-codes.ts (per-app, imported via @/common/types/error-codes).
| Code | HTTP | Meaning |
|---|---|---|
BANNER_NOT_FOUND | 404 | Banner publicId not found |
BANNER_ALREADY_DELETED | 400 | softDelete on already-deleted banner |
BANNER_NOT_DELETED | 400 | restore on non-deleted banner |
BANNER_SCHEDULE_INVALID | 400 | publishAt >= expiresAt |
BANNER_SEO_NOT_FOUND | 404 | seoId references non-existent SEO record |
PLACEMENT_NOT_FOUND | 404 | Placement slug/publicId not found |
PLACEMENT_SLUG_EXISTS | 409 | Slug already in use |
PLACEMENT_HAS_ACTIVE_ASSIGNMENTS | 409 | Cannot delete placement with active assignments |
CAMPAIGN_NOT_FOUND | 404 | Campaign publicId not found |
CAMPAIGN_ALREADY_DELETED | 400 | softDelete on already-deleted |
CAMPAIGN_NOT_DELETED | 400 | restore on non-deleted |
CAMPAIGN_DATE_INVALID | 400 | startsAt >= endsAt |
CAMPAIGN_INVALID_TRANSITION | 400 | VALID_TRANSITIONS violated |
CAMPAIGN_PLACEMENT_NOT_FOUND | 404 | campaign-placement link not found |
CAMPAIGN_PLACEMENT_ALREADY_EXISTS | 409 | duplicate campaign-placement link |
BANNER_ASSIGNMENT_NOT_FOUND | 404 | banner assignment not found |
BANNER_ASSIGNMENT_ALREADY_EXISTS | 409 | duplicate banner assignment |
TARGETING_RULE_NOT_FOUND | 404 | Targeting rule publicId not found |
TARGETING_RULE_INVALID_OPERATOR | 400 | Operator not valid for the given rule_type |
Error throw pattern (object form):
throw new NotFoundException({ message: "Banner not found.", errorCode: ErrorCodes.BANNER_NOT_FOUND });9. Performance Notes
- Serving endpoint is the hot path: Designed to return from Redis cache on the vast majority of requests. DB queries only on cache miss.
- Placement lookup outside cache: Intentional — provides fast 404 for invalid slugs without cache poisoning.
- 4-query serving algorithm: Placement → campaigns → targeting rules → assignments. Each query fetches all needed data in one round-trip.
- Analytics writes are async: Impression/click inserts go through BullMQ, never blocking the tracking response. The tracking endpoint returns
202 Acceptedimmediately. - Stats rollup is idempotent:
ON CONFLICT DO UPDATE SET impressions = EXCLUDED.impressions— re-running for the same date produces identical rows. Safe to retry failed jobs. - Schedule sync is batched: 4 parallel
UPDATE...RETURNINGqueries execute concurrently. Cache invalidation only fires whentotalChanged > 0. - Stats rollup batches inserts: Processes in batches of 100 rows.
10. Architecture Diagram
11. QA Checklist
- All 9 Drizzle tables have correct PK types (
serial/bigserial/ composite; NOT uuid for internal PKs) -
public_iduuid7 present onbanners,placements,campaigns,campaign_targeting_rules - Join tables (
campaign_placements,banner_assignments) use composite PKs (no separate id column) - Money columns use
_paisa bigintsuffix — no float/numeric for currency -
CacheKeyUtil.build()imported from@/common/utils/cache-key.util— NOT@nomor/redis -
servedAtadded AFTERredisCacheService.getOrSet()call, never inside the callback - All
inArray()calls guarded with.length > 0check before calling -
QueueName.BANNERSpresent inREGISTERED_QUEUESinbull.module.ts -
BannersModuleregistered inapp.module.ts - All 6 banner modules (Banners, Placements, Campaigns, Assignments, Targeting, plus BannerServing, BannerTracking, BannersWorkers) registered in
default.swagger.ts - All error codes prefixed
BANNER_,PLACEMENT_,CAMPAIGN_,CAMPAIGN_PLACEMENT_,BANNER_ASSIGNMENT_,TARGETING_RULE_ - Error throw uses object form:
throw new XxxException({ message, errorCode }) - Swagger decorators use positional form:
@ApiResponseDto(SomeDto)(not object form) - No
console.log— all logging vianew Logger(ClassName.name) - All tests pass:
pnpm --filter @nomor/api test --testPathPatterns="banners" - Drizzle migration file generated and runs without errors
12. File Map
apps/api/src/modules/banners/
banners.module.ts ← aggregate root
admin/
banners/ ← BannersAdminModule
placements/ ← PlacementsAdminModule
campaigns/ ← CampaignsAdminModule
assignments/ ← AssignmentsAdminModule
(campaign-placements-admin.{controller,service}.ts)
(banner-assignments-admin.{controller,service}.ts)
targeting/ ← TargetingAdminModule
public/
serving/ ← BannerServingModule
tracking/ ← BannerTrackingModule
workers/ ← BannersWorkersModule
(banner-events.processor.ts)
(banner-scheduler.service.ts)
__tests__/
unit/
banner-serving.service.unit.spec.ts
banner-tracking.service.unit.spec.ts
banner-events.processor.unit.spec.ts
banner-scheduler.service.unit.spec.ts
integration/
banners-admin.integration.spec.ts
banner-events.integration.spec.ts
e2e/
banner-serving.e2e.spec.ts
packages/db/src/schema/banners/
index.ts ← barrel
enums.ts
banners.ts
placements.ts
campaigns.ts
assignments.ts
targeting.ts
analytics.ts
packages/jobs/src/index.ts ← QueueName.BANNERS + BannerJob enum + payload interfaces13. Environment Variables
| Variable | Default | Description |
|---|---|---|
BANNER_SERVE_CACHE_TTL_SECONDS | 60 | Redis TTL for serving endpoint cache (seconds) |
BANNER_SCHEDULE_SYNC_CRON | * * * * * | Cron expression for schedule status sync job |
BANNER_STATS_ROLLUP_CRON | 0 1 * * * | Cron expression for nightly stats rollup job |
All three are added to apps/api/src/config/env.validation.ts with the defaults above. BANNER_SCHEDULE_SYNC_CRON and BANNER_STATS_ROLLUP_CRON are required (no default fallback) — service uses ConfigService.getOrThrow for them.