diff --git a/adapters/responses/dealCard.adapter.js b/adapters/responses/dealCard.adapter.js index fe33b9c..32913b3 100644 --- a/adapters/responses/dealCard.adapter.js +++ b/adapters/responses/dealCard.adapter.js @@ -12,6 +12,7 @@ function mapDealToDealCardResponse(deal) { location: deal.location ?? null, discountType: deal.discountType ?? null, discountValue: deal.discountValue ?? null, + barcodeId: deal.barcodeId ?? null, score: deal.score, commentsCount: deal.commentCount, diff --git a/adapters/responses/dealDetail.adapter.js b/adapters/responses/dealDetail.adapter.js index 8d3647e..d8e2b66 100644 --- a/adapters/responses/dealDetail.adapter.js +++ b/adapters/responses/dealDetail.adapter.js @@ -61,6 +61,7 @@ function mapDealToDealDetailResponse(deal) { location: deal.location ?? null, discountType: deal.discountType ?? null, discountValue: deal.discountValue ?? null, + barcodeId: deal.barcodeId ?? null, score: Number.isFinite(deal.score) ? deal.score : 0, myVote: deal.myVote ?? 0, isSaved: Boolean(deal.isSaved), diff --git a/db/dealAnalytics.db.js b/db/dealAnalytics.db.js index ca80ba7..0ce7c78 100644 --- a/db/dealAnalytics.db.js +++ b/db/dealAnalytics.db.js @@ -96,8 +96,39 @@ async function applyDealEventBatch(events = []) { return { inserted: data.length, increments } } +async function applyDealTotalsBatch(increments = []) { + const data = (Array.isArray(increments) ? increments : []).filter( + (item) => item && Number.isInteger(Number(item.dealId)) + ) + if (!data.length) return { updated: 0 } + + await prisma.$transaction(async (tx) => { + for (const inc of data) { + const dealId = Number(inc.dealId) + if (!Number.isInteger(dealId) || dealId <= 0) continue + await tx.dealAnalyticsTotal.upsert({ + where: { dealId }, + create: { + dealId, + impressions: Number(inc.impressions || 0), + views: Number(inc.views || 0), + clicks: Number(inc.clicks || 0), + }, + update: { + impressions: { increment: Number(inc.impressions || 0) }, + views: { increment: Number(inc.views || 0) }, + clicks: { increment: Number(inc.clicks || 0) }, + }, + }) + } + }) + + return { updated: data.length } +} + module.exports = { ensureTotalsForDealIds, getTotalsByDealIds, applyDealEventBatch, + applyDealTotalsBatch, } diff --git a/prisma/migrations/20260206010650_add_barcodeid/migration.sql b/prisma/migrations/20260206010650_add_barcodeid/migration.sql new file mode 100644 index 0000000..a8ae3e9 --- /dev/null +++ b/prisma/migrations/20260206010650_add_barcodeid/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Deal" ADD COLUMN "barcodeId" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d36786e..b9961a3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -224,6 +224,7 @@ model Deal { discountType DiscountType? @default(AMOUNT) discountValue Float? maxNotifiedMilestone Int @default(0) + barcodeId String? userId Int score Int @default(0) commentCount Int @default(0) diff --git a/routes/cache.routes.js b/routes/cache.routes.js new file mode 100644 index 0000000..cddc888 --- /dev/null +++ b/routes/cache.routes.js @@ -0,0 +1,19 @@ +const express = require("express") +const { getCachedImageByKey } = require("../services/redis/linkPreviewImageCache.service") + +const router = express.Router() + +router.get("/deal_create/:key", async (req, res) => { + try { + const key = req.params.key + const cached = await getCachedImageByKey(key) + if (!cached) return res.status(404).json({ error: "Not found" }) + res.setHeader("Content-Type", cached.contentType) + res.setHeader("Cache-Control", "public, max-age=300") + return res.status(200).send(cached.buffer) + } catch (err) { + return res.status(500).json({ error: "Sunucu hatasi" }) + } +}) + +module.exports = router diff --git a/routes/mod.routes.js b/routes/mod.routes.js index fb21997..ae04076 100644 --- a/routes/mod.routes.js +++ b/routes/mod.routes.js @@ -676,15 +676,7 @@ router.get("/categories", requireAuth, requireRole("MOD"), async (req, res) => { router.get("/deals/reports", requireAuth, requireRole("MOD"), async (req, res) => { try { - const page = Number(req.query.page || 1) - const status = req.query.status - const dealId = req.query.dealId - const userId = req.query.userId const payload = await dealReportService.listDealReports({ - page, - status, - dealId, - userId, }) res.json(payload) } catch (err) { @@ -693,6 +685,17 @@ router.get("/deals/reports", requireAuth, requireRole("MOD"), async (req, res) = } }) +router.get("/deals/reports/pending", requireAuth, requireRole("MOD"), async (req, res) => { + try { + const page = Number(req.query.page || 1) + const payload = await dealReportService.getPendingReports({ page }) + res.json(payload) + } catch (err) { + const status = err.statusCode || 500 + res.status(status).json({ error: err.message || "Sunucu hatasi" }) + } +}) + router.post("/badges", requireAuth, requireRole("MOD"), modBadgeCreateValidator, async (req, res) => { try { const badge = await badgeService.createBadge(req.validatedBadgeCreate) @@ -797,4 +800,3 @@ router.delete( ) module.exports = router - diff --git a/routes/seller.routes.js b/routes/seller.routes.js index fd97464..3835117 100644 --- a/routes/seller.routes.js +++ b/routes/seller.routes.js @@ -8,6 +8,8 @@ const { endpoints } = require("@shared/contracts") const { getSellerByName, getDealsBySellerName, getActiveSellers } = require("../services/seller.service") const { findSellerFromLink } = require("../services/sellerLookup.service") const { getProductPreviewFromUrl } = require("../services/productPreview.service") +const { setBarcodeForUrl } = require("../services/redis/linkPreviewCache.service") +const { cacheLinkPreviewImages } = require("../services/linkPreviewImage.service") const { mapSellerToSellerDetailsResponse } = require("../adapters/responses/sellerDetails.adapter") const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter") const { getClientIp } = require("../utils/requestInfo") @@ -24,10 +26,23 @@ router.post("/from-link", requireAuth, async (req, res) => { try { const sellerUrl = req.body.url if (!sellerUrl) return res.status(400).json({ error: "url parametresi zorunlu" }) - const [sellerLookup, product] = await Promise.all([ + const [sellerLookup, initialProduct] = await Promise.all([ findSellerFromLink(sellerUrl), getProductPreviewFromUrl(sellerUrl), ]) + let product = initialProduct + + if (product?.barcodeId) { + setBarcodeForUrl(sellerUrl, product.barcodeId, { ttlSeconds: 15 * 60 }).catch((err) => + console.error("Link preview barcode cache failed:", err?.message || err) + ) + } + + const baseUrl = `${req.protocol}://${req.get("host")}` + if (product && baseUrl) { + const cached = await cacheLinkPreviewImages({ product, baseUrl }) + product = cached.product + } const response = seller.sellerLookupResponseSchema.parse( sellerLookup diff --git a/routes/upload.routes.js b/routes/upload.routes.js index 90d81b3..f2483ae 100644 --- a/routes/upload.routes.js +++ b/routes/upload.routes.js @@ -5,6 +5,7 @@ const requireAuth = require("../middleware/requireAuth") const requireNotRestricted = require("../middleware/requireNotRestricted") const { upload } = require("../middleware/upload.middleware") const { uploadImage } = require("../services/uploadImage.service") +const { makeWebp } = require("../utils/processImage") const { enqueueAuditFromRequest, buildAuditMeta } = require("../services/audit.service") const { AUDIT_ACTIONS } = require("../services/auditActions") @@ -23,14 +24,14 @@ router.post( } const key = uuidv4() - const ext = req.file.originalname?.split(".").pop() || "jpg" - const path = `misc/${req.auth.userId}/${key}.${ext}` + const webpBuffer = await makeWebp(req.file.buffer, { quality: 40 }) + const path = `misc/${req.auth.userId}/${key}.webp` const url = await uploadImage({ bucket: "deal", path, - fileBuffer: req.file.buffer, - contentType: req.file.mimetype, + fileBuffer: webpBuffer, + contentType: "image/webp", }) enqueueAuditFromRequest( @@ -39,7 +40,7 @@ router.post( buildAuditMeta({ entityType: "MEDIA", entityId: path, - extra: { contentType: req.file.mimetype }, + extra: { contentType: "image/webp" }, }) ) diff --git a/server.js b/server.js index a27d83b..10597d9 100644 --- a/server.js +++ b/server.js @@ -19,6 +19,7 @@ const categoryRoutes =require("./routes/category.routes") const modRoutes = require("./routes/mod.routes") const uploadRoutes = require("./routes/upload.routes") const badgeRoutes = require("./routes/badge.routes") +const cacheRoutes = require("./routes/cache.routes") const { ensureDealSearchIndex } = require("./services/redis/searchIndex.service") const { seedRecentDealsToRedis, seedReferenceDataToRedis } = require("./services/redis/dealIndexing.service") const { ensureCommentIdCounter } = require("./services/redis/commentId.service") @@ -69,6 +70,7 @@ app.use("/api/category", categoryRoutes); app.use("/api/mod", modRoutes); app.use("/api/uploads", uploadRoutes); app.use("/api/badges", badgeRoutes); +app.use("/cache", cacheRoutes); app.get("/api/openapi.json", (req, res) => { res.sendFile(path.join(__dirname, "docs", "openapi.json")); @@ -79,12 +81,16 @@ app.get("/api/docs", (req, res) => { }); async function startServer() { - await ensureDealSearchIndex() - await seedReferenceDataToRedis() - await ensureDealIdCounter() - const ttlDays = Number(process.env.REDIS_DEAL_TTL_DAYS) || 31 - await seedRecentDealsToRedis({ days: 31, ttlDays }) - await ensureCommentIdCounter() + try { + await ensureDealSearchIndex() + await seedReferenceDataToRedis() + await ensureDealIdCounter() + const ttlDays = Number(process.env.REDIS_DEAL_TTL_DAYS) || 31 + await seedRecentDealsToRedis({ days: 31, ttlDays }) + await ensureCommentIdCounter() + } catch (err) { + console.error("Redis init skipped:", err?.message || err) + } // Sunucuyu dinlemeye ba??la app.listen(3000, () => console.log("Server running on http://localhost:3000")); } diff --git a/services/adminMetrics.service.js b/services/adminMetrics.service.js index bb81b05..006cdce 100644 --- a/services/adminMetrics.service.js +++ b/services/adminMetrics.service.js @@ -43,7 +43,7 @@ async function getPendingDealCount(redis) { try { const result = await redis.call( "FT.SEARCH", - "idx:data:deals", + "idx:deals", "@status:{PENDING}", "LIMIT", 0, @@ -111,6 +111,19 @@ async function getAdminMetrics() { }, dbsyncQueues: queues, } + } catch (err) { + const openReports = await dealReportDb.countDealReports({ status: "OPEN" }) + return { + pendingDeals: null, + openReports, + redis: { + usedMemory: null, + connectedClients: null, + keyspaceHits: null, + keyspaceMisses: null, + }, + dbsyncQueues: {}, + } } finally {} } diff --git a/services/avatar.service.js b/services/avatar.service.js index 48fa353..f2283c2 100644 --- a/services/avatar.service.js +++ b/services/avatar.service.js @@ -1,5 +1,6 @@ const fs = require("fs") const { uploadImage } = require("./uploadImage.service") +const { makeWebp } = require("../utils/processImage") const { validateImage } = require("../utils/validateImage") const userDB = require("../db/user.db") @@ -16,12 +17,13 @@ async function updateUserAvatar(userId, file) { }) const buffer = fs.readFileSync(file.path) + const webpBuffer = await makeWebp(buffer, { quality: 80 }) const imageUrl = await uploadImage({ bucket: "avatars", - path: `${userId}_${Date.now()}.jpg`, - fileBuffer: buffer, - contentType: file.mimetype, + path: `${userId}_${Date.now()}.webp`, + fileBuffer: webpBuffer, + contentType: "image/webp", }) fs.unlinkSync(file.path) diff --git a/services/deal.service.js b/services/deal.service.js index 18f3c30..5925952 100644 --- a/services/deal.service.js +++ b/services/deal.service.js @@ -19,6 +19,9 @@ const { getHotDealIds, getHotRangeDealIds, getDealsByIdsFromRedis } = require(". const { getTrendingDealIds } = require("./redis/trendingDealList.service") const { getNewDealIds } = require("./redis/newDealList.service") const { setUserPublicInRedis } = require("./redis/userPublicCache.service") +const { getUserSavedIdsFromRedis, getUserSavedMapForDeals } = require("./redis/userCache.service") +const { getMyVotesForDeals } = require("./redis/dealVote.service") +const { getBarcodeForUrl } = require("./redis/linkPreviewCache.service") const { buildDealSearchQuery, searchDeals, @@ -33,21 +36,22 @@ const MAX_LIMIT = 50 const MAX_SKIP = 5000 const MS_PER_DAY = 24 * 60 * 60 * 1000 -const DEAL_CARD_SELECT = { - id: true, - title: true, - description: true, - price: true, - originalPrice: true, - shippingPrice: true, - couponCode: true, - location: true, - discountType: true, - discountValue: true, - score: true, - commentCount: true, - url: true, - status: true, + const DEAL_CARD_SELECT = { + id: true, + title: true, + description: true, + price: true, + originalPrice: true, + shippingPrice: true, + couponCode: true, + location: true, + discountType: true, + discountValue: true, + barcodeId: true, + score: true, + commentCount: true, + url: true, + status: true, saletype: true, affiliateType: true, createdAt: true, @@ -62,20 +66,21 @@ const DEAL_CARD_SELECT = { }, } -const DEAL_DETAIL_SELECT = { - id: true, - title: true, - description: true, - url: true, - price: true, - originalPrice: true, - shippingPrice: true, - couponCode: true, - location: true, - discountType: true, - discountValue: true, - score: true, - commentCount: true, + const DEAL_DETAIL_SELECT = { + id: true, + title: true, + description: true, + url: true, + price: true, + originalPrice: true, + shippingPrice: true, + couponCode: true, + location: true, + discountType: true, + discountValue: true, + barcodeId: true, + score: true, + commentCount: true, status: true, saletype: true, affiliateType: true, @@ -374,6 +379,19 @@ async function getDealsFromRedisSearch({ includeMinMax: pagination.page === 1, }) + if (!searchResult) { + return getDealsFromDbPreset({ + preset: "RAW", + q, + page, + limit, + viewer, + filters, + baseWhere, + scope: "USER", + }) + } + if (searchResult.total === 0 && q) { const fuzzyTextQuery = buildFuzzyTextQuery(q) if (fuzzyTextQuery) { @@ -386,6 +404,18 @@ async function getDealsFromRedisSearch({ sortDir, includeMinMax: pagination.page === 1, }) + if (!searchResult) { + return getDealsFromDbPreset({ + preset: "RAW", + q, + page, + limit, + viewer, + filters, + baseWhere, + scope: "USER", + }) + } } } @@ -598,13 +628,13 @@ async function getHotDealsFromRedis({ page, limit, viewer, hotListId } = {}) { const { hotListId: listId, dealIds } = await getHotDealIds({ hotListId }) if (!dealIds.length) { - return { - page: pagination.page, - total: 0, - totalPages: 0, - results: [], - hotListId: listId, - } + const fallback = await getDealsFromDbPreset({ + preset: "HOT", + page, + limit, + viewer, + }) + return { ...fallback, hotListId: listId } } const pageIds = dealIds.slice(pagination.skip, pagination.skip + pagination.limit) @@ -636,13 +666,13 @@ async function getTrendingDealsFromRedis({ page, limit, viewer, trendingListId } const { trendingListId: listId, dealIds } = await getTrendingDealIds({ trendingListId }) if (!dealIds.length) { - return { - page: pagination.page, - total: 0, - totalPages: 0, - results: [], - trendingListId: listId, - } + const fallback = await getDealsFromDbPreset({ + preset: "TRENDING", + page, + limit, + viewer, + }) + return { ...fallback, trendingListId: listId } } const pageIds = dealIds.slice(pagination.skip, pagination.skip + pagination.limit) @@ -674,18 +704,57 @@ async function getHotRangeDealsFromRedis({ page, limit, viewer, range } = {}) { const { listId, dealIds } = await getHotRangeDealIds({ range }) if (!dealIds.length) { - return { - page: pagination.page, - total: 0, - totalPages: 0, - results: [], - hotListId: listId, - } + const preset = + range === "day" ? "HOT_DAY" : range === "week" ? "HOT_WEEK" : range === "month" ? "HOT_MONTH" : "HOT" + const fallback = await getDealsFromDbPreset({ + preset, + page, + limit, + viewer, + }) + return { ...fallback, hotListId: listId } } const pageIds = dealIds.slice(pagination.skip, pagination.skip + pagination.limit) const viewerId = viewer?.userId ? Number(viewer.userId) : null const deals = await getDealsByIdsFromRedis(pageIds, viewerId) + if (!deals.length) { + return getDealsFromDbPreset({ + preset: "NEW", + page, + limit: REDIS_SEARCH_LIMIT, + viewer, + }) + } + if (!deals.length) { + const preset = + range === "day" ? "HOT_DAY" : range === "week" ? "HOT_WEEK" : range === "month" ? "HOT_MONTH" : "HOT" + const fallback = await getDealsFromDbPreset({ + preset, + page, + limit, + viewer, + }) + return { ...fallback, hotListId: listId } + } + if (!deals.length) { + const fallback = await getDealsFromDbPreset({ + preset: "TRENDING", + page, + limit, + viewer, + }) + return { ...fallback, trendingListId: listId } + } + if (!deals.length) { + const fallback = await getDealsFromDbPreset({ + preset: "HOT", + page, + limit, + viewer, + }) + return { ...fallback, hotListId: listId } + } const enriched = deals.map((deal) => ({ ...deal, @@ -707,6 +776,69 @@ async function getHotRangeDealsFromRedis({ page, limit, viewer, range } = {}) { } } +async function getDealsFromDbPreset({ + preset, + q, + page, + limit, + viewer = null, + targetUserId = null, + filters = null, + baseWhere = null, + scope = "USER", +} = {}) { + const pagination = clampPagination({ page, limit }) + const { where: presetWhere, orderBy: presetOrder } = buildPresetCriteria(preset, { + viewer, + targetUserId, + }) + const searchClause = buildSearchClause(q) + const allowStatus = preset === "MY" || scope === "MOD" + const filterWhere = buildFilterWhere(filters, { allowStatus }) + + const clauses = [] + if (presetWhere && Object.keys(presetWhere).length > 0) clauses.push(presetWhere) + if (baseWhere && Object.keys(baseWhere).length > 0) clauses.push(baseWhere) + if (searchClause) clauses.push(searchClause) + if (filterWhere) clauses.push(filterWhere) + + const finalWhere = clauses.length === 0 ? {} : clauses.length === 1 ? clauses[0] : { AND: clauses } + const orderBy = presetOrder ?? [{ createdAt: "desc" }] + + const [deals, total] = await Promise.all([ + dealDB.findDeals(finalWhere, { + skip: pagination.skip, + take: pagination.limit, + orderBy, + select: DEAL_CARD_SELECT, + }), + dealDB.countDeals(finalWhere), + ]) + + const dealIds = deals.map((d) => Number(d.id)).filter((id) => Number.isInteger(id) && id > 0) + const viewerId = viewer?.userId ? Number(viewer.userId) : null + const [voteMap, savedMap] = await Promise.all([ + viewerId ? getMyVotesForDeals(dealIds, viewerId) : Promise.resolve(new Map()), + viewerId ? getUserSavedMapForDeals(viewerId, dealIds) : Promise.resolve(new Map()), + ]) + + const enriched = deals.map((deal) => { + const id = Number(deal.id) + return { + ...deal, + myVote: viewerId ? Number(voteMap.get(id) ?? 0) : 0, + isSaved: viewerId ? savedMap.get(id) === true : false, + } + }) + + return { + page: pagination.page, + total, + totalPages: Math.ceil(total / pagination.limit), + results: enriched, + } +} + async function getBestWidgetDeals({ viewer = null, limit = 5 } = {}) { const take = Math.max(1, Math.min(Number(limit) || 5, 20)) const viewerId = viewer?.userId ? Number(viewer.userId) : null @@ -733,9 +865,15 @@ async function getBestWidgetDeals({ viewer = null, limit = 5 } = {}) { } const [hotDay, hotWeek, hotMonth] = await Promise.all([ - pickTop(dayList?.dealIds || []), - pickTop(weekList?.dealIds || []), - pickTop(monthList?.dealIds || []), + dayList?.dealIds?.length + ? pickTop(dayList.dealIds) + : (await getDealsFromDbPreset({ preset: "HOT_DAY", page: 1, limit: take, viewer })).results, + weekList?.dealIds?.length + ? pickTop(weekList.dealIds) + : (await getDealsFromDbPreset({ preset: "HOT_WEEK", page: 1, limit: take, viewer })).results, + monthList?.dealIds?.length + ? pickTop(monthList.dealIds) + : (await getDealsFromDbPreset({ preset: "HOT_MONTH", page: 1, limit: take, viewer })).results, ]) return { hotDay, hotWeek, hotMonth } @@ -757,6 +895,8 @@ async function getDealSuggestions({ q, limit = 8, viewer } = {}) { sortDir: "DESC", }) + if (!searchResult) return { results: [] } + if (!searchResult.dealIds.length) return { results: [] } const viewerId = viewer?.userId ? Number(viewer.userId) : null @@ -801,12 +941,12 @@ async function getNewDealsFromRedis({ page, viewer, newListId } = {}) { const { newListId: listId, dealIds } = await getNewDealIds({ newListId }) if (!dealIds.length) { - return { - page: pagination.page, - total: 0, - totalPages: 0, - results: [], - } + return getDealsFromDbPreset({ + preset: "NEW", + page, + limit: REDIS_SEARCH_LIMIT, + viewer, + }) } const pageIds = dealIds.slice(pagination.skip, pagination.skip + pagination.limit) @@ -881,49 +1021,17 @@ async function getDeals({ }) } - const { where: presetWhere, orderBy: presetOrder } = buildPresetCriteria(preset, { + return getDealsFromDbPreset({ + preset, + q, + page, + limit, viewer, targetUserId, + filters, + baseWhere, + scope, }) - const searchClause = buildSearchClause(q) - const allowStatus = preset === "MY" || scope === "MOD" - const filterWhere = buildFilterWhere(filters, { allowStatus }) - - const clauses = [] - if (presetWhere && Object.keys(presetWhere).length > 0) clauses.push(presetWhere) - if (baseWhere && Object.keys(baseWhere).length > 0) clauses.push(baseWhere) - if (searchClause) clauses.push(searchClause) - if (filterWhere) clauses.push(filterWhere) - - const finalWhere = clauses.length === 0 ? {} : clauses.length === 1 ? clauses[0] : { AND: clauses } - const orderBy = presetOrder ?? [{ createdAt: "desc" }] - - const [deals, total] = await Promise.all([ - dealDB.findDeals(finalWhere, { - skip: pagination.skip, - take: pagination.limit, - orderBy, - select: DEAL_CARD_SELECT, - }), - dealDB.countDeals(finalWhere), - ]) - - const dealIds = deals.map((d) => d.id) - const viewerId = viewer?.userId ? Number(viewer.userId) : null - - const enriched = deals.map((deal) => ({ - ...deal, - myVote: viewerId - ? Number(deal.votes?.find((vote) => Number(vote.userId) === viewerId)?.voteType ?? 0) - : 0, - })) - - return { - page: pagination.page, - total, - totalPages: Math.ceil(total / pagination.limit), - results: enriched, - } } async function getDealById(id, viewer = null) { @@ -939,7 +1047,7 @@ async function getDealById(id, viewer = null) { if (!isOwner && !isMod) return null } - const [breadcrumb, similarDeals, userStatsAgg, myVote, commentsResp, seller] = await Promise.all([ + const [breadcrumb, similarDeals, userStatsAgg, myVote, commentsResp, seller, savedCache] = await Promise.all([ categoryDB.getCategoryBreadcrumb(deal.categoryId, { includeUndefined: false }), buildSimilarDealsForDetail( { @@ -956,6 +1064,7 @@ async function getDealById(id, viewer = null) { viewer?.userId ? getDealVoteFromRedis(deal.id, viewer.userId) : Promise.resolve(0), getCommentsForDeal({ dealId: deal.id, parentId: null, page: 1, limit: 10, sort: "NEW", viewerId: viewer?.userId ?? null }).catch(() => ({ results: [] })), deal.sellerId ? getSellerById(Number(deal.sellerId)) : Promise.resolve(null), + viewer?.userId ? getUserSavedIdsFromRedis(viewer.userId) : Promise.resolve(null), ]) const userStats = { @@ -972,8 +1081,7 @@ async function getDealById(id, viewer = null) { userStats, myVote, isSaved: viewer?.userId - ? Array.isArray(deal.savedBy) && - deal.savedBy.some((s) => Number(s?.userId) === Number(viewer.userId)) + ? Boolean(savedCache?.savedSet?.has(Number(deal.id))) : false, } } @@ -989,6 +1097,10 @@ async function createDeal(dealCreateData, files = []) { sellerId = seller.id dealCreateData.customSeller = null } + const cachedBarcode = await getBarcodeForUrl(dealCreateData.url) + if (cachedBarcode) { + dealCreateData.barcodeId = cachedBarcode + } } const userId = Number(dealCreateData?.user?.connect?.id) @@ -1065,6 +1177,7 @@ async function createDeal(dealCreateData, files = []) { location: dealCreateData.location ?? null, discountType: dealCreateData.discountType ?? null, discountValue: dealCreateData.discountValue ?? null, + barcodeId: dealCreateData.barcodeId ?? null, maxNotifiedMilestone: 0, userId, score: 0, @@ -1080,7 +1193,6 @@ async function createDeal(dealCreateData, files = []) { user, images, dealTags: [], - votes: [], comments: [], aiReview: null, } @@ -1105,9 +1217,10 @@ async function createDeal(dealCreateData, files = []) { percentOff: dealPayload.percentOff, couponCode: dealPayload.couponCode, location: dealPayload.location, - discountType: dealPayload.discountType, - discountValue: dealPayload.discountValue, - maxNotifiedMilestone: dealPayload.maxNotifiedMilestone, + discountType: dealPayload.discountType, + discountValue: dealPayload.discountValue, + barcodeId: dealPayload.barcodeId, + maxNotifiedMilestone: dealPayload.maxNotifiedMilestone, userId, status: dealPayload.status, saletype: dealPayload.saletype, diff --git a/services/dealReport.service.js b/services/dealReport.service.js index c901397..d194230 100644 --- a/services/dealReport.service.js +++ b/services/dealReport.service.js @@ -62,16 +62,40 @@ async function createDealReport({ dealId, userId, reason, note }) { return { reported: true } } -async function listDealReports({ page = 1, status = null, dealId = null, userId = null } = {}) { - const safePage = normalizePage(page) - const skip = (safePage - 1) * PAGE_LIMIT +async function listDealReports({ status = null, dealId = null, userId = null } = {}) { + const skip = 0 const where = {} const normalizedStatus = normalizeStatus(status) - if (normalizedStatus) where.status = normalizedStatus + where.status = normalizedStatus || "OPEN" + if (Number.isInteger(Number(dealId))) where.dealId = Number(dealId) if (Number.isInteger(Number(userId))) where.userId = Number(userId) + const [total, reports] = await Promise.all([ + dealReportDB.countDealReports(where), + dealReportDB.listDealReports(where, { + skip, + orderBy: { createdAt: "asc" }, + include: { + deal: { select: { id: true, title: true, status: true } }, + user: { select: { id: true, username: true } }, + }, + }), + ]) + + return { + total, + results: reports, + } +} + +async function getPendingReports({ page = 1 } = {}) { + const safePage = normalizePage(page) + const skip = (safePage - 1) * PAGE_LIMIT + + const where = { status: "OPEN" } + const [total, reports] = await Promise.all([ dealReportDB.countDealReports(where), dealReportDB.listDealReports(where, { @@ -114,5 +138,6 @@ async function updateDealReportStatus({ reportId, status }) { module.exports = { createDealReport, listDealReports, + getPendingReports, updateDealReportStatus, } diff --git a/services/dealSave.service.js b/services/dealSave.service.js index 72868fb..63a1aa6 100644 --- a/services/dealSave.service.js +++ b/services/dealSave.service.js @@ -31,8 +31,6 @@ const DEAL_CACHE_INCLUDE = { user: { select: { id: true, username: true, avatarUrl: 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: { @@ -166,6 +164,18 @@ async function listSavedDeals({ userId, page = 1 }) { { id: { in: missingIds }, status: { in: Array.from(ALLOWED_STATUSES) } }, { include: DEAL_CACHE_INCLUDE } ) + const fallbackMap = new Map() + missingDeals.forEach((deal) => { + const payload = mapDealToRedisJson(deal) + const myVote = 0 + fallbackMap.set(Number(deal.id), { + ...payload, + user: deal.user ?? null, + seller: deal.seller ?? null, + myVote, + isSaved: true, + }) + }) await Promise.all( missingDeals.map((deal) => { const payload = mapDealToRedisJson(deal) @@ -174,6 +184,9 @@ async function listSavedDeals({ userId, page = 1 }) { ) const hydrated = await getDealsByIdsFromRedis(missingIds, uid) hydrated.forEach((d) => cachedMap.set(Number(d.id), d)) + if (!hydrated.length && fallbackMap.size) { + fallbackMap.forEach((value, key) => cachedMap.set(key, value)) + } } const results = pageIds.map((id) => cachedMap.get(id)).filter(Boolean) diff --git a/services/linkPreviewImage.service.js b/services/linkPreviewImage.service.js new file mode 100644 index 0000000..39a1b90 --- /dev/null +++ b/services/linkPreviewImage.service.js @@ -0,0 +1,58 @@ +const { cacheImageFromUrl } = require("./redis/linkPreviewImageCache.service") + +function extractImageUrlsFromDescription(description, { max = 5 } = {}) { + if (!description || typeof description !== "string") return [] + const regex = /]+src=["']([^"']+)["'][^>]*>/gi + const urls = [] + let match + while ((match = regex.exec(description)) !== null) { + if (match[1]) urls.push(match[1]) + if (urls.length >= max) break + } + return urls +} + +function replaceDescriptionImageUrls(description, urlMap, { maxImages = 5 } = {}) { + if (!description || typeof description !== "string") return description + if (!urlMap || urlMap.size === 0) return description + let seen = 0 + return description.replace(/]+src=["']([^"']+)["'][^>]*>/gi, (full, src) => { + seen += 1 + if (seen > maxImages) return "" + const next = urlMap.get(src) + if (!next) return full + return full.replace(src, next) + }) +} + +async function cacheLinkPreviewImages({ product, baseUrl } = {}) { + if (!product || typeof product !== "object") return { product } + const images = Array.isArray(product.images) ? product.images : [] + const description = product.description || "" + const descImages = extractImageUrlsFromDescription(description, { max: 5 }) + const combined = [...images, ...descImages].filter(Boolean) + const unique = Array.from(new Set(combined)) + + const urlMap = new Map() + for (const url of unique) { + const cached = await cacheImageFromUrl(url, { ttlSeconds: 5 * 60 }) + if (cached?.key) { + urlMap.set(url, `${baseUrl}/cache/deal_create/${cached.key}`) + } + } + + const nextImages = images.map((url) => urlMap.get(url) || url) + const nextDescription = replaceDescriptionImageUrls(description, urlMap, { maxImages: 5 }) + + return { + product: { + ...product, + images: nextImages, + description: nextDescription, + }, + } +} + +module.exports = { + cacheLinkPreviewImages, +} diff --git a/services/mod.service.js b/services/mod.service.js index 03a0ec3..ad20aa2 100644 --- a/services/mod.service.js +++ b/services/mod.service.js @@ -309,10 +309,14 @@ async function updateDealForMod(dealId, input = {}, viewer = null) { updatedAt: updatedAt.toISOString(), }).catch((err) => console.error("DB sync deal update failed:", err?.message || err)) - const normalized = updated || existing - const enriched = await enrichDealSeller(normalized) - return normalizeDealForModResponse(enriched) -} + let normalized = updated || existing + if (!normalized?.user) { + const refreshed = await getOrCacheDealForModeration(id) + if (refreshed?.deal) normalized = refreshed.deal + } + const enriched = await enrichDealSeller(normalized) + return normalizeDealForModResponse(enriched) + } async function listAllCategories() { return listCategories({ diff --git a/services/redis/badgeCache.service.js b/services/redis/badgeCache.service.js index ecef383..eb8cbcc 100644 --- a/services/redis/badgeCache.service.js +++ b/services/redis/badgeCache.service.js @@ -55,6 +55,8 @@ async function getBadgesFromRedis() { } }) return badges + } catch { + return [] } finally {} } diff --git a/services/redis/cacheMetrics.service.js b/services/redis/cacheMetrics.service.js index 6377e7b..f5524a5 100644 --- a/services/redis/cacheMetrics.service.js +++ b/services/redis/cacheMetrics.service.js @@ -1,7 +1,7 @@ const { getRedisClient } = require("./client") const { getRequestContext } = require("../requestContext") -const MISS_HASH_KEY = "cache:misses" +const MISS_HASH_KEY = "metrics:cache:misses" function shouldLog() { return String(process.env.CACHE_MISS_LOG || "").trim() === "1" diff --git a/services/redis/categoryCache.service.js b/services/redis/categoryCache.service.js index 9942915..9e34422 100644 --- a/services/redis/categoryCache.service.js +++ b/services/redis/categoryCache.service.js @@ -17,6 +17,8 @@ async function getCategoryById(id) { await recordCacheMiss({ key: `${CATEGORIES_KEY}:${cid}`, label: "category" }) } return raw ? JSON.parse(raw) : null + } catch { + return null } finally {} } @@ -68,6 +70,8 @@ async function listCategoriesFromRedis() { } } return list + } catch { + return [] } finally {} } diff --git a/services/redis/categoryId.service.js b/services/redis/categoryId.service.js index da08eb0..835127f 100644 --- a/services/redis/categoryId.service.js +++ b/services/redis/categoryId.service.js @@ -1,6 +1,6 @@ const prisma = require("../../db/client") const { ensureCounterAtLeast, nextId } = require("./idGenerator.service") -const CATEGORY_ID_KEY = "ids:category" +const CATEGORY_ID_KEY = "data:ids:category" async function ensureCategoryIdCounter() { const latest = await prisma.category.findFirst({ @@ -12,7 +12,15 @@ async function ensureCategoryIdCounter() { } async function generateCategoryId() { - return nextId(CATEGORY_ID_KEY) + try { + return await nextId(CATEGORY_ID_KEY) + } catch { + const latest = await prisma.category.findFirst({ + select: { id: true }, + orderBy: { id: "desc" }, + }) + return (latest?.id ?? 0) + 1 + } } module.exports = { ensureCategoryIdCounter, generateCategoryId } diff --git a/services/redis/commentCache.service.js b/services/redis/commentCache.service.js index 7a042f4..fa7ccbe 100644 --- a/services/redis/commentCache.service.js +++ b/services/redis/commentCache.service.js @@ -2,9 +2,9 @@ const { getRedisClient } = require("./client") const { getOrCacheDeal, getDealIdByCommentId, ensureMinDealTtl } = require("./dealCache.service") const DEFAULT_TTL_SECONDS = 15 * 60 -const DEAL_KEY_PREFIX = "data:deals:" -const COMMENT_LOOKUP_KEY = "data:comments:lookup" -const COMMENT_IDS_KEY = "data:comments:ids" +const DEAL_KEY_PREFIX = "deals:cache:" +const COMMENT_LOOKUP_KEY = "comments:lookup" +const COMMENT_IDS_KEY = "comments:ids" function createRedisClient() { return getRedisClient() @@ -49,6 +49,8 @@ async function updateDealCommentsInRedis(dealId, comments, commentCount) { pipeline.call("JSON.SET", `${DEAL_KEY_PREFIX}${dealId}`, "$.commentCount", Number(commentCount)) } await pipeline.exec() + } catch { + // ignore cache failures } finally {} } @@ -129,6 +131,8 @@ async function addCommentToRedis(comment, { ttlSeconds = DEFAULT_TTL_SECONDS } = try { await redis.hset(COMMENT_LOOKUP_KEY, String(comment.id), String(comment.dealId)) await redis.sadd(COMMENT_IDS_KEY, String(comment.id)) + } catch { + // ignore cache failures } finally {} return { added: true } } diff --git a/services/redis/commentId.service.js b/services/redis/commentId.service.js index 6c09efa..48e5e94 100644 --- a/services/redis/commentId.service.js +++ b/services/redis/commentId.service.js @@ -1,6 +1,6 @@ const prisma = require("../../db/client") const { ensureCounterAtLeast, nextId } = require("./idGenerator.service") -const COMMENT_ID_KEY = "ids:comment" +const COMMENT_ID_KEY = "data:ids:comment" async function ensureCommentIdCounter() { const latest = await prisma.comment.findFirst({ @@ -12,7 +12,15 @@ async function ensureCommentIdCounter() { } async function generateCommentId() { - return nextId(COMMENT_ID_KEY) + try { + return await nextId(COMMENT_ID_KEY) + } catch { + const latest = await prisma.comment.findFirst({ + select: { id: true }, + orderBy: { id: "desc" }, + }) + return (latest?.id ?? 0) + 1 + } } module.exports = { ensureCommentIdCounter, generateCommentId } diff --git a/services/redis/dbSync.service.js b/services/redis/dbSync.service.js index 48184be..cbb6d81 100644 --- a/services/redis/dbSync.service.js +++ b/services/redis/dbSync.service.js @@ -1,166 +1,387 @@ 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 = "dbsync:votes" -const COMMENT_LIKE_HASH_KEY = "dbsync:commentLikes" -const COMMENT_HASH_KEY = "dbsync:comments" -const COMMENT_DELETE_HASH_KEY = "dbsync:commentDeletes" -const DEAL_UPDATE_HASH_KEY = "dbsync:dealUpdates" -const DEAL_CREATE_HASH_KEY = "dbsync:dealCreates" -const DEAL_AI_REVIEW_HASH_KEY = "dbsync:dealAiReviews" -const NOTIFICATION_HASH_KEY = "dbsync:notifications" -const NOTIFICATION_READ_HASH_KEY = "dbsync:notificationReads" -const DEAL_SAVE_HASH_KEY = "dbsync:dealSaves" -const AUDIT_HASH_KEY = "dbsync:audits" -const USER_UPDATE_HASH_KEY = "dbsync:users" -const USER_NOTE_HASH_KEY = "dbsync:userNotes" -const DEAL_REPORT_UPDATE_HASH_KEY = "dbsync:dealReportUpdates" -const CATEGORY_UPSERT_HASH_KEY = "dbsync:categoryUpserts" -const SELLER_UPSERT_HASH_KEY = "dbsync:sellerUpserts" -const SELLER_DOMAIN_UPSERT_HASH_KEY = "dbsync:sellerDomainUpserts" +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() - try { - const field = `vote:${dealId}:${userId}` - const payload = JSON.stringify({ - dealId: Number(dealId), - userId: Number(userId), - voteType: Number(voteType), - createdAt, - }) - await redis.hset(VOTE_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `commentLike:${commentId}:${userId}` - const payload = JSON.stringify({ - commentId: Number(commentId), - userId: Number(userId), - like: Boolean(like), - createdAt, - }) - await redis.hset(COMMENT_LIKE_HASH_KEY, field, payload) - } finally {} + 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() - try { - 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 redis.hset(COMMENT_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `commentDelete:${commentId}` - const payload = JSON.stringify({ - commentId: Number(commentId), - dealId: Number(dealId), - createdAt, - }) - await redis.hset(COMMENT_DELETE_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `dealUpdate:${dealId}` - const payload = JSON.stringify({ - dealId: Number(dealId), - data, - updatedAt, - }) - await redis.hset(DEAL_UPDATE_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `dealCreate:${dealId}` - const payload = JSON.stringify({ - dealId: Number(dealId), - data, - images: Array.isArray(images) ? images : [], - createdAt, - }) - await redis.hset(DEAL_CREATE_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `dealAiReview:${dealId}` - const payload = JSON.stringify({ - dealId: Number(dealId), - data, - updatedAt, - }) - await redis.hset(DEAL_AI_REVIEW_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `notification:${userId}:${Date.now()}` - const payload = JSON.stringify({ - userId: Number(userId), - message: String(message), - type: String(type || "INFO"), - createdAt, - }) - await redis.hset(NOTIFICATION_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `notificationRead:${userId}:${Date.now()}` - const payload = JSON.stringify({ - userId: Number(userId), - readAt, - }) - await redis.hset(NOTIFICATION_READ_HASH_KEY, field, payload) - } finally {} + 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 }) { @@ -169,118 +390,198 @@ async function queueDealSaveUpdate({ dealId, userId, action, createdAt }) { if (!["SAVE", "UNSAVE"].includes(normalized)) return const redis = createRedisClient() - try { - const field = `dealSave:${dealId}:${userId}` - const payload = JSON.stringify({ - dealId: Number(dealId), - userId: Number(userId), - action: normalized, - createdAt, - }) - await redis.hset(DEAL_SAVE_HASH_KEY, field, payload) - } finally {} + 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() - try { - 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 redis.hset(AUDIT_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `userUpdate:${userId}` - const payload = JSON.stringify({ - userId: Number(userId), - data, - updatedAt, - }) - await redis.hset(USER_UPDATE_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `userNote:${userId}:${Date.now()}` - const payload = JSON.stringify({ - userId: Number(userId), - createdById: Number(createdById), - note: String(note), - createdAt, - }) - await redis.hset(USER_NOTE_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `dealReport:${reportId}` - const payload = JSON.stringify({ - reportId: Number(reportId), - status: String(status), - updatedAt, - }) - await redis.hset(DEAL_REPORT_UPDATE_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `category:${categoryId}` - const payload = JSON.stringify({ - categoryId: Number(categoryId), - data, - updatedAt, - }) - await redis.hset(CATEGORY_UPSERT_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `seller:${sellerId}` - const payload = JSON.stringify({ - sellerId: Number(sellerId), - data, - updatedAt, - }) - await redis.hset(SELLER_UPSERT_HASH_KEY, field, payload) - } finally {} + 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() - try { - const field = `sellerDomain:${sellerId}:${String(domain).toLowerCase()}` - const payload = JSON.stringify({ - sellerId: Number(sellerId), - domain: String(domain).toLowerCase(), - createdById: Number(createdById), - }) - await redis.hset(SELLER_DOMAIN_UPSERT_HASH_KEY, field, payload) - } finally {} + 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 = { @@ -318,4 +619,5 @@ module.exports = { CATEGORY_UPSERT_HASH_KEY, SELLER_UPSERT_HASH_KEY, SELLER_DOMAIN_UPSERT_HASH_KEY, + DEAL_ANALYTICS_TOTAL_HASH_KEY, } diff --git a/services/redis/dealAnalytics.service.js b/services/redis/dealAnalytics.service.js index 74f26a4..ce387d3 100644 --- a/services/redis/dealAnalytics.service.js +++ b/services/redis/dealAnalytics.service.js @@ -2,9 +2,8 @@ const { randomUUID } = require("crypto") const { getRedisClient } = require("./client") const dealAnalyticsDb = require("../../db/dealAnalytics.db") const { ensureMinDealTtl } = require("./dealCache.service") - -const DEAL_EVENT_HASH_KEY = "dbsync:dealEvents" -const DEAL_ANALYTICS_TOTAL_PREFIX = "data:deals:analytics:total:" +const { DEAL_ANALYTICS_TOTAL_HASH_KEY } = require("./dbSync.service") +const DEAL_ANALYTICS_TOTAL_PREFIX = "deals:analytics:total:" function createRedisClient() { return getRedisClient() @@ -29,6 +28,21 @@ function isValidEventType(type) { return ["IMPRESSION", "VIEW", "CLICK"].includes(normalized) } +function aggregateEventIncrements(events = []) { + const byDeal = new Map() + for (const event of events) { + const dealId = Number(event.dealId) + if (!Number.isInteger(dealId) || dealId <= 0) continue + const type = String(event.type || "").toUpperCase() + const entry = byDeal.get(dealId) || { dealId, impressions: 0, views: 0, clicks: 0 } + if (type === "IMPRESSION") entry.impressions += 1 + else if (type === "VIEW") entry.views += 1 + else if (type === "CLICK") entry.clicks += 1 + byDeal.set(dealId, entry) + } + return Array.from(byDeal.values()) +} + async function seedDealAnalyticsTotals({ dealIds = [] } = {}) { const ids = normalizeIds(dealIds) if (!ids.length) return 0 @@ -75,22 +89,26 @@ async function queueDealEvents(events = []) { ) if (!valid.length) return 0 + const increments = aggregateEventIncrements(valid) + if (!increments.length) return 0 + const redis = createRedisClient() try { + await incrementDealAnalyticsTotalsInRedis(increments) const pipeline = redis.pipeline() - valid.forEach((event) => { - const field = `dealEvent:${randomUUID()}` - const payload = JSON.stringify({ - dealId: Number(event.dealId), - type: String(event.type).toUpperCase(), - userId: event.userId ? Number(event.userId) : null, - ip: event.ip ? String(event.ip) : null, - createdAt: event.createdAt || new Date().toISOString(), - }) - pipeline.hset(DEAL_EVENT_HASH_KEY, field, payload) + increments.forEach((entry) => { + const field = `dealTotals:${entry.dealId}:${randomUUID()}` + pipeline.hset(DEAL_ANALYTICS_TOTAL_HASH_KEY, field, JSON.stringify(entry)) }) await pipeline.exec() return valid.length + } catch { + try { + await dealAnalyticsDb.applyDealTotalsBatch(increments) + return valid.length + } catch { + return 0 + } } finally {} } @@ -154,6 +172,8 @@ async function incrementDealAnalyticsTotalsInRedis(increments = []) { }) await pipeline.exec() return data.length + } catch { + return 0 } finally {} } @@ -164,5 +184,5 @@ module.exports = { queueDealView, queueDealClick, incrementDealAnalyticsTotalsInRedis, - DEAL_EVENT_HASH_KEY, + DEAL_ANALYTICS_TOTAL_HASH_KEY, } diff --git a/services/redis/dealCache.service.js b/services/redis/dealCache.service.js index b0b3199..77a35f1 100644 --- a/services/redis/dealCache.service.js +++ b/services/redis/dealCache.service.js @@ -10,11 +10,10 @@ const { ensureUserMinTtl, } = require("./userPublicCache.service") -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 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" function createRedisClient() { return getRedisClient() @@ -46,19 +45,10 @@ async function getAnalyticsTotalsForDeal(dealId) { } async function cacheVotesAndAnalytics(redis, dealId, payload, { ttlSeconds, skipDbEnsure } = {}) { - const voteKey = `${DEAL_VOTE_HASH_PREFIX}${dealId}` const analyticsKey = `${DEAL_ANALYTICS_TOTAL_PREFIX}${dealId}` const pipeline = redis.pipeline() - pipeline.del(voteKey) - if (Array.isArray(payload?.votes) && payload.votes.length) { - payload.votes.forEach((vote) => { - if (!vote?.userId) return - pipeline.hset(voteKey, String(vote.userId), String(vote.voteType ?? 0)) - }) - } - const totals = skipDbEnsure ? { impressions: 0, views: 0, clicks: 0 } : await getAnalyticsTotalsForDeal(dealId) @@ -73,13 +63,14 @@ async function cacheVotesAndAnalytics(redis, dealId, payload, { ttlSeconds, skip ) if (ttlSeconds) { - if (Array.isArray(payload?.votes) && payload.votes.length) { - pipeline.expire(voteKey, Number(ttlSeconds)) - } pipeline.expire(analyticsKey, Number(ttlSeconds)) } - await pipeline.exec() + try { + await pipeline.exec() + } catch { + // ignore cache failures + } } async function ensureMinDealTtl(dealId, { minSeconds = 15 * 60 } = {}) { @@ -87,7 +78,6 @@ async function ensureMinDealTtl(dealId, { minSeconds = 15 * 60 } = {}) { if (!Number.isInteger(id) || id <= 0) return { bumped: false } const redis = createRedisClient() const key = `${DEAL_KEY_PREFIX}${id}` - const voteKey = `${DEAL_VOTE_HASH_PREFIX}${id}` const analyticsKey = `${DEAL_ANALYTICS_TOTAL_PREFIX}${id}` const minTtl = Math.max(1, Number(minSeconds) || 15 * 60) @@ -98,12 +88,13 @@ async function ensureMinDealTtl(dealId, { minSeconds = 15 * 60 } = {}) { const nextTtl = minTtl const pipeline = redis.pipeline() pipeline.expire(key, nextTtl) - pipeline.expire(voteKey, nextTtl) pipeline.expire(analyticsKey, nextTtl) await pipeline.exec() return { bumped: true, ttl: nextTtl } } return { bumped: false, ttl } + } catch { + return { bumped: false } } finally {} } @@ -119,27 +110,10 @@ async function updateDealSavesInRedis({ dealId, userId, action, createdAt, minSe const redis = createRedisClient() const key = `${DEAL_KEY_PREFIX}${id}` try { - const raw = await redis.call("JSON.GET", key, "$.savedBy") - let savedBy = [] - if (raw) { - const parsed = JSON.parse(raw) - const arr = Array.isArray(parsed) ? parsed[0] : [] - savedBy = Array.isArray(arr) ? arr : [] - } - - const exists = savedBy.some((s) => Number(s?.userId) === uid) - if (normalized === "SAVE" && !exists) { - savedBy = [ - { userId: uid, createdAt: createdAt ? toIso(createdAt) : new Date().toISOString() }, - ...savedBy, - ] - } else if (normalized === "UNSAVE" && exists) { - savedBy = savedBy.filter((s) => Number(s?.userId) !== uid) - } - - await redis.call("JSON.SET", key, "$.savedBy", JSON.stringify(savedBy)) await ensureMinDealTtl(id, { minSeconds }) return { updated: true } + } catch { + return { updated: false } } finally {} } @@ -184,6 +158,8 @@ async function getDealFromRedis(dealId) { } } return deal + } catch { + return null } finally {} } @@ -208,8 +184,6 @@ async function cacheDealFromDb(dealId, { ttlSeconds = 1800 } = {}) { }, 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: { @@ -252,6 +226,8 @@ async function cacheDealFromDb(dealId, { ttlSeconds = 1800 } = {}) { } await pipeline.exec() await cacheVotesAndAnalytics(redis, deal.id, payload, { ttlSeconds }) + } catch { + // ignore cache failures } finally {} if (deal.user) { await ensureUserMinTtl(deal.user.id, { minSeconds: ttlSeconds }) @@ -267,6 +243,8 @@ async function getDealIdByCommentId(commentId) { await recordCacheMiss({ key: `${COMMENT_LOOKUP_KEY}:${commentId}`, label: "comment-lookup" }) } return raw ? Number(raw) : null + } catch { + return null } finally {} } @@ -307,6 +285,8 @@ async function updateDealInRedis(dealId, patch = {}, { updatedAt = new Date() } const raw = await redis.call("JSON.GET", key) return raw ? JSON.parse(raw) : null + } catch { + return null } finally {} } @@ -336,6 +316,8 @@ async function setDealInRedis( skipDbEnsure: skipAnalyticsInit, }) return payload + } catch { + return payload } finally {} } diff --git a/services/redis/dealId.service.js b/services/redis/dealId.service.js index cbe29cf..9065359 100644 --- a/services/redis/dealId.service.js +++ b/services/redis/dealId.service.js @@ -1,6 +1,6 @@ const prisma = require("../../db/client") const { ensureCounterAtLeast, nextId } = require("./idGenerator.service") -const DEAL_ID_KEY = "ids:deal" +const DEAL_ID_KEY = "data:ids:deal" async function ensureDealIdCounter() { const latest = await prisma.deal.findFirst({ @@ -12,7 +12,15 @@ async function ensureDealIdCounter() { } async function generateDealId() { - return nextId(DEAL_ID_KEY) + try { + return await nextId(DEAL_ID_KEY) + } catch { + const latest = await prisma.deal.findFirst({ + select: { id: true }, + orderBy: { id: "desc" }, + }) + return (latest?.id ?? 0) + 1 + } } module.exports = { ensureDealIdCounter, generateDealId } diff --git a/services/redis/dealIndexing.service.js b/services/redis/dealIndexing.service.js index 888b226..25299c5 100644 --- a/services/redis/dealIndexing.service.js +++ b/services/redis/dealIndexing.service.js @@ -7,13 +7,12 @@ 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 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:sellerdomains" +const SELLER_DOMAINS_KEY = "data:seller:domains" const CATEGORIES_KEY = "data:categories" function createRedisClient() { @@ -43,14 +42,6 @@ function mapDealToRedisJson(deal) { })) : [] - 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) => { @@ -86,14 +77,6 @@ function mapDealToRedisJson(deal) { })) : [] - 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, @@ -106,9 +89,10 @@ function mapDealToRedisJson(deal) { 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) + discountType: deal.discountType ?? null, + discountValue: deal.discountValue ?? null, + barcodeId: deal.barcodeId ?? null, + maxNotifiedMilestone: Number.isFinite(deal.maxNotifiedMilestone) ? deal.maxNotifiedMilestone : 0, userId: deal.userId ?? null, @@ -132,8 +116,6 @@ function mapDealToRedisJson(deal) { })) : [], tags, - votes, - savedBy, comments, aiReview: deal.aiReview ? { @@ -175,8 +157,6 @@ async function seedRecentDealsToRedis({ days = 30, ttlDays = 31, batchSize = 200 }, 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: { @@ -262,17 +242,6 @@ async function seedRecentDealsToRedis({ days = 30, ttlDays = 31, batchSize = 200 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) } @@ -288,16 +257,11 @@ async function seedRecentDealsToRedis({ days = 30, ttlDays = 31, batchSize = 200 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) diff --git a/services/redis/dealSearch.service.js b/services/redis/dealSearch.service.js index 931c034..3af4d52 100644 --- a/services/redis/dealSearch.service.js +++ b/services/redis/dealSearch.service.js @@ -176,7 +176,7 @@ async function aggregatePriceRange(query) { try { const results = await redis.call( "FT.AGGREGATE", - "idx:data:deals", + "idx:deals", query || "*", "GROUPBY", "0", @@ -212,6 +212,8 @@ async function aggregatePriceRange(query) { minPrice: Number.isFinite(min) ? min : null, maxPrice: Number.isFinite(max) ? max : null, } + } catch { + return { minPrice: null, maxPrice: null } } finally {} } @@ -233,7 +235,7 @@ async function searchDeals({ const range = includeMinMax ? await aggregatePriceRange(query) : { minPrice: null, maxPrice: null } const results = await redis.call( "FT.SEARCH", - "idx:data:deals", + "idx:deals", query || "*", "SORTBY", sort.field, @@ -264,6 +266,8 @@ async function searchDeals({ minPrice: range.minPrice, maxPrice: range.maxPrice, } + } catch { + return null } finally {} } diff --git a/services/redis/dealVote.service.js b/services/redis/dealVote.service.js index 9741c62..075a5f4 100644 --- a/services/redis/dealVote.service.js +++ b/services/redis/dealVote.service.js @@ -1,19 +1,20 @@ const { getRedisClient } = require("./client") +const dealDb = require("../../db/deal.db") const { ensureMinDealTtl } = require("./dealCache.service") function createRedisClient() { return getRedisClient() } -const DEAL_VOTE_HASH_PREFIX = "data:deals:votes:" +const USER_VOTE_HASH_PREFIX = "users:votes:" +const USER_VOTE_TTL_SECONDS = 6 * 60 * 60 async function updateDealVoteInRedis({ dealId, userId, voteType, score }) { if (!dealId || !userId) return const redis = createRedisClient() try { - const key = `data:deals:${dealId}` - const voteKey = `${DEAL_VOTE_HASH_PREFIX}${dealId}` + const key = `deals:cache:${dealId}` const raw = await redis.call("JSON.GET", key) if (!raw) return { updated: false, delta: 0, score: null } @@ -23,33 +24,28 @@ async function updateDealVoteInRedis({ dealId, userId, voteType, score }) { ? Number(deal.maxNotifiedMilestone) : 0 const dealUserId = Number(deal?.userId) - const rawVotes = deal?.votes ?? [] - - let votes = [] - votes = Array.isArray(rawVotes) ? rawVotes : [] const normalizedUserId = Number(userId) const normalizedVoteType = Number(voteType) - const idx = votes.findIndex((vote) => Number(vote.userId) === normalizedUserId) - const oldVote = idx >= 0 ? Number(votes[idx]?.voteType ?? 0) : 0 - if (idx >= 0) { - votes[idx] = { userId: normalizedUserId, voteType: normalizedVoteType } - } else { - votes.push({ userId: normalizedUserId, voteType: normalizedVoteType }) - } - - await redis.call("JSON.SET", key, "$.votes", JSON.stringify(votes)) + const oldRaw = await redis.hget( + `${USER_VOTE_HASH_PREFIX}${normalizedUserId}`, + String(dealId) + ) + const oldVote = oldRaw == null ? 0 : Number(oldRaw) const delta = normalizedVoteType - oldVote const nextScore = score !== undefined && score !== null ? Number(score) : currentScore + delta await redis.call("JSON.SET", key, "$.score", nextScore) - await redis.hset(voteKey, String(normalizedUserId), String(normalizedVoteType)) - const dealTtl = await redis.ttl(key) - if (Number.isFinite(dealTtl) && dealTtl > 0) { - await redis.expire(voteKey, dealTtl) + if (normalizedVoteType === 0) { + await redis.hdel(`${USER_VOTE_HASH_PREFIX}${normalizedUserId}`, String(dealId)) + } else { + await redis.hset(`${USER_VOTE_HASH_PREFIX}${normalizedUserId}`, String(dealId), String(normalizedVoteType)) + await redis.expire(`${USER_VOTE_HASH_PREFIX}${normalizedUserId}`, USER_VOTE_TTL_SECONDS) } await ensureMinDealTtl(dealId, { minSeconds: 15 * 60 }) return { updated: true, delta, score: nextScore, maxNotifiedMilestone, dealUserId } + } catch { + return { updated: false, delta: 0, score: null } } finally {} } @@ -59,10 +55,36 @@ async function getDealVoteFromRedis(dealId, userId) { if (!Number.isInteger(id) || !Number.isInteger(uid)) return 0 const redis = createRedisClient() try { - const voteKey = `${DEAL_VOTE_HASH_PREFIX}${id}` - const raw = await redis.hget(voteKey, String(uid)) - const value = raw == null ? 0 : Number(raw) - return Number.isFinite(value) ? value : 0 + const userKey = `${USER_VOTE_HASH_PREFIX}${uid}` + const exists = await redis.exists(userKey) + if (!exists) { + const dbVotes = await dealDb.findVotes( + { userId: Number(uid) }, + { select: { dealId: true, voteType: true } } + ) + const pipeline = redis.pipeline() + dbVotes.forEach((vote) => { + const did = Number(vote.dealId) + const v = Number(vote.voteType) + if (!Number.isInteger(did)) return + if (Number.isFinite(v) && v !== 0) { + pipeline.hset(userKey, String(did), String(v)) + } + }) + pipeline.expire(userKey, USER_VOTE_TTL_SECONDS) + await pipeline.exec() + const fromDb = dbVotes.find((v) => Number(v.dealId) === id) + const dbVote = Number(fromDb?.voteType ?? 0) + return Number.isFinite(dbVote) ? dbVote : 0 + } + const raw = await redis.hget(userKey, String(id)) + if (raw != null) { + const value = Number(raw) + return Number.isFinite(value) ? value : 0 + } + return 0 + } catch { + return 0 } finally {} } @@ -76,17 +98,39 @@ async function getMyVotesForDeals(dealIds = [], userId) { const redis = createRedisClient() try { - const pipeline = redis.pipeline() - ids.forEach((id) => { - pipeline.hget(`${DEAL_VOTE_HASH_PREFIX}${id}`, String(uid)) - }) - const results = await pipeline.exec() + const userKey = `${USER_VOTE_HASH_PREFIX}${uid}` + const exists = await redis.exists(userKey) + if (exists) { + const results = await redis.hmget(userKey, ids.map(String)) + const map = new Map() + results.forEach((raw, idx) => { + const value = raw == null ? 0 : Number(raw) + map.set(ids[idx], Number.isFinite(value) ? value : 0) + }) + return map + } + + const dbVotes = await dealDb.findVotes( + { dealId: { in: ids }, userId: Number(uid) }, + { select: { dealId: true, voteType: true } } + ) const map = new Map() - results.forEach(([, raw], idx) => { - const value = raw == null ? 0 : Number(raw) - map.set(ids[idx], Number.isFinite(value) ? value : 0) + ids.forEach((id) => map.set(id, 0)) + const pipeline = redis.pipeline() + dbVotes.forEach((vote) => { + const did = Number(vote.dealId) + const v = Number(vote.voteType) + if (!Number.isInteger(did)) return + if (Number.isFinite(v) && v !== 0) { + map.set(did, v) + pipeline.hset(userKey, String(did), String(v)) + } }) + pipeline.expire(userKey, USER_VOTE_TTL_SECONDS) + await pipeline.exec() return map + } catch { + return new Map() } finally {} } diff --git a/services/redis/hotDealList.service.js b/services/redis/hotDealList.service.js index a8da543..9498b3e 100644 --- a/services/redis/hotDealList.service.js +++ b/services/redis/hotDealList.service.js @@ -8,9 +8,27 @@ function createRedisClient() { return getRedisClient() } +const USER_SAVED_HASH_PREFIX = "users:savedmap:" + +async function getUserSavedMap(redis, dealIds = [], viewerId = null) { + const uid = Number(viewerId) + if (!Number.isInteger(uid) || !dealIds.length) return new Map() + try { + const results = await redis.hmget(`${USER_SAVED_HASH_PREFIX}${uid}`, dealIds.map(String)) + const map = new Map() + results.forEach((raw, idx) => { + const value = raw == null ? 0 : Number(raw) + if (value) map.set(Number(dealIds[idx]), true) + }) + return map + } catch { + return new Map() + } +} + async function getHotDealListId(redis, hotListId) { if (hotListId) return String(hotListId) - const latest = await redis.get("lists:hot:latest") + const latest = await redis.get("deals:lists:hot:latest") return latest ? String(latest) : null } @@ -27,7 +45,7 @@ async function getHotDealIds({ hotListId } = {}) { const listId = await getHotDealListId(redis, hotListId) if (!listId) return { hotListId: null, dealIds: [] } - const key = `lists:hot:${listId}` + const key = `deals:lists:hot:${listId}` const raw = await redis.call("JSON.GET", key, "$.dealIds") if (!raw) return { hotListId: listId, dealIds: [] } @@ -38,6 +56,8 @@ async function getHotDealIds({ hotListId } = {}) { hotListId: listId, dealIds: Array.isArray(dealIds) ? dealIds.map((id) => Number(id)) : [], } + } catch { + return { hotListId: null, dealIds: [] } } finally {} } @@ -48,7 +68,7 @@ async function getDealsByIdsFromRedis(ids = [], viewerId = null) { try { const pipeline = redis.pipeline() ids.forEach((id) => { - pipeline.call("JSON.GET", `data:deals:${id}`) + pipeline.call("JSON.GET", `deals:cache:${id}`) }) const results = await pipeline.exec() @@ -76,6 +96,7 @@ async function getDealsByIdsFromRedis(ids = [], viewerId = null) { .filter((id) => Number.isInteger(id) && id > 0) const sellerMap = sellerIds.length ? await getSellersByIds(sellerIds) : new Map() const voteMap = viewerId ? await getMyVotesForDeals(ordered.map((d) => d.id), viewerId) : new Map() + const savedMap = viewerId ? await getUserSavedMap(redis, ordered.map((d) => d.id), viewerId) : new Map() const userIds = ordered .map((deal) => Number(deal?.userId)) @@ -89,7 +110,7 @@ async function getDealsByIdsFromRedis(ids = [], viewerId = null) { const missingSet = new Set(missingUserIds) const ttlPipeline = redis.pipeline() ordered.forEach((deal) => { - ttlPipeline.ttl(`data:deals:${deal.id}`) + ttlPipeline.ttl(`deals:cache:${deal.id}`) }) const ttlResults = await ttlPipeline.exec() const ttlByDealId = new Map() @@ -139,21 +160,20 @@ async function getDealsByIdsFromRedis(ids = [], viewerId = null) { if (seller) next = { ...next, seller } } const myVote = viewerId ? Number(voteMap.get(Number(next.id)) ?? 0) : 0 - const isSaved = viewerId - ? Array.isArray(next.savedBy) && - next.savedBy.some((s) => Number(s?.userId) === Number(viewerId)) - : false + const isSaved = viewerId ? savedMap.get(Number(next.id)) === true : false return { ...next, myVote, isSaved } }) return enriched + } catch { + return [] } finally {} } async function getDealByIdFromRedis(id, viewerId = null) { const redis = createRedisClient() try { - const raw = await redis.call("JSON.GET", `data:deals:${id}`) + const raw = await redis.call("JSON.GET", `deals:cache:${id}`) if (!raw) return null let deal = JSON.parse(raw) if (deal?.sellerId && !deal?.seller) { @@ -162,12 +182,13 @@ async function getDealByIdFromRedis(id, viewerId = null) { } if (viewerId) { const voteMap = await getMyVotesForDeals([deal.id], viewerId) - const isSaved = Array.isArray(deal.savedBy) - ? deal.savedBy.some((s) => Number(s?.userId) === Number(viewerId)) - : false + const savedMap = await getUserSavedMap(redis, [deal.id], viewerId) + const isSaved = savedMap.get(Number(deal.id)) === true deal = { ...deal, myVote: Number(voteMap.get(Number(deal.id)) ?? 0), isSaved } } return deal + } catch { + return null } finally {} } @@ -177,11 +198,11 @@ async function getHotRangeDealIds({ range, listId } = {}) { try { const prefix = range === "day" - ? "lists:hot_day" + ? "deals:lists:hot_day" : range === "week" - ? "lists:hot_week" + ? "deals:lists:hot_week" : range === "month" - ? "lists:hot_month" + ? "deals:lists:hot_month" : null if (!prefix) return { listId: null, dealIds: [] } @@ -199,6 +220,8 @@ async function getHotRangeDealIds({ range, listId } = {}) { listId: resolvedId, dealIds: Array.isArray(dealIds) ? dealIds.map((id) => Number(id)) : [], } + } catch { + return { listId: null, dealIds: [] } } finally {} } diff --git a/services/redis/linkPreviewCache.service.js b/services/redis/linkPreviewCache.service.js new file mode 100644 index 0000000..bfec200 --- /dev/null +++ b/services/redis/linkPreviewCache.service.js @@ -0,0 +1,53 @@ +const { getRedisClient } = require("./client") + +const BARCODE_KEY_PREFIX = "linkpreview:barcode:" +const DEFAULT_TTL_SECONDS = 15 * 60 + +function createRedisClient() { + return getRedisClient() +} + +function normalizeUrl(rawUrl) { + if (!rawUrl || typeof rawUrl !== "string") return null + try { + const url = new URL(rawUrl) + const hostname = url.hostname.toLowerCase() + const pathname = url.pathname || "/" + return `${url.protocol}//${hostname}${pathname}` + } catch { + return null + } +} + +async function setBarcodeForUrl(url, barcodeId, { ttlSeconds = DEFAULT_TTL_SECONDS } = {}) { + const normalized = normalizeUrl(url) + if (!normalized) return { saved: false } + if (barcodeId === undefined || barcodeId === null || barcodeId === "") return { saved: false } + + const redis = createRedisClient() + const key = `${BARCODE_KEY_PREFIX}${normalized}` + try { + await redis.set(key, String(barcodeId), "EX", Number(ttlSeconds) || DEFAULT_TTL_SECONDS) + return { saved: true } + } catch { + return { saved: false } + } finally {} +} + +async function getBarcodeForUrl(url) { + const normalized = normalizeUrl(url) + if (!normalized) return null + const redis = createRedisClient() + const key = `${BARCODE_KEY_PREFIX}${normalized}` + try { + const raw = await redis.get(key) + return raw ?? null + } catch { + return null + } finally {} +} + +module.exports = { + setBarcodeForUrl, + getBarcodeForUrl, +} diff --git a/services/redis/linkPreviewImageCache.service.js b/services/redis/linkPreviewImageCache.service.js new file mode 100644 index 0000000..9bee397 --- /dev/null +++ b/services/redis/linkPreviewImageCache.service.js @@ -0,0 +1,86 @@ +const crypto = require("crypto") +const axios = require("axios") +const { getRedisClient } = require("./client") + +const IMAGE_KEY_PREFIX = "cache:deal_create:image:" +const DEFAULT_TTL_SECONDS = 5 * 60 +const MAX_IMAGE_BYTES = 2 * 1024 * 1024 + +function createRedisClient() { + return getRedisClient() +} + +function normalizeUrl(rawUrl) { + if (!rawUrl || typeof rawUrl !== "string") return null + try { + const url = new URL(rawUrl) + if (!["http:", "https:"].includes(url.protocol)) return null + const hostname = url.hostname.toLowerCase() + const pathname = url.pathname || "/" + const search = url.search || "" + return `${url.protocol}//${hostname}${pathname}${search}` + } catch { + return null + } +} + +function buildKey(normalizedUrl) { + const hash = crypto.createHash("sha1").update(normalizedUrl).digest("hex") + return `${IMAGE_KEY_PREFIX}${hash}` +} + +async function cacheImageFromUrl(rawUrl, { ttlSeconds = DEFAULT_TTL_SECONDS } = {}) { + const normalized = normalizeUrl(rawUrl) + if (!normalized) return null + + const key = buildKey(normalized) + const redis = createRedisClient() + + try { + const cached = await redis.get(key) + if (cached) return { key: key.replace(IMAGE_KEY_PREFIX, ""), cached: true } + + const response = await axios.get(normalized, { + responseType: "arraybuffer", + timeout: 15000, + maxContentLength: MAX_IMAGE_BYTES, + maxBodyLength: MAX_IMAGE_BYTES, + validateStatus: (status) => status >= 200 && status < 300, + }) + + const contentType = String(response.headers?.["content-type"] || "") + if (!contentType.startsWith("image/")) return null + + const buffer = Buffer.from(response.data || []) + if (!buffer.length || buffer.length > MAX_IMAGE_BYTES) return null + + const payload = JSON.stringify({ + ct: contentType, + b64: buffer.toString("base64"), + }) + await redis.set(key, payload, "EX", Number(ttlSeconds) || DEFAULT_TTL_SECONDS) + return { key: key.replace(IMAGE_KEY_PREFIX, ""), cached: false } + } catch { + return null + } finally {} +} + +async function getCachedImageByKey(hashKey) { + if (!hashKey || typeof hashKey !== "string") return null + const redis = createRedisClient() + const key = `${IMAGE_KEY_PREFIX}${hashKey}` + try { + const raw = await redis.get(key) + if (!raw) return null + const parsed = JSON.parse(raw) + if (!parsed?.ct || !parsed?.b64) return null + return { contentType: parsed.ct, buffer: Buffer.from(parsed.b64, "base64") } + } catch { + return null + } finally {} +} + +module.exports = { + cacheImageFromUrl, + getCachedImageByKey, +} diff --git a/services/redis/newDealList.service.js b/services/redis/newDealList.service.js index c7fd5c3..7b89843 100644 --- a/services/redis/newDealList.service.js +++ b/services/redis/newDealList.service.js @@ -6,7 +6,7 @@ function createRedisClient() { async function getNewDealListId(redis, newListId) { if (newListId) return String(newListId) - const latest = await redis.get("lists:new:latest") + const latest = await redis.get("deals:lists:new:latest") return latest ? String(latest) : null } @@ -17,7 +17,7 @@ async function getNewDealIds({ newListId } = {}) { const listId = await getNewDealListId(redis, newListId) if (!listId) return { newListId: null, dealIds: [] } - const key = `lists:new:${listId}` + const key = `deals:lists:new:${listId}` const raw = await redis.call("JSON.GET", key, "$.dealIds") if (!raw) return { newListId: listId, dealIds: [] } @@ -28,6 +28,8 @@ async function getNewDealIds({ newListId } = {}) { newListId: listId, dealIds: Array.isArray(dealIds) ? dealIds.map((id) => Number(id)) : [], } + } catch { + return { newListId: null, dealIds: [] } } finally {} } diff --git a/services/redis/notificationPubsub.service.js b/services/redis/notificationPubsub.service.js index 11fe038..68a747f 100644 --- a/services/redis/notificationPubsub.service.js +++ b/services/redis/notificationPubsub.service.js @@ -12,6 +12,8 @@ async function publishNotification(payload) { try { const message = JSON.stringify(payload) return await redis.publish(NOTIFICATIONS_CHANNEL, message) + } catch { + return 0 } finally {} } diff --git a/services/redis/searchIndex.service.js b/services/redis/searchIndex.service.js index c7397a5..ee7dc11 100644 --- a/services/redis/searchIndex.service.js +++ b/services/redis/searchIndex.service.js @@ -10,12 +10,12 @@ async function ensureDealSearchIndex() { try { await redis.call( "FT.CREATE", - "idx:data:deals", + "idx:deals", "ON", "JSON", "PREFIX", "1", - "data:deals:", + "deals:cache:", "SCHEMA", "$.id", "AS", @@ -158,12 +158,12 @@ async function ensureDealSearchIndex() { "TAG" ) - console.log("✅ Redis search index created: idx:data:deals") + console.log("✅ Redis search index created: idx:deals") await ensureDealIndexFields(redis) } catch (err) { const message = err?.message || "" if (message.includes("Index already exists")) { - console.log("ℹ️ Redis search index already exists: idx:data:deals") + console.log("ℹ️ Redis search index already exists: idx:deals") await ensureDealIndexFields(redis) } else { throw err @@ -204,7 +204,7 @@ async function ensureDealIndexFields(redis) { for (const field of fields) { try { - await redis.call("FT.ALTER", "idx:data:deals", "SCHEMA", "ADD", ...field) + await redis.call("FT.ALTER", "idx:deals", "SCHEMA", "ADD", ...field) console.log(`✅ Redis search index field added: ${field[2]}`) } catch (err) { const message = err?.message || "" diff --git a/services/redis/sellerCache.service.js b/services/redis/sellerCache.service.js index 6b29fcf..8bf9010 100644 --- a/services/redis/sellerCache.service.js +++ b/services/redis/sellerCache.service.js @@ -2,7 +2,7 @@ const { getRedisClient } = require("./client") const { recordCacheMiss } = require("./cacheMetrics.service") const SELLERS_KEY = "data:sellers" -const SELLER_DOMAINS_KEY = "data:sellerdomains" +const SELLER_DOMAINS_KEY = "data:seller:domains" function createRedisClient() { return getRedisClient() @@ -28,6 +28,8 @@ async function getSellerById(id) { await recordCacheMiss({ key: `${SELLERS_KEY}:${sellerId}`, label: "seller" }) } return raw ? JSON.parse(raw) : null + } catch { + return null } finally {} } @@ -48,6 +50,8 @@ async function getSellersByIds(ids = []) { } }) return map + } catch { + return new Map() } finally {} } @@ -62,6 +66,8 @@ async function getSellerIdByDomain(domain) { } const id = raw ? Number(raw) : null return Number.isInteger(id) && id > 0 ? id : null + } catch { + return null } finally {} } @@ -131,6 +137,8 @@ async function listSellersFromRedis() { } } return list + } catch { + return [] } finally {} } diff --git a/services/redis/sellerId.service.js b/services/redis/sellerId.service.js index 82c106d..b6ac173 100644 --- a/services/redis/sellerId.service.js +++ b/services/redis/sellerId.service.js @@ -1,6 +1,6 @@ const prisma = require("../../db/client") const { ensureCounterAtLeast, nextId } = require("./idGenerator.service") -const SELLER_ID_KEY = "ids:seller" +const SELLER_ID_KEY = "data:ids:seller" async function ensureSellerIdCounter() { const latest = await prisma.seller.findFirst({ @@ -12,7 +12,15 @@ async function ensureSellerIdCounter() { } async function generateSellerId() { - return nextId(SELLER_ID_KEY) + try { + return await nextId(SELLER_ID_KEY) + } catch { + const latest = await prisma.seller.findFirst({ + select: { id: true }, + orderBy: { id: "desc" }, + }) + return (latest?.id ?? 0) + 1 + } } module.exports = { ensureSellerIdCounter, generateSellerId } diff --git a/services/redis/trendingDealList.service.js b/services/redis/trendingDealList.service.js index 9e6e53c..a9db8d6 100644 --- a/services/redis/trendingDealList.service.js +++ b/services/redis/trendingDealList.service.js @@ -6,7 +6,7 @@ function createRedisClient() { async function getTrendingDealListId(redis, trendingListId) { if (trendingListId) return String(trendingListId) - const latest = await redis.get("lists:trending:latest") + const latest = await redis.get("deals:lists:trending:latest") return latest ? String(latest) : null } @@ -17,7 +17,7 @@ async function getTrendingDealIds({ trendingListId } = {}) { const listId = await getTrendingDealListId(redis, trendingListId) if (!listId) return { trendingListId: null, dealIds: [] } - const key = `lists:trending:${listId}` + const key = `deals:lists:trending:${listId}` const raw = await redis.call("JSON.GET", key, "$.dealIds") if (!raw) return { trendingListId: listId, dealIds: [] } @@ -28,6 +28,8 @@ async function getTrendingDealIds({ trendingListId } = {}) { trendingListId: listId, dealIds: Array.isArray(dealIds) ? dealIds.map((id) => Number(id)) : [], } + } catch { + return { trendingListId: null, dealIds: [] } } finally {} } diff --git a/services/redis/userCache.service.js b/services/redis/userCache.service.js index deaaaeb..7139702 100644 --- a/services/redis/userCache.service.js +++ b/services/redis/userCache.service.js @@ -1,8 +1,10 @@ const { getRedisClient } = require("./client") +const dealSaveDb = require("../../db/dealSave.db") -const USER_KEY_PREFIX = "data:users:" -const USER_SAVED_SET_PREFIX = "data:users:saved:" -const USER_UNSAVED_SET_PREFIX = "data:users:unsaved:" +const USER_KEY_PREFIX = "users:cache:" +const USER_SAVED_SET_PREFIX = "users:saved:" +const USER_UNSAVED_SET_PREFIX = "users:unsaved:" +const USER_SAVED_HASH_PREFIX = "users:savedmap:" const DEFAULT_USER_TTL_SECONDS = 60 * 60 function createRedisClient() { @@ -19,6 +21,7 @@ async function ensureUserCache(userId, { ttlSeconds = DEFAULT_USER_TTL_SECONDS } if (!id) return false const redis = createRedisClient() const key = `${USER_KEY_PREFIX}${id}` + const savedMapKey = `${USER_SAVED_HASH_PREFIX}${id}` try { const exists = await redis.exists(key) if (!exists) { @@ -31,8 +34,11 @@ async function ensureUserCache(userId, { ttlSeconds = DEFAULT_USER_TTL_SECONDS } } if (ttlSeconds) { await redis.expire(key, Number(ttlSeconds)) + await redis.expire(savedMapKey, Number(ttlSeconds)) } return true + } catch { + return false } finally {} } @@ -43,6 +49,7 @@ async function getUserSavedIdsFromRedis(userId) { const key = `${USER_KEY_PREFIX}${id}` const setKey = `${USER_SAVED_SET_PREFIX}${id}` const unsavedKey = `${USER_UNSAVED_SET_PREFIX}${id}` + const savedMapKey = `${USER_SAVED_HASH_PREFIX}${id}` try { const [raw, setIds, unsavedIds] = await Promise.all([ redis.call("JSON.GET", key, "$.savedDeals"), @@ -65,6 +72,50 @@ async function getUserSavedIdsFromRedis(userId) { savedSet: new Set(setList), unsavedSet: new Set(unsavedList), } + } catch { + return { jsonIds: [], savedSet: new Set(), unsavedSet: new Set() } + } finally {} +} + +async function getUserSavedMapForDeals(userId, dealIds = []) { + const uid = normalizeUserId(userId) + const ids = Array.from( + new Set((Array.isArray(dealIds) ? dealIds : []).map(Number).filter((id) => Number.isInteger(id) && id > 0)) + ) + if (!uid || !ids.length) return new Map() + const redis = createRedisClient() + const key = `${USER_SAVED_HASH_PREFIX}${uid}` + try { + const exists = await redis.exists(key) + if (!exists) { + const saved = await dealSaveDb.findDealSavesByUser(uid, { + select: { dealId: true }, + where: { userId: uid }, + }) + const pipeline = redis.pipeline() + saved.forEach((entry) => { + if (!entry?.dealId) return + pipeline.hset(key, String(entry.dealId), "1") + }) + pipeline.expire(key, DEFAULT_USER_TTL_SECONDS) + await pipeline.exec() + const savedSet = new Set(saved.map((s) => Number(s.dealId))) + const map = new Map() + ids.forEach((id) => { + if (savedSet.has(id)) map.set(id, true) + }) + return map + } + + const results = await redis.hmget(key, ids.map(String)) + const map = new Map() + results.forEach((raw, idx) => { + const value = raw == null ? 0 : Number(raw) + if (value) map.set(ids[idx], true) + }) + return map + } catch { + return new Map() } finally {} } @@ -73,13 +124,23 @@ async function setUserSavedDeals(userId, ids = [], { ttlSeconds = DEFAULT_USER_T if (!uid) return false const redis = createRedisClient() const key = `${USER_KEY_PREFIX}${uid}` + const savedMapKey = `${USER_SAVED_HASH_PREFIX}${uid}` try { const list = Array.isArray(ids) ? ids : [] await redis.call("JSON.SET", key, "$.savedDeals", JSON.stringify(list)) + await redis.del(savedMapKey) + if (list.length) { + const pipeline = redis.pipeline() + list.forEach((id) => pipeline.hset(savedMapKey, String(id), "1")) + await pipeline.exec() + } if (ttlSeconds) { await redis.expire(key, Number(ttlSeconds)) + await redis.expire(savedMapKey, Number(ttlSeconds)) } return true + } catch { + return false } finally {} } @@ -91,6 +152,7 @@ async function addUserSavedDeal(userId, dealId, { ttlSeconds = DEFAULT_USER_TTL_ const key = `${USER_KEY_PREFIX}${uid}` const setKey = `${USER_SAVED_SET_PREFIX}${uid}` const unsavedKey = `${USER_UNSAVED_SET_PREFIX}${uid}` + const savedMapKey = `${USER_SAVED_HASH_PREFIX}${uid}` try { await ensureUserCache(uid, { ttlSeconds }) const raw = await redis.call("JSON.GET", key, "$.savedDeals") @@ -109,12 +171,16 @@ async function addUserSavedDeal(userId, dealId, { ttlSeconds = DEFAULT_USER_TTL_ } await redis.sadd(setKey, String(did)) await redis.srem(unsavedKey, String(did)) + await redis.hset(savedMapKey, String(did), "1") if (ttlSeconds) { await redis.expire(setKey, Number(ttlSeconds)) await redis.expire(unsavedKey, Number(ttlSeconds)) await redis.expire(key, Number(ttlSeconds)) + await redis.expire(savedMapKey, Number(ttlSeconds)) } return true + } catch { + return false } finally {} } @@ -126,6 +192,7 @@ async function removeUserSavedDeal(userId, dealId, { ttlSeconds = DEFAULT_USER_T const key = `${USER_KEY_PREFIX}${uid}` const setKey = `${USER_SAVED_SET_PREFIX}${uid}` const unsavedKey = `${USER_UNSAVED_SET_PREFIX}${uid}` + const savedMapKey = `${USER_SAVED_HASH_PREFIX}${uid}` try { const raw = await redis.call("JSON.GET", key, "$.savedDeals") if (raw) { @@ -138,18 +205,23 @@ async function removeUserSavedDeal(userId, dealId, { ttlSeconds = DEFAULT_USER_T } await redis.srem(setKey, String(did)) await redis.sadd(unsavedKey, String(did)) + await redis.hdel(savedMapKey, String(did)) if (ttlSeconds) { await redis.expire(setKey, Number(ttlSeconds)) await redis.expire(unsavedKey, Number(ttlSeconds)) await redis.expire(key, Number(ttlSeconds)) + await redis.expire(savedMapKey, Number(ttlSeconds)) } return true + } catch { + return false } finally {} } module.exports = { ensureUserCache, getUserSavedIdsFromRedis, + getUserSavedMapForDeals, setUserSavedDeals, addUserSavedDeal, removeUserSavedDeal, diff --git a/services/redis/userModerationCache.service.js b/services/redis/userModerationCache.service.js index beab2af..a9f0565 100644 --- a/services/redis/userModerationCache.service.js +++ b/services/redis/userModerationCache.service.js @@ -2,7 +2,7 @@ const { getRedisClient } = require("./client") const userDb = require("../../db/user.db") const { recordCacheMiss } = require("./cacheMetrics.service") -const USER_MOD_KEY_PREFIX = "user:mod:" +const USER_MOD_KEY_PREFIX = "users:moderation:" const DEFAULT_TTL_SECONDS = 60 * 60 function createRedisClient() { @@ -36,6 +36,8 @@ async function getUserModerationFromRedis(userId) { await recordCacheMiss({ key: `${USER_MOD_KEY_PREFIX}${id}`, label: "user-mod" }) } return raw ? JSON.parse(raw) : null + } catch { + return null } finally {} } @@ -48,6 +50,8 @@ async function setUserModerationInRedis(user, { ttlSeconds = DEFAULT_TTL_SECONDS await redis.call("JSON.SET", key, "$", JSON.stringify(payload)) if (ttlSeconds) await redis.expire(key, Number(ttlSeconds)) return true + } catch { + return false } finally {} } diff --git a/services/redis/userProfileCache.service.js b/services/redis/userProfileCache.service.js index 75f560d..5aef3df 100644 --- a/services/redis/userProfileCache.service.js +++ b/services/redis/userProfileCache.service.js @@ -1,8 +1,8 @@ const { getRedisClient } = require("./client") const { recordCacheMiss } = require("./cacheMetrics.service") -const PROFILE_KEY_PREFIX = "data:profiles:user:" -const DEFAULT_TTL_SECONDS = 60 +const PROFILE_KEY_PREFIX = "users:profile:" +const DEFAULT_TTL_SECONDS = 5 * 60 function createRedisClient() { return getRedisClient() @@ -24,6 +24,8 @@ async function getUserProfileFromRedis(userName) { return null } return JSON.parse(raw) + } catch { + return null } finally {} } @@ -39,6 +41,8 @@ async function setUserProfileInRedis(userName, payload, { ttlSeconds = DEFAULT_T await redis.expire(key, ttl) } return true + } catch { + return false } finally {} } diff --git a/services/redis/userPublicCache.service.js b/services/redis/userPublicCache.service.js index 0c54a71..b450cc8 100644 --- a/services/redis/userPublicCache.service.js +++ b/services/redis/userPublicCache.service.js @@ -1,8 +1,8 @@ const { getRedisClient } = require("./client") const { recordCacheMiss } = require("./cacheMetrics.service") -const USER_PUBLIC_ID_KEY_PREFIX = "user:id:" -const USER_PUBLIC_NAME_KEY_PREFIX = "user:name:" +const USER_PUBLIC_ID_KEY_PREFIX = "users:public:id:" +const USER_PUBLIC_NAME_KEY_PREFIX = "users:public:name:" const DEFAULT_USER_TTL_SECONDS = 60 * 60 function createRedisClient() { @@ -50,6 +50,8 @@ async function getUserPublicFromRedis(userId) { return null } return JSON.parse(raw) + } catch { + return null } finally {} } @@ -77,6 +79,8 @@ async function getUsersPublicByIds(userIds = []) { }) return map + } catch { + return new Map() } finally {} } @@ -108,14 +112,16 @@ async function setUsersPublicInRedis(users = [], { ttlSecondsById = null } = {}) }) await pipeline.exec() return payloads.length + } catch { + return 0 } finally {} } async function setUserPublicInRedis(user, { ttlSeconds = DEFAULT_USER_TTL_SECONDS } = {}) { const payload = normalizeUserPayload(user) if (!payload) return false - await setUsersPublicInRedis([payload], { ttlSecondsById: { [payload.id]: ttlSeconds } }) - return true + const count = await setUsersPublicInRedis([payload], { ttlSecondsById: { [payload.id]: ttlSeconds } }) + return count > 0 } async function ensureUserMinTtl(userId, { minSeconds = DEFAULT_USER_TTL_SECONDS } = {}) { @@ -133,6 +139,8 @@ async function ensureUserMinTtl(userId, { minSeconds = DEFAULT_USER_TTL_SECONDS return { bumped: true, ttl: nextTtl } } return { bumped: false, ttl } + } catch { + return { bumped: false } } finally {} } @@ -150,6 +158,8 @@ async function getUserIdByUsername(userName) { } const id = raw ? Number(raw) : null return Number.isInteger(id) && id > 0 ? id : null + } catch { + return null } finally {} } diff --git a/services/tag.service.js b/services/tag.service.js index 0faacd8..8f3800f 100644 --- a/services/tag.service.js +++ b/services/tag.service.js @@ -113,6 +113,27 @@ async function removeTagsFromDeal(dealId, tags = []) { select: { tag: { select: { id: true, slug: true, name: true } } }, }) + const remainingSlugs = new Set( + tagsForDeal.map((entry) => entry?.tag?.slug).filter(Boolean) + ) + if (slugs.length) { + const aiReview = await tx.dealAiReview.findUnique({ + where: { dealId: id }, + select: { tags: true }, + }) + if (aiReview && Array.isArray(aiReview.tags) && aiReview.tags.length) { + const filtered = aiReview.tags.filter( + (tag) => !slugs.includes(String(tag)) || remainingSlugs.has(String(tag)) + ) + if (filtered.length !== aiReview.tags.length) { + await tx.dealAiReview.update({ + where: { dealId: id }, + data: { tags: filtered }, + }) + } + } + } + return { tags: tagsForDeal.map((entry) => entry.tag), removed: toRemove.length, diff --git a/services/vote.service.js b/services/vote.service.js index fc9247e..379b977 100644 --- a/services/vote.service.js +++ b/services/vote.service.js @@ -1,7 +1,6 @@ -const dealDb = require("../db/deal.db"); const { updateDealVoteInRedis } = require("./redis/dealVote.service"); const { queueVoteUpdate, queueDealUpdate, queueNotificationCreate } = require("./redis/dbSync.service"); -const { updateDealInRedis } = require("./redis/dealCache.service"); +const { updateDealInRedis, getDealFromRedis } = require("./redis/dealCache.service"); const { publishNotification } = require("./redis/notificationPubsub.service"); async function voteDeal({ dealId, userId, voteType }) { @@ -17,10 +16,7 @@ async function voteDeal({ dealId, userId, voteType }) { throw err; } - const deal = await dealDb.findDeal( - { id: Number(dealId) }, - { select: { status: true } } - ); + const deal = await getDealFromRedis(Number(dealId)); if (!deal || !["ACTIVE", "EXPIRED"].includes(deal.status)) { const err = new Error("deal sadece ACTIVE veya EXPIRED iken oylanabilir"); err.statusCode = 400; @@ -79,13 +75,9 @@ async function voteDeal({ dealId, userId, voteType }) { let delta = redisResult?.delta ?? 0; let score = redisResult?.score ?? null; if (score === null) { - const current = await dealDb.findDeal( - { id: Number(dealId) }, - { select: { score: true, votes: { where: { userId: Number(userId) }, select: { voteType: true } } } } - ); - const oldVote = current?.votes?.[0]?.voteType ?? 0; - delta = Number(voteType) - Number(oldVote); - score = Number(current?.score ?? 0) + delta; + const current = await getDealFromRedis(Number(dealId)); + score = current ? Number(current?.score ?? 0) : null; + delta = 0; } return { diff --git a/utils/processImage.js b/utils/processImage.js index 49a0828..20640bb 100644 --- a/utils/processImage.js +++ b/utils/processImage.js @@ -16,4 +16,11 @@ async function makeThumbWebp(inputBuffer) { .toBuffer(); } -module.exports = { makeDetailWebp, makeThumbWebp }; +async function makeWebp(inputBuffer, { quality = 80 } = {}) { + return sharp(inputBuffer) + .rotate() + .webp({ quality }) + .toBuffer(); +} + +module.exports = { makeDetailWebp, makeThumbWebp, makeWebp }; diff --git a/workers/dbSync.worker.js b/workers/dbSync.worker.js index dbb9c06..aa1e6fe 100644 --- a/workers/dbSync.worker.js +++ b/workers/dbSync.worker.js @@ -21,10 +21,7 @@ const { SELLER_UPSERT_HASH_KEY, SELLER_DOMAIN_UPSERT_HASH_KEY, } = require("../services/redis/dbSync.service") -const { - DEAL_EVENT_HASH_KEY, - incrementDealAnalyticsTotalsInRedis, -} = require("../services/redis/dealAnalytics.service") +const { DEAL_ANALYTICS_TOTAL_HASH_KEY } = require("../services/redis/dealAnalytics.service") const commentLikeDb = require("../db/commentLike.db") const dealAnalyticsDb = require("../db/dealAnalytics.db") const prisma = require("../db/client") @@ -493,11 +490,11 @@ async function consumeCommentDeletes(redis) { }) } -async function consumeDealEvents(redis) { +async function consumeDealAnalyticsTotals(redis) { const data = await redis.eval( "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;", 1, - DEAL_EVENT_HASH_KEY + DEAL_ANALYTICS_TOTAL_HASH_KEY ) if (!data || data.length === 0) return 0 @@ -506,31 +503,29 @@ async function consumeDealEvents(redis) { pairs[data[i]] = data[i + 1] } - const events = [] + const increments = [] for (const payload of Object.values(pairs)) { try { const parsed = JSON.parse(payload) - if (!parsed?.dealId || (!parsed?.userId && !parsed?.ip)) continue - events.push({ + if (!parsed?.dealId) continue + increments.push({ dealId: Number(parsed.dealId), - type: String(parsed.type || "IMPRESSION").toUpperCase(), - userId: parsed.userId ? Number(parsed.userId) : null, - ip: parsed.ip ? String(parsed.ip) : null, - createdAt: parsed.createdAt, + impressions: Number(parsed.impressions || 0), + views: Number(parsed.views || 0), + clicks: Number(parsed.clicks || 0), }) } catch (err) { - console.error("db-sync dealEvent parse failed:", err?.message || err) + console.error("db-sync dealAnalyticsTotals parse failed:", err?.message || err) } } - if (!events.length) return 0 + if (!increments.length) return 0 try { - const result = await dealAnalyticsDb.applyDealEventBatch(events) - await incrementDealAnalyticsTotalsInRedis(result?.increments || []) - return result?.inserted ?? 0 + const result = await dealAnalyticsDb.applyDealTotalsBatch(increments) + return result?.updated ?? 0 } catch (err) { - console.error("db-sync dealEvent batch failed:", err?.message || err) + console.error("db-sync dealAnalyticsTotals batch failed:", err?.message || err) return 0 } } @@ -1003,7 +998,7 @@ async function handler() { const commentLikeCount = await consumeCommentLikeUpdates(redis) const commentDeleteCount = await consumeCommentDeletes(redis) const dealSaveCount = await consumeDealSaveUpdates(redis) - const dealEventCount = await consumeDealEvents(redis) + const dealEventCount = await consumeDealAnalyticsTotals(redis) const dealCreateCount = await consumeDealCreates(redis) const dealAiReviewCount = await consumeDealAiReviewUpdates(redis) const notificationReadCount = await consumeNotificationReads(redis) diff --git a/workers/hotDealList.worker.js b/workers/hotDealList.worker.js index ea626e5..9fa0d21 100644 --- a/workers/hotDealList.worker.js +++ b/workers/hotDealList.worker.js @@ -17,7 +17,7 @@ function parseSearchResults(results = []) { // i=1'den başlıyoruz (results[0] toplam sayıdır), ikişer ikişer atlıyoruz for (let i = 1; i < results.length; i += 2) { - const key = results[i]; // Örn: "data:deals:20" + const key = results[i]; // Örn: "deals:cache:20" const value = results[i + 1]; // Örn: ["$", "[{\"id\":20,...}]"] try { @@ -26,7 +26,7 @@ function parseSearchResults(results = []) { const [deal] = JSON.parse(value[1]); ids.push(Number(deal.id)); } catch { - // Eğer JSON'da bir sorun olursa, ID'yi key'den (data:deals:20) güvenli bir şekilde çek + // Eğer JSON'da bir sorun olursa, ID'yi key'den (deals:cache:20) güvenli bir şekilde çek const idFromKey = key.split(":")[2]; if (idFromKey) ids.push(Number(idFromKey)); } @@ -50,11 +50,11 @@ async function buildHotDealListForRange({ windowDays, listKey, latestKey }) { */ const query = `@status:{ACTIVE} @createdAtTs:[${cutoffMs} +inf]` - console.log(`🔍 Redis Query: FT.SEARCH idx:data:deals "${query}" SORTBY score DESC DIALECT 3`) + console.log(`🔍 Redis Query: FT.SEARCH idx:deals "${query}" SORTBY score DESC DIALECT 3`) const results = await redis.call( "FT.SEARCH", - "idx:data:deals", + "idx:deals", query, "SORTBY", "score", "DESC", "LIMIT", "0", String(HOT_DEAL_LIMIT), @@ -94,23 +94,23 @@ async function handler() { const results = {} results.hot = await buildHotDealListForRange({ windowDays: HOT_DEAL_WINDOW_DAYS, - listKey: "lists:hot", - latestKey: "lists:hot:latest", + listKey: "deals:lists:hot", + latestKey: "deals:lists:hot:latest", }) results.hotDay = await buildHotDealListForRange({ windowDays: HOT_DAY_WINDOW_DAYS, - listKey: "lists:hot_day", - latestKey: "lists:hot_day:latest", + listKey: "deals:lists:hot_day", + latestKey: "deals:lists:hot_day:latest", }) results.hotWeek = await buildHotDealListForRange({ windowDays: HOT_WEEK_WINDOW_DAYS, - listKey: "lists:hot_week", - latestKey: "lists:hot_week:latest", + listKey: "deals:lists:hot_week", + latestKey: "deals:lists:hot_week:latest", }) results.hotMonth = await buildHotDealListForRange({ windowDays: HOT_MONTH_WINDOW_DAYS, - listKey: "lists:hot_month", - latestKey: "lists:hot_month:latest", + listKey: "deals:lists:hot_month", + latestKey: "deals:lists:hot_month:latest", }) return results } diff --git a/workers/newDealList.worker.js b/workers/newDealList.worker.js index 324dbb3..653c78b 100644 --- a/workers/newDealList.worker.js +++ b/workers/newDealList.worker.js @@ -34,7 +34,7 @@ async function buildNewDealList() { const results = await redis.call( "FT.SEARCH", - "idx:data:deals", + "idx:deals", query, "SORTBY", "createdAtTs", @@ -59,10 +59,10 @@ async function buildNewDealList() { dealIds, } - const key = `lists:new:${runId}` + const key = `deals:lists:new:${runId}` await redis.call("JSON.SET", key, "$", JSON.stringify(payload)) await redis.expire(key, NEW_DEAL_TTL_SECONDS) - await redis.set("lists:new:latest", runId, "EX", NEW_DEAL_TTL_SECONDS) + await redis.set("deals:lists:new:latest", runId, "EX", NEW_DEAL_TTL_SECONDS) return { id: runId, total: dealIds.length } } catch (error) { diff --git a/workers/trendingDealList.worker.js b/workers/trendingDealList.worker.js index ec1861c..db394f7 100644 --- a/workers/trendingDealList.worker.js +++ b/workers/trendingDealList.worker.js @@ -14,7 +14,7 @@ function parseSearchResults(results = []) { // i=1'den başlıyoruz (results[0] toplam sayıdır), ikişer ikişer atlıyoruz for (let i = 1; i < results.length; i += 2) { - const key = results[i]; // Örn: "data:deals:20" + const key = results[i]; // Örn: "deals:cache:20" const value = results[i + 1]; // Örn: ["$", "[{\"id\":20,...}]"] try { @@ -23,7 +23,7 @@ function parseSearchResults(results = []) { const [deal] = JSON.parse(value[1]); ids.push(Number(deal.id)); } catch { - // Eğer JSON'da bir sorun olursa, ID'yi key'den (data:deals:20) güvenli bir şekilde çek + // Eğer JSON'da bir sorun olursa, ID'yi key'den (deals:cache:20) güvenli bir şekilde çek const idFromKey = key.split(":")[2]; if (idFromKey) ids.push(Number(idFromKey)); } @@ -51,11 +51,11 @@ async function buildTrendingDealList() { */ const query = `@status:{ACTIVE} @createdAtTs:[${cutoffMs} +inf]` - console.log(`🔍 Redis Query: FT.SEARCH idx:data:deals "${query}" SORTBY score DESC DIALECT 3`) + console.log(`🔍 Redis Query: FT.SEARCH idx:deals "${query}" SORTBY score DESC DIALECT 3`) const results = await redis.call( "FT.SEARCH", - "idx:data:deals", + "idx:deals", query, "SORTBY", "score", "DESC", "LIMIT", "0", String(TRENDING_DEAL_LIMIT), @@ -76,10 +76,10 @@ async function buildTrendingDealList() { dealIds, } - const key = `lists:trending:${runId}` + const key = `deals:lists:trending:${runId}` await redis.call("JSON.SET", key, "$", JSON.stringify(payload)) await redis.expire(key, TRENDING_DEAL_TTL_SECONDS) - await redis.set("lists:trending:latest", runId, "EX", TRENDING_DEAL_TTL_SECONDS) + await redis.set("deals:lists:trending:latest", runId, "EX", TRENDING_DEAL_TTL_SECONDS) return { id: runId, total: dealIds.length } } catch (error) {