const dealDB = require("../../db/deal.db") const dealAnalyticsDb = require("../../db/dealAnalytics.db") const categoryDB = require("../../db/category.db") const { findSellers } = require("../../db/seller.db") const { getRedisClient } = require("./client") const { setUsersPublicInRedis } = require("./userPublicCache.service") const { setBadgesInRedis } = require("./badgeCache.service") const badgeDb = require("../../db/badge.db") const DEAL_KEY_PREFIX = "data:deals:" const DEAL_VOTE_HASH_PREFIX = "data:deals:votes:" const DEAL_ANALYTICS_TOTAL_PREFIX = "data:deals:analytics:total:" const COMMENT_LOOKUP_KEY = "data:comments:lookup" const COMMENT_IDS_KEY = "data:comments:ids" const SELLERS_KEY = "data:sellers" const SELLER_DOMAINS_KEY = "data:sellerdomains" const CATEGORIES_KEY = "data:categories" function createRedisClient() { return getRedisClient() } function toIso(value) { return value instanceof Date ? value.toISOString() : value ?? null } function toEpochMs(value) { if (value instanceof Date) return value.getTime() const date = new Date(value) return Number.isNaN(date.getTime()) ? null : date.getTime() } function mapDealToRedisJson(deal) { const tags = Array.isArray(deal.dealTags) && deal.dealTags.length ? deal.dealTags .map((dt) => dt?.tag) .filter(Boolean) .map((tag) => ({ id: tag.id, slug: tag.slug, name: tag.name, })) : [] const votes = Array.isArray(deal.votes) && deal.votes.length ? deal.votes.map((vote) => ({ userId: vote.userId, voteType: vote.voteType, })) : [] const commentsRaw = Array.isArray(deal.comments) ? deal.comments : [] const repliesCountByParent = new Map() commentsRaw.forEach((comment) => { if (!comment.parentId) return if (comment.deletedAt) return repliesCountByParent.set( comment.parentId, (repliesCountByParent.get(comment.parentId) || 0) + 1 ) }) const comments = commentsRaw.length ? commentsRaw.map((comment) => ({ id: comment.id, dealId: comment.dealId, text: comment.text, userId: comment.userId, createdAt: toIso(comment.createdAt), parentId: comment.parentId ?? null, likeCount: Number.isFinite(comment.likeCount) ? comment.likeCount : 0, repliesCount: repliesCountByParent.get(comment.id) || 0, deletedAt: toIso(comment.deletedAt), user: comment.user ? { id: comment.user.id, username: comment.user.username, avatarUrl: comment.user.avatarUrl ?? null, } : null, likes: Array.isArray(comment.likes) ? comment.likes.map((like) => ({ userId: like.userId })) : [], })) : [] const savedBy = Array.isArray(deal.savedBy) && deal.savedBy.length ? deal.savedBy.map((save) => ({ userId: save.userId, createdAt: toIso(save.createdAt), })) : [] return { id: deal.id, title: deal.title, description: deal.description ?? null, url: deal.url ?? null, price: deal.price ?? null, originalPrice: deal.originalPrice ?? null, shippingPrice: deal.shippingPrice ?? null, percentOff: deal.percentOff ?? null, couponCode: deal.couponCode ?? null, hasCouponCode: deal.couponCode ? 1 : 0, location: deal.location ?? null, discountType: deal.discountType ?? null, discountValue: deal.discountValue ?? null, maxNotifiedMilestone: Number.isFinite(deal.maxNotifiedMilestone) ? deal.maxNotifiedMilestone : 0, userId: deal.userId ?? null, score: deal.score ?? 0, commentCount: deal.commentCount ?? 0, status: deal.status ?? null, saletype: deal.saletype ?? null, affiliateType: deal.affiliateType ?? null, sellerId: deal.sellerId ?? null, customSeller: deal.customSeller ?? null, categoryId: deal.categoryId ?? null, createdAt: toIso(deal.createdAt), updatedAt: toIso(deal.updatedAt), createdAtTs: toEpochMs(deal.createdAt), updatedAtTs: toEpochMs(deal.updatedAt), images: Array.isArray(deal.images) ? deal.images.map((img) => ({ id: img.id, imageUrl: img.imageUrl, order: img.order, })) : [], tags, votes, savedBy, comments, aiReview: deal.aiReview ? { bestCategoryId: deal.aiReview.bestCategoryId, tags: Array.isArray(deal.aiReview.tags) ? deal.aiReview.tags : [], needsReview: deal.aiReview.needsReview, hasIssue: deal.aiReview.hasIssue, issueType: deal.aiReview.issueType, issueReason: deal.aiReview.issueReason ?? null, } : null, } } async function seedRecentDealsToRedis({ days = 30, ttlDays = 31, batchSize = 200 } = {}) { const redis = createRedisClient() const cutoff = new Date(Date.now() - Number(days) * 24 * 60 * 60 * 1000) const ttlWindowMs = Math.max(1, Number(ttlDays)) * 24 * 60 * 60 * 1000 try { const deals = await dealDB.findDeals( { createdAt: { gte: cutoff } }, { orderBy: { createdAt: "desc" }, include: { user: { select: { id: true, username: true, avatarUrl: true, userBadges: { orderBy: { earnedAt: "desc" }, select: { earnedAt: true, badge: { select: { id: true, name: true, iconUrl: true, description: true } }, }, }, }, }, images: { orderBy: { order: "asc" }, select: { id: true, imageUrl: true, order: true } }, dealTags: { include: { tag: { select: { id: true, slug: true, name: true } } } }, votes: { select: { userId: true, voteType: true } }, savedBy: { select: { userId: true, createdAt: true } }, comments: { orderBy: { createdAt: "desc" }, include: { user: { select: { id: true, username: true, avatarUrl: true } }, likes: { select: { userId: true } }, }, }, aiReview: { select: { bestCategoryId: true, tags: true, needsReview: true, hasIssue: true, issueType: true, issueReason: true, }, }, }, } ) const dealIds = deals.map((deal) => deal.id) await dealAnalyticsDb.ensureTotalsForDealIds(dealIds) const totals = await dealAnalyticsDb.getTotalsByDealIds(dealIds) const totalsById = new Map( (Array.isArray(totals) ? totals : []).map((t) => [ t.dealId, { impressions: Number(t.impressions) || 0, views: Number(t.views) || 0, clicks: Number(t.clicks) || 0, }, ]) ) const userTtlById = {} const users = [] const seenUsers = new Set() deals.forEach((deal) => { const user = deal?.user if (user && user.id && !seenUsers.has(user.id)) { users.push(user) seenUsers.add(user.id) } const createdAt = deal?.createdAt instanceof Date ? deal.createdAt : new Date(deal?.createdAt) const ageMs = Number.isNaN(createdAt?.getTime()) ? 0 : Date.now() - createdAt.getTime() const ttlMs = Math.max(1, ttlWindowMs - Math.max(0, ageMs)) const ttlSeconds = Math.ceil(ttlMs / 1000) if (user?.id) { userTtlById[user.id] = Math.max(userTtlById[user.id] || 0, ttlSeconds) } }) let created = 0 for (let i = 0; i < deals.length; i += batchSize) { const chunk = deals.slice(i, i + batchSize) const pipeline = redis.pipeline() const setCommands = [] let cmdIndex = 0 for (const deal of chunk) { try { const key = `${DEAL_KEY_PREFIX}${deal.id}` const payload = JSON.stringify(mapDealToRedisJson(deal)) pipeline.call("JSON.SET", key, "$", payload, "NX") setCommands.push({ deal, index: cmdIndex }) cmdIndex += 1 const totals = totalsById.get(deal.id) || { impressions: 0, views: 0, clicks: 0 } pipeline.hset( `${DEAL_ANALYTICS_TOTAL_PREFIX}${deal.id}`, "impressions", String(totals.impressions || 0), "views", String(totals.views || 0), "clicks", String(totals.clicks || 0) ) cmdIndex += 1 if (Array.isArray(deal.comments) && deal.comments.length) { deal.comments.forEach((comment) => { pipeline.hset(COMMENT_LOOKUP_KEY, String(comment.id), String(deal.id)) pipeline.sadd(COMMENT_IDS_KEY, String(comment.id)) cmdIndex += 1 }) } if (Array.isArray(deal.votes) && deal.votes.length) { deal.votes.forEach((vote) => { if (!vote?.userId) return pipeline.hset( `${DEAL_VOTE_HASH_PREFIX}${deal.id}`, String(vote.userId), String(vote.voteType ?? 0) ) cmdIndex += 1 }) } } catch (err) { console.error("Redis seed skip deal:", deal?.id, err?.message || err) } } const results = await pipeline.exec() for (const entry of setCommands) { const deal = entry.deal const createdAt = deal?.createdAt instanceof Date ? deal.createdAt : new Date(deal?.createdAt) const ageMs = Number.isNaN(createdAt?.getTime()) ? 0 : Date.now() - createdAt.getTime() const ttlMs = Math.max(1, ttlWindowMs - Math.max(0, ageMs)) const ttlSeconds = Math.ceil(ttlMs / 1000) const dealKey = `${DEAL_KEY_PREFIX}${deal.id}` const voteKey = `${DEAL_VOTE_HASH_PREFIX}${deal.id}` const analyticsKey = `${DEAL_ANALYTICS_TOTAL_PREFIX}${deal.id}` const dealTtl = await redis.ttl(dealKey) if (dealTtl === -1) { await redis.expire(dealKey, ttlSeconds) } const voteTtl = await redis.ttl(voteKey) if (voteTtl === -1) { await redis.expire(voteKey, ttlSeconds) } const analyticsTtl = await redis.ttl(analyticsKey) if (analyticsTtl === -1) { await redis.expire(analyticsKey, ttlSeconds) } if (results?.[entry.index]?.[1] === "OK") { created += 1 } } } if (users.length) { await setUsersPublicInRedis(users, { ttlSecondsById: userTtlById }) } console.log(`✅ Redis seeded deals: ${created} added (last ${days} days)`) } finally {} } async function seedSellersToRedis(redis, sellers = []) { if (!sellers.length) return 0 const pipeline = redis.pipeline() sellers.forEach((seller) => { pipeline.hset( SELLERS_KEY, String(seller.id), JSON.stringify({ id: seller.id, name: seller.name, url: seller.url ?? null, sellerLogo: seller.sellerLogo ?? null, isActive: Boolean(seller.isActive), }) ) }) await pipeline.exec() return sellers.length } async function seedSellerDomainsToRedis(redis, sellers = []) { if (!sellers.length) return 0 const pipeline = redis.pipeline() sellers.forEach((seller) => { const domains = Array.isArray(seller.domains) ? seller.domains : [] domains.forEach((entry) => { if (!entry?.domain) return pipeline.hset(SELLER_DOMAINS_KEY, String(entry.domain).toLowerCase(), String(seller.id)) }) }) await pipeline.exec() return sellers.length } async function seedCategoriesToRedis(redis, categories = []) { if (!categories.length) return 0 const pipeline = redis.pipeline() categories.forEach((cat) => { pipeline.hset( CATEGORIES_KEY, String(cat.id), JSON.stringify({ id: cat.id, name: cat.name, slug: cat.slug, parentId: cat.parentId ?? null, isActive: cat.isActive !== undefined ? Boolean(cat.isActive) : true, description: cat.description ?? "", }) ) }) await pipeline.exec() return categories.length } async function seedReferenceDataToRedis() { const [sellers, categories, badges] = await Promise.all([ findSellers({}, { include: { domains: { select: { domain: true } } } }), categoryDB.listCategories({ select: { id: true, name: true, slug: true, parentId: true, isActive: true, description: true } }), badgeDb.listBadges(), ]) const redis = createRedisClient() try { await seedSellersToRedis(redis, sellers) await seedSellerDomainsToRedis(redis, sellers) await seedCategoriesToRedis(redis, categories) if (badges.length) await setBadgesInRedis(badges) console.log( `✅ Redis seeded reference data: sellers=${sellers.length} categories=${categories.length} badges=${badges.length}` ) } finally {} } module.exports = { seedRecentDealsToRedis, seedReferenceDataToRedis, mapDealToRedisJson }