const dealDB = require("../../db/deal.db") const userDB = require("../../db/user.db") const dealAnalyticsDb = require("../../db/dealAnalytics.db") const { getRedisClient } = require("./client") const { mapDealToRedisJson } = require("./dealIndexing.service") const { recordCacheMiss } = require("./cacheMetrics.service") const { getUserPublicFromRedis, setUserPublicInRedis, ensureUserMinTtl, } = require("./userPublicCache.service") const { reconcileDealCategoryIndex } = require("./categoryDealIndex.service") const { reconcileDealSellerIndex } = require("./sellerDealIndex.service") const DEAL_KEY_PREFIX = "deals:cache:" const DEAL_ANALYTICS_TOTAL_PREFIX = "deals:analytics:total:" const COMMENT_LOOKUP_KEY = "comments:lookup" const COMMENT_IDS_KEY = "comments:ids" 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() } async function getAnalyticsTotalsForDeal(dealId) { const id = Number(dealId) if (!Number.isInteger(id) || id <= 0) { return { impressions: 0, views: 0, clicks: 0 } } await dealAnalyticsDb.ensureTotalsForDealIds([id]) const totals = await dealAnalyticsDb.getTotalsByDealIds([id]) const entry = totals?.[0] || { impressions: 0, views: 0, clicks: 0 } return { impressions: Number(entry.impressions) || 0, views: Number(entry.views) || 0, clicks: Number(entry.clicks) || 0, } } async function cacheVotesAndAnalytics(redis, dealId, payload, { ttlSeconds, skipDbEnsure } = {}) { const analyticsKey = `${DEAL_ANALYTICS_TOTAL_PREFIX}${dealId}` const pipeline = redis.pipeline() const totals = skipDbEnsure ? { impressions: 0, views: 0, clicks: 0 } : await getAnalyticsTotalsForDeal(dealId) pipeline.hset( analyticsKey, "impressions", String(totals.impressions || 0), "views", String(totals.views || 0), "clicks", String(totals.clicks || 0) ) if (ttlSeconds) { pipeline.expire(analyticsKey, Number(ttlSeconds)) } try { await pipeline.exec() } catch { // ignore cache failures } } async function ensureMinDealTtl(dealId, { minSeconds = 15 * 60 } = {}) { const id = Number(dealId) if (!Number.isInteger(id) || id <= 0) return { bumped: false } const redis = createRedisClient() const key = `${DEAL_KEY_PREFIX}${id}` const analyticsKey = `${DEAL_ANALYTICS_TOTAL_PREFIX}${id}` const minTtl = Math.max(1, Number(minSeconds) || 15 * 60) try { const ttl = await redis.ttl(key) if (ttl === -2) return { bumped: false } // no key if (ttl === -1 || ttl < minTtl) { const nextTtl = minTtl const pipeline = redis.pipeline() pipeline.expire(key, nextTtl) pipeline.expire(analyticsKey, nextTtl) await pipeline.exec() return { bumped: true, ttl: nextTtl } } return { bumped: false, ttl } } catch { return { bumped: false } } finally {} } async function updateDealSavesInRedis({ dealId, userId, action, createdAt, minSeconds = 15 * 60 } = {}) { const id = Number(dealId) const uid = Number(userId) if (!Number.isInteger(id) || id <= 0 || !Number.isInteger(uid) || uid <= 0) { return { updated: false } } const normalized = String(action || "SAVE").toUpperCase() if (!["SAVE", "UNSAVE"].includes(normalized)) return { updated: false } const redis = createRedisClient() const key = `${DEAL_KEY_PREFIX}${id}` try { await ensureMinDealTtl(id, { minSeconds }) return { updated: true } } catch { return { updated: false } } finally {} } async function getDealFromRedis(dealId) { const redis = createRedisClient() try { const key = `${DEAL_KEY_PREFIX}${dealId}` const raw = await redis.call("JSON.GET", key) if (!raw) { await recordCacheMiss({ key }) return null } const deal = JSON.parse(raw) if (!deal?.user && deal?.userId) { const cachedUser = await getUserPublicFromRedis(deal.userId) if (cachedUser) { deal.user = cachedUser } else { const user = await userDB.findUser( { id: Number(deal.userId) }, { select: { id: true, username: true, avatarUrl: true, userBadges: { orderBy: { earnedAt: "desc" }, select: { earnedAt: true, badge: { select: { id: true, name: true, iconUrl: true, description: true } }, }, }, }, } ) if (user) { const ttl = await redis.ttl(key) const ttlSeconds = ttl > 0 ? ttl : undefined await setUserPublicInRedis(user, { ttlSeconds }) deal.user = user } } } return deal } catch { return null } finally {} } async function cacheDealFromDb(dealId, { ttlSeconds = 1800 } = {}) { const deal = await dealDB.findDeal( { id: Number(dealId) }, { 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 } } } }, 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, }, }, }, } ) if (!deal) return null if (deal.user) { await setUserPublicInRedis(deal.user, { ttlSeconds }) } const payload = mapDealToRedisJson(deal) const redis = createRedisClient() try { const key = `${DEAL_KEY_PREFIX}${deal.id}` const pipeline = redis.pipeline() pipeline.call("JSON.SET", key, "$", JSON.stringify(payload)) if (ttlSeconds) { pipeline.expire(key, Number(ttlSeconds)) } if (Array.isArray(payload.comments) && payload.comments.length) { payload.comments.forEach((comment) => { pipeline.hset(COMMENT_LOOKUP_KEY, String(comment.id), String(deal.id)) pipeline.sadd(COMMENT_IDS_KEY, String(comment.id)) }) } await pipeline.exec() await cacheVotesAndAnalytics(redis, deal.id, payload, { ttlSeconds }) await reconcileDealCategoryIndex({ before: null, after: payload }) await reconcileDealSellerIndex({ before: null, after: payload }) } catch { // ignore cache failures } finally {} if (deal.user) { await ensureUserMinTtl(deal.user.id, { minSeconds: ttlSeconds }) } return deal.user ? { ...payload, user: deal.user } : payload } async function getDealIdByCommentId(commentId) { const redis = createRedisClient() try { const raw = await redis.hget(COMMENT_LOOKUP_KEY, String(commentId)) if (!raw) { await recordCacheMiss({ key: `${COMMENT_LOOKUP_KEY}:${commentId}`, label: "comment-lookup" }) } return raw ? Number(raw) : null } catch { return null } finally {} } async function getOrCacheDeal(dealId, { ttlSeconds = 1800 } = {}) { const cached = await getDealFromRedis(dealId) if (cached) { await ensureMinDealTtl(dealId, { minSeconds: ttlSeconds }) return cached } return cacheDealFromDb(dealId, { ttlSeconds }) } async function getOrCacheDealForModeration(dealId, { ttlSeconds = 1800 } = {}) { const cached = await getDealFromRedis(dealId) if (cached) return { deal: cached, fromCache: true } const deal = await cacheDealFromDb(dealId, { ttlSeconds }) return { deal, fromCache: false } } async function updateDealInRedis(dealId, patch = {}, { updatedAt = new Date() } = {}) { const redis = createRedisClient() const key = `${DEAL_KEY_PREFIX}${dealId}` const ts = toEpochMs(updatedAt) const iso = toIso(updatedAt) try { const beforeRaw = await redis.call("JSON.GET", key) if (!beforeRaw) return null let beforeDeal = null try { beforeDeal = JSON.parse(beforeRaw) } catch {} const pipeline = redis.pipeline() Object.entries(patch || {}).forEach(([field, value]) => { if (value === undefined) return pipeline.call("JSON.SET", key, `$.${field}`, JSON.stringify(value)) }) if (iso) pipeline.call("JSON.SET", key, "$.updatedAt", JSON.stringify(iso)) if (ts != null) pipeline.call("JSON.SET", key, "$.updatedAtTs", JSON.stringify(ts)) await pipeline.exec() const raw = await redis.call("JSON.GET", key) const updated = raw ? JSON.parse(raw) : null await reconcileDealCategoryIndex({ before: beforeDeal, after: updated }) await reconcileDealSellerIndex({ before: beforeDeal, after: updated }) return updated } catch { return null } finally {} } async function setDealInRedis( dealId, payload, { ttlSeconds = 31 * 24 * 60 * 60, skipAnalyticsInit = false } = {} ) { if (!dealId || !payload) return null const redis = createRedisClient() const key = `${DEAL_KEY_PREFIX}${dealId}` try { const pipeline = redis.pipeline() pipeline.call("JSON.SET", key, "$", JSON.stringify(payload)) if (ttlSeconds) { pipeline.expire(key, Number(ttlSeconds)) } if (Array.isArray(payload.comments) && payload.comments.length) { payload.comments.forEach((comment) => { pipeline.hset(COMMENT_LOOKUP_KEY, String(comment.id), String(dealId)) pipeline.sadd(COMMENT_IDS_KEY, String(comment.id)) }) } await pipeline.exec() await cacheVotesAndAnalytics(redis, dealId, payload, { ttlSeconds, skipDbEnsure: skipAnalyticsInit, }) await reconcileDealCategoryIndex({ before: null, after: payload }) await reconcileDealSellerIndex({ before: null, after: payload }) return payload } catch { return payload } finally {} } module.exports = { getDealFromRedis, cacheDealFromDb, getOrCacheDeal, getDealIdByCommentId, getOrCacheDealForModeration, updateDealInRedis, setDealInRedis, ensureMinDealTtl, updateDealSavesInRedis, }