// apps/box-app-api/src/feature/homepage/homepage.service.ts import { Injectable, Logger } from '@nestjs/common'; import { RedisService } from '@box/db/redis/redis.service'; import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider'; import { nowSecBigInt } from '@box/common/time/time.util'; import { AdType } from '@prisma/mongo/client'; import type { AdPoolEntry } from '@box/common/ads/ad-types'; import { PrismaMongoService } from '../../prisma/prisma-mongo.service'; import { VideoService } from '../video/video.service'; import { HomeAdsDto, HomeAdDto, AnnouncementDto, CategoryDto, WaterfallAdsDto, PopupAdsDto, FloatingAdsDto, } from './dto/homepage.dto'; import { RecommendedVideosDto } from '../video/dto'; import { AdOrder, SystemParamSide, ANNOUNCEMENT_KEYWORD, AdSlot, } from './homepage.constants'; import type { HomeCategoryCacheItem, HomeTagCacheItem } from './homepage.types'; @Injectable() export class HomepageService { private readonly logger = new Logger(HomepageService.name); constructor( private readonly redis: RedisService, private readonly prisma: PrismaMongoService, private readonly videoService: VideoService, ) {} /** * Get complete homepage data in single API call */ async getHomepageData(): Promise<{ ads: HomeAdsDto; announcements: AnnouncementDto[]; categories: CategoryDto[]; videos: RecommendedVideosDto; }> { const [ads, announcements, categories, videos] = await Promise.all([ this.getHomeAds(), this.getAnnouncements(), this.getCategories(), this.videoService.getRecommendedVideos(), ]); return { ads, announcements, categories, videos, }; } /** * Fetch all ads for homepage */ private async getHomeAds(): Promise { const [carousel, banner, waterfall, popup, floating] = await Promise.all([ this.getAdsByType(AdType.CAROUSEL, AdOrder.RANDOM), this.getSingleAd(AdType.BANNER), this.getWaterfallAds(), this.getPopupAds(), this.getFloatingAds(), ]); return { carousel, banner, waterfall, popup, floating, }; } /** * Get waterfall ads (icons, texts, videos) */ private async getWaterfallAds(): Promise { const [icons, texts, videos] = await Promise.all([ this.getAdsByType(AdType.WATERFALL_ICON, AdOrder.RANDOM), this.getAdsByType(AdType.WATERFALL_TEXT, AdOrder.RANDOM), this.getAdsByType(AdType.WATERFALL_VIDEO, AdOrder.RANDOM), ]); return { icons, texts, videos }; } /** * Get popup ads (multi-step flow) */ private async getPopupAds(): Promise { const [allIcons, images, official] = await Promise.all([ this.getAdsByType(AdType.POPUP_ICON, AdOrder.RANDOM), this.getAdsByType(AdType.POPUP_IMAGE, AdOrder.RANDOM), this.getAdsByType(AdType.POPUP_OFFICIAL, AdOrder.SEQUENTIAL), ]); // Limit icons to max 6 const icons = allIcons.slice(0, 6); return { icons, images, official }; } /** * Get floating ads (bottom & edge) */ private async getFloatingAds(): Promise { const [bottom, edge] = await Promise.all([ this.getAdsByType(AdType.FLOATING_BOTTOM, AdOrder.RANDOM), this.getAdsByType(AdType.FLOATING_EDGE, AdOrder.RANDOM), ]); return { bottom, edge }; } /** * Generic method to fetch ads by type from pool */ private async getAdsByType( adType: AdType, order: AdOrder, ): Promise { const poolKey = tsCacheKeys.ad.poolByType(adType); let pool: AdPoolEntry[] | null = null; try { const entries = await this.redis.getJson(poolKey); if (!entries) { this.logger.warn( `Ad pool cache miss for adType=${adType}, key=${poolKey}. Cache may be cold; ensure cache-sync rebuilt pools.`, ); return []; } if (!entries.length) { this.logger.warn(`Ad pool empty for adType=${adType}, key=${poolKey}`); return []; } pool = entries; } catch (err) { if (err instanceof Error && err.message?.includes('WRONGTYPE')) { this.logger.warn( `Ad pool key ${poolKey} has wrong type, deleting incompatible key`, ); try { await this.redis.del(poolKey); this.logger.log( `Deleted incompatible ad pool key ${poolKey}. It will be rebuilt on next cache warmup.`, ); } catch (delErr) { this.logger.error( `Failed to delete incompatible key ${poolKey}`, delErr instanceof Error ? delErr.stack : String(delErr), ); } } else { this.logger.error( `Failed to read ad pool for adType=${adType}, key=${poolKey}`, err instanceof Error ? err.stack : String(err), ); } } if (!pool) { return []; } // Shuffle if random order const sortedPool = order === AdOrder.RANDOM ? this.shuffle(pool) : pool; // Fetch all ads in parallel const adPromises = sortedPool.map((entry) => this.fetchAdDetails(entry.id)); const ads = await Promise.all(adPromises); // Filter out nulls and map to DTO return ads.filter((ad): ad is HomeAdDto => ad !== null); } /** * Get single ad (e.g., banner) */ private async getSingleAd(adType: AdType): Promise { const ads = await this.getAdsByType(adType, AdOrder.RANDOM); return ads.length > 0 ? ads[0] : null; } /** * Fetch ad details from per-ad cache */ private async fetchAdDetails(adId: string): Promise { try { const now = nowSecBigInt(); const ad = await this.prisma.ads.findUnique({ where: { id: adId }, select: { id: true, adType: true, advertiser: true, title: true, adsContent: true, adsCoverImg: true, adsUrl: true, startDt: true, expiryDt: true, status: true, }, }); if (!ad) { this.logger.debug(`Ad not found for homepage slot: adId=${adId}`); return null; } if (ad.status !== 1 || ad.startDt > now) { return null; } if (ad.expiryDt !== BigInt(0) && ad.expiryDt < now) { return null; } return { id: ad.id, adType: ad.adType ?? 'UNKNOWN', title: ad.title ?? '', advertiser: ad.advertiser ?? '', content: ad.adsContent ?? undefined, coverImg: ad.adsCoverImg ?? undefined, targetUrl: ad.adsUrl ?? undefined, }; } catch (err) { this.logger.error( `Failed to fetch ad ${adId}`, err instanceof Error ? err.stack : String(err), ); return null; } } /** * Get slot name for ad type (maps to ADTYPE_POOLS config) */ private getSlotForType(adType: AdType): string { const slotMap: Record = { [AdType.STARTUP]: AdSlot.STARTUP, [AdType.CAROUSEL]: AdSlot.CAROUSEL, [AdType.POPUP_ICON]: AdSlot.MIDDLE, [AdType.POPUP_IMAGE]: AdSlot.MIDDLE, [AdType.POPUP_OFFICIAL]: AdSlot.MIDDLE, [AdType.WATERFALL_ICON]: AdSlot.WATERFALL, [AdType.WATERFALL_TEXT]: AdSlot.WATERFALL, [AdType.WATERFALL_VIDEO]: AdSlot.WATERFALL, [AdType.FLOATING_BOTTOM]: AdSlot.FLOATING, [AdType.FLOATING_EDGE]: AdSlot.EDGE, [AdType.BANNER]: AdSlot.TOP, [AdType.PREROLL]: AdSlot.PREROLL, [AdType.PAUSE]: AdSlot.PAUSE_OVERLAY, }; return slotMap[adType] ?? AdSlot.UNKNOWN; } /** * Get announcements (marquee notices) * Using SystemParam with side='client' and specific naming convention */ private async getAnnouncements(): Promise { try { // Fetch client-side params that could be announcements // Convention: params with name starting with 'announcement_' or 'notice_' const params = await this.prisma.systemParam.findMany({ where: { side: SystemParamSide.CLIENT, name: { contains: ANNOUNCEMENT_KEYWORD, }, }, orderBy: { id: 'asc', // sequential order by ID }, }); return params.map((p, idx) => ({ id: p.id.toString(), content: p.value ?? '', // Use 'value' field as content seq: idx, })); } catch (error) { this.logger.warn( 'Error fetching announcements from SystemParam, returning empty', ); return []; } } /** * Get video categories (delegated to VideoService) */ private async getCategories(): Promise { return this.videoService.getCategories(); } async getCategoryTags(channelId: string): Promise { try { const cacheKey = `category:tag:${channelId}`; const cache = await this.redis.getJson(cacheKey); if (cache) { return cache; } this.logger.log(`Cache miss for category tags of channelId=${channelId}`); const channel = await this.prisma.channel.findUnique({ where: { channelId }, // select: { // categories: true, // }, }); this.logger.log( `Fetched channel data for channelId=${channelId}: ${JSON.stringify(channel)}`, ); type ChannelCategory = { id: string; name?: string; }; const categoryIds = (channel?.categories as ChannelCategory[] | null)?.map((c) => c.id) ?? []; const categories = await this.prisma.category.findMany({ where: { id: { in: categoryIds }, }, select: { name: true, subtitle: true, tagNames: true, }, orderBy: { seq: 'desc', }, }); if (categories.length > 0) { await this.redis.setJson(cacheKey, categories, 24 * 3600); } return categories; } catch (err) { this.logger.error( `Error fetching home section videos for channelId=${channelId}`, err instanceof Error ? err.stack : String(err), ); return []; } } async getCategoryList(): Promise { const raw = await this.redis.get(tsCacheKeys.category.all()); return this.parseCategoryCache(raw); } async getTagList(): Promise> { const raw = await this.redis.get(tsCacheKeys.tag.all()); if (!raw) { return []; } try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { return parsed as HomeTagCacheItem[]; } if (parsed && typeof parsed === 'object') { return parsed as Record; } } catch { this.logger.warn('Failed to parse tag list from Redis cache'); } return []; } async searchByCategoryName(q: string): Promise { const term = q?.trim(); if (!term) { return []; } const lowercaseTerm = term.toLowerCase(); const categories = await this.getCategoryList(); return categories.filter((category) => category.name.toLowerCase().includes(lowercaseTerm), ); } async searchByTagName(q: string): Promise { const term = q?.trim(); if (!term) { return []; } const lowercaseTerm = term.toLowerCase(); const categories = await this.getCategoryList(); return categories.filter((category) => category.tags.some((tag) => tag.toLowerCase().includes(lowercaseTerm)), ); } private parseCategoryCache(raw: string | null): HomeCategoryCacheItem[] { if (!raw) { return []; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { this.logger.warn('Failed to parse category list from Redis cache'); return []; } if (!Array.isArray(parsed)) { return []; } const entries: HomeCategoryCacheItem[] = []; let hadInvalidEntry = false; for (const entry of parsed) { if (!this.isValidCategoryEntry(entry)) { hadInvalidEntry = true; continue; } const candidate = entry as HomeCategoryCacheItem; entries.push({ id: candidate.id, name: candidate.name, subtitle: candidate.subtitle, seq: candidate.seq, tags: candidate.tags, }); } if (hadInvalidEntry) { this.logger.warn('Skipped invalid entries while parsing category cache'); } return entries.sort((a, b) => a.seq - b.seq); } private isValidCategoryEntry(entry: unknown): entry is HomeCategoryCacheItem { if (!entry || typeof entry !== 'object') { return false; } const candidate = entry as Record; if ( typeof candidate.id !== 'string' || typeof candidate.name !== 'string' ) { return false; } const seq = candidate.seq; if (typeof seq !== 'number' || Number.isNaN(seq)) { return false; } const subtitle = candidate.subtitle; if ( subtitle !== undefined && subtitle !== null && typeof subtitle !== 'string' ) { return false; } const tags = candidate.tags; if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== 'string')) { return false; } return true; } /** * Get recommended videos (delegated to VideoService) */ // private async getRecommendedVideos(): Promise { // return this.videoService.getRecommendedVideos(); // } /** * Fisher-Yates shuffle for random ordering */ private shuffle(array: T[]): T[] { const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } }