const { getRedisClient } = require("./redis/client") const userInterestProfileDb = require("../db/userInterestProfile.db") const { getCategoryDealIndexKey } = require("./redis/categoryDealIndex.service") const { getDealsByIdsFromRedis } = require("./redis/hotDealList.service") const { getNewDealIds } = require("./redis/newDealList.service") const FEED_KEY_PREFIX = "deals:lists:personalized:user:" const FEED_TTL_SECONDS = Math.max(60, Number(process.env.PERSONAL_FEED_TTL_SECONDS) || 2 * 60 * 60) const FEED_REBUILD_THRESHOLD_SECONDS = Math.max( 60, Number(process.env.PERSONAL_FEED_REBUILD_THRESHOLD_SECONDS) || 60 * 60 ) const FEED_CANDIDATE_LIMIT = Math.max(20, Number(process.env.PERSONAL_FEED_CANDIDATE_LIMIT) || 120) const FEED_MAX_CATEGORIES = Math.max(1, Number(process.env.PERSONAL_FEED_MAX_CATEGORIES) || 8) const FEED_PER_CATEGORY_LIMIT = Math.max(5, Number(process.env.PERSONAL_FEED_PER_CATEGORY_LIMIT) || 40) const FEED_LOOKBACK_DAYS = Math.max(1, Number(process.env.PERSONAL_FEED_LOOKBACK_DAYS) || 30) const FEED_NOISE_MAX = Math.max(0, Number(process.env.PERSONAL_FEED_NOISE_MAX) || 50) const FEED_PAGE_LIMIT = 20 function normalizePositiveInt(value) { const num = Number(value) if (!Number.isInteger(num) || num <= 0) return null return num } function normalizePagination({ page, limit }) { const rawPage = Number(page) const rawLimit = Number(limit) const safePage = Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1 const safeLimit = Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 50) : 20 return { page: safePage, limit: safeLimit, skip: (safePage - 1) * safeLimit } } function getLatestKey(userId) { return `${FEED_KEY_PREFIX}${userId}:latest` } function getFeedKey(userId, feedId) { return `${FEED_KEY_PREFIX}${userId}:${feedId}` } function getFeedKeyMatchPattern(userId) { return `${FEED_KEY_PREFIX}${userId}:*` } function parseCategoryScores(rawScores) { if (!rawScores || typeof rawScores !== "object" || Array.isArray(rawScores)) return [] const entries = [] for (const [categoryIdRaw, scoreRaw] of Object.entries(rawScores)) { const categoryId = normalizePositiveInt(categoryIdRaw) const score = Number(scoreRaw) if (!categoryId || !Number.isFinite(score) || score <= 0) continue entries.push({ categoryId, score }) } return entries.sort((a, b) => b.score - a.score) } function buildFallbackFeedIds(dealIds = []) { return Array.from( new Set( (Array.isArray(dealIds) ? dealIds : []) .map((id) => Number(id)) .filter((id) => Number.isInteger(id) && id > 0) ) ).slice(0, FEED_CANDIDATE_LIMIT) } function computePersonalScore({ categoryScore, dealScore }) { const safeCategory = Math.max(0, Number(categoryScore) || 0) const safeDealScore = Math.max(1, Number(dealScore) || 0) const noise = FEED_NOISE_MAX > 0 ? Math.floor(Math.random() * (FEED_NOISE_MAX + 1)) : 0 return safeCategory * safeDealScore + noise } async function getFeedFromRedis(redis, userId) { const latestId = await redis.get(getLatestKey(userId)) if (!latestId) return null const key = getFeedKey(userId, latestId) const raw = await redis.call("JSON.GET", key) const ttl = Number(await redis.ttl(key)) if (!raw || ttl <= 0) return null try { const parsed = JSON.parse(raw) return { id: String(parsed.id || latestId), dealIds: buildFallbackFeedIds(parsed.dealIds || []), ttl, } } catch { return null } } async function listUserFeedKeys(redis, userId) { const pattern = getFeedKeyMatchPattern(userId) const keys = [] let cursor = "0" do { const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100) cursor = String(nextCursor) if (Array.isArray(batch) && batch.length) { batch.forEach((key) => { if (String(key).endsWith(":latest")) return keys.push(String(key)) }) } } while (cursor !== "0") return Array.from(new Set(keys)) } function extractFeedIdFromKey(userId, key) { const prefix = `${FEED_KEY_PREFIX}${userId}:` if (!String(key).startsWith(prefix)) return null const feedId = String(key).slice(prefix.length) return feedId || null } async function getBestFeedFromRedis(redis, userId) { const keys = await listUserFeedKeys(redis, userId) if (!keys.length) return null const pipeline = redis.pipeline() keys.forEach((key) => pipeline.ttl(key)) keys.forEach((key) => pipeline.call("JSON.GET", key)) const results = await pipeline.exec() if (!Array.isArray(results) || !results.length) return null const ttlResults = results.slice(0, keys.length) const jsonResults = results.slice(keys.length) let best = null keys.forEach((key, idx) => { try { const ttl = Number(ttlResults[idx]?.[1] ?? -1) if (!Number.isFinite(ttl) || ttl <= 0) return const raw = jsonResults[idx]?.[1] if (!raw) return const parsed = JSON.parse(raw) const dealIds = buildFallbackFeedIds(parsed?.dealIds || []) const feedId = extractFeedIdFromKey(userId, key) || String(parsed?.id || "") if (!feedId) return if (!best || ttl > best.ttl) { best = { id: feedId, dealIds, ttl, } } } catch {} }) return best } async function setLatestPointer(redis, userId, feedId, ttlSeconds) { const ttl = Math.max(1, Number(ttlSeconds) || FEED_TTL_SECONDS) await redis.set(getLatestKey(userId), String(feedId), "EX", ttl) } async function collectCandidateIdsFromIndexes(redis, topCategories = []) { if (!topCategories.length) return new Map() const cutoffTs = Date.now() - FEED_LOOKBACK_DAYS * 24 * 60 * 60 * 1000 const pipeline = redis.pipeline() const refs = [] topCategories.forEach((entry) => { const key = getCategoryDealIndexKey(entry.categoryId) if (!key) return pipeline.zrevrangebyscore(key, "+inf", String(cutoffTs), "LIMIT", 0, FEED_PER_CATEGORY_LIMIT) refs.push(entry) }) if (!refs.length) return new Map() const results = await pipeline.exec() const categoryByDealId = new Map() results.forEach((result, idx) => { const [, rawIds] = result || [] const categoryEntry = refs[idx] const ids = Array.isArray(rawIds) ? rawIds : [] ids.forEach((id) => { const dealId = Number(id) if (!Number.isInteger(dealId) || dealId <= 0) return if (!categoryByDealId.has(dealId)) { categoryByDealId.set(dealId, categoryEntry) } }) }) return categoryByDealId } async function buildPersonalizedFeedForUser(redis, userId) { const profile = await userInterestProfileDb.getUserInterestProfile(userId) const categories = parseCategoryScores(profile?.categoryScores).slice(0, FEED_MAX_CATEGORIES) if (!categories.length) { const fallback = await getNewDealIds({}) return { id: String(Date.now()), dealIds: buildFallbackFeedIds(fallback?.dealIds || []), } } const categoryByDealId = await collectCandidateIdsFromIndexes(redis, categories) const candidateIds = Array.from(categoryByDealId.keys()).slice(0, FEED_CANDIDATE_LIMIT * 3) if (!candidateIds.length) { const fallback = await getNewDealIds({}) return { id: String(Date.now()), dealIds: buildFallbackFeedIds(fallback?.dealIds || []), } } const deals = await getDealsByIdsFromRedis(candidateIds, userId) const scored = deals .filter((deal) => String(deal?.status || "").toUpperCase() === "ACTIVE") .map((deal) => { const entry = categoryByDealId.get(Number(deal.id)) const categoryScore = Number(entry?.score || 0) return { id: Number(deal.id), score: computePersonalScore({ categoryScore, dealScore: Number(deal.score || 0), }), } }) .filter((item) => Number.isInteger(item.id) && item.id > 0) scored.sort((a, b) => b.score - a.score) const rankedIds = Array.from(new Set(scored.map((item) => item.id))).slice(0, FEED_CANDIDATE_LIMIT) if (!rankedIds.length) { const fallback = await getNewDealIds({}) return { id: String(Date.now()), dealIds: buildFallbackFeedIds(fallback?.dealIds || []), } } return { id: String(Date.now()), dealIds: rankedIds, } } async function cacheFeed(redis, userId, feed) { const feedId = String(feed?.id || Date.now()) const dealIds = buildFallbackFeedIds(feed?.dealIds || []) const key = getFeedKey(userId, feedId) const payload = { id: feedId, createdAt: new Date().toISOString(), total: dealIds.length, dealIds, } await redis.call("JSON.SET", key, "$", JSON.stringify(payload)) await redis.expire(key, FEED_TTL_SECONDS) await setLatestPointer(redis, userId, feedId, FEED_TTL_SECONDS) return { id: feedId, dealIds, ttl: FEED_TTL_SECONDS } } async function getOrBuildFeedIds(userId) { const uid = normalizePositiveInt(userId) if (!uid) return { id: null, dealIds: [] } const redis = getRedisClient() try { const best = (await getBestFeedFromRedis(redis, uid)) || (await getFeedFromRedis(redis, uid)) if (best && best.ttl >= FEED_REBUILD_THRESHOLD_SECONDS) { await setLatestPointer(redis, uid, best.id, best.ttl) return best } if (best && best.ttl > 0) { // Keep current feed as fallback while creating a fresh one. const built = await buildPersonalizedFeedForUser(redis, uid) const cached = await cacheFeed(redis, uid, built) return cached?.dealIds?.length ? cached : best } } catch {} try { const built = await buildPersonalizedFeedForUser(redis, uid) return cacheFeed(redis, uid, built) } catch { const fallback = await getNewDealIds({}) const dealIds = buildFallbackFeedIds(fallback?.dealIds || []) const payload = { id: String(Date.now()), dealIds, ttl: 0 } try { return cacheFeed(redis, uid, payload) } catch { return payload } } } async function getPersonalizedDeals({ userId, page = 1, } = {}) { const uid = normalizePositiveInt(userId) if (!uid) return { page: 1, total: 0, totalPages: 0, results: [], personalizedListId: null } const pagination = normalizePagination({ page, limit: FEED_PAGE_LIMIT }) const feed = await getOrBuildFeedIds(uid) const ids = Array.isArray(feed?.dealIds) ? feed.dealIds : [] const pageIds = ids.slice(pagination.skip, pagination.skip + pagination.limit) const deals = await getDealsByIdsFromRedis(pageIds, uid) return { page: pagination.page, total: ids.length, totalPages: ids.length ? Math.ceil(ids.length / pagination.limit) : 0, results: deals, personalizedListId: feed?.id || null, } } module.exports = { getPersonalizedDeals, }