348 lines
10 KiB
JavaScript
348 lines
10 KiB
JavaScript
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,
|
|
}
|