382 lines
12 KiB
JavaScript
382 lines
12 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 { addDealToCategoryIndexInPipeline } = require("./categoryDealIndex.service")
|
|
const { addDealToSellerIndexInPipeline } = 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"
|
|
const SELLERS_KEY = "data:sellers"
|
|
const SELLER_DOMAINS_KEY = "data:seller:domains"
|
|
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 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 }))
|
|
: [],
|
|
}))
|
|
: []
|
|
|
|
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,
|
|
barcodeId: deal.barcodeId ?? 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,
|
|
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 } } } },
|
|
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 mapped = mapDealToRedisJson(deal)
|
|
const key = `${DEAL_KEY_PREFIX}${deal.id}`
|
|
const payload = JSON.stringify(mapped)
|
|
pipeline.call("JSON.SET", key, "$", payload, "NX")
|
|
setCommands.push({ deal, index: cmdIndex })
|
|
cmdIndex += 1
|
|
if (
|
|
addDealToCategoryIndexInPipeline(pipeline, {
|
|
dealId: deal.id,
|
|
categoryId: mapped.categoryId,
|
|
createdAtTs: mapped.createdAtTs,
|
|
status: mapped.status,
|
|
})
|
|
) {
|
|
cmdIndex += 1
|
|
}
|
|
if (
|
|
addDealToSellerIndexInPipeline(pipeline, {
|
|
dealId: deal.id,
|
|
sellerId: mapped.sellerId,
|
|
createdAtTs: mapped.createdAtTs,
|
|
status: mapped.status,
|
|
})
|
|
) {
|
|
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
|
|
})
|
|
}
|
|
} 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 analyticsKey = `${DEAL_ANALYTICS_TOTAL_PREFIX}${deal.id}`
|
|
const dealTtl = await redis.ttl(dealKey)
|
|
if (dealTtl === -1) {
|
|
await redis.expire(dealKey, 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 }
|
|
|