const prisma = require("./client") const DEFAULT_SATURATION_RATIO = 0.3 const DEFAULT_TX_USER_CHUNK_SIZE = Math.max( 1, Number(process.env.USER_INTEREST_DB_TX_USER_CHUNK_SIZE) || 200 ) function getDb(db) { return db || prisma } function normalizePositiveInt(value) { const num = Number(value) if (!Number.isInteger(num) || num <= 0) return null return num } function normalizePoints(value) { const num = Number(value) if (!Number.isFinite(num) || num <= 0) return null return Math.floor(num) } function normalizeScores(raw) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {} return { ...raw } } function normalizeSaturationRatio(value) { const num = Number(value) if (!Number.isFinite(num)) return DEFAULT_SATURATION_RATIO if (num <= 0 || num >= 1) return DEFAULT_SATURATION_RATIO return num } function getMaxAllowedBySaturation({ currentCategoryScore, totalScore, ratio }) { const current = Number(currentCategoryScore) || 0 const total = Number(totalScore) || 0 const otherTotal = Math.max(0, total - current) if (otherTotal <= 0) return Number.POSITIVE_INFINITY return Math.floor((otherTotal * ratio) / (1 - ratio)) } function aggregateIncrements(increments = []) { const map = new Map() for (const item of Array.isArray(increments) ? increments : []) { const userId = normalizePositiveInt(item?.userId) const categoryId = normalizePositiveInt(item?.categoryId) const points = normalizePoints(item?.points) if (!userId || !categoryId || !points) continue const key = `${userId}:${categoryId}` map.set(key, (map.get(key) || 0) + points) } const groupedByUser = new Map() for (const [key, points] of map.entries()) { const [userIdRaw, categoryIdRaw] = key.split(":") const userId = Number(userIdRaw) const categoryId = Number(categoryIdRaw) if (!groupedByUser.has(userId)) groupedByUser.set(userId, []) groupedByUser.get(userId).push({ categoryId, points }) } return groupedByUser } function chunkEntries(entries = [], size = DEFAULT_TX_USER_CHUNK_SIZE) { const normalizedSize = Math.max(1, Number(size) || DEFAULT_TX_USER_CHUNK_SIZE) const chunks = [] for (let i = 0; i < entries.length; i += normalizedSize) { chunks.push(entries.slice(i, i + normalizedSize)) } return chunks } async function getUserInterestProfile(userId, db) { const uid = normalizePositiveInt(userId) if (!uid) return null const p = getDb(db) const rows = await p.$queryRawUnsafe( 'SELECT "userId", "categoryScores", "totalScore", "createdAt", "updatedAt" FROM "UserInterestProfile" WHERE "userId" = $1 LIMIT 1', uid ) return Array.isArray(rows) && rows.length ? rows[0] : null } async function applyInterestIncrementsBatch(increments = [], options = {}, db) { const groupedByUser = aggregateIncrements(increments) if (!groupedByUser.size) { return { updated: 0, appliedPoints: 0 } } const saturationRatio = normalizeSaturationRatio(options?.saturationRatio) let updated = 0 let appliedPoints = 0 const userEntries = Array.from(groupedByUser.entries()) const chunks = chunkEntries(userEntries) for (const chunk of chunks) { await getDb(db).$transaction(async (tx) => { for (const [userId, entries] of chunk) { await tx.$executeRawUnsafe( 'INSERT INTO "UserInterestProfile" ("userId", "categoryScores", "totalScore", "createdAt", "updatedAt") VALUES ($1, \'{}\'::jsonb, 0, NOW(), NOW()) ON CONFLICT ("userId") DO NOTHING', userId ) const rows = await tx.$queryRawUnsafe( 'SELECT "userId", "categoryScores", "totalScore" FROM "UserInterestProfile" WHERE "userId" = $1 FOR UPDATE', userId ) const profile = Array.isArray(rows) && rows.length ? rows[0] : null if (!profile) continue const scores = normalizeScores(profile.categoryScores) let totalScore = Number(profile.totalScore || 0) let changed = false for (const entry of entries) { const categoryKey = String(entry.categoryId) const currentCategoryScore = Number(scores[categoryKey] || 0) const maxAllowedBySaturation = getMaxAllowedBySaturation({ currentCategoryScore, totalScore, ratio: saturationRatio, }) let nextCategoryScore = currentCategoryScore + entry.points if (Number.isFinite(maxAllowedBySaturation)) { nextCategoryScore = Math.min(nextCategoryScore, maxAllowedBySaturation) } const applied = Math.max(0, Math.floor(nextCategoryScore - currentCategoryScore)) if (applied <= 0) continue scores[categoryKey] = currentCategoryScore + applied totalScore += applied appliedPoints += applied changed = true } if (!changed) continue await tx.$executeRawUnsafe( 'UPDATE "UserInterestProfile" SET "categoryScores" = $1::jsonb, "totalScore" = $2, "updatedAt" = NOW() WHERE "userId" = $3', JSON.stringify(scores), totalScore, userId ) updated += 1 } }) } return { updated, appliedPoints } } module.exports = { getUserInterestProfile, applyInterestIncrementsBatch, }