HotTRDealsBackend/services/redis/dbSync.service.js
2026-02-07 22:42:02 +00:00

624 lines
18 KiB
JavaScript

const { getRedisClient } = require("./client")
const prisma = require("../../db/client")
const voteDb = require("../../db/vote.db")
const commentLikeDb = require("../../db/commentLike.db")
const dealAnalyticsDb = require("../../db/dealAnalytics.db")
const dealSaveDb = require("../../db/dealSave.db")
const VOTE_HASH_KEY = "bull:dbsync:votes"
const COMMENT_LIKE_HASH_KEY = "bull:dbsync:commentLikes"
const COMMENT_HASH_KEY = "bull:dbsync:comments"
const COMMENT_DELETE_HASH_KEY = "bull:dbsync:commentDeletes"
const DEAL_UPDATE_HASH_KEY = "bull:dbsync:dealUpdates"
const DEAL_CREATE_HASH_KEY = "bull:dbsync:dealCreates"
const DEAL_AI_REVIEW_HASH_KEY = "bull:dbsync:dealAiReviews"
const NOTIFICATION_HASH_KEY = "bull:dbsync:notifications"
const NOTIFICATION_READ_HASH_KEY = "bull:dbsync:notificationReads"
const DEAL_SAVE_HASH_KEY = "bull:dbsync:dealSaves"
const AUDIT_HASH_KEY = "bull:dbsync:audits"
const USER_UPDATE_HASH_KEY = "bull:dbsync:users"
const USER_NOTE_HASH_KEY = "bull:dbsync:userNotes"
const DEAL_REPORT_UPDATE_HASH_KEY = "bull:dbsync:dealReportUpdates"
const CATEGORY_UPSERT_HASH_KEY = "bull:dbsync:categoryUpserts"
const SELLER_UPSERT_HASH_KEY = "bull:dbsync:sellerUpserts"
const SELLER_DOMAIN_UPSERT_HASH_KEY = "bull:dbsync:sellerDomainUpserts"
const DEAL_ANALYTICS_TOTAL_HASH_KEY = "bull:dbsync:dealAnalyticsTotals"
function createRedisClient() {
return getRedisClient()
}
async function tryQueue({ redisAction, fallbackAction, label }) {
try {
await redisAction()
return { queued: true }
} catch (err) {
if (fallbackAction) {
try {
await fallbackAction()
return { queued: false, fallback: true }
} catch (fallbackErr) {
console.error(`[dbsync-fallback] ${label || "unknown"} failed:`, fallbackErr?.message || fallbackErr)
}
}
console.error(`[dbsync-queue] ${label || "unknown"} failed:`, err?.message || err)
return { queued: false, fallback: false }
}
}
const DEAL_UPDATE_FIELDS = new Set([
"title",
"description",
"url",
"price",
"originalPrice",
"shippingPrice",
"couponCode",
"location",
"discountType",
"discountValue",
"barcodeId",
"maxNotifiedMilestone",
"status",
"saletype",
"affiliateType",
"sellerId",
"customSeller",
"categoryId",
"userId",
])
function sanitizeDealUpdate(data) {
const patch = {}
if (!data || typeof data !== "object") return patch
for (const [key, value] of Object.entries(data)) {
if (!DEAL_UPDATE_FIELDS.has(key)) continue
patch[key] = value
}
return patch
}
function normalizeDealCreateData(data = {}) {
return {
id: Number(data.id),
title: String(data.title || ""),
description: data.description ?? null,
url: data.url ?? null,
price: data.price ?? null,
originalPrice: data.originalPrice ?? null,
shippingPrice: data.shippingPrice ?? null,
percentOff: data.percentOff ?? null,
couponCode: data.couponCode ?? null,
location: data.location ?? null,
discountType: data.discountType ?? null,
discountValue: data.discountValue ?? null,
barcodeId: data.barcodeId ?? null,
maxNotifiedMilestone: Number.isFinite(Number(data.maxNotifiedMilestone))
? Number(data.maxNotifiedMilestone)
: 0,
userId: Number(data.userId),
status: String(data.status || "PENDING"),
saletype: String(data.saletype || "ONLINE"),
affiliateType: String(data.affiliateType || "NON_AFFILIATE"),
sellerId: data.sellerId ? Number(data.sellerId) : null,
customSeller: data.customSeller ?? null,
categoryId: Number.isInteger(Number(data.categoryId)) ? Number(data.categoryId) : 0,
createdAt: data.createdAt ? new Date(data.createdAt) : new Date(),
updatedAt: data.updatedAt ? new Date(data.updatedAt) : new Date(),
}
}
async function queueVoteUpdate({ dealId, userId, voteType, createdAt }) {
if (!dealId || !userId) return
const redis = createRedisClient()
const field = `vote:${dealId}:${userId}`
const payload = JSON.stringify({
dealId: Number(dealId),
userId: Number(userId),
voteType: Number(voteType),
createdAt,
})
await tryQueue({
label: "vote",
redisAction: () => redis.hset(VOTE_HASH_KEY, field, payload),
fallbackAction: () =>
voteDb.voteDealTx({
dealId: Number(dealId),
userId: Number(userId),
voteType: Number(voteType),
createdAt,
}),
})
}
async function queueCommentLikeUpdate({ commentId, userId, like, createdAt }) {
if (!commentId || !userId) return
const redis = createRedisClient()
const field = `commentLike:${commentId}:${userId}`
const payload = JSON.stringify({
commentId: Number(commentId),
userId: Number(userId),
like: Boolean(like),
createdAt,
})
await tryQueue({
label: "comment-like",
redisAction: () => redis.hset(COMMENT_LIKE_HASH_KEY, field, payload),
fallbackAction: () =>
commentLikeDb.setCommentLike({
commentId: Number(commentId),
userId: Number(userId),
like: Boolean(like),
}),
})
}
async function queueCommentCreate({ commentId, dealId, userId, text, parentId, createdAt }) {
if (!commentId || !dealId || !userId) return
const redis = createRedisClient()
const field = `comment:${commentId}`
const payload = JSON.stringify({
commentId: Number(commentId),
dealId: Number(dealId),
userId: Number(userId),
text: String(text || ""),
parentId: parentId ? Number(parentId) : null,
createdAt,
})
await tryQueue({
label: "comment-create",
redisAction: () => redis.hset(COMMENT_HASH_KEY, field, payload),
fallbackAction: async () => {
await prisma.$transaction(async (tx) => {
await tx.comment.create({
data: {
id: Number(commentId),
dealId: Number(dealId),
userId: Number(userId),
text: String(text || ""),
parentId: parentId ? Number(parentId) : null,
createdAt: createdAt ? new Date(createdAt) : new Date(),
},
})
await tx.deal.update({
where: { id: Number(dealId) },
data: { commentCount: { increment: 1 } },
})
})
},
})
}
async function queueCommentDelete({ commentId, dealId, createdAt }) {
if (!commentId || !dealId) return
const redis = createRedisClient()
const field = `commentDelete:${commentId}`
const payload = JSON.stringify({
commentId: Number(commentId),
dealId: Number(dealId),
createdAt,
})
await tryQueue({
label: "comment-delete",
redisAction: () => redis.hset(COMMENT_DELETE_HASH_KEY, field, payload),
fallbackAction: async () => {
await prisma.$transaction(async (tx) => {
const result = await tx.comment.updateMany({
where: { id: Number(commentId), deletedAt: null },
data: { deletedAt: new Date() },
})
if (result.count > 0) {
await tx.deal.update({
where: { id: Number(dealId) },
data: { commentCount: { decrement: 1 } },
})
}
})
},
})
}
async function queueDealUpdate({ dealId, data, updatedAt }) {
if (!dealId || !data || typeof data !== "object") return
const redis = createRedisClient()
const field = `dealUpdate:${dealId}`
const payload = JSON.stringify({
dealId: Number(dealId),
data,
updatedAt,
})
const patch = sanitizeDealUpdate(data)
await tryQueue({
label: "deal-update",
redisAction: () => redis.hset(DEAL_UPDATE_HASH_KEY, field, payload),
fallbackAction: async () => {
if (!Object.keys(patch).length) return
await prisma.deal.update({
where: { id: Number(dealId) },
data: { ...patch, updatedAt: updatedAt ? new Date(updatedAt) : new Date() },
})
},
})
}
async function queueDealCreate({ dealId, data, images = [], createdAt }) {
if (!dealId || !data || typeof data !== "object") return
const redis = createRedisClient()
const field = `dealCreate:${dealId}`
const payload = JSON.stringify({
dealId: Number(dealId),
data,
images: Array.isArray(images) ? images : [],
createdAt,
})
await tryQueue({
label: "deal-create",
redisAction: () => redis.hset(DEAL_CREATE_HASH_KEY, field, payload),
fallbackAction: async () => {
const normalized = normalizeDealCreateData({ ...data, id: dealId })
await prisma.deal.create({ data: normalized })
await dealAnalyticsDb.ensureTotalsForDealIds([Number(dealId)])
if (Array.isArray(images) && images.length) {
const imagesData = images.map((img) => ({
dealId: Number(dealId),
imageUrl: String(img.imageUrl || ""),
order: Number(img.order || 0),
}))
await prisma.dealImage.createMany({ data: imagesData })
}
await prisma.$executeRawUnsafe(
'SELECT setval(pg_get_serial_sequence(\'"Deal"\', \'id\'), (SELECT MAX(id) FROM "Deal"))'
)
},
})
}
async function queueDealAiReviewUpdate({ dealId, data, updatedAt }) {
if (!dealId || !data || typeof data !== "object") return
const redis = createRedisClient()
const field = `dealAiReview:${dealId}`
const payload = JSON.stringify({
dealId: Number(dealId),
data,
updatedAt,
})
await tryQueue({
label: "deal-ai-review",
redisAction: () => redis.hset(DEAL_AI_REVIEW_HASH_KEY, field, payload),
fallbackAction: () =>
prisma.dealAiReview.upsert({
where: { dealId: Number(dealId) },
create: {
dealId: Number(dealId),
bestCategoryId: Number(data.bestCategoryId) || 0,
tags: Array.isArray(data.tags) ? data.tags : [],
needsReview: Boolean(data.needsReview),
hasIssue: Boolean(data.hasIssue),
issueType: String(data.issueType || "NONE"),
issueReason: data.issueReason ?? null,
},
update: {
bestCategoryId: Number(data.bestCategoryId) || 0,
tags: Array.isArray(data.tags) ? data.tags : [],
needsReview: Boolean(data.needsReview),
hasIssue: Boolean(data.hasIssue),
issueType: String(data.issueType || "NONE"),
issueReason: data.issueReason ?? null,
},
}),
})
}
async function queueNotificationCreate({ userId, message, type = "INFO", createdAt }) {
if (!userId || !message) return
const redis = createRedisClient()
const field = `notification:${userId}:${Date.now()}`
const payload = JSON.stringify({
userId: Number(userId),
message: String(message),
type: String(type || "INFO"),
createdAt,
})
await tryQueue({
label: "notification-create",
redisAction: () => redis.hset(NOTIFICATION_HASH_KEY, field, payload),
fallbackAction: async () => {
await prisma.$transaction(async (tx) => {
await tx.notification.create({
data: {
userId: Number(userId),
message: String(message),
type: String(type || "INFO"),
createdAt: createdAt ? new Date(createdAt) : new Date(),
},
})
await tx.user.update({
where: { id: Number(userId) },
data: { notificationCount: { increment: 1 } },
})
})
},
})
}
async function queueNotificationReadAll({ userId, readAt }) {
if (!userId) return
const redis = createRedisClient()
const field = `notificationRead:${userId}:${Date.now()}`
const payload = JSON.stringify({
userId: Number(userId),
readAt,
})
await tryQueue({
label: "notification-read",
redisAction: () => redis.hset(NOTIFICATION_READ_HASH_KEY, field, payload),
fallbackAction: async () => {
const readAtDate = readAt ? new Date(readAt) : new Date()
await prisma.notification.updateMany({
where: {
userId: Number(userId),
readAt: null,
createdAt: { lte: readAtDate },
},
data: { readAt: readAtDate },
})
},
})
}
async function queueDealSaveUpdate({ dealId, userId, action, createdAt }) {
if (!dealId || !userId) return
const normalized = String(action || "").toUpperCase()
if (!["SAVE", "UNSAVE"].includes(normalized)) return
const redis = createRedisClient()
const field = `dealSave:${dealId}:${userId}`
const payload = JSON.stringify({
dealId: Number(dealId),
userId: Number(userId),
action: normalized,
createdAt,
})
await tryQueue({
label: "deal-save",
redisAction: () => redis.hset(DEAL_SAVE_HASH_KEY, field, payload),
fallbackAction: async () => {
if (normalized === "SAVE") {
await dealSaveDb.upsertDealSave({
dealId: Number(dealId),
userId: Number(userId),
createdAt: createdAt ? new Date(createdAt) : new Date(),
})
return
}
await dealSaveDb.deleteDealSave({ dealId: Number(dealId), userId: Number(userId) })
},
})
}
async function queueAuditEvent({ userId, action, ip, userAgent, meta = null, createdAt }) {
if (!action) return
const redis = createRedisClient()
const field = `audit:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`
const payload = JSON.stringify({
userId: userId ? Number(userId) : null,
action: String(action),
ip: ip ?? null,
userAgent: userAgent ?? null,
meta,
createdAt,
})
await tryQueue({
label: "audit",
redisAction: () => redis.hset(AUDIT_HASH_KEY, field, payload),
fallbackAction: () =>
prisma.auditEvent.create({
data: {
userId: userId ? Number(userId) : null,
action: String(action),
ip: ip ?? null,
userAgent: userAgent ?? null,
meta,
createdAt: createdAt ? new Date(createdAt) : new Date(),
},
}),
})
}
async function queueUserUpdate({ userId, data, updatedAt }) {
if (!userId || !data || typeof data !== "object") return
const redis = createRedisClient()
const field = `userUpdate:${userId}`
const payload = JSON.stringify({
userId: Number(userId),
data,
updatedAt,
})
await tryQueue({
label: "user-update",
redisAction: () => redis.hset(USER_UPDATE_HASH_KEY, field, payload),
fallbackAction: () =>
prisma.user.update({
where: { id: Number(userId) },
data: { ...data, updatedAt: updatedAt ? new Date(updatedAt) : new Date() },
}),
})
}
async function queueUserNoteCreate({ userId, createdById, note, createdAt }) {
if (!userId || !createdById || !note) return
const redis = createRedisClient()
const field = `userNote:${userId}:${Date.now()}`
const payload = JSON.stringify({
userId: Number(userId),
createdById: Number(createdById),
note: String(note),
createdAt,
})
await tryQueue({
label: "user-note",
redisAction: () => redis.hset(USER_NOTE_HASH_KEY, field, payload),
fallbackAction: () =>
prisma.userNote.create({
data: {
userId: Number(userId),
createdById: Number(createdById),
note: String(note),
createdAt: createdAt ? new Date(createdAt) : new Date(),
},
}),
})
}
async function queueDealReportStatusUpdate({ reportId, status, updatedAt }) {
if (!reportId || !status) return
const redis = createRedisClient()
const field = `dealReport:${reportId}`
const payload = JSON.stringify({
reportId: Number(reportId),
status: String(status),
updatedAt,
})
await tryQueue({
label: "deal-report",
redisAction: () => redis.hset(DEAL_REPORT_UPDATE_HASH_KEY, field, payload),
fallbackAction: () =>
prisma.dealReport.update({
where: { id: Number(reportId) },
data: { status: String(status), updatedAt: updatedAt ? new Date(updatedAt) : new Date() },
}),
})
}
async function queueCategoryUpsert({ categoryId, data, updatedAt }) {
if (!categoryId || !data || typeof data !== "object") return
const redis = createRedisClient()
const field = `category:${categoryId}`
const payload = JSON.stringify({
categoryId: Number(categoryId),
data,
updatedAt,
})
await tryQueue({
label: "category-upsert",
redisAction: () => redis.hset(CATEGORY_UPSERT_HASH_KEY, field, payload),
fallbackAction: () =>
prisma.category.upsert({
where: { id: Number(categoryId) },
create: { id: Number(categoryId), ...data },
update: data,
}),
})
}
async function queueSellerUpsert({ sellerId, data, updatedAt }) {
if (!sellerId || !data || typeof data !== "object") return
const redis = createRedisClient()
const field = `seller:${sellerId}`
const payload = JSON.stringify({
sellerId: Number(sellerId),
data,
updatedAt,
})
await tryQueue({
label: "seller-upsert",
redisAction: () => redis.hset(SELLER_UPSERT_HASH_KEY, field, payload),
fallbackAction: () =>
prisma.seller.upsert({
where: { id: Number(sellerId) },
create: { id: Number(sellerId), ...data },
update: data,
}),
})
}
async function queueSellerDomainUpsert({ sellerId, domain, createdById }) {
if (!sellerId || !domain || !createdById) return
const redis = createRedisClient()
const normalizedDomain = String(domain).toLowerCase()
const field = `sellerDomain:${sellerId}:${normalizedDomain}`
const payload = JSON.stringify({
sellerId: Number(sellerId),
domain: normalizedDomain,
createdById: Number(createdById),
})
await tryQueue({
label: "seller-domain-upsert",
redisAction: () => redis.hset(SELLER_DOMAIN_UPSERT_HASH_KEY, field, payload),
fallbackAction: () =>
prisma.sellerDomain.upsert({
where: { domain: normalizedDomain },
create: {
domain: normalizedDomain,
sellerId: Number(sellerId),
createdById: Number(createdById),
},
update: { sellerId: Number(sellerId) },
}),
})
}
module.exports = {
queueVoteUpdate,
queueCommentLikeUpdate,
queueCommentCreate,
queueCommentDelete,
queueDealUpdate,
queueDealCreate,
queueDealAiReviewUpdate,
queueNotificationCreate,
queueNotificationReadAll,
queueDealSaveUpdate,
queueAuditEvent,
queueUserUpdate,
queueUserNoteCreate,
queueDealReportStatusUpdate,
queueCategoryUpsert,
queueSellerUpsert,
queueSellerDomainUpsert,
COMMENT_HASH_KEY,
COMMENT_DELETE_HASH_KEY,
VOTE_HASH_KEY,
COMMENT_LIKE_HASH_KEY,
DEAL_UPDATE_HASH_KEY,
DEAL_CREATE_HASH_KEY,
DEAL_AI_REVIEW_HASH_KEY,
NOTIFICATION_HASH_KEY,
NOTIFICATION_READ_HASH_KEY,
DEAL_SAVE_HASH_KEY,
AUDIT_HASH_KEY,
USER_UPDATE_HASH_KEY,
USER_NOTE_HASH_KEY,
DEAL_REPORT_UPDATE_HASH_KEY,
CATEGORY_UPSERT_HASH_KEY,
SELLER_UPSERT_HASH_KEY,
SELLER_DOMAIN_UPSERT_HASH_KEY,
DEAL_ANALYTICS_TOTAL_HASH_KEY,
}