Shop It Docs
Developer Resourcesbanners

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 mediaVariants or direct URLs
  • SEO metadata creation — seoId is a soft FK
  • Payment or billing — campaigns have budgetTotalPaisa / budgetDailyPaisa for 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 ModulePathResponsibility
BannersAdminModuleadmin/banners/Banner CRUD, lifecycle, schedule, soft-delete, restore
PlacementsAdminModuleadmin/placements/Placement CRUD, slug management
CampaignsAdminModuleadmin/campaigns/Campaign CRUD + lifecycle transitions (activate/pause/end/cancel/schedule)
AssignmentsAdminModuleadmin/assignments/Two controllers: campaign-placements + banner-assignments under admin/campaigns/...
TargetingAdminModuleadmin/targeting/Targeting rule CRUD per campaign
BannerServingModulepublic/serving/Public serving with cache + targeting evaluator
BannerTrackingModulepublic/tracking/Public event ingestion → BullMQ enqueue
BannersWorkersModuleworkers/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_stats

3.2 banners Table

ColumnTypeNotes
idserial PKInternal integer PK
public_iduuid NOT NULL UNIQUEuuid7, used in all API paths
titlevarchar(255) NOT NULL
descriptiontext
sourcebanner_source enum NOT NULL DEFAULT 'internal'internal / sponsored / affiliate / system
is_activeboolean NOT NULL DEFAULT true
primary_image_urltext NOT NULL
primary_image_altvarchar(255)
mobile_image_urltext
video_urltext
media_variantsjsonbRecord<string, string> for per-locale/per-size variants
headline / subheadlinevarchar(255)
cta_labelvarchar(100)
cta_urltext
cta_open_new_tabboolean NOT NULL DEFAULT false
utm_source / utm_medium / utm_campaign / utm_contentvarchar(100)
brand_id / category_id / product_idinteger (FKs)on delete: set null
tagstext[]GIN-indexed for tag search
bg_colorvarchar(7)hex
priorityinteger NOT NULL DEFAULT 0CHECK >= 0
publish_at / expires_attimestamptz
schedule_timezonevarchar(64) DEFAULT 'UTC'IANA tz string
recurrence_ruletext
recurrence_window_start / recurrence_window_endtimeHH:MM:SS — string-compared
schedule_statusbanner_schedule_status enum NOT NULL DEFAULT 'evergreen'evergreen / scheduled / active / expired
is_evergreen_fallbackboolean NOT NULL DEFAULT false
fallback_placeholder_urltext
seo_iduuid (FK)on delete: set null
created_byuuid (FK → admin_users.id)on delete: set null
created_at / updated_at / deleted_attimestamptz

3.3 placements Table

ColumnTypeNotes
idserial PK
public_iduuid NOT NULL UNIQUEuuid7
slugvarchar(100) NOT NULL UNIQUEURL-safe; used in serve endpoint
labelvarchar(255) NOT NULL
layout_typeplacement_layout enum NOT NULLfull_slider/full_static/half_pair/quarter_grid/sidebar_stack/sidebar_single/interstitial/popup/inline_card/sticky_bar
page_contextvarchar(100)
max_bannersinteger NOT NULL DEFAULT 1CHECK > 0
recommended_width / recommended_height / recommended_aspect_ratiohints
notestext
is_activeboolean NOT NULL DEFAULT true
sort_orderinteger NOT NULL DEFAULT 0CHECK >= 0
fallback_placeholder_urltext
allow_partial_renderboolean NOT NULL DEFAULT false
created_at / updated_attimestamptz

3.4 campaigns Table

ColumnTypeNotes
idserial PK
public_iduuid NOT NULL UNIQUEuuid7
namevarchar(255) NOT NULL
descriptiontext
sourcebanner_source enum NOT NULL DEFAULT 'internal'
statuscampaign_status enum NOT NULLdraft / scheduled / active / paused / ended / cancelled
starts_at / ends_attimestamptzstartsAt required for schedule transition
budget_total_paisa / budget_daily_paisabigint (paisa, no float)tracking only
impression_cap / click_capinteger
advertiser_name / advertiser_contact / contract_refvarchar(255)
priorityinteger NOT NULL DEFAULT 0
created_byuuid (FK → admin_users.id)
created_at / updated_at / deleted_attimestamptz

3.5 campaign_placements (join table — composite PK)

ColumnTypeNotes
campaign_idinteger NOT NULL (FK → campaigns.id)Composite PK with placement_id
placement_idinteger NOT NULL (FK → placements.id)
is_activeboolean NOT NULL DEFAULT true
created_attimestamptz

3.6 banner_assignments (join table — composite PK)

ColumnTypeNotes
campaign_idinteger NOT NULLComposite PK with placement_id + banner_id
placement_idinteger NOT NULL
banner_idinteger NOT NULL (FK → banners.id)
display_orderinteger NOT NULL DEFAULT 0ASC ordering for slot fill
weightinteger NOT NULL DEFAULT 100DESC tiebreaker
is_fallbackboolean NOT NULL DEFAULT false
fallback_priorityintegerASC for fallback ordering
is_activeboolean NOT NULL DEFAULT true
override_cta_label / override_cta_url / override_headline / override_subheadlineper-assignment overrides
transition_duration_msinteger
created_at / updated_attimestamptz

3.7 campaign_targeting_rules

ColumnTypeNotes
idserial PK
public_iduuid NOT NULL UNIQUEuuid7
campaign_idinteger NOT NULL (FK → campaigns.id)
rule_typetargeting_rule_type enum NOT NULL9 types: device, country, user_segment, login_state, language, hour_of_day, day_of_week, new_visitor, referrer_domain
operatorvarchar(32) NOT NULLvalidated by TargetingAdminService per rule_type
valuejsonb NOT NULLshape depends on rule_type
is_activeboolean NOT NULL DEFAULT true
created_at / updated_attimestamptz

3.8 banner_impressions (append-only)

ColumnTypeNotes
idbigserial PK
banner_idinteger NOT NULL (FK → banners.id, on delete: cascade)
placement_idinteger NOT NULL (FK → placements.id, on delete: cascade)
campaign_idinteger (FK → campaigns.id, on delete: set null)nullable
session_idvarchar(128)
user_iduuid (FK → customers.id, on delete: set null)nullable
device_typevarchar(20)mobile / tablet / desktop
country_codevarchar(2)ISO 3166-1 alpha-2
page_url / referrer_urltext
occurred_attimestamptz NOT NULL DEFAULT now()Client-supplied; converted to Date on insert.

3.9 banner_clicks (append-only)

Same as banner_impressions except:

  • No referrer_url column
  • Adds destination_url text (the URL clicked)

3.10 banner_stats (daily rollup — overwrite semantics)

ColumnTypeNotes
idserial PK
banner_id / placement_id / campaign_id (nullable)integer FKs
stat_datedate NOT NULLUTC date of rollup
impressionsinteger NOT NULL DEFAULT 0CHECK >= 0
clicksinteger NOT NULL DEFAULT 0CHECK >= 0
spend_paisabigintCHECK >= 0 OR NULL
created_at / updated_attimestamptz

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)

  1. Placement lookup (outside cache): Query placements by slug + is_active=true. Returns 404 (PLACEMENT_NOT_FOUND) if missing.
  2. Cache check: banners:serve:<slug>:<ctx_hash> (single prefix). On miss, fetch.
  3. Active campaigns: Query campaigns joined to campaign_placements for the placement_id where campaigns.status='active' AND not deleted AND within time window.
  4. Targeting rules: Batch-load all campaign_targeting_rules for the active campaign IDs. Group by campaignId. Evaluate AND logic.
  5. Banner assignments: Load banner_assignments joined to banners. Filter by isActive, not deleted, scheduleStatus in ('evergreen','active','scheduled'), and recurrence window.
  6. Slot fill: Sort by displayOrder ASC, weight DESC. Fill primary slots first up to maxBanners. If unfilled and allowPartialRender=true, fill with fallbacks ordered by fallbackPriority ASC.
  7. Cache write: Store { placement, banners } with TTL.
  8. Response: Add servedAt: new Date().toISOString() AFTER cache retrieval — never cached.

4.2 Schedule Filter

Applied per banner inside slot-fill:

scheduleStatusServe?
expiredNever
draftNever served (admin-only state)
scheduledOnly if publishAt <= now AND (expiresAt IS NULL OR expiresAt > now)
evergreenYes (subject to recurrence window)
activepublishAt <= 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_STATUS job invalidates banners:serve:* pattern via redisCacheService.invalidatePattern() when totalChanged > 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 PatternCached valueTTLSource
banners:serve:<slug>:<ctx_hash>{ placement, banners[] }BANNER_SERVE_CACHE_TTL_SECONDS (default 60s)ConfigService
banners:admin:list:<hash>Paginated admin banner list300sBANNERS_ADMIN_LIST_CACHE_PREFIX
placements:admin:list:<hash>Paginated admin placement list300sPLACEMENTS_ADMIN_LIST_CACHE_PREFIX
campaigns:admin:list:<hash>Paginated admin campaign list300sCAMPAIGNS_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.

JobEnum valueProducerConsumerDescription
BannerJob.RECORD_IMPRESSION"banner.record_impression"BannerTrackingServiceBannerEventsProcessorInsert row into banner_impressions
BannerJob.RECORD_CLICK"banner.record_click"BannerTrackingServiceBannerEventsProcessorInsert row into banner_clicks
BannerJob.SYNC_SCHEDULE_STATUS"banner.sync_schedule_status"BannerSchedulerService (cron)BannerEventsProcessor4-parallel status sync + cache invalidation
BannerJob.ROLLUP_STATS"banner.rollup_stats"BannerSchedulerService (cron)BannerEventsProcessorNightly 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 NameEnv VarDefaultAction
banner_schedule_sync_cronBANNER_SCHEDULE_SYNC_CRON* * * * *Enqueues SYNC_SCHEDULE_STATUS with jobId = banner-sync-${YYYY-MM-DDTHH:MM}
banner_stats_rollup_cronBANNER_STATS_ROLLUP_CRON0 1 * * *Enqueues ROLLUP_STATS with jobId = banner-rollup-${YYYY-MM-DD}

SYNC_SCHEDULE_STATUS runs 4 parallel updates:

  • banners scheduled → active (scheduleStatus='scheduled', publishAt <= now, not expired, not deleted)
  • banners → expired (scheduleStatus IN ('active','scheduled'), expiresAt < now, not deleted)
  • campaigns scheduled → 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).

CodeHTTPMeaning
BANNER_NOT_FOUND404Banner publicId not found
BANNER_ALREADY_DELETED400softDelete on already-deleted banner
BANNER_NOT_DELETED400restore on non-deleted banner
BANNER_SCHEDULE_INVALID400publishAt >= expiresAt
BANNER_SEO_NOT_FOUND404seoId references non-existent SEO record
PLACEMENT_NOT_FOUND404Placement slug/publicId not found
PLACEMENT_SLUG_EXISTS409Slug already in use
PLACEMENT_HAS_ACTIVE_ASSIGNMENTS409Cannot delete placement with active assignments
CAMPAIGN_NOT_FOUND404Campaign publicId not found
CAMPAIGN_ALREADY_DELETED400softDelete on already-deleted
CAMPAIGN_NOT_DELETED400restore on non-deleted
CAMPAIGN_DATE_INVALID400startsAt >= endsAt
CAMPAIGN_INVALID_TRANSITION400VALID_TRANSITIONS violated
CAMPAIGN_PLACEMENT_NOT_FOUND404campaign-placement link not found
CAMPAIGN_PLACEMENT_ALREADY_EXISTS409duplicate campaign-placement link
BANNER_ASSIGNMENT_NOT_FOUND404banner assignment not found
BANNER_ASSIGNMENT_ALREADY_EXISTS409duplicate banner assignment
TARGETING_RULE_NOT_FOUND404Targeting rule publicId not found
TARGETING_RULE_INVALID_OPERATOR400Operator 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 Accepted immediately.
  • 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...RETURNING queries execute concurrently. Cache invalidation only fires when totalChanged > 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_id uuid7 present on banners, placements, campaigns, campaign_targeting_rules
  • Join tables (campaign_placements, banner_assignments) use composite PKs (no separate id column)
  • Money columns use _paisa bigint suffix — no float/numeric for currency
  • CacheKeyUtil.build() imported from @/common/utils/cache-key.util — NOT @nomor/redis
  • servedAt added AFTER redisCacheService.getOrSet() call, never inside the callback
  • All inArray() calls guarded with .length > 0 check before calling
  • QueueName.BANNERS present in REGISTERED_QUEUES in bull.module.ts
  • BannersModule registered in app.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 via new 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 interfaces

13. Environment Variables

VariableDefaultDescription
BANNER_SERVE_CACHE_TTL_SECONDS60Redis TTL for serving endpoint cache (seconds)
BANNER_SCHEDULE_SYNC_CRON* * * * *Cron expression for schedule status sync job
BANNER_STATS_ROLLUP_CRON0 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.