Happy House - Ecommerce 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 /mobile/banners/serve/:slug) with contextual targeting and Redis caching (mobile-only route)
  • 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, all under admin/campaigns/..., all sharing the Assignments_* permission set
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 (all composite PKs)
  targeting.ts         ← campaign_targeting_rules
  (analytics.ts deleted — impression/click events and daily stats rollups now live in MongoDB, see packages/mongodb/src/schemas/banners/ and developer/banners/analytics.mdx)

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_urltextKept for backward compatibility. Destination when target_kind is null/url; for a typed target, the serving layer overwrites the response value from the resolved slug (the column itself is what the admin last wrote).
cta_open_new_tabboolean NOT NULL DEFAULT false
target_kindbanner_target_kind enumnullable — product / category / brand / url
target_product_idinteger (FK → products.id, restrict)nullable
target_category_idinteger (FK → categories.id, restrict)nullable
target_brand_idinteger (FK → brands.id, restrict)nullable
utm_source / utm_medium / utm_campaign / utm_contentvarchar(100)
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 Analytics (MongoDB, not Postgres)

Banner impression/click events and the daily stats rollup were migrated off PostgreSQL onto MongoDB — see developer/banners/analytics.mdx for the full schema, retention/TTL policy, and write-path detail. The packages/db/src/schema/banners/analytics.ts file (which defined banner_impressions/banner_clicks/banner_stats) was deleted; those 3 Postgres tables no longer exist.


4. Runtime Rules and Domain Invariants

4.1 Serving Flow (GET /mobile/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. Sort by displayOrder ASC, weight DESC.
  6. Slot fill: Split primary vs fallback (isFallback) from the qualifying assignments. 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.

Banners do not store catalog-product linkage fields as serving selectors — placement, campaign, assignment, schedule, and contextual targeting are the only inputs to which banner is chosen. The typed CTA target (§4.1a) is unrelated: it only changes what the CTA link points at once a banner has already been selected.

4.1a Typed CTA Target Resolution

banners.target_kind / target_product_id / target_category_id / target_brand_id are nullable columns constrained by chk_banners_target_exactly_one (packages/db/src/schema/banners/banners.ts). The CHECK has an all-null arm — every pre-existing banner has target_kind IS NULL, so the migration adding the columns did not need a backfill — plus one arm per kind requiring its matching id, and a url arm requiring cta_url IS NOT NULL.

Migration 0033_banner_typed_target.sql wrote the CHECK with a bare target_kind = 'product' comparison. A CHECK passes on NULL, not just on TRUE — so a row with target_kind NULL and target_product_id set was accepted: the first arm evaluated FALSE, the rest evaluated NULL, and FALSE OR NULL OR NULL OR NULL OR NULL is NULL, which passes. Migration 0034_banner_target_check_null_safe.sql wraps every discriminator in coalesce(target_kind::text, '') so each arm resolves to an actual boolean. Written up as AgentWiki/reference/learnings/backend/a-check-constraint-that-accepts-null.md; the same shape was copied cleanly from special_deal_target, but that table's target_kind is NOT NULL, and copying a proven constraint does not transfer its proof once a column's nullability changes.

BannerTargetService (apps/api/src/modules/banners/shared/banner-target.service.ts) owns resolution in both directions:

  • resolveWriteTarget() — admin create/update: public ids in the DTO → internal FK ids, validated against the same shape the CHECK enforces before the write reaches the database.
  • resolveResponseTargetPublicIds() / resolveResponseTargetsBatch() — admin read/list: FK ids → public ids. Admin sees a soft-deleted target's id as-is; nothing is hidden from operators.
  • resolveServingTargets() — customer-facing only: FK ids → current slug + public id, filtered by deletedAt IS NULL on the target table. Resolution is by slug, not a stored URL, so renaming a product does not break an existing banner's link — the next serve resolves the new slug. A soft-deleted target is absent from the resolved map; the caller (banner-serving.service.ts) then falls back to the banner's stored cta_url instead of serving a dead link.

cta_url sync happens at read time, not write time: the admin write path stores ctaUrl from the DTO as given, and banner-serving.service.ts computes resolvedCtaUrl = target ? buildTargetUrl(target) : row.ctaUrl per response, which is what keeps cta_url looking current without a backfill job.

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

Note: banner-assignment writes (BannerAssignmentsAdminService) do not invalidate any cache — assignment-level writes in this domain rely on the serving cache's 60s TTL for eventual consistency, not explicit invalidation.

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 @happy-shop/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"BannerTrackingServiceBannerEventsProcessorBannerImpression.create(...) (MongoDB)
BannerJob.RECORD_CLICK"banner.record_click"BannerTrackingServiceBannerEventsProcessorBannerClick.create(...) (MongoDB)
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 BannerStat (MongoDB)

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 6 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 @happy-shop/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 @happy-shop/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.