HotTRDealsBackend/services/redis/dealIndexing.service.js
2026-02-04 06:39:10 +00:00

395 lines
13 KiB
JavaScript

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 }