Happy House - Ecommerce Docs
Developer Resourcesbanners

Banners Analytics Architecture

Banners Analytics Architecture

The Banners module captures impression and click events asynchronously and aggregates them into daily stats rollups. All writes go through BullMQ — the tracking HTTP endpoints never write to a database directly. Impression/click events and the daily stats rollup are stored in MongoDB (packages/mongodb), not PostgreSQL — this reduces write/storage load on the primary Postgres database, which now holds only the banner/placement/campaign/targeting configuration entities (domain state, not analytics events).


1. Architecture Overview

Client browser/app

        │  POST /api/mobile/banners/events/impression
        │  POST /api/mobile/banners/events/click

BannerTrackingController    (@Public, 202 Accepted)

        │  validate banner/placement (and, if provided, campaign) exist via Postgres
        │  enqueue with jobId = correlationId, payload carries public_ids (not integer ids)

BullMQ BANNERS queue (Redis)

        │  async processing

BannerEventsProcessor
   ├── RECORD_IMPRESSION    → BannerImpression.create(...)      [MongoDB]
   ├── RECORD_CLICK         → BannerClick.create(...)           [MongoDB]
   ├── SYNC_SCHEDULE_STATUS → UPDATE banners + campaigns + invalidate cache   [PostgreSQL — unchanged]
   └── ROLLUP_STATS         → aggregate + BannerStat.bulkWrite(...) upsert    [MongoDB]

(BannerSchedulerService registers cron jobs that enqueue SYNC_SCHEDULE_STATUS
and ROLLUP_STATS into the same BANNERS queue — unchanged from before.)


Admin reads via BannerAnalyticsAdminController
   ├── GET /admin/banner-analytics/stats    → BannerStat.find(...)       [MongoDB, paginated]
   └── GET /admin/banner-analytics/events   → BannerImpression|Click.find(...) [MongoDB, cursor-paginated]

The tracking controller returns 202 Accepted immediately after enqueuing the job. Database writes happen out of band and do not affect response latency for the client. SYNC_SCHEDULE_STATUS is the one handler that remains entirely Postgres-backed — it transitions banners/campaigns schedule state, which is domain configuration, not an analytics event, and was never in scope for this migration.


2. BullMQ Queue Configuration

Queue name: QueueName.BANNERS = "banners" — unchanged.

Registered in apps/api/src/services/bullmq/bull.module.ts inside the REGISTERED_QUEUES array. This registration enables Bull Board UI monitoring, global default job options (attempts, backoff), and graceful shutdown via BullMQService.

Two NestJS modules register this queue:

  • BannerTrackingModule — for enqueue-only access (@InjectQueue(QueueName.BANNERS))
  • BannersWorkersModule — for processor + scheduler access (BannerEventsProcessor + BannerSchedulerService)

Nothing about queue registration, retry policy, or job option defaults changed as part of the Mongo migration — only what the processor does with each job's payload changed.


3. Job Contracts

All job contracts are defined in packages/jobs/src/index.ts:

JobEnum valueProducerConsumerPayload interface
BannerJob.RECORD_IMPRESSION"banner.record_impression"BannerTrackingService.recordImpressionBannerEventsProcessor.processRecordImpressionRecordBannerImpressionPayload
BannerJob.RECORD_CLICK"banner.record_click"BannerTrackingService.recordClickBannerEventsProcessor.processRecordClickRecordBannerClickPayload
BannerJob.SYNC_SCHEDULE_STATUS"banner.sync_schedule_status"BannerSchedulerService.runScheduleSync (cron)BannerEventsProcessor.processSyncScheduleStatusSyncBannerScheduleStatusPayload
BannerJob.ROLLUP_STATS"banner.rollup_stats"BannerSchedulerService.runStatsRollup (cron)BannerEventsProcessor.processRollupStatsRollupBannerStatsPayload

RecordBannerImpressionPayload and RecordBannerClickPayload changed shape as part of this migration — they now carry bannerPublicId/placementPublicId/campaignPublicId? (all string, uuid7) instead of the old bannerId/placementId/campaignId? (integer). This is because the processor now writes directly to MongoDB documents keyed on public_id — there is no longer an internal Postgres integer id in the loop for these two job types. SyncBannerScheduleStatusPayload/RollupBannerStatsPayload are unchanged.

export interface RecordBannerImpressionPayload {
  bannerPublicId: string;
  placementPublicId: string;
  campaignPublicId?: string;
  sessionId?: string;
  userId?: string;
  deviceType?: string;
  countryCode?: string;
  pageUrl?: string;
  referrerUrl?: string;
  occurredAt: string;     // ISO 8601
  correlationId: string;
}

export interface RecordBannerClickPayload {
  bannerPublicId: string;
  placementPublicId: string;
  campaignPublicId?: string;
  sessionId?: string;
  userId?: string;
  deviceType?: string;
  countryCode?: string;
  destinationUrl?: string;
  pageUrl?: string;
  occurredAt: string;
  correlationId: string;
}

4. Impression Flow (End-to-End)

4.1 Client Submission

POST /api/mobile/banners/events/impression
Content-Type: application/json

{
  "bannerPublicId": "019b0b5f-...",
  "placementPublicId": "019b0b5f-...",
  "campaignPublicId": "019b0b5f-...",
  "sessionId": "sess_abc123",
  "deviceType": "mobile",
  "countryCode": "NP",
  "pageUrl": "https://shop.example.com/p/123",
  "occurredAt": "2026-06-19T10:15:30.000Z",
  "correlationId": "imp_019b0b5f-..._1750334400000"
}

Unchanged — the public HTTP request/response contract was never touched by this migration.

4.2 Controller Handling

@Post("impression")
@HttpCode(HttpStatus.ACCEPTED)
@UseGuards(IpThrottlerGuard)
@IpThrottle({ limit: 600, windowSeconds: 60 })
async recordImpression(@Body() dto: RecordImpressionDto) {
  await this.trackingService.recordImpression(dto);
  return new ResponseDto("Impression recorded.", null);
}

Unchanged.

4.3 Service: Existence Validation (Postgres, unchanged) + Payload Construction (new)

BannerTrackingService.recordImpression() still validates that the banner and placement exist (and, if a campaignPublicId is supplied, whether that campaign exists) via the same Postgres lookups as before:

const [[banner], [placement], [campaign]] = await Promise.all([
  this.db.select({ id: banners.id }).from(banners)
    .where(eq(banners.publicId, bannerPublicId)).limit(1),
  this.db.select({ id: placements.id }).from(placements)
    .where(eq(placements.publicId, placementPublicId)).limit(1),
  campaignPublicId
    ? this.db.select({ id: campaigns.id }).from(campaigns)
        .where(eq(campaigns.publicId, campaignPublicId)).limit(1)
    : Promise.resolve([]),
]);
  • If banner not found: throws NotFoundException (BANNER_NOT_FOUND, 404) — unchanged.
  • If placement not found: throws NotFoundException (PLACEMENT_NOT_FOUND, 404) — unchanged.
  • If campaignPublicId provided but not found: no throw (campaign may have been deleted after the impression) — but the behavior downstream changed: previously the resolved (now-undefined) integer campaignId flowed into the job payload; today the same existence check gates whether dto.campaignPublicId (the original string) is forwarded into the payload at all — payload.campaignPublicId is dto.campaignPublicId when the campaign was found, and undefined when it wasn't. Net effect for callers is identical: a bogus/deleted campaign reference is silently dropped, not persisted.

What changed: the resolved integer ids (banner.id, placement.id) are no longer used to build the job payload at all — they exist only to prove existence. The payload is built directly from the original dto.bannerPublicId/dto.placementPublicId (already known-valid strings) plus the gated campaignPublicId above.

4.4 Enqueue with Deduplication

await this.bannersQueue.add(
  BannerJob.RECORD_IMPRESSION,
  payload,
  { jobId: correlationId },
).catch((err) => {
  if (isDuplicateJobError(err)) return;  // idempotent
  throw err;
});

Unchanged — jobId-based BullMQ deduplication works exactly as before; only what's inside payload changed (see ## 3).

4.5 Processor: MongoDB Write

BannerEventsProcessor.processRecordImpression(payload):

await BannerImpression.create({
  bannerPublicId: payload.bannerPublicId,
  placementPublicId: payload.placementPublicId,
  campaignPublicId: payload.campaignPublicId ?? null,
  sessionId: payload.sessionId ?? null,
  userId: payload.userId ?? null,
  deviceType: payload.deviceType ?? null,
  countryCode: payload.countryCode ?? null,
  pageUrl: payload.pageUrl ?? null,
  referrerUrl: payload.referrerUrl ?? null,
  occurredAt: new Date(payload.occurredAt),   // string → Date
});

BannerImpression is a raw Mongoose model exported from @happy-shop/mongodb (packages/mongodb/src/schemas/banners/banner-impression.schema.ts) — no NestJS DI wrapper, called directly as a static method, mirroring how this repo's pre-existing AuditLog model is used in activity.service.ts. occurredAt is still converted to a Date before the write. Nullable payload fields still default to null.


5. Click Flow

Identical to impression flow with two differences:

  • Job name: BannerJob.RECORD_CLICK
  • destinationUrl field is required in payload
  • No referrerUrl field on click events
  • Writes via BannerClick.create(...) (MongoDB) instead of banner_clicks (Postgres)

6. Schedule Status Sync Flow

Unchanged in every respect — still 100% PostgreSQL. This handler transitions banners/campaigns schedule status (domain configuration), which was never analytics data and was never in scope for the Mongo migration.

6.1 Cron Trigger

BannerSchedulerService.runScheduleSync() runs each minute (BANNER_SCHEDULE_SYNC_CRON, default * * * * *) and enqueues BannerJob.SYNC_SCHEDULE_STATUS with a deterministic per-minute jobId (banner-sync-${YYYY-MM-DDTHH:MM}), exactly as before.

6.2 Processor: 4-Parallel Postgres Updates

BannerEventsProcessor.processSyncScheduleStatus(payload) still runs 4 concurrent Drizzle update(...) calls against banners/campaigns (scheduled→active, active/scheduled→expired for banners; scheduled→active, active/scheduled→ended for campaigns), exactly as before this migration — byte-for-byte unchanged code.

6.3 Cache Invalidation

Unchanged — redisCacheService.invalidatePattern("banners:serve:*") fires only when totalChanged > 0; failure is warn-only.


7. Stats Rollup Flow

7.1 Cron Trigger

BannerSchedulerService.runStatsRollup() runs nightly (BANNER_STATS_ROLLUP_CRON, default 0 1 * * *) and enqueues BannerJob.ROLLUP_STATS for the previous day (UTC) with jobId = banner-rollup-${statDate} — unchanged.

7.2 Processor: MongoDB Aggregation

BannerEventsProcessor.processRollupStats(payload) replaces the old Drizzle select().groupBy() queries with two Mongo aggregation pipelines (one per collection), sharing the same pipeline stages:

const matchStage = {
  $match: {
    occurredAt: { $gte: dayStart, $lte: dayEnd },
    campaignPublicId: { $ne: null },
  },
};
const groupStage = {
  $group: {
    _id: {
      bannerPublicId: "$bannerPublicId",
      placementPublicId: "$placementPublicId",
      campaignPublicId: "$campaignPublicId",
    },
    eventCount: { $sum: 1 },
  },
};

const [impressionRows, clickRows] = await Promise.all([
  BannerImpression.aggregate([matchStage, groupStage]),
  BannerClick.aggregate([matchStage, groupStage]),
]);

Why filter campaignPublicId: { $ne: null }: identical reasoning to the old Postgres version — the BannerStat unique compound index on (bannerPublicId, placementPublicId, campaignPublicId, statDate) is the upsert key, and a rollup row only makes sense when tied to a specific campaign. Since the serving endpoint always supplies a campaign context, production analytics events always have a non-null campaignPublicId in practice.

7.3 Merge and Upsert

The in-memory merge (statsMap, keyed on ${bannerPublicId}:${placementPublicId}:${campaignPublicId}) is structurally identical to the old version — only the key's components changed from integer ids to public_id strings. The final write batches through BannerStat.bulkWrite([...]) using updateOne/upsert: true operations (in batches of ROLLUP_BATCH_SIZE = 100, unchanged), which is Mongo's direct equivalent of the old insert(...).onConflictDoUpdate(...).

7.4 Overwrite Semantics (Idempotency) — unchanged guarantee

Each updateOne's $set: { impressions, clicks, updatedAt } overwrites the prior value for that (banner, placement, campaign, date) tuple — not an accumulate. Running the rollup for the same statDate twice produces the same result; a retried job corrects rather than double-counts. This is the exact same idempotency guarantee the old ON CONFLICT DO UPDATE SET impressions = EXCLUDED.impressions provided — just expressed via Mongo's upsert: true instead of Postgres's onConflictDoUpdate.


8. Schema Details

All 3 collections live in packages/mongodb/src/schemas/banners/, following the same raw-Mongoose pattern as this repo's pre-existing AuditLog model — no @nestjs/mongoose, no NestJS DI wrapper.

BannerImpression / banner_impressions collection, and BannerClick / banner_clicks collection (append-only)

  • Fields: bannerPublicId, placementPublicId, campaignPublicId? (all string, indexed), sessionId?, userId?, deviceType?, countryCode?, pageUrl?, referrerUrl? (impression only) / destinationUrl? (click only), occurredAt (indexed Date), createdAt (append-only — no updatedAt).
  • Indexes: { occurredAt: -1 }, { bannerPublicId: 1, occurredAt: -1 }, { campaignPublicId: 1, occurredAt: -1 }.
  • TTL: 180 days on occurredAt — raw events auto-expire after 180 days; BannerStat's daily rollup is the durable, long-term-queryable record. This is a genuine improvement over the prior Postgres tables, which had no automatic retention/purge at all (see ## 10).
  • No internal integer id is ever stored — every reference is the entity's public_id, consistent with this codebase's Public ID Rule.

BannerStat / banner_stats collection (daily rollup, upserted)

  • Fields: bannerPublicId, placementPublicId, campaignPublicId?, statDate (YYYY-MM-DD string), impressions, clicks, spendPaisa? (reserved, always null today — no spend-computation logic exists anywhere in this codebase), createdAt/updatedAt (mutable — re-upserted daily).
  • Unique compound index: { bannerPublicId: 1, placementPublicId: 1, campaignPublicId: 1, statDate: 1 } — the upsert key; mirrors the old Postgres banner_stats_unique_day_idx.
  • No TTL — this is the durable rollup the admin analytics API queries.

9. DLQ and Job Failure Handling

Unchanged from before this migration — failed jobs still follow the global BullMQ configuration from bull.module.ts, still visible/retryable via BullBoard (/admin/bullboard).

SYNC_SCHEDULE_STATUS and ROLLUP_STATS remain safe to retry (both idempotent, as before). RECORD_IMPRESSION/RECORD_CLICK remain intentionally non-deduplicated at the persistence layer — a retry after a successful-but-marked-failed write can still produce a duplicate document, exactly the same accepted tradeoff as the old Postgres version (best-effort analytics, not billing-critical).


10. Data Retention

This changed materially as part of the migration — the old Postgres tables had no automatic purge at all (indefinite retention, growing forever). The new MongoDB collections have an explicit, built-in retention policy:

CollectionRetentionMechanism
banner_impressions180 daysMongoDB TTL index on occurredAt (expireAfterSeconds) — MongoDB automatically deletes expired documents in a background process; no cron job or manual cleanup needed
banner_clicks180 daysSame
banner_statsIndefiniteNo TTL — small row count regardless of raw event volume (one row per banner × placement × campaign × day); this is the long-term-queryable rollup

The 180-day window was chosen because raw events only need to support recent drill-down (the admin /events endpoint); the daily rollup (/stats) is the durable record for anything longer-range.


11. Observability

SignalWhat to Watch
BullBoard /admin/bullboard (BANNERS queue)Failed jobs, queue depth, processing rate — unchanged
SYNC_SCHEDULE_STATUS job logstotalChanged > 0 means banners/campaigns transitioned — unchanged
ROLLUP_STATS job logsLogs rows=<N> upserted per run
Redis memoryWatch for banners:serve:* key growth if TTL invalidation isn't working — unchanged
MongoDB banner_stats document countShould grow by roughly (active banner × placement × campaign combinations) per day; flat growth = rollup not running
MongoDB banner_impressions/banner_clicks document countShould self-bound around a 180-day rolling window once the TTL index has been active that long; unbounded growth before then is expected and not a signal of a problem
Duplicate impressionsIf correlationId dedup breaks at the BullMQ layer, document count per banner spikes — same signal as before, now observed via a Mongo query instead of a Postgres one

12. Admin Analytics API

Two read-only endpoints under apps/api/src/modules/banners/admin/analytics/, added specifically so admins can inspect banner/campaign performance without direct database access.

GET /admin/banner-analytics/stats

Paginated daily rollup, backed by BannerStat. Requires JwtAuthGuard/RoleGuard/BannerAnalytics_READ.

Query parameters: bannerPublicId?, placementPublicId?, campaignPublicId? (all optional uuid filters), from/to (required, YYYY-MM-DD, inclusive — the window must not exceed 90 days, or the endpoint returns 400 with BANNER_ANALYTICS_TIME_WINDOW_TOO_LARGE), plus the standard page/size/pagination fields inherited from QueryDto.

Response: ResponseDto<BannerStatResponseDto[]> with the standard offset-pagination metadata (count/currentPage/totalPage, per this codebase's ResponseDto convention).

GET /admin/banner-analytics/events

Cursor-paginated raw event drill-down, backed by BannerImpression or BannerClick depending on the required type query parameter ("impression" | "click"). Requires the same guards/permission as /stats.

Query parameters: type (required), bannerPublicId?/placementPublicId?/campaignPublicId? (optional filters), from?/to? (optional — defaults to the last 7 days when omitted; same 90-day cap applies), cursor? (opaque, from a previous page's nextCursor), limit? (default 20, max 100).

Response: ResponseDto<BannerEventResponseDto[]> with nextCursor (string or null on the last page) carried in ResponseDto's pagination argument, following the cursor-pagination convention used across admin list endpoints.

Both endpoints are documented in Swagger under the "Banner Analytics (Admin)" tag at /api/api-docs.


13. Architecture Sequence Diagram