import { Injectable, Logger } from '@nestjs/common'; import { randomUUID } from 'crypto'; import { CacheKeys } from '@box/common/cache/cache-keys'; import type { AdType } from '@box/common/ads/ad-types'; import { AdType as PrismaAdType } from '@prisma/mongo/client'; import { RedisService } from '@box/db/redis/redis.service'; import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service'; export interface AdPayload { id: string; channelId: string; channelName?: string; adsModuleId: string; adType: AdType; advertiser: string; title: string; adsContent?: string | null; adsCoverImg?: string | null; adsUrl?: string | null; trackingId?: string; } @Injectable() export class AdPoolService { private readonly logger = new Logger(AdPoolService.name); constructor( private readonly redis: RedisService, private readonly mongoPrisma: MongoPrismaService, ) {} /** Generate a unique tracking ID for ad impression tracking. */ private generateTrackingId(): string { try { return randomUUID(); } catch { // Fallback: generate a simple UUID-like string if crypto fails return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } } /** Rebuild all ad pools keyed by AdType. */ async rebuildAllAdPools(): Promise { const adTypes = Object.values(PrismaAdType) as AdType[]; for (const adType of adTypes) { try { const count = await this.rebuildPoolForType(adType); this.logger.log(`AdPool rebuild: adType=${adType}, ads=${count}`); } catch (err) { this.logger.error( `AdPool rebuild failed for adType=${adType}`, err instanceof Error ? err.stack : String(err), ); } } } /** Rebuild a single ad pool for an AdType. Returns number of ads written. */ async rebuildPoolForType(adType: AdType): Promise { const now = BigInt(Date.now()); const ads = await this.mongoPrisma.ads.findMany({ where: { status: 1, startDt: { lte: now }, OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }], adsModule: { is: { adType } }, }, orderBy: { seq: 'asc' }, include: { channel: { select: { name: true } }, adsModule: { select: { adType: true } }, }, }); const payloads: AdPayload[] = ads.map((ad) => ({ id: ad.id, channelId: ad.channelId, channelName: ad.channel?.name, adsModuleId: ad.adsModuleId, adType: ad.adsModule.adType as AdType, advertiser: ad.advertiser, title: ad.title, adsContent: ad.adsContent ?? null, adsCoverImg: ad.adsCoverImg ?? null, adsUrl: ad.adsUrl ?? null, })); const key = CacheKeys.appAdPoolByType(adType); await this.redis.del(key); if (!payloads.length) { // Ensure the key exists (as an empty SET) so checklist doesn't fail // Use a placeholder that will be ignored by consumers await this.redis.sadd(key, '__empty__'); return 0; } const members = payloads.map((p) => JSON.stringify(p)); await this.redis.sadd(key, ...members); return payloads.length; } /** Fetch one random ad payload from Redis SET. */ async getRandomFromRedisPool(adType: AdType): Promise { try { const key = CacheKeys.appAdPoolByType(adType); const raw = await this.redis.srandmember(key); if (!raw) return null; // Skip the empty placeholder marker if (raw === '__empty__') return null; const parsed = JSON.parse(raw) as Partial; if (!parsed || typeof parsed !== 'object' || !parsed.id) return null; return parsed as AdPayload; } catch (err) { this.logger.warn( `getRandomFromRedisPool error for adType=${adType}`, err instanceof Error ? err.stack : String(err), ); return null; } } /** Fallback: pick a random ad directly from MongoDB. */ async getRandomFromDb(adType: AdType): Promise { try { const now = BigInt(Date.now()); const ads = await this.mongoPrisma.ads.findMany({ where: { status: 1, startDt: { lte: now }, OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }], adsModule: { is: { adType } }, }, include: { channel: { select: { name: true } }, adsModule: { select: { adType: true } }, }, }); if (!ads.length) return null; const pickIndex = Math.floor(Math.random() * ads.length); const ad = ads[pickIndex]; const payload: AdPayload = { id: ad.id, channelId: ad.channelId, channelName: ad.channel?.name, adsModuleId: ad.adsModuleId, adType: ad.adsModule.adType as AdType, advertiser: ad.advertiser, title: ad.title, adsContent: ad.adsContent ?? null, adsCoverImg: ad.adsCoverImg ?? null, adsUrl: ad.adsUrl ?? null, trackingId: this.generateTrackingId(), }; // Fire-and-forget rebuild for freshness. void this.rebuildPoolForType(adType).catch(() => { /* ignore */ }); return payload; } catch (err) { this.logger.warn( `getRandomFromDb error for adType=${adType}`, err instanceof Error ? err.stack : String(err), ); return null; } } /** Prefer Redis; fall back to MongoDB. Never throws. */ async getRandomAdByType(adType: AdType): Promise { // Try Redis first const fromRedis = await this.getRandomFromRedisPool(adType); if (fromRedis) return fromRedis; // Redis miss or error already logged; fall back to Mongo const fromDb = await this.getRandomFromDb(adType); if (!fromDb) { this.logger.warn( `No ads available for adType=${adType} from Redis or Mongo`, ); return null; } // Fire-and-forget rebuild to refresh Redis cache void this.rebuildPoolForType(adType).catch(() => { /* ignore */ }); return fromDb; } }