diff --git a/adapters/requests/dealCreate.adapter.js b/adapters/requests/dealCreate.adapter.js
index 977f5c3..0bcbaf7 100644
--- a/adapters/requests/dealCreate.adapter.js
+++ b/adapters/requests/dealCreate.adapter.js
@@ -1,14 +1,44 @@
function mapCreateDealRequestToDealCreateData(payload, userId) {
- const { title, description, url, price, sellerName } = payload
+ const {
+ title,
+ description,
+ url,
+ price,
+ originalPrice,
+ sellerName,
+ customSeller,
+ couponCode,
+ location,
+ discountType,
+ discountValue,
+ } = payload
+
+ const normalizedCouponCode =
+ couponCode === undefined || couponCode === null
+ ? null
+ : String(couponCode).trim() || null
+ const hasUrl = Boolean(url)
+ const saleType = !hasUrl ? "OFFLINE" : normalizedCouponCode ? "CODE" : "ONLINE"
+
+ const hasPrice = price != null
+ const normalizedDiscountType = hasPrice ? null : discountType ?? null
+ const normalizedDiscountValue = hasPrice ? null : discountValue ?? null
+ const normalizedSellerName = sellerName ?? customSeller ?? null
return {
title,
description: description ?? null,
url: url ?? null,
price: price ?? null,
+ originalPrice: originalPrice ?? null,
+ couponCode: normalizedCouponCode,
+ location: location ?? null,
+ discountType: normalizedDiscountType,
+ discountValue: normalizedDiscountValue,
+ saletype: saleType,
// Burada customSeller yazıyoruz; servis gerektiğinde ilişkilendiriyor.
- customSeller: sellerName ?? null,
+ customSeller: normalizedSellerName,
user: {
connect: { id: userId },
diff --git a/adapters/responses/dealCard.adapter.js b/adapters/responses/dealCard.adapter.js
index d1cb903..fe33b9c 100644
--- a/adapters/responses/dealCard.adapter.js
+++ b/adapters/responses/dealCard.adapter.js
@@ -8,15 +8,20 @@ function mapDealToDealCardResponse(deal) {
price: deal.price ?? null,
originalPrice: deal.originalPrice ?? null,
shippingPrice: deal.shippingPrice ?? null,
+ couponCode: deal.couponCode ?? null,
+ location: deal.location ?? null,
+ discountType: deal.discountType ?? null,
+ discountValue: deal.discountValue ?? null,
score: deal.score,
commentsCount: deal.commentCount,
- url:deal.url,
+ hasLink: Boolean(deal.url),
status: deal.status,
saleType: deal.saletype,
affiliateType: deal.affiliateType,
myVote: deal.myVote ?? 0,
+ isSaved: Boolean(deal.isSaved),
createdAt: formatDateAsString(deal.createdAt),
updatedAt: formatDateAsString(deal.updatedAt),
diff --git a/adapters/responses/dealDetail.adapter.js b/adapters/responses/dealDetail.adapter.js
index 7d1288b..8d3647e 100644
--- a/adapters/responses/dealDetail.adapter.js
+++ b/adapters/responses/dealDetail.adapter.js
@@ -53,11 +53,17 @@ function mapDealToDealDetailResponse(deal) {
id: deal.id,
title: deal.title,
description: deal.description || "",
- url: deal.url ?? null,
+ hasLink: Boolean(deal.url),
price: deal.price ?? null,
originalPrice: deal.originalPrice ?? null,
shippingPrice: deal.shippingPrice ?? null,
+ couponCode: deal.couponCode ?? null,
+ location: deal.location ?? null,
+ discountType: deal.discountType ?? null,
+ discountValue: deal.discountValue ?? null,
score: Number.isFinite(deal.score) ? deal.score : 0,
+ myVote: deal.myVote ?? 0,
+ isSaved: Boolean(deal.isSaved),
commentsCount: deal._count?.comments ?? 0,
diff --git a/adapters/responses/login.adapter.js b/adapters/responses/login.adapter.js
index 7d6a95c..ef9d4f7 100644
--- a/adapters/responses/login.adapter.js
+++ b/adapters/responses/login.adapter.js
@@ -8,7 +8,6 @@ function mapLoginRequestToLoginInput(input) {
function mapLoginResultToResponse(result) {
return {
- token: result.accessToken, // <-- KRİTİK
user: result.user,
}
}
diff --git a/adapters/responses/register.adapter.js b/adapters/responses/register.adapter.js
index f73def8..29d63c3 100644
--- a/adapters/responses/register.adapter.js
+++ b/adapters/responses/register.adapter.js
@@ -9,7 +9,6 @@ function mapRegisterRequestToRegisterInput(input) {
function mapRegisterResultToResponse(result) {
return {
- token: result.accessToken, // <-- KRİTİK
user: result.user,
}
}
diff --git a/adapters/responses/userProfile.adapter.js b/adapters/responses/userProfile.adapter.js
index a97fc35..ddbcd5f 100644
--- a/adapters/responses/userProfile.adapter.js
+++ b/adapters/responses/userProfile.adapter.js
@@ -4,12 +4,31 @@ const dealCommentAdapter = require("./comment.adapter")
const publicUserAdapter = require("./publicUser.adapter") // yoksa yaz
const userProfileStatsAdapter = require("./userProfileStats.adapter")
-function mapUserProfileToResponse({ user, deals, comments, stats }) {
+const formatDateAsString = (value) =>
+ value instanceof Date ? value.toISOString() : value ?? null
+
+function mapUserBadgeToResponse(item) {
+ if (!item) return null
+ return {
+ badge: item.badge
+ ? {
+ id: item.badge.id,
+ name: item.badge.name,
+ iconUrl: item.badge.iconUrl ?? null,
+ description: item.badge.description ?? null,
+ }
+ : null,
+ earnedAt: formatDateAsString(item.earnedAt),
+ }
+}
+
+function mapUserProfileToResponse({ user, deals, comments, stats, badges }) {
return {
user: publicUserAdapter.mapUserToPublicUserDetailsResponse(user),
stats: userProfileStatsAdapter.mapUserProfileStatsToResponse(stats),
deals: deals.map(dealCardAdapter.mapDealToDealCardResponse),
comments: comments.map(dealCommentAdapter.mapCommentToUserCommentResponse),
+ badges: Array.isArray(badges) ? badges.map(mapUserBadgeToResponse).filter(Boolean) : [],
}
}
diff --git a/agents.md b/agents.md
new file mode 100644
index 0000000..12a003e
--- /dev/null
+++ b/agents.md
@@ -0,0 +1,34 @@
+Proje Talimatları Dosyası: agents.md
+Bu dosyayı projenin kök dizininde tutabilir ve AI'ya her yeni görevde "Lütfen önce agents.md dosyasındaki kuralları oku ve mevcut mimariye sadık kal" diyebilirsin.
+
+1. Mimari ve Teknoloji Standartları
+Mevcut Araçları Kullan: Yeni bir kütüphane eklemeden veya yardımcı fonksiyon yazmadan önce mutlaka projedeki mevcut yapıları kontrol et.
+
+Dosya Yapısı: Yeni dosyaları mevcut klasör hiyerarşisine uygun yerleştir.
+
+2. Kod Yazım Prensipleri (Anti-Overengineering)
+YAGNI (You Ain't Gonna Need It): Sadece şu anki gereksinim için kod yaz. Gelecekte lazım olabilir diye karmaşık generic yapılar veya soyutlamalar ekleme.
+
+Kısa ve Öz: Çözümü en basit yoldan hallet. "Mükemmel" kod yerine "okunabilir ve sürdürülebilir" kod tercih edilir.
+
+KISS: Karmaşık mantıkları küçük, test edilebilir fonksiyonlara böl.
+
+3. Güvenlik Protokolü (Kritik)
+Input Validation: Kullanıcıdan gelen her veriyi sanitize et ve doğrula.
+
+Secret Yönetimi: API anahtarlarını, şifreleri veya hassas verileri asla koda gömme (hardcode yapma). Sadece .env dosyalarını kullan.
+
+Zafiyet Kontrolü: dangerouslySetInnerHTML gibi riskli fonksiyonları kullanmadan önce onay iste.
+
+Error Handling: Hata mesajlarında sistem detaylarını (stack trace vb.) son kullanıcıya gösterme.
+
+4. İş Akışı ve Kontrol Listesi
+Bir kod bloğu üretmeden önce şu adımları izle:
+
+Projeyi tara: "Bu işi yapan bir fonksiyon/component zaten var mı?"
+
+Bağımlılıkları kontrol et: "Yeni bir paket eklemem gerekiyor mu? (Gerekmedikçe hayır)."
+
+Güvenliği doğrula: "Bu kod bir güvenlik açığı oluşturuyor mu?"
+
+Basitleştir: "Bu kodu daha az satırla ve daha anlaşılır yazabilir miyim?"
\ No newline at end of file
diff --git a/db/badge.db.js b/db/badge.db.js
new file mode 100644
index 0000000..553297f
--- /dev/null
+++ b/db/badge.db.js
@@ -0,0 +1,39 @@
+const { PrismaClient } = require("@prisma/client")
+const prisma = new PrismaClient()
+
+async function listBadges(options = {}) {
+ return prisma.badge.findMany({
+ where: options.where || undefined,
+ orderBy: options.orderBy || { name: "asc" },
+ select: options.select || undefined,
+ })
+}
+
+async function findBadge(where, options = {}) {
+ return prisma.badge.findUnique({
+ where,
+ select: options.select || undefined,
+ })
+}
+
+async function createBadge(data, options = {}) {
+ return prisma.badge.create({
+ data,
+ select: options.select || undefined,
+ })
+}
+
+async function updateBadge(where, data, options = {}) {
+ return prisma.badge.update({
+ where,
+ data,
+ select: options.select || undefined,
+ })
+}
+
+module.exports = {
+ listBadges,
+ findBadge,
+ createBadge,
+ updateBadge,
+}
diff --git a/db/category.db.js b/db/category.db.js
index 525c08b..dc4170f 100644
--- a/db/category.db.js
+++ b/db/category.db.js
@@ -1,11 +1,25 @@
const prisma = require("./client") // Prisma client
+function getDb(db) {
+ return db || prisma
+}
+
+async function findCategoryById(id, options = {}) {
+ const cid = Number(id)
+ if (!Number.isInteger(cid)) return null
+ return getDb(options.db).category.findUnique({
+ where: { id: cid },
+ select: options.select || undefined,
+ include: options.include || undefined,
+ })
+}
+
/**
* Kategoriyi slug'a gore bul
*/
async function findCategoryBySlug(slug, options = {}) {
const s = String(slug ?? "").trim().toLowerCase()
- return prisma.category.findUnique({
+ return getDb(options.db).category.findUnique({
where: { slug: s },
select: options.select || undefined,
include: options.include || undefined,
@@ -82,9 +96,33 @@ async function getCategoryBreadcrumb(categoryId, { includeUndefined = false } =
return path.reverse()
}
+async function listCategories(options = {}) {
+ return getDb(options.db).category.findMany({
+ select: options.select || undefined,
+ orderBy: options.orderBy || { id: "asc" },
+ })
+}
+
+async function createCategory(data, db) {
+ const p = getDb(db)
+ return p.category.create({ data })
+}
+
+async function updateCategory(id, data, db) {
+ const p = getDb(db)
+ return p.category.update({
+ where: { id },
+ data,
+ })
+}
+
module.exports = {
getCategoryBreadcrumb,
+ findCategoryById,
findCategoryBySlug,
listCategoryDeals,
+ listCategories,
getCategoryDescendantIds,
+ createCategory,
+ updateCategory,
}
diff --git a/db/commentLike.db.js b/db/commentLike.db.js
index 93036d7..e48709c 100644
--- a/db/commentLike.db.js
+++ b/db/commentLike.db.js
@@ -1,4 +1,5 @@
const prisma = require("./client")
+const { Prisma } = require("@prisma/client")
async function findLike(commentId, userId, db) {
const p = db || prisma
@@ -55,7 +56,77 @@ async function setCommentLike({ commentId, userId, like }) {
})
}
+async function applyCommentLikeBatch(items = []) {
+ if (!items.length) return { inserted: 0, deleted: 0 }
+
+ const likes = items.filter((i) => i.like)
+ const unlikes = items.filter((i) => !i.like)
+
+ return prisma.$transaction(async (tx) => {
+ let inserted = 0
+ let deleted = 0
+
+ if (likes.length) {
+ const values = Prisma.join(
+ likes.map((i) => Prisma.sql`(${i.commentId}, ${i.userId})`)
+ )
+ const insertSql = Prisma.sql`
+ WITH input("commentId","userId") AS (VALUES ${values}),
+ ins AS (
+ INSERT INTO "CommentLike" ("commentId","userId")
+ SELECT "commentId","userId" FROM input
+ ON CONFLICT ("commentId","userId") DO NOTHING
+ RETURNING "commentId"
+ ),
+ agg AS (
+ SELECT "commentId", COUNT(*)::int AS cnt FROM ins GROUP BY "commentId"
+ )
+ UPDATE "Comment" c
+ SET "likeCount" = c."likeCount" + agg.cnt
+ FROM agg
+ WHERE c.id = agg."commentId"
+ RETURNING agg.cnt
+ `
+
+ const rows = await tx.$queryRaw(insertSql)
+ if (Array.isArray(rows)) {
+ inserted = rows.reduce((sum, row) => sum + Number(row.cnt || 0), 0)
+ }
+ }
+
+ if (unlikes.length) {
+ const values = Prisma.join(
+ unlikes.map((i) => Prisma.sql`(${i.commentId}, ${i.userId})`)
+ )
+ const deleteSql = Prisma.sql`
+ WITH input("commentId","userId") AS (VALUES ${values}),
+ del AS (
+ DELETE FROM "CommentLike" cl
+ WHERE (cl."commentId", cl."userId") IN (SELECT "commentId","userId" FROM input)
+ RETURNING "commentId"
+ ),
+ agg AS (
+ SELECT "commentId", COUNT(*)::int AS cnt FROM del GROUP BY "commentId"
+ )
+ UPDATE "Comment" c
+ SET "likeCount" = c."likeCount" - agg.cnt
+ FROM agg
+ WHERE c.id = agg."commentId"
+ RETURNING agg.cnt
+ `
+
+ const rows = await tx.$queryRaw(deleteSql)
+ if (Array.isArray(rows)) {
+ deleted = rows.reduce((sum, row) => sum + Number(row.cnt || 0), 0)
+ }
+ }
+
+ return { inserted, deleted }
+ })
+}
+
module.exports = {
findLikesByUserAndCommentIds,
setCommentLike,
+ applyCommentLikeBatch,
}
diff --git a/db/dealAiReview.db.js b/db/dealAiReview.db.js
index 962458e..9ee1382 100644
--- a/db/dealAiReview.db.js
+++ b/db/dealAiReview.db.js
@@ -4,6 +4,7 @@ const prisma = require("./client")
async function upsertDealAiReview(dealId, input = {}) {
const data = {
bestCategoryId: input.bestCategoryId ?? input.best_category_id ?? 0,
+ tags: Array.isArray(input.tags) ? input.tags : [],
needsReview: Boolean(input.needsReview ?? input.needs_review ?? false),
hasIssue: Boolean(input.hasIssue ?? input.has_issue ?? false),
issueType: (input.issueType ?? input.issue_type ?? "NONE"),
diff --git a/db/dealAnalytics.db.js b/db/dealAnalytics.db.js
new file mode 100644
index 0000000..ca80ba7
--- /dev/null
+++ b/db/dealAnalytics.db.js
@@ -0,0 +1,103 @@
+const prisma = require("./client")
+
+function normalizeIds(ids = []) {
+ return Array.from(
+ new Set(
+ (Array.isArray(ids) ? ids : [])
+ .map((id) => Number(id))
+ .filter((id) => Number.isInteger(id) && id > 0)
+ )
+ )
+}
+
+async function ensureTotalsForDealIds(dealIds = []) {
+ const ids = normalizeIds(dealIds)
+ if (!ids.length) return 0
+ const existing = await prisma.deal.findMany({
+ where: { id: { in: ids } },
+ select: { id: true },
+ })
+ const existingIds = new Set(existing.map((d) => d.id))
+ if (!existingIds.size) return 0
+ const data = ids.filter((id) => existingIds.has(id)).map((dealId) => ({ dealId }))
+ const result = await prisma.dealAnalyticsTotal.createMany({
+ data,
+ skipDuplicates: true,
+ })
+ return result?.count ?? 0
+}
+
+async function getTotalsByDealIds(dealIds = []) {
+ const ids = normalizeIds(dealIds)
+ if (!ids.length) return []
+ return prisma.dealAnalyticsTotal.findMany({
+ where: { dealId: { in: ids } },
+ select: {
+ dealId: true,
+ impressions: true,
+ views: true,
+ clicks: true,
+ },
+ })
+}
+
+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 applyDealEventBatch(events = []) {
+ const filtered = (Array.isArray(events) ? events : []).filter(
+ (e) => e && e.dealId && (e.userId || e.ip)
+ )
+ if (!filtered.length) return { inserted: 0, increments: [] }
+
+ const data = filtered.map((event) => ({
+ dealId: Number(event.dealId),
+ type: String(event.type || "IMPRESSION").toUpperCase(),
+ userId: event.userId ? Number(event.userId) : null,
+ ip: event.ip ? String(event.ip) : null,
+ createdAt: event.createdAt ? new Date(event.createdAt) : new Date(),
+ }))
+
+ const increments = aggregateEventIncrements(data)
+
+ await prisma.$transaction(async (tx) => {
+ await tx.dealEvent.createMany({ data })
+
+ for (const inc of increments) {
+ await tx.dealAnalyticsTotal.upsert({
+ where: { dealId: inc.dealId },
+ create: {
+ dealId: inc.dealId,
+ impressions: inc.impressions,
+ views: inc.views,
+ clicks: inc.clicks,
+ },
+ update: {
+ impressions: { increment: inc.impressions },
+ views: { increment: inc.views },
+ clicks: { increment: inc.clicks },
+ },
+ })
+ }
+ })
+
+ return { inserted: data.length, increments }
+}
+
+module.exports = {
+ ensureTotalsForDealIds,
+ getTotalsByDealIds,
+ applyDealEventBatch,
+}
diff --git a/db/dealReport.db.js b/db/dealReport.db.js
new file mode 100644
index 0000000..ac02949
--- /dev/null
+++ b/db/dealReport.db.js
@@ -0,0 +1,54 @@
+const prisma = require("./client")
+
+function getDb(db) {
+ return db || prisma
+}
+
+async function upsertDealReport({ dealId, userId, reason, note }, db) {
+ const p = getDb(db)
+ return p.dealReport.upsert({
+ where: { dealId_userId: { dealId, userId } },
+ update: {
+ reason,
+ note: note ?? null,
+ status: "OPEN",
+ },
+ create: {
+ dealId,
+ userId,
+ reason,
+ note: note ?? null,
+ },
+ })
+}
+
+async function listDealReports(where = {}, { skip = 0, take = 20, orderBy, include } = {}, db) {
+ const p = getDb(db)
+ return p.dealReport.findMany({
+ where,
+ skip,
+ take,
+ orderBy: orderBy || { createdAt: "desc" },
+ include: include || undefined,
+ })
+}
+
+async function countDealReports(where = {}, db) {
+ const p = getDb(db)
+ return p.dealReport.count({ where })
+}
+
+async function updateDealReportStatus(reportId, status, db) {
+ const p = getDb(db)
+ return p.dealReport.update({
+ where: { id: Number(reportId) },
+ data: { status },
+ })
+}
+
+module.exports = {
+ upsertDealReport,
+ listDealReports,
+ countDealReports,
+ updateDealReportStatus,
+}
diff --git a/db/dealSave.db.js b/db/dealSave.db.js
new file mode 100644
index 0000000..e3d91da
--- /dev/null
+++ b/db/dealSave.db.js
@@ -0,0 +1,52 @@
+const prisma = require("./client")
+
+function getDb(db) {
+ return db || prisma
+}
+
+async function upsertDealSave({ userId, dealId, createdAt }, db) {
+ const p = getDb(db)
+ return p.dealSave.upsert({
+ where: { userId_dealId: { userId, dealId } },
+ update: {},
+ create: {
+ userId,
+ dealId,
+ createdAt: createdAt ?? undefined,
+ },
+ })
+}
+
+async function deleteDealSave({ userId, dealId }, db) {
+ const p = getDb(db)
+ return p.dealSave.delete({
+ where: { userId_dealId: { userId, dealId } },
+ })
+}
+
+async function findDealSavesByUser(
+ userId,
+ { skip = 0, take = 20, include, orderBy, where } = {},
+ db
+) {
+ const p = getDb(db)
+ return p.dealSave.findMany({
+ where: where || { userId },
+ include: include || undefined,
+ orderBy: orderBy || { createdAt: "desc" },
+ skip,
+ take,
+ })
+}
+
+async function countDealSavesByUser(userId, { where } = {}, db) {
+ const p = getDb(db)
+ return p.dealSave.count({ where: where || { userId } })
+}
+
+module.exports = {
+ upsertDealSave,
+ deleteDealSave,
+ findDealSavesByUser,
+ countDealSavesByUser,
+}
diff --git a/db/notification.db.js b/db/notification.db.js
new file mode 100644
index 0000000..b3911b9
--- /dev/null
+++ b/db/notification.db.js
@@ -0,0 +1,27 @@
+const prisma = require("./client")
+
+function getDb(db) {
+ return db || prisma
+}
+
+async function findNotifications(where, options = {}, db) {
+ const p = getDb(db)
+ return p.notification.findMany({
+ where,
+ select: options.select || undefined,
+ include: options.include || undefined,
+ orderBy: options.orderBy || { createdAt: "desc" },
+ skip: Number.isInteger(options.skip) ? options.skip : undefined,
+ take: Number.isInteger(options.take) ? options.take : undefined,
+ })
+}
+
+async function countNotifications(where, db) {
+ const p = getDb(db)
+ return p.notification.count({ where })
+}
+
+module.exports = {
+ findNotifications,
+ countNotifications,
+}
diff --git a/db/seller.db.js b/db/seller.db.js
index 2cb656e..b50485b 100644
--- a/db/seller.db.js
+++ b/db/seller.db.js
@@ -21,8 +21,30 @@ async function findSellerByDomain(domain) {
})
}
+async function findSellers(where = {}, options = {}) {
+ return prisma.seller.findMany({
+ where,
+ include: options.include || undefined,
+ select: options.select || undefined,
+ orderBy: options.orderBy || { name: "asc" },
+ })
+}
+
+async function createSeller(data) {
+ return prisma.seller.create({ data })
+}
+
+async function updateSeller(id, data) {
+ return prisma.seller.update({
+ where: { id },
+ data,
+ })
+}
module.exports = {
findSeller,
findSellerByDomain,
+ findSellers,
+ createSeller,
+ updateSeller,
}
diff --git a/db/user.db.js b/db/user.db.js
index 51d1466..735ce7e 100644
--- a/db/user.db.js
+++ b/db/user.db.js
@@ -18,7 +18,20 @@ async function updateUser(where, data, options = {}) {
})
}
+async function findUsersByIds(ids = [], options = {}) {
+ const normalized = Array.from(
+ new Set((Array.isArray(ids) ? ids : []).map((id) => Number(id)).filter((id) => Number.isInteger(id) && id > 0))
+ )
+ if (!normalized.length) return []
+ return prisma.user.findMany({
+ where: { id: { in: normalized } },
+ include: options.include || undefined,
+ select: options.select || undefined,
+ })
+}
+
module.exports = {
findUser,
updateUser,
+ findUsersByIds,
}
diff --git a/db/userBadge.db.js b/db/userBadge.db.js
new file mode 100644
index 0000000..4452bf6
--- /dev/null
+++ b/db/userBadge.db.js
@@ -0,0 +1,31 @@
+const { PrismaClient } = require("@prisma/client")
+const prisma = new PrismaClient()
+
+async function listUserBadges(userId, options = {}) {
+ return prisma.userBadge.findMany({
+ where: { userId },
+ orderBy: options.orderBy || { earnedAt: "desc" },
+ select: options.select || undefined,
+ include: options.include || undefined,
+ })
+}
+
+async function createUserBadge(data, options = {}) {
+ return prisma.userBadge.create({
+ data,
+ select: options.select || undefined,
+ })
+}
+
+async function deleteUserBadge(where, options = {}) {
+ return prisma.userBadge.delete({
+ where,
+ select: options.select || undefined,
+ })
+}
+
+module.exports = {
+ listUserBadges,
+ createUserBadge,
+ deleteUserBadge,
+}
diff --git a/db/userNote.db.js b/db/userNote.db.js
new file mode 100644
index 0000000..1f04d87
--- /dev/null
+++ b/db/userNote.db.js
@@ -0,0 +1,38 @@
+const prisma = require("./client")
+
+function getDb(db) {
+ return db || prisma
+}
+
+async function createUserNote({ userId, createdById, note }, db) {
+ const p = getDb(db)
+ return p.userNote.create({
+ data: {
+ userId,
+ createdById,
+ note,
+ },
+ })
+}
+
+async function listUserNotes({ userId, skip = 0, take = 20 }, db) {
+ const p = getDb(db)
+ return p.userNote.findMany({
+ where: { userId },
+ orderBy: { createdAt: "desc" },
+ skip,
+ take,
+ include: { createdBy: { select: { id: true, username: true } } },
+ })
+}
+
+async function countUserNotes(where = {}, db) {
+ const p = getDb(db)
+ return p.userNote.count({ where })
+}
+
+module.exports = {
+ createUserNote,
+ listUserNotes,
+ countUserNotes,
+}
diff --git a/db/vote.db.js b/db/vote.db.js
index f0056f3..6e16c10 100644
--- a/db/vote.db.js
+++ b/db/vote.db.js
@@ -1,57 +1,74 @@
const prisma = require("./client");
-async function voteDealTx({ dealId, userId, voteType }) {
- return prisma.$transaction(async (db) => {
- const current = await db.dealVote.findUnique({
- where: { dealId_userId: { dealId, userId } },
- select: { voteType: true },
- });
+async function voteDealTxWithDb(db, { dealId, userId, voteType, createdAt }) {
+ const timestamp = createdAt instanceof Date ? createdAt : createdAt ? new Date(createdAt) : new Date()
+ const current = await db.dealVote.findUnique({
+ where: { dealId_userId: { dealId, userId } },
+ select: { voteType: true },
+ })
- const oldValue = current ? current.voteType : 0;
- const delta = voteType - oldValue;
+ const oldValue = current ? current.voteType : 0
+ const delta = voteType - oldValue
- // history (append-only)
- await db.dealVoteHistory.create({
- data: { dealId, userId, voteType },
- });
+ // history (append-only)
+ await db.dealVoteHistory.create({
+ data: { dealId, userId, voteType, createdAt: timestamp },
+ })
- // current state
- await db.dealVote.upsert({
- where: { dealId_userId: { dealId, userId } },
- create: {
- dealId,
- userId,
- voteType,
- lastVotedAt: new Date(),
- },
- update: {
- voteType,
- lastVotedAt: new Date(),
- },
- });
-
- // score delta
- if (delta !== 0) {
- await db.deal.update({
- where: { id: dealId },
- data: { score: { increment: delta } },
- });
- }
-
- const deal = await db.deal.findUnique({
- where: { id: dealId },
- select: { score: true },
- });
-
- return {
+ // current state
+ await db.dealVote.upsert({
+ where: { dealId_userId: { dealId, userId } },
+ create: {
dealId,
+ userId,
voteType,
- delta,
- score: deal?.score ?? null,
- };
- });
+ createdAt: timestamp,
+ lastVotedAt: timestamp,
+ },
+ update: {
+ voteType,
+ lastVotedAt: timestamp,
+ },
+ })
+
+ // score delta
+ if (delta !== 0) {
+ await db.deal.update({
+ where: { id: dealId },
+ data: { score: { increment: delta } },
+ })
+ }
+
+ const deal = await db.deal.findUnique({
+ where: { id: dealId },
+ select: { score: true },
+ })
+
+ return {
+ dealId,
+ voteType,
+ delta,
+ score: deal?.score ?? null,
+ }
+}
+
+async function voteDealTx({ dealId, userId, voteType, createdAt }) {
+ return prisma.$transaction((db) =>
+ voteDealTxWithDb(db, { dealId, userId, voteType, createdAt })
+ )
+}
+
+async function voteDealBatchTx(items = []) {
+ if (!items.length) return { count: 0 }
+ return prisma.$transaction(async (db) => {
+ for (const item of items) {
+ await voteDealTxWithDb(db, item)
+ }
+ return { count: items.length }
+ })
}
module.exports = {
voteDealTx,
+ voteDealBatchTx,
};
diff --git a/docs/openapi.json b/docs/openapi.json
new file mode 100644
index 0000000..8e14d4d
--- /dev/null
+++ b/docs/openapi.json
@@ -0,0 +1,1413 @@
+
+{
+ "openapi": "3.0.3",
+ "info": {
+ "title": "HotTRDeals API",
+ "version": "1.0.0",
+ "description": "Backend endpoints documented from current Express routes. Each operation includes auth/api-key requirements and data sources (db/redis)."
+ },
+ "servers": [
+ { "url": "http://localhost:3000" }
+ ],
+ "components": {
+ "securitySchemes": {
+ "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" },
+ "cookieAuth": { "type": "apiKey", "in": "cookie", "name": "at" },
+ "apiKeyAuth": { "type": "apiKey", "in": "header", "name": "x-api-key" }
+ },
+ "schemas": {
+ "ErrorResponse": {
+ "type": "object",
+ "properties": {
+ "error": { "type": "string" },
+ "message": { "type": "string" }
+ }
+ },
+ "UserPublic": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer" },
+ "username": { "type": "string" },
+ "avatarUrl": { "type": "string", "nullable": true }
+ }
+ },
+ "AuthUser": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer" },
+ "username": { "type": "string" },
+ "email": { "type": "string" },
+ "role": { "type": "string" },
+ "avatarUrl": { "type": "string", "nullable": true }
+ }
+ },
+ "AuthResponse": {
+ "type": "object",
+ "properties": {
+ "user": { "$ref": "#/components/schemas/AuthUser" }
+ }
+ },
+ "DealCard": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer" },
+ "title": { "type": "string" },
+ "description": { "type": "string", "nullable": true },
+ "price": { "type": "number", "nullable": true },
+ "originalPrice": { "type": "number", "nullable": true },
+ "shippingPrice": { "type": "number", "nullable": true },
+ "couponCode": { "type": "string", "nullable": true },
+ "location": { "type": "string", "nullable": true },
+ "discountType": { "type": "string", "nullable": true },
+ "discountValue": { "type": "number", "nullable": true },
+ "score": { "type": "integer" },
+ "commentsCount": { "type": "integer" },
+ "status": { "type": "string" },
+ "saleType": { "type": "string" },
+ "affiliateType": { "type": "string" },
+ "myVote": { "type": "integer" },
+ "isSaved": { "type": "boolean" },
+ "createdAt": { "type": "string" },
+ "updatedAt": { "type": "string" },
+ "user": { "$ref": "#/components/schemas/UserPublic" },
+ "seller": { "type": "object", "nullable": true }
+ }
+ },
+ "DealDetail": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer" },
+ "title": { "type": "string" },
+ "description": { "type": "string", "nullable": true },
+ "url": { "type": "string", "nullable": true },
+ "price": { "type": "number", "nullable": true },
+ "originalPrice": { "type": "number", "nullable": true },
+ "shippingPrice": { "type": "number", "nullable": true },
+ "couponCode": { "type": "string", "nullable": true },
+ "location": { "type": "string", "nullable": true },
+ "discountType": { "type": "string", "nullable": true },
+ "discountValue": { "type": "number", "nullable": true },
+ "score": { "type": "integer" },
+ "commentsCount": { "type": "integer" },
+ "status": { "type": "string" },
+ "saleType": { "type": "string" },
+ "affiliateType": { "type": "string" },
+ "createdAt": { "type": "string" },
+ "updatedAt": { "type": "string" },
+ "user": { "$ref": "#/components/schemas/UserPublic" },
+ "seller": { "type": "object", "nullable": true },
+ "images": { "type": "array", "items": { "type": "object" } },
+ "comments": { "type": "array", "items": { "type": "object" } }
+ }
+ },
+ "PaginatedDeals": {
+ "type": "object",
+ "properties": {
+ "page": { "type": "integer" },
+ "total": { "type": "integer" },
+ "totalPages": { "type": "integer" },
+ "results": { "type": "array", "items": { "$ref": "#/components/schemas/DealCard" } }
+ }
+ },
+ "Notification": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer" },
+ "message": { "type": "string" },
+ "type": { "type": "string" },
+ "createdAt": { "type": "string" },
+ "readAt": { "type": "string", "nullable": true },
+ "unread": { "type": "boolean" }
+ }
+ },
+ "Badge": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "iconUrl": { "type": "string", "nullable": true },
+ "description": { "type": "string", "nullable": true }
+ }
+ },
+ "UserBadge": {
+ "type": "object",
+ "properties": {
+ "badge": { "$ref": "#/components/schemas/Badge" },
+ "earnedAt": { "type": "string" }
+ }
+ }
+ }
+ },
+ "paths": {
+ "/api/auth/register": {
+ "post": {
+ "tags": ["Auth"],
+ "summary": "Register",
+ "description": "Creates user; sets access/refresh cookies.",
+ "x-auth": "none",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "username": { "type": "string", "minLength": 5, "maxLength": 18 },
+ "email": { "type": "string" },
+ "password": { "type": "string", "minLength": 8 }
+ },
+ "required": ["username", "email", "password"]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Auth response", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } },
+ "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
+ }
+ }
+ },
+ "/api/auth/login": {
+ "post": {
+ "tags": ["Auth"],
+ "summary": "Login",
+ "description": "Login with email/password; sets access/refresh cookies.",
+ "x-auth": "none",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "email": { "type": "string" },
+ "password": { "type": "string" }
+ },
+ "required": ["email", "password"]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Auth response", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } },
+ "401": { "description": "Invalid credentials", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
+ }
+ }
+ },
+ "/api/auth/refresh": {
+ "post": {
+ "tags": ["Auth"],
+ "summary": "Refresh tokens",
+ "description": "Rotates refresh token from cookie `rt`.",
+ "x-auth": "cookie-only",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "responses": {
+ "200": { "description": "Auth response", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } },
+ "401": { "description": "Invalid/expired refresh", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
+ }
+ }
+ },
+ "/api/auth/logout": {
+ "post": {
+ "tags": ["Auth"],
+ "summary": "Logout",
+ "description": "Revokes refresh token if present; clears cookies.",
+ "x-auth": "cookie-only",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "responses": {
+ "204": { "description": "No content" },
+ "500": { "description": "Server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
+ }
+ }
+ },
+ "/api/auth/me": {
+ "get": {
+ "tags": ["Auth"],
+ "summary": "Get current user",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "responses": {
+ "200": { "description": "Auth user", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthUser" } } } },
+ "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
+ }
+ }
+ },
+
+ "/api/account/avatar": {
+ "post": {
+ "tags": ["Account"],
+ "summary": "Update avatar",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "file": { "type": "string", "format": "binary" }
+ },
+ "required": ["file"]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Avatar updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string" }, "user": { "$ref": "#/components/schemas/UserPublic" } } } } } }
+ }
+ }
+ },
+ "/api/account/me": {
+ "get": {
+ "tags": ["Account"],
+ "summary": "Account profile",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "responses": {
+ "200": {
+ "description": "User + recent notifications",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer" },
+ "username": { "type": "string" },
+ "avatarUrl": { "type": "string", "nullable": true },
+ "createdAt": { "type": "string" },
+ "notifications": { "type": "array", "items": { "$ref": "#/components/schemas/Notification" } },
+ "badges": { "type": "array", "items": { "$ref": "#/components/schemas/UserBadge" } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/account/notifications/read": {
+ "get": {
+ "tags": ["Account"],
+ "summary": "Mark all notifications read",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": false, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "responses": {
+ "200": { "description": "OK" }
+ }
+ }
+ },
+ "/api/account/notifications": {
+ "get": {
+ "tags": ["Account"],
+ "summary": "Notifications list",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }
+ ],
+ "responses": {
+ "200": {
+ "description": "Paginated notifications",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "page": { "type": "integer" },
+ "total": { "type": "integer" },
+ "totalPages": { "type": "integer" },
+ "results": { "type": "array", "items": { "$ref": "#/components/schemas/Notification" } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/account/password": {
+ "post": {
+ "tags": ["Account"],
+ "summary": "Change password",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "currentPassword": { "type": "string" },
+ "newPassword": { "type": "string", "minLength": 8 }
+ },
+ "required": ["currentPassword", "newPassword"]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Password changed", "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string" } } } } } }
+ }
+ }
+ },
+ "/api/badges": {
+ "get": {
+ "tags": ["Badges"],
+ "summary": "List badges",
+ "x-auth": "none",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "responses": {
+ "200": {
+ "description": "Badges",
+ "content": {
+ "application/json": {
+ "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Badge" } }
+ }
+ }
+ }
+ }
+ }
+ },
+
+ "/api/deals/users/{userName}/deals": {
+ "get": {
+ "tags": ["Deals"],
+ "summary": "User deals (public or my)",
+ "x-auth": "optional",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ {},
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "userName", "in": "path", "required": true, "schema": { "type": "string" } },
+ { "name": "q", "in": "query", "schema": { "type": "string" } },
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } }
+ ],
+ "responses": {
+ "200": { "description": "Paginated deals", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedDeals" } } } }
+ }
+ }
+ },
+ "/api/deals/me/deals": {
+ "get": {
+ "tags": ["Deals"],
+ "summary": "My deals",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } }
+ ],
+ "responses": {
+ "200": { "description": "Paginated deals", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedDeals" } } } }
+ }
+ }
+ },
+ "/api/deals/new": {
+ "get": {
+ "tags": ["Deals"],
+ "summary": "New deals",
+ "x-auth": "optional",
+ "x-api-key": "required",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "apiKeyAuth": [], "bearerAuth": [] },
+ { "apiKeyAuth": [], "cookieAuth": [] },
+ { "apiKeyAuth": [] }
+ ],
+ "parameters": [
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } }
+ ],
+ "responses": {
+ "200": { "description": "Paginated deals", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedDeals" } } } }
+ }
+ }
+ },
+ "/api/deals/hot": {
+ "get": {
+ "tags": ["Deals"],
+ "summary": "Hot deals",
+ "x-auth": "optional",
+ "x-api-key": "required",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "apiKeyAuth": [], "bearerAuth": [] },
+ { "apiKeyAuth": [], "cookieAuth": [] },
+ { "apiKeyAuth": [] }
+ ],
+ "parameters": [
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } }
+ ],
+ "responses": {
+ "200": { "description": "Paginated deals", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedDeals" } } } }
+ }
+ }
+ },
+ "/api/deals/trending": {
+ "get": {
+ "tags": ["Deals"],
+ "summary": "Trending deals",
+ "x-auth": "optional",
+ "x-api-key": "required",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "apiKeyAuth": [], "bearerAuth": [] },
+ { "apiKeyAuth": [], "cookieAuth": [] },
+ { "apiKeyAuth": [] }
+ ],
+ "parameters": [
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } }
+ ],
+ "responses": {
+ "200": { "description": "Paginated deals", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedDeals" } } } }
+ }
+ }
+ },
+ "/api/deals": {
+ "post": {
+ "tags": ["Deals"],
+ "summary": "Create deal",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "title": { "type": "string" },
+ "description": { "type": "string" },
+ "url": { "type": "string" },
+ "price": { "type": "number" },
+ "originalPrice": { "type": "number" },
+ "shippingPrice": { "type": "number" },
+ "couponCode": { "type": "string" },
+ "location": { "type": "string" },
+ "discountType": { "type": "string" },
+ "discountValue": { "type": "number" },
+ "sellerName": { "type": "string" },
+ "customSeller": { "type": "string" },
+ "images": { "type": "array", "items": { "type": "string", "format": "binary" } }
+ },
+ "required": ["title"]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Deal detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DealDetail" } } } }
+ }
+ }
+ },
+ "/api/deals/search/suggest": {
+ "get": {
+ "tags": ["Deals"],
+ "summary": "Deal search suggestions",
+ "x-auth": "optional",
+ "x-api-key": "none",
+ "x-deps": { "db": false, "redis": true },
+ "security": [
+ {},
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "q", "in": "query", "schema": { "type": "string" } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 8 } }
+ ],
+ "responses": {
+ "200": { "description": "DealCard[]", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DealCard" } } } } }
+ }
+ }
+ },
+ "/api/deals/url": {
+ "post": {
+ "tags": ["Deals"],
+ "summary": "Resolve deal URL",
+ "x-auth": "optional",
+ "x-api-key": "required",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "apiKeyAuth": [], "bearerAuth": [] },
+ { "apiKeyAuth": [], "cookieAuth": [] },
+ { "apiKeyAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": { "dealId": { "type": "integer" } },
+ "required": ["dealId"]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "URL", "content": { "application/json": { "schema": { "type": "object", "properties": { "url": { "type": "string", "nullable": true } } } } } }
+ }
+ }
+ },
+ "/api/deals/{id}/report": {
+ "post": {
+ "tags": ["Deals"],
+ "summary": "Report deal",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "type": "object", "properties": { "reason": { "type": "string" }, "note": { "type": "string" } } }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Report created", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ }
+ },
+ "/api/deals/{id}/save": {
+ "post": {
+ "tags": ["Deals"],
+ "summary": "Save deal",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "responses": {
+ "200": { "description": "Saved", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ },
+ "delete": {
+ "tags": ["Deals"],
+ "summary": "Unsave deal",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "responses": {
+ "200": { "description": "OK" }
+ }
+ }
+ },
+ "/api/deals/saved": {
+ "get": {
+ "tags": ["Deals"],
+ "summary": "Saved deals",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }
+ ],
+ "responses": {
+ "200": { "description": "Paginated deals", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedDeals" } } } }
+ }
+ }
+ },
+ "/api/deals/widgets/best": {
+ "get": {
+ "tags": ["Deals"],
+ "summary": "Best deals widget",
+ "x-auth": "optional",
+ "x-api-key": "required",
+ "x-deps": { "db": false, "redis": true },
+ "security": [
+ { "apiKeyAuth": [], "bearerAuth": [] },
+ { "apiKeyAuth": [], "cookieAuth": [] },
+ { "apiKeyAuth": [] }
+ ],
+ "parameters": [
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 5 } }
+ ],
+ "responses": {
+ "200": { "description": "Widget data", "content": { "application/json": { "schema": { "type": "object", "properties": { "hotDay": { "type": "array", "items": { "$ref": "#/components/schemas/DealCard" } }, "hotWeek": { "type": "array", "items": { "$ref": "#/components/schemas/DealCard" } }, "hotMonth": { "type": "array", "items": { "$ref": "#/components/schemas/DealCard" } } } } } } }
+ }
+ }
+ },
+ "/api/deals/search": {
+ "get": {
+ "tags": ["Deals"],
+ "summary": "Search deals",
+ "x-auth": "optional",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ {},
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "q", "in": "query", "schema": { "type": "string" } },
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } }
+ ],
+ "responses": {
+ "200": { "description": "Paginated deals", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedDeals" } } } }
+ }
+ }
+ },
+ "/api/deals/top": {
+ "get": {
+ "tags": ["Deals"],
+ "summary": "Top deals by range",
+ "x-auth": "optional",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ {},
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "range", "in": "query", "schema": { "type": "string", "enum": ["day", "week", "month"], "default": "day" } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 6 } }
+ ],
+ "responses": {
+ "200": { "description": "DealCard[]", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DealCard" } } } } }
+ }
+ }
+ },
+ "/api/deals/engagement": {
+ "post": {
+ "tags": ["Deals"],
+ "summary": "Engagement (my votes)",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "type": "object", "properties": { "ids": { "type": "array", "items": { "type": "integer" } } }, "required": ["ids"] }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Engagement list", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "integer" }, "myVote": { "type": "integer" } } } } } } }
+ }
+ }
+ },
+ "/api/deals/{id}": {
+ "get": {
+ "tags": ["Deals"],
+ "summary": "Deal detail",
+ "x-auth": "optional",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ {},
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "responses": {
+ "200": { "description": "Deal detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DealDetail" } } } },
+ "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
+ }
+ }
+ },
+
+ "/api/comments/{dealId}": {
+ "get": {
+ "tags": ["Comments"],
+ "summary": "List comments",
+ "x-auth": "optional",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ {},
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "dealId", "in": "path", "required": true, "schema": { "type": "integer" } },
+ { "name": "parentId", "in": "query", "schema": { "type": "integer", "nullable": true } },
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } },
+ { "name": "sort", "in": "query", "schema": { "type": "string", "enum": ["NEW", "TOP"], "default": "NEW" } }
+ ],
+ "responses": {
+ "200": { "description": "Paginated comments", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ }
+ },
+ "/api/comments": {
+ "post": {
+ "tags": ["Comments"],
+ "summary": "Create comment",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "type": "object", "properties": { "dealId": { "type": "integer" }, "text": { "type": "string" }, "parentId": { "type": "integer", "nullable": true } }, "required": ["dealId", "text"] }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Comment", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ }
+ },
+ "/api/comments/{id}": {
+ "delete": {
+ "tags": ["Comments"],
+ "summary": "Delete comment",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "responses": {
+ "200": { "description": "Deleted" },
+ "403": { "description": "Forbidden" },
+ "404": { "description": "Not found" }
+ }
+ }
+ },
+
+ "/api/comment-likes": {
+ "post": {
+ "tags": ["Comments"],
+ "summary": "Like/unlike comment",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "type": "object", "properties": { "commentId": { "type": "integer" }, "like": { "type": "boolean" } }, "required": ["commentId", "like"] }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Like result", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ }
+ },
+
+ "/api/vote": {
+ "post": {
+ "tags": ["Votes"],
+ "summary": "Vote deal",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "type": "object", "properties": { "dealId": { "type": "integer" }, "voteType": { "type": "integer" }, "dealVote": { "type": "integer" } }, "required": ["dealId", "voteType"] }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Vote result", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ }
+ },
+ "/api/vote/{dealId}": {
+ "get": {
+ "tags": ["Votes"],
+ "summary": "List votes for deal",
+ "x-auth": "none",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "parameters": [
+ { "name": "dealId", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "responses": {
+ "200": { "description": "Votes", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ }
+ },
+
+ "/api/deal-votes": {
+ "post": { "$ref": "#/paths/~1api~1vote/post" }
+ },
+ "/api/deal-votes/{dealId}": {
+ "get": { "$ref": "#/paths/~1api~1vote~1{dealId}/get" }
+ },
+
+ "/api/users/{userName}": {
+ "get": {
+ "tags": ["Users"],
+ "summary": "User profile",
+ "x-auth": "none",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "parameters": [
+ { "name": "userName", "in": "path", "required": true, "schema": { "type": "string" } }
+ ],
+ "responses": {
+ "200": { "description": "Profile", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ }
+ },
+ "/api/users/{userName}/comments": {
+ "get": {
+ "tags": ["Users"],
+ "summary": "User comments",
+ "x-auth": "none",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "parameters": [
+ { "name": "userName", "in": "path", "required": true, "schema": { "type": "string" } },
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }
+ ],
+ "responses": {
+ "200": { "description": "Paginated comments", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ }
+ },
+ "/api/users/{userName}/deals": {
+ "get": {
+ "tags": ["Users"],
+ "summary": "User deals",
+ "x-auth": "optional",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ {},
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "userName", "in": "path", "required": true, "schema": { "type": "string" } },
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }
+ ],
+ "responses": {
+ "200": { "description": "Paginated deals", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedDeals" } } } }
+ }
+ }
+ },
+
+ "/api/user/{userName}": { "get": { "$ref": "#/paths/~1api~1users~1{userName}/get" } },
+ "/api/user/{userName}/comments": { "get": { "$ref": "#/paths/~1api~1users~1{userName}~1comments/get" } },
+ "/api/user/{userName}/deals": { "get": { "$ref": "#/paths/~1api~1users~1{userName}~1deals/get" } },
+
+ "/api/seller/from-link": {
+ "post": {
+ "tags": ["Seller"],
+ "summary": "Resolve seller from URL",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": { "schema": { "type": "object", "properties": { "url": { "type": "string" } }, "required": ["url"] } }
+ }
+ },
+ "responses": {
+ "200": { "description": "Seller lookup", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ }
+ },
+ "/api/seller": {
+ "get": {
+ "tags": ["Seller"],
+ "summary": "Active sellers",
+ "x-auth": "none",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "responses": {
+ "200": { "description": "Seller list", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "object" } } } } }
+ }
+ }
+ },
+ "/api/seller/{sellerName}": {
+ "get": {
+ "tags": ["Seller"],
+ "summary": "Seller detail",
+ "x-auth": "none",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "parameters": [
+ { "name": "sellerName", "in": "path", "required": true, "schema": { "type": "string" } }
+ ],
+ "responses": {
+ "200": { "description": "Seller detail", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ }
+ },
+ "/api/seller/{sellerName}/deals": {
+ "get": {
+ "tags": ["Seller"],
+ "summary": "Seller deals",
+ "x-auth": "optional",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ {},
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "sellerName", "in": "path", "required": true, "schema": { "type": "string" } },
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } }
+ ],
+ "responses": {
+ "200": { "description": "Paginated deals", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedDeals" } } } }
+ }
+ }
+ },
+
+ "/api/category/{slug}": {
+ "get": {
+ "tags": ["Category"],
+ "summary": "Category detail",
+ "x-auth": "none",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "parameters": [
+ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
+ ],
+ "responses": {
+ "200": { "description": "Category detail", "content": { "application/json": { "schema": { "type": "object" } } } }
+ }
+ }
+ },
+ "/api/category/{slug}/deals": {
+ "get": {
+ "tags": ["Category"],
+ "summary": "Category deals",
+ "x-auth": "optional",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ {},
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } },
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } }
+ ],
+ "responses": {
+ "200": { "description": "Paginated deals", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedDeals" } } } }
+ }
+ }
+ },
+
+ "/api/mod/deals/pending": {
+ "get": {
+ "tags": ["Mod"],
+ "summary": "Pending deals",
+ "description": "Requires MOD role.",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "q", "in": "query", "schema": { "type": "string" } },
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 10 } }
+ ],
+ "responses": {
+ "200": { "description": "DealCard[]", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DealCard" } } } } }
+ }
+ }
+ },
+ "/api/mod/deals/{id}/approve": {
+ "post": {
+ "tags": ["Mod"],
+ "summary": "Approve deal",
+ "description": "Requires MOD role.",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "responses": { "200": { "description": "Status updated" } }
+ }
+ },
+ "/api/mod/deals/{id}/reject": { "post": { "$ref": "#/paths/~1api~1mod~1deals~1{id}~1approve/post" } },
+ "/api/mod/deals/{id}/expire": { "post": { "$ref": "#/paths/~1api~1mod~1deals~1{id}~1approve/post" } },
+ "/api/mod/deals/{id}/unexpire": { "post": { "$ref": "#/paths/~1api~1mod~1deals~1{id}~1approve/post" } },
+ "/api/mod/deals/{id}/detail": {
+ "get": {
+ "tags": ["Mod"],
+ "summary": "Deal detail for mod",
+ "description": "Requires MOD role.",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "responses": { "200": { "description": "Deal detail + aiReview", "content": { "application/json": { "schema": { "type": "object" } } } } }
+ }
+ },
+ "/api/mod/deals/{id}": {
+ "patch": {
+ "tags": ["Mod"],
+ "summary": "Update deal (mod)",
+ "description": "Requires MOD role.",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": true },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
+ "responses": { "200": { "description": "Updated deal", "content": { "application/json": { "schema": { "type": "object" } } } } }
+ }
+ },
+ "/api/mod/sellers": {
+ "get": {
+ "tags": ["Mod"],
+ "summary": "List sellers",
+ "description": "Requires MOD role.",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "responses": { "200": { "description": "Seller list", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "object" } } } } } }
+ }
+ },
+ "/api/mod/categories": {
+ "get": {
+ "tags": ["Mod"],
+ "summary": "List categories",
+ "description": "Requires MOD role.",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "responses": { "200": { "description": "Category list", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "object" } } } } } }
+ }
+ },
+ "/api/mod/badges": {
+ "post": {
+ "tags": ["Mod"],
+ "summary": "Create badge",
+ "description": "Requires MOD role.",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "iconUrl": { "type": "string", "nullable": true },
+ "description": { "type": "string", "nullable": true }
+ },
+ "required": ["name"]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Badge", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Badge" } } } }
+ }
+ }
+ },
+ "/api/mod/badges/{id}": {
+ "patch": {
+ "tags": ["Mod"],
+ "summary": "Update badge",
+ "description": "Requires MOD role.",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "iconUrl": { "type": "string", "nullable": true },
+ "description": { "type": "string", "nullable": true }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Badge", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Badge" } } } }
+ }
+ }
+ },
+ "/api/mod/badges/assign": {
+ "post": {
+ "tags": ["Mod"],
+ "summary": "Assign badge to user",
+ "description": "Requires MOD role.",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "userId": { "type": "integer" },
+ "badgeId": { "type": "integer" },
+ "earnedAt": { "type": "string" }
+ },
+ "required": ["userId", "badgeId"]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Assignment",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "userId": { "type": "integer" },
+ "badgeId": { "type": "integer" },
+ "earnedAt": { "type": "string" }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": ["Mod"],
+ "summary": "Remove badge from user",
+ "description": "Requires MOD role.",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "userId": { "type": "integer" },
+ "badgeId": { "type": "integer" }
+ },
+ "required": ["userId", "badgeId"]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Removed", "content": { "application/json": { "schema": { "type": "object", "properties": { "removed": { "type": "boolean" } } } } } }
+ }
+ }
+ },
+ "/api/mod/deals/reports": {
+ "get": {
+ "tags": ["Mod"],
+ "summary": "Deal reports",
+ "description": "Requires MOD role.",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": true, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "parameters": [
+ { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
+ { "name": "status", "in": "query", "schema": { "type": "string" } },
+ { "name": "dealId", "in": "query", "schema": { "type": "integer" } },
+ { "name": "userId", "in": "query", "schema": { "type": "integer" } }
+ ],
+ "responses": { "200": { "description": "Reports", "content": { "application/json": { "schema": { "type": "object" } } } } }
+ }
+ },
+
+ "/api/uploads/image": {
+ "post": {
+ "tags": ["Uploads"],
+ "summary": "Upload image",
+ "x-auth": "required",
+ "x-api-key": "none",
+ "x-deps": { "db": false, "redis": false },
+ "security": [
+ { "bearerAuth": [] },
+ { "cookieAuth": [] }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "properties": { "file": { "type": "string", "format": "binary" } },
+ "required": ["file"]
+ }
+ }
+ }
+ },
+ "responses": { "200": { "description": "URL", "content": { "application/json": { "schema": { "type": "object", "properties": { "url": { "type": "string" } } } } } } }
+ }
+ }
+ }
+}
diff --git a/docs/swagger.html b/docs/swagger.html
new file mode 100644
index 0000000..82aa673
--- /dev/null
+++ b/docs/swagger.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ HotTRDeals API Docs
+
+
+
+
+
+
+
+
diff --git a/jobs/dbSync.queue.js b/jobs/dbSync.queue.js
new file mode 100644
index 0000000..1666a3d
--- /dev/null
+++ b/jobs/dbSync.queue.js
@@ -0,0 +1,20 @@
+const { Queue } = require("bullmq")
+const { getRedisConnectionOptions } = require("../services/redis/connection")
+
+const connection = getRedisConnectionOptions()
+const queue = new Queue("db-sync", { connection })
+
+async function ensureDbSyncRepeatable() {
+ return queue.add(
+ "db-sync-batch",
+ {},
+ {
+ jobId: "db-sync-batch",
+ repeat: { every: 30000 },
+ removeOnComplete: true,
+ removeOnFail: 200,
+ }
+ )
+}
+
+module.exports = { queue, connection, ensureDbSyncRepeatable }
diff --git a/jobs/dealClassification.queue.js b/jobs/dealClassification.queue.js
index 0a24e5c..74222ed 100644
--- a/jobs/dealClassification.queue.js
+++ b/jobs/dealClassification.queue.js
@@ -1,9 +1,7 @@
const { Queue } = require("bullmq")
+const { getRedisConnectionOptions } = require("../services/redis/connection")
-const connection = {
- host: process.env.REDIS_HOST ,
- port: Number(process.env.REDIS_PORT ),
-}
+const connection = getRedisConnectionOptions()
const queue = new Queue("deal-classification", { connection })
diff --git a/jobs/hotDealList.queue.js b/jobs/hotDealList.queue.js
new file mode 100644
index 0000000..1a990d4
--- /dev/null
+++ b/jobs/hotDealList.queue.js
@@ -0,0 +1,20 @@
+const { Queue } = require("bullmq")
+const { getRedisConnectionOptions } = require("../services/redis/connection")
+
+const connection = getRedisConnectionOptions()
+const queue = new Queue("hotdeal-list", { connection })
+
+async function ensureHotDealListRepeatable() {
+ return queue.add(
+ "build-hotdeal-list",
+ {},
+ {
+ jobId: "hotdeal-list-builder",
+ repeat: { every: 30000 },
+ removeOnComplete: true,
+ removeOnFail: 100,
+ }
+ )
+}
+
+module.exports = { queue, connection, ensureHotDealListRepeatable }
diff --git a/jobs/newDealList.queue.js b/jobs/newDealList.queue.js
new file mode 100644
index 0000000..fe2c17f
--- /dev/null
+++ b/jobs/newDealList.queue.js
@@ -0,0 +1,20 @@
+const { Queue } = require("bullmq")
+const { getRedisConnectionOptions } = require("../services/redis/connection")
+
+const connection = getRedisConnectionOptions()
+const queue = new Queue("newdeal-list", { connection })
+
+async function ensureNewDealListRepeatable() {
+ return queue.add(
+ "build-newdeal-list",
+ {},
+ {
+ jobId: "newdeal-list-builder",
+ repeat: { every: 30000 },
+ removeOnComplete: true,
+ removeOnFail: 100,
+ }
+ )
+}
+
+module.exports = { queue, connection, ensureNewDealListRepeatable }
diff --git a/jobs/trendingDealList.queue.js b/jobs/trendingDealList.queue.js
new file mode 100644
index 0000000..ab46ad4
--- /dev/null
+++ b/jobs/trendingDealList.queue.js
@@ -0,0 +1,20 @@
+const { Queue } = require("bullmq")
+const { getRedisConnectionOptions } = require("../services/redis/connection")
+
+const connection = getRedisConnectionOptions()
+const queue = new Queue("trendingdeal-list", { connection })
+
+async function ensureTrendingDealListRepeatable() {
+ return queue.add(
+ "build-trendingdeal-list",
+ {},
+ {
+ jobId: "trendingdeal-list-builder",
+ repeat: { every: 30000 },
+ removeOnComplete: true,
+ removeOnFail: 100,
+ }
+ )
+}
+
+module.exports = { queue, connection, ensureTrendingDealListRepeatable }
diff --git a/middleware/optionalAuth.js b/middleware/optionalAuth.js
index eb4b3d2..c521ff7 100644
--- a/middleware/optionalAuth.js
+++ b/middleware/optionalAuth.js
@@ -2,10 +2,12 @@ const jwt = require("jsonwebtoken")
function getBearerToken(req) {
const h = req.headers.authorization
- if (!h) return null
- const [type, token] = h.split(" ")
- if (type !== "Bearer" || !token) return null
- return token
+ if (h) {
+ const [type, token] = h.split(" ")
+ if (type === "Bearer" && token) return token
+ }
+ const cookieToken = req.cookies?.at
+ return cookieToken || null
}
module.exports = function optionalAuth(req, res, next) {
diff --git a/middleware/requireApiKey.js b/middleware/requireApiKey.js
new file mode 100644
index 0000000..e8b98e6
--- /dev/null
+++ b/middleware/requireApiKey.js
@@ -0,0 +1,15 @@
+function requireApiKey(req, res, next) {
+ const expected = process.env.FRONTEND_API_KEY
+ const provided = req.headers["x-api-key"]
+
+ if (!expected) {
+ return res.status(500).json({ error: "API key not configured" })
+ }
+ if (!provided || String(provided) !== String(expected)) {
+ return res.status(401).json({ error: "Unauthorized" })
+ }
+
+ return next()
+}
+
+module.exports = requireApiKey
diff --git a/middleware/requireAuth.js b/middleware/requireAuth.js
index f53fabe..2da053d 100644
--- a/middleware/requireAuth.js
+++ b/middleware/requireAuth.js
@@ -1,14 +1,17 @@
const jwt = require("jsonwebtoken")
+const { getOrCacheUserModeration } = require("../services/redis/userModerationCache.service")
function getBearerToken(req) {
const h = req.headers.authorization
- if (!h) return null
- const [type, token] = h.split(" ")
- if (type !== "Bearer" || !token) return null
- return token
+ if (h) {
+ const [type, token] = h.split(" ")
+ if (type === "Bearer" && token) return token
+ }
+ const cookieToken = req.cookies?.at
+ return cookieToken || null
}
-module.exports = function requireAuth(req, res, next) {
+module.exports = async function requireAuth(req, res, next) {
const token = getBearerToken(req)
if (!token) return res.status(401).json({ error: "Token yok" })
@@ -22,6 +25,12 @@ module.exports = function requireAuth(req, res, next) {
}
if (!req.auth.userId) return res.status(401).json({ error: "Token geçersiz" })
+
+ const moderation = await getOrCacheUserModeration(req.auth.userId)
+ if (moderation?.disabledAt) {
+ return res.status(403).json({ error: "Hesap devre disi" })
+ }
+
next()
} catch (err) {
return res.status(401).json({ error: "Token geçersiz" })
diff --git a/middleware/requireNotRestricted.js b/middleware/requireNotRestricted.js
new file mode 100644
index 0000000..1d301f4
--- /dev/null
+++ b/middleware/requireNotRestricted.js
@@ -0,0 +1,37 @@
+const { getOrCacheUserModeration } = require("../services/redis/userModerationCache.service")
+
+function parseDate(value) {
+ if (!value) return null
+ const d = new Date(value)
+ return Number.isNaN(d.getTime()) ? null : d
+}
+
+function isActiveUntil(value) {
+ const dt = parseDate(value)
+ return dt ? dt.getTime() > Date.now() : false
+}
+
+module.exports = function requireNotRestricted(options = {}) {
+ const { checkMute = false, checkSuspend = false } = options
+
+ return async (req, res, next) => {
+ if (!req.auth?.userId) return res.status(401).json({ error: "Token yok" })
+ try {
+ const state = await getOrCacheUserModeration(req.auth.userId)
+ if (!state) return res.status(401).json({ error: "Kullanici bulunamadi" })
+
+ if (state.disabledAt) {
+ return res.status(403).json({ error: "Hesap devre disi" })
+ }
+ if (checkSuspend && isActiveUntil(state.suspendedUntil)) {
+ return res.status(403).json({ error: "Hesap gecici olarak kisitli" })
+ }
+ if (checkMute && isActiveUntil(state.mutedUntil)) {
+ return res.status(403).json({ error: "Yorum yapma kisitli" })
+ }
+ return next()
+ } catch (err) {
+ return res.status(500).json({ error: "Sunucu hatasi" })
+ }
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index e2b8964..4238367 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@prisma/client": "^6.18.0",
"@shared/contracts": "file:../Contracts",
"@supabase/supabase-js": "^2.78.0",
+ "axios": "^1.11.0",
"bcryptjs": "^3.0.2",
"bullmq": "^5.67.0",
"contracts": "^0.4.0",
@@ -1071,6 +1072,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
+ "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -1294,6 +1312,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/commander": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
@@ -1473,6 +1503,15 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -1671,6 +1710,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -1780,6 +1834,63 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/form-data/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/form-data/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1919,6 +2030,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -2670,6 +2796,12 @@
"node": ">= 0.10"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
diff --git a/package.json b/package.json
index ab18bc5..235effa 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"@prisma/client": "^6.18.0",
"@shared/contracts": "file:../Contracts",
"@supabase/supabase-js": "^2.78.0",
+ "axios": "^1.11.0",
"bcryptjs": "^3.0.2",
"bullmq": "^5.67.0",
"contracts": "^0.4.0",
diff --git a/prisma/categories.json b/prisma/categories.json
index 5e945a0..10f0bcb 100644
--- a/prisma/categories.json
+++ b/prisma/categories.json
@@ -1,242 +1,512 @@
[
- { "id": 0, "name": "Undefined", "slug": "undefined", "parentId": null, "description": "Henüz sınıflandırılmamış içerikler için geçici kategori." },
+ { "id": 100, "name": "Elektronik & Teknoloji", "slug": "electronics-tech", "parentId": null, "description": "Akıllı telefonlar, bilgisayarlar, ses ve görüntü sistemleri, giyilebilir teknoloji ve akıllı ev ürünleri dahil tüm teknolojik cihazlar ve aksesuarları." },
+ { "id": 200, "name": "Ev & Yaşam", "slug": "home-living", "parentId": null, "description": "Mobilya, dekorasyon, mutfak eşyaları, ev tekstili, temizlik ürünleri ve bahçe/dış mekan ürünleri." },
+ { "id": 300, "name": "Giyim & Moda", "slug": "fashion-apparel", "parentId": null, "description": "Kadın, erkek ve çocuk giyim, ayakkabı, çanta, takı ve diğer moda aksesuarları." },
+ { "id": 400, "name": "Güzellik & Kişisel Bakım", "slug": "beauty-personal-care", "parentId": null, "description": "Makyaj, cilt bakımı, saç bakımı, parfümler, kişisel hijyen ve tıraş/epilasyon ürünleri." },
+ { "id": 500, "name": "Gıda & Market", "slug": "food-groceries", "parentId": null, "description": "Temel gıda ürünleri, taze ürünler, içecekler, atıştırmalıklar, organik ve glutensiz gıdalar." },
+ { "id": 600, "name": "Oyun", "slug": "gaming", "parentId": null, "description": "Oyun konsolları, PC oyunları, dijital oyun içerikleri, oyun aksesuarları ve VR cihazları." },
+ { "id": 700, "name": "Otomotiv & Kendin Yap", "slug": "auto-diy", "parentId": null, "description": "Oto yedek parça, bakım ürünleri, araç aksesuarları, motosiklet ekipmanları ve el aletleri." },
+ { "id": 800, "name": "Spor & Outdoor", "slug": "sports-outdoor", "parentId": null, "description": "Fitness ekipmanları, kamp ve doğa sporları malzemeleri, bisiklet, su sporları ve sporcu besinleri." },
+ { "id": 900, "name": "Bebek & Çocuk", "slug": "baby-kids", "parentId": null, "description": "Bebek bezleri, mamalar, oyuncaklar, bebek araç gereçleri, çocuk giyim ve bebek odası mobilyaları." },
+ { "id": 1000, "name": "Kitap, Medya & Eğlence", "slug": "books-media-entertainment", "parentId": null, "description": "Basılı ve dijital kitaplar, filmler, müzik, dergiler ve dijital içerik abonelikleri." },
+ { "id": 1100, "name": "Ofis & Kırtasiye", "slug": "office-stationery", "parentId": null, "description": "Ofis malzemeleri, kırtasiye ürünleri, defterler, kalemler ve okul malzemeleri." },
+ { "id": 1200, "name": "Hizmetler & Seyahat", "slug": "services-travel", "parentId": null, "description": "İnternet ve mobil tarifeler, seyahat fırsatları, eğitim kursları ve ev hizmetleri." },
+ { "id": 1300, "name": "Sağlık & Wellness", "slug": "health-wellness", "parentId": null, "description": "Vitaminler, takviyeler, sporcu besinleri, medikal malzemeler ve kişisel sağlık cihazları." },
+ { "id": 1400, "name": "Evcil Hayvan Ürünleri", "slug": "pets", "parentId": null, "description": "Kedi, köpek ve diğer evcil hayvanlar için mamalar, bakım ürünleri, oyuncaklar ve aksesuarlar." },
+ { "id": 1500, "name": "Hediye Kartları & Kuponlar", "slug": "gift-cards-vouchers", "parentId": null, "description": "Mağaza, restoran, deneyim hediye kartları ve çeşitli indirim kuponları." },
+ { "id": 1600, "name": "Finans & Sigorta", "slug": "finance-insurance", "parentId": null, "description": "Kredi kartı, bankacılık hizmetleri, araç, konut ve seyahat sigortası teklifleri." },
- { "id": 1, "name": "Elektronik", "slug": "electronics", "parentId": 0, "description": "Telefon, bilgisayar, TV, ses sistemleri ve diğer elektronik ürünler." },
- { "id": 2, "name": "Kozmetik", "slug": "beauty", "parentId": 0, "description": "Makyaj, cilt bakımı, saç bakımı, parfüm ve kişisel bakım ürünleri." },
- { "id": 3, "name": "Gıda", "slug": "food", "parentId": 0, "description": "Atıştırmalık, içecek, temel gıda ve market ürünleri." },
- { "id": 4, "name": "Oto", "slug": "auto", "parentId": 0, "description": "Araç bakım, yağ, yedek parça ve oto aksesuar ürünleri." },
- { "id": 5, "name": "Ev & Bahçe", "slug": "home-garden", "parentId": 0, "description": "Ev ihtiyaçları, dekorasyon, temizlik ve bahçe ürünleri." },
+ { "id": 101, "name": "Telefon & Aksesuarları", "slug": "phone-accessories", "parentId": 100, "description": "Akıllı telefonlar, kılıflar, şarj cihazları, powerbankler, ekran koruyucular ve mobil aksesuarlar." },
+ { "id": 102, "name": "Akıllı Telefonlar", "slug": "smartphones", "parentId": 101, "description": "iOS ve Android işletim sistemli, farklı marka ve modelde akıllı telefonlar." },
+ { "id": 103, "name": "Telefon Kılıfları & Kapaklar", "slug": "phone-cases-covers", "parentId": 101, "description": "Silikon, deri, sert kapak gibi farklı malzeme ve tasarımlarda telefon kılıfları." },
+ { "id": 104, "name": "Şarj Aletleri & Kablolar", "slug": "chargers-cables", "parentId": 101, "description": "Hızlı şarj adaptörleri, USB-C, Lightning, Micro USB kablolar ve kablosuz şarj cihazları." },
+ { "id": 105, "name": "Powerbankler", "slug": "powerbanks", "parentId": 101, "description": "Taşınabilir şarj cihazları; farklı kapasite ve hızlı şarj özelliklerine sahip modeller." },
+ { "id": 106, "name": "Ekran Koruyucular", "slug": "screen-protectors", "parentId": 101, "description": "Temperli cam, plastik film ve sıvı ekran koruyucular." },
+ { "id": 107, "name": "Giyilebilir Teknoloji", "slug": "wearable-tech", "parentId": 100, "description": "Akıllı saatler, fitness takip bileklikleri ve diğer giyilebilir akıllı cihazlar." },
+ { "id": 108, "name": "Akıllı Saatler", "slug": "smartwatches", "parentId": 107, "description": "Bildirim, sağlık takibi, spor modları ve uygulama desteği sunan akıllı saatler." },
+ { "id": 109, "name": "Fitness Takip Bileklikleri", "slug": "fitness-trackers", "parentId": 107, "description": "Adım, uyku, nabız, kalori gibi metrikleri izleyen akıllı bileklikler." },
- { "id": 6, "name": "Bilgisayar", "slug": "computers", "parentId": 1, "description": "Masaüstü/dizüstü bilgisayarlar, tabletler ve bilgisayar ekipmanları." },
- { "id": 7, "name": "PC Bileşenleri", "slug": "pc-components", "parentId": 6, "description": "Bilgisayar toplama/yükseltme için işlemci, ekran kartı, RAM, depolama vb." },
- { "id": 8, "name": "RAM", "slug": "pc-ram", "parentId": 7, "description": "Bilgisayar performansını artırmaya yönelik bellek modülleri." },
- { "id": 9, "name": "SSD", "slug": "pc-ssd", "parentId": 7, "description": "Hızlı depolama çözümleri (NVMe/SATA) SSD diskler." },
- { "id": 10, "name": "CPU", "slug": "pc-cpu", "parentId": 7, "description": "Bilgisayar işlemcileri; performans, oyun ve iş kullanımına yönelik modeller." },
- { "id": 11, "name": "GPU", "slug": "pc-gpu", "parentId": 7, "description": "Ekran kartları; oyun, grafik tasarım ve video işleme için." },
+ { "id": 110, "name": "Bilgisayar & Laptop", "slug": "computers-laptops", "parentId": 100, "description": "Dizüstü ve masaüstü bilgisayarlar, tabletler, bilgisayar bileşenleri ve çevre birimleri." },
+ { "id": 111, "name": "Dizüstü Bilgisayarlar", "slug": "laptops", "parentId": 110, "description": "Oyun, iş, öğrenci ve günlük kullanıma uygun dizüstü bilgisayarlar." },
+ { "id": 112, "name": "Masaüstü Bilgisayarlar", "slug": "desktops", "parentId": 110, "description": "Hazır sistemler, iş istasyonları ve oyun odaklı masaüstü bilgisayarlar." },
+ { "id": 113, "name": "Tabletler", "slug": "tablets", "parentId": 110, "description": "Android, iPadOS ve Windows işletim sistemli tabletler ve aksesuarları." },
+ { "id": 114, "name": "Bilgisayar Bileşenleri", "slug": "pc-components", "parentId": 110, "description": "İşlemci, ekran kartı, RAM, depolama, anakart, PSU ve kasa gibi bilgisayar parçaları." },
+ { "id": 115, "name": "İşlemciler (CPU)", "slug": "cpus", "parentId": 114, "description": "Intel ve AMD markalı, farklı çekirdek ve performans seviyelerine sahip işlemciler." },
+ { "id": 116, "name": "Ekran Kartları (GPU)", "slug": "gpus", "parentId": 114, "description": "NVIDIA GeForce ve AMD Radeon serisi ekran kartları; oyun ve grafik tasarımı için." },
+ { "id": 117, "name": "RAM Bellekleri", "slug": "ram", "parentId": 114, "description": "DDR4, DDR5 standartlarında, farklı hız ve kapasitelerdeki bellek modülleri." },
+ { "id": 118, "name": "Dahili Depolama", "slug": "internal-storage", "parentId": 114, "description": "SSD (NVMe, SATA) ve HDD dahili depolama birimleri; hızlı ve geniş kapasite seçenekleri." },
+ { "id": 119, "name": "Anakartlar", "slug": "motherboards", "parentId": 114, "description": "Intel ve AMD yonga setli, farklı form faktörlerinde (ATX, Micro ATX) anakartlar." },
+ { "id": 120, "name": "Güç Kaynakları (PSU)", "slug": "psus", "parentId": 114, "description": "Bilgisayar bileşenlerini besleyen, farklı Watt ve 80 PLUS sertifikalı güç kaynakları." },
+ { "id": 121, "name": "Bilgisayar Kasaları", "slug": "pc-cases", "parentId": 114, "description": "Midi Tower, Full Tower, mini ITX boyutlarında, hava akışı optimize edilmiş kasalar." },
+ { "id": 122, "name": "Soğutma Sistemleri", "slug": "cooling-systems", "parentId": 114, "description": "CPU hava soğutucuları, sıvı soğutma (AIO) sistemleri ve kasa fanları." },
+ { "id": 123, "name": "Çevre Birimleri & Aksesuarlar", "slug": "peripherals-accessories", "parentId": 110, "description": "Monitörler, klavyeler, fareler, web kameraları, hoparlörler ve diğer PC aksesuarları." },
+ { "id": 124, "name": "Monitörler", "slug": "monitors", "parentId": 123, "description": "Oyun monitörleri (yüksek yenileme hızı), profesyonel ve günlük kullanıma uygun ekranlar." },
+ { "id": 125, "name": "Klavyeler", "slug": "keyboards", "parentId": 123, "description": "Mekanik, membran, oyuncu, ergonomik ve kablosuz klavye modelleri." },
+ { "id": 126, "name": "Fareler & Mousepadler", "slug": "mice-mousepads", "parentId": 123, "description": "Oyuncu, optik, lazer, kablolu/kablosuz fareler ve farklı boyutlarda mousepadler." },
+ { "id": 127, "name": "Web Kameraları", "slug": "webcams", "parentId": 123, "description": "Full HD, 2K, 4K çözünürlüklü web kameraları; yayın ve video konferans için." },
+ { "id": 128, "name": "Bilgisayar Hoparlörleri", "slug": "pc-speakers", "parentId": 123, "description": "2.0, 2.1, 5.1 kanal masaüstü hoparlör sistemleri ve soundbarlar." },
+ { "id": 129, "name": "Bilgisayar Mikrofonları", "slug": "pc-microphones", "parentId": 123, "description": "Yayıncı, oyuncu, podcast ve toplantı için PC uyumlu mikrofonlar." },
+ { "id": 130, "name": "USB Hublar & Dock İstasyonları", "slug": "usb-hubs-docks", "parentId": 123, "description": "Port çoğaltıcılar, Type-C hublar ve laptop dock istasyonları." },
+ { "id": 131, "name": "Laptop Çantaları & Kılıfları", "slug": "laptop-bags-sleeves", "parentId": 123, "description": "Dizüstü bilgisayar taşıma çantaları, sırt çantaları ve koruyucu kılıflar." },
- { "id": 12, "name": "Bilgisayar Aksesuarları", "slug": "pc-peripherals", "parentId": 6, "description": "Klavye, mouse, webcam, mikrofon, mousepad gibi çevre birimleri." },
- { "id": 13, "name": "Klavye", "slug": "pc-keyboard", "parentId": 12, "description": "Mekanik/membran, oyuncu ve ofis kullanımına uygun klavyeler." },
- { "id": 14, "name": "Mouse", "slug": "pc-mouse", "parentId": 12, "description": "Kablolu/kablosuz, oyuncu ve günlük kullanım mouse modelleri." },
- { "id": 15, "name": "Monitör", "slug": "pc-monitor", "parentId": 6, "description": "Bilgisayar monitörleri; oyun, ofis ve profesyonel kullanım seçenekleri." },
+ { "id": 132, "name": "Ağ Ürünleri", "slug": "networking", "parentId": 100, "description": "Modemler, routerlar, Wi-Fi genişleticiler, switchler ve ağ aksesuarları." },
+ { "id": 133, "name": "Modemler & Routerlar", "slug": "modems-routers", "parentId": 132, "description": "ADSL, VDSL, Fiber uyumlu modemler, Wi-Fi 6/7 destekli routerlar." },
+ { "id": 134, "name": "Menzil Genişleticiler & Mesh Sistemler", "slug": "wifi-extenders-mesh", "parentId": 132, "description": "Kablosuz ağ menzilini artıran repeaterlar ve tüm evi kapsayan Mesh Wi-Fi sistemleri." },
+ { "id": 135, "name": "Ağ Switchleri", "slug": "network-switches", "parentId": 132, "description": "Kablolu ağ bağlantılarını çoğaltan ve yöneten switch cihazları." },
- { "id": 16, "name": "Makyaj", "slug": "beauty-makeup", "parentId": 2, "description": "Ruj, fondöten, maskara ve diğer makyaj ürünleri." },
- { "id": 17, "name": "Ruj", "slug": "beauty-lipstick", "parentId": 16, "description": "Mat, parlak, likit ve farklı renk seçeneklerinde dudak ürünleri." },
- { "id": 18, "name": "Fondöten", "slug": "beauty-foundation", "parentId": 16, "description": "Cilt tonunu eşitleyen; mat/parlak bitişli fondöten ürünleri." },
- { "id": 19, "name": "Maskara", "slug": "beauty-mascara", "parentId": 16, "description": "Kirpiklere hacim, uzunluk ve kıvrım kazandıran maskaralar." },
+ { "id": 136, "name": "Yazıcı & Tarayıcı", "slug": "printers-scanners", "parentId": 100, "description": "Ev ve ofis kullanımı için yazıcılar, tarayıcılar, toner ve kartuşlar." },
+ { "id": 137, "name": "Yazıcılar", "slug": "printers", "parentId": 136, "description": "Lazer, mürekkep püskürtmeli, çok fonksiyonlu yazıcılar ve fotoğraf yazıcıları." },
+ { "id": 138, "name": "Toner & Kartuş", "slug": "ink-toner", "parentId": 136, "description": "Yazıcılar için orijinal ve uyumlu tonerler, mürekkep kartuşları." },
+ { "id": 139, "name": "Tarayıcılar", "slug": "scanners", "parentId": 136, "description": "Belge ve fotoğraf taraması için flatbed ve ADF (Otomatik Belge Besleyici) tarayıcılar." },
- { "id": 20, "name": "Cilt Bakımı", "slug": "beauty-skincare", "parentId": 2, "description": "Nemlendirici, temizleyici, serum, güneş kremi gibi cilt bakım ürünleri." },
- { "id": 21, "name": "Nemlendirici", "slug": "beauty-moisturizer", "parentId": 20, "description": "Cildi nemlendirip bariyeri destekleyen yüz/vücut nemlendiricileri." },
+ { "id": 140, "name": "Harici Depolama", "slug": "external-storage", "parentId": 100, "description": "Harici diskler, USB bellekler, NAS cihazları ve hafıza kartları." },
+ { "id": 141, "name": "Harici Diskler", "slug": "external-drives", "parentId": 140, "description": "Taşınabilir HDD ve SSD harici depolama cihazları, farklı kapasite ve hızlarda." },
+ { "id": 142, "name": "USB Bellekler", "slug": "usb-flash-drives", "parentId": 140, "description": "Farklı kapasite ve USB standartlarında (2.0, 3.0, 3.1) USB bellekler." },
+ { "id": 143, "name": "NAS Cihazları", "slug": "nas-devices", "parentId": 140, "description": "Ağa bağlı depolama (NAS) sunucuları ve kişisel bulut çözümleri." },
+ { "id": 144, "name": "Hafıza Kartları", "slug": "memory-cards", "parentId": 140, "description": "SD kart, Micro SD kart ve CF kart gibi farklı cihazlar için hafıza kartları." },
- { "id": 22, "name": "Atıştırmalık", "slug": "food-snacks", "parentId": 3, "description": "Cips, kuruyemiş, bisküvi, çikolata ve benzeri atıştırmalıklar." },
- { "id": 23, "name": "Çiğköfte", "slug": "food-cigkofte", "parentId": 22, "description": "Hazır çiğköfte ürünleri ve çiğköfte setleri." },
+ { "id": 145, "name": "Ses & Görüntü Sistemleri", "slug": "audio-video-systems", "parentId": 100, "description": "Kulaklıklar, hoparlörler, televizyonlar, soundbarlar, medya oynatıcılar ve pikaplar." },
+ { "id": 146, "name": "Kulaklıklar", "slug": "headphones-earbuds", "parentId": 145, "description": "Kulak üstü, kulak içi, kablolu, kablosuz, gürültü engelleme özellikli kulaklıklar." },
+ { "id": 147, "name": "TWS Kulaklıklar", "slug": "tws-earbuds", "parentId": 146, "description": "Tam kablosuz (True Wireless Stereo) kulak içi kulaklıklar ve şarj kutuları." },
+ { "id": 148, "name": "Hoparlörler", "slug": "speakers", "parentId": 145, "description": "Bluetooth hoparlörler, parti hoparlörleri, ev sinema sistemleri ve soundbarlar." },
+ { "id": 149, "name": "Bluetooth Hoparlörler", "slug": "bluetooth-speakers", "parentId": 148, "description": "Taşınabilir, suya dayanıklı ve şarj edilebilir Bluetooth hoparlörler." },
+ { "id": 150, "name": "Soundbarlar", "slug": "soundbars", "parentId": 148, "description": "Televizyon sesini iyileştiren, subwoofer'lı veya tek parça soundbar sistemleri." },
+ { "id": 151, "name": "Televizyonlar", "slug": "televisions", "parentId": 145, "description": "LED, QLED, OLED, Smart TV teknolojili, farklı boyut ve çözünürlükteki televizyonlar." },
+ { "id": 152, "name": "Projeksiyon Cihazları", "slug": "projectors", "parentId": 145, "description": "Ev sineması, iş sunumları ve dış mekan kullanımı için projeksiyon cihazları." },
+ { "id": 153, "name": "Medya Oynatıcılar", "slug": "media-players", "parentId": 145, "description": "Android TV Box, Apple TV, Fire TV Stick gibi akış ve medya oynatıcı cihazlar." },
+ { "id": 154, "name": "TV Aksesuarları", "slug": "tv-accessories", "parentId": 145, "description": "Uzaktan kumandalar, TV askı aparatları, HDMI kabloları ve uydu alıcıları." },
+ { "id": 155, "name": "Pikaplar & Plaklar", "slug": "turntables-vinyl", "parentId": 145, "description": "Analog müzik deneyimi için pikaplar, vinyl plaklar ve plak temizleme setleri." },
- { "id": 24, "name": "İçecek", "slug": "food-beverages", "parentId": 3, "description": "Kahve, çay, su, gazlı içecek ve diğer içecek ürünleri." },
- { "id": 25, "name": "Kahve", "slug": "food-coffee", "parentId": 24, "description": "Çekirdek/öğütülmüş, kapsül ve hazır kahve çeşitleri." },
+ { "id": 156, "name": "Kameralar & Fotoğrafçılık", "slug": "cameras-photography", "parentId": 100, "description": "DSLR, aynasız, aksiyon kameraları, drone'lar, lensler ve fotoğraf aksesuarları." },
+ { "id": 157, "name": "Fotoğraf Makineleri", "slug": "photo-cameras", "parentId": 156, "description": "DSLR, aynasız, kompakt ve anında baskı yapan fotoğraf makineleri." },
+ { "id": 158, "name": "Aksiyon Kameraları", "slug": "action-cameras", "parentId": 156, "description": "GoPro ve benzeri suya, darbelere dayanıklı, hareketli çekime uygun aksiyon kameraları." },
+ { "id": 159, "name": "Kamera Lensleri", "slug": "camera-lenses", "parentId": 156, "description": "Prime, zoom, geniş açı, tele ve makro lensler; farklı marka ve modellerde." },
+ { "id": 160, "name": "Dronelar", "slug": "drones", "parentId": 156, "description": "Hava fotoğrafçılığı, video çekimi ve eğlence amaçlı dronelar ve yedek parçaları." },
+ { "id": 161, "name": "Tripodlar & Stabilizatörler", "slug": "tripods-stabilizers", "parentId": 156, "description": "Fotoğraf ve video çekimi için tripodlar, monopodlar ve gimbal stabilizatörler." },
+ { "id": 162, "name": "Kamera Aksesuarları", "slug": "camera-accessories", "parentId": 156, "description": "Kamera çantaları, filtreler, harici flaşlar, bataryalar ve temizlik kitleri." },
- { "id": 26, "name": "Yağlar", "slug": "auto-oils", "parentId": 4, "description": "Motor yağı ve araç için kullanılan diğer yağ çeşitleri." },
- { "id": 27, "name": "Motor Yağı", "slug": "auto-engine-oil", "parentId": 26, "description": "Motoru koruyan; farklı viskozite ve onaylara sahip motor yağları." },
+ { "id": 163, "name": "Akıllı Ev Sistemleri", "slug": "smart-home-systems", "parentId": 100, "description": "Akıllı aydınlatma, prizler, güvenlik kameraları, sensörler ve ev otomasyon cihazları." },
+ { "id": 164, "name": "Akıllı Aydınlatma", "slug": "smart-lighting", "parentId": 163, "description": "Wi-Fi/Zigbee bağlantılı akıllı ampuller, LED şeritler ve aydınlatma sistemleri." },
+ { "id": 165, "name": "Akıllı Prizler", "slug": "smart-plugs", "parentId": 163, "description": "Uygulama ile kontrol edilebilen, zamanlayıcı ve enerji tüketimi takibi özellikli akıllı prizler." },
+ { "id": 166, "name": "Akıllı Güvenlik Kameraları", "slug": "smart-security-cameras", "parentId": 163, "description": "Ev ve ofis için iç/dış mekan IP güvenlik kameraları, bebek monitörleri." },
+ { "id": 167, "name": "Akıllı Sensörler", "slug": "smart-sensors", "parentId": 163, "description": "Hareket, kapı/pencere, sıcaklık/nem, duman ve su kaçağı sensörleri." },
+ { "id": 168, "name": "Akıllı Termostatlar", "slug": "smart-thermostats", "parentId": 163, "description": "Enerji tasarrufu sağlayan, uzaktan kontrol edilebilir akıllı termostatlar." },
+ { "id": 169, "name": "Sesli Asistanlar & Akıllı Ekranlar", "slug": "voice-assistants-smart-displays", "parentId": 163, "description": "Google Nest, Amazon Echo gibi sesli asistan cihazları ve akıllı ekranlar." },
- { "id": 28, "name": "Oto Parçaları", "slug": "auto-parts", "parentId": 4, "description": "Fren, filtre, aydınlatma ve diğer araç yedek parça ürünleri." },
- { "id": 29, "name": "Fren Balatası", "slug": "auto-brake-pads", "parentId": 28, "description": "Araç fren sistemi için ön/arka fren balatası ürünleri." },
+ { "id": 201, "name": "Mobilya", "slug": "furniture", "parentId": 200, "description": "Salon, yatak odası, yemek odası, çalışma odası mobilyaları ve depolama çözümleri." },
+ { "id": 202, "name": "Oturma Odası Mobilyaları", "slug": "living-room-furniture", "parentId": 201, "description": "Koltuk takımları, kanepeler, berjerler, TV üniteleri ve orta sehpalar." },
+ { "id": 203, "name": "Yatak Odası Mobilyaları", "slug": "bedroom-furniture", "parentId": 201, "description": "Yatak, baza, komodin, gardırop, şifonyer ve makyaj masaları." },
+ { "id": 204, "name": "Yemek Odası Mobilyaları", "slug": "dining-room-furniture", "parentId": 201, "description": "Yemek masaları, sandalyeler, konsollar ve vitrinler." },
+ { "id": 205, "name": "Çalışma Odası Mobilyaları", "slug": "home-office-furniture", "parentId": 201, "description": "Çalışma masaları, ofis koltukları, kitaplıklar ve dosya dolapları." },
+ { "id": 206, "name": "Depolama & Düzenleme", "slug": "storage-organization", "parentId": 201, "description": "Raflar, dolaplar, çekmeceler, kutular ve ev düzenleme ürünleri." },
- { "id": 30, "name": "Bahçe", "slug": "home-garden-garden", "parentId": 5, "description": "Bahçe bakımı, sulama ve dış mekân düzenleme ürünleri." },
- { "id": 31, "name": "Sulama", "slug": "garden-irrigation", "parentId": 30, "description": "Hortum, damla sulama, sprinkler ve sulama ekipmanları." },
+ { "id": 207, "name": "Ev Dekorasyonu", "slug": "home-decor", "parentId": 200, "description": "Halılar, tablolar, aynalar, vazolar, mumlar ve diğer dekoratif objeler." },
+ { "id": 208, "name": "Halılar & Kilimler", "slug": "rugs-carpets", "parentId": 207, "description": "Salon, yatak odası, mutfak ve koridor için halılar, kilimler ve paspaslar." },
+ { "id": 209, "name": "Duvar Dekorasyonu", "slug": "wall-decor", "parentId": 207, "description": "Kanvas tablolar, duvar aynaları, dekoratif raflar ve duvar saatleri." },
+ { "id": 210, "name": "Dekoratif Objeler", "slug": "decorative-objects", "parentId": 207, "description": "Vazolar, biblolar, heykeller, şamdanlar ve fotoğraf çerçeveleri." },
+ { "id": 211, "name": "Mumlar & Oda Kokuları", "slug": "candles-room-fragrance", "parentId": 207, "description": "Dekoratif mumlar, kokulu mumlar, difüzörler ve oda spreyleri." },
- { "id": 32, "name": "Telefon & Aksesuarları", "slug": "phone", "parentId": 1, "description": "Akıllı telefonlar ve telefonla ilgili tüm aksesuarlar." },
- { "id": 33, "name": "Akıllı Telefon", "slug": "phone-smartphone", "parentId": 32, "description": "Android/iOS akıllı telefonlar ve farklı marka/model seçenekleri." },
- { "id": 34, "name": "Telefon Kılıfı", "slug": "phone-case", "parentId": 32, "description": "Cihazı koruyan silikon, sert kapak, cüzdan tipi telefon kılıfları." },
- { "id": 35, "name": "Ekran Koruyucu", "slug": "phone-screen-protector", "parentId": 32, "description": "Cam/film ekran koruyucular; çizilme ve darbe koruması sağlar." },
- { "id": 36, "name": "Şarj & Kablo", "slug": "phone-charging", "parentId": 32, "description": "Şarj adaptörü, kablo, hızlı şarj ekipmanları ve aksesuarları." },
- { "id": 37, "name": "Powerbank", "slug": "phone-powerbank", "parentId": 32, "description": "Taşınabilir şarj cihazları; farklı kapasite ve hızlı şarj destekleri." },
+ { "id": 212, "name": "Aydınlatma", "slug": "lighting", "parentId": 200, "description": "Avizeler, lambaderler, masa lambaları, spot ışıklar ve LED aydınlatma çözümleri." },
+ { "id": 213, "name": "Avizeler & Sarkıtlar", "slug": "chandeliers-pendants", "parentId": 212, "description": "Salon, yemek odası ve mutfak için avizeler, sarkıt lambalar ve aplikler." },
+ { "id": 214, "name": "Masa Lambaları", "slug": "table-lamps", "parentId": 212, "description": "Çalışma masası, komodin ve okuma için masa lambaları." },
+ { "id": 215, "name": "Lambaderler", "slug": "floor-lamps", "parentId": 212, "description": "Oturma odası, köşe ve genel aydınlatma için modern ve klasik lambaderler." },
+ { "id": 216, "name": "LED Aydınlatma", "slug": "led-lighting", "parentId": 212, "description": "LED ampuller, şerit LED'ler, spot aydınlatmalar ve akıllı LED çözümleri." },
- { "id": 38, "name": "Giyilebilir Teknoloji", "slug": "wearables", "parentId": 1, "description": "Akıllı saat, bileklik ve sağlık/aktivite takibi yapan cihazlar." },
- { "id": 39, "name": "Akıllı Saat", "slug": "wearables-smartwatch", "parentId": 38, "description": "Bildirim, sağlık takibi ve uygulama desteği sunan akıllı saatler." },
- { "id": 40, "name": "Akıllı Bileklik", "slug": "wearables-band", "parentId": 38, "description": "Adım, uyku, nabız gibi metrikleri takip eden akıllı bileklikler." },
+ { "id": 217, "name": "Mutfak & Yemek", "slug": "kitchen-dining", "parentId": 200, "description": "Tencere/tava, yemek takımları, çatal-bıçak setleri, küçük ev aletleri ve mutfak gereçleri." },
+ { "id": 218, "name": "Tencere & Tava Setleri", "slug": "cookware-sets", "parentId": 217, "description": "Granit, döküm, çelik, teflon tencere ve tava setleri, fırın kapları." },
+ { "id": 219, "name": "Yemek & Kahvaltı Takımları", "slug": "dinner-breakfast-sets", "parentId": 217, "description": "Porselen, seramik, cam yemek takımları ve kahvaltı setleri." },
+ { "id": 220, "name": "Çatal & Bıçak Setleri", "slug": "cutlery-sets", "parentId": 217, "description": "Paslanmaz çelik çatal, bıçak, kaşık setleri ve servis takımları." },
+ { "id": 221, "name": "Bardak & Kadeh Takımları", "slug": "glassware-sets", "parentId": 217, "description": "Su bardakları, çay bardakları, kahve fincanları, kadehler ve kupalar." },
+ { "id": 222, "name": "Mutfak Gereçleri", "slug": "kitchen-utensils", "parentId": 217, "description": "Bıçak setleri, kepçe, spatula, kesme tahtaları, rende ve mutfak tartıları." },
+ { "id": 223, "name": "Saklama Kapları & Termoslar", "slug": "food-storage-thermoses", "parentId": 217, "description": "Yiyecek saklama kapları, erzak kapları, termoslar ve beslenme çantaları." },
+ { "id": 224, "name": "Küçük Ev Aletleri", "slug": "small-appliances", "parentId": 217, "description": "Kahve makineleri, blenderlar, tost makineleri, airfryerlar ve mikrodalga fırınlar." },
+ { "id": 225, "name": "Kahve Makineleri", "slug": "coffee-machines", "parentId": 224, "description": "Filtre kahve, espresso, kapsül ve Türk kahvesi makineleri." },
+ { "id": 226, "name": "Blenderlar & Mutfak Robotları", "slug": "blenders-food-processors", "parentId": 224, "description": "El blenderları, smoothie blenderlar, mutfak robotları ve mikserler." },
+ { "id": 227, "name": "Airfryerlar", "slug": "airfryers", "parentId": 224, "description": "Sağlıklı ve az yağlı pişirme için airfryer cihazları ve aksesuarları." },
+ { "id": 228, "name": "Tost Makineleri & Fritözler", "slug": "toasters-fryers", "parentId": 224, "description": "Ekmek kızartma makineleri, tost makineleri, derin yağ fritözleri." },
- { "id": 41, "name": "Ses & Audio", "slug": "audio", "parentId": 1, "description": "Kulaklık, hoparlör, mikrofon, soundbar ve ses ekipmanları." },
- { "id": 42, "name": "Kulaklık", "slug": "audio-headphones", "parentId": 41, "description": "Kulak üstü, kulak içi, kablolu/kablosuz kulaklık modelleri." },
- { "id": 43, "name": "TWS Kulaklık", "slug": "audio-tws", "parentId": 42, "description": "Tam kablosuz (True Wireless) kulak içi kulaklıklar." },
- { "id": 44, "name": "Bluetooth Hoparlör", "slug": "audio-bt-speaker", "parentId": 41, "description": "Taşınabilir kablosuz hoparlörler; ev ve dış mekân kullanımı için." },
- { "id": 45, "name": "Soundbar", "slug": "audio-soundbar", "parentId": 41, "description": "TV için daha güçlü ve net ses sağlayan soundbar sistemleri." },
- { "id": 46, "name": "Mikrofon", "slug": "audio-microphone", "parentId": 41, "description": "Yayın, toplantı ve kayıt amaçlı masaüstü/yalaka mikrofonlar." },
- { "id": 47, "name": "Plak / Pikap", "slug": "audio-turntable", "parentId": 41, "description": "Vinyl plak ve pikap ürünleri; analog müzik ekipmanları." },
+ { "id": 229, "name": "Beyaz Eşya", "slug": "large-appliances", "parentId": 200, "description": "Buzdolapları, çamaşır makineleri, bulaşık makineleri, fırınlar ve ocaklar." },
+ { "id": 230, "name": "Buzdolapları", "slug": "refrigerators", "parentId": 229, "description": "No Frost, kombi, gardırop tipi, tek kapılı buzdolabı modelleri." },
+ { "id": 231, "name": "Çamaşır Makineleri & Kurutucular", "slug": "washing-machines-dryers", "parentId": 229, "description": "Önden/üstten yüklemeli çamaşır makineleri, kurutma makineleri ve kurutmalı çamaşır makineleri." },
+ { "id": 232, "name": "Bulaşık Makineleri", "slug": "dishwashers", "parentId": 229, "description": "Ankastre ve solo bulaşık makinesi modelleri, farklı program ve kapasitelerde." },
+ { "id": 233, "name": "Fırınlar & Ocaklar", "slug": "ovens-hobs", "parentId": 229, "description": "Ankastre fırınlar, set üstü ocaklar, mikrodalga fırınlar ve davlumbazlar." },
- { "id": 48, "name": "TV & Video", "slug": "tv-video", "parentId": 1, "description": "Televizyonlar, projeksiyonlar, medya oynatıcılar ve TV aksesuarları." },
- { "id": 49, "name": "Televizyon", "slug": "tv", "parentId": 48, "description": "LED/QLED/OLED televizyonlar; farklı boyut ve çözünürlük seçenekleri." },
- { "id": 50, "name": "Projeksiyon", "slug": "projector", "parentId": 48, "description": "Ev sineması ve sunum amaçlı projeksiyon cihazları." },
+ { "id": 234, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 200, "description": "Nevresim takımları, yorgan, battaniye, perde, havlu ve yastıklar." },
+ { "id": 235, "name": "Nevresim Takımları", "slug": "bedding-sets", "parentId": 234, "description": "Tek kişilik, çift kişilik nevresim takımları, çarşaflar ve yastık kılıfları." },
+ { "id": 236, "name": "Yorganlar & Battaniyeler", "slug": "duvets-blankets", "parentId": 234, "description": "Elyaf, pamuk, yün yorganlar, polar, pamuklu ve örgü battaniyeler." },
+ { "id": 237, "name": "Perdeler & Jaluziler", "slug": "curtains-blinds", "parentId": 234, "description": "Tül, fon, stor, zebra perde modelleri ve jaluziler." },
+ { "id": 238, "name": "Havlu Setleri", "slug": "towel-sets", "parentId": 234, "description": "Banyo, el, yüz ve plaj havlusu setleri." },
+ { "id": 239, "name": "Yastıklar & Minderler", "slug": "pillows-cushions", "parentId": 234, "description": "Uyku yastıkları, dekoratif minderler ve koltuk şalları." },
- { "id": 51, "name": "Medya Oynatıcı", "slug": "tv-media-player", "parentId": 48, "description": "TV’ye bağlanıp uygulama/film/dizi oynatmayı sağlayan medya cihazları (Android TV box vb.)." },
- { "id": 52, "name": "TV Aksesuarları", "slug": "tv-accessories", "parentId": 48, "description": "TV için kumanda, askı aparatı, kablo, stand ve benzeri yardımcı aksesuarlar." },
- { "id": 53, "name": "Uydu Alıcısı / Receiver", "slug": "tv-receiver", "parentId": 48, "description": "Uydu yayını izlemek için receiver/uydu alıcısı ve ilgili cihazlar." },
+ { "id": 240, "name": "Temizlik & Çamaşır Bakımı", "slug": "cleaning-laundry-care", "parentId": 200, "description": "Süpürgeler, temizlik malzemeleri, deterjanlar ve ütü ürünleri." },
+ { "id": 241, "name": "Süpürgeler", "slug": "vacuum-cleaners", "parentId": 240, "description": "Robot süpürgeler, dikey süpürgeler, toz torbalı/torbasız süpürgeler ve buharlı temizleyiciler." },
+ { "id": 242, "name": "Temizlik Malzemeleri", "slug": "cleaning-supplies", "parentId": 240, "description": "Yüzey temizleyiciler, çamaşır suyu, cam temizleyici, süngerler ve bezler." },
+ { "id": 243, "name": "Çamaşır Deterjanları & Yumuşatıcılar", "slug": "laundry-detergents-softeners", "parentId": 240, "description": "Sıvı, toz çamaşır deterjanları, yumuşatıcılar ve leke çıkarıcılar." },
+ { "id": 244, "name": "Ütü & Ütü Masaları", "slug": "irons-ironing-boards", "parentId": 240, "description": "Buharlı ütüler, kazanlı ütüler, seyahat ütüleri ve ütü masaları." },
- { "id": 54, "name": "Konsollar", "slug": "console", "parentId": 191, "description": "PlayStation, Xbox, Nintendo konsolları; konsol oyunları ve aksesuarları." },
- { "id": 55, "name": "PlayStation", "slug": "console-playstation", "parentId": 54, "description": "PlayStation konsolları, oyunları, üyelikleri ve PlayStation aksesuarları." },
- { "id": 56, "name": "Xbox", "slug": "console-xbox", "parentId": 54, "description": "Xbox konsolları, oyunları, Game Pass/abonelik ve Xbox aksesuarları." },
- { "id": 57, "name": "Nintendo", "slug": "console-nintendo", "parentId": 54, "description": "Nintendo konsolları (Switch vb.), oyunları ve Nintendo aksesuarları." },
- { "id": 58, "name": "Oyunlar (Konsol)", "slug": "console-games", "parentId": 54, "description": "Konsollar için fiziksel/dijital oyunlar ve oyun içerikleri." },
- { "id": 59, "name": "Konsol Aksesuarları", "slug": "console-accessories", "parentId": 54, "description": "Kollar, şarj istasyonları, kulaklıklar, taşıma çantaları ve diğer konsol aksesuarları." },
+ { "id": 245, "name": "Bahçe & Dış Mekan", "slug": "garden-outdoor", "parentId": 200, "description": "Bahçe mobilyaları, mangallar, bahçe aletleri, sulama sistemleri ve bitki bakımı." },
+ { "id": 246, "name": "Bahçe Mobilyaları", "slug": "garden-furniture", "parentId": 245, "description": "Oturma grupları, masalar, sandalyeler, salıncaklar ve şezlonglar." },
+ { "id": 247, "name": "Mangallar & Barbeküler", "slug": "bbqs-grills", "parentId": 245, "description": "Kömürlü, gazlı mangallar, elektrikli ızgaralar ve barbekü aksesuarları." },
+ { "id": 248, "name": "Bahçe Aletleri", "slug": "gardening-tools", "parentId": 245, "description": "Çim biçme makineleri, budama makasları, tırmıklar, kürekler ve el aletleri setleri." },
+ { "id": 249, "name": "Sulama Sistemleri", "slug": "irrigation-systems", "parentId": 245, "description": "Bahçe hortumları, damla sulama setleri, sprinklerlar ve sulama tabancaları." },
+ { "id": 250, "name": "Bitki Bakımı & Tohum", "slug": "plant-care-seeds", "parentId": 245, "description": "Saksı bitkileri, çiçek tohumları, gübreler ve bitki besinleri." },
- { "id": 60, "name": "Kamera & Fotoğraf", "slug": "camera", "parentId": 1, "description": "Fotoğraf/video çekim ekipmanları; kamera gövdeleri, lensler ve aksesuarlar." },
- { "id": 61, "name": "Fotoğraf Makinesi", "slug": "camera-photo", "parentId": 60, "description": "DSLR, aynasız ve kompakt fotoğraf makineleri." },
- { "id": 62, "name": "Aksiyon Kamera", "slug": "camera-action", "parentId": 60, "description": "GoPro tarzı dayanıklı, suya dayanıklı ve hareketli çekime uygun aksiyon kameraları." },
- { "id": 63, "name": "Lens", "slug": "camera-lens", "parentId": 60, "description": "Kamera lensleri; prime/zoom, geniş açı, tele, portre ve benzeri seçenekler." },
- { "id": 64, "name": "Tripod", "slug": "camera-tripod", "parentId": 60, "description": "Fotoğraf/video için tripod, monopod ve stabil çekim destek ekipmanları." },
+ { "id": 251, "name": "Kendin Yap & El Aletleri", "slug": "diy-tools", "parentId": 200, "description": "Matkaplar, testereler, el aletleri, hırdavat ürünleri ve iş güvenliği ekipmanları." },
+ { "id": 252, "name": "Elektrikli El Aletleri", "slug": "power-tools", "parentId": 251, "description": "Şarjlı matkap, darbeli matkap, dekupaj testere, zımpara makineleri ve spiral taşlama makineleri." },
+ { "id": 253, "name": "El Aletleri", "slug": "hand-tools", "parentId": 251, "description": "Tornavida setleri, pense, anahtar takımları, çekiçler ve metreler." },
+ { "id": 254, "name": "Hırdavat & Bağlantı Elemanları", "slug": "hardware-fasteners", "parentId": 251, "description": "Vida, dübel, somun, cıvata, menteşe ve yapıştırıcılar." },
+ { "id": 255, "name": "İş Güvenliği Ekipmanları", "slug": "safety-equipment", "parentId": 251, "description": "İş eldivenleri, koruyucu gözlükler, kulaklıklar ve iş ayakkabıları." },
- { "id": 65, "name": "Akıllı Ev", "slug": "smart-home", "parentId": 1, "description": "Ev otomasyonu ürünleri; aydınlatma, priz, sensör ve güvenlik çözümleri." },
- { "id": 66, "name": "Güvenlik Kamerası", "slug": "smart-security-camera", "parentId": 65, "description": "Ev/ofis için IP kamera, iç/dış kamera ve izleme sistemleri." },
- { "id": 67, "name": "Akıllı Priz", "slug": "smart-plug", "parentId": 65, "description": "Uygulama ile kontrol edilen, zamanlayıcı/enerji takibi sunan akıllı prizler." },
- { "id": 68, "name": "Akıllı Ampul", "slug": "smart-bulb", "parentId": 65, "description": "Renk/ışık şiddeti kontrolü yapılabilen, Wi-Fi/Zigbee akıllı ampuller." },
- { "id": 69, "name": "Akıllı Sensör", "slug": "smart-sensor", "parentId": 65, "description": "Kapı/pencere, hareket, sıcaklık/nem gibi verileri ölçen akıllı sensörler." },
- { "id": 70, "name": "Ağ Ürünleri", "slug": "pc-networking", "parentId": 6, "description": "İnternet ve yerel ağ kurulum ürünleri; router, modem, switch, menzil genişletici." },
- { "id": 71, "name": "Router", "slug": "pc-router", "parentId": 70, "description": "Kablosuz ağ dağıtımı için router cihazları (Wi-Fi 5/6/6E/7 vb.)." },
- { "id": 72, "name": "Modem", "slug": "pc-modem", "parentId": 70, "description": "DSL/VDSL/FTTH uyumlu modemler ve modem-router cihazları." },
- { "id": 73, "name": "Switch", "slug": "pc-switch", "parentId": 70, "description": "Kablolu ağ için port çoğaltan network switch cihazları." },
- { "id": 74, "name": "Wi-Fi Extender", "slug": "pc-wifi-extender", "parentId": 70, "description": "Kablosuz ağ menzilini artıran repeater/extender ve mesh uyumlu cihazlar." },
+ { "id": 301, "name": "Kadın Giyim", "slug": "womens-clothing", "parentId": 300, "description": "Elbiseler, bluzlar, pantolonlar, etekler, dış giyim, iç giyim ve spor giyim." },
+ { "id": 302, "name": "Elbiseler", "slug": "dresses", "parentId": 301, "description": "Günlük, abiye, spor, kokteyl elbiseleri ve tulumlar." },
+ { "id": 303, "name": "Kadın Üst Giyim", "slug": "womens-tops", "parentId": 301, "description": "Tişörtler, bluzlar, gömlekler, kazaklar, hırkalar ve ceketler." },
+ { "id": 304, "name": "Kadın Alt Giyim", "slug": "womens-bottoms", "parentId": 301, "description": "Pantolonlar, jeanler, etekler, şortlar ve taytlar." },
+ { "id": 305, "name": "Kadın Dış Giyim", "slug": "womens-outerwear", "parentId": 301, "description": "Montlar, kabanlar, trençkotlar, yelekler ve blazer ceketler." },
+ { "id": 306, "name": "Kadın İç Giyim & Gecelik", "slug": "womens-underwear-nightwear", "parentId": 301, "description": "Sütyen, külot, pijama, gecelik, sabahlık ve korse modelleri." },
+ { "id": 307, "name": "Kadın Spor Giyim", "slug": "womens-sportswear", "parentId": 301, "description": "Spor taytları, spor sütyenleri, eşofman takımları ve spor tişörtleri." },
+ { "id": 308, "name": "Kadın Mayo & Bikini", "slug": "womens-swimwear", "parentId": 301, "description": "Mayo, bikini, tankini, pareo ve plaj giyim ürünleri." },
- { "id": 75, "name": "Yazıcı & Tarayıcı", "slug": "pc-printing", "parentId": 6, "description": "Ev/ofis baskı ve tarama ürünleri; yazıcı, tarayıcı ve sarf malzemeleri." },
- { "id": 76, "name": "Yazıcı", "slug": "pc-printer", "parentId": 75, "description": "Lazer/mürekkep püskürtmeli yazıcılar ve çok fonksiyonlu cihazlar." },
- { "id": 77, "name": "Toner & Kartuş", "slug": "pc-ink-toner", "parentId": 75, "description": "Yazıcılar için toner, kartuş, mürekkep ve ilgili sarf malzemeleri." },
- { "id": 78, "name": "Tarayıcı", "slug": "pc-scanner", "parentId": 75, "description": "Belge ve fotoğraf taraması için flatbed/ADF tarayıcı cihazları." },
+ { "id": 309, "name": "Erkek Giyim", "slug": "mens-clothing", "parentId": 300, "description": "Tişörtler, gömlekler, pantolonlar, dış giyim, iç giyim ve spor giyim." },
+ { "id": 310, "name": "Erkek Üst Giyim", "slug": "mens-tops", "parentId": 309, "description": "Tişörtler, polo yaka tişörtler, gömlekler, kazaklar, hırkalar ve sweatshirtler." },
+ { "id": 311, "name": "Erkek Alt Giyim", "slug": "mens-bottoms", "parentId": 309, "description": "Pantolonlar, jeanler, şortlar, eşofmanlar ve bermudalar." },
+ { "id": 312, "name": "Erkek Dış Giyim", "slug": "mens-outerwear", "parentId": 309, "description": "Montlar, kabanlar, ceketler, yelekler ve deri ceketler." },
+ { "id": 313, "name": "Erkek İç Giyim & Çorap", "slug": "mens-underwear-socks", "parentId": 309, "description": "Boxer, atlet, slip, külot ve çorap modelleri." },
+ { "id": 314, "name": "Erkek Spor Giyim", "slug": "mens-sportswear", "parentId": 309, "description": "Eşofman takımları, spor tişörtleri, şortlar ve eşofman altları." },
+ { "id": 315, "name": "Erkek Mayo & Şort", "slug": "mens-swimwear", "parentId": 309, "description": "Deniz şortları, mayolar ve plaj havluları." },
- { "id": 79, "name": "Dizüstü Bilgisayar", "slug": "pc-laptop", "parentId": 6, "description": "Taşınabilir dizüstü bilgisayarlar; günlük, oyun ve iş amaçlı modeller." },
- { "id": 80, "name": "Masaüstü Bilgisayar", "slug": "pc-desktop", "parentId": 6, "description": "Hazır masaüstü bilgisayarlar ve iş/oyun odaklı sistemler." },
- { "id": 81, "name": "Tablet", "slug": "pc-tablet", "parentId": 6, "description": "Android/iPadOS/Windows tabletler ve tablet benzeri cihazlar." },
+ { "id": 316, "name": "Ayakkabı", "slug": "footwear", "parentId": 300, "description": "Kadın, erkek ve çocuk ayakkabıları; spor, klasik, bot, sandalet ve terlikler." },
+ { "id": 317, "name": "Kadın Ayakkabı", "slug": "womens-shoes", "parentId": 316, "description": "Topuklu ayakkabılar, babetler, spor ayakkabılar, sandaletler, botlar ve terlikler." },
+ { "id": 318, "name": "Erkek Ayakkabı", "slug": "mens-shoes", "parentId": 316, "description": "Klasik ayakkabılar, spor ayakkabılar, botlar, sandaletler ve terlikler." },
+ { "id": 319, "name": "Çocuk Ayakkabı", "slug": "kids-shoes", "parentId": 316, "description": "Okul ayakkabıları, spor ayakkabıları, sandaletler ve botlar." },
- { "id": 82, "name": "Depolama", "slug": "pc-storage", "parentId": 6, "description": "Harici disk, USB bellek, NAS ve diğer depolama çözümleri." },
- { "id": 83, "name": "Harici Disk", "slug": "pc-external-drive", "parentId": 82, "description": "Taşınabilir harici HDD/SSD diskler ve yedekleme çözümleri." },
- { "id": 84, "name": "USB Flash", "slug": "pc-usb-drive", "parentId": 82, "description": "USB bellekler; farklı kapasite ve hız seçenekleri." },
- { "id": 85, "name": "NAS", "slug": "pc-nas", "parentId": 82, "description": "Ağ üzerinden depolama ve yedekleme için NAS cihazları ve disk kutuları." },
+ { "id": 320, "name": "Çanta & Bavul", "slug": "bags-luggage", "parentId": 300, "description": "El çantaları, sırt çantaları, cüzdanlar, valizler ve seyahat çantaları." },
+ { "id": 321, "name": "El Çantaları", "slug": "handbags", "parentId": 320, "description": "Omuz çantaları, çapraz çantalar, portföyler, clutchlar ve tote çantalar." },
+ { "id": 322, "name": "Sırt Çantaları", "slug": "backpacks", "parentId": 320, "description": "Günlük kullanım, okul, spor, laptop ve seyahat sırt çantaları." },
+ { "id": 323, "name": "Cüzdanlar", "slug": "wallets", "parentId": 320, "description": "Kadın ve erkek cüzdanları, kartlıklar ve bozuk para cüzdanları." },
+ { "id": 324, "name": "Seyahat Bavulları & Valizler", "slug": "travel-luggage", "parentId": 320, "description": "Kabin boyu, orta boy, büyük boy valizler ve seyahat setleri." },
- { "id": 86, "name": "Webcam", "slug": "pc-webcam", "parentId": 12, "description": "Görüntülü görüşme ve yayın için web kameraları (1080p/2K/4K vb.)." },
- { "id": 87, "name": "Hoparlör (PC)", "slug": "pc-speaker", "parentId": 12, "description": "Bilgisayar için masaüstü hoparlör sistemleri ve ses çözümleri." },
- { "id": 88, "name": "Mikrofon (PC)", "slug": "pc-mic", "parentId": 12, "description": "Oyun, yayın, toplantı ve kayıt için PC uyumlu mikrofonlar." },
- { "id": 89, "name": "Mousepad", "slug": "pc-mousepad", "parentId": 12, "description": "Mouse kullanımını iyileştiren, farklı boyut ve yüzey tiplerinde mousepadler." },
- { "id": 90, "name": "Dock / USB Hub", "slug": "pc-dock-hub", "parentId": 12, "description": "Port çoğaltma için USB hub ve laptop dock istasyonları." },
- { "id": 91, "name": "Laptop Çantası", "slug": "pc-laptop-bag", "parentId": 12, "description": "Dizüstü bilgisayar taşıma çantaları, kılıflar ve koruyucu çantalar." },
- { "id": 92, "name": "Gamepad / Controller", "slug": "pc-controller", "parentId": 12, "description": "PC ile uyumlu oyun kolları ve kontrolcü aksesuarları." },
+ { "id": 325, "name": "Aksesuarlar", "slug": "accessories", "parentId": 300, "description": "Takı, saat, kemer, şapka, gözlük, eşarp ve diğer moda aksesuarları." },
+ { "id": 326, "name": "Takı & Mücevher", "slug": "jewelry", "parentId": 325, "description": "Kolye, küpe, bileklik, yüzük, broş ve setler; altın, gümüş, pırlanta." },
+ { "id": 327, "name": "Saatler", "slug": "watches", "parentId": 325, "description": "Kol saatleri; analog, dijital, otomatik ve akıllı saatler." },
+ { "id": 328, "name": "Kemerler", "slug": "belts", "parentId": 325, "description": "Deri, kumaş, kadın ve erkek kemer modelleri." },
+ { "id": 329, "name": "Şapkalar & Bereler", "slug": "hats-beanies", "parentId": 325, "description": "Kasket, şapka, bere, bandana ve atkı setleri." },
+ { "id": 330, "name": "Güneş Gözlükleri", "slug": "sunglasses", "parentId": 325, "description": "Kadın, erkek ve çocuk güneş gözlükleri; farklı marka ve modellerde." },
+ { "id": 331, "name": "Eşarp & Şallar", "slug": "scarves-shawls", "parentId": 325, "description": "İpek, pamuk, yün eşarplar ve şal modelleri." },
+ { "id": 332, "name": "Eldivenler", "slug": "gloves", "parentId": 325, "description": "Deri, yün, polar, spor eldivenleri ve dokunmatik ekran uyumlu eldivenler." },
- { "id": 93, "name": "Anakart", "slug": "pc-motherboard", "parentId": 7, "description": "İşlemci soketi ve chipset’e göre PC anakartları (ATX/mATX/ITX)." },
- { "id": 94, "name": "Güç Kaynağı (PSU)", "slug": "pc-psu", "parentId": 7, "description": "Bilgisayar bileşenlerini besleyen PSU güç kaynakları (80+ sertifikalı vb.)." },
- { "id": 95, "name": "Kasa", "slug": "pc-case", "parentId": 7, "description": "Bilgisayar kasaları; hava akışı, boyut ve tasarıma göre seçenekler." },
+
+ { "id": 401, "name": "Makyaj", "slug": "makeup", "parentId": 400, "description": "Yüz, göz, dudak makyaj ürünleri ve makyaj aksesuarları." },
+ { "id": 402, "name": "Yüz Makyajı", "slug": "face-makeup", "parentId": 401, "description": "Fondöten, kapatıcı, pudra, allık, bronzer, aydınlatıcı ve makyaj bazları." },
+ { "id": 403, "name": "Göz Makyajı", "slug": "eye-makeup", "parentId": 401, "description": "Maskara, eyeliner, far paletleri, kaş kalemi, göz kalemi ve kirpik." },
+ { "id": 404, "name": "Dudak Makyajı", "slug": "lip-makeup", "parentId": 401, "description": "Ruj, dudak parlatıcısı, dudak kalemi, dudak balmı ve dudak nemlendiricileri." },
+ { "id": 405, "name": "Makyaj Fırçaları & Aksesuarları", "slug": "makeup-brushes-tools", "parentId": 401, "description": "Makyaj fırça setleri, süngerler, makyaj çantaları ve makyaj temizleme ürünleri." },
- { "id": 96, "name": "Soğutma", "slug": "pc-cooling", "parentId": 7, "description": "CPU/GPU ve kasa soğutma çözümleri; fanlar, sıvı soğutma ve aksesuarlar." },
- { "id": 97, "name": "Kasa Fanı", "slug": "pc-fan", "parentId": 96, "description": "Kasa içi hava akışı için fanlar (RGB/PWM vb. seçenekler)." },
- { "id": 98, "name": "Sıvı Soğutma", "slug": "pc-liquid-cooling", "parentId": 96, "description": "AIO ve özel loop sıvı soğutma çözümleri ve bileşenleri." },
+ { "id": 406, "name": "Cilt Bakımı", "slug": "skincare", "parentId": 400, "description": "Yüz temizleyiciler, nemlendiriciler, serumlar, maskeler, güneş kremleri ve tonikler." },
+ { "id": 407, "name": "Yüz Temizleyiciler", "slug": "face-cleansers", "parentId": 406, "description": "Jel, köpük, yağ bazlı, misel su ve peeling etkili yüz temizleyiciler." },
+ { "id": 408, "name": "Nemlendiriciler", "slug": "moisturizers", "parentId": 406, "description": "Yüz ve vücut nemlendiricileri, kremler, losyonlar ve yağlar; farklı cilt tiplerine özel." },
+ { "id": 409, "name": "Serumlar & Özel Bakım", "slug": "serums-special-care", "parentId": 406, "description": "Hyaluronik asit, C vitamini, retinol, niasinamid serumları ve leke/akne tedavileri." },
+ { "id": 410, "name": "Yüz Maskeleri", "slug": "face-masks", "parentId": 406, "description": "Kil maskeleri, kağıt maskeler, uyku maskeleri ve nemlendirici maskeler." },
+ { "id": 411, "name": "Güneş Kremleri & Güneş Sonrası", "slug": "sunscreen-after-sun", "parentId": 406, "description": "UVA/UVB korumalı yüz ve vücut güneş kremleri (SPF), güneş sonrası losyonları." },
+ { "id": 412, "name": "Tonikler & Esanslar", "slug": "toners-essences", "parentId": 406, "description": "Cilt dengeleyici, gözenek sıkılaştırıcı tonikler ve besleyici esanslar." },
+ { "id": 413, "name": "Göz Çevresi Bakımı", "slug": "eye-care", "parentId": 406, "description": "Göz kremleri, serumlar, morluk ve torba karşıtı ürünler." },
- { "id": 99, "name": "Parfüm", "slug": "beauty-fragrance", "parentId": 2, "description": "Kadın/erkek parfümleri, deodorantlar ve koku ürünleri." },
- { "id": 100, "name": "Kadın Parfüm", "slug": "beauty-fragrance-women", "parentId": 99, "description": "Kadınlara yönelik parfümler; EDT/EDP ve farklı koku profilleri." },
- { "id": 101, "name": "Erkek Parfüm", "slug": "beauty-fragrance-men", "parentId": 99, "description": "Erkeklere yönelik parfümler; EDT/EDP, fresh/odunsu/baharatlı koku seçenekleri." },
+ { "id": 414, "name": "Saç Bakımı", "slug": "haircare", "parentId": 400, "description": "Şampuan, saç kremi, saç maskesi, saç yağları, şekillendiriciler ve saç boyaları." },
+ { "id": 415, "name": "Şampuanlar & Saç Kremleri", "slug": "shampoo-conditioner", "parentId": 414, "description": "Kepek, yağlı/kuru saç, onarıcı, renk koruyucu ve hacim veren şampuan/saç kremleri." },
+ { "id": 416, "name": "Saç Maskeleri & Bakım Yağları", "slug": "hair-masks-oils", "parentId": 414, "description": "Saç dökülmesine karşı, besleyici, onarıcı maskeler ve argan yağı, hindistan cevizi yağı gibi bakım yağları." },
+ { "id": 417, "name": "Saç Şekillendiriciler", "slug": "hair-styling", "parentId": 414, "description": "Saç spreyi, wax, jöle, köpük, ısıya karşı koruyucu spreyler ve şekillendirici kremler." },
+ { "id": 418, "name": "Saç Boyaları & Renk Açıcılar", "slug": "hair-color-lighteners", "parentId": 414, "description": "Kalıcı, yarı kalıcı saç boyaları, bitkisel boyalar ve renk açıcı ürünler." },
+ { "id": 419, "name": "Saç Şekillendirme Cihazları", "slug": "hair-styling-tools", "parentId": 414, "description": "Saç kurutma makineleri, düzleştiriciler, maşalar, fön fırçaları ve saç fırçaları." },
- { "id": 102, "name": "Saç Bakımı", "slug": "beauty-haircare", "parentId": 2, "description": "Saç temizliği, onarımı ve şekillendirme için saç bakım ürünleri." },
- { "id": 103, "name": "Şampuan", "slug": "beauty-shampoo", "parentId": 102, "description": "Kepek, yağlı/kuru saç, onarıcı ve renk koruyucu şampuan çeşitleri." },
- { "id": 104, "name": "Saç Kremi", "slug": "beauty-conditioner", "parentId": 102, "description": "Saçı yumuşatan, kolay tarama sağlayan ve bakım yapan saç kremleri." },
- { "id": 105, "name": "Saç Şekillendirici", "slug": "beauty-hair-styling", "parentId": 102, "description": "Wax, jel, köpük, sprey ve ısı koruyucu gibi şekillendirici ürünler." },
+ { "id": 420, "name": "Parfümler & Deodorantlar", "slug": "fragrances-deodorants", "parentId": 400, "description": "Kadın ve erkek parfümleri, kolonyalar, vücut spreyleri ve deodorantlar." },
+ { "id": 421, "name": "Kadın Parfümleri", "slug": "womens-fragrances", "parentId": 420, "description": "Çiçeksi, oryantal, fresh, odunsu koku profillerinde kadın parfümleri (EDT/EDP)." },
+ { "id": 422, "name": "Erkek Parfümleri", "slug": "mens-fragrances", "parentId": 420, "description": "Odunsu, baharatlı, fresh, aromatik koku profillerinde erkek parfümleri (EDT/EDP)." },
+ { "id": 423, "name": "Vücut Spreyleri & Kolonyalar", "slug": "body-mists-colognes", "parentId": 420, "description": "Hafif ve ferahlatıcı vücut spreyleri, kolonyalar ve eau de toilette'ler." },
+ { "id": 424, "name": "Deodorantlar & Antiperspirantlar", "slug": "deodorants-antiperspirants", "parentId": 420, "description": "Ter kokusunu önleyen roll-on, sprey ve stick deodorantlar." },
- { "id": 106, "name": "Kişisel Bakım", "slug": "beauty-personal-care", "parentId": 2, "description": "Günlük hijyen ve bakım ürünleri; deodorant, tıraş ve epilasyon gibi." },
- { "id": 107, "name": "Deodorant", "slug": "beauty-deodorant", "parentId": 106, "description": "Ter kokusunu önlemeye yardımcı roll-on, sprey ve stick deodorantlar." },
- { "id": 108, "name": "Tıraş Ürünleri", "slug": "beauty-shaving", "parentId": 106, "description": "Tıraş köpüğü/jeli, losyon, aftershave ve tıraş bıçağı ürünleri." },
- { "id": 109, "name": "Ağda / Epilasyon", "slug": "beauty-hair-removal", "parentId": 106, "description": "Ağda bantları, ağda ürünleri, epilatör ve tüy alma yardımcıları." },
+ { "id": 425, "name": "Kişisel Hijyen & Bakım", "slug": "personal-hygiene-care", "parentId": 400, "description": "Duş jelleri, sabunlar, ağız bakım ürünleri, tıraş/epilasyon ürünleri ve el/ayak bakımı." },
+ { "id": 426, "name": "Duş & Banyo Ürünleri", "slug": "shower-bath-products", "parentId": 425, "description": "Duş jelleri, sabunlar, banyo köpükleri, peelingler ve vücut fırçaları." },
+ { "id": 427, "name": "Ağız Bakım Ürünleri", "slug": "oral-care", "parentId": 425, "description": "Diş macunları, diş fırçaları, ağız gargaraları, diş ipleri ve dil temizleyiciler." },
+ { "id": 428, "name": "Tıraş & Epilasyon", "slug": "shaving-hair-removal", "parentId": 425, "description": "Tıraş bıçakları, tıraş köpükleri/jelleri, aftershave, epilatörler ve ağda ürünleri." },
+ { "id": 429, "name": "El & Ayak Bakımı", "slug": "hand-foot-care", "parentId": 425, "description": "El kremleri, ayak maskeleri, tırnak makası, törpü ve manikür/pedikür setleri." },
+ { "id": 430, "name": "Men's Grooming", "slug": "mens-grooming", "parentId": 425, "description": "Erkeklere özel cilt, saç, sakal bakımı ve tıraş ürünleri." },
- { "id": 110, "name": "Serum", "slug": "beauty-skincare-serum", "parentId": 20, "description": "Leke, nem, anti-aging ve aydınlatma için yoğun içerikli cilt serumları." },
- { "id": 111, "name": "Güneş Kremi", "slug": "beauty-sunscreen", "parentId": 20, "description": "UVA/UVB koruması sağlayan yüz ve vücut güneş koruyucuları (SPF)." },
- { "id": 112, "name": "Temizleyici", "slug": "beauty-cleanser", "parentId": 20, "description": "Jel, köpük, yağ bazlı ve micellar gibi yüz temizleme ürünleri." },
- { "id": 113, "name": "Yüz Maskesi", "slug": "beauty-mask", "parentId": 20, "description": "Kil, kağıt ve gece maskeleri; nem, arındırma ve bakım amaçlı." },
- { "id": 114, "name": "Tonik", "slug": "beauty-toner", "parentId": 20, "description": "Cildi dengeleyen, gözenek görünümünü destekleyen tonik ürünleri." },
- { "id": 115, "name": "Temel Gıda", "slug": "food-staples", "parentId": 3, "description": "Günlük mutfak ihtiyaçları; makarna, bakliyat, yağ ve benzeri temel ürünler." },
- { "id": 116, "name": "Makarna", "slug": "food-pasta", "parentId": 115, "description": "Spagetti, penne, erişte ve farklı çeşitlerde makarna ürünleri." },
- { "id": 117, "name": "Pirinç & Bakliyat", "slug": "food-legumes", "parentId": 115, "description": "Pirinç, bulgur, mercimek, nohut, fasulye ve diğer bakliyatlar." },
- { "id": 118, "name": "Yağ & Sirke (Gıda)", "slug": "food-oil-vinegar", "parentId": 115, "description": "Zeytinyağı, ayçiçek yağı ve çeşitli sirke türleri gibi ürünler." },
+ { "id": 501, "name": "Temel Gıda & Kuru Gıda", "slug": "pantry-dry-food", "parentId": 500, "description": "Makarna, pirinç, bakliyat, un, yağ, salça, baharat ve konserveler." },
+ { "id": 502, "name": "Makarna & Erişte", "slug": "pasta-noodles", "parentId": 501, "description": "Spagetti, penne, fiyonk makarna, erişte ve glutensiz makarna çeşitleri." },
+ { "id": 503, "name": "Pirinç & Bakliyat", "slug": "rice-legumes", "parentId": 501, "description": "Osmancık pirinç, baldo pirinç, bulgur, mercimek, nohut, fasulye ve barbunya." },
+ { "id": 504, "name": "Un & Fırıncılık Malzemeleri", "slug": "flour-baking-supplies", "parentId": 501, "description": "Buğday unu, tam buğday unu, mısır unu, kabartma tozu, vanilya ve instant maya." },
+ { "id": 505, "name": "Yağlar & Sirkeler", "slug": "oils-vinegars", "parentId": 501, "description": "Zeytinyağı, ayçiçek yağı, mısırözü yağı, üzüm sirkesi, elma sirkesi ve nar ekşisi." },
+ { "id": 506, "name": "Soslar & Salçalar", "slug": "sauces-pastes", "parentId": 501, "description": "Domates salçası, biber salçası, ketçap, mayonez, hardal, acı soslar ve turşular." },
+ { "id": 507, "name": "Baharatlar & Otlar", "slug": "spices-herbs", "parentId": 501, "description": "Karabiber, kimyon, pul biber, nane, kekik, zerdeçal ve köri gibi baharatlar." },
+ { "id": 508, "name": "Konserveler & Hazır Yemekler", "slug": "canned-ready-meals", "parentId": 501, "description": "Ton balığı, fasulye konservesi, bezelye konservesi, hazır çorbalar ve paket yemekler." },
- { "id": 119, "name": "Kahvaltılık", "slug": "food-breakfast", "parentId": 3, "description": "Peynir, zeytin, reçel, bal ve diğer kahvaltılık ürünler." },
- { "id": 120, "name": "Peynir", "slug": "food-cheese", "parentId": 119, "description": "Beyaz peynir, kaşar, tulum ve farklı peynir çeşitleri." },
- { "id": 121, "name": "Zeytin", "slug": "food-olive", "parentId": 119, "description": "Siyah/yeşil, çekirdekli/çekirdeksiz ve salamura zeytin çeşitleri." },
- { "id": 122, "name": "Reçel & Bal", "slug": "food-jam-honey", "parentId": 119, "description": "Kahvaltılık reçeller, marmelatlar, bal ve benzeri tatlandırıcı ürünler." },
+ { "id": 509, "name": "Taze Ürünler", "slug": "fresh-produce", "parentId": 500, "description": "Meyveler, sebzeler, et, tavuk, balık, şarküteri ürünleri ve süt ürünleri." },
+ { "id": 510, "name": "Meyveler", "slug": "fruits", "parentId": 509, "description": "Mevsimlik meyveler, egzotik meyveler, kurutulmuş meyveler ve meyve püreleri." },
+ { "id": 511, "name": "Sebzeler", "slug": "vegetables", "parentId": 509, "description": "Yeşillikler, kök sebzeler, salatalık, domates ve organik sebzeler." },
+ { "id": 512, "name": "Et & Tavuk Ürünleri", "slug": "meat-poultry-products", "parentId": 509, "description": "Dana eti, kuzu eti, tavuk eti, hindi eti; kıyma, kuşbaşı, pirzola ve fileto." },
+ { "id": 513, "name": "Balık & Deniz Ürünleri", "slug": "fish-seafood", "parentId": 509, "description": "Somon, levrek, çipura, alabalık, karides, midye ve diğer deniz ürünleri." },
+ { "id": 514, "name": "Şarküteri & Kahvaltılık", "slug": "delicatessen-breakfast", "parentId": 509, "description": "Peynir çeşitleri, zeytin, tereyağı, yumurta, sucuk, salam, sosis ve pastırma." },
+ { "id": 515, "name": "Süt & Süt Ürünleri", "slug": "dairy-products", "parentId": 509, "description": "Süt, yoğurt, ayran, kefir, krema, peynir ve bitkisel süt alternatifleri." },
- { "id": 123, "name": "Gazlı İçecek", "slug": "food-soda", "parentId": 24, "description": "Kola, gazoz, aromalı soda ve benzeri gazlı içecekler." },
- { "id": 124, "name": "Su", "slug": "food-water", "parentId": 24, "description": "Pet şişe, damacana ve aromalı su seçenekleri." },
- { "id": 125, "name": "Enerji İçeceği", "slug": "food-energy", "parentId": 24, "description": "Enerji içecekleri; farklı hacim ve kafein/taurin içerikli seçenekler." },
- { "id": 126, "name": "Çay", "slug": "food-tea", "parentId": 24, "description": "Siyah çay, yeşil çay, bitki çayları ve aromalı çay çeşitleri." },
+ { "id": 516, "name": "Atıştırmalıklar & Şekerlemeler", "slug": "snacks-confectionery", "parentId": 500, "description": "Cips, kuruyemiş, bisküvi, çikolata, şekerleme ve dondurulmuş tatlılar." },
+ { "id": 517, "name": "Cips & Kraker", "slug": "crisps-crackers", "parentId": 516, "description": "Patates cipsi, mısır cipsi, aromalı krakerler ve galetalar." },
+ { "id": 518, "name": "Bisküvi & Kurabiyeler", "slug": "biscuits-cookies", "parentId": 516, "description": "Çikolatalı, kremalı, tuzlu bisküviler, kekler ve kurabiyeler." },
+ { "id": 519, "name": "Çikolata & Şekerlemeler", "slug": "chocolate-sweets", "parentId": 516, "description": "Sütlü, bitter, beyaz çikolatalar, gofretler, barlar, sakızlar ve şekerler." },
+ { "id": 520, "name": "Kuruyemişler & Kuru Meyveler", "slug": "nuts-dried-fruits", "parentId": 516, "description": "Fındık, fıstık, badem, ceviz, kaju, kuru kayısı, kuru incir ve kuru üzüm." },
+ { "id": 521, "name": "Dondurma & Dondurulmuş Tatlılar", "slug": "ice-cream-frozen-desserts", "parentId": 516, "description": "Kutu, çubuk, külah dondurmalar, dondurulmuş pastalar ve tatlılar." },
- { "id": 127, "name": "Dondurulmuş", "slug": "food-frozen", "parentId": 3, "description": "Dondurulmuş gıdalar; sebze, hazır ürünler ve dondurulmuş atıştırmalıklar." },
- { "id": 128, "name": "Et & Tavuk", "slug": "food-meat", "parentId": 3, "description": "Kırmızı et, tavuk ve işlenmiş et ürünleri; paketli market seçenekleri." },
- { "id": 129, "name": "Tatlı", "slug": "food-dessert", "parentId": 3, "description": "Pastane/market tatlıları, çikolata bazlı ürünler ve tatlı çeşitleri." },
+ { "id": 522, "name": "İçecekler", "slug": "beverages", "parentId": 500, "description": "Kahve, çay, su, gazlı içecekler, meyve suları ve alkollü içecekler." },
+ { "id": 523, "name": "Kahve Çeşitleri", "slug": "coffee-varieties", "parentId": 522, "description": "Türk kahvesi, filtre kahve, espresso, granül kahve, kapsül kahve ve çekirdek kahve." },
+ { "id": 524, "name": "Çay Çeşitleri", "slug": "tea-varieties", "parentId": 522, "description": "Siyah çay, yeşil çay, bitki çayları, meyve çayları ve özel harmanlar." },
+ { "id": 525, "name": "Gazlı İçecekler", "slug": "soft-drinks", "parentId": 522, "description": "Kola, gazoz, aromalı sodalar, enerji içecekleri ve soğuk çaylar." },
+ { "id": 526, "name": "Meyve Suları & Nektarlar", "slug": "juices-nectars", "parentId": 522, "description": "Doğal meyve suları, konsantre meyve suları, taze sıkılmış meyve suları ve nektarlar." },
+ { "id": 527, "name": "Su & Maden Suyu", "slug": "water-mineral-water", "parentId": 522, "description": "Pet şişe su, damacana su, aromalı su ve doğal maden suları." },
+ { "id": 528, "name": "Alkollü İçecekler", "slug": "alcoholic-beverages", "parentId": 522, "description": "Bira, şarap, viski, votka, rakı ve diğer alkollü içecekler (Yasal düzenlemelere göre)." },
- { "id": 130, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 4, "description": "Araç içi/dışı kullanım için aksesuarlar; düzenleyici, tutucu, bakım setleri vb." },
- { "id": 131, "name": "Araç İçi Elektronik", "slug": "auto-in-car-electronics", "parentId": 130, "description": "Araç içi kamera, multimedya, şarj cihazı, FM transmitter gibi elektronik ürünler." },
+ { "id": 529, "name": "Organik & Özel Beslenme", "slug": "organic-special-diet", "parentId": 500, "description": "Organik ürünler, glutensiz, şekersiz, vegan ve vejetaryen gıdalar." },
+ { "id": 530, "name": "Dondurulmuş Gıdalar", "slug": "frozen-foods", "parentId": 500, "description": "Dondurulmuş sebzeler, meyveler, hazır yemekler, hamur işleri ve deniz ürünleri." },
+ { "id": 531, "name": "Bebek & Çocuk Mamaları (Gıda)", "slug": "baby-kids-food-groceries", "parentId": 500, "description": "Bebek mamaları, ek gıdalar, püreler ve çocuklara özel sağlıklı atıştırmalıklar." },
- { "id": 132, "name": "Oto Bakım", "slug": "auto-care", "parentId": 4, "description": "Araç bakım ürünleri; cila, wax, kaplama, temizlik ve koruma çözümleri." },
- { "id": 133, "name": "Oto Temizlik", "slug": "auto-cleaning", "parentId": 132, "description": "İç/dış temizlik ürünleri; şampuan, köpük, bez, fırça ve temizleyiciler." },
- { "id": 134, "name": "Lastik & Jant", "slug": "auto-tires", "parentId": 4, "description": "Lastik, jant ve ilgili aksesuarlar; mevsimlik lastikler ve bakım ürünleri." },
- { "id": 135, "name": "Akü", "slug": "auto-battery", "parentId": 4, "description": "Otomobil aküleri ve akü takviye/şarj ekipmanları." },
- { "id": 136, "name": "Oto Aydınlatma", "slug": "auto-lighting", "parentId": 130, "description": "Far ampulü, LED dönüşüm kitleri ve araç iç/dış aydınlatma ürünleri." },
- { "id": 137, "name": "Oto Ses Sistemi", "slug": "auto-audio", "parentId": 130, "description": "Teyp, hoparlör, amfi, subwoofer ve araç ses sistemi ekipmanları." },
- { "id": 138, "name": "Mobilya", "slug": "home-furniture", "parentId": 5, "description": "Ev mobilyaları; masa, sandalye, koltuk, yatak ve depolama ürünleri." },
- { "id": 139, "name": "Yemek Masası", "slug": "home-dining-table", "parentId": 138, "description": "Mutfak/yemek odası için farklı boyut ve malzemelerde yemek masaları." },
- { "id": 140, "name": "Sandalye", "slug": "home-chair", "parentId": 138, "description": "Yemek odası, çalışma ve çok amaçlı kullanım için sandalyeler." },
- { "id": 141, "name": "Koltuk", "slug": "home-sofa", "parentId": 138, "description": "Oturma odası için koltuk, kanepe ve oturma grubu ürünleri." },
- { "id": 142, "name": "Yatak", "slug": "home-bed", "parentId": 138, "description": "Tek/çift kişilik yatak bazası, karyola ve yatak sistemleri." },
+ { "id": 601, "name": "Oyun Konsolları", "slug": "game-consoles", "parentId": 600, "description": "PlayStation, Xbox, Nintendo Switch, retro konsollar ve el konsolları." },
+ { "id": 602, "name": "PlayStation Konsolları", "slug": "playstation-consoles", "parentId": 601, "description": "PlayStation 5, PlayStation 4 ve önceki nesil konsollar ile özel sürümler." },
+ { "id": 603, "name": "Xbox Konsolları", "slug": "xbox-consoles", "parentId": 601, "description": "Xbox Series X/S, Xbox One ve önceki nesil konsollar ile özel sürümler." },
+ { "id": 604, "name": "Nintendo Konsolları", "slug": "nintendo-consoles", "parentId": 601, "description": "Nintendo Switch, Switch Lite, Switch OLED ve diğer Nintendo el konsolları." },
+ { "id": 605, "name": "Retro & Mini Konsollar", "slug": "retro-mini-consoles", "parentId": 601, "description": "Nostaljik oyun deneyimi sunan retro konsollar ve mini versiyonları." },
+ { "id": 606, "name": "Oyunlar", "slug": "games", "parentId": 600, "description": "Konsol oyunları, PC oyunları, dijital oyun kodları ve abonelikler." },
+ { "id": 607, "name": "PlayStation Oyunları", "slug": "playstation-games", "parentId": 606, "description": "PS5 ve PS4 için fiziksel ve dijital oyunlar; farklı tür ve indirimli fırsatlar." },
+ { "id": 608, "name": "Xbox Oyunları", "slug": "xbox-games", "parentId": 606, "description": "Xbox Series X/S ve Xbox One için fiziksel ve dijital oyunlar; Game Pass fırsatları." },
+ { "id": 609, "name": "Nintendo Oyunları", "slug": "nintendo-games", "parentId": 606, "description": "Nintendo Switch ve diğer Nintendo konsolları için oyun kartuşları ve eShop oyunları." },
+ { "id": 610, "name": "PC Oyunları", "slug": "pc-games", "parentId": 606, "description": "Steam, Epic Games, Origin ve diğer platformlar için dijital/fiziksel PC oyunları." },
+ { "id": 611, "name": "Dijital Oyun Kodları & Abonelikler", "slug": "digital-game-codes-subscriptions", "parentId": 606, "description": "PlayStation Plus, Xbox Game Pass, Nintendo eShop kodları ve oyun içi satın alımlar." },
- { "id": 143, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 5, "description": "Nevresim, battaniye, perde ve diğer ev tekstili ürünleri." },
- { "id": 144, "name": "Nevresim", "slug": "home-bedding", "parentId": 143, "description": "Nevresim takımları, çarşaflar ve yastık kılıfları." },
- { "id": 145, "name": "Yorgan & Battaniye", "slug": "home-blanket", "parentId": 143, "description": "Isı ve konfor sağlayan yorgan, battaniye ve uyku ürünleri." },
- { "id": 146, "name": "Perde", "slug": "home-curtain", "parentId": 143, "description": "Tül, fon ve stor gibi farklı perde çeşitleri ve aksesuarları." },
+ { "id": 612, "name": "Oyun Aksesuarları", "slug": "gaming-accessories", "parentId": 600, "description": "Kontrolcüler, kulaklıklar, direksiyon setleri, VR cihazları, oyun koltukları ve depolama." },
+ { "id": 613, "name": "Oyun Kontrolcüler", "slug": "game-controllers", "parentId": 612, "description": "PlayStation DualSense, Xbox Wireless Controller, Nintendo Joy-Con ve Pro Controller gibi kontrolcüler." },
+ { "id": 614, "name": "Oyun Kulaklıkları", "slug": "gaming-headsets", "parentId": 612, "description": "Surround ses, gürültü engelleme ve yüksek kaliteli mikrofonlu oyun kulaklıkları." },
+ { "id": 615, "name": "Oyun Direksiyonları & Joystickler", "slug": "gaming-wheels-joysticks", "parentId": 612, "description": "Yarış simülasyonları için direksiyon setleri ve uçuş simülasyonları için joystickler." },
+ { "id": 616, "name": "VR (Sanal Gerçeklik) Cihazları", "slug": "vr-devices", "parentId": 612, "description": "Oculus Quest, PlayStation VR gibi sanal gerçeklik başlıkları ve aksesuarları." },
+ { "id": 617, "name": "Oyun Depolama Birimleri", "slug": "gaming-storage", "parentId": 612, "description": "Konsollar ve PC için harici SSD'ler, HDD'ler ve oyun depolama kartları." },
+ { "id": 618, "name": "Oyun Koltukları & Masaları", "slug": "gaming-chairs-desks", "parentId": 600, "description": "Ergonomik oyun koltukları, oyuncu masaları ve monitör standları." },
- { "id": 147, "name": "Mutfak", "slug": "home-kitchen", "parentId": 5, "description": "Mutfak gereçleri, pişirme ekipmanları ve küçük ev aletleri." },
- { "id": 148, "name": "Tencere & Tava", "slug": "home-cookware", "parentId": 147, "description": "Tencere setleri, tava çeşitleri ve pişirme ekipmanları." },
- { "id": 149, "name": "Küçük Ev Aletleri", "slug": "home-small-appliances", "parentId": 147, "description": "Mutfakta kullanılan küçük elektrikli aletler; kahve makinesi, blender vb." },
- { "id": 150, "name": "Kahve Makinesi", "slug": "home-coffee-machine", "parentId": 149, "description": "Filtre, espresso, kapsül ve Türk kahvesi makineleri." },
- { "id": 151, "name": "Blender", "slug": "home-blender", "parentId": 149, "description": "Smoothie, çorba ve karıştırma işlemleri için blender ve el blender setleri." },
- { "id": 152, "name": "Airfryer", "slug": "home-airfryer", "parentId": 149, "description": "Az yağ ile pişirme yapmaya yarayan airfryer cihazları ve aksesuarları." },
- { "id": 153, "name": "Süpürge", "slug": "home-vacuum", "parentId": 149, "description": "Dikey, toz torbalı/torbasız ve robot süpürge dahil ev süpürgeleri." },
- { "id": 154, "name": "Aydınlatma", "slug": "home-lighting", "parentId": 5, "description": "Avize, lambader, masa lambası ve LED aydınlatma çözümleri." },
- { "id": 155, "name": "Dekorasyon", "slug": "home-decor", "parentId": 5, "description": "Evi kişiselleştiren dekoratif ürünler; aksesuar, tablo, obje ve benzerleri." },
- { "id": 156, "name": "Halı", "slug": "home-rug", "parentId": 155, "description": "Salon, koridor ve oda için halılar; farklı ölçü ve materyal seçenekleri." },
- { "id": 157, "name": "Duvar Dekoru", "slug": "home-wall-decor", "parentId": 155, "description": "Tablo, raf, ayna, sticker ve benzeri duvar dekor ürünleri." },
+ { "id": 701, "name": "Oto Yedek Parça", "slug": "auto-spare-parts", "parentId": 700, "description": "Fren sistemleri, filtreler, motor parçaları, aydınlatma ve silecekler gibi araç yedek parçaları." },
+ { "id": 702, "name": "Fren Sistemleri", "slug": "brake-systems", "parentId": 701, "description": "Fren balatası, fren diski, fren hidroliği ve kaliperler." },
+ { "id": 703, "name": "Filtreler", "slug": "filters", "parentId": 701, "description": "Yağ filtresi, hava filtresi, polen filtresi ve yakıt filtresi." },
+ { "id": 704, "name": "Motor Parçaları", "slug": "engine-parts", "parentId": 701, "description": "Buji, ateşleme bobini, triger seti, kayışlar, contalar ve motor kulakları." },
+ { "id": 705, "name": "Oto Aydınlatma", "slug": "auto-lighting", "parentId": 701, "description": "Far ampulü, LED farlar, stop lambaları, sinyal lambaları ve sis farları." },
+ { "id": 706, "name": "Silecekler", "slug": "wipers", "parentId": 701, "description": "Ön ve arka silecekler, silecek motorları ve silecek suyu." },
- { "id": 158, "name": "Temizlik", "slug": "home-cleaning", "parentId": 5, "description": "Ev temizliği için ürünler; deterjan, bez, sünger ve temizlik ekipmanları." },
- { "id": 159, "name": "Deterjan", "slug": "home-detergent", "parentId": 158, "description": "Çamaşır, bulaşık ve yüzey temizliği için deterjan ve temizlik kimyasalları." },
- { "id": 160, "name": "Kağıt Ürünleri", "slug": "home-paper-products", "parentId": 158, "description": "Tuvalet kağıdı, kağıt havlu, peçete ve benzeri kağıt temizlik ürünleri." },
+ { "id": 707, "name": "Motor Yağları & Sıvılar", "slug": "engine-oils-fluids", "parentId": 700, "description": "Motor yağı, şanzıman yağı, antifriz, fren hidroliği ve direksiyon yağı." },
+ { "id": 708, "name": "Motor Yağları", "slug": "engine-oils", "parentId": 707, "description": "Sentetik, yarı sentetik, mineral motor yağları; farklı viskozite ve onaylara sahip." },
+ { "id": 709, "name": "Antifriz & Soğutma Sıvıları", "slug": "antifreeze-coolants", "parentId": 707, "description": "Motor soğutma sistemleri için antifriz ve soğutma sıvıları." },
- { "id": 161, "name": "El Aletleri", "slug": "home-tools", "parentId": 5, "description": "Ev ve hobi işleri için el aletleri, tamir ve montaj ekipmanları." },
- { "id": 162, "name": "Matkap", "slug": "home-drill", "parentId": 161, "description": "Darbeli/darbesiz, şarjlı/kablolu matkap ve vidalama makineleri." },
- { "id": 163, "name": "Testere", "slug": "home-saw", "parentId": 161, "description": "Ahşap/metal kesim için el testereleri ve elektrikli testere çeşitleri." },
- { "id": 164, "name": "Vida & Dübel", "slug": "home-hardware", "parentId": 161, "description": "Montaj ve sabitleme için vida, dübel, bağlantı elemanları ve setler." },
+ { "id": 710, "name": "Lastik & Jant", "slug": "tires-wheels", "parentId": 700, "description": "Yazlık, kışlık, dört mevsim lastikler, jantlar ve aksesuarları." },
+ { "id": 711, "name": "Otomobil Lastikleri", "slug": "car-tires", "parentId": 710, "description": "Yazlık, kışlık ve dört mevsim otomobil lastikleri; farklı marka ve ebatlarda." },
+ { "id": 712, "name": "Jantlar", "slug": "wheels", "parentId": 710, "description": "Çelik ve alaşım jantlar, jant kapakları ve jant temizleyiciler." },
- { "id": 165, "name": "Evcil Hayvan", "slug": "pet", "parentId": 5, "description": "Kedi, köpek ve diğer evcil hayvanlar için mama, bakım ve ihtiyaç ürünleri." },
- { "id": 166, "name": "Kedi Maması", "slug": "pet-cat-food", "parentId": 165, "description": "Yavru/yetişkin kedi için kuru/yaş mama ve özel diyet mamaları." },
- { "id": 167, "name": "Köpek Maması", "slug": "pet-dog-food", "parentId": 165, "description": "Yavru/yetişkin köpek için kuru/yaş mama ve özel ihtiyaç mamaları." },
- { "id": 168, "name": "Kedi Kumu", "slug": "pet-cat-litter", "parentId": 165, "description": "Topaklanan/silikalı/bitkisel kedi kumları ve koku kontrol çözümleri." },
+ { "id": 713, "name": "Oto Bakım & Temizlik", "slug": "auto-care-cleaning", "parentId": 700, "description": "Araç yıkama, parlatma, iç ve dış temizlik ürünleri, cila ve boya koruma." },
+ { "id": 714, "name": "Dış Temizlik Ürünleri", "slug": "exterior-cleaning", "parentId": 713, "description": "Oto şampuanı, jant temizleyici, lastik parlatıcı, cam suyu ve böcek temizleyici." },
+ { "id": 715, "name": "İç Temizlik Ürünleri", "slug": "interior-cleaning", "parentId": 713, "description": "Torpidolar, koltuklar, döşemeler, kokpit temizleyiciler ve hava tazeleyiciler." },
+ { "id": 716, "name": "Cila & Boya Koruma", "slug": "polish-paint-protection", "parentId": 713, "description": "Araç cilaları, pastalar, seramik kaplama ürünleri ve boya koruyucular." },
- { "id": 169, "name": "Kırtasiye & Ofis", "slug": "office", "parentId": 0, "description": "Okul ve ofis ihtiyaçları; kağıt ürünleri, yazım gereçleri ve aksesuarlar." },
- { "id": 170, "name": "Kağıt & Defter", "slug": "office-paper-notebook", "parentId": 169, "description": "Defter, ajanda, not kağıdı ve farklı türde kağıt ürünleri." },
- { "id": 171, "name": "A4 Kağıdı", "slug": "office-a4-paper", "parentId": 170, "description": "Yazıcı ve fotokopi için A4 kağıt; farklı gramaj ve kalite seçenekleri." },
- { "id": 172, "name": "Kalem", "slug": "office-pen", "parentId": 169, "description": "Tükenmez, jel, kurşun, marker ve farklı amaçlara uygun kalemler." },
- { "id": 173, "name": "Okul Çantası", "slug": "office-school-bag", "parentId": 169, "description": "Öğrenciler için sırt çantası, beslenme çantası ve okul çantaları." },
+ { "id": 717, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 700, "description": "Araç içi/dışı aksesuarlar, ses sistemleri, kamera, navigasyon ve oto güvenlik ürünleri." },
+ { "id": 718, "name": "Araç İçi Elektronik", "slug": "in-car-electronics", "parentId": 717, "description": "Araç içi kamera (Dashcam), multimedya sistemleri, şarj cihazları, FM transmitterler." },
+ { "id": 719, "name": "Oto Ses Sistemleri", "slug": "car-audio-systems", "parentId": 717, "description": "Teyp, hoparlör, amfi, subwoofer ve araç içi eğlence sistemleri." },
+ { "id": 720, "name": "Navigasyon Cihazları", "slug": "navigation-devices", "parentId": 717, "description": "GPS navigasyon cihazları ve harita güncellemeleri." },
+ { "id": 721, "name": "Araç İçi Düzenleyiciler", "slug": "car-organizers", "parentId": 717, "description": "Bagaj düzenleyici, koltuk arkası organizer, telefon tutucular ve çöp kutuları." },
+ { "id": 722, "name": "Oto Güvenlik & Konfor", "slug": "auto-safety-comfort", "parentId": 717, "description": "Park sensörü, alarm sistemleri, koltuk kılıfları ve direksiyon kılıfları." },
- { "id": 174, "name": "Bebek & Çocuk", "slug": "baby", "parentId": 0, "description": "Bebek ve çocuk bakım/bez, mama, ıslak mendil ve oyuncak ürünleri." },
- { "id": 175, "name": "Bebek Bezi", "slug": "baby-diaper", "parentId": 174, "description": "Yeni doğan ve farklı bedenlerde bebek bezleri, külot bez seçenekleri." },
- { "id": 176, "name": "Islak Mendil", "slug": "baby-wipes", "parentId": 174, "description": "Bebek bakımı için ıslak mendil; hassas cilt uyumlu seçenekler." },
- { "id": 177, "name": "Bebek Maması", "slug": "baby-food", "parentId": 174, "description": "Bebekler için mama, ek gıda ve püre ürünleri." },
- { "id": 178, "name": "Oyuncak", "slug": "baby-toys", "parentId": 174, "description": "Bebek ve çocuklar için eğitici, zeka ve oyun oyuncakları." },
+ { "id": 723, "name": "Motosiklet & Scooter", "slug": "motorcycles-scooters", "parentId": 700, "description": "Motosikletler, scooterlar, kasklar, ekipmanlar ve aksesuarları." },
+ { "id": 724, "name": "Motosikletler", "slug": "motorcycles", "parentId": 723, "description": "Farklı kategori ve markalarda motosiklet modelleri." },
+ { "id": 725, "name": "Motosiklet Ekipmanları", "slug": "motorcycle-gear", "parentId": 723, "description": "Kasklar, montlar, eldivenler, pantolonlar ve motosiklet botları." },
+ { "id": 726, "name": "Motosiklet Aksesuarları", "slug": "motorcycle-accessories", "parentId": 723, "description": "Motosiklet çantaları, koruyucular, zincir yağları ve kilitler." },
- { "id": 179, "name": "Spor & Outdoor", "slug": "sports", "parentId": 0, "description": "Spor ekipmanları ve outdoor ürünleri; kamp, fitness, bisiklet ve daha fazlası." },
- { "id": 180, "name": "Kamp", "slug": "sports-camping", "parentId": 179, "description": "Çadır, uyku tulumu, kamp sandalyesi ve kamp ekipmanları." },
- { "id": 181, "name": "Fitness", "slug": "sports-fitness", "parentId": 179, "description": "Ağırlık, dambıl, mat ve evde antrenman için fitness ekipmanları." },
- { "id": 182, "name": "Bisiklet", "slug": "sports-bicycle", "parentId": 179, "description": "Şehir/dağ/katlanır bisikletler ve bisiklet aksesuarları." },
- { "id": 183, "name": "Moda", "slug": "fashion", "parentId": 0, "description": "Giyim, ayakkabı ve aksesuar ürünleri; kadın/erkek moda kategorileri." },
- { "id": 184, "name": "Ayakkabı", "slug": "fashion-shoes", "parentId": 183, "description": "Spor ayakkabı, günlük ayakkabı ve farklı kullanım amaçlarına uygun modeller." },
- { "id": 185, "name": "Erkek Giyim", "slug": "fashion-men", "parentId": 183, "description": "Erkek kıyafetleri; tişört, gömlek, pantolon, mont ve daha fazlası." },
- { "id": 186, "name": "Kadın Giyim", "slug": "fashion-women", "parentId": 183, "description": "Kadın kıyafetleri; elbise, bluz, pantolon, mont ve daha fazlası." },
- { "id": 187, "name": "Çanta", "slug": "fashion-bags", "parentId": 183, "description": "Sırt çantası, el çantası, valiz ve farklı kullanım amaçlı çantalar." },
+ { "id": 801, "name": "Fitness & Kardiyo", "slug": "fitness-cardio", "parentId": 800, "description": "Ağırlıklar, koşu bantları, egzersiz bisikletleri, fitness aksesuarları ve evde egzersiz ürünleri." },
+ { "id": 802, "name": "Ağırlık & Dambıl Setleri", "slug": "weights-dumbbells", "parentId": 801, "description": "Krom, döküm ağırlıklar, ayarlanabilir dambıl setleri ve barfiks barları." },
+ { "id": 803, "name": "Kardiyo Ekipmanları", "slug": "cardio-equipment", "parentId": 801, "description": "Koşu bantları, eliptik bisikletler, egzersiz bisikletleri, kürek makineleri ve stepperlar." },
+ { "id": 804, "name": "Fitness Aksesuarları", "slug": "fitness-accessories", "parentId": 801, "description": "Yoga matları, pilates topları, direnç bantları, atlama ipleri, el yayı ve ağırlık eldivenleri." },
+ { "id": 805, "name": "Evde Egzersiz", "slug": "home-workout", "parentId": 801, "description": "Mekik aletleri, şınav barları, kapı barları ve çok fonksiyonlu egzersiz aletleri." },
- { "id": 188, "name": "Kitap & Medya", "slug": "books-media", "parentId": 0, "description": "Kitaplar, dijital içerikler, oyun ve medya ürünleri." },
- { "id": 189, "name": "Kitap", "slug": "books", "parentId": 188, "description": "Roman, kişisel gelişim, eğitim ve diğer türlerde basılı kitaplar." },
- { "id": 190, "name": "Dijital Oyun (Genel)", "slug": "digital-games", "parentId": 191, "description": "PC/konsol platformları için dijital oyunlar, kodlar ve dijital içerikler." },
- { "id": 191, "name": "Oyun", "slug": "games", "parentId": 0, "description": "Konsol, PC ve dijital oyun fırsatları; oyun ekipmanları ve abonelikler." }
+ { "id": 806, "name": "Bisiklet", "slug": "cycling", "parentId": 800, "description": "Dağ, şehir, yol, katlanır ve elektrikli bisikletler, bisiklet aksesuarları ve giyim." },
+ { "id": 807, "name": "Bisiklet Çeşitleri", "slug": "bicycles", "parentId": 806, "description": "Yol bisikletleri, dağ bisikletleri, şehir bisikletleri, elektrikli bisikletler ve çocuk bisikletleri." },
+ { "id": 808, "name": "Bisiklet Aksesuarları", "slug": "cycling-accessories", "parentId": 806, "description": "Bisiklet kaskları, kilitler, ışıklar, pompalar, suluklar, bisiklet çantaları ve tamir kitleri." },
+ { "id": 809, "name": "Bisiklet Giyim", "slug": "cycling-apparel", "parentId": 806, "description": "Bisiklet formaları, şortları, eldivenleri, ayakkabıları ve termal giyim ürünleri." },
-]
+ { "id": 810, "name": "Kamp & Doğa Sporları", "slug": "camping-outdoor-sports", "parentId": 800, "description": "Çadır, uyku tulumu, kamp mobilyaları, yürüyüş ekipmanları ve outdoor giyim." },
+ { "id": 811, "name": "Çadırlar", "slug": "tents", "parentId": 810, "description": "Tek kişilik, iki kişilik, aile boyu kamp çadırları ve plaj çadırları." },
+ { "id": 812, "name": "Uyku Tulumları & Matlar", "slug": "sleeping-bags-mats", "parentId": 810, "description": "Farklı sıcaklık derecelerine uygun uyku tulumları ve kamp matları/yatakları." },
+ { "id": 813, "name": "Kamp Mobilyaları", "slug": "camping-furniture", "parentId": 810, "description": "Kamp sandalyeleri, masaları, katlanabilir dolaplar ve portatif ocaklar." },
+ { "id": 814, "name": "Yürüyüş & Trekking Ekipmanları", "slug": "hiking-trekking-gear", "parentId": 810, "description": "Sırt çantaları, yürüyüş batonları, su mataraları, pusulalar ve GPS cihazları." },
+ { "id": 815, "name": "Outdoor Giyim & Ayakkabı", "slug": "outdoor-apparel-footwear", "parentId": 810, "description": "Su geçirmez montlar, pantolonlar, termal içlikler, outdoor ayakkabıları ve botlar." },
+ { "id": 816, "name": "Kamp Aksesuarları", "slug": "camping-accessories", "parentId": 810, "description": "El fenerleri, kafa lambaları, kamp lambaları, ateş başlatıcılar ve çok amaçlı aletler." },
+
+ { "id": 817, "name": "Su Sporları", "slug": "water-sports", "parentId": 800, "description": "Yüzme, dalış, sörf, kano, kürek ve plaj sporları ekipmanları." },
+ { "id": 818, "name": "Yüzme Ekipmanları", "slug": "swimming-gear", "parentId": 817, "description": "Mayo, şort, yüzme gözlüğü, bone, palet, can yeleği ve deniz yatağı." },
+ { "id": 819, "name": "Dalış & Şnorkel Ekipmanları", "slug": "diving-snorkeling-gear", "parentId": 817, "description": "Dalış maskesi, şnorkel, palet, dalış elbisesi ve dalış bilgisayarları." },
+ { "id": 820, "name": "Sörf & Kano", "slug": "surfing-kayaking", "parentId": 817, "description": "Sörf tahtaları, paddleboardlar, kanolar ve kürekler." },
+
+ { "id": 821, "name": "Takım Sporları", "slug": "team-sports", "parentId": 800, "description": "Futbol, basketbol, voleybol, tenis ve diğer takım sporları ürünleri." },
+ { "id": 822, "name": "Futbol Malzemeleri", "slug": "football-gear", "parentId": 821, "description": "Futbol topu, forma, krampon, kaleci eldiveni, tekmelik ve antrenman ekipmanları." },
+ { "id": 823, "name": "Basketbol Malzemeleri", "slug": "basketball-gear", "parentId": 821, "description": "Basketbol topu, forma, şort, pota ve basketbol ayakkabıları." },
+ { "id": 824, "name": "Tenis & Raket Sporları", "slug": "tennis-racket-sports", "parentId": 821, "description": "Tenis raketi, top, tenis ayakkabısı, badminton ve masa tenisi ekipmanları." },
+
+ { "id": 826, "name": "Protez & Ortez (Spor)", "slug": "sports-prosthetics-orthotics", "parentId": 800, "description": "Dizlik, bileklik, bel korsesi, dirseklik ve diğer sporcu destek ürünleri." },
+
+
+ { "id": 901, "name": "Bebek Bakımı", "slug": "baby-care", "parentId": 900, "description": "Bebek bezleri, ıslak mendiller, şampuanlar, cilt bakım ürünleri ve banyo setleri." },
+ { "id": 902, "name": "Bebek Bezleri", "slug": "baby-diapers", "parentId": 901, "description": "Yenidoğan, farklı beden ve külot bez seçenekleri, ekolojik bebek bezleri." },
+ { "id": 903, "name": "Islak Mendiller", "slug": "baby-wipes", "parentId": 901, "description": "Hassas ciltler için, parfümsüz, su bazlı ve doğal içerikli ıslak mendiller." },
+ { "id": 904, "name": "Bebek Şampuan & Sabun", "slug": "baby-shampoo-soap", "parentId": 901, "description": "Göz yakmayan, hipoalerjenik formüllü bebek şampuanları ve sabunları." },
+ { "id": 905, "name": "Bebek Cilt Bakımı", "slug": "baby-skincare", "parentId": 901, "description": "Bebek yağları, losyonları, pişik kremleri, güneş kremleri ve masaj yağları." },
+ { "id": 906, "name": "Bebek Banyo Ürünleri", "slug": "baby-bath-products", "parentId": 901, "description": "Bebek küvetleri, banyo termometreleri, banyo oyuncakları ve havluları." },
+
+ { "id": 907, "name": "Bebek Beslenmesi", "slug": "baby-feeding", "parentId": 900, "description": "Bebek mamaları, ek gıdalar, biberonlar, emzikler, mama sandalyeleri ve beslenme aksesuarları." },
+ { "id": 908, "name": "Bebek Mamaları", "slug": "baby-formulas", "parentId": 907, "description": "Formül mamalar, devam sütleri, özel ihtiyaç mamaları ve organik bebek mamaları." },
+ { "id": 909, "name": "Ek Gıdalar & Püreler", "slug": "baby-food-purees", "parentId": 907, "description": "Meyve, sebze püreleri, tahıllı kaşık mamaları, bebek kahvaltıları ve atıştırmalıklar." },
+ { "id": 910, "name": "Biberonlar & Emzikler", "slug": "bottles-pacifiers", "parentId": 907, "description": "Farklı boy ve emzik ucu çeşitlerinde biberonlar, emzikler, emzik zincirleri ve biberon ısıtıcıları." },
+ { "id": 911, "name": "Mama Sandalyeleri", "slug": "high-chairs", "parentId": 907, "description": "Katlanabilir, ayarlanabilir, portatif mama sandalyeleri ve booster koltuklar." },
+ { "id": 912, "name": "Beslenme Aksesuarları", "slug": "feeding-accessories", "parentId": 907, "description": "Mama önlükleri, tabaklar, kaşık setleri, sterilizatörler ve mama saklama kapları." },
+
+ { "id": 913, "name": "Bebek Araç Gereçleri", "slug": "baby-gear", "parentId": 900, "description": "Bebek arabaları, oto koltukları, ana kucakları, yürüteçler ve park yatakları." },
+ { "id": 914, "name": "Bebek Arabaları", "slug": "strollers", "parentId": 913, "description": "Tekli, ikiz, baston tip, travel sistem bebek arabaları ve pusetler." },
+ { "id": 915, "name": "Oto Koltukları", "slug": "car-seats", "parentId": 913, "description": "Yenidoğan, isofix, yükseltici oto koltukları; farklı yaş ve ağırlık grupları için." },
+ { "id": 916, "name": "Ana Kucakları & Kangurular", "slug": "bouncers-carriers", "parentId": 913, "description": "Bebekleri taşımak için ana kucakları, salıncaklar ve ergonomik kangurular." },
+ { "id": 917, "name": "Yürüteçler & Aktivite Merkezleri", "slug": "walkers-activity-centers", "parentId": 913, "description": "Bebek yürüteçleri, aktivite masaları ve oyun merkezleri." },
+ { "id": 918, "name": "Park Yatakları & Seyahat Yatakları", "slug": "playards-travel-cribs", "parentId": 913, "description": "Katlanabilir park yatakları, seyahat yatakları ve oyun parkları." },
+
+ { "id": 919, "name": "Oyuncaklar & Eğitici Ürünler", "slug": "toys-educational", "parentId": 900, "description": "Bebek oyuncakları, eğitici oyuncaklar, kutu oyunları, yapbozlar ve dış mekan oyuncakları." },
+ { "id": 920, "name": "Bebek Oyuncakları", "slug": "baby-toys", "parentId": 919, "description": "Diş kaşıyıcı, çıngırak, uyku arkadaşı, oyun halısı, dönence ve aktivite küpleri." },
+ { "id": 921, "name": "Eğitici Oyuncaklar", "slug": "educational-toys", "parentId": 919, "description": "Ahşap oyuncaklar, yapım setleri, zeka geliştirici oyunlar, bilim kitleri ve robotik oyuncaklar." },
+ { "id": 922, "name": "Kutu Oyunları", "slug": "board-games", "parentId": 919, "description": "Çocuk ve aile için strateji, bilgi, şans oyunları ve klasik kutu oyunları." },
+ { "id": 923, "name": "Yapbozlar", "slug": "puzzles", "parentId": 919, "description": "Çocuk ve yetişkinler için farklı parça sayılarında yapbozlar, 3D yapbozlar." },
+ { "id": 924, "name": "Dış Mekan Oyuncakları", "slug": "outdoor-toys", "parentId": 919, "description": "Salıncak, kaydırak, kum havuzu, bisiklet, scooter, top ve bahçe oyuncakları." },
+
+ { "id": 925, "name": "Çocuk Giyim & Ayakkabı", "slug": "kids-clothing-shoes", "parentId": 900, "description": "Bebek, çocuk ve genç giyim, ayakkabı, dış giyim ve aksesuarlar." },
+ { "id": 926, "name": "Bebek Giyim", "slug": "baby-clothing", "parentId": 925, "description": "Tulumlar, zıbınlar, bodyler, elbiseler, pantolonlar ve dış giyim ürünleri." },
+ { "id": 927, "name": "Çocuk Giyim", "slug": "kids-clothing", "parentId": 925, "description": "Elbiseler, tişörtler, pantolonlar, montlar, eşofman takımları ve kostümler." },
+ { "id": 928, "name": "Çocuk Ayakkabı", "slug": "kids-footwear", "parentId": 925, "description": "Spor ayakkabılar, sandaletler, botlar, ev ayakkabıları ve okul ayakkabıları." },
+ { "id": 929, "name": "Çocuk Aksesuarları", "slug": "kids-accessories", "parentId": 925, "description": "Çanta, şapka, eldiven, atkı, saç aksesuarları ve kemerler." },
+
+ { "id": 930, "name": "Bebek Odası Mobilyaları", "slug": "nursery-furniture", "parentId": 900, "description": "Bebek yatakları, beşikler, şifonyerler, gardıroplar ve emzirme koltukları." },
+ { "id": 931, "name": "Bebek Yatakları & Beşikler", "slug": "cots-cribs", "parentId": 930, "description": "Sabit, sallanır beşikler, anne yanı yatakları ve büyüyebilen bebek yatakları." },
+ { "id": 932, "name": "Şifonyerler & Gardıroplar", "slug": "dressers-wardrobes", "parentId": 930, "description": "Bebek odası şifonyerleri, gardıroplar ve alt değiştirme üniteleri." },
+
+
+ { "id": 1001, "name": "Kitaplar", "slug": "books", "parentId": 1000, "description": "Roman, kişisel gelişim, tarih, bilim, çocuk kitapları, e-kitaplar ve sesli kitaplar." },
+ { "id": 1002, "name": "Edebiyat & Roman", "slug": "literature-novels", "parentId": 1001, "description": "Türk ve dünya edebiyatından klasikler, çağdaş romanlar, öykü ve şiir kitapları." },
+ { "id": 1003, "name": "Kişisel Gelişim & Psikoloji", "slug": "personal-development-psychology", "parentId": 1001, "description": "Motivasyon, farkındalık, psikoloji, felsefe, iş ve kariyer gelişim kitapları." },
+ { "id": 1004, "name": "Tarih & Bilim", "slug": "history-science", "parentId": 1001, "description": "Tarih kitapları, bilimsel araştırmalar, popüler bilim ve belgesel kitapları." },
+ { "id": 1005, "name": "Çocuk & Genç Kitapları", "slug": "kids-young-adult-books", "parentId": 1001, "description": "Masal, hikaye, boyama kitapları, eğitici çocuk kitapları ve gençlik romanları." },
+ { "id": 1006, "name": "E-Kitaplar & Sesli Kitaplar", "slug": "ebooks-audiobooks", "parentId": 1001, "description": "Dijital formatta kitaplar, e-kitap okuyucular ve sesli kitap platformları abonelikleri." },
+ { "id": 1007, "name": "Ders Kitapları & Eğitim", "slug": "textbooks-education", "parentId": 1001, "description": "Okul müfredatına uygun ders kitapları, yardımcı kaynaklar, test kitapları ve yabancı dil eğitim kitapları." },
+ { "id": 1008, "name": "Hobi & Sanat Kitapları", "slug": "hobby-art-books", "parentId": 1001, "description": "Yemek tarifleri, el işleri, resim, fotoğrafçılık ve diğer hobi alanlarına yönelik kitaplar." },
+
+ { "id": 1009, "name": "Filmler & TV Dizileri", "slug": "movies-tv-series", "parentId": 1000, "description": "DVD, Blu-ray filmler, dizi kutu setleri ve dijital platform abonelikleri." },
+ { "id": 1010, "name": "DVD & Blu-ray Filmler", "slug": "dvd-blu-ray-movies", "parentId": 1009, "description": "Farklı türlerde film ve dizi koleksiyonları, 4K Ultra HD filmler." },
+ { "id": 1011, "name": "Akış Platformları Abonelikleri", "slug": "streaming-subscriptions", "parentId": 1009, "description": "Netflix, Disney+, Amazon Prime Video, Exxen gibi video akış platformu üyelikleri." },
+
+ { "id": 1012, "name": "Müzik", "slug": "music", "parentId": 1000, "description": "CD'ler, plaklar, müzik aletleri ve dijital müzik abonelikleri." },
+ { "id": 1013, "name": "CD'ler & Plaklar", "slug": "cds-vinyls", "parentId": 1012, "description": "Farklı sanatçı ve müzik türlerinde CD albümler, vinyl plaklar ve kasetler." },
+ { "id": 1014, "name": "Müzik Enstrümanları", "slug": "musical-instruments", "parentId": 1012, "description": "Gitarlar, piyano, bateri, keman, flüt ve diğer müzik aletleri." },
+ { "id": 1015, "name": "Dijital Müzik Abonelikleri", "slug": "digital-music-subscriptions", "parentId": 1012, "description": "Spotify, Apple Music, YouTube Music gibi müzik platformu üyelikleri." },
+
+ { "id": 1016, "name": "Dergi & Gazete", "slug": "magazines-newspapers", "parentId": 1000, "description": "Popüler dergiler, sektörel yayınlar, çocuk dergileri ve gazete abonelikleri." },
+ { "id": 1017, "name": "Çizgi Roman & Manga", "slug": "comics-manga", "parentId": 1000, "description": "Türkçe ve yabancı çizgi romanlar, manga serileri ve grafik romanlar." },
+
+
+ { "id": 1101, "name": "Yazım Gereçleri", "slug": "writing-instruments", "parentId": 1100, "description": "Kalemler, uçlar, mürekkepler, fosforlu kalemler ve kalem setleri." },
+ { "id": 1102, "name": "Kalemler", "slug": "pens", "parentId": 1101, "description": "Tükenmez kalem, jel kalem, kurşun kalem, dolma kalem, roller kalem ve markörler." },
+ { "id": 1103, "name": "Defter & Ajanda", "slug": "notebooks-planners", "parentId": 1100, "description": "Çizgili, kareli, defterler, ajandalar, not defterleri, spiralli defterler ve günlükler." },
+ { "id": 1104, "name": "Kağıt Ürünleri", "slug": "paper-products", "parentId": 1100, "description": "A4 kağıt, renkli kağıtlar, kartonlar, zarflar, etiketler ve bloknotlar." },
+ { "id": 1105, "name": "A4 Kağıdı", "slug": "a4-paper", "parentId": 1104, "description": "Yazıcı ve fotokopi için farklı gramaj ve kalitelerde A4 kağıdı paketleri." },
+ { "id": 1106, "name": "Ofis Malzemeleri", "slug": "office-supplies", "parentId": 1100, "description": "Zımba, delgeç, ataş, klasör, dosya, bant, makas, yapıştırıcı ve masaüstü düzenleyiciler." },
+ { "id": 1107, "name": "Hesap Makineleri", "slug": "calculators", "parentId": 1100, "description": "Bilimsel hesap makineleri, finansal hesap makineleri ve masaüstü hesap makineleri." },
+ { "id": 1108, "name": "Okul Çantaları & Malzemeleri", "slug": "school-bags-supplies", "parentId": 1100, "description": "Sırt çantaları, beslenme çantaları, kalem kutuları, okul setleri ve eğitim gereçleri." },
+ { "id": 1109, "name": "Sanat & Hobi Malzemeleri", "slug": "art-craft-supplies", "parentId": 1100, "description": "Boyalar, fırçalar, tuval, çizim setleri, modelleme malzemeleri ve el işi kitleri." },
+
+
+ { "id": 1201, "name": "İnternet & İletişim", "slug": "internet-communication", "parentId": 1200, "description": "Genişbant internet, mobil hat tarifeleri, ev telefonu paketleri ve uydu/TV yayın hizmetleri." },
+ { "id": 1202, "name": "Genişbant İnternet Paketleri", "slug": "broadband-packages", "parentId": 1201, "description": "Fiber, ADSL, VDSL internet servis sağlayıcı fırsatları ve kampanyaları." },
+ { "id": 1203, "name": "Mobil Tarife & Paketler", "slug": "mobile-plans", "parentId": 1201, "description": "Farklı operatörlerin mobil internet, konuşma ve SMS paketleri, faturalı/faturasız hatlar." },
+ { "id": 1204, "name": "Ev Telefonu Hizmetleri", "slug": "home-phone-services", "parentId": 1201, "description": "Sabit hat ve VoIP telefon hizmeti fırsatları." },
+ { "id": 1205, "name": "Uydu & TV Yayın Hizmetleri", "slug": "satellite-tv-services", "parentId": 1201, "description": "Digiturk, D-Smart, Tivibu gibi platformların üyelik ve paket fırsatları." },
+
+ { "id": 1206, "name": "Seyahat Fırsatları", "slug": "travel-deals", "parentId": 1200, "description": "Uçak bileti, otel, tatil paketleri, araç kiralama, kruvaziyer turları ve yurt dışı turlar." },
+ { "id": 1207, "name": "Uçak Biletleri", "slug": "flight-tickets", "parentId": 1206, "description": "Yurt içi ve yurt dışı ucuz uçak bileti fırsatları, kampanyalı biletler." },
+ { "id": 1208, "name": "Otel & Konaklama", "slug": "hotels-accommodation", "parentId": 1206, "description": "Şehir otelleri, tatil köyleri, butik oteller, pansiyonlar ve daire kiralama." },
+ { "id": 1209, "name": "Tatil Paketleri", "slug": "holiday-packages", "parentId": 1206, "description": "Erken rezervasyon ve son dakika tatil paketleri, her şey dahil konseptler." },
+ { "id": 1210, "name": "Araç Kiralama", "slug": "car-rental", "parentId": 1206, "description": "Yurt içi ve yurt dışı farklı araç modelleri için kiralama hizmetleri." },
+ { "id": 1211, "name": "Kruvaziyer Turları", "slug": "cruises", "parentId": 1206, "description": "Akdeniz, Ege, Karayipler gibi farklı rotalarda gemi turları ve cruise fırsatları." },
+ { "id": 1212, "name": "Yurt Dışı Turlar", "slug": "international-tours", "parentId": 1206, "description": "Avrupa, Asya, Amerika turları, kültür turları ve macera turları." },
+
+ { "id": 1213, "name": "Deneyimler & Etkinlikler", "slug": "experiences-events", "parentId": 1200, "description": "Konser, tiyatro bileti, workshop, spa, spor etkinlikleri ve macera aktiviteleri." },
+ { "id": 1214, "name": "Restoran & Yemek Fırsatları", "slug": "restaurant-dining-deals", "parentId": 1200, "description": "İndirimli menüler, yemek kuponları, popüler restoranlarda özel kampanyalar." },
+ { "id": 1215, "name": "Eğitim & Kurslar", "slug": "education-courses", "parentId": 1200, "description": "Online eğitimler, dil kursları, hobi atölyeleri, sertifika programları ve üniversite dersleri." },
+ { "id": 1216, "name": "Ev Hizmetleri", "slug": "home-services", "parentId": 1200, "description": "Temizlik, tesisat, tadilat, bakım, onarım, taşımacılık ve haşere kontrol hizmetleri." },
+ { "id": 1217, "name": "Sağlık & Güzellik Hizmetleri", "slug": "health-beauty-services", "parentId": 1200, "description": "Spa, masaj, cilt bakımı, saç kesimi, manikür/pedikür, lazer epilasyon fırsatları." },
+
+
+ { "id": 1301, "name": "Vitaminler & Takviyeler", "slug": "vitamins-supplements", "parentId": 1300, "description": "Multivitaminler, D vitamini, omega-3, probiyotikler, mineral ve bitkisel takviyeler." },
+ { "id": 1302, "name": "Multivitaminler", "slug": "multivitamins", "parentId": 1301, "description": "Genel sağlık, bağışıklık sistemi, enerji ve zindelik için multivitamin kompleksleri." },
+ { "id": 1303, "name": "Mineraller", "slug": "minerals", "parentId": 1301, "description": "Çinko, magnezyum, demir, kalsiyum, selenyum gibi mineral takviyeleri." },
+ { "id": 1304, "name": "Bitkisel Takviyeler", "slug": "herbal-supplements", "parentId": 1301, "description": "Ginseng, zerdeçal, ekinezya, propolis, yeşil çay ekstresi gibi bitkisel destek ürünleri." },
+ { "id": 1305, "name": "Probiyotikler & Sindirim", "slug": "probiotics-digestion", "parentId": 1301, "description": "Sindirim sistemi sağlığı ve bağırsak florası desteği için probiyotik ve prebiyotik takviyeler." },
+ { "id": 1306, "name": "Omega-3 & Balık Yağı", "slug": "omega-3-fish-oil", "parentId": 1301, "description": "Kalp, beyin ve göz sağlığı için balık yağı ve diğer Omega-3 takviyeleri." },
+
+ { "id": 1307, "name": "Sporcu Besinleri", "slug": "sports-nutrition", "parentId": 1300, "description": "Protein tozları, kreatin, BCAA, amino asitler, enerji barları ve sporcu içecekleri." },
+ { "id": 1308, "name": "Protein Tozları", "slug": "protein-powders", "parentId": 1307, "description": "Whey protein, kazein, vegan protein, izole protein tozları; kas gelişimi ve onarımı için." },
+ { "id": 1309, "name": "Kreatin & Amino Asitler", "slug": "creatine-amino-acids", "parentId": 1307, "description": "Kreatin, BCAA, glutamin, arjinin gibi sporcu performansını destekleyici takviyeler." },
+ { "id": 1310, "name": "Enerji & Performans Ürünleri", "slug": "energy-performance", "parentId": 1307, "description": "Pre-workout, enerji jelleri, karbonhidrat tozları ve sporcu içecekleri." },
+
+ { "id": 1311, "name": "İlk Yardım & Medikal Malzemeler", "slug": "first-aid-medical", "parentId": 1300, "description": "Yara bandı, gazlı bez, antiseptik, ağrı kesici, ateş ölçer ve tansiyon aleti." },
+ { "id": 1312, "name": "Ağrı Kesiciler & Reçetesiz İlaçlar", "slug": "pain-relief-otc-meds", "parentId": 1311, "description": "Parasetamol, ibuprofen içeren reçetesiz ağrı kesiciler, soğuk algınlığı ve grip ilaçları." },
+ { "id": 1313, "name": "Yara Bakım Ürünleri", "slug": "wound-care", "parentId": 1311, "description": "Yara bantları, gazlı bezler, steril pedler, antiseptik spreyler, yara kremleri ve sargı bezleri." },
+ { "id": 1314, "name": "Ateş Ölçerler & Tansiyon Aletleri", "slug": "thermometers-bp-monitors", "parentId": 1311, "description": "Dijital ateş ölçerler, temassız termometreler, manuel ve dijital tansiyon aletleri." },
+ { "id": 1315, "name": "Tıbbi Cihazlar & Ortezler", "slug": "medical-devices-orthotics", "parentId": 1311, "description": "Nebulizatörler, şeker ölçüm cihazları, dizlik, bileklik, bel korsesi ve destek ürünleri." },
+
+ { "id": 1316, "name": "Göz Sağlığı Ürünleri", "slug": "eye-health-products", "parentId": 1300, "description": "Kontakt lens, lens solüsyonları, göz damlaları ve optik gözlükler." },
+ { "id": 1317, "name": "Kontakt Lensler", "slug": "contact-lenses", "parentId": 1316, "description": "Günlük, aylık, yıllık kontakt lensler, astigmatlı lensler ve renkli lensler." },
+ { "id": 1318, "name": "Lens Solüsyonları", "slug": "lens-solutions", "parentId": 1316, "description": "Tüm lens türleri için temizleme, dezenfekte etme ve saklama solüsyonları." },
+ { "id": 1319, "name": "Optik Gözlükler & Çerçeveler", "slug": "optical-glasses-frames", "parentId": 1316, "description": "Reçeteli ve reçetesiz optik gözlük çerçeveleri, okuma gözlükleri." },
+
+ { "id": 1320, "name": "Zayıflama & Diyet Ürünleri", "slug": "weight-loss-diet", "parentId": 1300, "description": "Zayıflama çayları, takviyeleri, diyet yemekleri, protein barları ve shake'ler." },
+ { "id": 1321, "name": "Ağız & Diş Sağlığı (Gelişmiş)", "slug": "oral-dental-health-advanced", "parentId": 1300, "description": "Elektrikli diş fırçaları, ağız duşları, diş beyazlatıcı ürünler ve profesyonel ağız bakım setleri." },
+
+ { "id": 1401, "name": "Kedi Ürünleri", "slug": "cat-supplies", "parentId": 1400, "description": "Kedi mamaları, kumları, oyuncakları, bakım ürünleri, yatakları ve taşıma çantaları." },
+ { "id": 1402, "name": "Kedi Mamaları", "slug": "cat-food", "parentId": 1401, "description": "Kuru ve yaş kedi mamaları, özel diyet mamaları, yavru/yetişkin mamaları ve ödül mamaları." },
+ { "id": 1403, "name": "Kedi Kumları & Tuvaletleri", "slug": "cat-litter-trays", "parentId": 1401, "description": "Topaklanan, silika, bitkisel kedi kumları, kedi tuvaletleri ve kum kürekleri." },
+ { "id": 1404, "name": "Kedi Oyuncakları", "slug": "cat-toys", "parentId": 1401, "description": "Tüy topları, lazer oyuncaklar, interaktif oyuncaklar, tırmalama tahtaları ve aktivite merkezleri." },
+ { "id": 1405, "name": "Kedi Bakım Ürünleri", "slug": "cat-grooming", "parentId": 1401, "description": "Kedi şampuanları, taraklar, tüy toplayıcılar, tırnak makasları ve pire damlaları." },
+ { "id": 1406, "name": "Kedi Yatakları & Taşıma", "slug": "cat-beds-carriers", "parentId": 1401, "description": "Kedi yatakları, minderler, seyahat çantaları, taşıma kafesleri ve kedi evleri." },
+
+ { "id": 1407, "name": "Köpek Ürünleri", "slug": "dog-supplies", "parentId": 1400, "description": "Köpek mamaları, tasmaları, yatakları, oyuncakları, bakım ürünleri ve eğitim malzemeleri." },
+ { "id": 1408, "name": "Köpek Mamaları", "slug": "dog-food", "parentId": 1407, "description": "Kuru ve yaş köpek mamaları, özel diyet mamaları, yavru/yetişkin mamaları ve ödül mamaları." },
+ { "id": 1409, "name": "Köpek Tasmaları & Yaka", "slug": "dog-collars-leashes", "parentId": 1407, "description": "Yürüyüş tasmaları, eğitim tasmaları, göğüs tasması, boyunluklar ve yaka aksesuarları." },
+ { "id": 1410, "name": "Köpek Yatakları & Kulübeleri", "slug": "dog-beds-kennels", "parentId": 1407, "description": "Köpek yatakları, minderler, seyahat kafesleri, kulübeler ve bahçe evleri." },
+ { "id": 1411, "name": "Köpek Oyuncakları", "slug": "dog-toys", "parentId": 1407, "description": "Çiğneme oyuncakları, top, frizbi, interaktif oyuncaklar ve zeka geliştirici oyuncaklar." },
+ { "id": 1412, "name": "Köpek Bakım Ürünleri", "slug": "dog-grooming", "parentId": 1407, "description": "Köpek şampuanları, taraklar, fırçalar, tırnak makasları ve pati bakım ürünleri." },
+ { "id": 1413, "name": "Köpek Eğitim Malzemeleri", "slug": "dog-training-supplies", "parentId": 1407, "description": "Eğitim ödülleri, tıkırdatıcılar, eğitim setleri ve engeller." },
+
+ { "id": 1414, "name": "Küçük Evcil Hayvan Ürünleri", "slug": "small-pet-supplies", "parentId": 1400, "description": "Kafesler, mamalar, altlıklar ve oyuncaklar (kuş, hamster, tavşan, balık vb.)." },
+ { "id": 1415, "name": "Kuş Ürünleri", "slug": "bird-supplies", "parentId": 1414, "description": "Kuş kafesleri, yemler, suluklar, oyuncaklar ve kuş kumları." },
+ { "id": 1416, "name": "Hamster & Kemirgen Ürünleri", "slug": "hamster-rodent-supplies", "parentId": 1414, "description": "Hamster kafesleri, yemler, altlıklar, tüneller ve oyuncaklar." },
+ { "id": 1417, "name": "Akvaryum & Balık Ürünleri", "slug": "aquarium-fish-supplies", "parentId": 1414, "description": "Akvaryumlar, balık yemleri, filtreler, ısıtıcılar, aydınlatma ve akvaryum dekorları." },
+
+ { "id": 1501, "name": "Mağaza Hediye Kartları", "slug": "retailer-gift-cards", "parentId": 1500, "description": "Büyük perakende zincirleri, online mağazalar ve marka mağazaları için hediye kartları." },
+ { "id": 1502, "name": "Deneyim Hediye Kartları", "slug": "experience-gift-cards", "parentId": 1500, "description": "Spa, masaj, macera parkı, workshop, uçuş deneyimi gibi özel deneyimler için hediye kartları." },
+ { "id": 1503, "name": "Restoran Hediye Kartları", "slug": "restaurant-gift-cards", "parentId": 1500, "description": "Zincir restoranlar, kafeler ve popüler yemek mekanları için hediye kartları." },
+ { "id": 1504, "name": "Dijital Hediye Kartları", "slug": "digital-gift-cards", "parentId": 1500, "description": "Çeşitli platform ve mağazalar için dijital olarak gönderilebilir hediye kodları ve e-hediye kartları." },
+ { "id": 1505, "name": "İndirim Kuponları & Fırsat Kodları", "slug": "discount-vouchers-codes", "parentId": 1500, "description": "Farklı ürün ve hizmetlerde geçerli indirim, kampanya ve promosyon kodları." },
+
+
+ { "id": 1601, "name": "Kredi Kartları", "slug": "credit-cards", "parentId": 1600, "description": "Farklı bankaların kredi kartı teklifleri, avantajları, puan kampanyaları ve özel indirimler." },
+ { "id": 1602, "name": "Bankacılık Hizmetleri", "slug": "banking-services", "parentId": 1600, "description": "Yeni hesap açma, mevduat faizleri, yatırım ürünleri ve diğer bankacılık fırsatları." },
+ { "id": 1603, "name": "Sigorta Teklifleri", "slug": "insurance-offers", "parentId": 1600, "description": "Araç, konut, seyahat, sağlık, hayat ve tamamlayıcı sağlık sigortası kampanyaları." },
+ { "id": 1604, "name": "Araç Sigortası", "slug": "car-insurance", "parentId": 1603, "description": "Kasko ve zorunlu trafik sigortası teklifleri; farklı sigorta şirketlerinden karşılaştırmalı fiyatlar." },
+ { "id": 1605, "name": "Konut Sigortası", "slug": "home-insurance", "parentId": 1603, "description": "DASK (Zorunlu Deprem Sigortası) ve konut sigortası poliçeleri, ek teminatlar." },
+ { "id": 1606, "name": "Seyahat Sigortası", "slug": "travel-insurance", "parentId": 1603, "description": "Yurt içi ve yurt dışı seyahat sağlık sigortası, seyahat iptali teminatları." },
+ { "id": 1607, "name": "Sağlık & Hayat Sigortası", "slug": "health-life-insurance", "parentId": 1603, "description": "Özel sağlık sigortası, tamamlayıcı sağlık sigortası, hayat sigortası ve bireysel emeklilik ürünleri." },
+ { "id": 1608, "name": "Krediler & Mortgage", "slug": "loans-mortgages", "parentId": 1600, "description": "İhtiyaç kredisi, konut kredisi, taşıt kredisi ve mortgage faiz oranları ve başvuru fırsatları." }
+]
\ No newline at end of file
diff --git a/prisma/categories_org.json b/prisma/categories_org.json
new file mode 100644
index 0000000..5e945a0
--- /dev/null
+++ b/prisma/categories_org.json
@@ -0,0 +1,242 @@
+[
+ { "id": 0, "name": "Undefined", "slug": "undefined", "parentId": null, "description": "Henüz sınıflandırılmamış içerikler için geçici kategori." },
+
+ { "id": 1, "name": "Elektronik", "slug": "electronics", "parentId": 0, "description": "Telefon, bilgisayar, TV, ses sistemleri ve diğer elektronik ürünler." },
+ { "id": 2, "name": "Kozmetik", "slug": "beauty", "parentId": 0, "description": "Makyaj, cilt bakımı, saç bakımı, parfüm ve kişisel bakım ürünleri." },
+ { "id": 3, "name": "Gıda", "slug": "food", "parentId": 0, "description": "Atıştırmalık, içecek, temel gıda ve market ürünleri." },
+ { "id": 4, "name": "Oto", "slug": "auto", "parentId": 0, "description": "Araç bakım, yağ, yedek parça ve oto aksesuar ürünleri." },
+ { "id": 5, "name": "Ev & Bahçe", "slug": "home-garden", "parentId": 0, "description": "Ev ihtiyaçları, dekorasyon, temizlik ve bahçe ürünleri." },
+
+ { "id": 6, "name": "Bilgisayar", "slug": "computers", "parentId": 1, "description": "Masaüstü/dizüstü bilgisayarlar, tabletler ve bilgisayar ekipmanları." },
+ { "id": 7, "name": "PC Bileşenleri", "slug": "pc-components", "parentId": 6, "description": "Bilgisayar toplama/yükseltme için işlemci, ekran kartı, RAM, depolama vb." },
+ { "id": 8, "name": "RAM", "slug": "pc-ram", "parentId": 7, "description": "Bilgisayar performansını artırmaya yönelik bellek modülleri." },
+ { "id": 9, "name": "SSD", "slug": "pc-ssd", "parentId": 7, "description": "Hızlı depolama çözümleri (NVMe/SATA) SSD diskler." },
+ { "id": 10, "name": "CPU", "slug": "pc-cpu", "parentId": 7, "description": "Bilgisayar işlemcileri; performans, oyun ve iş kullanımına yönelik modeller." },
+ { "id": 11, "name": "GPU", "slug": "pc-gpu", "parentId": 7, "description": "Ekran kartları; oyun, grafik tasarım ve video işleme için." },
+
+ { "id": 12, "name": "Bilgisayar Aksesuarları", "slug": "pc-peripherals", "parentId": 6, "description": "Klavye, mouse, webcam, mikrofon, mousepad gibi çevre birimleri." },
+ { "id": 13, "name": "Klavye", "slug": "pc-keyboard", "parentId": 12, "description": "Mekanik/membran, oyuncu ve ofis kullanımına uygun klavyeler." },
+ { "id": 14, "name": "Mouse", "slug": "pc-mouse", "parentId": 12, "description": "Kablolu/kablosuz, oyuncu ve günlük kullanım mouse modelleri." },
+ { "id": 15, "name": "Monitör", "slug": "pc-monitor", "parentId": 6, "description": "Bilgisayar monitörleri; oyun, ofis ve profesyonel kullanım seçenekleri." },
+
+ { "id": 16, "name": "Makyaj", "slug": "beauty-makeup", "parentId": 2, "description": "Ruj, fondöten, maskara ve diğer makyaj ürünleri." },
+ { "id": 17, "name": "Ruj", "slug": "beauty-lipstick", "parentId": 16, "description": "Mat, parlak, likit ve farklı renk seçeneklerinde dudak ürünleri." },
+ { "id": 18, "name": "Fondöten", "slug": "beauty-foundation", "parentId": 16, "description": "Cilt tonunu eşitleyen; mat/parlak bitişli fondöten ürünleri." },
+ { "id": 19, "name": "Maskara", "slug": "beauty-mascara", "parentId": 16, "description": "Kirpiklere hacim, uzunluk ve kıvrım kazandıran maskaralar." },
+
+ { "id": 20, "name": "Cilt Bakımı", "slug": "beauty-skincare", "parentId": 2, "description": "Nemlendirici, temizleyici, serum, güneş kremi gibi cilt bakım ürünleri." },
+ { "id": 21, "name": "Nemlendirici", "slug": "beauty-moisturizer", "parentId": 20, "description": "Cildi nemlendirip bariyeri destekleyen yüz/vücut nemlendiricileri." },
+
+ { "id": 22, "name": "Atıştırmalık", "slug": "food-snacks", "parentId": 3, "description": "Cips, kuruyemiş, bisküvi, çikolata ve benzeri atıştırmalıklar." },
+ { "id": 23, "name": "Çiğköfte", "slug": "food-cigkofte", "parentId": 22, "description": "Hazır çiğköfte ürünleri ve çiğköfte setleri." },
+
+ { "id": 24, "name": "İçecek", "slug": "food-beverages", "parentId": 3, "description": "Kahve, çay, su, gazlı içecek ve diğer içecek ürünleri." },
+ { "id": 25, "name": "Kahve", "slug": "food-coffee", "parentId": 24, "description": "Çekirdek/öğütülmüş, kapsül ve hazır kahve çeşitleri." },
+
+ { "id": 26, "name": "Yağlar", "slug": "auto-oils", "parentId": 4, "description": "Motor yağı ve araç için kullanılan diğer yağ çeşitleri." },
+ { "id": 27, "name": "Motor Yağı", "slug": "auto-engine-oil", "parentId": 26, "description": "Motoru koruyan; farklı viskozite ve onaylara sahip motor yağları." },
+
+ { "id": 28, "name": "Oto Parçaları", "slug": "auto-parts", "parentId": 4, "description": "Fren, filtre, aydınlatma ve diğer araç yedek parça ürünleri." },
+ { "id": 29, "name": "Fren Balatası", "slug": "auto-brake-pads", "parentId": 28, "description": "Araç fren sistemi için ön/arka fren balatası ürünleri." },
+
+ { "id": 30, "name": "Bahçe", "slug": "home-garden-garden", "parentId": 5, "description": "Bahçe bakımı, sulama ve dış mekân düzenleme ürünleri." },
+ { "id": 31, "name": "Sulama", "slug": "garden-irrigation", "parentId": 30, "description": "Hortum, damla sulama, sprinkler ve sulama ekipmanları." },
+
+ { "id": 32, "name": "Telefon & Aksesuarları", "slug": "phone", "parentId": 1, "description": "Akıllı telefonlar ve telefonla ilgili tüm aksesuarlar." },
+ { "id": 33, "name": "Akıllı Telefon", "slug": "phone-smartphone", "parentId": 32, "description": "Android/iOS akıllı telefonlar ve farklı marka/model seçenekleri." },
+ { "id": 34, "name": "Telefon Kılıfı", "slug": "phone-case", "parentId": 32, "description": "Cihazı koruyan silikon, sert kapak, cüzdan tipi telefon kılıfları." },
+ { "id": 35, "name": "Ekran Koruyucu", "slug": "phone-screen-protector", "parentId": 32, "description": "Cam/film ekran koruyucular; çizilme ve darbe koruması sağlar." },
+ { "id": 36, "name": "Şarj & Kablo", "slug": "phone-charging", "parentId": 32, "description": "Şarj adaptörü, kablo, hızlı şarj ekipmanları ve aksesuarları." },
+ { "id": 37, "name": "Powerbank", "slug": "phone-powerbank", "parentId": 32, "description": "Taşınabilir şarj cihazları; farklı kapasite ve hızlı şarj destekleri." },
+
+ { "id": 38, "name": "Giyilebilir Teknoloji", "slug": "wearables", "parentId": 1, "description": "Akıllı saat, bileklik ve sağlık/aktivite takibi yapan cihazlar." },
+ { "id": 39, "name": "Akıllı Saat", "slug": "wearables-smartwatch", "parentId": 38, "description": "Bildirim, sağlık takibi ve uygulama desteği sunan akıllı saatler." },
+ { "id": 40, "name": "Akıllı Bileklik", "slug": "wearables-band", "parentId": 38, "description": "Adım, uyku, nabız gibi metrikleri takip eden akıllı bileklikler." },
+
+ { "id": 41, "name": "Ses & Audio", "slug": "audio", "parentId": 1, "description": "Kulaklık, hoparlör, mikrofon, soundbar ve ses ekipmanları." },
+ { "id": 42, "name": "Kulaklık", "slug": "audio-headphones", "parentId": 41, "description": "Kulak üstü, kulak içi, kablolu/kablosuz kulaklık modelleri." },
+ { "id": 43, "name": "TWS Kulaklık", "slug": "audio-tws", "parentId": 42, "description": "Tam kablosuz (True Wireless) kulak içi kulaklıklar." },
+ { "id": 44, "name": "Bluetooth Hoparlör", "slug": "audio-bt-speaker", "parentId": 41, "description": "Taşınabilir kablosuz hoparlörler; ev ve dış mekân kullanımı için." },
+ { "id": 45, "name": "Soundbar", "slug": "audio-soundbar", "parentId": 41, "description": "TV için daha güçlü ve net ses sağlayan soundbar sistemleri." },
+ { "id": 46, "name": "Mikrofon", "slug": "audio-microphone", "parentId": 41, "description": "Yayın, toplantı ve kayıt amaçlı masaüstü/yalaka mikrofonlar." },
+ { "id": 47, "name": "Plak / Pikap", "slug": "audio-turntable", "parentId": 41, "description": "Vinyl plak ve pikap ürünleri; analog müzik ekipmanları." },
+
+ { "id": 48, "name": "TV & Video", "slug": "tv-video", "parentId": 1, "description": "Televizyonlar, projeksiyonlar, medya oynatıcılar ve TV aksesuarları." },
+ { "id": 49, "name": "Televizyon", "slug": "tv", "parentId": 48, "description": "LED/QLED/OLED televizyonlar; farklı boyut ve çözünürlük seçenekleri." },
+ { "id": 50, "name": "Projeksiyon", "slug": "projector", "parentId": 48, "description": "Ev sineması ve sunum amaçlı projeksiyon cihazları." },
+
+ { "id": 51, "name": "Medya Oynatıcı", "slug": "tv-media-player", "parentId": 48, "description": "TV’ye bağlanıp uygulama/film/dizi oynatmayı sağlayan medya cihazları (Android TV box vb.)." },
+ { "id": 52, "name": "TV Aksesuarları", "slug": "tv-accessories", "parentId": 48, "description": "TV için kumanda, askı aparatı, kablo, stand ve benzeri yardımcı aksesuarlar." },
+ { "id": 53, "name": "Uydu Alıcısı / Receiver", "slug": "tv-receiver", "parentId": 48, "description": "Uydu yayını izlemek için receiver/uydu alıcısı ve ilgili cihazlar." },
+
+ { "id": 54, "name": "Konsollar", "slug": "console", "parentId": 191, "description": "PlayStation, Xbox, Nintendo konsolları; konsol oyunları ve aksesuarları." },
+ { "id": 55, "name": "PlayStation", "slug": "console-playstation", "parentId": 54, "description": "PlayStation konsolları, oyunları, üyelikleri ve PlayStation aksesuarları." },
+ { "id": 56, "name": "Xbox", "slug": "console-xbox", "parentId": 54, "description": "Xbox konsolları, oyunları, Game Pass/abonelik ve Xbox aksesuarları." },
+ { "id": 57, "name": "Nintendo", "slug": "console-nintendo", "parentId": 54, "description": "Nintendo konsolları (Switch vb.), oyunları ve Nintendo aksesuarları." },
+ { "id": 58, "name": "Oyunlar (Konsol)", "slug": "console-games", "parentId": 54, "description": "Konsollar için fiziksel/dijital oyunlar ve oyun içerikleri." },
+ { "id": 59, "name": "Konsol Aksesuarları", "slug": "console-accessories", "parentId": 54, "description": "Kollar, şarj istasyonları, kulaklıklar, taşıma çantaları ve diğer konsol aksesuarları." },
+
+ { "id": 60, "name": "Kamera & Fotoğraf", "slug": "camera", "parentId": 1, "description": "Fotoğraf/video çekim ekipmanları; kamera gövdeleri, lensler ve aksesuarlar." },
+ { "id": 61, "name": "Fotoğraf Makinesi", "slug": "camera-photo", "parentId": 60, "description": "DSLR, aynasız ve kompakt fotoğraf makineleri." },
+ { "id": 62, "name": "Aksiyon Kamera", "slug": "camera-action", "parentId": 60, "description": "GoPro tarzı dayanıklı, suya dayanıklı ve hareketli çekime uygun aksiyon kameraları." },
+ { "id": 63, "name": "Lens", "slug": "camera-lens", "parentId": 60, "description": "Kamera lensleri; prime/zoom, geniş açı, tele, portre ve benzeri seçenekler." },
+ { "id": 64, "name": "Tripod", "slug": "camera-tripod", "parentId": 60, "description": "Fotoğraf/video için tripod, monopod ve stabil çekim destek ekipmanları." },
+
+ { "id": 65, "name": "Akıllı Ev", "slug": "smart-home", "parentId": 1, "description": "Ev otomasyonu ürünleri; aydınlatma, priz, sensör ve güvenlik çözümleri." },
+ { "id": 66, "name": "Güvenlik Kamerası", "slug": "smart-security-camera", "parentId": 65, "description": "Ev/ofis için IP kamera, iç/dış kamera ve izleme sistemleri." },
+ { "id": 67, "name": "Akıllı Priz", "slug": "smart-plug", "parentId": 65, "description": "Uygulama ile kontrol edilen, zamanlayıcı/enerji takibi sunan akıllı prizler." },
+ { "id": 68, "name": "Akıllı Ampul", "slug": "smart-bulb", "parentId": 65, "description": "Renk/ışık şiddeti kontrolü yapılabilen, Wi-Fi/Zigbee akıllı ampuller." },
+ { "id": 69, "name": "Akıllı Sensör", "slug": "smart-sensor", "parentId": 65, "description": "Kapı/pencere, hareket, sıcaklık/nem gibi verileri ölçen akıllı sensörler." },
+
+ { "id": 70, "name": "Ağ Ürünleri", "slug": "pc-networking", "parentId": 6, "description": "İnternet ve yerel ağ kurulum ürünleri; router, modem, switch, menzil genişletici." },
+ { "id": 71, "name": "Router", "slug": "pc-router", "parentId": 70, "description": "Kablosuz ağ dağıtımı için router cihazları (Wi-Fi 5/6/6E/7 vb.)." },
+ { "id": 72, "name": "Modem", "slug": "pc-modem", "parentId": 70, "description": "DSL/VDSL/FTTH uyumlu modemler ve modem-router cihazları." },
+ { "id": 73, "name": "Switch", "slug": "pc-switch", "parentId": 70, "description": "Kablolu ağ için port çoğaltan network switch cihazları." },
+ { "id": 74, "name": "Wi-Fi Extender", "slug": "pc-wifi-extender", "parentId": 70, "description": "Kablosuz ağ menzilini artıran repeater/extender ve mesh uyumlu cihazlar." },
+
+ { "id": 75, "name": "Yazıcı & Tarayıcı", "slug": "pc-printing", "parentId": 6, "description": "Ev/ofis baskı ve tarama ürünleri; yazıcı, tarayıcı ve sarf malzemeleri." },
+ { "id": 76, "name": "Yazıcı", "slug": "pc-printer", "parentId": 75, "description": "Lazer/mürekkep püskürtmeli yazıcılar ve çok fonksiyonlu cihazlar." },
+ { "id": 77, "name": "Toner & Kartuş", "slug": "pc-ink-toner", "parentId": 75, "description": "Yazıcılar için toner, kartuş, mürekkep ve ilgili sarf malzemeleri." },
+ { "id": 78, "name": "Tarayıcı", "slug": "pc-scanner", "parentId": 75, "description": "Belge ve fotoğraf taraması için flatbed/ADF tarayıcı cihazları." },
+
+ { "id": 79, "name": "Dizüstü Bilgisayar", "slug": "pc-laptop", "parentId": 6, "description": "Taşınabilir dizüstü bilgisayarlar; günlük, oyun ve iş amaçlı modeller." },
+ { "id": 80, "name": "Masaüstü Bilgisayar", "slug": "pc-desktop", "parentId": 6, "description": "Hazır masaüstü bilgisayarlar ve iş/oyun odaklı sistemler." },
+ { "id": 81, "name": "Tablet", "slug": "pc-tablet", "parentId": 6, "description": "Android/iPadOS/Windows tabletler ve tablet benzeri cihazlar." },
+
+ { "id": 82, "name": "Depolama", "slug": "pc-storage", "parentId": 6, "description": "Harici disk, USB bellek, NAS ve diğer depolama çözümleri." },
+ { "id": 83, "name": "Harici Disk", "slug": "pc-external-drive", "parentId": 82, "description": "Taşınabilir harici HDD/SSD diskler ve yedekleme çözümleri." },
+ { "id": 84, "name": "USB Flash", "slug": "pc-usb-drive", "parentId": 82, "description": "USB bellekler; farklı kapasite ve hız seçenekleri." },
+ { "id": 85, "name": "NAS", "slug": "pc-nas", "parentId": 82, "description": "Ağ üzerinden depolama ve yedekleme için NAS cihazları ve disk kutuları." },
+
+ { "id": 86, "name": "Webcam", "slug": "pc-webcam", "parentId": 12, "description": "Görüntülü görüşme ve yayın için web kameraları (1080p/2K/4K vb.)." },
+ { "id": 87, "name": "Hoparlör (PC)", "slug": "pc-speaker", "parentId": 12, "description": "Bilgisayar için masaüstü hoparlör sistemleri ve ses çözümleri." },
+ { "id": 88, "name": "Mikrofon (PC)", "slug": "pc-mic", "parentId": 12, "description": "Oyun, yayın, toplantı ve kayıt için PC uyumlu mikrofonlar." },
+ { "id": 89, "name": "Mousepad", "slug": "pc-mousepad", "parentId": 12, "description": "Mouse kullanımını iyileştiren, farklı boyut ve yüzey tiplerinde mousepadler." },
+ { "id": 90, "name": "Dock / USB Hub", "slug": "pc-dock-hub", "parentId": 12, "description": "Port çoğaltma için USB hub ve laptop dock istasyonları." },
+ { "id": 91, "name": "Laptop Çantası", "slug": "pc-laptop-bag", "parentId": 12, "description": "Dizüstü bilgisayar taşıma çantaları, kılıflar ve koruyucu çantalar." },
+ { "id": 92, "name": "Gamepad / Controller", "slug": "pc-controller", "parentId": 12, "description": "PC ile uyumlu oyun kolları ve kontrolcü aksesuarları." },
+
+ { "id": 93, "name": "Anakart", "slug": "pc-motherboard", "parentId": 7, "description": "İşlemci soketi ve chipset’e göre PC anakartları (ATX/mATX/ITX)." },
+ { "id": 94, "name": "Güç Kaynağı (PSU)", "slug": "pc-psu", "parentId": 7, "description": "Bilgisayar bileşenlerini besleyen PSU güç kaynakları (80+ sertifikalı vb.)." },
+ { "id": 95, "name": "Kasa", "slug": "pc-case", "parentId": 7, "description": "Bilgisayar kasaları; hava akışı, boyut ve tasarıma göre seçenekler." },
+
+ { "id": 96, "name": "Soğutma", "slug": "pc-cooling", "parentId": 7, "description": "CPU/GPU ve kasa soğutma çözümleri; fanlar, sıvı soğutma ve aksesuarlar." },
+ { "id": 97, "name": "Kasa Fanı", "slug": "pc-fan", "parentId": 96, "description": "Kasa içi hava akışı için fanlar (RGB/PWM vb. seçenekler)." },
+ { "id": 98, "name": "Sıvı Soğutma", "slug": "pc-liquid-cooling", "parentId": 96, "description": "AIO ve özel loop sıvı soğutma çözümleri ve bileşenleri." },
+
+ { "id": 99, "name": "Parfüm", "slug": "beauty-fragrance", "parentId": 2, "description": "Kadın/erkek parfümleri, deodorantlar ve koku ürünleri." },
+ { "id": 100, "name": "Kadın Parfüm", "slug": "beauty-fragrance-women", "parentId": 99, "description": "Kadınlara yönelik parfümler; EDT/EDP ve farklı koku profilleri." },
+ { "id": 101, "name": "Erkek Parfüm", "slug": "beauty-fragrance-men", "parentId": 99, "description": "Erkeklere yönelik parfümler; EDT/EDP, fresh/odunsu/baharatlı koku seçenekleri." },
+
+ { "id": 102, "name": "Saç Bakımı", "slug": "beauty-haircare", "parentId": 2, "description": "Saç temizliği, onarımı ve şekillendirme için saç bakım ürünleri." },
+ { "id": 103, "name": "Şampuan", "slug": "beauty-shampoo", "parentId": 102, "description": "Kepek, yağlı/kuru saç, onarıcı ve renk koruyucu şampuan çeşitleri." },
+ { "id": 104, "name": "Saç Kremi", "slug": "beauty-conditioner", "parentId": 102, "description": "Saçı yumuşatan, kolay tarama sağlayan ve bakım yapan saç kremleri." },
+ { "id": 105, "name": "Saç Şekillendirici", "slug": "beauty-hair-styling", "parentId": 102, "description": "Wax, jel, köpük, sprey ve ısı koruyucu gibi şekillendirici ürünler." },
+
+ { "id": 106, "name": "Kişisel Bakım", "slug": "beauty-personal-care", "parentId": 2, "description": "Günlük hijyen ve bakım ürünleri; deodorant, tıraş ve epilasyon gibi." },
+ { "id": 107, "name": "Deodorant", "slug": "beauty-deodorant", "parentId": 106, "description": "Ter kokusunu önlemeye yardımcı roll-on, sprey ve stick deodorantlar." },
+ { "id": 108, "name": "Tıraş Ürünleri", "slug": "beauty-shaving", "parentId": 106, "description": "Tıraş köpüğü/jeli, losyon, aftershave ve tıraş bıçağı ürünleri." },
+ { "id": 109, "name": "Ağda / Epilasyon", "slug": "beauty-hair-removal", "parentId": 106, "description": "Ağda bantları, ağda ürünleri, epilatör ve tüy alma yardımcıları." },
+
+ { "id": 110, "name": "Serum", "slug": "beauty-skincare-serum", "parentId": 20, "description": "Leke, nem, anti-aging ve aydınlatma için yoğun içerikli cilt serumları." },
+ { "id": 111, "name": "Güneş Kremi", "slug": "beauty-sunscreen", "parentId": 20, "description": "UVA/UVB koruması sağlayan yüz ve vücut güneş koruyucuları (SPF)." },
+ { "id": 112, "name": "Temizleyici", "slug": "beauty-cleanser", "parentId": 20, "description": "Jel, köpük, yağ bazlı ve micellar gibi yüz temizleme ürünleri." },
+ { "id": 113, "name": "Yüz Maskesi", "slug": "beauty-mask", "parentId": 20, "description": "Kil, kağıt ve gece maskeleri; nem, arındırma ve bakım amaçlı." },
+ { "id": 114, "name": "Tonik", "slug": "beauty-toner", "parentId": 20, "description": "Cildi dengeleyen, gözenek görünümünü destekleyen tonik ürünleri." },
+
+ { "id": 115, "name": "Temel Gıda", "slug": "food-staples", "parentId": 3, "description": "Günlük mutfak ihtiyaçları; makarna, bakliyat, yağ ve benzeri temel ürünler." },
+ { "id": 116, "name": "Makarna", "slug": "food-pasta", "parentId": 115, "description": "Spagetti, penne, erişte ve farklı çeşitlerde makarna ürünleri." },
+ { "id": 117, "name": "Pirinç & Bakliyat", "slug": "food-legumes", "parentId": 115, "description": "Pirinç, bulgur, mercimek, nohut, fasulye ve diğer bakliyatlar." },
+ { "id": 118, "name": "Yağ & Sirke (Gıda)", "slug": "food-oil-vinegar", "parentId": 115, "description": "Zeytinyağı, ayçiçek yağı ve çeşitli sirke türleri gibi ürünler." },
+
+ { "id": 119, "name": "Kahvaltılık", "slug": "food-breakfast", "parentId": 3, "description": "Peynir, zeytin, reçel, bal ve diğer kahvaltılık ürünler." },
+ { "id": 120, "name": "Peynir", "slug": "food-cheese", "parentId": 119, "description": "Beyaz peynir, kaşar, tulum ve farklı peynir çeşitleri." },
+ { "id": 121, "name": "Zeytin", "slug": "food-olive", "parentId": 119, "description": "Siyah/yeşil, çekirdekli/çekirdeksiz ve salamura zeytin çeşitleri." },
+ { "id": 122, "name": "Reçel & Bal", "slug": "food-jam-honey", "parentId": 119, "description": "Kahvaltılık reçeller, marmelatlar, bal ve benzeri tatlandırıcı ürünler." },
+
+ { "id": 123, "name": "Gazlı İçecek", "slug": "food-soda", "parentId": 24, "description": "Kola, gazoz, aromalı soda ve benzeri gazlı içecekler." },
+ { "id": 124, "name": "Su", "slug": "food-water", "parentId": 24, "description": "Pet şişe, damacana ve aromalı su seçenekleri." },
+ { "id": 125, "name": "Enerji İçeceği", "slug": "food-energy", "parentId": 24, "description": "Enerji içecekleri; farklı hacim ve kafein/taurin içerikli seçenekler." },
+ { "id": 126, "name": "Çay", "slug": "food-tea", "parentId": 24, "description": "Siyah çay, yeşil çay, bitki çayları ve aromalı çay çeşitleri." },
+
+ { "id": 127, "name": "Dondurulmuş", "slug": "food-frozen", "parentId": 3, "description": "Dondurulmuş gıdalar; sebze, hazır ürünler ve dondurulmuş atıştırmalıklar." },
+ { "id": 128, "name": "Et & Tavuk", "slug": "food-meat", "parentId": 3, "description": "Kırmızı et, tavuk ve işlenmiş et ürünleri; paketli market seçenekleri." },
+ { "id": 129, "name": "Tatlı", "slug": "food-dessert", "parentId": 3, "description": "Pastane/market tatlıları, çikolata bazlı ürünler ve tatlı çeşitleri." },
+
+ { "id": 130, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 4, "description": "Araç içi/dışı kullanım için aksesuarlar; düzenleyici, tutucu, bakım setleri vb." },
+ { "id": 131, "name": "Araç İçi Elektronik", "slug": "auto-in-car-electronics", "parentId": 130, "description": "Araç içi kamera, multimedya, şarj cihazı, FM transmitter gibi elektronik ürünler." },
+
+ { "id": 132, "name": "Oto Bakım", "slug": "auto-care", "parentId": 4, "description": "Araç bakım ürünleri; cila, wax, kaplama, temizlik ve koruma çözümleri." },
+ { "id": 133, "name": "Oto Temizlik", "slug": "auto-cleaning", "parentId": 132, "description": "İç/dış temizlik ürünleri; şampuan, köpük, bez, fırça ve temizleyiciler." },
+ { "id": 134, "name": "Lastik & Jant", "slug": "auto-tires", "parentId": 4, "description": "Lastik, jant ve ilgili aksesuarlar; mevsimlik lastikler ve bakım ürünleri." },
+ { "id": 135, "name": "Akü", "slug": "auto-battery", "parentId": 4, "description": "Otomobil aküleri ve akü takviye/şarj ekipmanları." },
+ { "id": 136, "name": "Oto Aydınlatma", "slug": "auto-lighting", "parentId": 130, "description": "Far ampulü, LED dönüşüm kitleri ve araç iç/dış aydınlatma ürünleri." },
+ { "id": 137, "name": "Oto Ses Sistemi", "slug": "auto-audio", "parentId": 130, "description": "Teyp, hoparlör, amfi, subwoofer ve araç ses sistemi ekipmanları." },
+
+ { "id": 138, "name": "Mobilya", "slug": "home-furniture", "parentId": 5, "description": "Ev mobilyaları; masa, sandalye, koltuk, yatak ve depolama ürünleri." },
+ { "id": 139, "name": "Yemek Masası", "slug": "home-dining-table", "parentId": 138, "description": "Mutfak/yemek odası için farklı boyut ve malzemelerde yemek masaları." },
+ { "id": 140, "name": "Sandalye", "slug": "home-chair", "parentId": 138, "description": "Yemek odası, çalışma ve çok amaçlı kullanım için sandalyeler." },
+ { "id": 141, "name": "Koltuk", "slug": "home-sofa", "parentId": 138, "description": "Oturma odası için koltuk, kanepe ve oturma grubu ürünleri." },
+ { "id": 142, "name": "Yatak", "slug": "home-bed", "parentId": 138, "description": "Tek/çift kişilik yatak bazası, karyola ve yatak sistemleri." },
+
+ { "id": 143, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 5, "description": "Nevresim, battaniye, perde ve diğer ev tekstili ürünleri." },
+ { "id": 144, "name": "Nevresim", "slug": "home-bedding", "parentId": 143, "description": "Nevresim takımları, çarşaflar ve yastık kılıfları." },
+ { "id": 145, "name": "Yorgan & Battaniye", "slug": "home-blanket", "parentId": 143, "description": "Isı ve konfor sağlayan yorgan, battaniye ve uyku ürünleri." },
+ { "id": 146, "name": "Perde", "slug": "home-curtain", "parentId": 143, "description": "Tül, fon ve stor gibi farklı perde çeşitleri ve aksesuarları." },
+
+ { "id": 147, "name": "Mutfak", "slug": "home-kitchen", "parentId": 5, "description": "Mutfak gereçleri, pişirme ekipmanları ve küçük ev aletleri." },
+ { "id": 148, "name": "Tencere & Tava", "slug": "home-cookware", "parentId": 147, "description": "Tencere setleri, tava çeşitleri ve pişirme ekipmanları." },
+ { "id": 149, "name": "Küçük Ev Aletleri", "slug": "home-small-appliances", "parentId": 147, "description": "Mutfakta kullanılan küçük elektrikli aletler; kahve makinesi, blender vb." },
+ { "id": 150, "name": "Kahve Makinesi", "slug": "home-coffee-machine", "parentId": 149, "description": "Filtre, espresso, kapsül ve Türk kahvesi makineleri." },
+ { "id": 151, "name": "Blender", "slug": "home-blender", "parentId": 149, "description": "Smoothie, çorba ve karıştırma işlemleri için blender ve el blender setleri." },
+ { "id": 152, "name": "Airfryer", "slug": "home-airfryer", "parentId": 149, "description": "Az yağ ile pişirme yapmaya yarayan airfryer cihazları ve aksesuarları." },
+ { "id": 153, "name": "Süpürge", "slug": "home-vacuum", "parentId": 149, "description": "Dikey, toz torbalı/torbasız ve robot süpürge dahil ev süpürgeleri." },
+
+ { "id": 154, "name": "Aydınlatma", "slug": "home-lighting", "parentId": 5, "description": "Avize, lambader, masa lambası ve LED aydınlatma çözümleri." },
+ { "id": 155, "name": "Dekorasyon", "slug": "home-decor", "parentId": 5, "description": "Evi kişiselleştiren dekoratif ürünler; aksesuar, tablo, obje ve benzerleri." },
+ { "id": 156, "name": "Halı", "slug": "home-rug", "parentId": 155, "description": "Salon, koridor ve oda için halılar; farklı ölçü ve materyal seçenekleri." },
+ { "id": 157, "name": "Duvar Dekoru", "slug": "home-wall-decor", "parentId": 155, "description": "Tablo, raf, ayna, sticker ve benzeri duvar dekor ürünleri." },
+
+ { "id": 158, "name": "Temizlik", "slug": "home-cleaning", "parentId": 5, "description": "Ev temizliği için ürünler; deterjan, bez, sünger ve temizlik ekipmanları." },
+ { "id": 159, "name": "Deterjan", "slug": "home-detergent", "parentId": 158, "description": "Çamaşır, bulaşık ve yüzey temizliği için deterjan ve temizlik kimyasalları." },
+ { "id": 160, "name": "Kağıt Ürünleri", "slug": "home-paper-products", "parentId": 158, "description": "Tuvalet kağıdı, kağıt havlu, peçete ve benzeri kağıt temizlik ürünleri." },
+
+ { "id": 161, "name": "El Aletleri", "slug": "home-tools", "parentId": 5, "description": "Ev ve hobi işleri için el aletleri, tamir ve montaj ekipmanları." },
+ { "id": 162, "name": "Matkap", "slug": "home-drill", "parentId": 161, "description": "Darbeli/darbesiz, şarjlı/kablolu matkap ve vidalama makineleri." },
+ { "id": 163, "name": "Testere", "slug": "home-saw", "parentId": 161, "description": "Ahşap/metal kesim için el testereleri ve elektrikli testere çeşitleri." },
+ { "id": 164, "name": "Vida & Dübel", "slug": "home-hardware", "parentId": 161, "description": "Montaj ve sabitleme için vida, dübel, bağlantı elemanları ve setler." },
+
+ { "id": 165, "name": "Evcil Hayvan", "slug": "pet", "parentId": 5, "description": "Kedi, köpek ve diğer evcil hayvanlar için mama, bakım ve ihtiyaç ürünleri." },
+ { "id": 166, "name": "Kedi Maması", "slug": "pet-cat-food", "parentId": 165, "description": "Yavru/yetişkin kedi için kuru/yaş mama ve özel diyet mamaları." },
+ { "id": 167, "name": "Köpek Maması", "slug": "pet-dog-food", "parentId": 165, "description": "Yavru/yetişkin köpek için kuru/yaş mama ve özel ihtiyaç mamaları." },
+ { "id": 168, "name": "Kedi Kumu", "slug": "pet-cat-litter", "parentId": 165, "description": "Topaklanan/silikalı/bitkisel kedi kumları ve koku kontrol çözümleri." },
+
+ { "id": 169, "name": "Kırtasiye & Ofis", "slug": "office", "parentId": 0, "description": "Okul ve ofis ihtiyaçları; kağıt ürünleri, yazım gereçleri ve aksesuarlar." },
+ { "id": 170, "name": "Kağıt & Defter", "slug": "office-paper-notebook", "parentId": 169, "description": "Defter, ajanda, not kağıdı ve farklı türde kağıt ürünleri." },
+ { "id": 171, "name": "A4 Kağıdı", "slug": "office-a4-paper", "parentId": 170, "description": "Yazıcı ve fotokopi için A4 kağıt; farklı gramaj ve kalite seçenekleri." },
+ { "id": 172, "name": "Kalem", "slug": "office-pen", "parentId": 169, "description": "Tükenmez, jel, kurşun, marker ve farklı amaçlara uygun kalemler." },
+ { "id": 173, "name": "Okul Çantası", "slug": "office-school-bag", "parentId": 169, "description": "Öğrenciler için sırt çantası, beslenme çantası ve okul çantaları." },
+
+ { "id": 174, "name": "Bebek & Çocuk", "slug": "baby", "parentId": 0, "description": "Bebek ve çocuk bakım/bez, mama, ıslak mendil ve oyuncak ürünleri." },
+ { "id": 175, "name": "Bebek Bezi", "slug": "baby-diaper", "parentId": 174, "description": "Yeni doğan ve farklı bedenlerde bebek bezleri, külot bez seçenekleri." },
+ { "id": 176, "name": "Islak Mendil", "slug": "baby-wipes", "parentId": 174, "description": "Bebek bakımı için ıslak mendil; hassas cilt uyumlu seçenekler." },
+ { "id": 177, "name": "Bebek Maması", "slug": "baby-food", "parentId": 174, "description": "Bebekler için mama, ek gıda ve püre ürünleri." },
+ { "id": 178, "name": "Oyuncak", "slug": "baby-toys", "parentId": 174, "description": "Bebek ve çocuklar için eğitici, zeka ve oyun oyuncakları." },
+
+ { "id": 179, "name": "Spor & Outdoor", "slug": "sports", "parentId": 0, "description": "Spor ekipmanları ve outdoor ürünleri; kamp, fitness, bisiklet ve daha fazlası." },
+ { "id": 180, "name": "Kamp", "slug": "sports-camping", "parentId": 179, "description": "Çadır, uyku tulumu, kamp sandalyesi ve kamp ekipmanları." },
+ { "id": 181, "name": "Fitness", "slug": "sports-fitness", "parentId": 179, "description": "Ağırlık, dambıl, mat ve evde antrenman için fitness ekipmanları." },
+ { "id": 182, "name": "Bisiklet", "slug": "sports-bicycle", "parentId": 179, "description": "Şehir/dağ/katlanır bisikletler ve bisiklet aksesuarları." },
+
+ { "id": 183, "name": "Moda", "slug": "fashion", "parentId": 0, "description": "Giyim, ayakkabı ve aksesuar ürünleri; kadın/erkek moda kategorileri." },
+ { "id": 184, "name": "Ayakkabı", "slug": "fashion-shoes", "parentId": 183, "description": "Spor ayakkabı, günlük ayakkabı ve farklı kullanım amaçlarına uygun modeller." },
+ { "id": 185, "name": "Erkek Giyim", "slug": "fashion-men", "parentId": 183, "description": "Erkek kıyafetleri; tişört, gömlek, pantolon, mont ve daha fazlası." },
+ { "id": 186, "name": "Kadın Giyim", "slug": "fashion-women", "parentId": 183, "description": "Kadın kıyafetleri; elbise, bluz, pantolon, mont ve daha fazlası." },
+ { "id": 187, "name": "Çanta", "slug": "fashion-bags", "parentId": 183, "description": "Sırt çantası, el çantası, valiz ve farklı kullanım amaçlı çantalar." },
+
+ { "id": 188, "name": "Kitap & Medya", "slug": "books-media", "parentId": 0, "description": "Kitaplar, dijital içerikler, oyun ve medya ürünleri." },
+ { "id": 189, "name": "Kitap", "slug": "books", "parentId": 188, "description": "Roman, kişisel gelişim, eğitim ve diğer türlerde basılı kitaplar." },
+ { "id": 190, "name": "Dijital Oyun (Genel)", "slug": "digital-games", "parentId": 191, "description": "PC/konsol platformları için dijital oyunlar, kodlar ve dijital içerikler." },
+ { "id": 191, "name": "Oyun", "slug": "games", "parentId": 0, "description": "Konsol, PC ve dijital oyun fırsatları; oyun ekipmanları ve abonelikler." }
+
+]
diff --git a/prisma/deals.json b/prisma/deals.json
index db20382..ed567dd 100644
--- a/prisma/deals.json
+++ b/prisma/deals.json
@@ -1,230 +1,7599 @@
[
{
- "title": "Samsung 990 PRO 1TB NVMe SSD",
+ "title": "Samsung 990 PRO 1TB NVMe SSD #1",
"price": 3299.99,
"originalPrice": 3799.99,
- "url": "https://example.com/samsung-990pro-1tb",
+ "url": "https://example.com/samsung-990pro-1tb?v=1",
"q": "nvme ssd"
},
{
- "title": "Logitech MX Master 3S Mouse",
+ "title": "Logitech MX Master 3S Mouse #2",
"price": 2499.9,
"originalPrice": 2999.9,
- "url": "https://example.com/mx-master-3s",
+ "url": "https://example.com/mx-master-3s?v=2",
"q": "wireless mouse"
},
{
- "title": "Sony WH-1000XM5 Kulaklık",
+ "title": "Sony WH-1000XM5 Kulaklık #3",
"price": 9999.0,
"originalPrice": 11999.0,
"shippingPrice": 0,
- "url": "https://example.com/sony-xm5",
+ "url": "https://example.com/sony-xm5?v=3",
"q": "headphones"
},
{
- "title": "Apple AirPods Pro 2",
+ "title": "Apple AirPods Pro 2 #4",
"price": 8499.0,
"originalPrice": 9999.0,
- "url": "https://example.com/airpods-pro-2",
+ "url": "https://example.com/airpods-pro-2?v=4",
"q": "earbuds"
},
{
- "title": "Anker 65W GaN Şarj Aleti",
+ "title": "Anker 65W GaN Şarj Aleti #5",
"price": 899.0,
"shippingPrice": 39.9,
- "url": "https://example.com/anker-65w-gan",
+ "url": "https://example.com/anker-65w-gan?v=5",
"q": "charger"
},
{
- "title": "Kindle Paperwhite 16GB",
+ "title": "Kindle Paperwhite 16GB #6",
"price": 5199.0,
"originalPrice": 5999.0,
"shippingPrice": 0,
- "url": "https://example.com/kindle-paperwhite",
+ "url": "https://example.com/kindle-paperwhite?v=6",
"q": "ebook reader"
},
{
- "title": "Dell 27\" 144Hz Monitör",
+ "title": "Dell 27\" 144Hz Monitör #7",
"price": 7999.0,
"originalPrice": 9499.0,
"shippingPrice": 0,
- "url": "https://example.com/dell-27-144hz",
+ "url": "https://example.com/dell-27-144hz?v=7",
"q": "gaming monitor"
},
{
- "title": "TP-Link Wi-Fi 6 Router",
+ "title": "TP-Link Wi-Fi 6 Router #8",
"price": 1999.0,
"shippingPrice": 29.9,
- "url": "https://example.com/tplink-wifi6",
+ "url": "https://example.com/tplink-wifi6?v=8",
"q": "wifi router"
},
{
- "title": "Razer Huntsman Mini Klavye",
+ "title": "Razer Huntsman Mini Klavye #9",
"price": 3499.0,
"originalPrice": 3999.0,
- "url": "https://example.com/huntsman-mini",
+ "url": "https://example.com/huntsman-mini?v=9",
"q": "mechanical keyboard"
},
{
- "title": "WD Elements 2TB Harici Disk",
+ "title": "WD Elements 2TB Harici Disk #10",
"price": 2399.0,
"shippingPrice": 49.9,
- "url": "https://example.com/wd-elements-2tb",
+ "url": "https://example.com/wd-elements-2tb?v=10",
"q": "external hard drive"
},
{
- "title": "Samsung T7 Shield 1TB SSD",
+ "title": "Samsung T7 Shield 1TB SSD #11",
"price": 2799.0,
"originalPrice": 3299.0,
"shippingPrice": 0,
- "url": "https://example.com/samsung-t7-shield",
+ "url": "https://example.com/samsung-t7-shield?v=11",
"q": "portable ssd"
},
{
- "title": "Xiaomi Mi Band 8",
+ "title": "Xiaomi Mi Band 8 #12",
"price": 1399.0,
"originalPrice": 1699.0,
"shippingPrice": 0,
- "url": "https://example.com/mi-band-8",
+ "url": "https://example.com/mi-band-8?v=12",
"q": "smart band"
},
{
- "title": "Philips Airfryer 6.2L",
+ "title": "Philips Airfryer 6.2L #13",
"price": 5999.0,
"originalPrice": 7499.0,
- "url": "https://example.com/philips-airfryer",
+ "url": "https://example.com/philips-airfryer?v=13",
"q": "air fryer"
},
{
- "title": "Dyson V12 Detect Slim",
+ "title": "Dyson V12 Detect Slim #14",
"price": 21999.0,
"originalPrice": 25999.0,
"shippingPrice": 0,
- "url": "https://example.com/dyson-v12",
+ "url": "https://example.com/dyson-v12?v=14",
"q": "vacuum cleaner"
},
{
- "title": "Nespresso Vertuo Kahve Makinesi",
+ "title": "Nespresso Vertuo Kahve Makinesi #15",
"price": 6999.0,
"originalPrice": 8499.0,
"shippingPrice": 0,
- "url": "https://example.com/nespresso-vertuo",
+ "url": "https://example.com/nespresso-vertuo?v=15",
"q": "coffee machine"
},
{
- "title": "Nintendo Switch OLED 64GB",
+ "title": "Nintendo Switch OLED 64GB #16",
"price": 11999.0,
"originalPrice": 13999.0,
"shippingPrice": 0,
- "url": "https://example.com/nintendo-switch-oled",
+ "url": "https://example.com/nintendo-switch-oled?v=16",
"q": "game console"
},
{
- "title": "PlayStation 5 DualSense Controller",
+ "title": "PlayStation 5 DualSense Controller #17",
"price": 2499.0,
"originalPrice": 2999.0,
- "url": "https://example.com/ps5-dualsense",
+ "url": "https://example.com/ps5-dualsense?v=17",
"q": "game controller"
},
{
- "title": "Xbox Game Pass 3 Aylık Üyelik",
+ "title": "Xbox Game Pass 3 Aylık Üyelik #18",
"price": 699.0,
"originalPrice": 899.0,
- "url": "https://example.com/xbox-game-pass-3m",
+ "url": "https://example.com/xbox-game-pass-3m?v=18",
"q": "subscription"
},
{
- "title": "JBL Flip 6 Bluetooth Hoparlör",
+ "title": "JBL Flip 6 Bluetooth Hoparlör #19",
"price": 3499.0,
"originalPrice": 4299.0,
"shippingPrice": 0,
- "url": "https://example.com/jbl-flip-6",
+ "url": "https://example.com/jbl-flip-6?v=19",
"q": "bluetooth speaker"
},
{
- "title": "ASUS TUF Gaming RTX 4060 8GB",
+ "title": "ASUS TUF Gaming RTX 4060 8GB #20",
"price": 16999.0,
"originalPrice": 19999.0,
"shippingPrice": 0,
- "url": "https://example.com/rtx-4060-asus-tuf",
+ "url": "https://example.com/rtx-4060-asus-tuf?v=20",
"q": "graphics card"
},
{
- "title": "Corsair Vengeance 32GB (2x16) DDR5 6000",
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #21",
"price": 3999.0,
"originalPrice": 4999.0,
- "url": "https://example.com/corsair-ddr5-32gb-6000",
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=21",
"q": "ram memory"
},
{
- "title": "Samsung 55\" 4K Smart TV (Crystal UHD)",
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #22",
"price": 18999.0,
"originalPrice": 22999.0,
"shippingPrice": 0,
- "url": "https://example.com/samsung-55-4k-tv",
+ "url": "https://example.com/samsung-55-4k-tv?v=22",
"q": "television"
},
{
- "title": "LG 27\" 4K IPS Monitör",
+ "title": "LG 27\" 4K IPS Monitör #23",
"price": 10999.0,
"originalPrice": 12999.0,
"shippingPrice": 0,
- "url": "https://example.com/lg-27-4k-ips",
+ "url": "https://example.com/lg-27-4k-ips?v=23",
"q": "4k monitor"
},
{
- "title": "Roborock S8 Robot Süpürge",
+ "title": "Roborock S8 Robot Süpürge #24",
"price": 24999.0,
"originalPrice": 29999.0,
"shippingPrice": 0,
- "url": "https://example.com/roborock-s8",
+ "url": "https://example.com/roborock-s8?v=24",
"q": "robot vacuum"
},
{
- "title": "Tefal Ultragliss Buharlı Ütü",
+ "title": "Tefal Ultragliss Buharlı Ütü #25",
"price": 1799.0,
"originalPrice": 2199.0,
"shippingPrice": 29.9,
- "url": "https://example.com/tefal-ultragliss-iron",
+ "url": "https://example.com/tefal-ultragliss-iron?v=25",
"q": "steam iron"
},
{
- "title": "Brita Marella XL Su Arıtma Sürahisi",
+ "title": "Brita Marella XL Su Arıtma Sürahisi #26",
"price": 899.0,
"originalPrice": 1099.0,
"shippingPrice": 19.9,
- "url": "https://example.com/brita-marella-xl",
+ "url": "https://example.com/brita-marella-xl?v=26",
"q": "water filter"
},
{
- "title": "IKEA Markus Ofis Koltuğu",
+ "title": "IKEA Markus Ofis Koltuğu #27",
"price": 4999.0,
"shippingPrice": 59.9,
- "url": "https://example.com/ikea-markus",
+ "url": "https://example.com/ikea-markus?v=27",
"q": "office chair"
},
{
- "title": "Xiaomi Redmi Note 13 256GB",
+ "title": "Xiaomi Redmi Note 13 256GB #28",
"price": 9999.0,
"originalPrice": 11999.0,
"shippingPrice": 0,
- "url": "https://example.com/redmi-note-13-256",
+ "url": "https://example.com/redmi-note-13-256?v=28",
"q": "smartphone"
},
{
- "title": "Garmin Forerunner 255",
+ "title": "Garmin Forerunner 255 #29",
"price": 8999.0,
"originalPrice": 10499.0,
"shippingPrice": 0,
- "url": "https://example.com/garmin-fr-255",
+ "url": "https://example.com/garmin-fr-255?v=29",
"q": "sports watch"
},
{
- "title": "Philips Hue Starter Kit (3 Ampul + Bridge)",
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #30",
"price": 4499.0,
"originalPrice": 5499.0,
"shippingPrice": 0,
- "url": "https://example.com/philips-hue-starter",
+ "url": "https://example.com/philips-hue-starter?v=30",
"q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #31",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=31",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #32",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=32",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #33",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=33",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #34",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=34",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #35",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=35",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #36",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=36",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #37",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=37",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #38",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=38",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #39",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=39",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #40",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=40",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #41",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=41",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #42",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=42",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #43",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=43",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #44",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=44",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #45",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=45",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #46",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=46",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #47",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=47",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #48",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=48",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #49",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=49",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #50",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=50",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #51",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=51",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #52",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=52",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #53",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=53",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #54",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=54",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #55",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=55",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #56",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=56",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #57",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=57",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #58",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=58",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #59",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=59",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #60",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=60",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #61",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=61",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #62",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=62",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #63",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=63",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #64",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=64",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #65",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=65",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #66",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=66",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #67",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=67",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #68",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=68",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #69",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=69",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #70",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=70",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #71",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=71",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #72",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=72",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #73",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=73",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #74",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=74",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #75",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=75",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #76",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=76",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #77",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=77",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #78",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=78",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #79",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=79",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #80",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=80",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #81",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=81",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #82",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=82",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #83",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=83",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #84",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=84",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #85",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=85",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #86",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=86",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #87",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=87",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #88",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=88",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #89",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=89",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #90",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=90",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #91",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=91",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #92",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=92",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #93",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=93",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #94",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=94",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #95",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=95",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #96",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=96",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #97",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=97",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #98",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=98",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #99",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=99",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #100",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=100",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #101",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=101",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #102",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=102",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #103",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=103",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #104",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=104",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #105",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=105",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #106",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=106",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #107",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=107",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #108",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=108",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #109",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=109",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #110",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=110",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #111",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=111",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #112",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=112",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #113",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=113",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #114",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=114",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #115",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=115",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #116",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=116",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #117",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=117",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #118",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=118",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #119",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=119",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #120",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=120",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #121",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=121",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #122",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=122",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #123",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=123",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #124",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=124",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #125",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=125",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #126",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=126",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #127",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=127",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #128",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=128",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #129",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=129",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #130",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=130",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #131",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=131",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #132",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=132",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #133",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=133",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #134",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=134",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #135",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=135",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #136",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=136",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #137",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=137",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #138",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=138",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #139",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=139",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #140",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=140",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #141",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=141",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #142",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=142",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #143",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=143",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #144",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=144",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #145",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=145",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #146",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=146",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #147",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=147",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #148",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=148",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #149",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=149",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #150",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=150",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #151",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=151",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #152",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=152",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #153",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=153",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #154",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=154",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #155",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=155",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #156",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=156",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #157",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=157",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #158",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=158",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #159",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=159",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #160",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=160",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #161",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=161",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #162",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=162",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #163",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=163",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #164",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=164",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #165",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=165",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #166",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=166",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #167",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=167",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #168",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=168",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #169",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=169",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #170",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=170",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #171",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=171",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #172",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=172",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #173",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=173",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #174",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=174",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #175",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=175",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #176",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=176",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #177",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=177",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #178",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=178",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #179",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=179",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #180",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=180",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #181",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=181",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #182",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=182",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #183",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=183",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #184",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=184",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #185",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=185",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #186",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=186",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #187",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=187",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #188",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=188",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #189",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=189",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #190",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=190",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #191",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=191",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #192",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=192",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #193",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=193",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #194",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=194",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #195",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=195",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #196",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=196",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #197",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=197",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #198",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=198",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #199",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=199",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #200",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=200",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #201",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=201",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #202",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=202",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #203",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=203",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #204",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=204",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #205",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=205",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #206",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=206",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #207",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=207",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #208",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=208",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #209",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=209",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #210",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=210",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #211",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=211",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #212",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=212",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #213",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=213",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #214",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=214",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #215",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=215",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #216",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=216",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #217",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=217",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #218",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=218",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #219",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=219",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #220",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=220",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #221",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=221",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #222",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=222",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #223",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=223",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #224",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=224",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #225",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=225",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #226",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=226",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #227",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=227",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #228",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=228",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #229",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=229",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #230",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=230",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #231",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=231",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #232",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=232",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #233",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=233",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #234",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=234",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #235",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=235",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #236",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=236",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #237",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=237",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #238",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=238",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #239",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=239",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #240",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=240",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #241",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=241",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #242",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=242",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #243",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=243",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #244",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=244",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #245",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=245",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #246",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=246",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #247",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=247",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #248",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=248",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #249",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=249",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #250",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=250",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #251",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=251",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #252",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=252",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #253",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=253",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #254",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=254",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #255",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=255",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #256",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=256",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #257",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=257",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #258",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=258",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #259",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=259",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #260",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=260",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #261",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=261",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #262",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=262",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #263",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=263",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #264",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=264",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #265",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=265",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #266",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=266",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #267",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=267",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #268",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=268",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #269",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=269",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #270",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=270",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #271",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=271",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #272",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=272",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #273",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=273",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #274",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=274",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #275",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=275",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #276",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=276",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #277",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=277",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #278",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=278",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #279",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=279",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #280",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=280",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #281",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=281",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #282",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=282",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #283",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=283",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #284",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=284",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #285",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=285",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #286",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=286",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #287",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=287",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #288",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=288",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #289",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=289",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #290",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=290",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #291",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=291",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #292",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=292",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #293",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=293",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #294",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=294",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #295",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=295",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #296",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=296",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #297",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=297",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #298",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=298",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #299",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=299",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #300",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=300",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #301",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=301",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #302",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=302",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #303",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=303",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #304",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=304",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #305",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=305",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #306",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=306",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #307",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=307",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #308",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=308",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #309",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=309",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #310",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=310",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #311",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=311",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #312",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=312",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #313",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=313",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #314",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=314",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #315",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=315",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #316",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=316",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #317",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=317",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #318",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=318",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #319",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=319",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #320",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=320",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #321",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=321",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #322",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=322",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #323",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=323",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #324",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=324",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #325",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=325",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #326",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=326",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #327",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=327",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #328",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=328",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #329",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=329",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #330",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=330",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #331",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=331",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #332",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=332",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #333",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=333",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #334",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=334",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #335",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=335",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #336",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=336",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #337",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=337",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #338",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=338",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #339",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=339",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #340",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=340",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #341",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=341",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #342",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=342",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #343",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=343",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #344",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=344",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #345",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=345",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #346",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=346",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #347",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=347",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #348",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=348",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #349",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=349",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #350",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=350",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #351",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=351",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #352",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=352",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #353",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=353",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #354",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=354",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #355",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=355",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #356",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=356",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #357",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=357",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #358",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=358",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #359",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=359",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #360",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=360",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #361",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=361",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #362",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=362",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #363",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=363",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #364",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=364",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #365",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=365",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #366",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=366",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #367",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=367",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #368",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=368",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #369",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=369",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #370",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=370",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #371",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=371",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #372",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=372",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #373",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=373",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #374",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=374",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #375",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=375",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #376",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=376",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #377",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=377",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #378",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=378",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #379",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=379",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #380",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=380",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #381",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=381",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #382",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=382",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #383",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=383",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #384",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=384",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #385",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=385",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #386",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=386",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #387",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=387",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #388",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=388",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #389",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=389",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #390",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=390",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #391",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=391",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #392",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=392",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #393",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=393",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #394",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=394",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #395",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=395",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #396",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=396",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #397",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=397",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #398",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=398",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #399",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=399",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #400",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=400",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #401",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=401",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #402",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=402",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #403",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=403",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #404",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=404",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #405",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=405",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #406",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=406",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #407",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=407",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #408",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=408",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #409",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=409",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #410",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=410",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #411",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=411",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #412",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=412",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #413",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=413",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #414",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=414",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #415",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=415",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #416",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=416",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #417",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=417",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #418",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=418",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #419",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=419",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #420",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=420",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #421",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=421",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #422",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=422",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #423",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=423",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #424",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=424",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #425",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=425",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #426",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=426",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #427",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=427",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #428",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=428",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #429",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=429",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #430",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=430",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #431",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=431",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #432",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=432",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #433",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=433",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #434",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=434",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #435",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=435",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #436",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=436",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #437",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=437",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #438",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=438",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #439",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=439",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #440",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=440",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #441",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=441",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #442",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=442",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #443",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=443",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #444",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=444",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #445",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=445",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #446",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=446",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #447",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=447",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #448",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=448",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #449",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=449",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #450",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=450",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #451",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=451",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #452",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=452",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #453",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=453",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #454",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=454",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #455",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=455",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #456",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=456",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #457",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=457",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #458",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=458",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #459",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=459",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #460",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=460",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #461",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=461",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #462",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=462",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #463",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=463",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #464",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=464",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #465",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=465",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #466",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=466",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #467",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=467",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #468",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=468",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #469",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=469",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #470",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=470",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #471",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=471",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #472",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=472",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #473",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=473",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #474",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=474",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #475",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=475",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #476",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=476",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #477",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=477",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #478",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=478",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #479",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=479",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #480",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=480",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #481",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=481",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #482",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=482",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #483",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=483",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #484",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=484",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #485",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=485",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #486",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=486",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #487",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=487",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #488",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=488",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #489",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=489",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #490",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=490",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #491",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=491",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #492",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=492",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #493",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=493",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #494",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=494",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #495",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=495",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #496",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=496",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #497",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=497",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #498",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=498",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #499",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=499",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #500",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=500",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #501",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=501",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #502",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=502",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #503",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=503",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #504",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=504",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #505",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=505",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #506",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=506",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #507",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=507",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #508",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=508",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #509",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=509",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #510",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=510",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #511",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=511",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #512",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=512",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #513",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=513",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #514",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=514",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #515",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=515",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #516",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=516",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #517",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=517",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #518",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=518",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #519",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=519",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #520",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=520",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #521",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=521",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #522",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=522",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #523",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=523",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #524",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=524",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #525",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=525",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #526",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=526",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #527",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=527",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #528",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=528",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #529",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=529",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #530",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=530",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #531",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=531",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #532",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=532",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #533",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=533",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #534",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=534",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #535",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=535",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #536",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=536",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #537",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=537",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #538",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=538",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #539",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=539",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #540",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=540",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #541",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=541",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #542",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=542",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #543",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=543",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #544",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=544",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #545",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=545",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #546",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=546",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #547",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=547",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #548",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=548",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #549",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=549",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #550",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=550",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #551",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=551",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #552",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=552",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #553",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=553",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #554",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=554",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #555",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=555",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #556",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=556",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #557",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=557",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #558",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=558",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #559",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=559",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #560",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=560",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #561",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=561",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #562",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=562",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #563",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=563",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #564",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=564",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #565",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=565",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #566",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=566",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #567",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=567",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #568",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=568",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #569",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=569",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #570",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=570",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #571",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=571",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #572",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=572",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #573",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=573",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #574",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=574",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #575",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=575",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #576",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=576",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #577",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=577",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #578",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=578",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #579",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=579",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #580",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=580",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #581",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=581",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #582",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=582",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #583",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=583",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #584",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=584",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #585",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=585",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #586",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=586",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #587",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=587",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #588",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=588",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #589",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=589",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #590",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=590",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #591",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=591",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #592",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=592",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #593",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=593",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #594",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=594",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #595",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=595",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #596",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=596",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #597",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=597",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #598",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=598",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #599",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=599",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #600",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=600",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #601",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=601",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #602",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=602",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #603",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=603",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #604",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=604",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #605",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=605",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #606",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=606",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #607",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=607",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #608",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=608",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #609",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=609",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #610",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=610",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #611",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=611",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #612",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=612",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #613",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=613",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #614",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=614",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #615",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=615",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #616",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=616",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #617",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=617",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #618",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=618",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #619",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=619",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #620",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=620",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #621",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=621",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #622",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=622",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #623",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=623",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #624",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=624",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #625",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=625",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #626",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=626",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #627",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=627",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #628",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=628",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #629",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=629",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #630",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=630",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #631",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=631",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #632",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=632",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #633",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=633",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #634",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=634",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #635",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=635",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #636",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=636",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #637",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=637",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #638",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=638",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #639",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=639",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #640",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=640",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #641",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=641",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #642",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=642",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #643",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=643",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #644",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=644",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #645",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=645",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #646",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=646",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #647",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=647",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #648",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=648",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #649",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=649",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #650",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=650",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #651",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=651",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #652",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=652",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #653",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=653",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #654",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=654",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #655",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=655",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #656",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=656",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #657",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=657",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #658",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=658",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #659",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=659",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #660",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=660",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #661",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=661",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #662",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=662",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #663",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=663",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #664",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=664",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #665",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=665",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #666",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=666",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #667",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=667",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #668",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=668",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #669",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=669",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #670",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=670",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #671",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=671",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #672",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=672",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #673",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=673",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #674",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=674",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #675",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=675",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #676",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=676",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #677",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=677",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #678",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=678",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #679",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=679",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #680",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=680",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #681",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=681",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #682",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=682",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #683",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=683",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #684",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=684",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #685",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=685",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #686",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=686",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #687",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=687",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #688",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=688",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #689",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=689",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #690",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=690",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #691",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=691",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #692",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=692",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #693",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=693",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #694",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=694",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #695",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=695",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #696",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=696",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #697",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=697",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #698",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=698",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #699",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=699",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #700",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=700",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #701",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=701",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #702",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=702",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #703",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=703",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #704",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=704",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #705",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=705",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #706",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=706",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #707",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=707",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #708",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=708",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #709",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=709",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #710",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=710",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #711",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=711",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #712",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=712",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #713",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=713",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #714",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=714",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #715",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=715",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #716",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=716",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #717",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=717",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #718",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=718",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #719",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=719",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #720",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=720",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #721",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=721",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #722",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=722",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #723",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=723",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #724",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=724",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #725",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=725",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #726",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=726",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #727",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=727",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #728",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=728",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #729",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=729",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #730",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=730",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #731",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=731",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #732",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=732",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #733",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=733",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #734",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=734",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #735",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=735",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #736",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=736",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #737",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=737",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #738",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=738",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #739",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=739",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #740",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=740",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #741",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=741",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #742",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=742",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #743",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=743",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #744",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=744",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #745",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=745",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #746",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=746",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #747",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=747",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #748",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=748",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #749",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=749",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #750",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=750",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #751",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=751",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #752",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=752",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #753",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=753",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #754",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=754",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #755",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=755",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #756",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=756",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #757",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=757",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #758",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=758",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #759",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=759",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #760",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=760",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #761",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=761",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #762",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=762",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #763",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=763",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #764",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=764",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #765",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=765",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #766",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=766",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #767",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=767",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #768",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=768",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #769",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=769",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #770",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=770",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #771",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=771",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #772",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=772",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #773",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=773",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #774",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=774",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #775",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=775",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #776",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=776",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #777",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=777",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #778",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=778",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #779",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=779",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #780",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=780",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #781",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=781",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #782",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=782",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #783",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=783",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #784",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=784",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #785",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=785",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #786",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=786",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #787",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=787",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #788",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=788",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #789",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=789",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #790",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=790",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #791",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=791",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #792",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=792",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #793",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=793",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #794",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=794",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #795",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=795",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #796",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=796",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #797",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=797",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #798",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=798",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #799",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=799",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #800",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=800",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #801",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=801",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #802",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=802",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #803",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=803",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #804",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=804",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #805",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=805",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #806",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=806",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #807",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=807",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #808",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=808",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #809",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=809",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #810",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=810",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #811",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=811",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #812",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=812",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #813",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=813",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #814",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=814",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #815",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=815",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #816",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=816",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #817",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=817",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #818",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=818",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #819",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=819",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #820",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=820",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #821",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=821",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #822",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=822",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #823",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=823",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #824",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=824",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #825",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=825",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #826",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=826",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #827",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=827",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #828",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=828",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #829",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=829",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #830",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=830",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #831",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=831",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #832",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=832",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #833",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=833",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #834",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=834",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #835",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=835",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #836",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=836",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #837",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=837",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #838",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=838",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #839",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=839",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #840",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=840",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #841",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=841",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #842",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=842",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #843",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=843",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #844",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=844",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #845",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=845",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #846",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=846",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #847",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=847",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #848",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=848",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #849",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=849",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #850",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=850",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #851",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=851",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #852",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=852",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #853",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=853",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #854",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=854",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #855",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=855",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #856",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=856",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #857",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=857",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #858",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=858",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #859",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=859",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #860",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=860",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #861",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=861",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #862",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=862",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #863",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=863",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #864",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=864",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #865",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=865",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #866",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=866",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #867",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=867",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #868",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=868",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #869",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=869",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #870",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=870",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #871",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=871",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #872",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=872",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #873",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=873",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #874",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=874",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #875",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=875",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #876",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=876",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #877",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=877",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #878",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=878",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #879",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=879",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #880",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=880",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #881",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=881",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #882",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=882",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #883",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=883",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #884",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=884",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #885",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=885",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #886",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=886",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #887",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=887",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #888",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=888",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #889",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=889",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #890",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=890",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #891",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=891",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #892",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=892",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #893",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=893",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #894",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=894",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #895",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=895",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #896",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=896",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #897",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=897",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #898",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=898",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #899",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=899",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #900",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=900",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #901",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=901",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #902",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=902",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #903",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=903",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #904",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=904",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #905",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=905",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #906",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=906",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #907",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=907",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #908",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=908",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #909",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=909",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #910",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=910",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #911",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=911",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #912",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=912",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #913",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=913",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #914",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=914",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #915",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=915",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #916",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=916",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #917",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=917",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #918",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=918",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #919",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=919",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #920",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=920",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #921",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=921",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #922",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=922",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #923",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=923",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #924",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=924",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #925",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=925",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #926",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=926",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #927",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=927",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #928",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=928",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #929",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=929",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #930",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=930",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #931",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=931",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #932",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=932",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #933",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=933",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #934",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=934",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #935",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=935",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #936",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=936",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #937",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=937",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #938",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=938",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #939",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=939",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #940",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=940",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #941",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=941",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #942",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=942",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #943",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=943",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #944",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=944",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #945",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=945",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #946",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=946",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #947",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=947",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #948",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=948",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #949",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=949",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #950",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=950",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #951",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=951",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #952",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=952",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #953",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=953",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #954",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=954",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #955",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=955",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #956",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=956",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #957",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=957",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #958",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=958",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #959",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=959",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #960",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=960",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #961",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=961",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #962",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=962",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #963",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=963",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #964",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=964",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #965",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=965",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #966",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=966",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #967",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=967",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #968",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=968",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #969",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=969",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #970",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=970",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD #971",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield?v=971",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8 #972",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8?v=972",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L #973",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer?v=973",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim #974",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12?v=974",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi #975",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo?v=975",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB #976",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled?v=976",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller #977",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense?v=977",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik #978",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m?v=978",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör #979",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6?v=979",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB #980",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf?v=980",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000 #981",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000?v=981",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD) #982",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv?v=982",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör #983",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips?v=983",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge #984",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8?v=984",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü #985",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron?v=985",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi #986",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl?v=986",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu #987",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus?v=987",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB #988",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256?v=988",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255 #989",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255?v=989",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge) #990",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter?v=990",
+ "q": "smart lights"
+ },
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD #991",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb?v=991",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse #992",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s?v=992",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık #993",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5?v=993",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2 #994",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2?v=994",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti #995",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan?v=995",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB #996",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite?v=996",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör #997",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz?v=997",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router #998",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6?v=998",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye #999",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini?v=999",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk #1000",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb?v=1000",
+ "q": "external hard drive"
}
-]
+]
\ No newline at end of file
diff --git a/prisma/deals_org.json b/prisma/deals_org.json
new file mode 100644
index 0000000..fc60d4e
--- /dev/null
+++ b/prisma/deals_org.json
@@ -0,0 +1,231 @@
+[
+ {
+ "title": "Samsung 990 PRO 1TB NVMe SSD",
+ "price": 3299.99,
+ "originalPrice": 3799.99,
+ "url": "https://example.com/samsung-990pro-1tb",
+ "q": "nvme ssd"
+ },
+ {
+ "title": "Logitech MX Master 3S Mouse",
+ "price": 2499.9,
+ "originalPrice": 2999.9,
+ "url": "https://example.com/mx-master-3s",
+ "q": "wireless mouse"
+ },
+ {
+ "title": "Sony WH-1000XM5 Kulaklık",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/sony-xm5",
+ "q": "headphones"
+ },
+ {
+ "title": "Apple AirPods Pro 2",
+ "price": 8499.0,
+ "originalPrice": 9999.0,
+ "url": "https://example.com/airpods-pro-2",
+ "q": "earbuds"
+ },
+ {
+ "title": "Anker 65W GaN Şarj Aleti",
+ "price": 899.0,
+ "shippingPrice": 39.9,
+ "url": "https://example.com/anker-65w-gan",
+ "q": "charger"
+ },
+ {
+ "title": "Kindle Paperwhite 16GB",
+ "price": 5199.0,
+ "originalPrice": 5999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/kindle-paperwhite",
+ "q": "ebook reader"
+ },
+ {
+ "title": "Dell 27\" 144Hz Monitör",
+ "price": 7999.0,
+ "originalPrice": 9499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dell-27-144hz",
+ "q": "gaming monitor"
+ },
+ {
+ "title": "TP-Link Wi-Fi 6 Router",
+ "price": 1999.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tplink-wifi6",
+ "q": "wifi router"
+ },
+ {
+ "title": "Razer Huntsman Mini Klavye",
+ "price": 3499.0,
+ "originalPrice": 3999.0,
+ "url": "https://example.com/huntsman-mini",
+ "q": "mechanical keyboard"
+ },
+ {
+ "title": "WD Elements 2TB Harici Disk",
+ "price": 2399.0,
+ "shippingPrice": 49.9,
+ "url": "https://example.com/wd-elements-2tb",
+ "q": "external hard drive"
+ },
+ {
+ "title": "Samsung T7 Shield 1TB SSD",
+ "price": 2799.0,
+ "originalPrice": 3299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-t7-shield",
+ "q": "portable ssd"
+ },
+ {
+ "title": "Xiaomi Mi Band 8",
+ "price": 1399.0,
+ "originalPrice": 1699.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/mi-band-8",
+ "q": "smart band"
+ },
+ {
+ "title": "Philips Airfryer 6.2L",
+ "price": 5999.0,
+ "originalPrice": 7499.0,
+ "url": "https://example.com/philips-airfryer",
+ "q": "air fryer"
+ },
+ {
+ "title": "Dyson V12 Detect Slim",
+ "price": 21999.0,
+ "originalPrice": 25999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/dyson-v12",
+ "q": "vacuum cleaner"
+ },
+ {
+ "title": "Nespresso Vertuo Kahve Makinesi",
+ "price": 6999.0,
+ "originalPrice": 8499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nespresso-vertuo",
+ "q": "coffee machine"
+ },
+ {
+ "title": "Nintendo Switch OLED 64GB",
+ "price": 11999.0,
+ "originalPrice": 13999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/nintendo-switch-oled",
+ "q": "game console"
+ },
+ {
+ "title": "PlayStation 5 DualSense Controller",
+ "price": 2499.0,
+ "originalPrice": 2999.0,
+ "url": "https://example.com/ps5-dualsense",
+ "q": "game controller"
+ },
+ {
+ "title": "Xbox Game Pass 3 Aylık Üyelik",
+ "price": 699.0,
+ "originalPrice": 899.0,
+ "url": "https://example.com/xbox-game-pass-3m",
+ "q": "subscription"
+ },
+ {
+ "title": "JBL Flip 6 Bluetooth Hoparlör",
+ "price": 3499.0,
+ "originalPrice": 4299.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/jbl-flip-6",
+ "q": "bluetooth speaker"
+ },
+ {
+ "title": "ASUS TUF Gaming RTX 4060 8GB",
+ "price": 16999.0,
+ "originalPrice": 19999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/rtx-4060-asus-tuf",
+ "q": "graphics card"
+ },
+ {
+ "title": "Corsair Vengeance 32GB (2x16) DDR5 6000",
+ "price": 3999.0,
+ "originalPrice": 4999.0,
+ "url": "https://example.com/corsair-ddr5-32gb-6000",
+ "q": "ram memory"
+ },
+ {
+ "title": "Samsung 55\" 4K Smart TV (Crystal UHD)",
+ "price": 18999.0,
+ "originalPrice": 22999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/samsung-55-4k-tv",
+ "q": "television"
+ },
+ {
+ "title": "LG 27\" 4K IPS Monitör",
+ "price": 10999.0,
+ "originalPrice": 12999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/lg-27-4k-ips",
+ "q": "4k monitor"
+ },
+ {
+ "title": "Roborock S8 Robot Süpürge",
+ "price": 24999.0,
+ "originalPrice": 29999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/roborock-s8",
+ "q": "robot vacuum"
+ },
+ {
+ "title": "Tefal Ultragliss Buharlı Ütü",
+ "price": 1799.0,
+ "originalPrice": 2199.0,
+ "shippingPrice": 29.9,
+ "url": "https://example.com/tefal-ultragliss-iron",
+ "q": "steam iron"
+ },
+ {
+ "title": "Brita Marella XL Su Arıtma Sürahisi",
+ "price": 899.0,
+ "originalPrice": 1099.0,
+ "shippingPrice": 19.9,
+ "url": "https://example.com/brita-marella-xl",
+ "q": "water filter"
+ },
+ {
+ "title": "IKEA Markus Ofis Koltuğu",
+ "price": 4999.0,
+ "shippingPrice": 59.9,
+ "url": "https://example.com/ikea-markus",
+ "q": "office chair"
+ },
+ {
+ "title": "Xiaomi Redmi Note 13 256GB",
+ "price": 9999.0,
+ "originalPrice": 11999.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/redmi-note-13-256",
+ "q": "smartphone"
+ },
+ {
+ "title": "Garmin Forerunner 255",
+ "price": 8999.0,
+ "originalPrice": 10499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/garmin-fr-255",
+ "q": "sports watch"
+ },
+ {
+ "title": "Philips Hue Starter Kit (3 Ampul + Bridge)",
+ "price": 4499.0,
+ "originalPrice": 5499.0,
+ "shippingPrice": 0,
+ "url": "https://example.com/philips-hue-starter",
+ "q": "smart lights"
+ }
+
+]
diff --git a/prisma/migrations/20260129010417_add_type_of_deal/migration.sql b/prisma/migrations/20260129010417_add_type_of_deal/migration.sql
new file mode 100644
index 0000000..4dbae7c
--- /dev/null
+++ b/prisma/migrations/20260129010417_add_type_of_deal/migration.sql
@@ -0,0 +1,8 @@
+-- CreateEnum
+CREATE TYPE "DiscountType" AS ENUM ('PERCENT', 'AMOUNT');
+
+-- AlterTable
+ALTER TABLE "Deal" ADD COLUMN "couponCode" TEXT,
+ADD COLUMN "discountType" "DiscountType" DEFAULT 'AMOUNT',
+ADD COLUMN "discountValue" DOUBLE PRECISION,
+ADD COLUMN "location" TEXT;
diff --git a/prisma/migrations/20260131043221_add_analytics/migration.sql b/prisma/migrations/20260131043221_add_analytics/migration.sql
new file mode 100644
index 0000000..b7371f7
--- /dev/null
+++ b/prisma/migrations/20260131043221_add_analytics/migration.sql
@@ -0,0 +1,44 @@
+-- CreateEnum
+CREATE TYPE "DealEventType" AS ENUM ('IMPRESSION', 'VIEW', 'CLICK');
+
+-- CreateTable
+CREATE TABLE "DealAnalyticsTotal" (
+ "dealId" INTEGER NOT NULL,
+ "impressions" INTEGER NOT NULL DEFAULT 0,
+ "views" INTEGER NOT NULL DEFAULT 0,
+ "clicks" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "DealAnalyticsTotal_pkey" PRIMARY KEY ("dealId")
+);
+
+-- CreateTable
+CREATE TABLE "DealEvent" (
+ "id" SERIAL NOT NULL,
+ "dealId" INTEGER NOT NULL,
+ "type" "DealEventType" NOT NULL,
+ "userId" INTEGER,
+ "ip" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "DealEvent_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "DealAnalyticsTotal_updatedAt_idx" ON "DealAnalyticsTotal"("updatedAt");
+
+-- CreateIndex
+CREATE INDEX "DealEvent_dealId_type_createdAt_idx" ON "DealEvent"("dealId", "type", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "DealEvent_userId_createdAt_idx" ON "DealEvent"("userId", "createdAt");
+
+-- AddForeignKey
+ALTER TABLE "DealAnalyticsTotal" ADD CONSTRAINT "DealAnalyticsTotal_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DealEvent" ADD CONSTRAINT "DealEvent_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DealEvent" ADD CONSTRAINT "DealEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260202012203_add_max_miletsont_to_deal/migration.sql b/prisma/migrations/20260202012203_add_max_miletsont_to_deal/migration.sql
new file mode 100644
index 0000000..087ba8c
--- /dev/null
+++ b/prisma/migrations/20260202012203_add_max_miletsont_to_deal/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Deal" ADD COLUMN "maxNotifiedMilestone" INTEGER NOT NULL DEFAULT 0;
diff --git a/prisma/migrations/20260202013348_add_notifications/migration.sql b/prisma/migrations/20260202013348_add_notifications/migration.sql
new file mode 100644
index 0000000..c8072b0
--- /dev/null
+++ b/prisma/migrations/20260202013348_add_notifications/migration.sql
@@ -0,0 +1,19 @@
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "notificationCount" INTEGER NOT NULL DEFAULT 0;
+
+-- CreateTable
+CREATE TABLE "Notification" (
+ "id" SERIAL NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "title" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "readAt" TIMESTAMP(3),
+
+ CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Notification_userId_createdAt_idx" ON "Notification"("userId", "createdAt");
+
+-- AddForeignKey
+ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260202021140_edit_notifications/migration.sql b/prisma/migrations/20260202021140_edit_notifications/migration.sql
new file mode 100644
index 0000000..2415466
--- /dev/null
+++ b/prisma/migrations/20260202021140_edit_notifications/migration.sql
@@ -0,0 +1,11 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `title` on the `Notification` table. All the data in the column will be lost.
+ - Added the required column `message` to the `Notification` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- AlterTable
+ALTER TABLE "Notification" DROP COLUMN "title",
+ADD COLUMN "message" TEXT NOT NULL,
+ADD COLUMN "type" TEXT NOT NULL DEFAULT 'INFO';
diff --git a/prisma/migrations/20260202222731_add_dealsave/migration.sql b/prisma/migrations/20260202222731_add_dealsave/migration.sql
new file mode 100644
index 0000000..b98d29a
--- /dev/null
+++ b/prisma/migrations/20260202222731_add_dealsave/migration.sql
@@ -0,0 +1,23 @@
+-- CreateTable
+CREATE TABLE "DealSave" (
+ "userId" INTEGER NOT NULL,
+ "dealId" INTEGER NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "DealSave_pkey" PRIMARY KEY ("userId","dealId")
+);
+
+-- CreateIndex
+CREATE INDEX "DealSave_userId_createdAt_idx" ON "DealSave"("userId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "DealSave_dealId_idx" ON "DealSave"("dealId");
+
+-- CreateIndex
+CREATE INDEX "Comment_userId_createdAt_idx" ON "Comment"("userId", "createdAt");
+
+-- AddForeignKey
+ALTER TABLE "DealSave" ADD CONSTRAINT "DealSave_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DealSave" ADD CONSTRAINT "DealSave_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260203013000_add_pg_trgm_search/migration.sql b/prisma/migrations/20260203013000_add_pg_trgm_search/migration.sql
new file mode 100644
index 0000000..5cda010
--- /dev/null
+++ b/prisma/migrations/20260203013000_add_pg_trgm_search/migration.sql
@@ -0,0 +1,9 @@
+-- Enable trigram extension for fast ILIKE/contains searches
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+-- GIN trigram indexes for title/description search
+CREATE INDEX IF NOT EXISTS "Deal_title_trgm_idx"
+ ON "Deal" USING GIN ("title" gin_trgm_ops);
+
+CREATE INDEX IF NOT EXISTS "Deal_description_trgm_idx"
+ ON "Deal" USING GIN ("description" gin_trgm_ops);
diff --git a/prisma/migrations/20260203034543_add_deal_report/migration.sql b/prisma/migrations/20260203034543_add_deal_report/migration.sql
new file mode 100644
index 0000000..f35f8c5
--- /dev/null
+++ b/prisma/migrations/20260203034543_add_deal_report/migration.sql
@@ -0,0 +1,43 @@
+-- CreateEnum
+CREATE TYPE "DealReportReason" AS ENUM ('EXPIRED', 'WRONG_PRICE', 'MISLEADING', 'SPAM', 'OTHER');
+
+-- CreateEnum
+CREATE TYPE "DealReportStatus" AS ENUM ('OPEN', 'REVIEWED', 'CLOSED');
+
+-- DropIndex
+DROP INDEX "Deal_description_trgm_idx";
+
+-- DropIndex
+DROP INDEX "Deal_title_trgm_idx";
+
+-- CreateTable
+CREATE TABLE "DealReport" (
+ "id" SERIAL NOT NULL,
+ "dealId" INTEGER NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "reason" "DealReportReason" NOT NULL,
+ "note" TEXT,
+ "status" "DealReportStatus" NOT NULL DEFAULT 'OPEN',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "DealReport_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "DealReport_dealId_createdAt_idx" ON "DealReport"("dealId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "DealReport_userId_createdAt_idx" ON "DealReport"("userId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "DealReport_status_createdAt_idx" ON "DealReport"("status", "createdAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DealReport_dealId_userId_key" ON "DealReport"("dealId", "userId");
+
+-- AddForeignKey
+ALTER TABLE "DealReport" ADD CONSTRAINT "DealReport_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DealReport" ADD CONSTRAINT "DealReport_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260203235837_add_badges/migration.sql b/prisma/migrations/20260203235837_add_badges/migration.sql
new file mode 100644
index 0000000..19d8a1c
--- /dev/null
+++ b/prisma/migrations/20260203235837_add_badges/migration.sql
@@ -0,0 +1,35 @@
+-- CreateTable
+CREATE TABLE "Badge" (
+ "id" SERIAL NOT NULL,
+ "name" TEXT NOT NULL,
+ "iconUrl" VARCHAR(512),
+ "description" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Badge_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "UserBadge" (
+ "userId" INTEGER NOT NULL,
+ "badgeId" INTEGER NOT NULL,
+ "earnedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "UserBadge_pkey" PRIMARY KEY ("userId","badgeId")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Badge_name_key" ON "Badge"("name");
+
+-- CreateIndex
+CREATE INDEX "UserBadge_userId_earnedAt_idx" ON "UserBadge"("userId", "earnedAt");
+
+-- CreateIndex
+CREATE INDEX "UserBadge_badgeId_idx" ON "UserBadge"("badgeId");
+
+-- AddForeignKey
+ALTER TABLE "UserBadge" ADD CONSTRAINT "UserBadge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "UserBadge" ADD CONSTRAINT "UserBadge_badgeId_fkey" FOREIGN KEY ("badgeId") REFERENCES "Badge"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20260204011345_add_tags/migration.sql b/prisma/migrations/20260204011345_add_tags/migration.sql
new file mode 100644
index 0000000..c59a10d
--- /dev/null
+++ b/prisma/migrations/20260204011345_add_tags/migration.sql
@@ -0,0 +1,6 @@
+-- AlterTable
+ALTER TABLE "DealAiReview" ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
+
+-- AlterTable
+ALTER TABLE "Tag" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ADD COLUMN "usageCount" INTEGER NOT NULL DEFAULT 0;
diff --git a/prisma/migrations/20260204043042_many_changes/migration.sql b/prisma/migrations/20260204043042_many_changes/migration.sql
new file mode 100644
index 0000000..e7531c3
--- /dev/null
+++ b/prisma/migrations/20260204043042_many_changes/migration.sql
@@ -0,0 +1,52 @@
+-- AlterTable
+ALTER TABLE "Category" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true;
+
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "disabledAt" TIMESTAMP(3),
+ADD COLUMN "mutedUntil" TIMESTAMP(3),
+ADD COLUMN "suspendedUntil" TIMESTAMP(3);
+
+-- CreateTable
+CREATE TABLE "UserNote" (
+ "id" SERIAL NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "createdById" INTEGER NOT NULL,
+ "note" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "UserNote_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "AuditEvent" (
+ "id" SERIAL NOT NULL,
+ "userId" INTEGER,
+ "action" TEXT NOT NULL,
+ "ip" TEXT,
+ "userAgent" TEXT,
+ "meta" JSONB,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "AuditEvent_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "UserNote_userId_createdAt_idx" ON "UserNote"("userId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "UserNote_createdById_idx" ON "UserNote"("createdById");
+
+-- CreateIndex
+CREATE INDEX "AuditEvent_userId_createdAt_idx" ON "AuditEvent"("userId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "AuditEvent_action_createdAt_idx" ON "AuditEvent"("action", "createdAt");
+
+-- AddForeignKey
+ALTER TABLE "UserNote" ADD CONSTRAINT "UserNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "UserNote" ADD CONSTRAINT "UserNote_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "AuditEvent" ADD CONSTRAINT "AuditEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 4a3c7a7..d36786e 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -23,6 +23,10 @@ model User {
passwordHash String
avatarUrl String? @db.VarChar(512)
role UserRole @default(USER)
+ notificationCount Int @default(0)
+ mutedUntil DateTime?
+ suspendedUntil DateTime?
+ disabledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@ -37,6 +41,52 @@ model User {
refreshTokens RefreshToken[] // <-- bunu ekle
commentLikes CommentLike[]
+ dealEvents DealEvent[]
+ notifications Notification[]
+ dealSaves DealSave[]
+ dealReports DealReport[]
+ userBadges UserBadge[]
+ auditEvents AuditEvent[]
+ userNotes UserNote[] @relation("UserNotes")
+ notesAuthored UserNote[] @relation("UserNotesAuthor")
+}
+
+model UserNote {
+ id Int @id @default(autoincrement())
+ userId Int
+ createdById Int
+ note String
+ createdAt DateTime @default(now())
+
+ user User @relation("UserNotes", fields: [userId], references: [id], onDelete: Cascade)
+ createdBy User @relation("UserNotesAuthor", fields: [createdById], references: [id], onDelete: Cascade)
+
+ @@index([userId, createdAt])
+ @@index([createdById])
+}
+
+model Badge {
+ id Int @id @default(autoincrement())
+ name String @unique
+ iconUrl String? @db.VarChar(512)
+ description String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ userBadges UserBadge[]
+}
+
+model UserBadge {
+ userId Int
+ badgeId Int
+ earnedAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ badge Badge @relation(fields: [badgeId], references: [id], onDelete: Cascade)
+
+ @@id([userId, badgeId])
+ @@index([userId, earnedAt])
+ @@index([badgeId])
}
model RefreshToken {
@@ -82,6 +132,11 @@ enum AffiliateType {
USER_AFFILIATE
}
+enum DiscountType {
+ PERCENT
+ AMOUNT
+}
+
model SellerDomain {
id Int @id @default(autoincrement())
domain String @unique
@@ -116,6 +171,7 @@ model Category {
slug String @unique
description String @default("")
parentId Int?
+ isActive Boolean @default(true)
parent Category? @relation("CategoryParent", fields: [parentId], references: [id])
children Category[] @relation("CategoryParent")
@@ -131,6 +187,8 @@ model Tag {
id Int @id @default(autoincrement())
slug String @unique
name String
+ usageCount Int @default(0)
+ createdAt DateTime @default(now())
dealTags DealTag[]
}
@@ -161,6 +219,11 @@ model Deal {
originalPrice Float?
shippingPrice Float?
percentOff Float?
+ couponCode String?
+ location String?
+ discountType DiscountType? @default(AMOUNT)
+ discountValue Float?
+ maxNotifiedMilestone Int @default(0)
userId Int
score Int @default(0)
commentCount Int @default(0)
@@ -189,10 +252,46 @@ model Deal {
// NEW: tags (multiple, optional)
dealTags DealTag[]
aiReview DealAiReview?
+ analyticsTotal DealAnalyticsTotal?
+ events DealEvent[]
+ savedBy DealSave[]
+ reports DealReport[]
@@index([categoryId, createdAt])
@@index([userId, createdAt])
}
+model DealSave {
+ userId Int
+ dealId Int
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
+
+ @@id([userId, dealId])
+ @@index([userId, createdAt])
+ @@index([dealId])
+}
+
+model DealReport {
+ id Int @id @default(autoincrement())
+ dealId Int
+ userId Int
+ reason DealReportReason
+ note String?
+ status DealReportStatus @default(OPEN)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([dealId, userId])
+ @@index([dealId, createdAt])
+ @@index([userId, createdAt])
+ @@index([status, createdAt])
+}
+
enum DealNoticeSeverity {
INFO
WARNING
@@ -284,6 +383,7 @@ model Comment {
@@index([dealId, createdAt])
@@index([parentId, createdAt])
@@index([dealId, parentId, createdAt])
+ @@index([userId, createdAt])
@@index([deletedAt])
}
@@ -310,6 +410,26 @@ enum DealAiIssueType {
OTHER
}
+enum DealEventType {
+ IMPRESSION
+ VIEW
+ CLICK
+}
+
+enum DealReportReason {
+ EXPIRED
+ WRONG_PRICE
+ MISLEADING
+ SPAM
+ OTHER
+}
+
+enum DealReportStatus {
+ OPEN
+ REVIEWED
+ CLOSED
+}
+
model DealAiReview {
id Int @id @default(autoincrement())
@@ -317,6 +437,7 @@ model DealAiReview {
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
bestCategoryId Int
+ tags String[] @default([])
needsReview Boolean @default(false)
hasIssue Boolean @default(false)
@@ -328,3 +449,59 @@ model DealAiReview {
@@index([needsReview, hasIssue, updatedAt])
}
+
+model AuditEvent {
+ id Int @id @default(autoincrement())
+ userId Int?
+ action String
+ ip String?
+ userAgent String?
+ meta Json?
+ createdAt DateTime @default(now())
+
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
+
+ @@index([userId, createdAt])
+ @@index([action, createdAt])
+}
+
+model DealAnalyticsTotal {
+ dealId Int @id
+ impressions Int @default(0)
+ views Int @default(0)
+ clicks Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
+
+ @@index([updatedAt])
+}
+
+model DealEvent {
+ id Int @id @default(autoincrement())
+ dealId Int
+ type DealEventType
+ userId Int?
+ ip String?
+ createdAt DateTime @default(now())
+
+ deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
+
+ @@index([dealId, type, createdAt])
+ @@index([userId, createdAt])
+}
+
+model Notification {
+ id Int @id @default(autoincrement())
+ userId Int
+ message String
+ type String @default("INFO")
+ createdAt DateTime @default(now())
+ readAt DateTime?
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId, createdAt])
+}
diff --git a/prisma/seed.js b/prisma/seed.js
index dc32d1b..ba169ff 100644
--- a/prisma/seed.js
+++ b/prisma/seed.js
@@ -32,6 +32,12 @@ function toNumberOrNull(v) {
return Number.isFinite(n) ? n : null
}
+function toDateOrNull(v) {
+ if (v === null || v === undefined || v === "") return null
+ const d = new Date(v)
+ return Number.isNaN(d.getTime()) ? null : d
+}
+
async function upsertTagBySlug(slug, name) {
const s = normalizeSlug(slug)
return prisma.tag.upsert({
@@ -143,6 +149,56 @@ async function seedCategoriesFromJson(categoriesFilePath) {
return { count: categories.length }
}
+function loadSellersJson(filePath) {
+ const raw = fs.readFileSync(filePath, "utf-8")
+ const arr = JSON.parse(raw)
+
+ if (!Array.isArray(arr)) throw new Error("sellers.json array olmalı")
+
+ const sellers = arr.map((s) => ({
+ name: String(s.name ?? "").trim(),
+ url: String(s.url ?? "").trim(),
+ sellerLogo: String(s.sellerLogo ?? "").trim(),
+ isActive: s.isActive === undefined ? true : Boolean(s.isActive),
+ createdAt: toDateOrNull(s.createdAt),
+ createdById: toNumberOrNull(s.createdById),
+ }))
+
+ for (const s of sellers) {
+ if (!s.name) throw new Error("Seller name boÅŸ olamaz")
+ }
+
+ return sellers
+}
+
+async function seedSellersFromJson(filePath, fallbackCreatedById) {
+ const sellers = loadSellersJson(filePath)
+ let count = 0
+
+ for (const s of sellers) {
+ const createdById = s.createdById ?? fallbackCreatedById
+ if (!createdById) throw new Error(`Seller createdById eksik: ${s.name}`)
+
+ const createData = {
+ name: s.name,
+ url: s.url,
+ sellerLogo: s.sellerLogo,
+ isActive: s.isActive,
+ createdById,
+ }
+ if (s.createdAt) createData.createdAt = s.createdAt
+
+ await prisma.seller.upsert({
+ where: { name: s.name },
+ update: { url: s.url, sellerLogo: s.sellerLogo, isActive: s.isActive },
+ create: createData,
+ })
+ count++
+ }
+
+ return { count }
+}
+
function loadDealsJson(filePath) {
const raw = fs.readFileSync(filePath, "utf-8")
const arr = JSON.parse(raw)
@@ -195,7 +251,7 @@ async function seedDealsFromJson({ userId, sellerId, categoryId, dealsFilePath }
// 30 adet olacak şekilde çoğalt (title/url benzersizleşsin)
const items = []
- for (let i = 0; i < 30; i++) {
+ for (let i = 0; i < 1000; i++) {
const base = baseItems[i % baseItems.length]
const n = i + 1
@@ -304,18 +360,11 @@ async function main() {
},
})
- // ---------- SELLER ----------
- const amazon = await prisma.seller.upsert({
- where: { name: "Amazon" },
- update: { isActive: true },
- create: {
- name: "Amazon",
- url: "https://www.amazon.com.tr",
- sellerLogo:"https://1000logos.net/wp-content/uploads/2016/10/Amazon-logo-meaning.jpg",
- isActive: true,
- createdById: admin.id,
- },
- })
+ // ---------- SELLERS (FROM JSON) ----------
+ const sellersFilePath = path.join(__dirname, "sellers.json")
+ await seedSellersFromJson(sellersFilePath, admin.id)
+ const amazon = await prisma.seller.findUnique({ where: { name: "Amazon" } })
+ if (!amazon) throw new Error("Amazon seller bulunamadı (sellers.json)")
// ---------- SELLER DOMAINS ----------
const domains = ["amazon.com", "amazon.com.tr"]
diff --git a/prisma/sellers.json b/prisma/sellers.json
new file mode 100644
index 0000000..0fa6a85
--- /dev/null
+++ b/prisma/sellers.json
@@ -0,0 +1,92 @@
+[
+ {
+ "id": 1,
+ "name": "Amazon",
+ "isActive": true,
+ "createdAt": "2026-01-29 01:04:29.303",
+ "createdById": 1,
+ "url": "https://www.amazon.com.tr",
+ "sellerLogo": "https://1000logos.net/wp-content/uploads/2016/10/Amazon-logo-meaning.jpg"
+ },
+ {
+ "id": 2,
+ "name": "Hepsiburada",
+ "isActive": true,
+ "createdAt": "2026-01-29 01:04:29.303",
+ "createdById": 1,
+ "url": "https://www.hepsiburada.com",
+ "sellerLogo": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS1NIkEmEEsw3WWQ5iSo2W1usknVAAONvhWhw&s"
+ },
+ {
+ "id": 3,
+ "name": "Trendyol",
+ "isActive": true,
+ "createdAt": "2026-01-29 01:04:29.303",
+ "createdById": 1,
+ "url": "https://trendyol.com",
+ "sellerLogo": "https://i.pinimg.com/474x/d2/af/2a/d2af2abde73f423c666b11d79a38a29d.jpg"
+ },
+ {
+ "id": 4,
+ "name": "VatanComputer",
+ "isActive": true,
+ "createdAt": "2026-01-29 01:04:29.303",
+ "createdById": 1,
+ "url": "https://vatancomputer.com",
+ "sellerLogo": "https://play-lh.googleusercontent.com/iP50PzgiBCES-7gmSk4Kp7uKnE1ql7Y3_4qedM5-4bvfhAHa9zhBQt9F-wtUSbfRewKo"
+ },
+ {
+ "id": 5,
+ "name": "n11",
+ "isActive": true,
+ "createdAt": "2026-01-29 01:04:29.303",
+ "createdById": 1,
+ "url": "https://n11.com",
+ "sellerLogo": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQP9buDHy2As5JmuAM-YjHVUKSa8_dYfMs7iw&s"
+ },
+ {
+ "id": 6,
+ "name": "Teknosa",
+ "isActive": true,
+ "createdAt": "2026-01-29 01:04:29.303",
+ "createdById": 1,
+ "url": "https://teknosa.com",
+ "sellerLogo": "https://play-lh.googleusercontent.com/7b5sPSt00vCZWGLTvlGfOqdNAw2tn3tnxMrdnK778AJ0aol7KQlacushBYS_57enh5xUXAn9Xb4zCXDrsey2V9I"
+ },
+ {
+ "id": 7,
+ "name": "PTTAVM",
+ "isActive": true,
+ "createdAt": "2026-01-29 01:04:29.303",
+ "createdById": 1,
+ "url": "https://pttavm.com",
+ "sellerLogo": "https://play-lh.googleusercontent.com/qy_zGBn3CBx3BZtouz1JkZnVgcipOpmFmzLcmhuacxQKxDcQVJnxmDSac4eYCKUOxsf6xJvyK64jrBBDRn71Cg"
+ },
+ {
+ "id": 8,
+ "name": "MediaMarkt",
+ "isActive": true,
+ "createdAt": "2026-01-29 01:04:29.303",
+ "createdById": 1,
+ "url": "https://mediamarkt.com.tr",
+ "sellerLogo": "https://pbs.twimg.com/profile_images/1874762833495269376/GKHjhatC_400x400.jpg"
+ },
+ {
+ "id": 9,
+ "name": "Sephora",
+ "isActive": true,
+ "createdAt": "2026-01-29 01:04:29.303",
+ "createdById": 1,
+ "url": "https://sephora.com.tr",
+ "sellerLogo": "https://play-lh.googleusercontent.com/nk7tte8LNfR0PmALr2rFNovCr_ftOL6YRXwpzfFoB1d08dElj9BtEfO0Y48y41tLnw=w600-h300-pc0xffffff-pd"
+ },
+ {
+ "id": 10,
+ "name": "Gratis",
+ "isActive": true,
+ "createdAt": "2026-01-29 01:04:29.303",
+ "createdById": 1,
+ "url": "https://gratis.com",
+ "sellerLogo": "https://play-lh.googleusercontent.com/_B6PzArqRTIcI-VkMS3UbGY7fd10pxKJZ0V_dX3QTuhHBz-hd4j9tiz-RKFW2FW9l41t"
+ }
+]
diff --git a/routes/accountSettings.routes.js b/routes/accountSettings.routes.js
index 6c9cee6..ff598e5 100644
--- a/routes/accountSettings.routes.js
+++ b/routes/accountSettings.routes.js
@@ -1,13 +1,20 @@
const express = require("express")
const multer = require("multer")
const requireAuth = require("../middleware/requireAuth.js")
-const { getUserProfile } = require("../services/profile.service")
+const {
+ getUserProfile,
+ markAllNotificationsRead,
+ getUserNotificationsPage,
+ changePassword,
+} = require("../services/profile.service")
const { endpoints } = require("@shared/contracts")
const router = express.Router()
const upload = multer({ dest: "uploads/" })
const { updateUserAvatar } = require("../services/avatar.service")
+const { enqueueAuditFromRequest, buildAuditMeta } = require("../services/audit.service")
+const { AUDIT_ACTIONS } = require("../services/auditActions")
const { account } = endpoints
@@ -18,6 +25,15 @@ router.post(
async (req, res) => {
try {
const updatedUser = await updateUserAvatar(req.auth.userId, req.file)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.ACCOUNT.AVATAR_UPDATE,
+ buildAuditMeta({
+ entityType: "USER",
+ entityId: req.auth.userId,
+ after: { avatarUrl: updatedUser.avatarUrl ?? null },
+ })
+ )
res.json(
account.avatarUploadResponseSchema.parse({
@@ -41,4 +57,51 @@ router.get("/me", requireAuth, async (req, res) => {
}
})
+router.get("/notifications/read", requireAuth, async (req, res) => {
+ try {
+ await markAllNotificationsRead(req.auth.userId)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.ACCOUNT.NOTIFICATIONS_READ,
+ buildAuditMeta({
+ entityType: "USER",
+ entityId: req.auth.userId,
+ extra: { action: "mark_all_read" },
+ })
+ )
+ res.sendStatus(200)
+ } catch (err) {
+ res.status(400).json({ error: err.message })
+ }
+})
+
+router.get("/notifications", requireAuth, async (req, res) => {
+ try {
+ const input = account.accountNotificationsListRequestSchema.parse(req.query)
+ const payload = await getUserNotificationsPage(req.auth.userId, input.page, 10)
+ res.json(account.accountNotificationsListResponseSchema.parse(payload))
+ } catch (err) {
+ res.status(400).json({ error: err.message })
+ }
+})
+
+router.post("/password", requireAuth, async (req, res) => {
+ try {
+ const input = account.accountPasswordChangeRequestSchema.parse(req.body)
+ const payload = await changePassword(req.auth.userId, input)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.ACCOUNT.PASSWORD_CHANGE,
+ buildAuditMeta({
+ entityType: "USER",
+ entityId: req.auth.userId,
+ })
+ )
+ res.json(account.accountPasswordChangeResponseSchema.parse(payload))
+ } catch (err) {
+ res.status(400).json({ error: err.message })
+ }
+})
+
module.exports = router
+
diff --git a/routes/auth.routes.js b/routes/auth.routes.js
index 8cfa113..bdc501e 100644
--- a/routes/auth.routes.js
+++ b/routes/auth.routes.js
@@ -13,32 +13,37 @@ const { mapMeRequestToUserId, mapMeResultToResponse } = require("../adapters/res
const { auth } = endpoints
-// NOT: app.js’de cookie-parser olmalı:
+// NOT: app.jsde cookie-parser olmali:
// const cookieParser = require("cookie-parser")
// app.use(cookieParser())
function getCookieOptions() {
const isProd = process.env.NODE_ENV === "production"
-
- // DEV: http localhost -> secure false, sameSite lax
- if (!isProd) {
- return {
- httpOnly: true,
- secure: false,
- sameSite: "lax",
- path: "/",
- }
- }
-
- // PROD: cross-site kullanacaksan (frontend ayrı domain)
return {
httpOnly: true,
- secure: true,
- sameSite: "none",
+ secure: isProd,
+ sameSite: "lax",
path: "/",
}
}
+function parseExpiresInToMs(value) {
+ if (!value) return 15 * 60 * 1000
+ if (typeof value === "number" && Number.isFinite(value)) return value * 1000
+ const str = String(value).trim().toLowerCase()
+ const match = str.match(/^(\d+)(ms|s|m|h|d)?$/)
+ if (!match) return 15 * 60 * 1000
+ const n = Number(match[1])
+ const unit = match[2] || "s"
+ const mult =
+ unit === "ms" ? 1 :
+ unit === "s" ? 1000 :
+ unit === "m" ? 60 * 1000 :
+ unit === "h" ? 60 * 60 * 1000 :
+ unit === "d" ? 24 * 60 * 60 * 1000 :
+ 1000
+ return n * mult
+}
function setRefreshCookie(res, refreshToken) {
const opts = getCookieOptions()
@@ -46,11 +51,22 @@ function setRefreshCookie(res, refreshToken) {
res.cookie("rt", refreshToken, { ...opts, maxAge: maxAgeMs })
}
+function setAccessCookie(res, accessToken) {
+ const opts = getCookieOptions()
+ const maxAgeMs = parseExpiresInToMs(process.env.ACCESS_TOKEN_EXPIRES_IN || "15m")
+ res.cookie("at", accessToken, { ...opts, maxAge: maxAgeMs })
+}
+
function clearRefreshCookie(res) {
const opts = getCookieOptions()
res.clearCookie("rt", { ...opts })
}
+function clearAccessCookie(res) {
+ const opts = getCookieOptions()
+ res.clearCookie("at", { ...opts })
+}
+
router.post(
"/register",
validate(auth.registerRequestSchema, "body", "validatedRegisterInput"),
@@ -63,10 +79,10 @@ router.post(
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
})
- // refresh cookie set
+ // refresh + access cookie set
if (result.refreshToken) setRefreshCookie(res, result.refreshToken)
+ if (result.accessToken) setAccessCookie(res, result.accessToken)
- // response body: access + user (adapter refresh'i koymamalı)
const response = auth.authResponseSchema.parse(mapRegisterResultToResponse(result))
res.json(response)
} catch (err) {
@@ -88,21 +104,18 @@ router.post(
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
})
- // refresh cookie set
+ // refresh + access cookie set
setRefreshCookie(res, result.refreshToken)
+ setAccessCookie(res, result.accessToken)
const response = auth.authResponseSchema.parse(mapLoginResultToResponse(result))
res.json(response)
} catch (err) {
- console.error("LOGIN ERROR:", err) // <-- ekle
- console.error("LOGIN ERROR MSG:", err?.message)
- console.error("LOGIN ERROR STACK:", err?.stack)
-
- const status = err.statusCode || 500
- res.status(status).json({
- message: err.statusCode ? err.message : "Giris islemi basarisiz.",
- })
-}
+ const status = err.statusCode || 500
+ res.status(status).json({
+ message: err.statusCode ? err.message : "Giris islemi basarisiz.",
+ })
+ }
}
)
@@ -116,14 +129,15 @@ router.post("/refresh", async (req, res) => {
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
})
- // rotate -> yeni refresh cookie
+ // rotate -> yeni refresh + access cookie
setRefreshCookie(res, result.refreshToken)
+ setAccessCookie(res, result.accessToken)
- // body: access + user (adapter refresh'i koymamalı)
const response = auth.authResponseSchema.parse(mapLoginResultToResponse(result))
res.json(response)
} catch (err) {
clearRefreshCookie(res)
+ clearAccessCookie(res)
const status = err.statusCode || 401
res.status(status).json({ message: err.message || "Refresh basarisiz" })
}
@@ -133,15 +147,19 @@ router.post("/logout", async (req, res) => {
try {
const refreshToken = req.cookies?.rt
- // logout idempotent olsun
if (refreshToken) {
- await authService.logout({ refreshToken })
+ await authService.logout({
+ refreshToken,
+ meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
+ })
}
clearRefreshCookie(res)
+ clearAccessCookie(res)
res.status(204).send()
} catch (err) {
clearRefreshCookie(res)
+ clearAccessCookie(res)
const status = err.statusCode || 500
res.status(status).json({ message: err.message || "Cikis basarisiz" })
}
@@ -149,7 +167,7 @@ router.post("/logout", async (req, res) => {
router.get("/me", requireAuth, async (req, res) => {
try {
- const userId = mapMeRequestToUserId(req) // req.auth.userId okumalı
+ const userId = mapMeRequestToUserId(req)
const user = await authService.getMe(userId)
const response = auth.meResponseSchema.parse(mapMeResultToResponse(user))
res.json(response)
@@ -160,3 +178,4 @@ router.get("/me", requireAuth, async (req, res) => {
})
module.exports = router
+
diff --git a/routes/badge.routes.js b/routes/badge.routes.js
new file mode 100644
index 0000000..2d4598a
--- /dev/null
+++ b/routes/badge.routes.js
@@ -0,0 +1,19 @@
+const express = require("express")
+const router = express.Router()
+const badgeService = require("../services/badge.service")
+const { ensureBadgesCached } = require("../services/redis/badgeCache.service")
+const { endpoints } = require("@shared/contracts")
+
+const { badges } = endpoints
+
+router.get("/", async (req, res) => {
+ try {
+ const payload = await ensureBadgesCached()
+ res.json(badges.badgesListResponseSchema.parse(payload))
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+})
+
+module.exports = router
diff --git a/routes/category.routes.js b/routes/category.routes.js
index 56b6e15..8940de1 100644
--- a/routes/category.routes.js
+++ b/routes/category.routes.js
@@ -5,6 +5,8 @@ const optionalAuth = require("../middleware/optionalAuth")
const { mapCategoryToCategoryDetailsResponse }=require("../adapters/responses/categoryDetails.adapter")
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter")
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
+const { getClientIp } = require("../utils/requestInfo")
+const { queueDealImpressions } = require("../services/redis/dealAnalytics.service")
router.get("/:slug", async (req, res) => {
@@ -45,10 +47,22 @@ router.get("/:slug/deals", optionalAuth, async (req, res) => {
});
const response = mapPaginatedDealsToDealCardResponse(payload)
+ const dealIds = payload?.results?.map((deal) => deal.id) || []
+ queueDealImpressions({
+ dealIds,
+ userId: req.auth?.userId ?? null,
+ ip: getClientIp(req),
+ }).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
- // frontend DealCard[] bekliyor
- res.json(response.results)
+ res.json({
+ page: response.page,
+ total: response.total,
+ totalPages: response.totalPages,
+ results: response.results,
+ minPrice: payload?.minPrice ?? null,
+ maxPrice: payload?.maxPrice ?? null,
+ })
} catch (err) {
res.status(500).json({ error: "Kategoriye ait fırsatlar alınırken bir hata oluştu", message: err.message });
}
diff --git a/routes/comment.routes.js b/routes/comment.routes.js
index dc23f51..80bf4f3 100644
--- a/routes/comment.routes.js
+++ b/routes/comment.routes.js
@@ -1,9 +1,12 @@
const express = require("express")
const requireAuth = require("../middleware/requireAuth.js")
+const requireNotRestricted = require("../middleware/requireNotRestricted")
const optionalAuth = require("../middleware/optionalAuth")
const { validate } = require("../middleware/validate.middleware")
const { endpoints } = require("@shared/contracts")
const { createComment, deleteComment } = require("../services/comment.service")
+const { enqueueAuditFromRequest, buildAuditMeta } = require("../services/audit.service")
+const { AUDIT_ACTIONS } = require("../services/auditActions")
const dealCommentAdapter = require("../adapters/responses/comment.adapter")
const commentService = require("../services/comment.service")
@@ -44,6 +47,7 @@ router.get(
router.post(
"/",
requireAuth,
+ requireNotRestricted({ checkMute: true, checkSuspend: true }),
validate(comments.commentCreateRequestSchema, "body", "validatedCommentPayload"),
async (req, res) => {
try {
@@ -52,6 +56,15 @@ router.post(
const comment = await createComment({ dealId, userId, text, parentId })
const mapped = dealCommentAdapter.mapCommentToDealCommentResponse(comment)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.COMMENT.CREATE,
+ buildAuditMeta({
+ entityType: "COMMENT",
+ entityId: comment.id,
+ extra: { dealId, parentId: parentId ?? null },
+ })
+ )
res.json(comments.commentCreateResponseSchema.parse(mapped))
} catch (err) {
res.status(500).json({ error: err.message || "Sunucu hatasi" })
@@ -67,6 +80,14 @@ router.delete(
try {
const { id } = req.validatedDeleteComment
const result = await deleteComment(id, req.auth.userId)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.COMMENT.DELETE,
+ buildAuditMeta({
+ entityType: "COMMENT",
+ entityId: Number(id),
+ })
+ )
res.json(comments.commentDeleteResponseSchema.parse(result))
} catch (err) {
const status = err.message?.includes("yetkin") ? 403 : 404
@@ -76,3 +97,4 @@ router.delete(
)
module.exports = router
+
diff --git a/routes/deal.routes.js b/routes/deal.routes.js
index d4ed970..2379a27 100644
--- a/routes/deal.routes.js
+++ b/routes/deal.routes.js
@@ -3,20 +3,46 @@ const express = require("express")
const router = express.Router()
const requireAuth = require("../middleware/requireAuth")
+const requireNotRestricted = require("../middleware/requireNotRestricted")
const optionalAuth = require("../middleware/optionalAuth")
const { upload } = require("../middleware/upload.middleware")
const { validate } = require("../middleware/validate.middleware")
const { endpoints } = require("@shared/contracts")
+const requireApiKey = require("../middleware/requireApiKey")
const userDB = require("../db/user.db")
-const { getDeals, getDealById, createDeal } = require("../services/deal.service")
+const {
+ getDeals,
+ getDealById,
+ createDeal,
+ getDealEngagement,
+ getDealSuggestions,
+ getBestWidgetDeals,
+} = require("../services/deal.service")
+const dealSaveService = require("../services/dealSave.service")
+const dealReportService = require("../services/dealReport.service")
const { mapCreateDealRequestToDealCreateData } = require("../adapters/requests/dealCreate.adapter")
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter")
-const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
+const { mapDealToDealCardResponse, mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
+const { getClientIp } = require("../utils/requestInfo")
+const {
+ queueDealImpressions,
+ queueDealView,
+ queueDealClick,
+} = require("../services/redis/dealAnalytics.service")
+const { getOrCacheDeal } = require("../services/redis/dealCache.service")
+const { enqueueAuditFromRequest, buildAuditMeta } = require("../services/audit.service")
+const { AUDIT_ACTIONS } = require("../services/auditActions")
const { deals, users } = endpoints
+function parsePage(value) {
+ const num = Number(value)
+ if (!Number.isInteger(num) || num < 1) return 1
+ return num
+}
+
const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery")
const buildViewer = (req) =>
@@ -26,7 +52,7 @@ function createListHandler(preset) {
return async (req, res) => {
try {
const viewer = buildViewer(req)
- const { q, page, limit } = req.validatedDealListQuery
+ const { q, page, limit, hotListId, trendingListId } = req.validatedDealListQuery
const payload = await getDeals({
preset,
@@ -35,11 +61,19 @@ function createListHandler(preset) {
limit,
viewer,
filters: req.query,
+ hotListId,
+ trendingListId,
})
const response = deals.dealsListResponseSchema.parse(
mapPaginatedDealsToDealCardResponse(payload)
)
+ const dealIds = payload?.results?.map((deal) => deal.id) || []
+ queueDealImpressions({
+ dealIds,
+ userId: req.auth?.userId ?? null,
+ ip: getClientIp(req),
+ }).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
res.json(response)
} catch (err) {
console.error(err)
@@ -49,6 +83,7 @@ function createListHandler(preset) {
}
}
+
// Public deals of a user (viewer optional; self profile => "MY" else "USER_PUBLIC")
router.get(
"/users/:userName/deals",
@@ -65,7 +100,7 @@ router.get(
if (!targetUser) return res.status(404).json({ error: "Kullanici bulunamadi" })
- const { q, page, limit } = req.validatedDealListQuery
+ const { q, page, limit, hotListId, trendingListId } = req.validatedDealListQuery
const viewer = buildViewer(req)
const isSelfProfile = viewer?.userId === targetUser.id
const preset = isSelfProfile ? "MY" : "USER_PUBLIC"
@@ -78,11 +113,19 @@ router.get(
targetUserId: targetUser.id,
viewer,
filters: req.query,
+ hotListId,
+ trendingListId,
})
const response = deals.dealsListResponseSchema.parse(
mapPaginatedDealsToDealCardResponse(payload)
)
+ const dealIds = payload?.results?.map((deal) => deal.id) || []
+ queueDealImpressions({
+ dealIds,
+ userId: req.auth?.userId ?? null,
+ ip: getClientIp(req),
+ }).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
res.json(response)
} catch (err) {
console.error(err)
@@ -100,10 +143,180 @@ router.get(
createListHandler("MY")
)
-router.get("/new", optionalAuth, listQueryValidator, createListHandler("NEW"))
-router.get("/hot", optionalAuth, listQueryValidator, createListHandler("HOT"))
-router.get("/trending", optionalAuth, listQueryValidator, createListHandler("TRENDING"))
-router.get("/", optionalAuth, listQueryValidator, createListHandler("NEW"))
+router.get("/new", requireApiKey, optionalAuth, listQueryValidator, createListHandler("NEW"))
+router.get("/hot", requireApiKey, optionalAuth, listQueryValidator, createListHandler("HOT"))
+router.get("/trending", requireApiKey, optionalAuth, listQueryValidator, createListHandler("TRENDING"))
+
+router.get("/search/suggest", optionalAuth, async (req, res) => {
+ try {
+ const q = String(req.query.q || "").trim()
+ const limit = Number(req.query.limit || 8)
+ const payload = await getDealSuggestions({ q, limit, viewer: buildViewer(req) })
+ const response = mapPaginatedDealsToDealCardResponse({
+ page: 1,
+ total: payload.results.length,
+ totalPages: 1,
+ results: payload.results,
+ })
+ res.json(response.results)
+ } catch (err) {
+ console.error(err)
+ res.status(500).json({ error: "Sunucu hatasi" })
+ }
+})
+
+// Resolve deal URL (SSR uses api key; user token optional)
+router.post("/url", requireApiKey, optionalAuth, async (req, res) => {
+ try {
+ const dealId = Number(req.body?.dealId)
+ if (!Number.isInteger(dealId) || dealId <= 0) {
+ return res.status(400).json({ error: "dealId invalid" })
+ }
+
+ const deal = await getOrCacheDeal(dealId, { ttlSeconds: 15 * 60 })
+ if (!deal) return res.status(404).json({ error: "Deal bulunamadi" })
+
+ if (deal.status === "PENDING" || deal.status === "REJECTED") {
+ const isOwner = req.auth?.userId && Number(deal.userId) === Number(req.auth.userId)
+ const isMod = req.auth?.role === "MOD" || req.auth?.role === "ADMIN"
+ if (!isOwner && !isMod) return res.status(404).json({ error: "Deal bulunamadi" })
+ }
+
+ const userId = req.auth?.userId ?? null
+ const ip = getClientIp(req)
+ queueDealClick({ dealId, userId, ip }).catch((err) =>
+ console.error("Deal click queue failed:", err?.message || err)
+ )
+
+ res.json({ url: deal.url ?? null })
+ } catch (err) {
+ console.error(err)
+ res.status(500).json({ error: "Sunucu hatasi" })
+ }
+})
+
+// Report deal (auth required)
+router.post("/:id/report", requireAuth, async (req, res) => {
+ try {
+ const id = Number(req.params.id)
+ const reason = req.body?.reason
+ const note = req.body?.note
+ const result = await dealReportService.createDealReport({
+ dealId: id,
+ userId: req.auth.userId,
+ reason,
+ note,
+ })
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.DEAL.REPORT_CREATE,
+ buildAuditMeta({
+ entityType: "DEAL",
+ entityId: id,
+ extra: { reason: reason ?? null },
+ })
+ )
+ res.json(result)
+ } catch (err) {
+ console.error(err)
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Report basarisiz" })
+ }
+})
+
+// Saved deals (auth required)
+router.post("/:id/save", requireAuth, async (req, res) => {
+ try {
+ const id = Number(req.params.id)
+ const result = await dealSaveService.saveDealForUser({
+ userId: req.auth.userId,
+ dealId: id,
+ })
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.DEAL.SAVE,
+ buildAuditMeta({
+ entityType: "DEAL",
+ entityId: id,
+ })
+ )
+ res.json(result)
+ } catch (err) {
+ console.error(err)
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Kaydetme basarisiz" })
+ }
+})
+
+router.delete("/:id/save", requireAuth, async (req, res) => {
+ try {
+ const id = Number(req.params.id)
+ await dealSaveService.removeSavedDealForUser({
+ userId: req.auth.userId,
+ dealId: id,
+ })
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.DEAL.UNSAVE,
+ buildAuditMeta({
+ entityType: "DEAL",
+ entityId: id,
+ })
+ )
+ res.sendStatus(200)
+ } catch (err) {
+ console.error(err)
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Silme basarisiz" })
+ }
+})
+
+router.get("/saved", requireAuth, async (req, res) => {
+ try {
+ const page = parsePage(req.query.page)
+ const payload = await dealSaveService.listSavedDeals({
+ userId: req.auth.userId,
+ page,
+ })
+ const response = deals.dealsListResponseSchema.parse(
+ mapPaginatedDealsToDealCardResponse(payload)
+ )
+ res.json(response)
+ } catch (err) {
+ console.error(err)
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Kaydedilenler alinamadi" })
+ }
+})
+
+// Best deals widget: hot day/week/year top 5
+router.get("/widgets/best", requireApiKey, optionalAuth, async (req, res) => {
+ try {
+ const viewer = buildViewer(req)
+ const limitRaw = Number(req.query.limit ?? 5)
+ const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(20, limitRaw)) : 5
+
+ const payload = await getBestWidgetDeals({ viewer, limit })
+ const hotDay = payload.hotDay.map(mapDealToDealCardResponse)
+ const hotWeek = payload.hotWeek.map(mapDealToDealCardResponse)
+ const hotMonth = payload.hotMonth.map(mapDealToDealCardResponse)
+
+ const dealIds = [...payload.hotDay, ...payload.hotWeek, ...payload.hotMonth].map((d) => d.id)
+ if (dealIds.length) {
+ queueDealImpressions({
+ dealIds,
+ userId: req.auth?.userId ?? null,
+ ip: getClientIp(req),
+ }).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
+ }
+
+ res.json({ hotDay, hotWeek, hotMonth })
+ } catch (err) {
+ console.error(err)
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+})
router.get(
"/search",
@@ -111,7 +324,7 @@ router.get(
listQueryValidator,
async (req, res) => {
try {
- const { q, page, limit } = req.validatedDealListQuery
+ const { q, page, limit, hotListId, trendingListId } = req.validatedDealListQuery
if (!q || !q.trim()) {
return res.json({ results: [], total: 0, totalPages: 0, page })
}
@@ -123,11 +336,21 @@ router.get(
limit,
viewer: buildViewer(req),
filters: req.query,
+ baseWhere: { status: "ACTIVE" },
+ hotListId,
+ trendingListId,
+ useRedisSearch: true,
})
const response = deals.dealsListResponseSchema.parse(
mapPaginatedDealsToDealCardResponse(payload)
)
+ const dealIds = payload?.results?.map((deal) => deal.id) || []
+ queueDealImpressions({
+ dealIds,
+ userId: req.auth?.userId ?? null,
+ ip: getClientIp(req),
+ }).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
res.json(response)
} catch (err) {
console.error(err)
@@ -162,6 +385,12 @@ router.get("/top", optionalAuth, async (req, res) => {
const response = deals.dealsListResponseSchema.parse(
mapPaginatedDealsToDealCardResponse(payload)
)
+ const dealIds = payload?.results?.map((deal) => deal.id) || []
+ queueDealImpressions({
+ dealIds,
+ userId: req.auth?.userId ?? null,
+ ip: getClientIp(req),
+ }).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
// frontend DealCard[] bekliyor
res.json(response.results)
@@ -172,6 +401,22 @@ router.get("/top", optionalAuth, async (req, res) => {
}
})
+router.post(
+ "/engagement",
+ requireAuth,
+ validate(deals.dealEngagementRequestSchema, "body", "validatedEngagement"),
+ async (req, res) => {
+ try {
+ const { ids } = req.validatedEngagement
+ const viewer = buildViewer(req)
+ const engagement = await getDealEngagement(ids, viewer)
+ res.json(deals.dealEngagementResponseSchema.parse(engagement))
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
@@ -186,9 +431,13 @@ router.get(
const deal = await getDealById(id, buildViewer(req))
if (!deal) return res.status(404).json({ error: "Deal bulunamadi" })
- const mapped = mapDealToDealDetailResponse(deal)
+ queueDealView({
+ dealId: deal.id,
+ userId: req.auth?.userId ?? null,
+ ip: getClientIp(req),
+ }).catch((err) => console.error("Deal view queue failed:", err?.message || err))
- console.log(mapped)
+ const mapped = mapDealToDealDetailResponse(deal)
res.json(deals.dealDetailResponseSchema.parse(mapped))
} catch (err) {
console.error(err)
@@ -201,6 +450,7 @@ router.get(
router.post(
"/",
requireAuth,
+ requireNotRestricted({ checkSuspend: true }),
upload.array("images", 5),
validate(deals.dealCreateRequestSchema, "body", "validatedDealPayload"),
async (req, res) => {
@@ -213,6 +463,15 @@ router.post(
const deal = await createDeal(dealCreateData, req.files || [])
const mapped = mapDealToDealDetailResponse(deal)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.DEAL.CREATE,
+ buildAuditMeta({
+ entityType: "DEAL",
+ entityId: deal.id,
+ after: { title: deal.title },
+ })
+ )
res.json(deals.dealCreateResponseSchema.parse(mapped))
} catch (err) {
console.error(err)
@@ -222,3 +481,4 @@ router.post(
)
module.exports = router
+
diff --git a/routes/mod.routes.js b/routes/mod.routes.js
index 72b79a6..fb21997 100644
--- a/routes/mod.routes.js
+++ b/routes/mod.routes.js
@@ -5,16 +5,64 @@ const requireAuth = require("../middleware/requireAuth")
const requireRole = require("../middleware/requireRole")
const { validate } = require("../middleware/validate.middleware")
const { endpoints } = require("@shared/contracts")
-const { getPendingDeals, approveDeal, rejectDeal, expireDeal, unexpireDeal } = require("../services/mod.service")
+const {
+ getPendingDeals,
+ approveDeal,
+ rejectDeal,
+ expireDeal,
+ unexpireDeal,
+ getDealDetailForMod,
+ updateDealForMod,
+} = require("../services/mod.service")
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
+const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter")
+const dealReportService = require("../services/dealReport.service")
+const badgeService = require("../services/badge.service")
+const { setBadgeInRedis } = require("../services/redis/badgeCache.service")
+const { attachTagsToDeal, removeTagsFromDeal, replaceTagsForDeal } = require("../services/tag.service")
+const { updateDealInRedis, getOrCacheDealForModeration } = require("../services/redis/dealCache.service")
+const { queueDealUpdate } = require("../services/redis/dbSync.service")
+const moderationService = require("../services/moderation.service")
+const adminService = require("../services/admin.service")
+const adminMetricsService = require("../services/adminMetrics.service")
+const { deleteCommentAsMod } = require("../services/comment.service")
+const { enqueueAuditFromRequest, buildAuditMeta } = require("../services/audit.service")
+const { AUDIT_ACTIONS } = require("../services/auditActions")
-const { deals } = endpoints
+const { deals, mod } = endpoints
const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery")
+const modUpdateValidator = validate(mod.modDealUpdateRequestSchema, "body", "validatedDealUpdate")
+const modDealIdValidator = validate(mod.modDealUpdateParamsSchema, "params", "validatedDealId")
+const modBadgeCreateValidator = validate(mod.modBadgeCreateRequestSchema, "body", "validatedBadgeCreate")
+const modBadgeUpdateParamsValidator = validate(mod.modBadgeUpdateParamsSchema, "params", "validatedBadgeId")
+const modBadgeUpdateValidator = validate(mod.modBadgeUpdateRequestSchema, "body", "validatedBadgeUpdate")
+const modBadgeAssignValidator = validate(mod.modBadgeAssignRequestSchema, "body", "validatedBadgeAssign")
+const modBadgeRemoveValidator = validate(mod.modBadgeRemoveRequestSchema, "body", "validatedBadgeRemove")
const buildViewer = (req) =>
req.auth ? { userId: req.auth.userId, role: req.auth.role } : null
+const formatDateAsString = (value) =>
+ value instanceof Date ? value.toISOString() : value ?? null
+
+function parseTagsFromBody(req, { allowEmpty = false } = {}) {
+ const tags = Array.isArray(req.body?.tags) ? req.body.tags : []
+ if (!allowEmpty && !tags.length) {
+ const err = new Error("Tag listesi gerekli")
+ err.statusCode = 400
+ throw err
+ }
+ return tags
+}
+
+const ALLOWED_DEAL_STATUSES = new Set(["PENDING", "ACTIVE", "REJECTED", "EXPIRED"])
+
+function normalizeDealStatus(value) {
+ const normalized = String(value || "").trim().toUpperCase()
+ return ALLOWED_DEAL_STATUSES.has(normalized) ? normalized : null
+}
+
router.get("/deals/pending", requireAuth, requireRole("MOD"), listQueryValidator, async (req, res) => {
try {
const { q, page, limit } = req.validatedDealListQuery
@@ -42,6 +90,15 @@ router.post(
try {
const { id } = req.validatedDealId
const updated = await approveDeal(id)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.MOD.DEAL_APPROVE,
+ buildAuditMeta({
+ entityType: "DEAL",
+ entityId: Number(id),
+ after: { status: updated.status },
+ })
+ )
res.json({ id: updated.id, status: updated.status })
} catch (err) {
const status = err.statusCode || 500
@@ -59,6 +116,15 @@ router.post(
try {
const { id } = req.validatedDealId
const updated = await rejectDeal(id)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.MOD.DEAL_REJECT,
+ buildAuditMeta({
+ entityType: "DEAL",
+ entityId: Number(id),
+ after: { status: updated.status },
+ })
+ )
res.json({ id: updated.id, status: updated.status })
} catch (err) {
const status = err.statusCode || 500
@@ -76,6 +142,15 @@ router.post(
try {
const { id } = req.validatedDealId
const updated = await expireDeal(id)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.MOD.DEAL_EXPIRE,
+ buildAuditMeta({
+ entityType: "DEAL",
+ entityId: Number(id),
+ after: { status: updated.status },
+ })
+ )
res.json({ id: updated.id, status: updated.status })
} catch (err) {
const status = err.statusCode || 500
@@ -93,6 +168,15 @@ router.post(
try {
const { id } = req.validatedDealId
const updated = await unexpireDeal(id)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.MOD.DEAL_UNEXPIRE,
+ buildAuditMeta({
+ entityType: "DEAL",
+ entityId: Number(id),
+ after: { status: updated.status },
+ })
+ )
res.json({ id: updated.id, status: updated.status })
} catch (err) {
const status = err.statusCode || 500
@@ -101,4 +185,616 @@ router.post(
}
)
+router.get(
+ "/deals/:id/detail",
+ requireAuth,
+ requireRole("MOD"),
+ validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedDealId
+ const { deal, aiReview } = await getDealDetailForMod(id, buildViewer(req))
+ const mapped = mapDealToDealDetailResponse(deal)
+ const response = {
+ ...mapped,
+ aiReview: aiReview
+ ? {
+ dealId: aiReview.dealId,
+ bestCategoryId: aiReview.bestCategoryId,
+ categoryBreadcrumb: aiReview.categoryBreadcrumb || [],
+ needsReview: aiReview.needsReview,
+ hasIssue: aiReview.hasIssue,
+ issueType: aiReview.issueType,
+ issueReason: aiReview.issueReason ?? null,
+ tags: Array.isArray(aiReview.tags) ? aiReview.tags : [],
+ createdAt: formatDateAsString(aiReview.createdAt),
+ }
+ : null,
+ }
+
+ res.json(response)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.patch(
+ "/deals/:id",
+ requireAuth,
+ requireRole("MOD"),
+ modDealIdValidator,
+ modUpdateValidator,
+ async (req, res) => {
+ try {
+ const { id } = req.validatedDealId
+ const updated = await updateDealForMod(id, req.validatedDealUpdate, buildViewer(req))
+ const mapped = mapDealToDealDetailResponse(updated)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.MOD.DEAL_UPDATE,
+ buildAuditMeta({
+ entityType: "DEAL",
+ entityId: Number(id),
+ extra: { fields: Object.keys(req.validatedDealUpdate || {}) },
+ })
+ )
+ res.json(mapped)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.post(
+ "/deals/:id/tags",
+ requireAuth,
+ requireRole("MOD"),
+ validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedDealId
+ const tags = parseTagsFromBody(req)
+ const result = await attachTagsToDeal(id, tags)
+ await updateDealInRedis(id, { tags: result.tags }, { updatedAt: new Date() })
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.MOD.DEAL_TAG_ADD,
+ buildAuditMeta({
+ entityType: "DEAL",
+ entityId: Number(id),
+ after: { tags: result.tags },
+ })
+ )
+ res.json({ tags: result.tags })
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.delete(
+ "/deals/:id/tags",
+ requireAuth,
+ requireRole("MOD"),
+ validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedDealId
+ const tags = parseTagsFromBody(req)
+ const result = await removeTagsFromDeal(id, tags)
+ await updateDealInRedis(id, { tags: result.tags }, { updatedAt: new Date() })
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.MOD.DEAL_TAG_REMOVE,
+ buildAuditMeta({
+ entityType: "DEAL",
+ entityId: Number(id),
+ after: { tags: result.tags },
+ })
+ )
+ res.json({ tags: result.tags })
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.put(
+ "/deals/:id/tags",
+ requireAuth,
+ requireRole("MOD"),
+ validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedDealId
+ const tags = parseTagsFromBody(req, { allowEmpty: true })
+ const result = await replaceTagsForDeal(id, tags)
+ await updateDealInRedis(id, { tags: result.tags }, { updatedAt: new Date() })
+ res.json({ tags: result.tags })
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.delete(
+ "/comments/:id",
+ requireAuth,
+ requireRole("MOD"),
+ validate(mod.modDealUpdateParamsSchema, "params", "validatedCommentId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedCommentId
+ const result = await deleteCommentAsMod(id)
+ res.json(result)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.post(
+ "/users/:id/mute",
+ requireAuth,
+ requireRole("MOD"),
+ validate(mod.modDealUpdateParamsSchema, "params", "validatedUserId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedUserId
+ const durationDays = Number(req.body?.durationDays || 7)
+ const result = await moderationService.muteUser(id, { durationDays })
+ res.json({
+ userId: result.id,
+ mutedUntil: result.mutedUntil ? new Date(result.mutedUntil).toISOString() : null,
+ })
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.delete(
+ "/users/:id/mute",
+ requireAuth,
+ requireRole("MOD"),
+ validate(mod.modDealUpdateParamsSchema, "params", "validatedUserId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedUserId
+ const result = await moderationService.clearMute(id)
+ res.json({ userId: result.id, mutedUntil: null })
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.post(
+ "/users/:id/notes",
+ requireAuth,
+ requireRole("MOD"),
+ validate(mod.modDealUpdateParamsSchema, "params", "validatedUserId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedUserId
+ const note = String(req.body?.note || "").trim()
+ const result = await moderationService.addUserNote({
+ userId: id,
+ createdById: req.auth.userId,
+ note,
+ })
+ res.json(result)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.get(
+ "/users/:id/notes",
+ requireAuth,
+ requireRole("MOD"),
+ validate(mod.modDealUpdateParamsSchema, "params", "validatedUserId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedUserId
+ const page = Number(req.query.page || 1)
+ const limit = Number(req.query.limit || 20)
+ const result = await moderationService.listUserNotes({ userId: id, page, limit })
+ res.json(result)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.patch(
+ "/deals/reports/:id",
+ requireAuth,
+ requireRole("MOD"),
+ validate(mod.modDealUpdateParamsSchema, "params", "validatedReportId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedReportId
+ const status = req.body?.status
+ const result = await dealReportService.updateDealReportStatus({
+ reportId: id,
+ status,
+ })
+ res.json(result)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.post(
+ "/users/:id/disable",
+ requireAuth,
+ requireRole("ADMIN"),
+ validate(mod.modDealUpdateParamsSchema, "params", "validatedUserId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedUserId
+ const result = await moderationService.disableUser(id)
+ res.json({
+ userId: result.id,
+ disabledAt: result.disabledAt ? new Date(result.disabledAt).toISOString() : null,
+ })
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.delete(
+ "/users/:id/disable",
+ requireAuth,
+ requireRole("ADMIN"),
+ validate(mod.modDealUpdateParamsSchema, "params", "validatedUserId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedUserId
+ const result = await moderationService.enableUser(id)
+ res.json({ userId: result.id, disabledAt: null })
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.patch(
+ "/users/:id/role",
+ requireAuth,
+ requireRole("ADMIN"),
+ validate(mod.modDealUpdateParamsSchema, "params", "validatedUserId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedUserId
+ const role = req.body?.role
+ if (String(role || "").toUpperCase() === "ADMIN") {
+ return res.status(400).json({ error: "ADMIN rolü verilemez" })
+ }
+ const result = await moderationService.updateUserRole(id, role)
+ res.json({ userId: result.id, role: result.role })
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.post("/categories", requireAuth, requireRole("ADMIN"), async (req, res) => {
+ try {
+ const category = await adminService.createCategory(req.body || {})
+ res.json(category)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+})
+
+router.patch(
+ "/categories/:id",
+ requireAuth,
+ requireRole("ADMIN"),
+ validate(mod.modDealUpdateParamsSchema, "params", "validatedCategoryId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedCategoryId
+ const category = await adminService.updateCategory(id, req.body || {})
+ res.json(category)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.post("/sellers", requireAuth, requireRole("ADMIN"), async (req, res) => {
+ try {
+ if (req.body?.id) {
+ const seller = await adminService.updateSeller(req.body.id, req.body || {}, {
+ createdById: req.auth.userId,
+ })
+ return res.json(seller)
+ }
+ const seller = await adminService.createSeller(req.body || {}, {
+ createdById: req.auth.userId,
+ })
+ res.json(seller)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+})
+
+router.patch(
+ "/sellers/:id",
+ requireAuth,
+ requireRole("ADMIN"),
+ validate(mod.modDealUpdateParamsSchema, "params", "validatedSellerId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedSellerId
+ const seller = await adminService.updateSeller(id, req.body || {}, { createdById: req.auth.userId })
+ res.json(seller)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.get("/admin/categories", requireAuth, requireRole("ADMIN"), async (req, res) => {
+ try {
+ const categories = await adminService.listCategoriesCached()
+ res.json(categories)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+})
+
+router.get("/admin/sellers", requireAuth, requireRole("ADMIN"), async (req, res) => {
+ try {
+ const sellers = await adminService.listSellersCached()
+ res.json(sellers)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+})
+
+router.get("/admin/metrics", requireAuth, requireRole("ADMIN"), async (req, res) => {
+ try {
+ const metrics = await adminMetricsService.getAdminMetrics()
+ res.json(metrics)
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+})
+
+router.patch(
+ "/deals/:id/override",
+ requireAuth,
+ requireRole("ADMIN"),
+ validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
+ async (req, res) => {
+ try {
+ const { id } = req.validatedDealId
+ const status = req.body?.status
+ const userId = req.body?.userId
+ const normalizedStatus = status !== undefined ? normalizeDealStatus(status) : null
+ if (status !== undefined && !normalizedStatus) {
+ return res.status(400).json({ error: "Gecersiz status" })
+ }
+ const normalizedUserId =
+ userId !== undefined && userId !== null ? Number(userId) : undefined
+ if (normalizedUserId !== undefined) {
+ if (!Number.isInteger(normalizedUserId) || normalizedUserId <= 0) {
+ return res.status(400).json({ error: "Gecersiz userId" })
+ }
+ }
+
+ const { deal } = await getOrCacheDealForModeration(id)
+ if (!deal) return res.status(404).json({ error: "Deal bulunamadi" })
+
+ const patch = {}
+ if (status !== undefined) patch.status = normalizedStatus
+ if (normalizedUserId !== undefined) patch.userId = normalizedUserId
+ if (!Object.keys(patch).length) {
+ return res.status(400).json({ error: "Guncellenecek alan yok" })
+ }
+
+ const updatedAt = new Date()
+ const updated = await updateDealInRedis(id, patch, { updatedAt })
+ queueDealUpdate({
+ dealId: Number(id),
+ data: patch,
+ updatedAt: updatedAt.toISOString(),
+ }).catch((err) => console.error("DB sync deal override failed:", err?.message || err))
+
+ res.json({
+ id: Number(id),
+ status: updated?.status ?? patch.status ?? deal.status,
+ userId: updated?.userId ?? patch.userId ?? deal.userId,
+ })
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.get("/sellers", requireAuth, requireRole("MOD"), async (req, res) => {
+ try {
+ const sellers = await adminService.listSellersCached()
+ const payload = sellers.map((seller) => ({
+ id: seller.id,
+ name: seller.name,
+ url: seller.url ?? "",
+ sellerLogo: seller.sellerLogo ?? "",
+ isActive: seller.isActive ?? true,
+ }))
+ res.json(mod.modSellerListResponseSchema.parse(payload))
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+})
+
+router.get("/categories", requireAuth, requireRole("MOD"), async (req, res) => {
+ try {
+ const categories = await adminService.listCategoriesCached()
+ const payload = categories.map((category) => ({
+ id: category.id,
+ name: category.name,
+ parentId: category.parentId ?? null,
+ }))
+ res.json(mod.modCategoryListResponseSchema.parse(payload))
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+})
+
+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) {
+ 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)
+ await setBadgeInRedis(badge)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.MOD.BADGE_CREATE,
+ buildAuditMeta({
+ entityType: "BADGE",
+ entityId: badge.id,
+ after: { name: badge.name },
+ })
+ )
+ res.json(mod.modBadgeResponseSchema.parse(badge))
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+})
+
+router.patch(
+ "/badges/:id",
+ requireAuth,
+ requireRole("MOD"),
+ modBadgeUpdateParamsValidator,
+ modBadgeUpdateValidator,
+ async (req, res) => {
+ try {
+ const { id } = req.validatedBadgeId
+ const badge = await badgeService.updateBadge(id, req.validatedBadgeUpdate)
+ await setBadgeInRedis(badge)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.MOD.BADGE_UPDATE,
+ buildAuditMeta({
+ entityType: "BADGE",
+ entityId: badge.id,
+ extra: { fields: Object.keys(req.validatedBadgeUpdate || {}) },
+ })
+ )
+ res.json(mod.modBadgeResponseSchema.parse(badge))
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.post(
+ "/badges/assign",
+ requireAuth,
+ requireRole("MOD"),
+ modBadgeAssignValidator,
+ async (req, res) => {
+ try {
+ const assigned = await badgeService.assignBadgeToUser(req.validatedBadgeAssign)
+ const response = {
+ userId: assigned.userId,
+ badgeId: assigned.badgeId,
+ earnedAt: assigned.earnedAt instanceof Date ? assigned.earnedAt.toISOString() : assigned.earnedAt,
+ }
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.MOD.BADGE_ASSIGN,
+ buildAuditMeta({
+ entityType: "USER",
+ entityId: response.userId,
+ extra: { badgeId: response.badgeId, earnedAt: response.earnedAt },
+ })
+ )
+ res.json(mod.modBadgeAssignResponseSchema.parse(response))
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
+router.delete(
+ "/badges/assign",
+ requireAuth,
+ requireRole("MOD"),
+ modBadgeRemoveValidator,
+ async (req, res) => {
+ try {
+ await badgeService.removeBadgeFromUser(req.validatedBadgeRemove)
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.MOD.BADGE_REMOVE,
+ buildAuditMeta({
+ entityType: "USER",
+ entityId: req.validatedBadgeRemove.userId,
+ extra: { badgeId: req.validatedBadgeRemove.badgeId },
+ })
+ )
+ res.json(mod.modBadgeRemoveResponseSchema.parse({ removed: true }))
+ } catch (err) {
+ const status = err.statusCode || 500
+ res.status(status).json({ error: err.message || "Sunucu hatasi" })
+ }
+ }
+)
+
module.exports = router
+
diff --git a/routes/seller.routes.js b/routes/seller.routes.js
index 8da137b..fd97464 100644
--- a/routes/seller.routes.js
+++ b/routes/seller.routes.js
@@ -5,10 +5,13 @@ const requireAuth = require("../middleware/requireAuth")
const optionalAuth = require("../middleware/optionalAuth")
const { validate } = require("../middleware/validate.middleware")
const { endpoints } = require("@shared/contracts")
-const { getSellerByName, getDealsBySellerName } = require("../services/seller.service")
+const { getSellerByName, getDealsBySellerName, getActiveSellers } = require("../services/seller.service")
const { findSellerFromLink } = require("../services/sellerLookup.service")
+const { getProductPreviewFromUrl } = require("../services/productPreview.service")
const { mapSellerToSellerDetailsResponse } = require("../adapters/responses/sellerDetails.adapter")
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
+const { getClientIp } = require("../utils/requestInfo")
+const { queueDealImpressions } = require("../services/redis/dealAnalytics.service")
const { seller, deals } = endpoints
@@ -20,7 +23,11 @@ const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "vali
router.post("/from-link", requireAuth, async (req, res) => {
try {
const sellerUrl = req.body.url
- const sellerLookup = await findSellerFromLink(sellerUrl)
+ if (!sellerUrl) return res.status(400).json({ error: "url parametresi zorunlu" })
+ const [sellerLookup, product] = await Promise.all([
+ findSellerFromLink(sellerUrl),
+ getProductPreviewFromUrl(sellerUrl),
+ ])
const response = seller.sellerLookupResponseSchema.parse(
sellerLookup
@@ -31,8 +38,9 @@ router.post("/from-link", requireAuth, async (req, res) => {
name: sellerLookup.name,
url: sellerLookup.url ?? null,
},
+ product,
}
- : { found: false, seller: null }
+ : { found: false, seller: null, product }
)
return res.json(response)
@@ -42,6 +50,16 @@ router.post("/from-link", requireAuth, async (req, res) => {
}
})
+router.get("/", async (req, res) => {
+ try {
+ const sellers = await getActiveSellers()
+ res.json(sellers)
+ } catch (e) {
+ const status = e.statusCode || 500
+ res.status(status).json({ error: e.message || "Sunucu hatasi" })
+ }
+})
+
router.get("/:sellerName", async (req, res) => {
try {
const sellerName = req.params.sellerName
@@ -68,7 +86,20 @@ router.get("/:sellerName/deals", optionalAuth, listQueryValidator, async (req, r
})
const response = mapPaginatedDealsToDealCardResponse(payload)
- res.json(response.results)
+ const dealIds = payload?.results?.map((deal) => deal.id) || []
+ queueDealImpressions({
+ dealIds,
+ userId: req.auth?.userId ?? null,
+ ip: getClientIp(req),
+ }).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
+ res.json({
+ page: response.page,
+ total: response.total,
+ totalPages: response.totalPages,
+ results: response.results,
+ minPrice: payload?.minPrice ?? null,
+ maxPrice: payload?.maxPrice ?? null,
+ })
} catch (e) {
const status = e.statusCode || 500
res.status(status).json({ error: e.message || "Sunucu hatasi" })
diff --git a/routes/upload.routes.js b/routes/upload.routes.js
new file mode 100644
index 0000000..90d81b3
--- /dev/null
+++ b/routes/upload.routes.js
@@ -0,0 +1,55 @@
+const express = require("express")
+const { v4: uuidv4 } = require("uuid")
+
+const requireAuth = require("../middleware/requireAuth")
+const requireNotRestricted = require("../middleware/requireNotRestricted")
+const { upload } = require("../middleware/upload.middleware")
+const { uploadImage } = require("../services/uploadImage.service")
+const { enqueueAuditFromRequest, buildAuditMeta } = require("../services/audit.service")
+const { AUDIT_ACTIONS } = require("../services/auditActions")
+
+const router = express.Router()
+
+router.post(
+ "/image",
+ requireAuth,
+ requireNotRestricted({ checkSuspend: true }),
+ upload.single("file"),
+ async (req, res) => {
+ try {
+ if (!req.file) return res.status(400).json({ error: "Dosya zorunlu" })
+ if (!req.file.mimetype?.startsWith("image/")) {
+ return res.status(400).json({ error: "Sadece resim kabul edilir" })
+ }
+
+ const key = uuidv4()
+ const ext = req.file.originalname?.split(".").pop() || "jpg"
+ const path = `misc/${req.auth.userId}/${key}.${ext}`
+
+ const url = await uploadImage({
+ bucket: "deal",
+ path,
+ fileBuffer: req.file.buffer,
+ contentType: req.file.mimetype,
+ })
+
+ enqueueAuditFromRequest(
+ req,
+ AUDIT_ACTIONS.MEDIA.UPLOAD,
+ buildAuditMeta({
+ entityType: "MEDIA",
+ entityId: path,
+ extra: { contentType: req.file.mimetype },
+ })
+ )
+
+ res.json({ url })
+ } catch (err) {
+ console.error(err)
+ res.status(500).json({ error: "Sunucu hatasi" })
+ }
+ }
+)
+
+module.exports = router
+
diff --git a/routes/user.routes.js b/routes/user.routes.js
index 6bee424..a08adcb 100644
--- a/routes/user.routes.js
+++ b/routes/user.routes.js
@@ -2,11 +2,26 @@
const express = require("express")
const router = express.Router()
const { validate } = require("../middleware/validate.middleware")
+const optionalAuth = require("../middleware/optionalAuth")
const userService = require("../services/user.service")
const userProfileAdapter = require("../adapters/responses/userProfile.adapter")
+const commentAdapter = require("../adapters/responses/comment.adapter")
+const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
const { endpoints } = require("@shared/contracts")
+const {
+ getUserProfileFromRedis,
+ setUserProfileInRedis,
+} = require("../services/redis/userProfileCache.service")
const { users } = endpoints
+const PROFILE_PAGE_SIZE = 15
+const PROFILE_CACHE_TTL_SECONDS = 60
+
+function parsePage(value) {
+ const num = Number(value)
+ if (!Number.isInteger(num) || num < 1) return 1
+ return num
+}
router.get(
"/:userName",
@@ -14,12 +29,20 @@ router.get(
async (req, res) => {
try {
const { userName } = req.validatedUserProfile
+ const cached = await getUserProfileFromRedis(userName)
+ if (cached) return res.json(cached)
const data = await userService.getUserProfileByUsername(userName)
const response = users.userProfileResponseSchema.parse(
userProfileAdapter.mapUserProfileToResponse(data)
)
- res.json(response)
+ const payload = {
+ ...response,
+ dealsPagination: data.dealsPagination,
+ commentsPagination: data.commentsPagination,
+ }
+ await setUserProfileInRedis(userName, payload, { ttlSeconds: PROFILE_CACHE_TTL_SECONDS })
+ res.json(payload)
} catch (err) {
console.error(err)
const status = err.statusCode || 500
@@ -30,4 +53,58 @@ router.get(
}
)
+router.get(
+ "/:userName/comments",
+ validate(users.userProfileRequestSchema, "params", "validatedUserProfile"),
+ async (req, res) => {
+ try {
+ const { userName } = req.validatedUserProfile
+ const page = parsePage(req.query.page)
+ const payload = await userService.getUserCommentsByUsername(userName, {
+ page,
+ limit: PROFILE_PAGE_SIZE,
+ })
+ const mapped = payload.results.map(commentAdapter.mapCommentToUserCommentResponse)
+ res.json({
+ page: payload.page,
+ total: payload.total,
+ totalPages: payload.totalPages,
+ limit: payload.limit,
+ results: mapped,
+ })
+ } catch (err) {
+ console.error(err)
+ const status = err.statusCode || 500
+ res.status(status).json({
+ message: err.message || "Kullanici yorumlari alinamadi.",
+ })
+ }
+ }
+)
+
+router.get(
+ "/:userName/deals",
+ optionalAuth,
+ validate(users.userProfileRequestSchema, "params", "validatedUserProfile"),
+ async (req, res) => {
+ try {
+ const { userName } = req.validatedUserProfile
+ const page = parsePage(req.query.page)
+ const payload = await userService.getUserDealsByUsername(userName, {
+ page,
+ limit: PROFILE_PAGE_SIZE,
+ viewer: req.auth ? { userId: req.auth.userId, role: req.auth.role } : null,
+ })
+ const response = mapPaginatedDealsToDealCardResponse(payload)
+ res.json(response)
+ } catch (err) {
+ console.error(err)
+ const status = err.statusCode || 500
+ res.status(status).json({
+ message: err.message || "Kullanici deal'lari alinamadi.",
+ })
+ }
+ }
+)
+
module.exports = router
diff --git a/routes/vote.routes.js b/routes/vote.routes.js
index d707b04..1e8d996 100644
--- a/routes/vote.routes.js
+++ b/routes/vote.routes.js
@@ -1,5 +1,6 @@
const express = require("express")
const requireAuth = require("../middleware/requireAuth")
+const requireNotRestricted = require("../middleware/requireNotRestricted")
const { validate } = require("../middleware/validate.middleware")
const { endpoints } = require("@shared/contracts")
const voteService = require("../services/vote.service")
@@ -10,6 +11,7 @@ const { votes } = endpoints
router.post(
"/",
requireAuth,
+ requireNotRestricted({ checkSuspend: true }),
validate(votes.voteRequestSchema, "body", "validatedVotePayload"),
async (req, res) => {
try {
diff --git a/server.js b/server.js
index d3380ad..a27d83b 100644
--- a/server.js
+++ b/server.js
@@ -1,4 +1,5 @@
const express = require("express");
+const path = require("path");
const cors = require("cors");
require("dotenv").config();
const cookieParser = require("cookie-parser");
@@ -16,12 +17,24 @@ const voteRoutes = require("./routes/vote.routes");
const commentLikeRoutes = require("./routes/commentLike.routes");
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 { ensureDealSearchIndex } = require("./services/redis/searchIndex.service")
+const { seedRecentDealsToRedis, seedReferenceDataToRedis } = require("./services/redis/dealIndexing.service")
+const { ensureCommentIdCounter } = require("./services/redis/commentId.service")
+const { ensureDealIdCounter } = require("./services/redis/dealId.service")
+const dealDb = require("./db/deal.db")
+const { slugify } = require("./utils/slugify")
+const { requestContextMiddleware } = require("./services/requestContext")
const app = express();
+app.set("trust proxy", true)
+
// CORS middleware'ı ile dışardan gelen istekleri kontrol et
const allowedOrigins = new Set([
"http://192.168.1.205:3001",
"http://localhost:3001",
+ "http://localhost:3000",
]);
app.use(
@@ -37,7 +50,9 @@ app.use(
// JSON, URL encoded ve cookies'leri parse etme
app.use(express.json()); // JSON verisi almak için
app.use(express.urlencoded({ extended: true })); // URL encoded veriler için
-app.use(cookieParser()); // Cookies'leri çözümlemek için
+app.use(cookieParser());
+app.use(requestContextMiddleware) // Cookies'leri çözümlemek için
+
// API route'larını tanımlama
app.use("/api/users", userRoutesneedRefactor); // User işlemleri
@@ -52,5 +67,29 @@ app.use("/api/vote", voteRoutes); // Vote işlemleri
app.use("/api/comment-likes", commentLikeRoutes); // Comment like işlemleri
app.use("/api/category", categoryRoutes);
app.use("/api/mod", modRoutes);
-// Sunucuyu dinlemeye başla
-app.listen(3000, () => console.log("Server running on http://localhost:3000"));
+app.use("/api/uploads", uploadRoutes);
+app.use("/api/badges", badgeRoutes);
+
+app.get("/api/openapi.json", (req, res) => {
+ res.sendFile(path.join(__dirname, "docs", "openapi.json"));
+});
+
+app.get("/api/docs", (req, res) => {
+ res.sendFile(path.join(__dirname, "docs", "swagger.html"));
+});
+
+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()
+ // Sunucuyu dinlemeye ba??la
+ app.listen(3000, () => console.log("Server running on http://localhost:3000"));
+}
+
+startServer().catch((err) => {
+ console.error("Server failed to start:", err?.message || err)
+ process.exit(1)
+})
diff --git a/services/admin.service.js b/services/admin.service.js
new file mode 100644
index 0000000..c87ed40
--- /dev/null
+++ b/services/admin.service.js
@@ -0,0 +1,306 @@
+const categoryDb = require("../db/category.db")
+const sellerDb = require("../db/seller.db")
+const { slugify } = require("../utils/slugify")
+const {
+ listCategoriesFromRedis,
+ setCategoryInRedis,
+ setCategoriesInRedis,
+ getCategoryById,
+} = require("./redis/categoryCache.service")
+const {
+ listSellersFromRedis,
+ setSellerInRedis,
+ setSellersInRedis,
+ getSellerById,
+ setSellerDomainInRedis,
+} = require("./redis/sellerCache.service")
+const {
+ queueCategoryUpsert,
+ queueSellerUpsert,
+ queueSellerDomainUpsert,
+} = require("./redis/dbSync.service")
+const { ensureCategoryIdCounter, generateCategoryId } = require("./redis/categoryId.service")
+const { ensureSellerIdCounter, generateSellerId } = require("./redis/sellerId.service")
+
+function httpError(statusCode, message) {
+ const err = new Error(message)
+ err.statusCode = statusCode
+ return err
+}
+
+function normalizeCategoryPayload(input = {}, fallback = {}) {
+ const name = input.name !== undefined ? String(input.name || "").trim() : fallback.name
+ const rawSlug = input.slug !== undefined ? String(input.slug || "").trim() : fallback.slug
+ const slug = rawSlug ? slugify(rawSlug) : name ? slugify(name) : fallback.slug
+ const description =
+ input.description !== undefined ? String(input.description || "").trim() : fallback.description
+ const parentId =
+ input.parentId !== undefined && input.parentId !== null
+ ? Number(input.parentId)
+ : input.parentId === null
+ ? null
+ : fallback.parentId ?? null
+ const isActive =
+ input.isActive !== undefined ? Boolean(input.isActive) : Boolean(fallback.isActive ?? true)
+
+ return { name, slug, description, parentId, isActive }
+}
+
+async function ensureCategoryParent(parentId) {
+ if (parentId === null || parentId === undefined) return null
+ const pid = Number(parentId)
+ if (!Number.isInteger(pid) || pid < 0) throw httpError(400, "INVALID_PARENT_ID")
+ if (pid === 0) return 0
+ const cached = await getCategoryById(pid)
+ if (cached) return pid
+ const fromDb = await categoryDb.findCategoryById(pid, { select: { id: true, name: true, slug: true, parentId: true, isActive: true, description: true } })
+ if (!fromDb) throw httpError(404, "CATEGORY_PARENT_NOT_FOUND")
+ await setCategoryInRedis(fromDb)
+ return pid
+}
+
+async function listCategoriesCached() {
+ let categories = await listCategoriesFromRedis()
+ if (!categories.length) {
+ categories = await categoryDb.listCategories({
+ select: { id: true, name: true, slug: true, parentId: true, isActive: true, description: true },
+ orderBy: { id: "asc" },
+ })
+ if (categories.length) await setCategoriesInRedis(categories)
+ }
+
+ return categories
+ .map((cat) => ({
+ id: cat.id,
+ name: cat.name,
+ slug: cat.slug,
+ parentId: cat.parentId ?? null,
+ isActive: cat.isActive !== undefined ? Boolean(cat.isActive) : true,
+ description: cat.description ?? "",
+ }))
+ .sort((a, b) => a.id - b.id)
+}
+
+async function createCategory(input = {}) {
+ const payload = normalizeCategoryPayload(input)
+ if (!payload.name) throw httpError(400, "CATEGORY_NAME_REQUIRED")
+ if (!payload.slug) throw httpError(400, "CATEGORY_SLUG_REQUIRED")
+
+ const categories = await listCategoriesCached()
+ const duplicate = categories.find((c) => c.slug === payload.slug)
+ if (duplicate) throw httpError(400, "CATEGORY_SLUG_EXISTS")
+
+ await ensureCategoryIdCounter()
+ const id = await generateCategoryId()
+ const parentId = await ensureCategoryParent(payload.parentId ?? null)
+
+ const category = {
+ id,
+ name: payload.name,
+ slug: payload.slug,
+ parentId: parentId ?? null,
+ isActive: payload.isActive,
+ description: payload.description ?? "",
+ }
+
+ await setCategoryInRedis(category)
+ queueCategoryUpsert({
+ categoryId: id,
+ data: {
+ name: category.name,
+ slug: category.slug,
+ parentId: category.parentId,
+ isActive: category.isActive,
+ description: category.description ?? "",
+ },
+ updatedAt: new Date().toISOString(),
+ }).catch((err) => console.error("DB sync category create failed:", err?.message || err))
+
+ return category
+}
+
+async function updateCategory(categoryId, input = {}) {
+ const id = Number(categoryId)
+ if (!Number.isInteger(id) || id < 0) throw httpError(400, "INVALID_CATEGORY_ID")
+
+ const cached = await getCategoryById(id)
+ const existing =
+ cached ||
+ (await categoryDb.findCategoryById(id, {
+ select: { id: true, name: true, slug: true, parentId: true, isActive: true, description: true },
+ }))
+ if (!existing) throw httpError(404, "CATEGORY_NOT_FOUND")
+
+ const payload = normalizeCategoryPayload(input, existing)
+ if (!payload.name) throw httpError(400, "CATEGORY_NAME_REQUIRED")
+ if (!payload.slug) throw httpError(400, "CATEGORY_SLUG_REQUIRED")
+ if (payload.parentId !== null && Number(payload.parentId) === id) {
+ throw httpError(400, "INVALID_PARENT_ID")
+ }
+
+ const categories = await listCategoriesCached()
+ const duplicate = categories.find((c) => c.slug === payload.slug && Number(c.id) !== id)
+ if (duplicate) throw httpError(400, "CATEGORY_SLUG_EXISTS")
+
+ const parentId = await ensureCategoryParent(payload.parentId ?? null)
+ const category = {
+ id,
+ name: payload.name,
+ slug: payload.slug,
+ parentId: parentId ?? null,
+ isActive: payload.isActive,
+ description: payload.description ?? "",
+ }
+
+ await setCategoryInRedis(category)
+ queueCategoryUpsert({
+ categoryId: id,
+ data: {
+ name: category.name,
+ slug: category.slug,
+ parentId: category.parentId,
+ isActive: category.isActive,
+ description: category.description ?? "",
+ },
+ updatedAt: new Date().toISOString(),
+ }).catch((err) => console.error("DB sync category update failed:", err?.message || err))
+
+ return category
+}
+
+function normalizeSellerPayload(input = {}, fallback = {}) {
+ const name = input.name !== undefined ? String(input.name || "").trim() : fallback.name
+ const url = input.url !== undefined ? String(input.url || "").trim() : fallback.url
+ const sellerLogo =
+ input.sellerLogo !== undefined ? String(input.sellerLogo || "").trim() : fallback.sellerLogo
+ const isActive =
+ input.isActive !== undefined ? Boolean(input.isActive) : Boolean(fallback.isActive ?? true)
+ return { name, url: url ?? "", sellerLogo: sellerLogo ?? "", isActive }
+}
+
+async function listSellersCached() {
+ let sellers = await listSellersFromRedis()
+ if (!sellers.length) {
+ sellers = await sellerDb.findSellers({}, {
+ select: { id: true, name: true, url: true, sellerLogo: true, isActive: true },
+ orderBy: { name: "asc" },
+ })
+ if (sellers.length) await setSellersInRedis(sellers)
+ }
+ return sellers.map((seller) => ({
+ id: seller.id,
+ name: seller.name,
+ url: seller.url ?? "",
+ sellerLogo: seller.sellerLogo ?? "",
+ isActive: seller.isActive !== undefined ? Boolean(seller.isActive) : true,
+ }))
+}
+
+async function createSeller(input = {}, { createdById } = {}) {
+ const payload = normalizeSellerPayload(input)
+ if (!payload.name) throw httpError(400, "SELLER_NAME_REQUIRED")
+ const creatorId = Number(createdById)
+ if (!Number.isInteger(creatorId) || creatorId <= 0) throw httpError(400, "CREATED_BY_REQUIRED")
+
+ const sellers = await listSellersCached()
+ const duplicate = sellers.find((s) => s.name.toLowerCase() === payload.name.toLowerCase())
+ if (duplicate) throw httpError(400, "SELLER_NAME_EXISTS")
+
+ await ensureSellerIdCounter()
+ const id = await generateSellerId()
+ const seller = {
+ id,
+ name: payload.name,
+ url: payload.url ?? "",
+ sellerLogo: payload.sellerLogo ?? "",
+ isActive: payload.isActive,
+ }
+
+ await setSellerInRedis(seller)
+ queueSellerUpsert({
+ sellerId: id,
+ data: {
+ name: seller.name,
+ url: seller.url ?? "",
+ sellerLogo: seller.sellerLogo ?? "",
+ isActive: seller.isActive,
+ createdById: creatorId,
+ },
+ updatedAt: new Date().toISOString(),
+ }).catch((err) => console.error("DB sync seller create failed:", err?.message || err))
+
+ if (input.domain) {
+ const domain = String(input.domain || "").trim().toLowerCase()
+ if (domain) {
+ await setSellerDomainInRedis(domain, id)
+ queueSellerDomainUpsert({ sellerId: id, domain, createdById: creatorId }).catch((err) =>
+ console.error("DB sync seller domain failed:", err?.message || err)
+ )
+ }
+ }
+
+ return seller
+}
+
+async function updateSeller(sellerId, input = {}, { createdById } = {}) {
+ const id = Number(sellerId)
+ if (!Number.isInteger(id) || id <= 0) throw httpError(400, "INVALID_SELLER_ID")
+ const creatorId = Number(createdById)
+ if (!Number.isInteger(creatorId) || creatorId <= 0) throw httpError(400, "CREATED_BY_REQUIRED")
+
+ const cached = await getSellerById(id)
+ const existing =
+ cached ||
+ (await sellerDb.findSeller({ id }, { select: { id: true, name: true, url: true, sellerLogo: true, isActive: true } }))
+ if (!existing) throw httpError(404, "SELLER_NOT_FOUND")
+
+ const payload = normalizeSellerPayload(input, existing)
+ if (!payload.name) throw httpError(400, "SELLER_NAME_REQUIRED")
+
+ const sellers = await listSellersCached()
+ const duplicate = sellers.find(
+ (s) => s.name.toLowerCase() === payload.name.toLowerCase() && Number(s.id) !== id
+ )
+ if (duplicate) throw httpError(400, "SELLER_NAME_EXISTS")
+
+ const seller = {
+ id,
+ name: payload.name,
+ url: payload.url ?? "",
+ sellerLogo: payload.sellerLogo ?? "",
+ isActive: payload.isActive,
+ }
+
+ await setSellerInRedis(seller)
+ queueSellerUpsert({
+ sellerId: id,
+ data: {
+ name: seller.name,
+ url: seller.url ?? "",
+ sellerLogo: seller.sellerLogo ?? "",
+ isActive: seller.isActive,
+ },
+ updatedAt: new Date().toISOString(),
+ }).catch((err) => console.error("DB sync seller update failed:", err?.message || err))
+
+ if (input.domain) {
+ const domain = String(input.domain || "").trim().toLowerCase()
+ if (domain) {
+ await setSellerDomainInRedis(domain, id)
+ queueSellerDomainUpsert({ sellerId: id, domain, createdById: creatorId }).catch((err) =>
+ console.error("DB sync seller domain failed:", err?.message || err)
+ )
+ }
+ }
+
+ return seller
+}
+
+module.exports = {
+ listCategoriesCached,
+ createCategory,
+ updateCategory,
+ listSellersCached,
+ createSeller,
+ updateSeller,
+}
diff --git a/services/adminMetrics.service.js b/services/adminMetrics.service.js
new file mode 100644
index 0000000..bb81b05
--- /dev/null
+++ b/services/adminMetrics.service.js
@@ -0,0 +1,117 @@
+const { getRedisClient } = require("./redis/client")
+const dealReportDb = require("../db/dealReport.db")
+const {
+ VOTE_HASH_KEY,
+ COMMENT_LIKE_HASH_KEY,
+ COMMENT_HASH_KEY,
+ COMMENT_DELETE_HASH_KEY,
+ DEAL_UPDATE_HASH_KEY,
+ DEAL_CREATE_HASH_KEY,
+ DEAL_AI_REVIEW_HASH_KEY,
+ NOTIFICATION_HASH_KEY,
+ NOTIFICATION_READ_HASH_KEY,
+ DEAL_SAVE_HASH_KEY,
+ AUDIT_HASH_KEY,
+ USER_UPDATE_HASH_KEY,
+ USER_NOTE_HASH_KEY,
+ DEAL_REPORT_UPDATE_HASH_KEY,
+ CATEGORY_UPSERT_HASH_KEY,
+ SELLER_UPSERT_HASH_KEY,
+ SELLER_DOMAIN_UPSERT_HASH_KEY,
+} = require("./redis/dbSync.service")
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+function parseRedisInfo(raw = "") {
+ const info = {}
+ String(raw)
+ .split("\n")
+ .forEach((line) => {
+ if (!line || line.startsWith("#")) return
+ const idx = line.indexOf(":")
+ if (idx <= 0) return
+ const key = line.slice(0, idx)
+ const value = line.slice(idx + 1).trim()
+ info[key] = value
+ })
+ return info
+}
+
+async function getPendingDealCount(redis) {
+ try {
+ const result = await redis.call(
+ "FT.SEARCH",
+ "idx:data:deals",
+ "@status:{PENDING}",
+ "LIMIT",
+ 0,
+ 0
+ )
+ const total = Array.isArray(result) ? Number(result[0]) : null
+ return Number.isFinite(total) ? total : 0
+ } catch (err) {
+ return null
+ }
+}
+
+async function getQueueSizes(redis) {
+ const keys = [
+ VOTE_HASH_KEY,
+ COMMENT_LIKE_HASH_KEY,
+ COMMENT_HASH_KEY,
+ COMMENT_DELETE_HASH_KEY,
+ DEAL_UPDATE_HASH_KEY,
+ DEAL_CREATE_HASH_KEY,
+ DEAL_AI_REVIEW_HASH_KEY,
+ NOTIFICATION_HASH_KEY,
+ NOTIFICATION_READ_HASH_KEY,
+ DEAL_SAVE_HASH_KEY,
+ AUDIT_HASH_KEY,
+ USER_UPDATE_HASH_KEY,
+ USER_NOTE_HASH_KEY,
+ DEAL_REPORT_UPDATE_HASH_KEY,
+ CATEGORY_UPSERT_HASH_KEY,
+ SELLER_UPSERT_HASH_KEY,
+ SELLER_DOMAIN_UPSERT_HASH_KEY,
+ ]
+
+ const pipeline = redis.pipeline()
+ keys.forEach((key) => pipeline.hlen(key))
+ const results = await pipeline.exec()
+
+ const sizes = {}
+ keys.forEach((key, idx) => {
+ const value = results?.[idx]?.[1]
+ sizes[key] = Number.isFinite(Number(value)) ? Number(value) : 0
+ })
+ return sizes
+}
+
+async function getAdminMetrics() {
+ const redis = createRedisClient()
+ try {
+ const [pendingDeals, queues, infoRaw, openReports] = await Promise.all([
+ getPendingDealCount(redis),
+ getQueueSizes(redis),
+ redis.info(),
+ dealReportDb.countDealReports({ status: "OPEN" }),
+ ])
+ const info = parseRedisInfo(infoRaw)
+
+ return {
+ pendingDeals,
+ openReports,
+ redis: {
+ usedMemory: info.used_memory ?? null,
+ connectedClients: info.connected_clients ?? null,
+ keyspaceHits: info.keyspace_hits ?? null,
+ keyspaceMisses: info.keyspace_misses ?? null,
+ },
+ dbsyncQueues: queues,
+ }
+ } finally {}
+}
+
+module.exports = { getAdminMetrics }
diff --git a/services/audit.service.js b/services/audit.service.js
new file mode 100644
index 0000000..09b29dc
--- /dev/null
+++ b/services/audit.service.js
@@ -0,0 +1,51 @@
+const { queueAuditEvent } = require("./redis/dbSync.service")
+
+function buildAuditMeta({ entityType, entityId, before = undefined, after = undefined, extra = undefined } = {}) {
+ const meta = {}
+ if (entityType) meta.entityType = entityType
+ if (entityId !== undefined) meta.entityId = entityId
+ if (before !== undefined) meta.before = before
+ if (after !== undefined) meta.after = after
+ if (extra !== undefined) meta.extra = extra
+ return meta
+}
+
+function normalizeAuditMeta(meta) {
+ if (!meta) return null
+ if (typeof meta !== "object") return buildAuditMeta({ extra: meta })
+ const entityType = meta.entityType
+ const entityId = Object.prototype.hasOwnProperty.call(meta, "entityId") ? meta.entityId : undefined
+ const before = Object.prototype.hasOwnProperty.call(meta, "before") ? meta.before : undefined
+ const after = Object.prototype.hasOwnProperty.call(meta, "after") ? meta.after : undefined
+ const extra = Object.prototype.hasOwnProperty.call(meta, "extra") ? meta.extra : undefined
+ return buildAuditMeta({ entityType, entityId, before, after, extra })
+}
+
+function enqueueAuditEvent({ userId, action, ip, userAgent, meta = null }) {
+ if (!userId || !action) return
+ const normalizedMeta = normalizeAuditMeta(meta)
+ queueAuditEvent({
+ userId: Number(userId),
+ action,
+ ip,
+ userAgent,
+ meta: normalizedMeta,
+ }).catch((err) => console.error("Audit enqueue failed:", err?.message || err))
+}
+
+function enqueueAuditFromRequest(req, action, meta = null) {
+ if (!req?.auth?.userId) return
+ enqueueAuditEvent({
+ userId: req.auth.userId,
+ action,
+ ip: req.ip,
+ userAgent: req.headers["user-agent"] || null,
+ meta,
+ })
+}
+
+module.exports = {
+ enqueueAuditEvent,
+ enqueueAuditFromRequest,
+ buildAuditMeta,
+}
diff --git a/services/auditActions.js b/services/auditActions.js
new file mode 100644
index 0000000..3583fe8
--- /dev/null
+++ b/services/auditActions.js
@@ -0,0 +1,43 @@
+const AUDIT_ACTIONS = {
+ AUTH: {
+ LOGIN_SUCCESS: "AUTH_LOGIN_SUCCESS",
+ LOGOUT: "AUTH_LOGOUT",
+ REGISTER: "AUTH_REGISTER",
+ REFRESH: "AUTH_REFRESH",
+ },
+ ACCOUNT: {
+ AVATAR_UPDATE: "ACCOUNT_AVATAR_UPDATE",
+ NOTIFICATIONS_READ: "ACCOUNT_NOTIFICATIONS_READ",
+ PASSWORD_CHANGE: "ACCOUNT_PASSWORD_CHANGE",
+ },
+ DEAL: {
+ CREATE: "DEAL_CREATE",
+ REPORT_CREATE: "DEAL_REPORT_CREATE",
+ SAVE: "DEAL_SAVE",
+ UNSAVE: "DEAL_UNSAVE",
+ },
+ COMMENT: {
+ CREATE: "COMMENT_CREATE",
+ DELETE: "COMMENT_DELETE",
+ },
+ MEDIA: {
+ UPLOAD: "MEDIA_UPLOAD",
+ },
+ MOD: {
+ DEAL_APPROVE: "MOD_DEAL_APPROVE",
+ DEAL_REJECT: "MOD_DEAL_REJECT",
+ DEAL_EXPIRE: "MOD_DEAL_EXPIRE",
+ DEAL_UNEXPIRE: "MOD_DEAL_UNEXPIRE",
+ DEAL_UPDATE: "MOD_DEAL_UPDATE",
+ DEAL_TAG_ADD: "MOD_DEAL_TAG_ADD",
+ DEAL_TAG_REMOVE: "MOD_DEAL_TAG_REMOVE",
+ BADGE_CREATE: "MOD_BADGE_CREATE",
+ BADGE_UPDATE: "MOD_BADGE_UPDATE",
+ BADGE_ASSIGN: "MOD_BADGE_ASSIGN",
+ BADGE_REMOVE: "MOD_BADGE_REMOVE",
+ },
+}
+
+module.exports = {
+ AUDIT_ACTIONS,
+}
diff --git a/services/auth.service.js b/services/auth.service.js
index 9259128..1e162c4 100644
--- a/services/auth.service.js
+++ b/services/auth.service.js
@@ -5,6 +5,11 @@ const crypto = require("crypto")
const authDb = require("../db/auth.db")
const refreshTokenDb = require("../db/refreshToken.db")
+const { queueAuditEvent } = require("./redis/dbSync.service")
+const { AUDIT_ACTIONS } = require("./auditActions")
+const { buildAuditMeta } = require("./audit.service")
+
+const REUSE_GRACE_MS = Number(process.env.REFRESH_REUSE_GRACE_MS || 10000)
function httpError(statusCode, message) {
const err = new Error(message)
@@ -12,7 +17,7 @@ function httpError(statusCode, message) {
return err
}
-// Access token: kısa ömür
+// Access token: kisa ömür
function signAccessToken(user) {
const jti = crypto.randomUUID()
const payload = {
@@ -26,7 +31,7 @@ function signAccessToken(user) {
return { token, jti }
}
-// Refresh token: opaque (JWT değil) + DB’de hash
+// Refresh token: opaque (JWT degil) + DB'de hash
function generateRefreshToken() {
// 64 byte -> url-safe base64
return crypto.randomBytes(64).toString("base64url")
@@ -53,10 +58,11 @@ function mapUserPublic(user) {
async function login({ email, password, meta = {} }) {
const user = await authDb.findUserByEmail(email)
- if (!user) throw httpError(400, "Kullanıcı bulunamadı.")
+ if (!user) throw httpError(400, "Kullanici bulunamadi.")
+ if (user.disabledAt) throw httpError(403, "Hesap devre disi.")
const isMatch = await bcrypt.compare(password, user.passwordHash)
- if (!isMatch) throw httpError(401, "Şifre hatalı.")
+ if (!isMatch) throw httpError(401, "Sifre hatali.")
const { token: accessToken } = signAccessToken(user)
@@ -74,6 +80,15 @@ async function login({ email, password, meta = {} }) {
userAgent: meta.userAgent ?? null,
})
+ queueAuditEvent({
+ userId: user.id,
+ action: AUDIT_ACTIONS.AUTH.LOGIN_SUCCESS,
+ ip: meta.ip ?? null,
+ userAgent: meta.userAgent ?? null,
+ meta: buildAuditMeta({ entityType: "USER", entityId: user.id }),
+ createdAt: new Date().toISOString(),
+ }).catch((err) => console.error("Audit queue login failed:", err?.message || err))
+
return {
accessToken,
refreshToken,
@@ -83,7 +98,7 @@ async function login({ email, password, meta = {} }) {
async function register({ username, email, password, meta = {} }) {
const existingUser = await authDb.findUserByEmail(email)
- if (existingUser) throw httpError(400, "Bu e-posta zaten kayıtlı.")
+ if (existingUser) throw httpError(400, "Bu e-posta zaten kayitli.")
const passwordHash = await bcrypt.hash(password, 10)
const user = await authDb.createUser({ username, email, passwordHash })
@@ -104,6 +119,15 @@ async function register({ username, email, password, meta = {} }) {
userAgent: meta.userAgent ?? null,
})
+ queueAuditEvent({
+ userId: user.id,
+ action: AUDIT_ACTIONS.AUTH.REGISTER,
+ ip: meta.ip ?? null,
+ userAgent: meta.userAgent ?? null,
+ meta: buildAuditMeta({ entityType: "USER", entityId: user.id }),
+ createdAt: new Date().toISOString(),
+ }).catch((err) => console.error("Audit queue register failed:", err?.message || err))
+
return {
accessToken,
refreshToken,
@@ -122,19 +146,28 @@ async function refresh({ refreshToken, meta = {} }) {
if (!existing) throw httpError(401, "Refresh token geçersiz")
- // süresi geçmiş
+ // süresi geçmis
if (existing.expiresAt && existing.expiresAt.getTime() < Date.now()) {
await refreshTokenDb.revokeRefreshTokenById(existing.id)
- throw httpError(401, "Refresh token süresi dolmuş")
+ throw httpError(401, "Refresh token süresi dolmus")
}
- // reuse tespiti: revoke edilmiş token tekrar gelirse -> tüm aileyi kapat
+ // reuse tespiti: revoke edilmis token tekrar gelirse -> tüm aileyi kapat
if (existing.revokedAt) {
- await refreshTokenDb.revokeRefreshTokenFamily(existing.familyId)
- throw httpError(401, "Refresh token reuse tespit edildi")
+ const revokedAt = existing.revokedAt instanceof Date ? existing.revokedAt : new Date(existing.revokedAt)
+ const withinGrace =
+ existing.replacedById &&
+ revokedAt &&
+ Date.now() - revokedAt.getTime() <= REUSE_GRACE_MS
+
+ if (!withinGrace) {
+ await refreshTokenDb.revokeRefreshTokenFamily(existing.familyId)
+ throw httpError(401, "Refresh token reuse tespit edildi")
+ }
}
const user = existing.user
+ if (user?.disabledAt) throw httpError(403, "Hesap devre disi.")
const { token: accessToken } = signAccessToken(user)
const newRefreshToken = generateRefreshToken()
@@ -146,13 +179,22 @@ async function refresh({ refreshToken, meta = {} }) {
newToken: {
userId: user.id,
tokenHash: newTokenHash,
- familyId: existing.familyId, // aynı aile
+ familyId: existing.familyId, // ayni aile
jti: newJti,
expiresAt: refreshExpiresAt(),
},
meta: { ip: meta.ip ?? null, userAgent: meta.userAgent ?? null },
})
+ queueAuditEvent({
+ userId: user.id,
+ action: AUDIT_ACTIONS.AUTH.REFRESH,
+ ip: meta.ip ?? null,
+ userAgent: meta.userAgent ?? null,
+ meta: buildAuditMeta({ entityType: "USER", entityId: user.id }),
+ createdAt: new Date().toISOString(),
+ }).catch((err) => console.error("Audit queue refresh failed:", err?.message || err))
+
return {
accessToken,
refreshToken: newRefreshToken,
@@ -160,13 +202,26 @@ async function refresh({ refreshToken, meta = {} }) {
}
}
-async function logout({ refreshToken }) {
+async function logout({ refreshToken, meta = {} }) {
if (!refreshToken) return
const tokenHash = hashToken(refreshToken)
// token yoksa sessiz geçmek genelde daha iyi (idempotent logout)
try {
+ const existing = await refreshTokenDb.findRefreshTokenByHash(tokenHash, {
+ select: { userId: true },
+ })
await refreshTokenDb.revokeRefreshTokenByHash(tokenHash)
+ if (existing?.userId) {
+ queueAuditEvent({
+ userId: existing.userId,
+ action: AUDIT_ACTIONS.AUTH.LOGOUT,
+ ip: meta.ip ?? null,
+ userAgent: meta.userAgent ?? null,
+ meta: buildAuditMeta({ entityType: "USER", entityId: existing.userId }),
+ createdAt: new Date().toISOString(),
+ }).catch((err) => console.error("Audit queue logout failed:", err?.message || err))
+ }
} catch (_) {}
}
@@ -174,7 +229,7 @@ async function getMe(userId) {
const user = await authDb.findUserById(Number(userId), {
select: { id: true, username: true, email: true, avatarUrl: true, role: true },
})
- if (!user) throw httpError(404, "Kullanıcı bulunamadı")
+ if (!user) throw httpError(404, "Kullanici bulunamadi")
return user
}
diff --git a/services/avatar.service.js b/services/avatar.service.js
index 16a4b2a..48fa353 100644
--- a/services/avatar.service.js
+++ b/services/avatar.service.js
@@ -3,6 +3,7 @@ const { uploadImage } = require("./uploadImage.service")
const { validateImage } = require("../utils/validateImage")
const userDB = require("../db/user.db")
+const { setUserPublicInRedis } = require("./redis/userPublicCache.service")
async function updateUserAvatar(userId, file) {
if (!file) {
@@ -25,7 +26,9 @@ async function updateUserAvatar(userId, file) {
fs.unlinkSync(file.path)
- return updateAvatarUrl(userId, imageUrl)
+ const updated = await updateAvatarUrl(userId, imageUrl)
+ await setUserPublicInRedis(updated, { ttlSeconds: 60 * 60 })
+ return updated
}
@@ -38,6 +41,13 @@ async function updateAvatarUrl(userId, imageUrl) {
id: true,
username: true,
avatarUrl: true,
+ userBadges: {
+ orderBy: { earnedAt: "desc" },
+ select: {
+ earnedAt: true,
+ badge: { select: { id: true, name: true, iconUrl: true, description: true } },
+ },
+ },
},
}
)
diff --git a/services/badge.service.js b/services/badge.service.js
new file mode 100644
index 0000000..ea2a639
--- /dev/null
+++ b/services/badge.service.js
@@ -0,0 +1,84 @@
+const badgeDb = require("../db/badge.db")
+const userBadgeDb = require("../db/userBadge.db")
+const userDb = require("../db/user.db")
+
+function assertPositiveInt(value, name) {
+ const num = Number(value)
+ if (!Number.isInteger(num) || num <= 0) throw new Error(`Geçersiz ${name}.`)
+ return num
+}
+
+function normalizeOptionalString(value) {
+ if (value === undefined) return undefined
+ if (value === null) return null
+ const trimmed = String(value).trim()
+ return trimmed ? trimmed : null
+}
+
+async function listBadges() {
+ return badgeDb.listBadges()
+}
+
+async function createBadge({ name, iconUrl, description }) {
+ const normalizedName = String(name || "").trim()
+ if (!normalizedName) throw new Error("Badge adı zorunlu.")
+
+ return badgeDb.createBadge({
+ name: normalizedName,
+ iconUrl: normalizeOptionalString(iconUrl),
+ description: normalizeOptionalString(description),
+ })
+}
+
+async function updateBadge(badgeId, { name, iconUrl, description }) {
+ const id = assertPositiveInt(badgeId, "badgeId")
+ const data = {}
+
+ if (name !== undefined) {
+ const normalizedName = String(name || "").trim()
+ if (!normalizedName) throw new Error("Badge adı zorunlu.")
+ data.name = normalizedName
+ }
+ if (iconUrl !== undefined) data.iconUrl = normalizeOptionalString(iconUrl)
+ if (description !== undefined) data.description = normalizeOptionalString(description)
+
+ if (!Object.keys(data).length) throw new Error("Güncellenecek alan yok.")
+
+ return badgeDb.updateBadge({ id }, data)
+}
+
+async function assignBadgeToUser({ userId, badgeId, earnedAt }) {
+ const uid = assertPositiveInt(userId, "userId")
+ const bid = assertPositiveInt(badgeId, "badgeId")
+ const earnedAtDate = earnedAt ? new Date(earnedAt) : new Date()
+ if (Number.isNaN(earnedAtDate.getTime())) throw new Error("Geçersiz kazanılma tarihi.")
+
+ const [user, badge] = await Promise.all([
+ userDb.findUser({ id: uid }, { select: { id: true } }),
+ badgeDb.findBadge({ id: bid }, { select: { id: true } }),
+ ])
+ if (!user) throw new Error("Kullanıcı bulunamadı.")
+ if (!badge) throw new Error("Badge bulunamadı.")
+
+ return userBadgeDb.createUserBadge({
+ userId: uid,
+ badgeId: bid,
+ earnedAt: earnedAtDate,
+ })
+}
+
+async function removeBadgeFromUser({ userId, badgeId }) {
+ const uid = assertPositiveInt(userId, "userId")
+ const bid = assertPositiveInt(badgeId, "badgeId")
+ return userBadgeDb.deleteUserBadge({
+ userId_badgeId: { userId: uid, badgeId: bid },
+ })
+}
+
+module.exports = {
+ listBadges,
+ createBadge,
+ updateBadge,
+ assignBadgeToUser,
+ removeBadgeFromUser,
+}
diff --git a/services/category.service.js b/services/category.service.js
index 345372c..817c009 100644
--- a/services/category.service.js
+++ b/services/category.service.js
@@ -33,6 +33,7 @@ async function getDealsByCategoryId(categoryId, { page = 1, limit = 10, filters
scope,
baseWhere: { categoryId: { in: categoryIds } },
filters,
+ useRedisSearch: true,
})
}
diff --git a/services/comment.service.js b/services/comment.service.js
index 45a5e99..1f96950 100644
--- a/services/comment.service.js
+++ b/services/comment.service.js
@@ -1,34 +1,18 @@
-const dealDB = require("../db/deal.db")
+const userDB = require("../db/user.db")
const commentDB = require("../db/comment.db")
-const prisma = require("../db/client")
-
-function assertPositiveInt(v, name = "id") {
- const n = Number(v)
- if (!Number.isInteger(n) || n <= 0) throw new Error(`Geçersiz ${name}.`)
- return n
-}
-
-const DEFAULT_LIMIT = 20
-const MAX_LIMIT = 50
-const MAX_SKIP = 5000
-
-function clampPagination({ page, limit }) {
- const rawPage = Number(page)
- const rawLimit = Number(limit)
- const normalizedPage = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1
- let normalizedLimit = Number.isFinite(rawLimit) ? Math.max(1, Math.floor(rawLimit)) : DEFAULT_LIMIT
- normalizedLimit = Math.min(MAX_LIMIT, normalizedLimit)
- const skip = (normalizedPage - 1) * normalizedLimit
- if (skip > MAX_SKIP) throw new Error("PAGE_TOO_DEEP")
- return { page: normalizedPage, limit: normalizedLimit, skip }
-}
+const {
+ addCommentToRedis,
+ removeCommentFromRedis,
+ getCommentsForDeal,
+} = require("./redis/commentCache.service")
+const { getOrCacheDeal, getDealIdByCommentId } = require("./redis/dealCache.service")
+const { generateCommentId } = require("./redis/commentId.service")
+const { queueCommentCreate, queueCommentDelete } = require("./redis/dbSync.service")
function parseParentId(value) {
- if (value === undefined) return null
- if (value === null) return null
- if (value === "" || value === "null") return null
+ if (value === undefined || value === null || value === "" || value === "null") return null
const pid = Number(value)
- if (!Number.isInteger(pid) || pid <= 0) throw new Error("Geçersiz parentId.")
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error("Gecersiz parentId.")
return pid
}
@@ -38,141 +22,164 @@ function normalizeSort(value) {
return "NEW"
}
+async function ensureDealCached(dealId) {
+ return getOrCacheDeal(dealId, { ttlSeconds: 15 * 60 })
+}
async function getCommentsByDealId(dealId, { parentId, page, limit, sort, viewer } = {}) {
const id = Number(dealId)
+ const deal = await ensureDealCached(id)
+ if (!deal) throw new Error("Deal bulunamadi.")
- const deal = await dealDB.findDeal({ id })
- if (!deal) throw new Error("Deal bulunamadı.")
-
- const include = {
- user: { select: { id: true, username: true, avatarUrl: true } },
- _count: { select: { replies: true } },
- }
- const pagination = clampPagination({ page, limit })
- const parsedParentId = parseParentId(parentId)
- const sortMode = normalizeSort(sort)
- const orderBy =
- sortMode === "TOP"
- ? [{ likeCount: "desc" }, { createdAt: "desc" }]
- : [{ createdAt: "desc" }]
-
- const where = { dealId: id, parentId: parsedParentId }
- const [results, total] = await Promise.all([
- commentDB.findComments(where, {
- include,
- orderBy,
- skip: pagination.skip,
- take: pagination.limit,
- }),
- commentDB.countComments(where),
- ])
-
- let likedIds = new Set()
- if (viewer?.userId && results.length > 0) {
- const commentLikeDb = require("../db/commentLike.db")
- const likes = await commentLikeDb.findLikesByUserAndCommentIds(
- viewer.userId,
- results.map((c) => c.id)
- )
- likedIds = new Set(likes.map((l) => l.commentId))
- }
-
- const enriched = results.map((comment) => ({
- ...comment,
- myLike: likedIds.has(comment.id),
- }))
-
- return {
- page: pagination.page,
- total,
- totalPages: Math.ceil(total / pagination.limit),
- results: enriched,
- }
+ return getCommentsForDeal({
+ dealId: id,
+ deal,
+ parentId: parseParentId(parentId),
+ page,
+ limit,
+ sort: normalizeSort(sort),
+ viewerId: viewer?.userId ?? null,
+ })
}
async function createComment({ dealId, userId, text, parentId = null }) {
if (!text || typeof text !== "string" || !text.trim()) {
- throw new Error("Yorum boş olamaz.")
+ throw new Error("Yorum bos olamaz.")
}
- const trimmed = text.trim()
- const include = { user: { select: { id: true, username: true, avatarUrl: true } } }
+ const deal = await ensureDealCached(dealId)
+ if (!deal) throw new Error("Deal bulunamadi.")
+ if (deal.status !== "ACTIVE" && deal.status !== "EXPIRED") {
+ throw new Error("Bu deal icin yorum acilamaz.")
+ }
- return prisma.$transaction(async (tx) => {
- const deal = await dealDB.findDeal({ id: dealId }, { select: { id: true, status: true } }, tx)
- if (!deal) throw new Error("Deal bulunamadı.")
- if (deal.status !== "ACTIVE" && deal.status !== "EXPIRED") {
- throw new Error("Bu deal için yorum açılamaz.")
+ let parent = null
+ if (parentId != null) {
+ const pid = parseParentId(parentId)
+ const comments = Array.isArray(deal.comments) ? deal.comments : []
+ const cachedParent = comments.find((c) => Number(c.id) === Number(pid))
+ if (!cachedParent || cachedParent.deletedAt) throw new Error("Yanıtlanan yorum bulunamadi.")
+ if (Number(cachedParent.dealId) !== Number(dealId)) {
+ throw new Error("Yanıtlanan yorum bu deal'a ait degil.")
}
+ parent = { id: cachedParent.id, dealId: cachedParent.dealId }
+ }
- // ✅ Reply ise parent doğrula
- let parent = null
- if (parentId != null) {
- const pid = Number(parentId)
- if (!Number.isFinite(pid) || pid <= 0) throw new Error("Geçersiz parentId.")
+ const user = await userDB.findUser(
+ { id: userId },
+ { select: { id: true, username: true, avatarUrl: true } }
+ )
+ if (!user) throw new Error("Kullanici bulunamadi.")
- parent = await commentDB.findComment({ id: pid }, { select: { id: true, dealId: true } }, tx)
- if (!parent) throw new Error("Yanıtlanan yorum bulunamadı.")
- if (parent.dealId !== dealId) throw new Error("Yanıtlanan yorum bu deal'a ait değil.")
- }
+ const createdAt = new Date()
+ const commentId = await generateCommentId()
+ const comment = {
+ id: commentId,
+ text: text.trim(),
+ userId,
+ dealId,
+ parentId: parent ? parent.id : null,
+ createdAt,
+ likeCount: 0,
+ repliesCount: 0,
+ user,
+ }
- const comment = await commentDB.createComment(
- {
- text: trimmed,
- userId,
- dealId,
- parentId: parent ? parent.id : null,
- },
- { include },
- tx
- )
-
- await dealDB.updateDeal(
- { id: dealId },
- { commentCount: { increment: 1 } },
- {},
- tx
- )
-
- return comment
+ await addCommentToRedis({
+ ...comment,
+ repliesCount: 0,
})
+
+ queueCommentCreate({
+ commentId,
+ dealId,
+ userId,
+ text: text.trim(),
+ parentId: parent ? parent.id : null,
+ createdAt: createdAt.toISOString(),
+ }).catch((err) => console.error("DB sync comment create failed:", err?.message || err))
+
+ return comment
}
-
async function deleteComment(commentId, userId) {
+ const cid = Number(commentId)
+ if (!Number.isInteger(cid) || cid <= 0) throw new Error("Gecersiz commentId.")
- const comment = await commentDB.findComment(
- { id: commentId },
- { select: { userId: true, dealId: true, deletedAt: true } }
- )
+ let dealId = await getDealIdByCommentId(cid)
+ let dbFallback = null
+ if (!dealId) {
+ dbFallback = await commentDB.findComment(
+ { id: cid },
+ { select: { id: true, dealId: true, userId: true, parentId: true, deletedAt: true } }
+ )
+ if (!dbFallback || dbFallback.deletedAt) throw new Error("Yorum bulunamadi.")
+ dealId = dbFallback.dealId
+ }
- if (!comment || comment.deletedAt) throw new Error("Yorum bulunamadı.")
- if (comment.userId !== userId) throw new Error("Bu yorumu silme yetkin yok.")
+ const deal = await ensureDealCached(dealId)
+ if (!deal) throw new Error("Yorum bulunamadi.")
- await prisma.$transaction(async (tx) => {
- const result = await commentDB.softDeleteComment({ id: commentId, deletedAt: null }, tx)
- if (result.count > 0) {
- await dealDB.updateDeal(
- { id: comment.dealId },
- { commentCount: { decrement: 1 } },
- {},
- tx
- )
- }
+ const comments = Array.isArray(deal.comments) ? deal.comments : []
+ const comment = comments.find((c) => Number(c.id) === cid)
+ const effective = comment || dbFallback
+ if (!effective || effective.deletedAt) throw new Error("Yorum bulunamadi.")
+ if (Number(effective.userId) !== Number(userId)) throw new Error("Bu yorumu silme yetkin yok.")
+
+ queueCommentDelete({
+ commentId: cid,
+ dealId: effective.dealId,
+ createdAt: new Date().toISOString(),
+ }).catch((err) => console.error("DB sync comment delete failed:", err?.message || err))
+
+ await removeCommentFromRedis({
+ commentId: cid,
+ dealId: effective.dealId,
})
return { message: "Yorum silindi." }
}
-async function commentChange(length,dealId){
+async function deleteCommentAsMod(commentId) {
+ const cid = Number(commentId)
+ if (!Number.isInteger(cid) || cid <= 0) throw new Error("Gecersiz commentId.")
+ let dealId = await getDealIdByCommentId(cid)
+ let dbFallback = null
+ if (!dealId) {
+ dbFallback = await commentDB.findComment(
+ { id: cid },
+ { select: { id: true, dealId: true, userId: true, parentId: true, deletedAt: true } }
+ )
+ if (!dbFallback || dbFallback.deletedAt) throw new Error("Yorum bulunamadi.")
+ dealId = dbFallback.dealId
+ }
+ const deal = await ensureDealCached(dealId)
+ if (!deal) throw new Error("Yorum bulunamadi.")
+ const comments = Array.isArray(deal.comments) ? deal.comments : []
+ const comment = comments.find((c) => Number(c.id) === cid)
+ const effective = comment || dbFallback
+ if (!effective || effective.deletedAt) throw new Error("Yorum bulunamadi.")
+
+ queueCommentDelete({
+ commentId: cid,
+ dealId: effective.dealId,
+ createdAt: new Date().toISOString(),
+ }).catch((err) => console.error("DB sync comment delete failed:", err?.message || err))
+
+ await removeCommentFromRedis({
+ commentId: cid,
+ dealId: effective.dealId,
+ })
+
+ return { message: "Yorum silindi." }
}
module.exports = {
getCommentsByDealId,
createComment,
deleteComment,
+ deleteCommentAsMod,
}
diff --git a/services/commentLike.service.js b/services/commentLike.service.js
index 00bc2fd..a91c7bf 100644
--- a/services/commentLike.service.js
+++ b/services/commentLike.service.js
@@ -1,5 +1,7 @@
-const commentLikeDb = require("../db/commentLike.db")
-const commentDb = require("../db/comment.db")
+const { updateCommentLikeInRedisByDeal } = require("./redis/commentCache.service")
+const { queueCommentLikeUpdate } = require("./redis/dbSync.service")
+const { getDealIdByCommentId, getOrCacheDeal } = require("./redis/dealCache.service")
+const commentDB = require("../db/comment.db")
function parseLike(value) {
if (typeof value === "boolean") return value
@@ -14,15 +16,44 @@ function parseLike(value) {
async function setCommentLike({ commentId, userId, like }) {
const cid = Number(commentId)
- if (!Number.isInteger(cid) || cid <= 0) throw new Error("Geçersiz commentId.")
+ if (!Number.isInteger(cid) || cid <= 0) throw new Error("Gecersiz commentId.")
const shouldLike = parseLike(like)
- if (shouldLike === null) throw new Error("Geçersiz like.")
+ if (shouldLike === null) throw new Error("Gecersiz like.")
- // Ensure comment exists (and not deleted)
- const existing = await commentDb.findComment({ id: cid }, { select: { id: true } })
- if (!existing) throw new Error("Yorum bulunamadı.")
+ let dealId = await getDealIdByCommentId(cid)
+ if (!dealId) {
+ const fallback = await commentDB.findComment(
+ { id: cid },
+ { select: { id: true, dealId: true, deletedAt: true } }
+ )
+ if (!fallback || fallback.deletedAt) throw new Error("Yorum bulunamadi.")
+ dealId = fallback.dealId
+ }
- return commentLikeDb.setCommentLike({ commentId: cid, userId, like: shouldLike })
+ const deal = await getOrCacheDeal(dealId, { ttlSeconds: 15 * 60 })
+ if (!deal) throw new Error("Deal bulunamadi.")
+ if (deal.status !== "ACTIVE" && deal.status !== "EXPIRED") {
+ throw new Error("Bu deal icin yorum begenisi acilamaz.")
+ }
+
+ const redisResult = await updateCommentLikeInRedisByDeal({
+ dealId,
+ commentId: cid,
+ userId,
+ like: shouldLike,
+ }).catch((err) => {
+ console.error("Redis comment like update failed:", err?.message || err)
+ return { liked: shouldLike, delta: 0, likeCount: 0 }
+ })
+
+ queueCommentLikeUpdate({
+ commentId: cid,
+ userId,
+ like: shouldLike,
+ createdAt: new Date().toISOString(),
+ }).catch((err) => console.error("DB sync commentLike queue failed:", err?.message || err))
+
+ return redisResult
}
module.exports = {
diff --git a/services/deal.service.js b/services/deal.service.js
index cc35394..18f3c30 100644
--- a/services/deal.service.js
+++ b/services/deal.service.js
@@ -1,12 +1,32 @@
// services/deal.service.js
const dealDB = require("../db/deal.db")
+const userDB = require("../db/user.db")
const { findSellerFromLink } = require("./sellerLookup.service")
const { makeDetailWebp, makeThumbWebp } = require("../utils/processImage")
const { v4: uuidv4 } = require("uuid")
const { uploadImage } = require("./uploadImage.service")
const categoryDB = require("../db/category.db")
-const dealImageDB = require("../db/dealImage.db")
const { enqueueDealClassification } = require("../jobs/dealClassification.queue")
+const { getCommentsForDeal } = require("./redis/commentCache.service")
+const { getOrCacheDeal } = require("./redis/dealCache.service")
+const { mapDealToRedisJson } = require("./redis/dealIndexing.service")
+const { setDealInRedis } = require("./redis/dealCache.service")
+const { queueDealCreate } = require("./redis/dbSync.service")
+const { generateDealId } = require("./redis/dealId.service")
+const { getSellerById } = require("./redis/sellerCache.service")
+const { getDealVoteFromRedis } = require("./redis/dealVote.service")
+const { getHotDealIds, getHotRangeDealIds, getDealsByIdsFromRedis } = require("./redis/hotDealList.service")
+const { getTrendingDealIds } = require("./redis/trendingDealList.service")
+const { getNewDealIds } = require("./redis/newDealList.service")
+const { setUserPublicInRedis } = require("./redis/userPublicCache.service")
+const {
+ buildDealSearchQuery,
+ searchDeals,
+ buildTitlePrefixQuery,
+ buildTextSearchQuery,
+ buildPrefixTextQuery,
+ buildFuzzyTextQuery,
+} = require("./redis/dealSearch.service")
const DEFAULT_LIMIT = 20
const MAX_LIMIT = 50
@@ -20,6 +40,10 @@ const DEAL_CARD_SELECT = {
price: true,
originalPrice: true,
shippingPrice: true,
+ couponCode: true,
+ location: true,
+ discountType: true,
+ discountValue: true,
score: true,
commentCount: true,
url: true,
@@ -46,6 +70,10 @@ const DEAL_DETAIL_SELECT = {
price: true,
originalPrice: true,
shippingPrice: true,
+ couponCode: true,
+ location: true,
+ discountType: true,
+ discountValue: true,
score: true,
commentCount: true,
status: true,
@@ -179,6 +207,20 @@ function parseEnumList(value, allowedSet) {
return filtered.length ? Array.from(new Set(filtered)) : null
}
+function parseSort(value) {
+ const normalized = String(value || "").trim().toLowerCase()
+ if (["score", "price", "createdat", "createdatts"].includes(normalized)) return normalized
+ return "createdAtTs"
+}
+
+function parseSortDir(value, sortBy) {
+ const normalized = String(value || "").trim().toLowerCase()
+ if (normalized === "asc") return "ASC"
+ if (normalized === "desc") return "DESC"
+ if (String(sortBy).toLowerCase() === "price") return "ASC"
+ return "DESC"
+}
+
function buildFilterWhere(rawFilters = {}, { allowStatus = false } = {}) {
if (!rawFilters || typeof rawFilters !== "object") return null
@@ -195,7 +237,21 @@ function buildFilterWhere(rawFilters = {}, { allowStatus = false } = {}) {
const saleTypes = parseEnumList(rawFilters.saleType, SALE_TYPES)
if (saleTypes?.length) {
- clauses.push({ saletype: { in: saleTypes } })
+ const hasCode = saleTypes.includes("CODE")
+ const others = saleTypes.filter((t) => t !== "CODE")
+ if (hasCode) {
+ const orClauses = []
+ orClauses.push({
+ saletype: "CODE",
+ couponCode: { not: null },
+ })
+ if (others.length) {
+ orClauses.push({ saletype: { in: others } })
+ }
+ clauses.push(orClauses.length === 1 ? orClauses[0] : { OR: orClauses })
+ } else {
+ clauses.push({ saletype: { in: saleTypes } })
+ }
}
const affiliateTypes = parseEnumList(rawFilters.affiliateType, AFFILIATE_TYPES)
@@ -248,6 +304,122 @@ function buildFilterWhere(rawFilters = {}, { allowStatus = false } = {}) {
return clauses.length === 1 ? clauses[0] : { AND: clauses }
}
+function buildRedisSearchFilters(rawFilters = {}, baseWhere = null) {
+ const filters = rawFilters || {}
+ const statuses = parseEnumList(filters.status, DEAL_STATUSES)
+
+ const categoryIds = parseIdList(filters.categoryId ?? filters.categoryIds)
+ const sellerIds = parseIdList(filters.sellerId ?? filters.sellerIds)
+ const saleTypes = parseEnumList(filters.saleType, SALE_TYPES)
+
+ const minPrice = parseNumber(filters.minPrice ?? filters.priceMin)
+ const maxPrice = parseNumber(filters.maxPrice ?? filters.priceMax)
+
+ const minScore = parseNumber(filters.minScore)
+ const maxScore = parseNumber(filters.maxScore)
+
+ const merged = {
+ statuses,
+ categoryIds,
+ sellerIds,
+ saleTypes,
+ minPrice,
+ maxPrice,
+ minScore,
+ maxScore,
+ }
+
+ if (baseWhere) {
+ if (baseWhere.status) {
+ merged.statuses = parseEnumList(baseWhere.status, DEAL_STATUSES) || merged.statuses
+ }
+ if (baseWhere.categoryId?.in) {
+ merged.categoryIds = Array.from(new Set([...(merged.categoryIds || []), ...baseWhere.categoryId.in]))
+ } else if (Number.isInteger(baseWhere.categoryId)) {
+ merged.categoryIds = Array.from(new Set([...(merged.categoryIds || []), baseWhere.categoryId]))
+ }
+ if (baseWhere.sellerId?.in) {
+ merged.sellerIds = Array.from(new Set([...(merged.sellerIds || []), ...baseWhere.sellerId.in]))
+ } else if (Number.isInteger(baseWhere.sellerId)) {
+ merged.sellerIds = Array.from(new Set([...(merged.sellerIds || []), baseWhere.sellerId]))
+ }
+ }
+
+ return merged
+}
+
+const REDIS_SEARCH_LIMIT = 20
+
+async function getDealsFromRedisSearch({
+ q,
+ page,
+ viewer,
+ filters,
+ baseWhere,
+} = {}) {
+ const pagination = clampPagination({ page, limit: REDIS_SEARCH_LIMIT })
+ const filterValues = buildRedisSearchFilters(filters, baseWhere)
+ const filterQuery = buildDealSearchQuery(filterValues)
+ const primaryTextQuery = buildPrefixTextQuery(q) ?? buildTextSearchQuery(q)
+ const primaryQuery = [filterQuery, primaryTextQuery].filter(Boolean).join(" ") || "*"
+ const sortBy = parseSort(filters?.sortBy)
+ const sortDir = parseSortDir(filters?.sortDir, sortBy)
+
+ let searchResult = await searchDeals({
+ query: primaryQuery,
+ page: pagination.page,
+ limit: REDIS_SEARCH_LIMIT,
+ sortBy,
+ sortDir,
+ includeMinMax: pagination.page === 1,
+ })
+
+ if (searchResult.total === 0 && q) {
+ const fuzzyTextQuery = buildFuzzyTextQuery(q)
+ if (fuzzyTextQuery) {
+ const fuzzyQuery = [filterQuery, fuzzyTextQuery].filter(Boolean).join(" ") || "*"
+ searchResult = await searchDeals({
+ query: fuzzyQuery,
+ page: pagination.page,
+ limit: REDIS_SEARCH_LIMIT,
+ sortBy,
+ sortDir,
+ includeMinMax: pagination.page === 1,
+ })
+ }
+ }
+
+ if (!searchResult.dealIds.length) {
+ return {
+ page: pagination.page,
+ total: 0,
+ totalPages: 0,
+ results: [],
+ }
+ }
+
+ const viewerId = viewer?.userId ? Number(viewer.userId) : null
+ const deals = await getDealsByIdsFromRedis(searchResult.dealIds, viewerId)
+ const enriched = deals.map((deal) => ({
+ ...deal,
+ id: Number(deal.id),
+ score: Number.isFinite(deal.score) ? deal.score : 0,
+ commentCount: Number.isFinite(deal.commentCount) ? deal.commentCount : 0,
+ price: deal.price ?? null,
+ originalPrice: deal.originalPrice ?? null,
+ shippingPrice: deal.shippingPrice ?? null,
+ discountValue: deal.discountValue ?? null,
+ }))
+
+ return {
+ page: searchResult.page,
+ total: searchResult.total,
+ totalPages: searchResult.totalPages,
+ results: enriched,
+ minPrice: searchResult.minPrice,
+ maxPrice: searchResult.maxPrice,
+ }
+}
function buildPresetCriteria(preset, { viewer, targetUserId } = {}) {
const now = new Date()
switch (preset) {
@@ -421,6 +593,245 @@ async function buildSimilarDealsForDetail(targetDeal, { limit = 12 } = {}) {
}))
}
+async function getHotDealsFromRedis({ page, limit, viewer, hotListId } = {}) {
+ const pagination = clampPagination({ page, limit })
+ const { hotListId: listId, dealIds } = await getHotDealIds({ hotListId })
+
+ if (!dealIds.length) {
+ return {
+ page: pagination.page,
+ total: 0,
+ totalPages: 0,
+ results: [],
+ 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)
+
+ const enriched = deals.map((deal) => ({
+ ...deal,
+ id: Number(deal.id),
+ score: Number.isFinite(deal.score) ? deal.score : 0,
+ commentCount: Number.isFinite(deal.commentCount) ? deal.commentCount : 0,
+ price: deal.price ?? null,
+ originalPrice: deal.originalPrice ?? null,
+ shippingPrice: deal.shippingPrice ?? null,
+ discountValue: deal.discountValue ?? null,
+ }))
+
+ return {
+ page: pagination.page,
+ total: dealIds.length,
+ totalPages: Math.ceil(dealIds.length / pagination.limit),
+ results: enriched,
+ hotListId: listId,
+ }
+}
+
+async function getTrendingDealsFromRedis({ page, limit, viewer, trendingListId } = {}) {
+ const pagination = clampPagination({ page, limit })
+ const { trendingListId: listId, dealIds } = await getTrendingDealIds({ trendingListId })
+
+ if (!dealIds.length) {
+ return {
+ page: pagination.page,
+ total: 0,
+ totalPages: 0,
+ results: [],
+ trendingListId: 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)
+
+ const enriched = deals.map((deal) => ({
+ ...deal,
+ id: Number(deal.id),
+ score: Number.isFinite(deal.score) ? deal.score : 0,
+ commentCount: Number.isFinite(deal.commentCount) ? deal.commentCount : 0,
+ price: deal.price ?? null,
+ originalPrice: deal.originalPrice ?? null,
+ shippingPrice: deal.shippingPrice ?? null,
+ discountValue: deal.discountValue ?? null,
+ }))
+
+ return {
+ page: pagination.page,
+ total: dealIds.length,
+ totalPages: Math.ceil(dealIds.length / pagination.limit),
+ results: enriched,
+ trendingListId: listId,
+ }
+}
+
+async function getHotRangeDealsFromRedis({ page, limit, viewer, range } = {}) {
+ const pagination = clampPagination({ page, limit })
+ const { listId, dealIds } = await getHotRangeDealIds({ range })
+
+ if (!dealIds.length) {
+ return {
+ page: pagination.page,
+ total: 0,
+ totalPages: 0,
+ results: [],
+ 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)
+
+ const enriched = deals.map((deal) => ({
+ ...deal,
+ id: Number(deal.id),
+ score: Number.isFinite(deal.score) ? deal.score : 0,
+ commentCount: Number.isFinite(deal.commentCount) ? deal.commentCount : 0,
+ price: deal.price ?? null,
+ originalPrice: deal.originalPrice ?? null,
+ shippingPrice: deal.shippingPrice ?? null,
+ discountValue: deal.discountValue ?? null,
+ }))
+
+ return {
+ page: pagination.page,
+ total: dealIds.length,
+ totalPages: Math.ceil(dealIds.length / pagination.limit),
+ results: enriched,
+ hotListId: listId,
+ }
+}
+
+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
+
+ const [dayList, weekList, monthList] = await Promise.all([
+ getHotRangeDealIds({ range: "day" }),
+ getHotRangeDealIds({ range: "week" }),
+ getHotRangeDealIds({ range: "month" }),
+ ])
+
+ const pickTop = async (ids = []) => {
+ const pageIds = ids.slice(0, take)
+ const deals = await getDealsByIdsFromRedis(pageIds, viewerId)
+ return deals.map((deal) => ({
+ ...deal,
+ id: Number(deal.id),
+ score: Number.isFinite(deal.score) ? deal.score : 0,
+ commentCount: Number.isFinite(deal.commentCount) ? deal.commentCount : 0,
+ price: deal.price ?? null,
+ originalPrice: deal.originalPrice ?? null,
+ shippingPrice: deal.shippingPrice ?? null,
+ discountValue: deal.discountValue ?? null,
+ }))
+ }
+
+ const [hotDay, hotWeek, hotMonth] = await Promise.all([
+ pickTop(dayList?.dealIds || []),
+ pickTop(weekList?.dealIds || []),
+ pickTop(monthList?.dealIds || []),
+ ])
+
+ return { hotDay, hotWeek, hotMonth }
+}
+
+async function getDealSuggestions({ q, limit = 8, viewer } = {}) {
+ const term = String(q || "").trim()
+ if (!term) return { results: [] }
+
+ const query = buildTitlePrefixQuery(term)
+ if (!query) return { results: [] }
+
+ const normalizedLimit = Math.max(1, Math.min(Number(limit) || 8, 20))
+ const searchResult = await searchDeals({
+ query,
+ page: 1,
+ limit: normalizedLimit,
+ sortBy: "score",
+ sortDir: "DESC",
+ })
+
+ if (!searchResult.dealIds.length) return { results: [] }
+
+ const viewerId = viewer?.userId ? Number(viewer.userId) : null
+ const deals = await getDealsByIdsFromRedis(searchResult.dealIds, viewerId)
+ const enriched = deals.map((deal) => ({
+ ...deal,
+ id: Number(deal.id),
+ score: Number.isFinite(deal.score) ? deal.score : 0,
+ commentCount: Number.isFinite(deal.commentCount) ? deal.commentCount : 0,
+ price: deal.price ?? null,
+ originalPrice: deal.originalPrice ?? null,
+ shippingPrice: deal.shippingPrice ?? null,
+ discountValue: deal.discountValue ?? null,
+ }))
+
+ return { results: enriched }
+}
+
+function hasSearchFilters(filters = {}) {
+ if (!filters || typeof filters !== "object") return false
+ const keys = [
+ "status",
+ "categoryId",
+ "categoryIds",
+ "sellerId",
+ "sellerIds",
+ "saleType",
+ "minPrice",
+ "maxPrice",
+ "priceMin",
+ "priceMax",
+ "minScore",
+ "maxScore",
+ "sortBy",
+ "sortDir",
+ ]
+ return keys.some((key) => filters[key] !== undefined && filters[key] !== null && String(filters[key]) !== "")
+}
+
+async function getNewDealsFromRedis({ page, viewer, newListId } = {}) {
+ const pagination = clampPagination({ page, limit: REDIS_SEARCH_LIMIT })
+ const { newListId: listId, dealIds } = await getNewDealIds({ newListId })
+
+ if (!dealIds.length) {
+ return {
+ page: pagination.page,
+ total: 0,
+ totalPages: 0,
+ results: [],
+ }
+ }
+
+ const pageIds = dealIds.slice(pagination.skip, pagination.skip + pagination.limit)
+ const viewerId = viewer?.userId ? Number(viewer.userId) : null
+ const deals = await getDealsByIdsFromRedis(pageIds, viewerId)
+
+ const enriched = deals.map((deal) => ({
+ ...deal,
+ id: Number(deal.id),
+ score: Number.isFinite(deal.score) ? deal.score : 0,
+ commentCount: Number.isFinite(deal.commentCount) ? deal.commentCount : 0,
+ price: deal.price ?? null,
+ originalPrice: deal.originalPrice ?? null,
+ shippingPrice: deal.shippingPrice ?? null,
+ discountValue: deal.discountValue ?? null,
+ }))
+
+ return {
+ page: pagination.page,
+ total: dealIds.length,
+ totalPages: Math.ceil(dealIds.length / pagination.limit),
+ results: enriched,
+ }
+}
+
async function getDeals({
preset = "NEW",
q,
@@ -431,8 +842,45 @@ async function getDeals({
filters = null,
baseWhere = null,
scope = "USER",
+ hotListId = null,
+ trendingListId = null,
+ useRedisSearch = false,
}) {
const pagination = clampPagination({ page, limit })
+ if (preset === "HOT") {
+ const listId = hotListId ?? filters?.hotListId ?? filters?.hotlistId ?? null
+ return getHotDealsFromRedis({ page, limit, viewer, hotListId: listId })
+ }
+ if (preset === "HOT_DAY") {
+ return getHotRangeDealsFromRedis({ page, limit, viewer, range: "day" })
+ }
+ if (preset === "HOT_WEEK") {
+ return getHotRangeDealsFromRedis({ page, limit, viewer, range: "week" })
+ }
+ if (preset === "HOT_MONTH") {
+ return getHotRangeDealsFromRedis({ page, limit, viewer, range: "month" })
+ }
+ if (preset === "TRENDING") {
+ const listId = trendingListId ?? filters?.trendingListId ?? filters?.trendinglistId ?? null
+ return getTrendingDealsFromRedis({ page, limit, viewer, trendingListId: listId })
+ }
+
+ if (preset === "NEW" && !q && !hasSearchFilters(filters) && !useRedisSearch) {
+ const listId = filters?.newListId ?? filters?.newlistId ?? null
+ return getNewDealsFromRedis({ page, viewer, newListId: listId })
+ }
+
+ if (useRedisSearch) {
+ return getDealsFromRedisSearch({
+ q,
+ page,
+ limit,
+ viewer,
+ filters,
+ baseWhere,
+ })
+ }
+
const { where: presetWhere, orderBy: presetOrder } = buildPresetCriteria(preset, {
viewer,
targetUserId,
@@ -461,19 +909,13 @@ async function getDeals({
])
const dealIds = deals.map((d) => d.id)
- const voteByDealId = new Map()
-
- if (viewer?.userId && dealIds.length > 0) {
- const votes = await dealDB.findVotes(
- { userId: viewer.userId, dealId: { in: dealIds } },
- { select: { dealId: true, voteType: true } }
- )
- votes.forEach((vote) => voteByDealId.set(vote.dealId, vote.voteType))
- }
+ const viewerId = viewer?.userId ? Number(viewer.userId) : null
const enriched = deals.map((deal) => ({
...deal,
- myVote: voteByDealId.get(deal.id) ?? 0,
+ myVote: viewerId
+ ? Number(deal.votes?.find((vote) => Number(vote.userId) === viewerId)?.voteType ?? 0)
+ : 0,
}))
return {
@@ -485,16 +927,19 @@ async function getDeals({
}
async function getDealById(id, viewer = null) {
- const deal = await dealDB.findDeal(
- { id: Number(id) },
- {
- select: DEAL_DETAIL_SELECT,
- }
- )
+ const deal = await getOrCacheDeal(Number(id), { ttlSeconds: 15 * 60 })
if (!deal) return null
- const [breadcrumb, similarDeals, userStatsAgg] = await Promise.all([
+ const dealUserId = Number(deal.userId ?? deal.user?.id)
+
+ if (deal.status === "PENDING" || deal.status === "REJECTED") {
+ const isOwner = viewer?.userId && dealUserId === Number(viewer.userId)
+ const isMod = viewer?.role === "MOD" || viewer?.role === "ADMIN"
+ if (!isOwner && !isMod) return null
+ }
+
+ const [breadcrumb, similarDeals, userStatsAgg, myVote, commentsResp, seller] = await Promise.all([
categoryDB.getCategoryBreadcrumb(deal.categoryId, { includeUndefined: false }),
buildSimilarDealsForDetail(
{
@@ -505,7 +950,12 @@ async function getDealById(id, viewer = null) {
},
{ limit: 12 }
),
- deal.user?.id ? dealDB.aggregateDeals({ userId: deal.user.id }) : Promise.resolve(null),
+ dealUserId
+ ? dealDB.aggregateDeals({ userId: dealUserId, status: { in: ["ACTIVE", "EXPIRED"] } })
+ : Promise.resolve(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),
])
const userStats = {
@@ -515,30 +965,68 @@ async function getDealById(id, viewer = null) {
return {
...deal,
- comments: [],
+ seller: deal.seller ?? seller ?? null,
+ comments: commentsResp?.results || [],
breadcrumb,
similarDeals,
userStats,
+ myVote,
+ isSaved: viewer?.userId
+ ? Array.isArray(deal.savedBy) &&
+ deal.savedBy.some((s) => Number(s?.userId) === Number(viewer.userId))
+ : false,
}
}
async function createDeal(dealCreateData, files = []) {
+ const dealId = await generateDealId()
+ const now = new Date()
+
+ let sellerId = null
if (dealCreateData.url) {
const seller = await findSellerFromLink(dealCreateData.url)
if (seller) {
- dealCreateData.seller = { connect: { id: seller.id } }
+ sellerId = seller.id
dealCreateData.customSeller = null
}
}
- const deal = await dealDB.createDeal(dealCreateData)
+ const userId = Number(dealCreateData?.user?.connect?.id)
+ if (!Number.isInteger(userId) || userId <= 0) {
+ const err = new Error("INVALID_USER")
+ err.statusCode = 400
+ throw err
+ }
- const rows = []
+ const user = await userDB.findUser(
+ { id: userId },
+ {
+ select: {
+ id: true,
+ username: true,
+ avatarUrl: true,
+ userBadges: {
+ orderBy: { earnedAt: "desc" },
+ select: {
+ earnedAt: true,
+ badge: { select: { id: true, name: true, iconUrl: true, description: true } },
+ },
+ },
+ },
+ }
+ )
+ if (!user) {
+ const err = new Error("USER_NOT_FOUND")
+ err.statusCode = 404
+ throw err
+ }
+
+ const images = []
for (let i = 0; i < files.length && i < 5; i++) {
const file = files[i]
const order = i
const key = uuidv4()
- const basePath = `deals/${deal.id}/${key}`
+ const basePath = `deals/${dealId}/${key}`
const detailPath = `${basePath}_detail.webp`
const thumbPath = `${basePath}_thumb.webp`
const BUCKET = "deal"
@@ -561,20 +1049,127 @@ async function createDeal(dealCreateData, files = []) {
})
}
- rows.push({ dealId: deal.id, order, imageUrl: detailUrl })
+ images.push({ id: 0, order, imageUrl: detailUrl })
}
- if (rows.length > 0) {
- await dealImageDB.createManyDealImages(rows)
+ const dealPayload = {
+ id: dealId,
+ title: dealCreateData.title,
+ description: dealCreateData.description ?? null,
+ url: dealCreateData.url ?? null,
+ price: dealCreateData.price ?? null,
+ originalPrice: dealCreateData.originalPrice ?? null,
+ shippingPrice: dealCreateData.shippingPrice ?? null,
+ percentOff: dealCreateData.percentOff ?? null,
+ couponCode: dealCreateData.couponCode ?? null,
+ location: dealCreateData.location ?? null,
+ discountType: dealCreateData.discountType ?? null,
+ discountValue: dealCreateData.discountValue ?? null,
+ maxNotifiedMilestone: 0,
+ userId,
+ score: 0,
+ commentCount: 0,
+ status: "PENDING",
+ saletype: dealCreateData.saletype ?? "ONLINE",
+ affiliateType: dealCreateData.affiliateType ?? "NON_AFFILIATE",
+ sellerId: sellerId ?? null,
+ customSeller: dealCreateData.customSeller ?? null,
+ categoryId: dealCreateData.categoryId ?? 0,
+ createdAt: now,
+ updatedAt: now,
+ user,
+ images,
+ dealTags: [],
+ votes: [],
+ comments: [],
+ aiReview: null,
}
- await enqueueDealClassification({ dealId: deal.id })
+ const redisPayload = mapDealToRedisJson(dealPayload)
+ await setUserPublicInRedis(user, { ttlSeconds: 31 * 24 * 60 * 60 })
+ await setDealInRedis(dealId, redisPayload, {
+ ttlSeconds: 31 * 24 * 60 * 60,
+ skipAnalyticsInit: true,
+ })
- return getDealById(deal.id)
+ queueDealCreate({
+ dealId,
+ data: {
+ id: dealId,
+ title: dealPayload.title,
+ description: dealPayload.description,
+ url: dealPayload.url,
+ price: dealPayload.price,
+ originalPrice: dealPayload.originalPrice,
+ shippingPrice: dealPayload.shippingPrice,
+ percentOff: dealPayload.percentOff,
+ couponCode: dealPayload.couponCode,
+ location: dealPayload.location,
+ discountType: dealPayload.discountType,
+ discountValue: dealPayload.discountValue,
+ maxNotifiedMilestone: dealPayload.maxNotifiedMilestone,
+ userId,
+ status: dealPayload.status,
+ saletype: dealPayload.saletype,
+ affiliateType: dealPayload.affiliateType,
+ sellerId: dealPayload.sellerId,
+ customSeller: dealPayload.customSeller,
+ categoryId: dealPayload.categoryId,
+ createdAt: now.toISOString(),
+ updatedAt: now.toISOString(),
+ },
+ images: images.map((img) => ({ imageUrl: img.imageUrl, order: img.order })),
+ createdAt: now.toISOString(),
+ }).catch((err) => console.error("DB sync deal create failed:", err?.message || err))
+
+ await enqueueDealClassification({ dealId })
+
+ const seller = dealPayload.sellerId ? await getSellerById(dealPayload.sellerId) : null
+ const responseDeal = {
+ ...dealPayload,
+ seller: seller ?? null,
+ images,
+ comments: [],
+ notices: [],
+ breadcrumb: [],
+ similarDeals: [],
+ userStats: { totalLikes: 0, totalDeals: 0 },
+ myVote: 0,
+ _count: { comments: 0 },
+ }
+
+ return responseDeal
+}
+
+async function getDealEngagement(ids = [], viewer = null) {
+ const normalized = (Array.isArray(ids) ? ids : [])
+ .map((id) => Number(id))
+ .filter((id) => Number.isInteger(id) && id > 0)
+
+ const uniqueIds = Array.from(new Set(normalized))
+
+ if (!viewer?.userId || uniqueIds.length === 0) {
+ return normalized.map((id) => ({ id, myVote: 0 }))
+ }
+
+ const votes = await dealDB.findVotes(
+ { userId: viewer.userId, dealId: { in: uniqueIds } },
+ { select: { dealId: true, voteType: true } }
+ )
+ const voteByDealId = new Map()
+ votes.forEach((vote) => voteByDealId.set(vote.dealId, vote.voteType))
+
+ return normalized.map((id) => ({
+ id,
+ myVote: voteByDealId.get(id) ?? 0,
+ }))
}
module.exports = {
getDeals,
getDealById,
createDeal,
+ getDealEngagement,
+ getDealSuggestions,
+ getBestWidgetDeals,
}
diff --git a/services/dealClassification.service.js b/services/dealClassification.service.js
index ea97fea..764e0ec 100644
--- a/services/dealClassification.service.js
+++ b/services/dealClassification.service.js
@@ -3,42 +3,47 @@ const OpenAI = require("openai")
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const SYSTEM_PROMPT = `
-Classify the deal into exactly ONE category_id and optionally suggest up to 5 tags.
+You are a specialized AI Moderator and Categorizer for a Turkish Deal Platform. Your goal is to analyze deal submissions for security issues and classify them accurately.
-Tags are NOT keyword repeats. Tags must represent INTENT/AUDIENCE/USE-CASE.
+### 1. CRITICAL SECURITY: TURKISH MASKED PROFANITY DETECTION
+- You must detect "hidden", "masked", or "merged" Turkish profanity.
+- SCAN FOR STUCK WORDS: Check if slurs are merged with normal words (e.g., "ucuzamk", "güvenleyramıyiyip", "fırsatpiç").
+- DECOMPOSE STRINGS: Carefully analyze long strings of characters to see if a slur is buried inside.
+- KEYWORDS (Check inside words): "yram", "yyrk", "amk", "skm", "oç", "piç", "göt", "daly", "sikt", "yarrak", "pipi", "meme".
+- PHONETIC CHECK: If a word sounds like a Turkish slur when read phonetically, it IS an issue.
+- If ANY issue is found:
+ - Set has_issue = true
+ - Set issue_type = PROFANITY
+ - Set issue_reason = Contains hidden/merged Turkish profanity
+ - Set needs_review = true
-- Prefer audience or use-case tags such as: okul, ofis, is, gaming, kamp, mutfak, temizlik, araba, bahce, bebek, evcil-hayvan, fitness.
-- Do NOT output literal product words.
-- You MAY infer relevant intent/audience tags even if not explicitly written, as long as the inference is strong and widely accepted.
-- Avoid weak guesses: if the intent/audience is not clear, set needs_review=true and tags can be [].
+### 2. CLASSIFICATION & TAGGING
+- CATEGORY: Choose exactly ONE category_id that best fits the product.
-Forbidden:
-- store/company/seller names
-- promotion/marketing words
-- generic category words
+### TAGGING STRATEGY (BRAND, MODEL & LIFESTYLE):
+- Goal: Create a precise user interest profile using 3 distinct tags.
+- Use NATURAL capitalization and spaces.
-Max 5 tags total, lowercase.
-Review / safety:
-- Set needs_review=true if you are not confident about the chosen category OR if the deal text looks problematic.
-- If unclear/unrelated, use best_category_id=0 and needs_review=true.
-- Set has_issue=true if the text contains profanity, harassment, hate, explicit sexual content, doxxing/personal data, scams/phishing, or clear spam.
-- If has_issue=true, briefly explain in issue_reason (short, generic, no quotes).
+1. **Brand (Who?):** The manufacturer (e.g., "Apple", "HIQ Nutrition", "Lego").
+2. **Model (What?):** Specific series/model, MAX 2-3 words (e.g., "Creatine Monohydrate", "iPhone 15 Pro", "Star Wars").
+3. **Lifestyle/Interest (Vibe?):** The root interest that connects different categories (e.g., "spor", "teknoloji", "oyun", "hobi", "moda", "luks", "ev-yasam").
-Output JSON only:
-{
- "best_category_id": number,
- "needs_review": boolean,
- "tags": string[],
- "has_issue": boolean,
- "issue_type": "NONE" | "PROFANITY" | "PHONE_NUMBER" | "PERSONAL_DATA" | "SPAM" | "OTHER",
- "issue_reason": string | null
-}
+### RULES:
+- MAX 3 tags.
+- DO NOT include technical specs like "600g", "128GB", "siyah", "44mm".
+- DO NOT use the exact category name (e.g., if category is "Sporcu Besini", don't use "sporcu-besini", use "spor").
+- If no brand/model found, provide only the Lifestyle tag.
+
+### EXAMPLE OUTPUTS:
+- "HIQ NUTRITION HIQ Creatine %100 Monohydrate XL 600g" -> ["HIQ Nutrition", "Creatine Monohydrate", "spor"]
+- "Apple iPhone 15 Pro 128GB" -> ["Apple", "iPhone 15 Pro", "teknoloji"]
+- "Lego Star Wars Millennium Falcon Seti" -> ["Lego", "Star Wars", "hobi"]
+- "Versace Erkek Kol Saati" -> ["Versace", "VRSCVE", "luks"]
+- "Nike Air Max Ayakkabı" -> ["Nike", "Air Max", "spor"].
`
const TAXONOMY_LINE =
- `TAXONOMY:0 undefined;1 electronics;2 beauty;3 food;4 auto;5 home-garden;6 computers;7 pc-components;8 pc-ram;9 pc-ssd;10 pc-cpu;11 pc-gpu;12 pc-peripherals;13 pc-keyboard;14 pc-mouse;15 pc-monitor;16 beauty-makeup;17 beauty-lipstick;18 beauty-foundation;19 beauty-mascara;20 beauty-skincare;21 beauty-moisturizer;22 food-snacks;23 food-cigkofte;24 food-beverages;25 food-coffee;26 auto-oils;27 auto-engine-oil;28 auto-parts;29 auto-brake-pads;30 home-garden-garden;31 garden-irrigation;32 phone;33 phone-smartphone;34 phone-case;35 phone-screen-protector;36 phone-charging;37 phone-powerbank;38 wearables;39 wearables-smartwatch;40 wearables-band;41 audio;42 audio-headphones;43 audio-tws;44 audio-bt-speaker;45 audio-soundbar;46 audio-microphone;47 audio-turntable;48 tv-video;49 tv;50 projector;51 tv-media-player;52 tv-accessories;53 tv-receiver;54 console;55 console-playstation;56 console-xbox;57 console-nintendo;58 console-games;59 console-accessories;60 camera;61 camera-photo;62 camera-action;63 camera-lens;64 camera-tripod;65 smart-home;66 smart-security-camera;67 smart-plug;68 smart-bulb;69 smart-sensor;70 pc-networking;71 pc-router;72 pc-modem;73 pc-switch;74 pc-wifi-extender;75 pc-printing;76 pc-printer;77 pc-ink-toner;78 pc-scanner;79 pc-laptop;80 pc-desktop;81 pc-tablet;82 pc-storage;83 pc-external-drive;84 pc-usb-drive;85 pc-nas;86 pc-webcam;87 pc-speaker;88 pc-mic;89 pc-mousepad;90 pc-dock-hub;91 pc-laptop-bag;92 pc-controller;93 pc-motherboard;94 pc-psu;95 pc-case;96 pc-cooling;97 pc-fan;98 pc-liquid-cooling;99 beauty-fragrance;100 beauty-fragrance-women;101 beauty-fragrance-men;102 beauty-haircare;103 beauty-shampoo;104 beauty-conditioner;105 beauty-hair-styling;106 beauty-personal-care;107 beauty-deodorant;108 beauty-shaving;109 beauty-hair-removal;110 beauty-skincare-serum;111 beauty-sunscreen;112 beauty-cleanser;113 beauty-mask;114 beauty-toner;115 food-staples;116 food-pasta;117 food-legumes;118 food-oil-vinegar;119 food-breakfast;120 food-cheese;121 food-olive;122 food-jam-honey;123 food-soda;124 food-water;125 food-energy;126 food-tea;127 food-frozen;128 food-meat;129 food-dessert;130 auto-accessories;131 auto-in-car-electronics;132 auto-care;133 auto-cleaning;134 auto-tires;135 auto-battery;136 auto-lighting;137 auto-audio;138 home-furniture;139 home-dining-table;140 home-chair;141 home-sofa;142 home-bed;143 home-textile;144 home-bedding;145 home-blanket;146 home-curtain;147 home-kitchen;148 home-cookware;149 home-small-appliances;150 home-coffee-machine;151 home-blender;152 home-airfryer;153 home-vacuum;154 home-lighting;155 home-decor;156 home-rug;157 home-wall-decor;158 home-cleaning;159 home-detergent;160 home-paper-products;161 home-tools;162 home-drill;163 home-saw;164 home-hardware;165 pet;166 pet-cat-food;167 pet-dog-food;168 pet-cat-litter;169 office;170 office-paper-notebook;171 office-a4-paper;172 office-pen;173 office-school-bag;174 baby;175 baby-diaper;176 baby-wipes;177 baby-food;178 baby-toys;179 sports;180 sports-camping;181 sports-fitness;182 sports-bicycle;183 fashion;184 fashion-shoes;185 fashion-men;186 fashion-women;187 fashion-bags;188 books-media;189 books;190 digital-games`
-
-const CATEGORY_ENUM = [...Array(191).keys()] // 0..31
+ "100:Elektronik|101:Tel_Aksesuar|102:Akilli_Telefon|103:Tel_Kilif|104:Sarj_Kablo|105:Powerbank|106:Ekran_Koruyucu|107:Giyilebilir_Tekno|108:Akilli_Saat|109:Fitness_Bileklik|110:Bilgisayar_Laptop|111:Laptop|112:Masaustu_PC|113:Tablet|114:PC_Bilesen|115:Islemci_CPU|116:Ekran_Karti_GPU|117:RAM_Bellek|118:Dahili_Depolama_SSD|119:Anakart|120:Guc_Kaynagi_PSU|121:PC_Kasasi|122:Sogutma_Sistemi|123:PC_Cevre_Birim|124:Monitor|125:Klavye|126:Fare_Mousepad|127:Webcam|128:PC_Hoparlor|129:PC_Mikrofon|130:USB_Hub_Dock|131:Laptop_Cantasi|132:Ag_Urunleri_Modem|133:Modem_Router|134:Wifi_Genisletici|135:Ag_Switch|136:Yazici_Tarayici|137:Yazici|138:Toner_Kartus|139:Tarayici|140:Harici_Depolama|141:Harici_Disk|142:USB_Bellek|143:NAS_Cihazi|144:Hafiza_Karti|145:Ses_Goruntu|146:Kulaklik|147:TWS_Kulaklik|148:Hoparlor_Sistemleri|149:Bluetooth_Hoparlor|150:Soundbar|151:Televizyon|152:Projeksiyon|153:Medya_Oynatici|154:TV_Aksesuar|155:Pikap_Plak|156:Kamera_Foto|157:Fotograf_Makinesi|158:Aksiyon_Kamerasi|159:Kamera_Lens|160:Drone|161:Tripod_Stabilizer|162:Kamera_Aksesuar|163:Akilli_Ev|164:Akilli_Aydinlatma|165:Akilli_Priz|166:Akilli_Guvenlik_Kam|167:Akilli_Sensor|168:Akilli_Termostat|169:Sesli_Asistan|200:Ev_Yasam|201:Mobilya|202:Oturma_Odasi|203:Yatak_Odasi|204:Yemek_Odasi|205:Calisma_Odasi|206:Depolama_Duzenleme|207:Ev_Dekorasyon|208:Hali_Kilim|209:Duvar_Dekor|210:Dekoratif_Obje|211:Mum_Oda_Kokusu|212:Aydinlatma_Genel|213:Avize_Sarkit|214:Masa_Lambasi|215:Lambader|216:LED_Aydinlatma|217:Mutfak_Yemek|218:Tencere_Tava|219:Yemek_Takimi|220:Catal_Bicak|221:Bardak_Kadeh|222:Mutfak_Gerecleri|223:Saklama_Kabi_Termos|224:Kucuk_Ev_Aletleri|225:Kahve_Makinesi|226:Blender_Robot|227:Airfryer|228:Tost_Mak_Fritoz|229:Beyaz_Esya|230:Buzdolabi|231:Camasir_Kurutma|232:Bulasik_Makinesi|233:Firin_Ocak|234:Ev_Tekstili|235:Nevresim_Takimi|236:Yorgan_Battaniye|237:Perde_Jaluzi|238:Havlu_Seti|239:Yastik_Minder|240:Temizlik_Bakim|241:Supurge_Robot|242:Temizlik_Malzeme|243:Deterjan_Yumusatici|244:Utu_Masasi|245:Bahce_Dis_Mekan|246:Bahce_Mobilyasi|247:Mangal_Barbeku|248:Bahce_Aletleri|249:Sulama_Sistemi|250:Bitki_Bakim_Tohum|251:Kendin_Yap_DIY|252:Elektronik_El_Aletleri|253:El_Aletleri|254:Hirdavat_Baglanti|255:Is_Guvenligi|300:Giyim_Moda|301:Kadin_Giyim|302:Elbise|303:Kadin_Ust_Giyim|304:Kadin_Alt_Giyim|305:Kadin_Dis_Giyim|306:Kadin_Ic_Giyim|307:Kadin_Spor_Giyim|308:Kadin_Mayo_Bikini|309:Erkek_Giyim|310:Erkek_Ust_Giyim|311:Erkek_Alt_Giyim|312:Erkek_Dis_Giyim|313:Erkek_Ic_Giyim|314:Erkek_Spor_Giyim|315:Erkek_Mayo_Sort|316:Ayakkabi|317:Kadin_Ayakkabi|318:Erkek_Ayakkabi|319:Cocuk_Ayakkabi|320:Canta_Bavul|321:El_Cantasi|322:Sirt_Cantasi|323:Cuzdan|324:Valiz_Bavul|325:Aksesuar_Moda|326:Taki_Mucevher|327:Saat|328:Kemer|329:Sapka_Bere|330:Gunes_Gozlugu|331:Esarp_Sal|332:Eldiven|400:Guzellik_Kisisel_Bakim|401:Makyaj|406:Cilt_Bakimi|414:Sac_Bakimi|419:Sac_Sekillendirme_Cihaz|420:Parfum_Deodorant|425:Kisisel_Hijyen|430:Erkek_Bakim_Grooming|500:Gida_Market|501:Temel_Gida|509:Taze_Urunler|516:Atistirmalik_Sekerleme|522:Icecekler|529:Organik_Ozel_Beslenme|530:Dondurulmus_Gida|531:Bebek_Cocuk_Gida|600:Oyun|601:Oyun_Konsolu|602:PS_Konsol|603:Xbox_Konsol|604:Nintendo_Konsol|605:Retro_Konsol|606:Video_Oyunlari|611:Dijital_Oyun_Abonelik|612:Oyun_Aksesuar|616:VR_Cihaz|618:Oyuncu_Koltuk_Masa|700:Otomotiv|701:Oto_Yedek_Parca|707:Oto_Yag_Sivi|710:Lastik_Jant|713:Oto_Bakim_Temizlik|717:Oto_Aksesuar|719:Oto_Ses_Sistemi|723:Motosiklet_Scooter|800:Spor_Outdoor|801:Fitness_Kardiyo|806:Bisiklet|808:Bisiklet_Aksesuar|810:Kamp_Outdoor|817:Su_Sporlari|821:Takim_Sporlari|900:Bebek_Cocuk|901:Bebek_Bakimi|902:Bebek_Bezi|907:Bebek_Beslenme|911:Mama_Sandalyeleri|913:Bebek_Arac_Gerec|914:Bebek_Arabasi|915:Oto_Koltugu|919:Oyuncak|921:Egitici_Oyuncak|922:Kutu_Oyunlari|923:Yapboz_Puzzle|925:Cocuk_Giyim_Ayakkabi|930:Bebek_Odasi_Mobilya|1000:Kitap_Medya|1001:Kitaplar|1009:Film_Dizi|1012:Muzik_Enstruman|1016:Dergi_Gazete|1017:Cizgi_Roman_Manga|1100:Ofis_Kirtasiye|1103:Defter_Ajanda|1108:Okul_Cantasi_Malzeme|1109:Sanat_Hobi_Malzeme|1200:Hizmetler_Seyahat|1201:Internet_Iletisim|1206:Seyahat_Otel_Ucak|1213:Deneyim_Etkinlik|1214:Restoran_Yemek|1215:Egitim_Kurslar|1216:Ev_Hizmetleri|1300:Saglik_Wellness|1301:Vitamin_Takviye|1307:Sporcu_Besini|1311:Medikal_Malzeme|1316:Goz_Sagligi|1320:Zayiflama_Diyet|1400:Evcil_Hayvan|1401:Kedi_Urunleri|1407:Kopek_Urunleri|1414:Kus_Balik_Kucuk_Pet|1500:Hediye_Kupon|1600:Finans_Sigorta"
function s(x) {
return x == null ? "" : String(x)
@@ -91,7 +96,7 @@ async function classifyDeal({ title, description, url, seller }) {
"issue_reason",
],
properties: {
- best_category_id: { type: "integer", enum: CATEGORY_ENUM },
+ best_category_id: { type: "integer" },
needs_review: { type: "boolean" },
tags: { type: "array", items: { type: "string" }, maxItems: 5 },
has_issue: { type: "boolean" },
diff --git a/services/dealReport.service.js b/services/dealReport.service.js
new file mode 100644
index 0000000..c901397
--- /dev/null
+++ b/services/dealReport.service.js
@@ -0,0 +1,118 @@
+const dealDB = require("../db/deal.db")
+const dealReportDB = require("../db/dealReport.db")
+const { queueDealReportStatusUpdate } = require("./redis/dbSync.service")
+
+const PAGE_LIMIT = 20
+const ALLOWED_REASONS = new Set([
+ "EXPIRED",
+ "WRONG_PRICE",
+ "MISLEADING",
+ "SPAM",
+ "OTHER",
+])
+const ALLOWED_STATUSES = new Set(["OPEN", "REVIEWED", "CLOSED"])
+
+function assertPositiveInt(value, name) {
+ const num = Number(value)
+ if (!Number.isInteger(num) || num <= 0) throw new Error(`Gecersiz ${name}.`)
+ return num
+}
+
+function normalizePage(value) {
+ const num = Number(value)
+ if (!Number.isInteger(num) || num < 1) return 1
+ return num
+}
+
+function normalizeReason(value) {
+ const normalized = String(value || "").trim().toUpperCase()
+ return ALLOWED_REASONS.has(normalized) ? normalized : null
+}
+
+function normalizeStatus(value) {
+ if (!value) return null
+ const normalized = String(value || "").trim().toUpperCase()
+ return ALLOWED_STATUSES.has(normalized) ? normalized : null
+}
+
+async function createDealReport({ dealId, userId, reason, note }) {
+ const did = assertPositiveInt(dealId, "dealId")
+ const uid = assertPositiveInt(userId, "userId")
+ const normalizedReason = normalizeReason(reason)
+ if (!normalizedReason) {
+ const err = new Error("Gecersiz reason.")
+ err.statusCode = 400
+ throw err
+ }
+
+ const deal = await dealDB.findDeal({ id: did }, { select: { id: true } })
+ if (!deal) {
+ const err = new Error("Deal bulunamadi.")
+ err.statusCode = 404
+ throw err
+ }
+
+ await dealReportDB.upsertDealReport({
+ dealId: did,
+ userId: uid,
+ reason: normalizedReason,
+ note: note ? String(note).trim().slice(0, 500) : null,
+ })
+
+ return { reported: true }
+}
+
+async function listDealReports({ page = 1, status = null, dealId = null, userId = null } = {}) {
+ const safePage = normalizePage(page)
+ const skip = (safePage - 1) * PAGE_LIMIT
+
+ const where = {}
+ const normalizedStatus = normalizeStatus(status)
+ if (normalizedStatus) where.status = normalizedStatus
+ 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,
+ take: PAGE_LIMIT,
+ orderBy: { createdAt: "asc" },
+ include: {
+ deal: { select: { id: true, title: true, status: true } },
+ user: { select: { id: true, username: true } },
+ },
+ }),
+ ])
+
+ return {
+ page: safePage,
+ total,
+ totalPages: total ? Math.ceil(total / PAGE_LIMIT) : 0,
+ results: reports,
+ }
+}
+
+async function updateDealReportStatus({ reportId, status }) {
+ const rid = assertPositiveInt(reportId, "reportId")
+ const normalizedStatus = normalizeStatus(status)
+ if (!normalizedStatus) {
+ const err = new Error("Gecersiz status.")
+ err.statusCode = 400
+ throw err
+ }
+
+ queueDealReportStatusUpdate({
+ reportId: rid,
+ status: normalizedStatus,
+ updatedAt: new Date().toISOString(),
+ }).catch((err) => console.error("DB sync dealReport status failed:", err?.message || err))
+
+ return { reportId: rid, status: normalizedStatus }
+}
+
+module.exports = {
+ createDealReport,
+ listDealReports,
+ updateDealReportStatus,
+}
diff --git a/services/dealSave.service.js b/services/dealSave.service.js
new file mode 100644
index 0000000..72868fb
--- /dev/null
+++ b/services/dealSave.service.js
@@ -0,0 +1,193 @@
+const dealDB = require("../db/deal.db")
+const dealSaveDB = require("../db/dealSave.db")
+const { getDealsByIdsFromRedis } = require("./redis/hotDealList.service")
+const {
+ ensureUserCache,
+ getUserSavedIdsFromRedis,
+ addUserSavedDeal,
+ removeUserSavedDeal,
+ setUserSavedDeals,
+} = require("./redis/userCache.service")
+const { mapDealToRedisJson } = require("./redis/dealIndexing.service")
+const { getOrCacheDeal, updateDealSavesInRedis, setDealInRedis } = require("./redis/dealCache.service")
+const { queueDealSaveUpdate } = require("./redis/dbSync.service")
+
+const PAGE_LIMIT = 20
+const ALLOWED_STATUSES = new Set(["ACTIVE", "EXPIRED"])
+
+function assertPositiveInt(value, name) {
+ const num = Number(value)
+ if (!Number.isInteger(num) || num <= 0) throw new Error(`Gecersiz ${name}.`)
+ return num
+}
+
+function normalizePage(value) {
+ const num = Number(value)
+ if (!Number.isInteger(num) || num < 1) return 1
+ return num
+}
+
+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: {
+ user: { select: { id: true, username: true, avatarUrl: true } },
+ likes: { select: { userId: true } },
+ },
+ },
+ aiReview: {
+ select: {
+ bestCategoryId: true,
+ needsReview: true,
+ hasIssue: true,
+ issueType: true,
+ issueReason: true,
+ },
+ },
+}
+
+async function saveDealForUser({ userId, dealId }) {
+ const uid = assertPositiveInt(userId, "userId")
+ const did = assertPositiveInt(dealId, "dealId")
+
+ const deal = await getOrCacheDeal(did, { ttlSeconds: 15 * 60 })
+ .catch(() => null)
+ if (!deal) {
+ const err = new Error("Deal bulunamadi.")
+ err.statusCode = 404
+ throw err
+ }
+ if (!ALLOWED_STATUSES.has(String(deal.status))) {
+ const err = new Error("Bu deal kaydedilemez.")
+ err.statusCode = 400
+ throw err
+ }
+
+ await updateDealSavesInRedis({
+ dealId: did,
+ userId: uid,
+ action: "SAVE",
+ createdAt: new Date().toISOString(),
+ minSeconds: 15 * 60,
+ })
+ await addUserSavedDeal(uid, did, { ttlSeconds: 60 * 60 })
+ queueDealSaveUpdate({
+ dealId: did,
+ userId: uid,
+ action: "SAVE",
+ createdAt: new Date().toISOString(),
+ }).catch((err) => console.error("DB sync dealSave queue failed:", err?.message || err))
+ return { saved: true }
+}
+
+async function removeSavedDealForUser({ userId, dealId }) {
+ const uid = assertPositiveInt(userId, "userId")
+ const did = assertPositiveInt(dealId, "dealId")
+
+ await updateDealSavesInRedis({
+ dealId: did,
+ userId: uid,
+ action: "UNSAVE",
+ createdAt: new Date().toISOString(),
+ minSeconds: 15 * 60,
+ })
+ await removeUserSavedDeal(uid, did, { ttlSeconds: 60 * 60 })
+ queueDealSaveUpdate({
+ dealId: did,
+ userId: uid,
+ action: "UNSAVE",
+ createdAt: new Date().toISOString(),
+ }).catch((err) => console.error("DB sync dealSave queue failed:", err?.message || err))
+ return { removed: true }
+}
+
+async function listSavedDeals({ userId, page = 1 }) {
+ const uid = assertPositiveInt(userId, "userId")
+ const safePage = normalizePage(page)
+ const skip = (safePage - 1) * PAGE_LIMIT
+
+ await ensureUserCache(uid, { ttlSeconds: 60 * 60 })
+ const redisCache = await getUserSavedIdsFromRedis(uid)
+ const redisJsonIds = redisCache?.jsonIds || []
+ const savedSet = redisCache?.savedSet || new Set()
+ const unsavedSet = redisCache?.unsavedSet || new Set()
+
+ const where = {
+ userId: uid,
+ deal: { status: { in: Array.from(ALLOWED_STATUSES) } },
+ }
+
+ const [total, saves] = await Promise.all([
+ dealSaveDB.countDealSavesByUser(uid, { where }),
+ dealSaveDB.findDealSavesByUser(
+ uid,
+ {
+ skip,
+ take: PAGE_LIMIT,
+ orderBy: { createdAt: "desc" },
+ where,
+ }
+ ),
+ ])
+
+ const dbDealIds = saves.map((s) => Number(s.dealId)).filter((id) => Number.isInteger(id) && id > 0)
+ const baseDb = dbDealIds.filter((id) => !unsavedSet.has(id))
+ const extraSaved = Array.from(savedSet).filter((id) => !unsavedSet.has(id))
+
+ let mergedIds = []
+ if (redisJsonIds.length) {
+ const filteredJson = redisJsonIds.filter((id) => !unsavedSet.has(id))
+ const jsonSet = new Set(filteredJson)
+ const prependSaved = extraSaved.filter((id) => !jsonSet.has(id))
+ mergedIds = [...prependSaved, ...filteredJson]
+ baseDb.forEach((id) => {
+ if (!jsonSet.has(id)) mergedIds.push(id)
+ })
+ } else {
+ const baseSet = new Set(baseDb)
+ const prependSaved = extraSaved.filter((id) => !baseSet.has(id))
+ mergedIds = [...prependSaved, ...baseDb]
+ }
+
+ await setUserSavedDeals(uid, mergedIds, { ttlSeconds: 60 * 60 })
+ const pageIds = mergedIds.slice(skip, skip + PAGE_LIMIT)
+
+ const cachedDeals = await getDealsByIdsFromRedis(pageIds, uid)
+ const cachedMap = new Map(cachedDeals.map((d) => [Number(d.id), d]))
+ const missingIds = pageIds.filter((id) => !cachedMap.has(id))
+
+ if (missingIds.length) {
+ const missingDeals = await dealDB.findDeals(
+ { id: { in: missingIds }, status: { in: Array.from(ALLOWED_STATUSES) } },
+ { include: DEAL_CACHE_INCLUDE }
+ )
+ await Promise.all(
+ missingDeals.map((deal) => {
+ const payload = mapDealToRedisJson(deal)
+ return setDealInRedis(deal.id, payload, { ttlSeconds: 15 * 60 })
+ })
+ )
+ const hydrated = await getDealsByIdsFromRedis(missingIds, uid)
+ hydrated.forEach((d) => cachedMap.set(Number(d.id), d))
+ }
+
+ const results = pageIds.map((id) => cachedMap.get(id)).filter(Boolean)
+
+ return {
+ page: safePage,
+ total: mergedIds.length,
+ totalPages: mergedIds.length ? Math.ceil(mergedIds.length / PAGE_LIMIT) : 0,
+ results,
+ }
+}
+
+module.exports = {
+ saveDealForUser,
+ removeSavedDealForUser,
+ listSavedDeals,
+}
diff --git a/services/mod.service.js b/services/mod.service.js
index 49eda53..03a0ec3 100644
--- a/services/mod.service.js
+++ b/services/mod.service.js
@@ -1,5 +1,41 @@
const dealService = require("./deal.service")
-const dealDB = require("../db/deal.db")
+const dealAiReviewDB = require("../db/dealAiReview.db")
+const categoryDB = require("../db/category.db")
+const { findCategoryById, listCategories } = categoryDB
+const { findSeller } = require("../db/seller.db")
+const {
+ getOrCacheDealForModeration,
+ updateDealInRedis,
+} = require("./redis/dealCache.service")
+const { queueDealUpdate, queueNotificationCreate } = require("./redis/dbSync.service")
+const { publishNotification } = require("./redis/notificationPubsub.service")
+const { getSellerById } = require("./redis/sellerCache.service")
+const { attachTagsToDeal, normalizeTags } = require("./tag.service")
+
+function normalizeDealForModResponse(deal) {
+ if (!deal) return deal
+ const images = Array.isArray(deal.images)
+ ? deal.images.map((img, idx) => ({
+ id: img.id ?? 0,
+ imageUrl: img.imageUrl,
+ order: img.order ?? idx,
+ }))
+ : []
+
+ return {
+ ...deal,
+ images,
+ myVote: deal.myVote ?? 0,
+ _count: { comments: Number(deal.commentCount ?? 0) },
+ }
+}
+
+async function enrichDealSeller(deal) {
+ if (!deal || deal.seller || !deal.sellerId) return deal
+ const seller = await getSellerById(Number(deal.sellerId))
+ if (!seller) return deal
+ return { ...deal, seller }
+}
async function getPendingDeals({ page = 1, limit = 10, filters = {}, viewer = null } = {}) {
return dealService.getDeals({
@@ -11,6 +47,7 @@ async function getPendingDeals({ page = 1, limit = 10, filters = {}, viewer = nu
scope: "MOD",
baseWhere: { status: "PENDING" },
filters,
+ useRedisSearch: true,
})
}
@@ -22,7 +59,7 @@ async function updateDealStatus(dealId, nextStatus) {
throw err
}
- const deal = await dealDB.findDeal({ id }, { select: { id: true, status: true } })
+ const { deal } = await getOrCacheDealForModeration(id)
if (!deal) {
const err = new Error("DEAL_NOT_FOUND")
err.statusCode = 404
@@ -31,17 +68,95 @@ async function updateDealStatus(dealId, nextStatus) {
if (deal.status === nextStatus) return { id: deal.id, status: deal.status }
- const updated = await dealDB.updateDeal(
- { id },
- { status: nextStatus },
- { select: { id: true, status: true } }
- )
+ const updatedAt = new Date()
+ await updateDealInRedis(id, { status: nextStatus }, { updatedAt })
+ queueDealUpdate({
+ dealId: id,
+ data: { status: nextStatus },
+ updatedAt: updatedAt.toISOString(),
+ }).catch((err) => console.error("DB sync deal status update failed:", err?.message || err))
- return updated
+ return { id: deal.id, status: nextStatus }
}
async function approveDeal(dealId) {
- return updateDealStatus(dealId, "ACTIVE")
+ const id = Number(dealId)
+ if (!Number.isInteger(id) || id <= 0) {
+ const err = new Error("INVALID_DEAL_ID")
+ err.statusCode = 400
+ throw err
+ }
+
+ const { deal } = await getOrCacheDealForModeration(id)
+ if (!deal) {
+ const err = new Error("DEAL_NOT_FOUND")
+ err.statusCode = 404
+ throw err
+ }
+
+ const aiReviewFromDb = await dealAiReviewDB.findDealAiReviewByDealId(id, {
+ select: { bestCategoryId: true, tags: true },
+ })
+
+ let categoryId = Number(deal.categoryId || 0)
+ if (!categoryId) {
+ const aiCategoryId = Number(deal.aiReview?.bestCategoryId || 0)
+ if (aiCategoryId > 0) {
+ categoryId = aiCategoryId
+ } else {
+ const fallbackId = aiReviewFromDb?.bestCategoryId ?? 0
+ if (!Number.isInteger(fallbackId) || fallbackId <= 0) {
+ const err = new Error("CATEGORY_REQUIRED")
+ err.statusCode = 400
+ throw err
+ }
+ categoryId = fallbackId
+ }
+ }
+
+ const aiTags = Array.isArray(deal.aiReview?.tags)
+ ? deal.aiReview.tags
+ : Array.isArray(aiReviewFromDb?.tags)
+ ? aiReviewFromDb.tags
+ : []
+ const normalizedTags = normalizeTags(aiTags)
+
+ if (deal.status === "ACTIVE" && categoryId === Number(deal.categoryId || 0) && !normalizedTags.length) {
+ return { id: deal.id, status: deal.status }
+ }
+
+ const tagResult = normalizedTags.length
+ ? await attachTagsToDeal(id, normalizedTags)
+ : { tags: [] }
+
+ const updatedAt = new Date()
+ const redisPatch = { status: "ACTIVE", categoryId }
+ if (tagResult.tags?.length) {
+ redisPatch.tags = tagResult.tags
+ }
+ await updateDealInRedis(id, redisPatch, { updatedAt })
+ queueDealUpdate({
+ dealId: id,
+ data: { status: "ACTIVE", categoryId },
+ updatedAt: updatedAt.toISOString(),
+ }).catch((err) => console.error("DB sync deal approve failed:", err?.message || err))
+
+ if (Number.isInteger(Number(deal.userId)) && Number(deal.userId) > 0) {
+ const payload = {
+ userId: Number(deal.userId),
+ message: "Fırsatın onaylandı!",
+ type: "MODERATION",
+ createdAt: updatedAt.toISOString(),
+ }
+ queueNotificationCreate(payload).catch((err) =>
+ console.error("DB sync approval notification failed:", err?.message || err)
+ )
+ publishNotification(payload).catch((err) =>
+ console.error("Approval notification publish failed:", err?.message || err)
+ )
+ }
+
+ return { id: deal.id, status: "ACTIVE" }
}
async function rejectDeal(dealId) {
@@ -56,10 +171,163 @@ async function unexpireDeal(dealId) {
return updateDealStatus(dealId, "ACTIVE")
}
+async function getDealDetailForMod(dealId, viewer = null) {
+ const deal = await dealService.getDealById(dealId, viewer)
+ if (!deal) {
+ const err = new Error("DEAL_NOT_FOUND")
+ err.statusCode = 404
+ throw err
+ }
+
+ const aiReview = await dealAiReviewDB.findDealAiReviewByDealId(Number(dealId), {
+ select: {
+ dealId: true,
+ bestCategoryId: true,
+ tags: true,
+ needsReview: true,
+ hasIssue: true,
+ issueType: true,
+ issueReason: true,
+ createdAt: true,
+ },
+ })
+
+ const categoryBreadcrumb = aiReview
+ ? await categoryDB.getCategoryBreadcrumb(aiReview.bestCategoryId, { includeUndefined: false })
+ : []
+
+ return {
+ deal,
+ aiReview: aiReview ? { ...aiReview, categoryBreadcrumb } : null,
+ }
+}
+
+async function updateDealForMod(dealId, input = {}, viewer = null) {
+ const id = Number(dealId)
+ if (!Number.isInteger(id) || id <= 0) {
+ const err = new Error("INVALID_DEAL_ID")
+ err.statusCode = 400
+ throw err
+ }
+
+ const { deal: existing } = await getOrCacheDealForModeration(id)
+ if (!existing) {
+ const err = new Error("DEAL_NOT_FOUND")
+ err.statusCode = 404
+ throw err
+ }
+
+ if (input.sellerId !== undefined && input.customSeller !== undefined) {
+ const err = new Error("SELLER_CONFLICT")
+ err.statusCode = 400
+ throw err
+ }
+
+ const data = {}
+
+ if (input.title !== undefined) data.title = input.title
+ if (input.description !== undefined) data.description = input.description ?? null
+ if (input.url !== undefined) data.url = input.url ?? null
+ if (input.price !== undefined) data.price = input.price ?? null
+ if (input.originalPrice !== undefined) data.originalPrice = input.originalPrice ?? null
+ if (input.shippingPrice !== undefined) data.shippingPrice = input.shippingPrice ?? null
+ if (input.couponCode !== undefined) data.couponCode = input.couponCode ?? null
+ if (input.location !== undefined) data.location = input.location ?? null
+ if (input.discountValue !== undefined) data.discountValue = input.discountValue ?? null
+ if (input.discountType !== undefined) {
+ const normalized =
+ typeof input.discountType === "string" ? input.discountType.toUpperCase() : null
+ if (normalized && !["PERCENT", "AMOUNT"].includes(normalized)) {
+ const err = new Error("INVALID_DISCOUNT_TYPE")
+ err.statusCode = 400
+ throw err
+ }
+ data.discountType = normalized
+ }
+ if (input.saleType !== undefined) {
+ const normalized = typeof input.saleType === "string" ? input.saleType.toUpperCase() : null
+ if (normalized && !["ONLINE", "OFFLINE", "CODE"].includes(normalized)) {
+ const err = new Error("INVALID_SALE_TYPE")
+ err.statusCode = 400
+ throw err
+ }
+ data.saletype = normalized
+ }
+
+ if (input.sellerId !== undefined) {
+ const sellerId = Number(input.sellerId)
+ if (!Number.isInteger(sellerId) || sellerId <= 0) {
+ const err = new Error("INVALID_SELLER_ID")
+ err.statusCode = 400
+ throw err
+ }
+ const seller = await findSeller({ id: sellerId }, { select: { id: true } })
+ if (!seller) {
+ const err = new Error("SELLER_NOT_FOUND")
+ err.statusCode = 404
+ throw err
+ }
+ data.sellerId = sellerId
+ data.customSeller = null
+ }
+
+ if (input.customSeller !== undefined) {
+ const normalized =
+ typeof input.customSeller === "string" ? input.customSeller.trim() : null
+ data.customSeller = normalized || null
+ if (data.customSeller) data.sellerId = null
+ }
+
+ if (input.categoryId !== undefined) {
+ const categoryId = Number(input.categoryId)
+ if (!Number.isInteger(categoryId) || categoryId < 0) {
+ const err = new Error("INVALID_CATEGORY_ID")
+ err.statusCode = 400
+ throw err
+ }
+ if (categoryId > 0) {
+ const category = await findCategoryById(categoryId, { select: { id: true } })
+ if (!category) {
+ const err = new Error("CATEGORY_NOT_FOUND")
+ err.statusCode = 404
+ throw err
+ }
+ }
+ data.categoryId = categoryId
+ }
+
+ if (!Object.keys(data).length) {
+ const enriched = await enrichDealSeller(existing)
+ return normalizeDealForModResponse(enriched)
+ }
+
+ const updatedAt = new Date()
+ const updated = await updateDealInRedis(id, data, { updatedAt })
+ queueDealUpdate({
+ dealId: id,
+ data,
+ 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)
+}
+
+async function listAllCategories() {
+ return listCategories({
+ select: { id: true, name: true, parentId: true },
+ orderBy: { id: "asc" },
+ })
+}
+
module.exports = {
getPendingDeals,
approveDeal,
rejectDeal,
expireDeal,
unexpireDeal,
+ getDealDetailForMod,
+ updateDealForMod,
+ listAllCategories,
}
diff --git a/services/moderation.service.js b/services/moderation.service.js
new file mode 100644
index 0000000..b5f1975
--- /dev/null
+++ b/services/moderation.service.js
@@ -0,0 +1,150 @@
+const userDb = require("../db/user.db")
+const userNoteDb = require("../db/userNote.db")
+const refreshTokenDb = require("../db/refreshToken.db")
+const { queueUserUpdate, queueUserNoteCreate } = require("./redis/dbSync.service")
+const {
+ getOrCacheUserModeration,
+ setUserModerationInRedis,
+} = require("./redis/userModerationCache.service")
+
+function assertUserId(userId) {
+ const id = Number(userId)
+ if (!Number.isInteger(id) || id <= 0) {
+ const err = new Error("INVALID_USER_ID")
+ err.statusCode = 400
+ throw err
+ }
+ return id
+}
+
+function computeUntil(durationDays = 7) {
+ const days = Number(durationDays)
+ const safeDays = Number.isFinite(days) && days > 0 ? days : 7
+ return new Date(Date.now() + safeDays * 24 * 60 * 60 * 1000)
+}
+
+async function ensureUserExists(userId) {
+ const id = assertUserId(userId)
+ const existing = await getOrCacheUserModeration(id)
+ if (!existing) {
+ const err = new Error("USER_NOT_FOUND")
+ err.statusCode = 404
+ throw err
+ }
+ return { id, existing }
+}
+
+async function updateUserModeration(id, patch) {
+ const updatedAt = new Date()
+ const payload = { id, ...patch }
+ await setUserModerationInRedis(payload, { ttlSeconds: 60 * 60 })
+ queueUserUpdate({
+ userId: id,
+ data: patch,
+ updatedAt: updatedAt.toISOString(),
+ }).catch((err) => console.error("DB sync user update failed:", err?.message || err))
+ return { id, ...patch }
+}
+
+async function muteUser(userId, { durationDays = 7 } = {}) {
+ const { id } = await ensureUserExists(userId)
+ const mutedUntil = computeUntil(durationDays)
+ return updateUserModeration(id, { mutedUntil })
+}
+
+async function clearMute(userId) {
+ const { id } = await ensureUserExists(userId)
+ return updateUserModeration(id, { mutedUntil: null })
+}
+
+async function suspendUser(userId, { durationDays = 7 } = {}) {
+ const { id } = await ensureUserExists(userId)
+ const suspendedUntil = computeUntil(durationDays)
+ return updateUserModeration(id, { suspendedUntil })
+}
+
+async function clearSuspend(userId) {
+ const { id } = await ensureUserExists(userId)
+ return updateUserModeration(id, { suspendedUntil: null })
+}
+
+async function disableUser(userId) {
+ const { id } = await ensureUserExists(userId)
+ const disabledAt = new Date()
+ await refreshTokenDb.revokeAllUserRefreshTokens(id)
+ return updateUserModeration(id, { disabledAt })
+}
+
+async function enableUser(userId) {
+ const { id } = await ensureUserExists(userId)
+ return updateUserModeration(id, { disabledAt: null })
+}
+
+async function updateUserRole(userId, role) {
+ const { id } = await ensureUserExists(userId)
+ const normalized = String(role || "").toUpperCase()
+ if (!["USER", "MOD"].includes(normalized)) {
+ const err = new Error("INVALID_ROLE")
+ err.statusCode = 400
+ throw err
+ }
+ return updateUserModeration(id, { role: normalized })
+}
+
+async function addUserNote({ userId, createdById, note }) {
+ const uid = assertUserId(userId)
+ const cid = assertUserId(createdById)
+ const text = String(note || "").trim()
+ if (!text) {
+ const err = new Error("NOTE_REQUIRED")
+ err.statusCode = 400
+ throw err
+ }
+
+ queueUserNoteCreate({
+ userId: uid,
+ createdById: cid,
+ note: text.slice(0, 1000),
+ createdAt: new Date().toISOString(),
+ }).catch((err) => console.error("DB sync user note failed:", err?.message || err))
+
+ return { userId: uid, createdById: cid, note: text.slice(0, 1000) }
+}
+
+async function listUserNotes({ userId, page = 1, limit = 20 }) {
+ const uid = assertUserId(userId)
+ const safePage = Number.isInteger(Number(page)) && Number(page) > 0 ? Number(page) : 1
+ const safeLimit = Number.isInteger(Number(limit)) && Number(limit) > 0 ? Number(limit) : 20
+ const skip = (safePage - 1) * safeLimit
+
+ const [notes, total, userExists] = await Promise.all([
+ userNoteDb.listUserNotes({ userId: uid, skip, take: safeLimit }),
+ userNoteDb.countUserNotes({ userId: uid }),
+ userDb.findUser({ id: uid }, { select: { id: true } }).then((u) => Boolean(u)),
+ ])
+
+ if (!userExists) {
+ const err = new Error("USER_NOT_FOUND")
+ err.statusCode = 404
+ throw err
+ }
+
+ return {
+ page: safePage,
+ total,
+ totalPages: total ? Math.ceil(total / safeLimit) : 0,
+ results: notes,
+ }
+}
+
+module.exports = {
+ muteUser,
+ clearMute,
+ suspendUser,
+ clearSuspend,
+ disableUser,
+ enableUser,
+ updateUserRole,
+ addUserNote,
+ listUserNotes,
+}
diff --git a/services/productPreview.service.js b/services/productPreview.service.js
new file mode 100644
index 0000000..afca955
--- /dev/null
+++ b/services/productPreview.service.js
@@ -0,0 +1,32 @@
+const axios = require("axios")
+
+function buildScraperUrl(baseUrl, targetUrl) {
+ if (!baseUrl) throw new Error("SCRAPER_API_URL missing")
+ if (!targetUrl) throw new Error("url parametresi zorunlu")
+
+ const normalizedBase = String(baseUrl)
+ const encoded = encodeURIComponent(String(targetUrl))
+
+ if (normalizedBase.includes("{url}")) {
+ return normalizedBase.replace("{url}", encoded)
+ }
+
+ if (normalizedBase.endsWith("?") || normalizedBase.endsWith("&")) {
+ return `${normalizedBase}url=${encoded}`
+ }
+
+ return normalizedBase.endsWith("/")
+ ? `${normalizedBase}${encoded}`
+ : `${normalizedBase}${encoded}`
+}
+
+async function getProductPreviewFromUrl(url) {
+ const baseUrl = process.env.SCRAPER_API_URL
+ const scraperUrl = buildScraperUrl(baseUrl, url)
+
+ const { data } = await axios.get(scraperUrl, { timeout: 20000 })
+ if (data && typeof data === "object" && data.product) return data.product
+ return data
+}
+
+module.exports = { getProductPreviewFromUrl }
diff --git a/services/profile.service.js b/services/profile.service.js
index 29244d3..db83d4f 100644
--- a/services/profile.service.js
+++ b/services/profile.service.js
@@ -1,4 +1,8 @@
+const bcrypt = require("bcryptjs")
const userDb = require("../db/user.db")
+const notificationDb = require("../db/notification.db")
+const refreshTokenDb = require("../db/refreshToken.db")
+const { queueNotificationReadAll } = require("./redis/dbSync.service")
function assertPositiveInt(v, name = "id") {
const n = Number(v)
@@ -20,14 +24,144 @@ async function getUserProfile(userId) {
const select = {
id: true,
username: true,
- email: true,
avatarUrl: true,
createdAt: true,
+ userBadges: {
+ orderBy: { earnedAt: "desc" },
+ select: {
+ earnedAt: true,
+ badge: { select: { id: true, name: true, iconUrl: true, description: true } },
+ },
+ },
+ notifications: {
+ orderBy: { createdAt: "desc" },
+ take: 3,
+ select: {
+ id: true,
+ message: true,
+ type: true,
+ createdAt: true,
+ readAt: true,
+ },
+ },
+ }
+ const user = await userDb.findUser({ id }, { select })
+ if (!user) return user
+
+ const formatDate = (value) => (value instanceof Date ? value.toISOString() : value ?? null)
+
+ const notifications = Array.isArray(user.notifications)
+ ? user.notifications.map((n) => ({
+ ...n,
+ createdAt: formatDate(n.createdAt),
+ readAt: formatDate(n.readAt),
+ unread: n.readAt == null,
+ }))
+ : []
+
+ const badges = Array.isArray(user.userBadges)
+ ? user.userBadges.map((item) => ({
+ badge: item.badge
+ ? {
+ id: item.badge.id,
+ name: item.badge.name,
+ iconUrl: item.badge.iconUrl ?? null,
+ description: item.badge.description ?? null,
+ }
+ : null,
+ earnedAt: formatDate(item.earnedAt),
+ }))
+ : []
+
+ return {
+ id: user.id,
+ username: user.username,
+ avatarUrl: user.avatarUrl ?? null,
+ createdAt: formatDate(user.createdAt),
+ notifications,
+ badges,
}
- return userDb.findUser({ id }, { select })
}
module.exports = {
updateAvatarUrl,
getUserProfile,
+ markAllNotificationsRead,
+ getUserNotificationsPage,
+ changePassword,
+}
+
+async function markAllNotificationsRead(userId) {
+ const id = assertPositiveInt(userId, "userId")
+ const readAt = new Date().toISOString()
+ await queueNotificationReadAll({ userId: id, readAt })
+ return { queued: true, readAt }
+}
+
+async function getUserNotificationsPage(userId, page = 1, limit = 10) {
+ const id = assertPositiveInt(userId, "userId")
+ const pageNumber = assertPositiveInt(page, "page")
+ const take = assertPositiveInt(limit, "limit")
+ const skip = (pageNumber - 1) * take
+
+ const [total, notifications] = await Promise.all([
+ notificationDb.countNotifications({ userId: id }),
+ notificationDb.findNotifications(
+ { userId: id },
+ {
+ orderBy: { createdAt: "desc" },
+ skip,
+ take,
+ select: {
+ id: true,
+ message: true,
+ type: true,
+ createdAt: true,
+ readAt: true,
+ },
+ }
+ ),
+ ])
+
+ const formatDate = (value) => (value instanceof Date ? value.toISOString() : value ?? null)
+ const results = Array.isArray(notifications)
+ ? notifications.map((n) => ({
+ ...n,
+ createdAt: formatDate(n.createdAt),
+ readAt: formatDate(n.readAt),
+ unread: n.readAt == null,
+ }))
+ : []
+
+ const totalPages = Math.ceil(total / take)
+
+ return {
+ page: pageNumber,
+ total,
+ totalPages,
+ results,
+ }
+}
+
+async function changePassword(userId, { currentPassword, newPassword }) {
+ const id = assertPositiveInt(userId, "userId")
+ if (!currentPassword || typeof currentPassword !== "string")
+ throw new Error("Mevcut şifre gerekli.")
+ if (!newPassword || typeof newPassword !== "string")
+ throw new Error("Yeni şifre gerekli.")
+
+ const user = await userDb.findUser(
+ { id },
+ { select: { id: true, passwordHash: true } }
+ )
+ if (!user) throw new Error("Kullanıcı bulunamadı.")
+
+ const isMatch = await bcrypt.compare(currentPassword, user.passwordHash)
+ if (!isMatch) throw new Error("Mevcut şifre hatalı.")
+
+ const passwordHash = await bcrypt.hash(newPassword, 10)
+ await userDb.updateUser({ id }, { passwordHash })
+ await refreshTokenDb.revokeAllUserRefreshTokens(id)
+
+ return { message: "Şifre güncellendi." }
}
diff --git a/services/redis/badgeCache.service.js b/services/redis/badgeCache.service.js
new file mode 100644
index 0000000..ecef383
--- /dev/null
+++ b/services/redis/badgeCache.service.js
@@ -0,0 +1,75 @@
+const { getRedisClient } = require("./client")
+const badgeDb = require("../../db/badge.db")
+
+const BADGES_KEY = "data:badges"
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+function normalizeBadge(badge) {
+ if (!badge?.id) return null
+ return {
+ id: badge.id,
+ name: badge.name,
+ iconUrl: badge.iconUrl ?? null,
+ description: badge.description ?? null,
+ }
+}
+
+async function setBadgesInRedis(badges = []) {
+ const items = (Array.isArray(badges) ? badges : [])
+ .map(normalizeBadge)
+ .filter(Boolean)
+ if (!items.length) return 0
+ const redis = createRedisClient()
+ try {
+ const pipeline = redis.pipeline()
+ items.forEach((badge) => {
+ pipeline.hset(BADGES_KEY, String(badge.id), JSON.stringify(badge))
+ })
+ await pipeline.exec()
+ return items.length
+ } finally {}
+}
+
+async function setBadgeInRedis(badge) {
+ const payload = normalizeBadge(badge)
+ if (!payload) return false
+ await setBadgesInRedis([payload])
+ return true
+}
+
+async function getBadgesFromRedis() {
+ const redis = createRedisClient()
+ try {
+ const raw = await redis.hvals(BADGES_KEY)
+ if (!raw?.length) return []
+ const badges = []
+ raw.forEach((item) => {
+ try {
+ const parsed = JSON.parse(item)
+ if (parsed?.id) badges.push(parsed)
+ } catch {
+ return
+ }
+ })
+ return badges
+ } finally {}
+}
+
+async function ensureBadgesCached() {
+ const cached = await getBadgesFromRedis()
+ if (cached.length) return cached
+ const badges = await badgeDb.listBadges()
+ if (badges.length) await setBadgesInRedis(badges)
+ return badges
+}
+
+module.exports = {
+ BADGES_KEY,
+ getBadgesFromRedis,
+ setBadgesInRedis,
+ setBadgeInRedis,
+ ensureBadgesCached,
+}
diff --git a/services/redis/cacheMetrics.service.js b/services/redis/cacheMetrics.service.js
new file mode 100644
index 0000000..6377e7b
--- /dev/null
+++ b/services/redis/cacheMetrics.service.js
@@ -0,0 +1,34 @@
+const { getRedisClient } = require("./client")
+const { getRequestContext } = require("../requestContext")
+
+const MISS_HASH_KEY = "cache:misses"
+
+function shouldLog() {
+ return String(process.env.CACHE_MISS_LOG || "").trim() === "1"
+}
+
+function buildField({ key, label }) {
+ const ctx = getRequestContext()
+ const ctxPart = ctx ? `${ctx.method} ${ctx.path}` : "unknown"
+ const labelPart = label ? `| ${label}` : ""
+ return `${ctxPart} | ${key}${labelPart}`
+}
+
+async function recordCacheMiss({ key, label }) {
+ if (!key) return
+ const field = buildField({ key, label })
+ const redis = getRedisClient()
+ try {
+ await redis.hincrby(MISS_HASH_KEY, field, 1)
+ if (shouldLog()) {
+ console.log(`[cache-miss] ${field}`)
+ }
+ } catch (_) {
+ // ignore
+ }
+}
+
+module.exports = {
+ recordCacheMiss,
+ MISS_HASH_KEY,
+}
diff --git a/services/redis/categoryCache.service.js b/services/redis/categoryCache.service.js
new file mode 100644
index 0000000..9942915
--- /dev/null
+++ b/services/redis/categoryCache.service.js
@@ -0,0 +1,81 @@
+const { getRedisClient } = require("./client")
+const { recordCacheMiss } = require("./cacheMetrics.service")
+
+const CATEGORIES_KEY = "data:categories"
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+async function getCategoryById(id) {
+ const cid = Number(id)
+ if (!Number.isInteger(cid) || cid < 0) return null
+ const redis = createRedisClient()
+ try {
+ const raw = await redis.hget(CATEGORIES_KEY, String(cid))
+ if (!raw) {
+ await recordCacheMiss({ key: `${CATEGORIES_KEY}:${cid}`, label: "category" })
+ }
+ return raw ? JSON.parse(raw) : null
+ } finally {}
+}
+
+async function setCategoryInRedis(category) {
+ if (!category?.id) return false
+ const redis = createRedisClient()
+ try {
+ await redis.hset(CATEGORIES_KEY, String(category.id), JSON.stringify(category))
+ return true
+ } finally {}
+}
+
+async function setCategoriesInRedis(categories = []) {
+ const list = Array.isArray(categories) ? categories : []
+ if (!list.length) return 0
+ const redis = createRedisClient()
+ try {
+ const pipeline = redis.pipeline()
+ list.forEach((cat) => {
+ if (!cat?.id) return
+ pipeline.hset(CATEGORIES_KEY, String(cat.id), JSON.stringify(cat))
+ })
+ await pipeline.exec()
+ return list.length
+ } finally {}
+}
+
+async function removeCategoryFromRedis(categoryId) {
+ const cid = Number(categoryId)
+ if (!Number.isInteger(cid) || cid < 0) return 0
+ const redis = createRedisClient()
+ try {
+ await redis.hdel(CATEGORIES_KEY, String(cid))
+ return 1
+ } finally {}
+}
+
+async function listCategoriesFromRedis() {
+ const redis = createRedisClient()
+ try {
+ const raw = await redis.hgetall(CATEGORIES_KEY)
+ const list = []
+ for (const value of Object.values(raw || {})) {
+ try {
+ const parsed = JSON.parse(value)
+ if (parsed && parsed.id !== undefined) list.push(parsed)
+ } catch {
+ continue
+ }
+ }
+ return list
+ } finally {}
+}
+
+module.exports = {
+ CATEGORIES_KEY,
+ getCategoryById,
+ setCategoryInRedis,
+ setCategoriesInRedis,
+ removeCategoryFromRedis,
+ listCategoriesFromRedis,
+}
diff --git a/services/redis/categoryId.service.js b/services/redis/categoryId.service.js
new file mode 100644
index 0000000..da08eb0
--- /dev/null
+++ b/services/redis/categoryId.service.js
@@ -0,0 +1,18 @@
+const prisma = require("../../db/client")
+const { ensureCounterAtLeast, nextId } = require("./idGenerator.service")
+const CATEGORY_ID_KEY = "ids:category"
+
+async function ensureCategoryIdCounter() {
+ const latest = await prisma.category.findFirst({
+ select: { id: true },
+ orderBy: { id: "desc" },
+ })
+ const maxId = latest?.id ?? 0
+ await ensureCounterAtLeast(CATEGORY_ID_KEY, maxId)
+}
+
+async function generateCategoryId() {
+ return nextId(CATEGORY_ID_KEY)
+}
+
+module.exports = { ensureCategoryIdCounter, generateCategoryId }
diff --git a/services/redis/client.js b/services/redis/client.js
new file mode 100644
index 0000000..6f8c529
--- /dev/null
+++ b/services/redis/client.js
@@ -0,0 +1,14 @@
+
+const Redis = require("ioredis")
+const { getRedisConnectionOptions } = require("./connection")
+
+let sharedClient
+
+function getRedisClient() {
+ if (!sharedClient) {
+ sharedClient = new Redis(getRedisConnectionOptions())
+ }
+ return sharedClient
+}
+
+module.exports = { getRedisClient }
diff --git a/services/redis/commentCache.service.js b/services/redis/commentCache.service.js
new file mode 100644
index 0000000..7a042f4
--- /dev/null
+++ b/services/redis/commentCache.service.js
@@ -0,0 +1,206 @@
+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"
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+function normalizeParentId(value) {
+ if (value === undefined || value === null || value === "" || value === "null") return null
+ const n = Number(value)
+ return Number.isInteger(n) && n > 0 ? n : null
+}
+
+function pickComments(deal, parentId) {
+ const list = Array.isArray(deal?.comments) ? deal.comments : []
+ return list.filter(
+ (c) =>
+ (normalizeParentId(c.parentId) ?? null) === (normalizeParentId(parentId) ?? null) &&
+ !c.deletedAt
+ )
+}
+
+function sortComments(list, sort) {
+ const mode = String(sort || "NEW").toUpperCase()
+ if (mode === "TOP") {
+ return list.sort((a, b) => {
+ const diff = Number(b.likeCount || 0) - Number(a.likeCount || 0)
+ if (diff !== 0) return diff
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+ })
+ }
+ return list.sort(
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+ )
+}
+
+
+async function updateDealCommentsInRedis(dealId, comments, commentCount) {
+ const redis = createRedisClient()
+ try {
+ const pipeline = redis.pipeline()
+ pipeline.call("JSON.SET", `${DEAL_KEY_PREFIX}${dealId}`, "$.comments", JSON.stringify(comments))
+ if (typeof commentCount === "number") {
+ pipeline.call("JSON.SET", `${DEAL_KEY_PREFIX}${dealId}`, "$.commentCount", Number(commentCount))
+ }
+ await pipeline.exec()
+ } finally {}
+}
+
+async function getCommentsForDeal({
+ dealId,
+ deal = null,
+ parentId = null,
+ page = 1,
+ limit = 10,
+ sort = "NEW",
+ viewerId = null,
+} = {}) {
+ const resolvedDeal =
+ deal || (await getOrCacheDeal(dealId, { ttlSeconds: DEFAULT_TTL_SECONDS }))
+ if (!resolvedDeal) return { page: 1, total: 0, totalPages: 0, results: [] }
+
+ const normalizedLimit = Math.max(1, Math.min(Number(limit) || 10, 50))
+ const normalizedPage = Math.max(1, Number(page) || 1)
+
+ let comments = pickComments(resolvedDeal, parentId)
+ comments = sortComments(comments, sort)
+
+ const total = comments.length
+ const totalPages = Math.ceil(total / normalizedLimit)
+ const start = (normalizedPage - 1) * normalizedLimit
+ const results = comments.slice(start, start + normalizedLimit).map((comment) => {
+ const likes = Array.isArray(comment.likes) ? comment.likes : []
+ const myLike = viewerId
+ ? likes.some((l) => Number(l.userId) === Number(viewerId))
+ : false
+ return {
+ ...comment,
+ myLike,
+ }
+ })
+
+ return { page: normalizedPage, total, totalPages, results }
+}
+
+async function addCommentToRedis(comment, { ttlSeconds = DEFAULT_TTL_SECONDS } = {}) {
+ const deal = await getOrCacheDeal(comment.dealId, { ttlSeconds })
+ if (!deal) return { added: false }
+
+ const comments = Array.isArray(deal.comments) ? deal.comments : []
+ const newComment = {
+ id: comment.id,
+ dealId: comment.dealId,
+ userId: comment.userId,
+ text: comment.text,
+ createdAt: comment.createdAt instanceof Date ? comment.createdAt.toISOString() : comment.createdAt,
+ parentId: normalizeParentId(comment.parentId),
+ likeCount: Number.isFinite(comment.likeCount) ? comment.likeCount : 0,
+ repliesCount: Number.isFinite(comment.repliesCount) ? comment.repliesCount : 0,
+ deletedAt: null,
+ user: comment.user
+ ? {
+ id: comment.user.id,
+ username: comment.user.username,
+ avatarUrl: comment.user.avatarUrl ?? null,
+ }
+ : null,
+ likes: [],
+ }
+
+ comments.unshift(newComment)
+
+ if (newComment.parentId) {
+ const parent = comments.find((c) => Number(c.id) === Number(newComment.parentId))
+ if (parent) {
+ parent.repliesCount = Number.isFinite(parent.repliesCount) ? parent.repliesCount + 1 : 1
+ }
+ }
+
+ const commentCount = Number.isFinite(deal.commentCount) ? deal.commentCount + 1 : 1
+ await updateDealCommentsInRedis(comment.dealId, comments, commentCount)
+ await ensureMinDealTtl(comment.dealId, { minSeconds: DEFAULT_TTL_SECONDS })
+ const redis = createRedisClient()
+ try {
+ await redis.hset(COMMENT_LOOKUP_KEY, String(comment.id), String(comment.dealId))
+ await redis.sadd(COMMENT_IDS_KEY, String(comment.id))
+ } finally {}
+ return { added: true }
+}
+
+async function removeCommentFromRedis({ commentId, dealId }) {
+ const deal = await getOrCacheDeal(dealId, { ttlSeconds: DEFAULT_TTL_SECONDS })
+ if (!deal) return { removed: false }
+
+ const comments = Array.isArray(deal.comments) ? deal.comments : []
+ const target = comments.find((c) => Number(c.id) === Number(commentId))
+ if (!target) return { removed: false }
+ if (target.deletedAt) return { removed: false, alreadyDeleted: true }
+
+ target.deletedAt = new Date().toISOString()
+ const commentCount = Math.max(0, Number(deal.commentCount || 0) - 1)
+
+ if (target.parentId) {
+ const parent = comments.find((c) => Number(c.id) === Number(target.parentId))
+ if (parent && Number.isFinite(parent.repliesCount)) {
+ parent.repliesCount = Math.max(0, parent.repliesCount - 1)
+ }
+ }
+
+ await updateDealCommentsInRedis(dealId, comments, commentCount)
+ await ensureMinDealTtl(dealId, { minSeconds: DEFAULT_TTL_SECONDS })
+ return { removed: true }
+}
+
+async function updateCommentLikeInRedisByDeal({ dealId, commentId, userId, like }) {
+ const deal = await getOrCacheDeal(dealId, { ttlSeconds: DEFAULT_TTL_SECONDS })
+ if (!deal) return { liked: Boolean(like), delta: 0, likeCount: 0 }
+
+ const comments = Array.isArray(deal.comments) ? deal.comments : []
+ const target = comments.find((c) => Number(c.id) === Number(commentId))
+ if (!target || target.deletedAt) return { liked: Boolean(like), delta: 0, likeCount: 0 }
+
+ let likes = Array.isArray(target.likes) ? target.likes : []
+ const exists = likes.some((l) => Number(l.userId) === Number(userId))
+
+ let delta = 0
+ if (like) {
+ if (!exists) {
+ likes = [...likes, { userId: Number(userId) }]
+ delta = 1
+ }
+ } else if (exists) {
+ likes = likes.filter((l) => Number(l.userId) !== Number(userId))
+ delta = -1
+ }
+
+ target.likes = likes
+ if (delta !== 0) {
+ target.likeCount = Math.max(0, Number(target.likeCount || 0) + delta)
+ } else {
+ target.likeCount = Number.isFinite(target.likeCount) ? target.likeCount : likes.length
+ }
+
+ await updateDealCommentsInRedis(dealId, comments)
+ await ensureMinDealTtl(dealId, { minSeconds: DEFAULT_TTL_SECONDS })
+ return { liked: Boolean(like), delta, likeCount: Number(target.likeCount || 0) }
+}
+
+async function updateCommentLikeInRedis({ commentId, userId, like }) {
+ const dealId = await getDealIdByCommentId(commentId)
+ if (!dealId) return { liked: Boolean(like), delta: 0, likeCount: 0 }
+ return updateCommentLikeInRedisByDeal({ dealId, commentId, userId, like })
+}
+
+module.exports = {
+ getCommentsForDeal,
+ addCommentToRedis,
+ removeCommentFromRedis,
+ updateCommentLikeInRedis,
+ updateCommentLikeInRedisByDeal,
+}
diff --git a/services/redis/commentId.service.js b/services/redis/commentId.service.js
new file mode 100644
index 0000000..6c09efa
--- /dev/null
+++ b/services/redis/commentId.service.js
@@ -0,0 +1,18 @@
+const prisma = require("../../db/client")
+const { ensureCounterAtLeast, nextId } = require("./idGenerator.service")
+const COMMENT_ID_KEY = "ids:comment"
+
+async function ensureCommentIdCounter() {
+ const latest = await prisma.comment.findFirst({
+ select: { id: true },
+ orderBy: { id: "desc" },
+ })
+ const maxId = latest?.id ?? 0
+ await ensureCounterAtLeast(COMMENT_ID_KEY, maxId)
+}
+
+async function generateCommentId() {
+ return nextId(COMMENT_ID_KEY)
+}
+
+module.exports = { ensureCommentIdCounter, generateCommentId }
diff --git a/services/redis/connection.js b/services/redis/connection.js
new file mode 100644
index 0000000..f390acf
--- /dev/null
+++ b/services/redis/connection.js
@@ -0,0 +1,9 @@
+function getRedisConnectionOptions() {
+ return {
+ host: process.env.REDIS_HOST,
+ port: Number(process.env.REDIS_PORT),
+ password: process.env.REDIS_PASSWORD || undefined,
+ }
+}
+
+module.exports = { getRedisConnectionOptions }
diff --git a/services/redis/dbSync.service.js b/services/redis/dbSync.service.js
new file mode 100644
index 0000000..48184be
--- /dev/null
+++ b/services/redis/dbSync.service.js
@@ -0,0 +1,321 @@
+const { getRedisClient } = require("./client")
+
+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"
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+async function queueDealSaveUpdate({ dealId, userId, action, createdAt }) {
+ if (!dealId || !userId) return
+ const normalized = String(action || "").toUpperCase()
+ if (!["SAVE", "UNSAVE"].includes(normalized)) return
+ const redis = createRedisClient()
+
+ 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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+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 {}
+}
+
+module.exports = {
+ queueVoteUpdate,
+ queueCommentLikeUpdate,
+ queueCommentCreate,
+ queueCommentDelete,
+ queueDealUpdate,
+ queueDealCreate,
+ queueDealAiReviewUpdate,
+ queueNotificationCreate,
+ queueNotificationReadAll,
+ queueDealSaveUpdate,
+ queueAuditEvent,
+ queueUserUpdate,
+ queueUserNoteCreate,
+ queueDealReportStatusUpdate,
+ queueCategoryUpsert,
+ queueSellerUpsert,
+ queueSellerDomainUpsert,
+ COMMENT_HASH_KEY,
+ COMMENT_DELETE_HASH_KEY,
+ VOTE_HASH_KEY,
+ COMMENT_LIKE_HASH_KEY,
+ DEAL_UPDATE_HASH_KEY,
+ DEAL_CREATE_HASH_KEY,
+ DEAL_AI_REVIEW_HASH_KEY,
+ NOTIFICATION_HASH_KEY,
+ NOTIFICATION_READ_HASH_KEY,
+ DEAL_SAVE_HASH_KEY,
+ AUDIT_HASH_KEY,
+ USER_UPDATE_HASH_KEY,
+ USER_NOTE_HASH_KEY,
+ DEAL_REPORT_UPDATE_HASH_KEY,
+ CATEGORY_UPSERT_HASH_KEY,
+ SELLER_UPSERT_HASH_KEY,
+ SELLER_DOMAIN_UPSERT_HASH_KEY,
+}
diff --git a/services/redis/dealAnalytics.service.js b/services/redis/dealAnalytics.service.js
new file mode 100644
index 0000000..74f26a4
--- /dev/null
+++ b/services/redis/dealAnalytics.service.js
@@ -0,0 +1,168 @@
+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:"
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+function getTotalKey(dealId) {
+ return `${DEAL_ANALYTICS_TOTAL_PREFIX}${dealId}`
+}
+
+function normalizeIds(ids = []) {
+ return Array.from(
+ new Set(
+ (Array.isArray(ids) ? ids : [])
+ .map((id) => Number(id))
+ .filter((id) => Number.isInteger(id) && id > 0)
+ )
+ )
+}
+
+function isValidEventType(type) {
+ const normalized = String(type || "").toUpperCase()
+ return ["IMPRESSION", "VIEW", "CLICK"].includes(normalized)
+}
+
+async function seedDealAnalyticsTotals({ dealIds = [] } = {}) {
+ const ids = normalizeIds(dealIds)
+ if (!ids.length) return 0
+
+ await dealAnalyticsDb.ensureTotalsForDealIds(ids)
+ const totals = await dealAnalyticsDb.getTotalsByDealIds(ids)
+ const totalsById = new Map(totals.map((t) => [t.dealId, t]))
+
+ const redis = createRedisClient()
+ try {
+ const pipeline = redis.pipeline()
+ ids.forEach((id) => {
+ const total = totalsById.get(id) || { impressions: 0, views: 0, clicks: 0 }
+ pipeline.hset(
+ getTotalKey(id),
+ "impressions",
+ String(total.impressions || 0),
+ "views",
+ String(total.views || 0),
+ "clicks",
+ String(total.clicks || 0)
+ )
+ })
+ await pipeline.exec()
+ return ids.length
+ } finally {}
+}
+
+async function initDealAnalyticsTotal(dealId) {
+ const id = Number(dealId)
+ if (!Number.isInteger(id) || id <= 0) return 0
+ await dealAnalyticsDb.ensureTotalsForDealIds([id])
+ await seedDealAnalyticsTotals({ dealIds: [id] })
+ return 1
+}
+
+async function queueDealEvents(events = []) {
+ const valid = (Array.isArray(events) ? events : []).filter(
+ (e) =>
+ e &&
+ Number.isInteger(Number(e.dealId)) &&
+ (e.userId || e.ip) &&
+ isValidEventType(e.type)
+ )
+ if (!valid.length) return 0
+
+ const redis = createRedisClient()
+ try {
+ 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)
+ })
+ await pipeline.exec()
+ return valid.length
+ } finally {}
+}
+
+async function queueDealImpressions({ dealIds = [], userId = null, ip = null } = {}) {
+ if (!userId && !ip) return 0
+ const ids = normalizeIds(dealIds)
+ if (!ids.length) return 0
+ const events = ids.map((dealId) => ({
+ dealId,
+ type: "IMPRESSION",
+ userId,
+ ip,
+ }))
+ await Promise.all(ids.map((id) => ensureMinDealTtl(id, { minSeconds: 15 * 60 })))
+ return queueDealEvents(events)
+}
+
+async function queueDealView({ dealId, userId = null, ip = null } = {}) {
+ if (!userId && !ip) return 0
+ const id = Number(dealId)
+ if (!Number.isInteger(id) || id <= 0) return 0
+ await ensureMinDealTtl(id, { minSeconds: 15 * 60 })
+ return queueDealEvents([
+ {
+ dealId: id,
+ type: "VIEW",
+ userId,
+ ip,
+ },
+ ])
+}
+
+async function queueDealClick({ dealId, userId = null, ip = null } = {}) {
+ if (!userId && !ip) return 0
+ const id = Number(dealId)
+ if (!Number.isInteger(id) || id <= 0) return 0
+ await ensureMinDealTtl(id, { minSeconds: 15 * 60 })
+ return queueDealEvents([
+ {
+ dealId: id,
+ type: "CLICK",
+ userId,
+ ip,
+ },
+ ])
+}
+
+async function incrementDealAnalyticsTotalsInRedis(increments = []) {
+ const data = (Array.isArray(increments) ? increments : []).filter(
+ (item) => item && Number.isInteger(Number(item.dealId))
+ )
+ if (!data.length) return 0
+ const redis = createRedisClient()
+ try {
+ const pipeline = redis.pipeline()
+ data.forEach((item) => {
+ const key = getTotalKey(item.dealId)
+ if (item.impressions) pipeline.hincrby(key, "impressions", Number(item.impressions))
+ if (item.views) pipeline.hincrby(key, "views", Number(item.views))
+ if (item.clicks) pipeline.hincrby(key, "clicks", Number(item.clicks))
+ })
+ await pipeline.exec()
+ return data.length
+ } finally {}
+}
+
+module.exports = {
+ seedDealAnalyticsTotals,
+ initDealAnalyticsTotal,
+ queueDealImpressions,
+ queueDealView,
+ queueDealClick,
+ incrementDealAnalyticsTotalsInRedis,
+ DEAL_EVENT_HASH_KEY,
+}
diff --git a/services/redis/dealCache.service.js b/services/redis/dealCache.service.js
new file mode 100644
index 0000000..b0b3199
--- /dev/null
+++ b/services/redis/dealCache.service.js
@@ -0,0 +1,352 @@
+const dealDB = require("../../db/deal.db")
+const userDB = require("../../db/user.db")
+const dealAnalyticsDb = require("../../db/dealAnalytics.db")
+const { getRedisClient } = require("./client")
+const { mapDealToRedisJson } = require("./dealIndexing.service")
+const { recordCacheMiss } = require("./cacheMetrics.service")
+const {
+ getUserPublicFromRedis,
+ setUserPublicInRedis,
+ 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"
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+function toIso(value) {
+ return value instanceof Date ? value.toISOString() : value ?? null
+}
+
+function toEpochMs(value) {
+ if (value instanceof Date) return value.getTime()
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? null : date.getTime()
+}
+
+async function getAnalyticsTotalsForDeal(dealId) {
+ const id = Number(dealId)
+ if (!Number.isInteger(id) || id <= 0) {
+ return { impressions: 0, views: 0, clicks: 0 }
+ }
+ await dealAnalyticsDb.ensureTotalsForDealIds([id])
+ const totals = await dealAnalyticsDb.getTotalsByDealIds([id])
+ const entry = totals?.[0] || { impressions: 0, views: 0, clicks: 0 }
+ return {
+ impressions: Number(entry.impressions) || 0,
+ views: Number(entry.views) || 0,
+ clicks: Number(entry.clicks) || 0,
+ }
+}
+
+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)
+ pipeline.hset(
+ analyticsKey,
+ "impressions",
+ String(totals.impressions || 0),
+ "views",
+ String(totals.views || 0),
+ "clicks",
+ String(totals.clicks || 0)
+ )
+
+ if (ttlSeconds) {
+ if (Array.isArray(payload?.votes) && payload.votes.length) {
+ pipeline.expire(voteKey, Number(ttlSeconds))
+ }
+ pipeline.expire(analyticsKey, Number(ttlSeconds))
+ }
+
+ await pipeline.exec()
+}
+
+async function ensureMinDealTtl(dealId, { minSeconds = 15 * 60 } = {}) {
+ const id = Number(dealId)
+ 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)
+
+ try {
+ const ttl = await redis.ttl(key)
+ if (ttl === -2) return { bumped: false } // no key
+ if (ttl === -1 || ttl < minTtl) {
+ 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 }
+ } finally {}
+}
+
+async function updateDealSavesInRedis({ dealId, userId, action, createdAt, minSeconds = 15 * 60 } = {}) {
+ const id = Number(dealId)
+ const uid = Number(userId)
+ if (!Number.isInteger(id) || id <= 0 || !Number.isInteger(uid) || uid <= 0) {
+ return { updated: false }
+ }
+ const normalized = String(action || "SAVE").toUpperCase()
+ if (!["SAVE", "UNSAVE"].includes(normalized)) return { updated: false }
+
+ 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 }
+ } finally {}
+}
+
+async function getDealFromRedis(dealId) {
+ const redis = createRedisClient()
+ try {
+ const key = `${DEAL_KEY_PREFIX}${dealId}`
+ const raw = await redis.call("JSON.GET", key)
+ if (!raw) {
+ await recordCacheMiss({ key })
+ return null
+ }
+ const deal = JSON.parse(raw)
+ if (!deal?.user && deal?.userId) {
+ const cachedUser = await getUserPublicFromRedis(deal.userId)
+ if (cachedUser) {
+ deal.user = cachedUser
+ } else {
+ const user = await userDB.findUser(
+ { id: Number(deal.userId) },
+ {
+ select: {
+ id: true,
+ username: true,
+ avatarUrl: true,
+ userBadges: {
+ orderBy: { earnedAt: "desc" },
+ select: {
+ earnedAt: true,
+ badge: { select: { id: true, name: true, iconUrl: true, description: true } },
+ },
+ },
+ },
+ }
+ )
+ if (user) {
+ const ttl = await redis.ttl(key)
+ const ttlSeconds = ttl > 0 ? ttl : undefined
+ await setUserPublicInRedis(user, { ttlSeconds })
+ deal.user = user
+ }
+ }
+ }
+ return deal
+ } finally {}
+}
+
+async function cacheDealFromDb(dealId, { ttlSeconds = 1800 } = {}) {
+ const deal = await dealDB.findDeal(
+ { id: Number(dealId) },
+ {
+ include: {
+ user: {
+ select: {
+ id: true,
+ username: true,
+ avatarUrl: true,
+ userBadges: {
+ orderBy: { earnedAt: "desc" },
+ select: {
+ earnedAt: true,
+ badge: { select: { id: true, name: true, iconUrl: true, description: true } },
+ },
+ },
+ },
+ },
+ images: { orderBy: { order: "asc" }, select: { id: true, imageUrl: true, order: true } },
+ dealTags: { include: { tag: { select: { id: true, slug: true, name: true } } } },
+ votes: { select: { userId: true, voteType: true } },
+ savedBy: { select: { userId: true, createdAt: true } },
+ comments: {
+ orderBy: { createdAt: "desc" },
+ include: {
+ user: { select: { id: true, username: true, avatarUrl: true } },
+ likes: { select: { userId: true } },
+ },
+ },
+ aiReview: {
+ select: {
+ bestCategoryId: true,
+ tags: true,
+ needsReview: true,
+ hasIssue: true,
+ issueType: true,
+ issueReason: true,
+ },
+ },
+ },
+ }
+ )
+
+ if (!deal) return null
+ if (deal.user) {
+ await setUserPublicInRedis(deal.user, { ttlSeconds })
+ }
+ const payload = mapDealToRedisJson(deal)
+ const redis = createRedisClient()
+ try {
+ const key = `${DEAL_KEY_PREFIX}${deal.id}`
+ const pipeline = redis.pipeline()
+ pipeline.call("JSON.SET", key, "$", JSON.stringify(payload))
+ if (ttlSeconds) {
+ pipeline.expire(key, Number(ttlSeconds))
+ }
+ if (Array.isArray(payload.comments) && payload.comments.length) {
+ payload.comments.forEach((comment) => {
+ pipeline.hset(COMMENT_LOOKUP_KEY, String(comment.id), String(deal.id))
+ pipeline.sadd(COMMENT_IDS_KEY, String(comment.id))
+ })
+ }
+ await pipeline.exec()
+ await cacheVotesAndAnalytics(redis, deal.id, payload, { ttlSeconds })
+ } finally {}
+ if (deal.user) {
+ await ensureUserMinTtl(deal.user.id, { minSeconds: ttlSeconds })
+ }
+ return deal.user ? { ...payload, user: deal.user } : payload
+}
+
+async function getDealIdByCommentId(commentId) {
+ const redis = createRedisClient()
+ try {
+ const raw = await redis.hget(COMMENT_LOOKUP_KEY, String(commentId))
+ if (!raw) {
+ await recordCacheMiss({ key: `${COMMENT_LOOKUP_KEY}:${commentId}`, label: "comment-lookup" })
+ }
+ return raw ? Number(raw) : null
+ } finally {}
+}
+
+async function getOrCacheDeal(dealId, { ttlSeconds = 1800 } = {}) {
+ const cached = await getDealFromRedis(dealId)
+ if (cached) {
+ await ensureMinDealTtl(dealId, { minSeconds: ttlSeconds })
+ return cached
+ }
+ return cacheDealFromDb(dealId, { ttlSeconds })
+}
+
+async function getOrCacheDealForModeration(dealId, { ttlSeconds = 1800 } = {}) {
+ const cached = await getDealFromRedis(dealId)
+ if (cached) return { deal: cached, fromCache: true }
+ const deal = await cacheDealFromDb(dealId, { ttlSeconds })
+ return { deal, fromCache: false }
+}
+
+async function updateDealInRedis(dealId, patch = {}, { updatedAt = new Date() } = {}) {
+ const redis = createRedisClient()
+ const key = `${DEAL_KEY_PREFIX}${dealId}`
+ const ts = toEpochMs(updatedAt)
+ const iso = toIso(updatedAt)
+
+ try {
+ const exists = await redis.call("JSON.GET", key)
+ if (!exists) return null
+
+ const pipeline = redis.pipeline()
+ Object.entries(patch || {}).forEach(([field, value]) => {
+ if (value === undefined) return
+ pipeline.call("JSON.SET", key, `$.${field}`, JSON.stringify(value))
+ })
+ if (iso) pipeline.call("JSON.SET", key, "$.updatedAt", JSON.stringify(iso))
+ if (ts != null) pipeline.call("JSON.SET", key, "$.updatedAtTs", JSON.stringify(ts))
+ await pipeline.exec()
+
+ const raw = await redis.call("JSON.GET", key)
+ return raw ? JSON.parse(raw) : null
+ } finally {}
+}
+
+async function setDealInRedis(
+ dealId,
+ payload,
+ { ttlSeconds = 31 * 24 * 60 * 60, skipAnalyticsInit = false } = {}
+) {
+ if (!dealId || !payload) return null
+ const redis = createRedisClient()
+ const key = `${DEAL_KEY_PREFIX}${dealId}`
+ try {
+ const pipeline = redis.pipeline()
+ pipeline.call("JSON.SET", key, "$", JSON.stringify(payload))
+ if (ttlSeconds) {
+ pipeline.expire(key, Number(ttlSeconds))
+ }
+ if (Array.isArray(payload.comments) && payload.comments.length) {
+ payload.comments.forEach((comment) => {
+ pipeline.hset(COMMENT_LOOKUP_KEY, String(comment.id), String(dealId))
+ pipeline.sadd(COMMENT_IDS_KEY, String(comment.id))
+ })
+ }
+ await pipeline.exec()
+ await cacheVotesAndAnalytics(redis, dealId, payload, {
+ ttlSeconds,
+ skipDbEnsure: skipAnalyticsInit,
+ })
+ return payload
+ } finally {}
+}
+
+module.exports = {
+ getDealFromRedis,
+ cacheDealFromDb,
+ getOrCacheDeal,
+ getDealIdByCommentId,
+ getOrCacheDealForModeration,
+ updateDealInRedis,
+ setDealInRedis,
+ ensureMinDealTtl,
+ updateDealSavesInRedis,
+}
diff --git a/services/redis/dealId.service.js b/services/redis/dealId.service.js
new file mode 100644
index 0000000..cbe29cf
--- /dev/null
+++ b/services/redis/dealId.service.js
@@ -0,0 +1,18 @@
+const prisma = require("../../db/client")
+const { ensureCounterAtLeast, nextId } = require("./idGenerator.service")
+const DEAL_ID_KEY = "ids:deal"
+
+async function ensureDealIdCounter() {
+ const latest = await prisma.deal.findFirst({
+ select: { id: true },
+ orderBy: { id: "desc" },
+ })
+ const maxId = latest?.id ?? 0
+ await ensureCounterAtLeast(DEAL_ID_KEY, maxId)
+}
+
+async function generateDealId() {
+ return nextId(DEAL_ID_KEY)
+}
+
+module.exports = { ensureDealIdCounter, generateDealId }
diff --git a/services/redis/dealIndexing.service.js b/services/redis/dealIndexing.service.js
new file mode 100644
index 0000000..888b226
--- /dev/null
+++ b/services/redis/dealIndexing.service.js
@@ -0,0 +1,394 @@
+const dealDB = require("../../db/deal.db")
+const dealAnalyticsDb = require("../../db/dealAnalytics.db")
+const categoryDB = require("../../db/category.db")
+const { findSellers } = require("../../db/seller.db")
+const { getRedisClient } = require("./client")
+const { setUsersPublicInRedis } = require("./userPublicCache.service")
+const { setBadgesInRedis } = require("./badgeCache.service")
+const badgeDb = require("../../db/badge.db")
+
+const 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 SELLERS_KEY = "data:sellers"
+const SELLER_DOMAINS_KEY = "data:sellerdomains"
+const CATEGORIES_KEY = "data:categories"
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+function toIso(value) {
+ return value instanceof Date ? value.toISOString() : value ?? null
+}
+
+function toEpochMs(value) {
+ if (value instanceof Date) return value.getTime()
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? null : date.getTime()
+}
+
+function mapDealToRedisJson(deal) {
+ const tags =
+ Array.isArray(deal.dealTags) && deal.dealTags.length
+ ? deal.dealTags
+ .map((dt) => dt?.tag)
+ .filter(Boolean)
+ .map((tag) => ({
+ id: tag.id,
+ slug: tag.slug,
+ name: tag.name,
+ }))
+ : []
+
+ const 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) => {
+ if (!comment.parentId) return
+ if (comment.deletedAt) return
+ repliesCountByParent.set(
+ comment.parentId,
+ (repliesCountByParent.get(comment.parentId) || 0) + 1
+ )
+ })
+
+ const comments = commentsRaw.length
+ ? commentsRaw.map((comment) => ({
+ id: comment.id,
+ dealId: comment.dealId,
+ text: comment.text,
+ userId: comment.userId,
+ createdAt: toIso(comment.createdAt),
+ parentId: comment.parentId ?? null,
+ likeCount: Number.isFinite(comment.likeCount) ? comment.likeCount : 0,
+ repliesCount: repliesCountByParent.get(comment.id) || 0,
+ deletedAt: toIso(comment.deletedAt),
+ user: comment.user
+ ? {
+ id: comment.user.id,
+ username: comment.user.username,
+ avatarUrl: comment.user.avatarUrl ?? null,
+ }
+ : null,
+ likes: Array.isArray(comment.likes)
+ ? comment.likes.map((like) => ({ userId: like.userId }))
+ : [],
+ }))
+ : []
+
+ 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,
+ description: deal.description ?? null,
+ url: deal.url ?? null,
+ price: deal.price ?? null,
+ originalPrice: deal.originalPrice ?? null,
+ shippingPrice: deal.shippingPrice ?? null,
+ percentOff: deal.percentOff ?? null,
+ couponCode: deal.couponCode ?? null,
+ hasCouponCode: deal.couponCode ? 1 : 0,
+ location: deal.location ?? null,
+ discountType: deal.discountType ?? null,
+ discountValue: deal.discountValue ?? null,
+ maxNotifiedMilestone: Number.isFinite(deal.maxNotifiedMilestone)
+ ? deal.maxNotifiedMilestone
+ : 0,
+ userId: deal.userId ?? null,
+ score: deal.score ?? 0,
+ commentCount: deal.commentCount ?? 0,
+ status: deal.status ?? null,
+ saletype: deal.saletype ?? null,
+ affiliateType: deal.affiliateType ?? null,
+ sellerId: deal.sellerId ?? null,
+ customSeller: deal.customSeller ?? null,
+ categoryId: deal.categoryId ?? null,
+ createdAt: toIso(deal.createdAt),
+ updatedAt: toIso(deal.updatedAt),
+ createdAtTs: toEpochMs(deal.createdAt),
+ updatedAtTs: toEpochMs(deal.updatedAt),
+ images: Array.isArray(deal.images)
+ ? deal.images.map((img) => ({
+ id: img.id,
+ imageUrl: img.imageUrl,
+ order: img.order,
+ }))
+ : [],
+ tags,
+ votes,
+ savedBy,
+ comments,
+ aiReview: deal.aiReview
+ ? {
+ bestCategoryId: deal.aiReview.bestCategoryId,
+ tags: Array.isArray(deal.aiReview.tags) ? deal.aiReview.tags : [],
+ needsReview: deal.aiReview.needsReview,
+ hasIssue: deal.aiReview.hasIssue,
+ issueType: deal.aiReview.issueType,
+ issueReason: deal.aiReview.issueReason ?? null,
+ }
+ : null,
+ }
+}
+
+async function seedRecentDealsToRedis({ days = 30, ttlDays = 31, batchSize = 200 } = {}) {
+ const redis = createRedisClient()
+ const cutoff = new Date(Date.now() - Number(days) * 24 * 60 * 60 * 1000)
+ const ttlWindowMs = Math.max(1, Number(ttlDays)) * 24 * 60 * 60 * 1000
+
+ try {
+ const deals = await dealDB.findDeals(
+ { createdAt: { gte: cutoff } },
+ {
+ orderBy: { createdAt: "desc" },
+ include: {
+ user: {
+ select: {
+ id: true,
+ username: true,
+ avatarUrl: true,
+ userBadges: {
+ orderBy: { earnedAt: "desc" },
+ select: {
+ earnedAt: true,
+ badge: { select: { id: true, name: true, iconUrl: true, description: true } },
+ },
+ },
+ },
+ },
+ images: { orderBy: { order: "asc" }, select: { id: true, imageUrl: true, order: true } },
+ dealTags: { include: { tag: { select: { id: true, slug: true, name: true } } } },
+ votes: { select: { userId: true, voteType: true } },
+ savedBy: { select: { userId: true, createdAt: true } },
+ comments: {
+ orderBy: { createdAt: "desc" },
+ include: {
+ user: { select: { id: true, username: true, avatarUrl: true } },
+ likes: { select: { userId: true } },
+ },
+ },
+ aiReview: {
+ select: {
+ bestCategoryId: true,
+ tags: true,
+ needsReview: true,
+ hasIssue: true,
+ issueType: true,
+ issueReason: true,
+ },
+ },
+ },
+ }
+ )
+
+ const dealIds = deals.map((deal) => deal.id)
+ await dealAnalyticsDb.ensureTotalsForDealIds(dealIds)
+ const totals = await dealAnalyticsDb.getTotalsByDealIds(dealIds)
+ const totalsById = new Map(
+ (Array.isArray(totals) ? totals : []).map((t) => [
+ t.dealId,
+ {
+ impressions: Number(t.impressions) || 0,
+ views: Number(t.views) || 0,
+ clicks: Number(t.clicks) || 0,
+ },
+ ])
+ )
+
+ const userTtlById = {}
+ const users = []
+ const seenUsers = new Set()
+ deals.forEach((deal) => {
+ const user = deal?.user
+ if (user && user.id && !seenUsers.has(user.id)) {
+ users.push(user)
+ seenUsers.add(user.id)
+ }
+ const createdAt = deal?.createdAt instanceof Date ? deal.createdAt : new Date(deal?.createdAt)
+ const ageMs = Number.isNaN(createdAt?.getTime()) ? 0 : Date.now() - createdAt.getTime()
+ const ttlMs = Math.max(1, ttlWindowMs - Math.max(0, ageMs))
+ const ttlSeconds = Math.ceil(ttlMs / 1000)
+ if (user?.id) {
+ userTtlById[user.id] = Math.max(userTtlById[user.id] || 0, ttlSeconds)
+ }
+ })
+
+ let created = 0
+ for (let i = 0; i < deals.length; i += batchSize) {
+ const chunk = deals.slice(i, i + batchSize)
+ const pipeline = redis.pipeline()
+ const setCommands = []
+ let cmdIndex = 0
+
+ for (const deal of chunk) {
+ try {
+ const key = `${DEAL_KEY_PREFIX}${deal.id}`
+ const payload = JSON.stringify(mapDealToRedisJson(deal))
+ pipeline.call("JSON.SET", key, "$", payload, "NX")
+ setCommands.push({ deal, index: cmdIndex })
+ cmdIndex += 1
+ const totals = totalsById.get(deal.id) || { impressions: 0, views: 0, clicks: 0 }
+ pipeline.hset(
+ `${DEAL_ANALYTICS_TOTAL_PREFIX}${deal.id}`,
+ "impressions",
+ String(totals.impressions || 0),
+ "views",
+ String(totals.views || 0),
+ "clicks",
+ String(totals.clicks || 0)
+ )
+ cmdIndex += 1
+ if (Array.isArray(deal.comments) && deal.comments.length) {
+ deal.comments.forEach((comment) => {
+ pipeline.hset(COMMENT_LOOKUP_KEY, String(comment.id), String(deal.id))
+ pipeline.sadd(COMMENT_IDS_KEY, String(comment.id))
+ cmdIndex += 1
+ })
+ }
+ 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)
+ }
+ }
+
+ const results = await pipeline.exec()
+
+ for (const entry of setCommands) {
+ const deal = entry.deal
+ const createdAt = deal?.createdAt instanceof Date ? deal.createdAt : new Date(deal?.createdAt)
+ const ageMs = Number.isNaN(createdAt?.getTime()) ? 0 : Date.now() - createdAt.getTime()
+ const ttlMs = Math.max(1, ttlWindowMs - Math.max(0, ageMs))
+ const ttlSeconds = Math.ceil(ttlMs / 1000)
+
+ const dealKey = `${DEAL_KEY_PREFIX}${deal.id}`
+ const 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)
+ }
+ if (results?.[entry.index]?.[1] === "OK") {
+ created += 1
+ }
+ }
+ }
+
+ if (users.length) {
+ await setUsersPublicInRedis(users, { ttlSecondsById: userTtlById })
+ }
+
+ console.log(`✅ Redis seeded deals: ${created} added (last ${days} days)`)
+ } finally {}
+}
+
+async function seedSellersToRedis(redis, sellers = []) {
+ if (!sellers.length) return 0
+ const pipeline = redis.pipeline()
+ sellers.forEach((seller) => {
+ pipeline.hset(
+ SELLERS_KEY,
+ String(seller.id),
+ JSON.stringify({
+ id: seller.id,
+ name: seller.name,
+ url: seller.url ?? null,
+ sellerLogo: seller.sellerLogo ?? null,
+ isActive: Boolean(seller.isActive),
+ })
+ )
+ })
+ await pipeline.exec()
+ return sellers.length
+}
+
+async function seedSellerDomainsToRedis(redis, sellers = []) {
+ if (!sellers.length) return 0
+ const pipeline = redis.pipeline()
+ sellers.forEach((seller) => {
+ const domains = Array.isArray(seller.domains) ? seller.domains : []
+ domains.forEach((entry) => {
+ if (!entry?.domain) return
+ pipeline.hset(SELLER_DOMAINS_KEY, String(entry.domain).toLowerCase(), String(seller.id))
+ })
+ })
+ await pipeline.exec()
+ return sellers.length
+}
+
+async function seedCategoriesToRedis(redis, categories = []) {
+ if (!categories.length) return 0
+ const pipeline = redis.pipeline()
+ categories.forEach((cat) => {
+ pipeline.hset(
+ CATEGORIES_KEY,
+ String(cat.id),
+ JSON.stringify({
+ id: cat.id,
+ name: cat.name,
+ slug: cat.slug,
+ parentId: cat.parentId ?? null,
+ isActive: cat.isActive !== undefined ? Boolean(cat.isActive) : true,
+ description: cat.description ?? "",
+ })
+ )
+ })
+ await pipeline.exec()
+ return categories.length
+}
+
+async function seedReferenceDataToRedis() {
+ const [sellers, categories, badges] = await Promise.all([
+ findSellers({}, { include: { domains: { select: { domain: true } } } }),
+ categoryDB.listCategories({ select: { id: true, name: true, slug: true, parentId: true, isActive: true, description: true } }),
+ badgeDb.listBadges(),
+ ])
+
+ const redis = createRedisClient()
+ try {
+ await seedSellersToRedis(redis, sellers)
+ await seedSellerDomainsToRedis(redis, sellers)
+ await seedCategoriesToRedis(redis, categories)
+ if (badges.length) await setBadgesInRedis(badges)
+ console.log(
+ `✅ Redis seeded reference data: sellers=${sellers.length} categories=${categories.length} badges=${badges.length}`
+ )
+ } finally {}
+}
+
+module.exports = { seedRecentDealsToRedis, seedReferenceDataToRedis, mapDealToRedisJson }
+
diff --git a/services/redis/dealSearch.service.js b/services/redis/dealSearch.service.js
new file mode 100644
index 0000000..931c034
--- /dev/null
+++ b/services/redis/dealSearch.service.js
@@ -0,0 +1,277 @@
+const { getRedisClient } = require("./client")
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+function normalizeIds(ids = []) {
+ return Array.from(
+ new Set(
+ (Array.isArray(ids) ? ids : [])
+ .map((id) => Number(id))
+ .filter((id) => Number.isInteger(id) && id > 0)
+ )
+ )
+}
+
+function buildTagFilter(field, values = []) {
+ const list = (Array.isArray(values) ? values : [])
+ .map((v) => String(v).trim())
+ .filter(Boolean)
+ if (!list.length) return null
+ return `@${field}:{${list.join("|")}}`
+}
+
+function buildSaleTypeQuery(values = []) {
+ const list = (Array.isArray(values) ? values : [])
+ .map((v) => String(v).trim().toUpperCase())
+ .filter(Boolean)
+ if (!list.length) return null
+
+ const hasCode = list.includes("CODE")
+ const others = list.filter((v) => v !== "CODE")
+
+ if (!hasCode) {
+ return `@saletype:{${list.join("|")}}`
+ }
+
+ const codePart = "(@saletype:{CODE} @hasCouponCode:[1 1])"
+ if (!others.length) return codePart
+ const otherPart = `@saletype:{${others.join("|")}}`
+ return `(${codePart} | ${otherPart})`
+}
+
+function buildNumericRange(field, min, max) {
+ if (min == null && max == null) return null
+ const lower = min == null ? "-inf" : String(min)
+ const upper = max == null ? "+inf" : String(max)
+ return `@${field}:[${lower} ${upper}]`
+}
+
+function buildNumericOrList(field, ids = []) {
+ const list = normalizeIds(ids)
+ if (!list.length) return null
+ if (list.length === 1) return `@${field}:[${list[0]} ${list[0]}]`
+ if (list.length <= 6) {
+ return `(${list.map((id) => `@${field}:[${id} ${id}]`).join("|")})`
+ }
+
+ // compress into contiguous ranges to shorten query
+ const sorted = [...list].sort((a, b) => a - b)
+ const ranges = []
+ let start = sorted[0]
+ let prev = sorted[0]
+ for (let i = 1; i < sorted.length; i += 1) {
+ const current = sorted[i]
+ if (current === prev + 1) {
+ prev = current
+ continue
+ }
+ ranges.push([start, prev])
+ start = current
+ prev = current
+ }
+ ranges.push([start, prev])
+
+ if (ranges.length === 1) {
+ return `@${field}:[${ranges[0][0]} ${ranges[0][1]}]`
+ }
+ return `(${ranges.map((r) => `@${field}:[${r[0]} ${r[1]}]`).join("|")})`
+}
+
+function buildDealSearchQuery({
+ statuses,
+ categoryIds,
+ sellerIds,
+ saleTypes,
+ minPrice,
+ maxPrice,
+ minScore,
+ maxScore,
+} = {}) {
+ const parts = []
+ const statusFilter = buildTagFilter("status", statuses)
+ if (statusFilter) parts.push(statusFilter)
+
+ const saleTypeFilter = buildSaleTypeQuery(saleTypes)
+ if (saleTypeFilter) parts.push(saleTypeFilter)
+
+ const categoryFilter = buildNumericOrList("categoryId", categoryIds)
+ if (categoryFilter) parts.push(categoryFilter)
+
+ const sellerFilter = buildNumericOrList("sellerId", sellerIds)
+ if (sellerFilter) parts.push(sellerFilter)
+
+ const priceFilter = buildNumericRange("price", minPrice, maxPrice)
+ if (priceFilter) parts.push(priceFilter)
+
+ const scoreFilter = buildNumericRange("score", minScore, maxScore)
+ if (scoreFilter) parts.push(scoreFilter)
+
+ return parts.length ? parts.join(" ") : "*"
+}
+
+function escapeRedisSearchText(input = "") {
+ return String(input)
+ .replace(/\\/g, "\\\\")
+ .replace(/["'@\\-]/g, "\\$&")
+ .replace(/[{}()[\]|<>~*?:]/g, "\\$&")
+}
+
+function buildTextSearchQuery(term) {
+ const trimmed = String(term || "").trim()
+ if (!trimmed) return null
+ const tokens = trimmed.split(/\s+/).filter(Boolean).map(escapeRedisSearchText)
+ if (!tokens.length) return null
+ const query = tokens.join(" ")
+ return `(@title:(${query}) | @description:(${query}))`
+}
+
+function buildPrefixTextQuery(term) {
+ const trimmed = String(term || "").trim()
+ if (!trimmed) return null
+ const tokens = trimmed
+ .split(/\s+/)
+ .filter(Boolean)
+ .map(escapeRedisSearchText)
+ .map((token) => `${token}*`)
+ if (!tokens.length) return null
+ const query = tokens.join(" ")
+ return `(@title:(${query}) | @description:(${query}))`
+}
+
+function buildFuzzyTextQuery(term) {
+ const trimmed = String(term || "").trim()
+ if (!trimmed) return null
+ const tokens = trimmed
+ .split(/\s+/)
+ .filter(Boolean)
+ .map(escapeRedisSearchText)
+ .map((token) => `%${token}%`)
+ if (!tokens.length) return null
+ const query = tokens.join(" ")
+ return `(@title:(${query}) | @description:(${query}))`
+}
+
+function buildTitlePrefixQuery(term) {
+ const trimmed = String(term || "").trim()
+ if (!trimmed) return null
+ const tokens = trimmed.split(/\s+/).filter(Boolean).map(escapeRedisSearchText)
+ if (!tokens.length) return null
+ const titleQuery = tokens.map((t) => `${t}*`).join(" ")
+ return `@status:{ACTIVE} @title:(${titleQuery})`
+}
+
+function resolveSort({ sortBy, sortDir } = {}) {
+ const field = String(sortBy || "createdAtTs").toLowerCase()
+ const dir = String(sortDir || "desc").toUpperCase() === "ASC" ? "ASC" : "DESC"
+ if (field === "score") return { field: "score", dir }
+ if (field === "price") return { field: "price", dir }
+ if (field === "createdat" || field === "createdatts") return { field: "createdAtTs", dir }
+ return { field: "createdAtTs", dir }
+}
+
+async function aggregatePriceRange(query) {
+ const redis = createRedisClient()
+ try {
+ const results = await redis.call(
+ "FT.AGGREGATE",
+ "idx:data:deals",
+ query || "*",
+ "GROUPBY",
+ "0",
+ "REDUCE",
+ "MIN",
+ "1",
+ "@price",
+ "AS",
+ "minPrice",
+ "REDUCE",
+ "MAX",
+ "1",
+ "@price",
+ "AS",
+ "maxPrice",
+ "DIALECT",
+ "3"
+ )
+
+ if (!Array.isArray(results) || results.length < 2) {
+ return { minPrice: null, maxPrice: null }
+ }
+
+ const row = results[1]
+ if (!Array.isArray(row)) return { minPrice: null, maxPrice: null }
+ const data = {}
+ for (let i = 0; i < row.length; i += 2) {
+ data[row[i]] = row[i + 1]
+ }
+ const min = data.minPrice != null ? Number(data.minPrice) : null
+ const max = data.maxPrice != null ? Number(data.maxPrice) : null
+ return {
+ minPrice: Number.isFinite(min) ? min : null,
+ maxPrice: Number.isFinite(max) ? max : null,
+ }
+ } finally {}
+}
+
+async function searchDeals({
+ query,
+ page = 1,
+ limit = 20,
+ sortBy = "createdAtTs",
+ sortDir = "DESC",
+ includeMinMax = false,
+} = {}) {
+ const normalizedPage = Math.max(1, Number(page) || 1)
+ const normalizedLimit = Math.max(1, Math.min(Number(limit) || 20, 50))
+ const offset = (normalizedPage - 1) * normalizedLimit
+ const sort = resolveSort({ sortBy, sortDir })
+
+ const redis = createRedisClient()
+ try {
+ const range = includeMinMax ? await aggregatePriceRange(query) : { minPrice: null, maxPrice: null }
+ const results = await redis.call(
+ "FT.SEARCH",
+ "idx:data:deals",
+ query || "*",
+ "SORTBY",
+ sort.field,
+ sort.dir,
+ "LIMIT",
+ String(offset),
+ String(normalizedLimit),
+ "RETURN",
+ "0",
+ "DIALECT",
+ "3"
+ )
+
+ const total = Number(results?.[0] || 0)
+ const ids = Array.isArray(results) ? results.slice(1) : []
+ const dealIds = ids
+ .map((key) => {
+ const parts = String(key).split(":")
+ return Number(parts[2])
+ })
+ .filter((id) => Number.isInteger(id) && id > 0)
+
+ return {
+ total,
+ page: normalizedPage,
+ totalPages: Math.ceil(total / normalizedLimit),
+ dealIds,
+ minPrice: range.minPrice,
+ maxPrice: range.maxPrice,
+ }
+ } finally {}
+}
+
+module.exports = {
+ buildDealSearchQuery,
+ searchDeals,
+ buildTitlePrefixQuery,
+ buildTextSearchQuery,
+ buildPrefixTextQuery,
+ buildFuzzyTextQuery,
+}
diff --git a/services/redis/dealVote.service.js b/services/redis/dealVote.service.js
new file mode 100644
index 0000000..9741c62
--- /dev/null
+++ b/services/redis/dealVote.service.js
@@ -0,0 +1,97 @@
+const { getRedisClient } = require("./client")
+const { ensureMinDealTtl } = require("./dealCache.service")
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+const DEAL_VOTE_HASH_PREFIX = "data:deals:votes:"
+
+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 raw = await redis.call("JSON.GET", key)
+ if (!raw) return { updated: false, delta: 0, score: null }
+
+ const deal = JSON.parse(raw)
+ const currentScore = Number.isFinite(deal?.score) ? Number(deal.score) : 0
+ const maxNotifiedMilestone = Number.isFinite(deal?.maxNotifiedMilestone)
+ ? 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 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)
+ }
+ await ensureMinDealTtl(dealId, { minSeconds: 15 * 60 })
+
+ return { updated: true, delta, score: nextScore, maxNotifiedMilestone, dealUserId }
+ } finally {}
+}
+
+async function getDealVoteFromRedis(dealId, userId) {
+ const id = Number(dealId)
+ const uid = Number(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
+ } finally {}
+}
+
+async function getMyVotesForDeals(dealIds = [], userId) {
+ const uid = Number(userId)
+ if (!Number.isInteger(uid)) return new Map()
+ const ids = (Array.isArray(dealIds) ? dealIds : [])
+ .map((id) => Number(id))
+ .filter((id) => Number.isInteger(id) && id > 0)
+ if (!ids.length) return new Map()
+
+ 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 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
+ } finally {}
+}
+
+module.exports = {
+ updateDealVoteInRedis,
+ getDealVoteFromRedis,
+ getMyVotesForDeals,
+}
diff --git a/services/redis/hotDealList.service.js b/services/redis/hotDealList.service.js
new file mode 100644
index 0000000..a8da543
--- /dev/null
+++ b/services/redis/hotDealList.service.js
@@ -0,0 +1,205 @@
+const { getRedisClient } = require("./client")
+const { getSellersByIds, getSellerById } = require("./sellerCache.service")
+const { getUsersPublicByIds, setUsersPublicInRedis } = require("./userPublicCache.service")
+const userDB = require("../../db/user.db")
+const { getMyVotesForDeals } = require("./dealVote.service")
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+async function getHotDealListId(redis, hotListId) {
+ if (hotListId) return String(hotListId)
+ const latest = await redis.get("lists:hot:latest")
+ return latest ? String(latest) : null
+}
+
+async function getListId(redis, listKeyPrefix, listId) {
+ if (listId) return String(listId)
+ const latest = await redis.get(`${listKeyPrefix}:latest`)
+ return latest ? String(latest) : null
+}
+
+async function getHotDealIds({ hotListId } = {}) {
+ const redis = createRedisClient()
+
+ try {
+ const listId = await getHotDealListId(redis, hotListId)
+ if (!listId) return { hotListId: null, dealIds: [] }
+
+ const key = `lists:hot:${listId}`
+ const raw = await redis.call("JSON.GET", key, "$.dealIds")
+ if (!raw) return { hotListId: listId, dealIds: [] }
+
+ const parsed = JSON.parse(raw)
+ const dealIds = Array.isArray(parsed) ? parsed[0] : []
+
+ return {
+ hotListId: listId,
+ dealIds: Array.isArray(dealIds) ? dealIds.map((id) => Number(id)) : [],
+ }
+ } finally {}
+}
+
+async function getDealsByIdsFromRedis(ids = [], viewerId = null) {
+ if (!ids.length) return []
+ const redis = createRedisClient()
+
+ try {
+ const pipeline = redis.pipeline()
+ ids.forEach((id) => {
+ pipeline.call("JSON.GET", `data:deals:${id}`)
+ })
+
+ const results = await pipeline.exec()
+ const deals = []
+
+ results.forEach(([, raw], idx) => {
+ if (!raw) return
+ try {
+ const deal = JSON.parse(raw)
+ if (deal && deal.id) {
+ deals.push({ deal, index: idx })
+ }
+ } catch {
+ return
+ }
+ })
+
+ // Preserve original order (ids array order)
+ const ordered = deals
+ .sort((a, b) => a.index - b.index)
+ .map((item) => item.deal)
+
+ const sellerIds = ordered
+ .map((deal) => Number(deal?.sellerId))
+ .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 userIds = ordered
+ .map((deal) => Number(deal?.userId))
+ .filter((id) => Number.isInteger(id) && id > 0)
+ const userMap = userIds.length ? await getUsersPublicByIds(userIds) : new Map()
+ const missingUserIds = Array.from(
+ new Set(userIds.filter((id) => !userMap.has(id)))
+ )
+
+ if (missingUserIds.length) {
+ const missingSet = new Set(missingUserIds)
+ const ttlPipeline = redis.pipeline()
+ ordered.forEach((deal) => {
+ ttlPipeline.ttl(`data:deals:${deal.id}`)
+ })
+ const ttlResults = await ttlPipeline.exec()
+ const ttlByDealId = new Map()
+ ttlResults.forEach(([, ttl], idx) => {
+ const dealId = ordered[idx]?.id
+ if (dealId) ttlByDealId.set(Number(dealId), Number(ttl))
+ })
+
+ const users = await userDB.findUsersByIds(missingUserIds, {
+ select: {
+ id: true,
+ username: true,
+ avatarUrl: true,
+ userBadges: {
+ orderBy: { earnedAt: "desc" },
+ select: {
+ earnedAt: true,
+ badge: { select: { id: true, name: true, iconUrl: true, description: true } },
+ },
+ },
+ },
+ })
+
+ const ttlByUserId = {}
+ ordered.forEach((deal) => {
+ const uid = Number(deal?.userId)
+ if (!missingSet.has(uid)) return
+ const ttl = ttlByDealId.get(Number(deal?.id))
+ if (ttl == null || ttl <= 0) return
+ ttlByUserId[uid] = Math.max(ttlByUserId[uid] || 0, ttl)
+ })
+
+ if (users.length) {
+ await setUsersPublicInRedis(users, { ttlSecondsById: ttlByUserId })
+ users.forEach((u) => userMap.set(u.id, u))
+ }
+ }
+
+ const enriched = ordered.map((deal) => {
+ let next = deal
+ if (!next?.user && next?.userId) {
+ const user = userMap.get(Number(next.userId)) || null
+ if (user) next = { ...next, user }
+ }
+ if (!next?.seller && next?.sellerId) {
+ const seller = sellerMap.get(Number(next.sellerId)) || 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
+ return { ...next, myVote, isSaved }
+ })
+
+ return enriched
+ } finally {}
+}
+
+async function getDealByIdFromRedis(id, viewerId = null) {
+ const redis = createRedisClient()
+ try {
+ const raw = await redis.call("JSON.GET", `data:deals:${id}`)
+ if (!raw) return null
+ let deal = JSON.parse(raw)
+ if (deal?.sellerId && !deal?.seller) {
+ const seller = await getSellerById(Number(deal.sellerId))
+ if (seller) deal = { ...deal, seller }
+ }
+ 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
+ deal = { ...deal, myVote: Number(voteMap.get(Number(deal.id)) ?? 0), isSaved }
+ }
+ return deal
+ } finally {}
+}
+
+async function getHotRangeDealIds({ range, listId } = {}) {
+ const redis = createRedisClient()
+
+ try {
+ const prefix =
+ range === "day"
+ ? "lists:hot_day"
+ : range === "week"
+ ? "lists:hot_week"
+ : range === "month"
+ ? "lists:hot_month"
+ : null
+ if (!prefix) return { listId: null, dealIds: [] }
+
+ const resolvedId = await getListId(redis, prefix, listId)
+ if (!resolvedId) return { listId: null, dealIds: [] }
+
+ const key = `${prefix}:${resolvedId}`
+ const raw = await redis.call("JSON.GET", key, "$.dealIds")
+ if (!raw) return { listId: resolvedId, dealIds: [] }
+
+ const parsed = JSON.parse(raw)
+ const dealIds = Array.isArray(parsed) ? parsed[0] : []
+
+ return {
+ listId: resolvedId,
+ dealIds: Array.isArray(dealIds) ? dealIds.map((id) => Number(id)) : [],
+ }
+ } finally {}
+}
+
+module.exports = { getHotDealIds, getHotRangeDealIds, getDealsByIdsFromRedis, getDealByIdFromRedis }
diff --git a/services/redis/idGenerator.service.js b/services/redis/idGenerator.service.js
new file mode 100644
index 0000000..52b76d7
--- /dev/null
+++ b/services/redis/idGenerator.service.js
@@ -0,0 +1,28 @@
+const { getRedisClient } = require("./client")
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+async function ensureCounterAtLeast(key, minValue) {
+ const redis = createRedisClient()
+ try {
+ const currentRaw = await redis.get(key)
+ const current = currentRaw ? Number(currentRaw) : 0
+ if (!Number.isFinite(current) || current < minValue) {
+ await redis.set(key, String(minValue))
+ return minValue
+ }
+ return current
+ } finally {}
+}
+
+async function nextId(key) {
+ const redis = createRedisClient()
+ try {
+ const value = await redis.incr(key)
+ return Number(value)
+ } finally {}
+}
+
+module.exports = { ensureCounterAtLeast, nextId }
diff --git a/services/redis/newDealList.service.js b/services/redis/newDealList.service.js
new file mode 100644
index 0000000..c7fd5c3
--- /dev/null
+++ b/services/redis/newDealList.service.js
@@ -0,0 +1,34 @@
+const { getRedisClient } = require("./client")
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+async function getNewDealListId(redis, newListId) {
+ if (newListId) return String(newListId)
+ const latest = await redis.get("lists:new:latest")
+ return latest ? String(latest) : null
+}
+
+async function getNewDealIds({ newListId } = {}) {
+ const redis = createRedisClient()
+
+ try {
+ const listId = await getNewDealListId(redis, newListId)
+ if (!listId) return { newListId: null, dealIds: [] }
+
+ const key = `lists:new:${listId}`
+ const raw = await redis.call("JSON.GET", key, "$.dealIds")
+ if (!raw) return { newListId: listId, dealIds: [] }
+
+ const parsed = JSON.parse(raw)
+ const dealIds = Array.isArray(parsed) ? parsed[0] : []
+
+ return {
+ newListId: listId,
+ dealIds: Array.isArray(dealIds) ? dealIds.map((id) => Number(id)) : [],
+ }
+ } finally {}
+}
+
+module.exports = { getNewDealIds }
diff --git a/services/redis/notificationPubsub.service.js b/services/redis/notificationPubsub.service.js
new file mode 100644
index 0000000..11fe038
--- /dev/null
+++ b/services/redis/notificationPubsub.service.js
@@ -0,0 +1,18 @@
+const { getRedisClient } = require("./client")
+
+const NOTIFICATIONS_CHANNEL = "notifications"
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+async function publishNotification(payload) {
+ if (!payload) return 0
+ const redis = createRedisClient()
+ try {
+ const message = JSON.stringify(payload)
+ return await redis.publish(NOTIFICATIONS_CHANNEL, message)
+ } finally {}
+}
+
+module.exports = { publishNotification, NOTIFICATIONS_CHANNEL }
diff --git a/services/redis/searchIndex.service.js b/services/redis/searchIndex.service.js
new file mode 100644
index 0000000..c7397a5
--- /dev/null
+++ b/services/redis/searchIndex.service.js
@@ -0,0 +1,218 @@
+const { getRedisClient } = require("./client")
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+async function ensureDealSearchIndex() {
+ const redis = createRedisClient()
+
+ try {
+ await redis.call(
+ "FT.CREATE",
+ "idx:data:deals",
+ "ON",
+ "JSON",
+ "PREFIX",
+ "1",
+ "data:deals:",
+ "SCHEMA",
+ "$.id",
+ "AS",
+ "id",
+ "NUMERIC",
+ "SORTABLE",
+ "$.title",
+ "AS",
+ "title",
+ "TEXT",
+ "WEIGHT",
+ "5.0",
+ "$.description",
+ "AS",
+ "description",
+ "TEXT",
+ "WEIGHT",
+ "2.0",
+ "$.url",
+ "AS",
+ "url",
+ "TEXT",
+ "$.price",
+ "AS",
+ "price",
+ "NUMERIC",
+ "SORTABLE",
+ "$.originalPrice",
+ "AS",
+ "originalPrice",
+ "NUMERIC",
+ "$.shippingPrice",
+ "AS",
+ "shippingPrice",
+ "NUMERIC",
+ "$.percentOff",
+ "AS",
+ "percentOff",
+ "NUMERIC",
+ "$.couponCode",
+ "AS",
+ "couponCode",
+ "TEXT",
+ "$.location",
+ "AS",
+ "location",
+ "TEXT",
+ "$.discountType",
+ "AS",
+ "discountType",
+ "TAG",
+ "$.discountValue",
+ "AS",
+ "discountValue",
+ "NUMERIC",
+ "$.userId",
+ "AS",
+ "userId",
+ "NUMERIC",
+ "SORTABLE",
+ "$.score",
+ "AS",
+ "score",
+ "NUMERIC",
+ "SORTABLE",
+ "$.commentCount",
+ "AS",
+ "commentCount",
+ "NUMERIC",
+ "$.status",
+ "AS",
+ "status",
+ "TAG",
+ "$.saletype",
+ "AS",
+ "saletype",
+ "TAG",
+ "$.affiliateType",
+ "AS",
+ "affiliateType",
+ "TAG",
+ "$.sellerId",
+ "AS",
+ "sellerId",
+ "NUMERIC",
+ "$.customSeller",
+ "AS",
+ "customSeller",
+ "TEXT",
+ "$.categoryId",
+ "AS",
+ "categoryId",
+ "NUMERIC",
+ "SORTABLE",
+ "$.createdAt",
+ "AS",
+ "createdAt",
+ "TEXT",
+ "$.createdAtTs",
+ "AS",
+ "createdAtTs",
+ "NUMERIC",
+ "SORTABLE",
+ "$.updatedAt",
+ "AS",
+ "updatedAt",
+ "TEXT",
+ "$.updatedAtTs",
+ "AS",
+ "updatedAtTs",
+ "NUMERIC",
+ "SORTABLE",
+ "$.seller.name",
+ "AS",
+ "sellerName",
+ "TEXT",
+ "$.seller.id",
+ "AS",
+ "sellerIdNested",
+ "NUMERIC",
+ "$.seller.url",
+ "AS",
+ "sellerUrl",
+ "TEXT",
+ "$.user.id",
+ "AS",
+ "userIdNested",
+ "NUMERIC",
+ "$.user.username",
+ "AS",
+ "username",
+ "TEXT",
+ "$.images[*].imageUrl",
+ "AS",
+ "imageUrls",
+ "TEXT",
+ "$.tags[*].slug",
+ "AS",
+ "tagSlugs",
+ "TAG"
+ )
+
+ console.log("✅ Redis search index created: idx:data: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")
+ await ensureDealIndexFields(redis)
+ } else {
+ throw err
+ }
+ } finally {}
+}
+
+async function ensureDealIndexFields(redis) {
+ const fields = [
+ [
+ "$.createdAtTs",
+ "AS",
+ "createdAtTs",
+ "NUMERIC",
+ "SORTABLE",
+ ],
+ [
+ "$.updatedAtTs",
+ "AS",
+ "updatedAtTs",
+ "NUMERIC",
+ "SORTABLE",
+ ],
+ [
+ "$.couponCode",
+ "AS",
+ "couponCode",
+ "TEXT",
+ ],
+ [
+ "$.hasCouponCode",
+ "AS",
+ "hasCouponCode",
+ "NUMERIC",
+ "SORTABLE",
+ ],
+ ]
+
+ for (const field of fields) {
+ try {
+ await redis.call("FT.ALTER", "idx:data:deals", "SCHEMA", "ADD", ...field)
+ console.log(`✅ Redis search index field added: ${field[2]}`)
+ } catch (err) {
+ const message = err?.message || ""
+ if (!message.includes("Duplicate field")) {
+ throw err
+ }
+ }
+ }
+}
+
+module.exports = { ensureDealSearchIndex }
diff --git a/services/redis/sellerCache.service.js b/services/redis/sellerCache.service.js
new file mode 100644
index 0000000..6b29fcf
--- /dev/null
+++ b/services/redis/sellerCache.service.js
@@ -0,0 +1,149 @@
+const { getRedisClient } = require("./client")
+const { recordCacheMiss } = require("./cacheMetrics.service")
+
+const SELLERS_KEY = "data:sellers"
+const SELLER_DOMAINS_KEY = "data:sellerdomains"
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+function normalizeIds(ids = []) {
+ return Array.from(
+ new Set(
+ (Array.isArray(ids) ? ids : [])
+ .map((id) => Number(id))
+ .filter((id) => Number.isInteger(id) && id > 0)
+ )
+ )
+}
+
+async function getSellerById(id) {
+ const sellerId = Number(id)
+ if (!Number.isInteger(sellerId) || sellerId <= 0) return null
+ const redis = createRedisClient()
+ try {
+ const raw = await redis.hget(SELLERS_KEY, String(sellerId))
+ if (!raw) {
+ await recordCacheMiss({ key: `${SELLERS_KEY}:${sellerId}`, label: "seller" })
+ }
+ return raw ? JSON.parse(raw) : null
+ } finally {}
+}
+
+async function getSellersByIds(ids = []) {
+ const normalized = normalizeIds(ids)
+ if (!normalized.length) return new Map()
+ const redis = createRedisClient()
+ try {
+ const raw = await redis.hmget(SELLERS_KEY, normalized.map(String))
+ const map = new Map()
+ raw.forEach((item, idx) => {
+ if (!item) return
+ try {
+ const seller = JSON.parse(item)
+ map.set(normalized[idx], seller)
+ } catch {
+ return
+ }
+ })
+ return map
+ } finally {}
+}
+
+async function getSellerIdByDomain(domain) {
+ const normalized = String(domain || "").trim().toLowerCase()
+ if (!normalized) return null
+ const redis = createRedisClient()
+ try {
+ const raw = await redis.hget(SELLER_DOMAINS_KEY, normalized)
+ if (!raw) {
+ await recordCacheMiss({ key: `${SELLER_DOMAINS_KEY}:${normalized}`, label: "seller-domain" })
+ }
+ const id = raw ? Number(raw) : null
+ return Number.isInteger(id) && id > 0 ? id : null
+ } finally {}
+}
+
+async function setSellersInRedis(sellers = []) {
+ const list = Array.isArray(sellers) ? sellers : []
+ if (!list.length) return 0
+ const redis = createRedisClient()
+ try {
+ const pipeline = redis.pipeline()
+ list.forEach((seller) => {
+ if (!seller?.id) return
+ pipeline.hset(SELLERS_KEY, String(seller.id), JSON.stringify(seller))
+ })
+ await pipeline.exec()
+ return list.length
+ } finally {}
+}
+
+async function setSellerInRedis(seller) {
+ if (!seller?.id) return false
+ await setSellersInRedis([seller])
+ return true
+}
+
+async function setSellerDomainInRedis(domain, sellerId) {
+ const normalized = String(domain || "").trim().toLowerCase()
+ const id = Number(sellerId)
+ if (!normalized || !Number.isInteger(id) || id <= 0) return false
+ const redis = createRedisClient()
+ try {
+ await redis.hset(SELLER_DOMAINS_KEY, normalized, String(id))
+ return true
+ } finally {}
+}
+
+async function removeSellerFromRedis(sellerId) {
+ const id = Number(sellerId)
+ if (!Number.isInteger(id) || id <= 0) return 0
+ const redis = createRedisClient()
+ try {
+ await redis.hdel(SELLERS_KEY, String(id))
+ return 1
+ } finally {}
+}
+
+async function removeSellerDomainFromRedis(domain) {
+ const normalized = String(domain || "").trim().toLowerCase()
+ if (!normalized) return 0
+ const redis = createRedisClient()
+ try {
+ await redis.hdel(SELLER_DOMAINS_KEY, normalized)
+ return 1
+ } finally {}
+}
+
+async function listSellersFromRedis() {
+ const redis = createRedisClient()
+ try {
+ const raw = await redis.hgetall(SELLERS_KEY)
+ const list = []
+ for (const value of Object.values(raw || {})) {
+ try {
+ const parsed = JSON.parse(value)
+ if (parsed && parsed.id) list.push(parsed)
+ } catch {
+ continue
+ }
+ }
+ return list
+ } finally {}
+}
+
+module.exports = {
+ getSellerById,
+ getSellersByIds,
+ getSellerIdByDomain,
+ setSellerInRedis,
+ setSellersInRedis,
+ setSellerDomainInRedis,
+ removeSellerFromRedis,
+ removeSellerDomainFromRedis,
+ listSellersFromRedis,
+ SELLERS_KEY,
+ SELLER_DOMAINS_KEY,
+}
diff --git a/services/redis/sellerId.service.js b/services/redis/sellerId.service.js
new file mode 100644
index 0000000..82c106d
--- /dev/null
+++ b/services/redis/sellerId.service.js
@@ -0,0 +1,18 @@
+const prisma = require("../../db/client")
+const { ensureCounterAtLeast, nextId } = require("./idGenerator.service")
+const SELLER_ID_KEY = "ids:seller"
+
+async function ensureSellerIdCounter() {
+ const latest = await prisma.seller.findFirst({
+ select: { id: true },
+ orderBy: { id: "desc" },
+ })
+ const maxId = latest?.id ?? 0
+ await ensureCounterAtLeast(SELLER_ID_KEY, maxId)
+}
+
+async function generateSellerId() {
+ return nextId(SELLER_ID_KEY)
+}
+
+module.exports = { ensureSellerIdCounter, generateSellerId }
diff --git a/services/redis/trendingDealList.service.js b/services/redis/trendingDealList.service.js
new file mode 100644
index 0000000..9e6e53c
--- /dev/null
+++ b/services/redis/trendingDealList.service.js
@@ -0,0 +1,34 @@
+const { getRedisClient } = require("./client")
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+async function getTrendingDealListId(redis, trendingListId) {
+ if (trendingListId) return String(trendingListId)
+ const latest = await redis.get("lists:trending:latest")
+ return latest ? String(latest) : null
+}
+
+async function getTrendingDealIds({ trendingListId } = {}) {
+ const redis = createRedisClient()
+
+ try {
+ const listId = await getTrendingDealListId(redis, trendingListId)
+ if (!listId) return { trendingListId: null, dealIds: [] }
+
+ const key = `lists:trending:${listId}`
+ const raw = await redis.call("JSON.GET", key, "$.dealIds")
+ if (!raw) return { trendingListId: listId, dealIds: [] }
+
+ const parsed = JSON.parse(raw)
+ const dealIds = Array.isArray(parsed) ? parsed[0] : []
+
+ return {
+ trendingListId: listId,
+ dealIds: Array.isArray(dealIds) ? dealIds.map((id) => Number(id)) : [],
+ }
+ } finally {}
+}
+
+module.exports = { getTrendingDealIds }
diff --git a/services/redis/userCache.service.js b/services/redis/userCache.service.js
new file mode 100644
index 0000000..deaaaeb
--- /dev/null
+++ b/services/redis/userCache.service.js
@@ -0,0 +1,156 @@
+const { getRedisClient } = require("./client")
+
+const USER_KEY_PREFIX = "data:users:"
+const USER_SAVED_SET_PREFIX = "data:users:saved:"
+const USER_UNSAVED_SET_PREFIX = "data:users:unsaved:"
+const DEFAULT_USER_TTL_SECONDS = 60 * 60
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+function normalizeUserId(userId) {
+ const id = Number(userId)
+ return Number.isInteger(id) && id > 0 ? id : null
+}
+
+async function ensureUserCache(userId, { ttlSeconds = DEFAULT_USER_TTL_SECONDS } = {}) {
+ const id = normalizeUserId(userId)
+ if (!id) return false
+ const redis = createRedisClient()
+ const key = `${USER_KEY_PREFIX}${id}`
+ try {
+ const exists = await redis.exists(key)
+ if (!exists) {
+ await redis.call(
+ "JSON.SET",
+ key,
+ "$",
+ JSON.stringify({ id, savedDeals: [] })
+ )
+ }
+ if (ttlSeconds) {
+ await redis.expire(key, Number(ttlSeconds))
+ }
+ return true
+ } finally {}
+}
+
+async function getUserSavedIdsFromRedis(userId) {
+ const id = normalizeUserId(userId)
+ if (!id) return []
+ const redis = createRedisClient()
+ const key = `${USER_KEY_PREFIX}${id}`
+ const setKey = `${USER_SAVED_SET_PREFIX}${id}`
+ const unsavedKey = `${USER_UNSAVED_SET_PREFIX}${id}`
+ try {
+ const [raw, setIds, unsavedIds] = await Promise.all([
+ redis.call("JSON.GET", key, "$.savedDeals"),
+ redis.smembers(setKey),
+ redis.smembers(unsavedKey),
+ ])
+ const fromJson = raw ? JSON.parse(raw) : []
+ const arr = Array.isArray(fromJson) ? (Array.isArray(fromJson[0]) ? fromJson[0] : fromJson) : []
+ const jsonIds = arr
+ .map((v) => Number(v))
+ .filter((v) => Number.isInteger(v) && v > 0)
+ const setList = (Array.isArray(setIds) ? setIds : [])
+ .map((v) => Number(v))
+ .filter((v) => Number.isInteger(v) && v > 0)
+ const unsavedList = (Array.isArray(unsavedIds) ? unsavedIds : [])
+ .map((v) => Number(v))
+ .filter((v) => Number.isInteger(v) && v > 0)
+ return {
+ jsonIds,
+ savedSet: new Set(setList),
+ unsavedSet: new Set(unsavedList),
+ }
+ } finally {}
+}
+
+async function setUserSavedDeals(userId, ids = [], { ttlSeconds = DEFAULT_USER_TTL_SECONDS } = {}) {
+ const uid = normalizeUserId(userId)
+ if (!uid) return false
+ const redis = createRedisClient()
+ const key = `${USER_KEY_PREFIX}${uid}`
+ try {
+ const list = Array.isArray(ids) ? ids : []
+ await redis.call("JSON.SET", key, "$.savedDeals", JSON.stringify(list))
+ if (ttlSeconds) {
+ await redis.expire(key, Number(ttlSeconds))
+ }
+ return true
+ } finally {}
+}
+
+async function addUserSavedDeal(userId, dealId, { ttlSeconds = DEFAULT_USER_TTL_SECONDS } = {}) {
+ const uid = normalizeUserId(userId)
+ const did = normalizeUserId(dealId)
+ if (!uid || !did) return false
+ const redis = createRedisClient()
+ const key = `${USER_KEY_PREFIX}${uid}`
+ const setKey = `${USER_SAVED_SET_PREFIX}${uid}`
+ const unsavedKey = `${USER_UNSAVED_SET_PREFIX}${uid}`
+ try {
+ await ensureUserCache(uid, { ttlSeconds })
+ const raw = await redis.call("JSON.GET", key, "$.savedDeals")
+ let list = []
+ if (raw) {
+ const parsed = JSON.parse(raw)
+ const arr = Array.isArray(parsed) ? parsed[0] : []
+ list = Array.isArray(arr) ? arr : []
+ }
+ const exists = Array.isArray(list)
+ ? list.some((v) => Number(v) === did)
+ : false
+ if (!exists) {
+ list = [did, ...list]
+ await redis.call("JSON.SET", key, "$.savedDeals", JSON.stringify(list))
+ }
+ await redis.sadd(setKey, String(did))
+ await redis.srem(unsavedKey, String(did))
+ if (ttlSeconds) {
+ await redis.expire(setKey, Number(ttlSeconds))
+ await redis.expire(unsavedKey, Number(ttlSeconds))
+ await redis.expire(key, Number(ttlSeconds))
+ }
+ return true
+ } finally {}
+}
+
+async function removeUserSavedDeal(userId, dealId, { ttlSeconds = DEFAULT_USER_TTL_SECONDS } = {}) {
+ const uid = normalizeUserId(userId)
+ const did = normalizeUserId(dealId)
+ if (!uid || !did) return false
+ const redis = createRedisClient()
+ const key = `${USER_KEY_PREFIX}${uid}`
+ const setKey = `${USER_SAVED_SET_PREFIX}${uid}`
+ const unsavedKey = `${USER_UNSAVED_SET_PREFIX}${uid}`
+ try {
+ const raw = await redis.call("JSON.GET", key, "$.savedDeals")
+ if (raw) {
+ const parsed = JSON.parse(raw)
+ const arr = Array.isArray(parsed) ? parsed[0] : []
+ const list = Array.isArray(arr)
+ ? arr.filter((v) => Number(v) !== did)
+ : []
+ await redis.call("JSON.SET", key, "$.savedDeals", JSON.stringify(list))
+ }
+ await redis.srem(setKey, String(did))
+ await redis.sadd(unsavedKey, String(did))
+ if (ttlSeconds) {
+ await redis.expire(setKey, Number(ttlSeconds))
+ await redis.expire(unsavedKey, Number(ttlSeconds))
+ await redis.expire(key, Number(ttlSeconds))
+ }
+ return true
+ } finally {}
+}
+
+module.exports = {
+ ensureUserCache,
+ getUserSavedIdsFromRedis,
+ setUserSavedDeals,
+ addUserSavedDeal,
+ removeUserSavedDeal,
+}
diff --git a/services/redis/userModerationCache.service.js b/services/redis/userModerationCache.service.js
new file mode 100644
index 0000000..beab2af
--- /dev/null
+++ b/services/redis/userModerationCache.service.js
@@ -0,0 +1,74 @@
+const { getRedisClient } = require("./client")
+const userDb = require("../../db/user.db")
+const { recordCacheMiss } = require("./cacheMetrics.service")
+
+const USER_MOD_KEY_PREFIX = "user:mod:"
+const DEFAULT_TTL_SECONDS = 60 * 60
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+function normalizeUserId(userId) {
+ const id = Number(userId)
+ return Number.isInteger(id) && id > 0 ? id : null
+}
+
+function normalizeModerationPayload(user) {
+ const id = normalizeUserId(user?.id)
+ if (!id) return null
+ return {
+ id,
+ role: user?.role ?? "USER",
+ mutedUntil: user?.mutedUntil ? new Date(user.mutedUntil).toISOString() : null,
+ suspendedUntil: user?.suspendedUntil ? new Date(user.suspendedUntil).toISOString() : null,
+ disabledAt: user?.disabledAt ? new Date(user.disabledAt).toISOString() : null,
+ }
+}
+
+async function getUserModerationFromRedis(userId) {
+ const id = normalizeUserId(userId)
+ if (!id) return null
+ const redis = createRedisClient()
+ try {
+ const raw = await redis.call("JSON.GET", `${USER_MOD_KEY_PREFIX}${id}`)
+ if (!raw) {
+ await recordCacheMiss({ key: `${USER_MOD_KEY_PREFIX}${id}`, label: "user-mod" })
+ }
+ return raw ? JSON.parse(raw) : null
+ } finally {}
+}
+
+async function setUserModerationInRedis(user, { ttlSeconds = DEFAULT_TTL_SECONDS } = {}) {
+ const payload = normalizeModerationPayload(user)
+ if (!payload) return false
+ const redis = createRedisClient()
+ const key = `${USER_MOD_KEY_PREFIX}${payload.id}`
+ try {
+ await redis.call("JSON.SET", key, "$", JSON.stringify(payload))
+ if (ttlSeconds) await redis.expire(key, Number(ttlSeconds))
+ return true
+ } finally {}
+}
+
+async function getOrCacheUserModeration(userId) {
+ const id = normalizeUserId(userId)
+ if (!id) return null
+ const cached = await getUserModerationFromRedis(id)
+ if (cached) return cached
+
+ const user = await userDb.findUser(
+ { id },
+ { select: { id: true, role: true, mutedUntil: true, suspendedUntil: true, disabledAt: true } }
+ )
+ if (!user) return null
+ await setUserModerationInRedis(user, { ttlSeconds: DEFAULT_TTL_SECONDS })
+ return normalizeModerationPayload(user)
+}
+
+module.exports = {
+ USER_MOD_KEY_PREFIX,
+ getUserModerationFromRedis,
+ setUserModerationInRedis,
+ getOrCacheUserModeration,
+}
diff --git a/services/redis/userProfileCache.service.js b/services/redis/userProfileCache.service.js
new file mode 100644
index 0000000..75f560d
--- /dev/null
+++ b/services/redis/userProfileCache.service.js
@@ -0,0 +1,48 @@
+const { getRedisClient } = require("./client")
+const { recordCacheMiss } = require("./cacheMetrics.service")
+
+const PROFILE_KEY_PREFIX = "data:profiles:user:"
+const DEFAULT_TTL_SECONDS = 60
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+function normalizeUserName(userName) {
+ const normalized = String(userName || "").trim().toLowerCase()
+ return normalized || null
+}
+
+async function getUserProfileFromRedis(userName) {
+ const keyName = normalizeUserName(userName)
+ if (!keyName) return null
+ const redis = createRedisClient()
+ try {
+ const raw = await redis.call("JSON.GET", `${PROFILE_KEY_PREFIX}${keyName}`)
+ if (!raw) {
+ await recordCacheMiss({ key: `${PROFILE_KEY_PREFIX}${keyName}`, label: "user-profile" })
+ return null
+ }
+ return JSON.parse(raw)
+ } finally {}
+}
+
+async function setUserProfileInRedis(userName, payload, { ttlSeconds = DEFAULT_TTL_SECONDS } = {}) {
+ const keyName = normalizeUserName(userName)
+ if (!keyName || !payload) return false
+ const redis = createRedisClient()
+ try {
+ const key = `${PROFILE_KEY_PREFIX}${keyName}`
+ await redis.call("JSON.SET", key, "$", JSON.stringify(payload))
+ const ttl = Number(ttlSeconds)
+ if (Number.isFinite(ttl) && ttl > 0) {
+ await redis.expire(key, ttl)
+ }
+ return true
+ } finally {}
+}
+
+module.exports = {
+ getUserProfileFromRedis,
+ setUserProfileInRedis,
+}
diff --git a/services/redis/userPublicCache.service.js b/services/redis/userPublicCache.service.js
new file mode 100644
index 0000000..0c54a71
--- /dev/null
+++ b/services/redis/userPublicCache.service.js
@@ -0,0 +1,163 @@
+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 DEFAULT_USER_TTL_SECONDS = 60 * 60
+
+function createRedisClient() {
+ return getRedisClient()
+}
+
+function normalizeUserId(userId) {
+ const id = Number(userId)
+ return Number.isInteger(id) && id > 0 ? id : null
+}
+
+function normalizeUserPayload(user) {
+ const id = normalizeUserId(user?.id)
+ if (!id) return null
+ const badgesSource = Array.isArray(user?.badges)
+ ? user.badges
+ : Array.isArray(user?.userBadges)
+ ? user.userBadges
+ : []
+ const badgeIds = badgesSource
+ .map((item) => {
+ if (!item) return null
+ const badge = item.badge || item
+ const id = Number(badge?.id)
+ return Number.isInteger(id) && id > 0 ? id : null
+ })
+ .filter(Boolean)
+ return {
+ id,
+ username: user?.username ?? null,
+ avatarUrl: user?.avatarUrl ?? null,
+ badgeIds: Array.from(new Set(badgeIds)),
+ }
+}
+
+async function getUserPublicFromRedis(userId) {
+ const id = normalizeUserId(userId)
+ if (!id) return null
+ const redis = createRedisClient()
+ const key = `${USER_PUBLIC_ID_KEY_PREFIX}${id}`
+ try {
+ const raw = await redis.call("JSON.GET", key)
+ if (!raw) {
+ await recordCacheMiss({ key, label: "user-public" })
+ return null
+ }
+ return JSON.parse(raw)
+ } finally {}
+}
+
+async function getUsersPublicByIds(userIds = []) {
+ const ids = Array.from(
+ new Set((Array.isArray(userIds) ? userIds : []).map(normalizeUserId).filter(Boolean))
+ )
+ if (!ids.length) return new Map()
+
+ const redis = createRedisClient()
+ try {
+ const pipeline = redis.pipeline()
+ ids.forEach((id) => pipeline.call("JSON.GET", `${USER_PUBLIC_ID_KEY_PREFIX}${id}`))
+ const results = await pipeline.exec()
+ const map = new Map()
+
+ results.forEach(([, raw], idx) => {
+ if (!raw) return
+ try {
+ const user = JSON.parse(raw)
+ if (user && user.id) map.set(ids[idx], user)
+ } catch {
+ return
+ }
+ })
+
+ return map
+ } finally {}
+}
+
+async function setUsersPublicInRedis(users = [], { ttlSecondsById = null } = {}) {
+ const payloads = (Array.isArray(users) ? users : [])
+ .map(normalizeUserPayload)
+ .filter(Boolean)
+ if (!payloads.length) return 0
+
+ const redis = createRedisClient()
+ try {
+ const pipeline = redis.pipeline()
+ payloads.forEach((user) => {
+ const key = `${USER_PUBLIC_ID_KEY_PREFIX}${user.id}`
+ pipeline.call("JSON.SET", key, "$", JSON.stringify(user))
+ if (user.username) {
+ pipeline.set(`${USER_PUBLIC_NAME_KEY_PREFIX}${String(user.username).toLowerCase()}`, String(user.id))
+ }
+ const ttlSeconds = ttlSecondsById?.[user.id]
+ if (ttlSeconds) {
+ pipeline.expire(key, Number(ttlSeconds))
+ if (user.username) {
+ pipeline.expire(
+ `${USER_PUBLIC_NAME_KEY_PREFIX}${String(user.username).toLowerCase()}`,
+ Number(ttlSeconds)
+ )
+ }
+ }
+ })
+ await pipeline.exec()
+ return payloads.length
+ } 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
+}
+
+async function ensureUserMinTtl(userId, { minSeconds = DEFAULT_USER_TTL_SECONDS } = {}) {
+ const id = normalizeUserId(userId)
+ if (!id) return { bumped: false }
+ const redis = createRedisClient()
+ const key = `${USER_PUBLIC_ID_KEY_PREFIX}${id}`
+ const minTtl = Math.max(1, Number(minSeconds) || DEFAULT_USER_TTL_SECONDS)
+ try {
+ const ttl = await redis.ttl(key)
+ if (ttl === -2) return { bumped: false } // no key
+ if (ttl === -1 || ttl < minTtl) {
+ const nextTtl = minTtl
+ await redis.expire(key, nextTtl)
+ return { bumped: true, ttl: nextTtl }
+ }
+ return { bumped: false, ttl }
+ } finally {}
+}
+
+async function getUserIdByUsername(userName) {
+ const normalized = String(userName || "").trim().toLowerCase()
+ if (!normalized) return null
+ const redis = createRedisClient()
+ try {
+ const raw = await redis.get(`${USER_PUBLIC_NAME_KEY_PREFIX}${normalized}`)
+ if (!raw) {
+ await recordCacheMiss({
+ key: `${USER_PUBLIC_NAME_KEY_PREFIX}${normalized}`,
+ label: "user-name",
+ })
+ }
+ const id = raw ? Number(raw) : null
+ return Number.isInteger(id) && id > 0 ? id : null
+ } finally {}
+}
+
+module.exports = {
+ getUserPublicFromRedis,
+ getUsersPublicByIds,
+ setUserPublicInRedis,
+ setUsersPublicInRedis,
+ ensureUserMinTtl,
+ getUserIdByUsername,
+}
diff --git a/services/requestContext.js b/services/requestContext.js
new file mode 100644
index 0000000..ad365cf
--- /dev/null
+++ b/services/requestContext.js
@@ -0,0 +1,20 @@
+const { AsyncLocalStorage } = require("async_hooks")
+
+const storage = new AsyncLocalStorage()
+
+function requestContextMiddleware(req, res, next) {
+ const context = {
+ method: req.method,
+ path: req.originalUrl || req.url,
+ }
+ storage.run(context, () => next())
+}
+
+function getRequestContext() {
+ return storage.getStore() || null
+}
+
+module.exports = {
+ requestContextMiddleware,
+ getRequestContext,
+}
diff --git a/services/seller.service.js b/services/seller.service.js
index 38f9360..df03df7 100644
--- a/services/seller.service.js
+++ b/services/seller.service.js
@@ -1,5 +1,5 @@
// services/seller/sellerService.js
-const { findSeller } = require("../db/seller.db")
+const { findSeller, findSellers } = require("../db/seller.db")
const dealService = require("./deal.service")
function normalizeSellerName(value) {
@@ -37,12 +37,21 @@ async function getDealsBySellerName(name, { page = 1, limit = 10, filters = {},
scope,
baseWhere: { sellerId: seller.id, status: "ACTIVE" },
filters,
+ useRedisSearch: true,
})
return { seller, payload }
}
+async function getActiveSellers() {
+ return findSellers(
+ { isActive: true },
+ { select: { name: true, sellerLogo: true }, orderBy: { name: "asc" } }
+ )
+}
+
module.exports = {
getSellerByName,
getDealsBySellerName,
+ getActiveSellers,
}
diff --git a/services/sellerLookup.service.js b/services/sellerLookup.service.js
index e29aeac..5dfdf6d 100644
--- a/services/sellerLookup.service.js
+++ b/services/sellerLookup.service.js
@@ -1,4 +1,5 @@
const { findSellerByDomain } = require("../db/seller.db")
+const { getSellerById, getSellerIdByDomain } = require("./redis/sellerCache.service")
function normalizeDomain(hostname) {
return hostname.replace(/^www\./, "")
@@ -15,18 +16,26 @@ async function findSellerFromLink(url) {
const domain = normalizeDomain(hostname)
- const seller = await findSellerByDomain(domain)
- if (seller) {
- return seller
+ const cachedSellerId = await getSellerIdByDomain(domain)
+ if (cachedSellerId) {
+ const cachedSeller = await getSellerById(cachedSellerId)
+ if (cachedSeller) return cachedSeller
}
+ const seller = await findSellerByDomain(domain)
+ if (seller) return seller
+
const domainParts = domain.split(".")
for (let i = 1; i <= domainParts.length - 2; i += 1) {
const parentDomain = domainParts.slice(i).join(".")
- const parentSeller = await findSellerByDomain(parentDomain)
- if (parentSeller) {
- return parentSeller
+ const cachedParentId = await getSellerIdByDomain(parentDomain)
+ if (cachedParentId) {
+ const cachedParent = await getSellerById(cachedParentId)
+ if (cachedParent) return cachedParent
}
+
+ const parentSeller = await findSellerByDomain(parentDomain)
+ if (parentSeller) return parentSeller
}
return null
diff --git a/services/tag.service.js b/services/tag.service.js
new file mode 100644
index 0000000..0faacd8
--- /dev/null
+++ b/services/tag.service.js
@@ -0,0 +1,191 @@
+const prisma = require("../db/client")
+const { slugify } = require("../utils/slugify")
+
+function normalizeTags(tags = []) {
+ const arr = Array.isArray(tags) ? tags : []
+ const map = new Map()
+
+ arr.forEach((raw) => {
+ let name = ""
+ if (typeof raw === "string") {
+ name = raw
+ } else if (raw && typeof raw === "object") {
+ if (typeof raw.name === "string") name = raw.name
+ else if (typeof raw.title === "string") name = raw.title
+ else if (typeof raw.slug === "string") name = raw.slug
+ else if (typeof raw.label === "string") name = raw.label
+ else if (typeof raw.text === "string") name = raw.text
+ }
+
+ const trimmed = String(name || "").trim()
+ if (!trimmed) return
+ const slug = slugify(trimmed)
+ if (!slug) return
+ if (!map.has(slug)) {
+ map.set(slug, { slug, name: trimmed })
+ }
+ })
+
+ return Array.from(map.values()).slice(0, 5)
+}
+
+async function attachTagsToDeal(dealId, tags = []) {
+ const id = Number(dealId)
+ if (!Number.isInteger(id) || id <= 0) throw new Error("INVALID_DEAL_ID")
+
+ const normalized = normalizeTags(tags)
+ if (!normalized.length) return { tags: [], created: 0 }
+
+ return prisma.$transaction(async (tx) => {
+ const upserted = await Promise.all(
+ normalized.map((t) =>
+ tx.tag.upsert({
+ where: { slug: t.slug },
+ update: {},
+ create: { slug: t.slug, name: t.name },
+ })
+ )
+ )
+
+ const tagIds = upserted.map((t) => t.id)
+ const existing = await tx.dealTag.findMany({
+ where: { dealId: id, tagId: { in: tagIds } },
+ select: { tagId: true },
+ })
+ const existingIds = new Set(existing.map((e) => e.tagId))
+ const toCreate = upserted.filter((t) => !existingIds.has(t.id))
+
+ if (toCreate.length) {
+ await tx.dealTag.createMany({
+ data: toCreate.map((t) => ({ dealId: id, tagId: t.id })),
+ skipDuplicates: true,
+ })
+ await tx.tag.updateMany({
+ where: { id: { in: toCreate.map((t) => t.id) } },
+ data: { usageCount: { increment: 1 } },
+ })
+ }
+
+ const tagsForDeal = await tx.dealTag.findMany({
+ where: { dealId: id },
+ select: { tag: { select: { id: true, slug: true, name: true } } },
+ })
+
+ return {
+ tags: tagsForDeal.map((entry) => entry.tag),
+ created: toCreate.length,
+ }
+ })
+}
+
+async function removeTagsFromDeal(dealId, tags = []) {
+ const id = Number(dealId)
+ if (!Number.isInteger(id) || id <= 0) throw new Error("INVALID_DEAL_ID")
+
+ const normalized = normalizeTags(tags)
+ if (!normalized.length) return { tags: [], removed: 0 }
+ const slugs = normalized.map((t) => t.slug)
+
+ return prisma.$transaction(async (tx) => {
+ const tagRows = await tx.tag.findMany({
+ where: { slug: { in: slugs } },
+ select: { id: true },
+ })
+ if (!tagRows.length) return { tags: [], removed: 0 }
+ const tagIds = tagRows.map((t) => t.id)
+
+ const existing = await tx.dealTag.findMany({
+ where: { dealId: id, tagId: { in: tagIds } },
+ select: { tagId: true },
+ })
+ const existingIds = new Set(existing.map((e) => e.tagId))
+ const toRemove = tagIds.filter((tagId) => existingIds.has(tagId))
+ if (toRemove.length) {
+ await tx.dealTag.deleteMany({ where: { dealId: id, tagId: { in: toRemove } } })
+ await tx.tag.updateMany({
+ where: { id: { in: toRemove }, usageCount: { gt: 0 } },
+ data: { usageCount: { decrement: 1 } },
+ })
+ }
+
+ const tagsForDeal = await tx.dealTag.findMany({
+ where: { dealId: id },
+ select: { tag: { select: { id: true, slug: true, name: true } } },
+ })
+
+ return {
+ tags: tagsForDeal.map((entry) => entry.tag),
+ removed: toRemove.length,
+ }
+ })
+}
+
+async function replaceTagsForDeal(dealId, tags = []) {
+ const id = Number(dealId)
+ if (!Number.isInteger(id) || id <= 0) throw new Error("INVALID_DEAL_ID")
+
+ const normalized = normalizeTags(tags)
+
+ return prisma.$transaction(async (tx) => {
+ const existing = await tx.dealTag.findMany({
+ where: { dealId: id },
+ select: { tagId: true },
+ })
+ const existingIds = new Set(existing.map((e) => e.tagId))
+
+ let desiredIds = []
+ if (normalized.length) {
+ const upserted = await Promise.all(
+ normalized.map((t) =>
+ tx.tag.upsert({
+ where: { slug: t.slug },
+ update: {},
+ create: { slug: t.slug, name: t.name },
+ })
+ )
+ )
+ desiredIds = upserted.map((t) => t.id)
+ }
+
+ const desiredSet = new Set(desiredIds)
+ const toRemove = Array.from(existingIds).filter((tagId) => !desiredSet.has(tagId))
+ const toAdd = desiredIds.filter((tagId) => !existingIds.has(tagId))
+
+ if (toRemove.length) {
+ await tx.dealTag.deleteMany({ where: { dealId: id, tagId: { in: toRemove } } })
+ await tx.tag.updateMany({
+ where: { id: { in: toRemove }, usageCount: { gt: 0 } },
+ data: { usageCount: { decrement: 1 } },
+ })
+ }
+
+ if (toAdd.length) {
+ await tx.dealTag.createMany({
+ data: toAdd.map((tagId) => ({ dealId: id, tagId })),
+ skipDuplicates: true,
+ })
+ await tx.tag.updateMany({
+ where: { id: { in: toAdd } },
+ data: { usageCount: { increment: 1 } },
+ })
+ }
+
+ const tagsForDeal = await tx.dealTag.findMany({
+ where: { dealId: id },
+ select: { tag: { select: { id: true, slug: true, name: true } } },
+ })
+
+ return {
+ tags: tagsForDeal.map((entry) => entry.tag),
+ added: toAdd.length,
+ removed: toRemove.length,
+ }
+ })
+}
+
+module.exports = {
+ attachTagsToDeal,
+ normalizeTags,
+ removeTagsFromDeal,
+ replaceTagsForDeal,
+}
diff --git a/services/user.service.js b/services/user.service.js
index f71929e..3b7be6a 100644
--- a/services/user.service.js
+++ b/services/user.service.js
@@ -4,13 +4,47 @@ const dealDB = require("../db/deal.db")
const commentDB = require("../db/comment.db")
const dealService = require("./deal.service")
+const PROFILE_PAGE_SIZE = 15
+
+function normalizePage(value) {
+ const num = Number(value)
+ if (!Number.isInteger(num) || num < 1) return 1
+ return num
+}
+
+function buildPagination({ page, total, limit }) {
+ const safeTotal = Number.isFinite(total) ? Math.max(0, total) : 0
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : PROFILE_PAGE_SIZE
+ return {
+ page,
+ total: safeTotal,
+ totalPages: safeTotal === 0 ? 0 : Math.ceil(safeTotal / safeLimit),
+ limit: safeLimit,
+ }
+}
+
async function getUserProfileByUsername(userName) {
const username = String(userName).trim()
if (!username) throw new Error("username zorunlu")
const user = await userDB.findUser(
{ username },
- { select: { id: true, username: true, email: true, avatarUrl: true, createdAt: true } }
+ {
+ select: {
+ id: true,
+ username: true,
+ email: true,
+ avatarUrl: true,
+ createdAt: true,
+ userBadges: {
+ orderBy: { earnedAt: "desc" },
+ select: {
+ earnedAt: true,
+ badge: { select: { id: true, name: true, iconUrl: true, description: true } },
+ },
+ },
+ },
+ }
)
if (!user) {
@@ -19,41 +53,142 @@ async function getUserProfileByUsername(userName) {
throw err
}
- const [dealAgg, totalComments, comments] = await Promise.all([
- dealDB.aggregateDeals({ userId: user.id }),
- commentDB.countComments({ userId: user.id }),
- commentDB.findComments(
- { userId: user.id },
- {
- orderBy: { createdAt: "desc" },
- take: 20,
- include: {
- user: { select: { id: true, username: true, avatarUrl: true } },
- deal: { select: { id: true, title: true } },
- },
- }
- ),
- ])
+ const commentsPage = 1
+ const dealsPage = 1
- const userDeals = await dealService.getDeals({
+ const [dealAgg, totalComments, comments, userDeals] = await Promise.all([
+ dealDB.aggregateDeals({ userId: user.id, status: { in: ["ACTIVE", "EXPIRED"] } }),
+ commentDB.countComments({ userId: user.id }),
+ commentDB.findComments(
+ { userId: user.id },
+ {
+ orderBy: { createdAt: "desc" },
+ skip: (commentsPage - 1) * PROFILE_PAGE_SIZE,
+ take: PROFILE_PAGE_SIZE,
+ include: {
+ user: { select: { id: true, username: true, avatarUrl: true } },
+ deal: { select: { id: true, title: true } },
+ },
+ }
+ ),
+ dealService.getDeals({
preset: "USER_PUBLIC",
targetUserId: user.id,
viewer: null,
- page: 1,
- limit: 20,
- })
+ page: dealsPage,
+ limit: PROFILE_PAGE_SIZE,
+ }),
+ ])
- const totalDeals = dealAgg?._count?._all ?? 0
- const stats = {
- totalLikes: dealAgg?._sum?.score ?? 0,
- totalComments: totalComments ?? 0,
- totalShares: totalDeals,
- totalDeals,
- }
+ const totalDeals = dealAgg?._count?._all ?? 0
+ const stats = {
+ totalLikes: dealAgg?._sum?.score ?? 0,
+ totalComments: totalComments ?? 0,
+ totalShares: totalDeals,
+ totalDeals,
+ }
+ return {
+ user,
+ stats,
+ deals: userDeals.results,
+ comments,
+ badges: user.userBadges || [],
+ dealsPagination: buildPagination({
+ page: userDeals.page ?? dealsPage,
+ total: userDeals.total ?? 0,
+ limit: PROFILE_PAGE_SIZE,
+ }),
+ commentsPagination: buildPagination({
+ page: commentsPage,
+ total: totalComments ?? 0,
+ limit: PROFILE_PAGE_SIZE,
+ }),
+ }
+}
- return { user, stats, deals: userDeals.results, comments }
+async function getUserCommentsByUsername(userName, { page = 1, limit = PROFILE_PAGE_SIZE } = {}) {
+ const username = String(userName).trim()
+ if (!username) throw new Error("username zorunlu")
- }
+ const user = await userDB.findUser(
+ { username },
+ { select: { id: true } }
+ )
+ if (!user) {
+ const err = new Error("Kullanici bulunamadi.")
+ err.statusCode = 404
+ throw err
+ }
-module.exports = { getUserProfileByUsername }
+ const safePage = normalizePage(page)
+ const safeLimit = PROFILE_PAGE_SIZE
+ const skip = (safePage - 1) * safeLimit
+
+ const [total, results] = await Promise.all([
+ commentDB.countComments({ userId: user.id }),
+ commentDB.findComments(
+ { userId: user.id },
+ {
+ orderBy: { createdAt: "desc" },
+ skip,
+ take: safeLimit,
+ include: {
+ user: { select: { id: true, username: true, avatarUrl: true } },
+ deal: { select: { id: true, title: true } },
+ },
+ }
+ ),
+ ])
+
+ return {
+ page: safePage,
+ total: total ?? 0,
+ totalPages: total ? Math.ceil(total / safeLimit) : 0,
+ limit: safeLimit,
+ results,
+ }
+}
+
+async function getUserDealsByUsername(userName, { page = 1, limit = PROFILE_PAGE_SIZE, viewer = null } = {}) {
+ const username = String(userName).trim()
+ if (!username) throw new Error("username zorunlu")
+
+ const user = await userDB.findUser(
+ { username },
+ { select: { id: true } }
+ )
+ if (!user) {
+ const err = new Error("Kullanici bulunamadi.")
+ err.statusCode = 404
+ throw err
+ }
+
+ const safePage = normalizePage(page)
+ const safeLimit = PROFILE_PAGE_SIZE
+
+ const isSelfProfile = viewer?.userId && Number(viewer.userId) === Number(user.id)
+ const preset = isSelfProfile ? "MY" : "USER_PUBLIC"
+
+ const payload = await dealService.getDeals({
+ preset,
+ targetUserId: user.id,
+ viewer,
+ page: safePage,
+ limit: safeLimit,
+ })
+
+ return {
+ page: payload.page ?? safePage,
+ total: payload.total ?? 0,
+ totalPages: payload.totalPages ?? 0,
+ limit: safeLimit,
+ results: payload.results ?? [],
+ }
+}
+
+module.exports = {
+ getUserProfileByUsername,
+ getUserCommentsByUsername,
+ getUserDealsByUsername,
+}
diff --git a/services/vote.service.js b/services/vote.service.js
index 9b74cf1..fc9247e 100644
--- a/services/vote.service.js
+++ b/services/vote.service.js
@@ -1,4 +1,8 @@
-const voteDb = require("../db/vote.db");
+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 { publishNotification } = require("./redis/notificationPubsub.service");
async function voteDeal({ dealId, userId, voteType }) {
if (!dealId || !userId || voteType === undefined) {
@@ -13,7 +17,83 @@ async function voteDeal({ dealId, userId, voteType }) {
throw err;
}
- return voteDb.voteDealTx({ dealId, userId, voteType });
+ const deal = await dealDb.findDeal(
+ { id: Number(dealId) },
+ { select: { status: true } }
+ );
+ if (!deal || !["ACTIVE", "EXPIRED"].includes(deal.status)) {
+ const err = new Error("deal sadece ACTIVE veya EXPIRED iken oylanabilir");
+ err.statusCode = 400;
+ throw err;
+ }
+
+ const createdAt = new Date();
+
+ const redisResult = await updateDealVoteInRedis({
+ dealId,
+ userId,
+ voteType,
+ }).catch((err) => {
+ console.error("Redis vote update failed:", err?.message || err);
+ return { updated: false, delta: 0, score: null, maxNotifiedMilestone: 0, dealUserId: null };
+ });
+
+ queueVoteUpdate({
+ dealId,
+ userId,
+ voteType,
+ createdAt: createdAt.toISOString(),
+ }).catch((err) => console.error("DB sync vote queue failed:", err?.message || err));
+
+ const scoreVal = Number(redisResult?.score)
+ const maxNotified = Number(redisResult?.maxNotifiedMilestone ?? 0)
+ const ownerId = Number(redisResult?.dealUserId)
+ if (Number.isFinite(scoreVal) && Number.isInteger(ownerId) && ownerId > 0) {
+ const milestone = 100
+ if (scoreVal >= milestone && maxNotified < milestone) {
+ const updatedAt = new Date()
+ await updateDealInRedis(dealId, { maxNotifiedMilestone: milestone }, { updatedAt }).catch((err) =>
+ console.error("Redis deal milestone update failed:", err?.message || err)
+ )
+ queueDealUpdate({
+ dealId,
+ data: { maxNotifiedMilestone: milestone },
+ updatedAt: updatedAt.toISOString(),
+ }).catch((err) => console.error("DB sync deal milestone update failed:", err?.message || err))
+ queueNotificationCreate({
+ userId: ownerId,
+ message: "Fırsatın 100 beğeniyi geçti!",
+ type: "MILESTONE",
+ createdAt: updatedAt.toISOString(),
+ }).catch((err) => console.error("DB sync notification queue failed:", err?.message || err))
+ publishNotification({
+ userId: ownerId,
+ message: "Fırsatın 100 beğeniyi geçti!",
+ type: "MILESTONE",
+ createdAt: updatedAt.toISOString(),
+ }).catch((err) => console.error("Notification publish failed:", err?.message || err))
+ }
+ }
+
+ // Fallback: Redis yoksa mevcut DB'den approx hesapla
+ 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;
+ }
+
+ return {
+ dealId,
+ voteType,
+ delta,
+ score,
+ };
}
async function getVotes(dealId) {
diff --git a/utils/requestInfo.js b/utils/requestInfo.js
new file mode 100644
index 0000000..717f2e4
--- /dev/null
+++ b/utils/requestInfo.js
@@ -0,0 +1,18 @@
+function getClientIp(req) {
+ const forwarded = req.headers["x-forwarded-for"]
+ if (typeof forwarded === "string" && forwarded.trim()) {
+ return forwarded.split(",")[0].trim()
+ }
+ if (Array.isArray(forwarded) && forwarded.length > 0) {
+ return String(forwarded[0]).trim()
+ }
+
+ const realIp = req.headers["x-real-ip"]
+ if (typeof realIp === "string" && realIp.trim()) return realIp.trim()
+ if (Array.isArray(realIp) && realIp.length > 0) return String(realIp[0]).trim()
+
+ if (req.ip) return String(req.ip)
+ return null
+}
+
+module.exports = { getClientIp }
diff --git a/utils/slugify.js b/utils/slugify.js
new file mode 100644
index 0000000..6354524
--- /dev/null
+++ b/utils/slugify.js
@@ -0,0 +1,30 @@
+function slugify(input = "") {
+ const map = {
+ ç: "c",
+ Ç: "c",
+ ğ: "g",
+ Ğ: "g",
+ ı: "i",
+ I: "i",
+ İ: "i",
+ ö: "o",
+ Ö: "o",
+ ş: "s",
+ Ş: "s",
+ ü: "u",
+ Ü: "u",
+ }
+
+ const normalized = String(input || "")
+ .split("")
+ .map((ch) => (map[ch] ? map[ch] : ch))
+ .join("")
+ .toLowerCase()
+
+ return normalized
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .replace(/-{2,}/g, "-")
+}
+
+module.exports = { slugify }
diff --git a/validators/dealCreate.validator.js b/validators/dealCreate.validator.js
index f107ef4..0512ea0 100644
--- a/validators/dealCreate.validator.js
+++ b/validators/dealCreate.validator.js
@@ -14,6 +14,7 @@ const createDealPayloadSchema = z.object({
url: optionalUrlString().optional(),
price: optionalPrice().optional(),
sellerName: optionalTrimmedString().optional(),
+ customSeller: optionalTrimmedString().optional(),
})
module.exports = {
diff --git a/workers/dbSync.worker.js b/workers/dbSync.worker.js
new file mode 100644
index 0000000..dbb9c06
--- /dev/null
+++ b/workers/dbSync.worker.js
@@ -0,0 +1,1064 @@
+const { Worker } = require("bullmq")
+const Redis = require("ioredis")
+const { getRedisConnectionOptions } = require("../services/redis/connection")
+const voteDb = require("../db/vote.db")
+const {
+ VOTE_HASH_KEY,
+ COMMENT_LIKE_HASH_KEY,
+ COMMENT_HASH_KEY,
+ COMMENT_DELETE_HASH_KEY,
+ DEAL_UPDATE_HASH_KEY,
+ DEAL_CREATE_HASH_KEY,
+ DEAL_AI_REVIEW_HASH_KEY,
+ NOTIFICATION_HASH_KEY,
+ NOTIFICATION_READ_HASH_KEY,
+ DEAL_SAVE_HASH_KEY,
+ AUDIT_HASH_KEY,
+ USER_UPDATE_HASH_KEY,
+ USER_NOTE_HASH_KEY,
+ DEAL_REPORT_UPDATE_HASH_KEY,
+ CATEGORY_UPSERT_HASH_KEY,
+ SELLER_UPSERT_HASH_KEY,
+ SELLER_DOMAIN_UPSERT_HASH_KEY,
+} = require("../services/redis/dbSync.service")
+const {
+ DEAL_EVENT_HASH_KEY,
+ incrementDealAnalyticsTotalsInRedis,
+} = require("../services/redis/dealAnalytics.service")
+const commentLikeDb = require("../db/commentLike.db")
+const dealAnalyticsDb = require("../db/dealAnalytics.db")
+const prisma = require("../db/client")
+
+function createRedisClient() {
+ return new Redis(getRedisConnectionOptions())
+}
+
+async function consumeUserUpdates(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ USER_UPDATE_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const dedup = new Map()
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ const id = Number(parsed.userId)
+ if (!id || !parsed?.data) continue
+ const updatedAt = parsed.updatedAt ? new Date(parsed.updatedAt) : new Date()
+ const existing = dedup.get(id)
+ if (!existing || updatedAt > existing.updatedAt) {
+ dedup.set(id, { userId: id, data: parsed.data, updatedAt })
+ }
+ } catch (err) {
+ console.error("db-sync user update parse failed:", err?.message || err)
+ }
+ }
+
+ const items = Array.from(dedup.values())
+ if (!items.length) return 0
+
+ let updated = 0
+ for (const item of items) {
+ try {
+ await prisma.user.update({
+ where: { id: item.userId },
+ data: { ...item.data, updatedAt: item.updatedAt },
+ })
+ updated += 1
+ } catch (err) {
+ console.error("db-sync user update failed:", err?.message || err)
+ }
+ }
+
+ return updated
+}
+
+async function consumeUserNotes(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ USER_NOTE_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const items = []
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ if (!parsed?.userId || !parsed?.createdById || !parsed?.note) continue
+ items.push({
+ userId: Number(parsed.userId),
+ createdById: Number(parsed.createdById),
+ note: String(parsed.note),
+ createdAt: parsed.createdAt ? new Date(parsed.createdAt) : new Date(),
+ })
+ } catch (err) {
+ console.error("db-sync user note parse failed:", err?.message || err)
+ }
+ }
+
+ if (!items.length) return 0
+ try {
+ await prisma.userNote.createMany({ data: items })
+ return items.length
+ } catch (err) {
+ console.error("db-sync user note batch failed:", err?.message || err)
+ return 0
+ }
+}
+
+async function consumeDealReportUpdates(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ DEAL_REPORT_UPDATE_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const items = []
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ if (!parsed?.reportId || !parsed?.status) continue
+ items.push({
+ reportId: Number(parsed.reportId),
+ status: String(parsed.status),
+ updatedAt: parsed.updatedAt ? new Date(parsed.updatedAt) : new Date(),
+ })
+ } catch (err) {
+ console.error("db-sync dealReport update parse failed:", err?.message || err)
+ }
+ }
+
+ if (!items.length) return 0
+
+ let updated = 0
+ for (const item of items) {
+ try {
+ await prisma.dealReport.update({
+ where: { id: item.reportId },
+ data: { status: item.status, updatedAt: item.updatedAt },
+ })
+ updated += 1
+ } catch (err) {
+ console.error("db-sync dealReport update failed:", err?.message || err)
+ }
+ }
+
+ return updated
+}
+
+async function consumeCategoryUpserts(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ CATEGORY_UPSERT_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const items = []
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ if (!parsed?.categoryId || !parsed?.data) continue
+ items.push({
+ categoryId: Number(parsed.categoryId),
+ data: parsed.data,
+ })
+ } catch (err) {
+ console.error("db-sync category upsert parse failed:", err?.message || err)
+ }
+ }
+
+ if (!items.length) return 0
+ let updated = 0
+ for (const item of items) {
+ try {
+ await prisma.category.upsert({
+ where: { id: item.categoryId },
+ create: { id: item.categoryId, ...item.data },
+ update: item.data,
+ })
+ updated += 1
+ } catch (err) {
+ console.error("db-sync category upsert failed:", err?.message || err)
+ }
+ }
+ return updated
+}
+
+async function consumeSellerUpserts(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ SELLER_UPSERT_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const items = []
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ if (!parsed?.sellerId || !parsed?.data) continue
+ items.push({
+ sellerId: Number(parsed.sellerId),
+ data: parsed.data,
+ })
+ } catch (err) {
+ console.error("db-sync seller upsert parse failed:", err?.message || err)
+ }
+ }
+
+ if (!items.length) return 0
+ let updated = 0
+ for (const item of items) {
+ try {
+ await prisma.seller.upsert({
+ where: { id: item.sellerId },
+ create: { id: item.sellerId, ...item.data },
+ update: item.data,
+ })
+ updated += 1
+ } catch (err) {
+ console.error("db-sync seller upsert failed:", err?.message || err)
+ }
+ }
+ return updated
+}
+
+async function consumeSellerDomainUpserts(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ SELLER_DOMAIN_UPSERT_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const items = []
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ if (!parsed?.sellerId || !parsed?.domain || !parsed?.createdById) continue
+ items.push({
+ sellerId: Number(parsed.sellerId),
+ domain: String(parsed.domain).toLowerCase(),
+ createdById: Number(parsed.createdById),
+ })
+ } catch (err) {
+ console.error("db-sync seller domain parse failed:", err?.message || err)
+ }
+ }
+
+ if (!items.length) return 0
+ let created = 0
+ for (const item of items) {
+ try {
+ await prisma.sellerDomain.upsert({
+ where: { domain: item.domain },
+ create: {
+ domain: item.domain,
+ sellerId: item.sellerId,
+ createdById: item.createdById,
+ },
+ update: { sellerId: item.sellerId },
+ })
+ created += 1
+ } catch (err) {
+ console.error("db-sync seller domain upsert failed:", err?.message || err)
+ }
+ }
+ return created
+}
+
+async function consumeVoteUpdates(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ VOTE_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const dedup = new Map()
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ const key = `${parsed.dealId}:${parsed.userId}`
+ const createdAt = parsed.createdAt ? new Date(parsed.createdAt) : new Date()
+ const existing = dedup.get(key)
+ if (!existing || createdAt > existing.createdAt) {
+ dedup.set(key, {
+ dealId: Number(parsed.dealId),
+ userId: Number(parsed.userId),
+ voteType: Number(parsed.voteType),
+ createdAt,
+ })
+ }
+ } catch (err) {
+ console.error("db-sync vote parse/update failed:", err?.message || err)
+ }
+ }
+
+ const batch = Array.from(dedup.values())
+ if (!batch.length) return 0
+ const result = await voteDb.voteDealBatchTx(batch)
+ return result?.count ?? batch.length
+}
+
+async function consumeCommentLikeUpdates(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ COMMENT_LIKE_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const dedup = new Map()
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ const key = `${parsed.commentId}:${parsed.userId}`
+ const createdAt = parsed.createdAt ? new Date(parsed.createdAt) : new Date()
+ const existing = dedup.get(key)
+ if (!existing || createdAt > existing.createdAt) {
+ dedup.set(key, {
+ commentId: Number(parsed.commentId),
+ userId: Number(parsed.userId),
+ like: Boolean(parsed.like),
+ createdAt,
+ })
+ }
+ } catch (err) {
+ console.error("db-sync commentLike parse failed:", err?.message || err)
+ }
+ }
+
+ const batch = Array.from(dedup.values())
+ if (!batch.length) return 0
+
+ try {
+ const result = await commentLikeDb.applyCommentLikeBatch(batch)
+ return (result?.inserted || 0) + (result?.deleted || 0)
+ } catch (err) {
+ console.error("db-sync commentLike batch failed:", err?.message || err)
+ return 0
+ }
+}
+
+async function consumeCommentCreates(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ COMMENT_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const items = []
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ items.push({
+ id: Number(parsed.commentId),
+ dealId: Number(parsed.dealId),
+ userId: Number(parsed.userId),
+ text: String(parsed.text || ""),
+ parentId: parsed.parentId ? Number(parsed.parentId) : null,
+ createdAt: parsed.createdAt ? new Date(parsed.createdAt) : new Date(),
+ })
+ } catch (err) {
+ console.error("db-sync comment parse failed:", err?.message || err)
+ }
+ }
+
+ if (!items.length) return 0
+
+ return prisma.$transaction(async (tx) => {
+ let created = 0
+ for (const item of items) {
+ try {
+ await tx.comment.create({
+ data: {
+ id: item.id,
+ dealId: item.dealId,
+ userId: item.userId,
+ text: item.text,
+ parentId: item.parentId,
+ createdAt: item.createdAt,
+ },
+ })
+ await tx.deal.update({
+ where: { id: item.dealId },
+ data: { commentCount: { increment: 1 } },
+ })
+ created += 1
+ } catch (err) {
+ // ignore duplicates
+ }
+ }
+ return created
+ })
+}
+
+async function consumeCommentDeletes(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ COMMENT_DELETE_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const items = []
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ items.push({
+ commentId: Number(parsed.commentId),
+ dealId: Number(parsed.dealId),
+ })
+ } catch (err) {
+ console.error("db-sync comment delete parse failed:", err?.message || err)
+ }
+ }
+
+ if (!items.length) return 0
+
+ return prisma.$transaction(async (tx) => {
+ let deleted = 0
+ for (const item of items) {
+ const result = await tx.comment.updateMany({
+ where: { id: item.commentId, deletedAt: null },
+ data: { deletedAt: new Date() },
+ })
+ if (result.count > 0) {
+ await tx.deal.update({
+ where: { id: item.dealId },
+ data: { commentCount: { decrement: 1 } },
+ })
+ deleted += 1
+ }
+ }
+ return deleted
+ })
+}
+
+async function consumeDealEvents(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
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const events = []
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ if (!parsed?.dealId || (!parsed?.userId && !parsed?.ip)) continue
+ events.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,
+ })
+ } catch (err) {
+ console.error("db-sync dealEvent parse failed:", err?.message || err)
+ }
+ }
+
+ if (!events.length) return 0
+
+ try {
+ const result = await dealAnalyticsDb.applyDealEventBatch(events)
+ await incrementDealAnalyticsTotalsInRedis(result?.increments || [])
+ return result?.inserted ?? 0
+ } catch (err) {
+ console.error("db-sync dealEvent batch failed:", err?.message || err)
+ return 0
+ }
+}
+
+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,
+ 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 consumeDealCreates(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ DEAL_CREATE_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const items = []
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ if (!parsed?.dealId || !parsed?.data) continue
+ items.push({
+ dealId: Number(parsed.dealId),
+ data: normalizeDealCreateData(parsed.data),
+ images: Array.isArray(parsed.images) ? parsed.images : [],
+ })
+ } catch (err) {
+ console.error("db-sync deal create parse failed:", err?.message || err)
+ }
+ }
+
+ if (!items.length) return 0
+
+ let created = 0
+ for (const item of items) {
+ try {
+ const data = { ...item.data, id: item.dealId }
+ await prisma.deal.create({ data })
+ await dealAnalyticsDb.ensureTotalsForDealIds([item.dealId])
+ if (item.images.length) {
+ const imagesData = item.images.map((img) => ({
+ dealId: item.dealId,
+ imageUrl: String(img.imageUrl || ""),
+ order: Number(img.order || 0),
+ }))
+ await prisma.dealImage.createMany({ data: imagesData })
+ }
+ created += 1
+ } catch (err) {
+ console.error("db-sync deal create failed:", err?.message || err)
+ }
+ }
+
+ if (created > 0) {
+ await prisma.$executeRawUnsafe(
+ 'SELECT setval(pg_get_serial_sequence(\'"Deal"\', \'id\'), (SELECT MAX(id) FROM "Deal"))'
+ )
+ }
+
+ return created
+}
+
+async function consumeDealAiReviewUpdates(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ DEAL_AI_REVIEW_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const dedup = new Map()
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ const dealId = Number(parsed.dealId)
+ if (!dealId || !parsed?.data) continue
+ const updatedAt = parsed.updatedAt ? new Date(parsed.updatedAt) : new Date()
+ const existing = dedup.get(dealId)
+ if (!existing || updatedAt > existing.updatedAt) {
+ dedup.set(dealId, {
+ dealId,
+ data: parsed.data,
+ updatedAt,
+ })
+ }
+ } catch (err) {
+ console.error("db-sync deal aiReview parse failed:", err?.message || err)
+ }
+ }
+
+ const batch = Array.from(dedup.values())
+ if (!batch.length) return 0
+
+ let updated = 0
+ for (const item of batch) {
+ try {
+ await prisma.dealAiReview.upsert({
+ where: { dealId: item.dealId },
+ create: {
+ dealId: item.dealId,
+ bestCategoryId: Number(item.data.bestCategoryId) || 0,
+ tags: Array.isArray(item.data.tags) ? item.data.tags : [],
+ needsReview: Boolean(item.data.needsReview),
+ hasIssue: Boolean(item.data.hasIssue),
+ issueType: String(item.data.issueType || "NONE"),
+ issueReason: item.data.issueReason ?? null,
+ },
+ update: {
+ bestCategoryId: Number(item.data.bestCategoryId) || 0,
+ tags: Array.isArray(item.data.tags) ? item.data.tags : [],
+ needsReview: Boolean(item.data.needsReview),
+ hasIssue: Boolean(item.data.hasIssue),
+ issueType: String(item.data.issueType || "NONE"),
+ issueReason: item.data.issueReason ?? null,
+ },
+ })
+ updated += 1
+ } catch (err) {
+ console.error("db-sync deal aiReview update failed:", err?.message || err)
+ }
+ }
+
+ return updated
+}
+
+const DEAL_UPDATE_FIELDS = new Set([
+ "title",
+ "description",
+ "url",
+ "price",
+ "originalPrice",
+ "shippingPrice",
+ "couponCode",
+ "location",
+ "discountType",
+ "discountValue",
+ "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
+}
+
+async function consumeDealUpdates(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ DEAL_UPDATE_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const dedup = new Map()
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ const id = Number(parsed.dealId)
+ if (!id) continue
+ const updatedAt = parsed.updatedAt ? new Date(parsed.updatedAt) : new Date()
+ const existing = dedup.get(id)
+ if (!existing || updatedAt > existing.updatedAt) {
+ dedup.set(id, {
+ dealId: id,
+ data: sanitizeDealUpdate(parsed.data),
+ updatedAt,
+ })
+ }
+ } catch (err) {
+ console.error("db-sync deal update parse failed:", err?.message || err)
+ }
+ }
+
+ const batch = Array.from(dedup.values()).filter((item) => Object.keys(item.data).length)
+ if (!batch.length) return 0
+
+ let updated = 0
+ for (const item of batch) {
+ try {
+ await prisma.deal.update({
+ where: { id: item.dealId },
+ data: { ...item.data, updatedAt: item.updatedAt },
+ })
+ updated += 1
+ } catch (err) {
+ console.error("db-sync deal update failed:", err?.message || err)
+ }
+ }
+
+ return updated
+}
+
+async function consumeNotifications(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ NOTIFICATION_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const items = []
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ if (!parsed?.userId || !parsed?.message) continue
+ items.push({
+ userId: Number(parsed.userId),
+ message: String(parsed.message),
+ type: String(parsed.type || "INFO"),
+ createdAt: parsed.createdAt ? new Date(parsed.createdAt) : new Date(),
+ })
+ } catch (err) {
+ console.error("db-sync notification parse failed:", err?.message || err)
+ }
+ }
+
+ if (!items.length) return 0
+
+ let created = 0
+ for (const item of items) {
+ try {
+ await prisma.$transaction(async (tx) => {
+ await tx.notification.create({
+ data: {
+ userId: item.userId,
+ message: item.message,
+ type: item.type,
+ createdAt: item.createdAt,
+ },
+ })
+ await tx.user.update({
+ where: { id: item.userId },
+ data: { notificationCount: { increment: 1 } },
+ })
+ })
+ created += 1
+ } catch (err) {
+ console.error("db-sync notification create failed:", err?.message || err)
+ }
+ }
+
+ return created
+}
+
+async function consumeNotificationReads(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ NOTIFICATION_READ_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const dedup = new Map()
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ if (!parsed?.userId) continue
+ const readAt = parsed.readAt ? new Date(parsed.readAt) : new Date()
+ const existing = dedup.get(parsed.userId)
+ if (!existing || readAt > existing.readAt) {
+ dedup.set(parsed.userId, { userId: Number(parsed.userId), readAt })
+ }
+ } catch (err) {
+ console.error("db-sync notification read parse failed:", err?.message || err)
+ }
+ }
+
+ const items = Array.from(dedup.values())
+ if (!items.length) return 0
+
+ let updated = 0
+ for (const item of items) {
+ try {
+ const result = await prisma.notification.updateMany({
+ where: {
+ userId: item.userId,
+ readAt: null,
+ createdAt: { lte: item.readAt },
+ },
+ data: { readAt: item.readAt },
+ })
+ updated += result?.count || 0
+ } catch (err) {
+ console.error("db-sync notification read update failed:", err?.message || err)
+ }
+ }
+
+ return updated
+}
+
+async function consumeDealSaveUpdates(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ DEAL_SAVE_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const dedup = new Map()
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ const key = `${parsed.dealId}:${parsed.userId}`
+ const createdAt = parsed.createdAt ? new Date(parsed.createdAt) : new Date()
+ const existing = dedup.get(key)
+ if (!existing || createdAt > existing.createdAt) {
+ dedup.set(key, {
+ dealId: Number(parsed.dealId),
+ userId: Number(parsed.userId),
+ action: String(parsed.action || "SAVE").toUpperCase(),
+ createdAt,
+ })
+ }
+ } catch (err) {
+ console.error("db-sync dealSave parse failed:", err?.message || err)
+ }
+ }
+
+ const items = Array.from(dedup.values())
+ if (!items.length) return 0
+
+ const saves = items.filter((i) => i.action === "SAVE")
+ const unsaves = items.filter((i) => i.action === "UNSAVE")
+
+ try {
+ await prisma.$transaction(async (tx) => {
+ if (saves.length) {
+ await tx.dealSave.createMany({
+ data: saves.map((i) => ({
+ dealId: i.dealId,
+ userId: i.userId,
+ createdAt: i.createdAt,
+ })),
+ skipDuplicates: true,
+ })
+ }
+ if (unsaves.length) {
+ await tx.dealSave.deleteMany({
+ where: {
+ OR: unsaves.map((i) => ({
+ dealId: i.dealId,
+ userId: i.userId,
+ })),
+ },
+ })
+ }
+ })
+ } catch (err) {
+ console.error("db-sync dealSave batch failed:", err?.message || err)
+ return 0
+ }
+
+ return items.length
+}
+
+async function consumeAuditEvents(redis) {
+ const data = await redis.eval(
+ "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;",
+ 1,
+ AUDIT_HASH_KEY
+ )
+ if (!data || data.length === 0) return 0
+
+ const pairs = {}
+ for (let i = 0; i < data.length; i += 2) {
+ pairs[data[i]] = data[i + 1]
+ }
+
+ const items = []
+ for (const payload of Object.values(pairs)) {
+ try {
+ const parsed = JSON.parse(payload)
+ if (!parsed?.action) continue
+ items.push({
+ userId: parsed.userId ? Number(parsed.userId) : null,
+ action: String(parsed.action),
+ ip: parsed.ip ?? null,
+ userAgent: parsed.userAgent ?? null,
+ meta: parsed.meta ?? null,
+ createdAt: parsed.createdAt ? new Date(parsed.createdAt) : new Date(),
+ })
+ } catch (err) {
+ console.error("db-sync audit parse failed:", err?.message || err)
+ }
+ }
+
+ if (!items.length) return 0
+
+ try {
+ await prisma.auditEvent.createMany({ data: items })
+ } catch (err) {
+ console.error("db-sync audit batch failed:", err?.message || err)
+ return 0
+ }
+
+ return items.length
+}
+
+async function handler() {
+ const redis = createRedisClient()
+
+ try {
+ const commentCreateCount = await consumeCommentCreates(redis)
+ const commentLikeCount = await consumeCommentLikeUpdates(redis)
+ const commentDeleteCount = await consumeCommentDeletes(redis)
+ const dealSaveCount = await consumeDealSaveUpdates(redis)
+ const dealEventCount = await consumeDealEvents(redis)
+ const dealCreateCount = await consumeDealCreates(redis)
+ const dealAiReviewCount = await consumeDealAiReviewUpdates(redis)
+ const notificationReadCount = await consumeNotificationReads(redis)
+ const notificationCount = await consumeNotifications(redis)
+ const dealUpdateCount = await consumeDealUpdates(redis)
+ const voteCount = await consumeVoteUpdates(redis)
+ const auditCount = await consumeAuditEvents(redis)
+ const userUpdateCount = await consumeUserUpdates(redis)
+ const userNoteCount = await consumeUserNotes(redis)
+ const dealReportUpdateCount = await consumeDealReportUpdates(redis)
+ const categoryUpsertCount = await consumeCategoryUpserts(redis)
+ const sellerUpsertCount = await consumeSellerUpserts(redis)
+ const sellerDomainUpsertCount = await consumeSellerDomainUpserts(redis)
+ return {
+ votes: voteCount,
+ commentLikes: commentLikeCount,
+ commentsCreated: commentCreateCount,
+ commentsDeleted: commentDeleteCount,
+ dealSaves: dealSaveCount,
+ dealEvents: dealEventCount,
+ dealCreates: dealCreateCount,
+ dealAiReviews: dealAiReviewCount,
+ notificationsRead: notificationReadCount,
+ notifications: notificationCount,
+ dealUpdates: dealUpdateCount,
+ audits: auditCount,
+ userUpdates: userUpdateCount,
+ userNotes: userNoteCount,
+ dealReportUpdates: dealReportUpdateCount,
+ categoryUpserts: categoryUpsertCount,
+ sellerUpserts: sellerUpsertCount,
+ sellerDomainUpserts: sellerDomainUpsertCount,
+ }
+ } finally {
+ redis.disconnect()
+ }
+}
+
+function startDbSyncWorker() {
+ const worker = new Worker("db-sync", handler, {
+ connection: getRedisConnectionOptions(),
+ concurrency: 1,
+ })
+
+ worker.on("completed", (job) => {
+ console.log(
+ `✅ DB sync batch done. Votes: ${job.returnvalue?.votes ?? 0} CommentLikes: ${job.returnvalue?.commentLikes ?? 0} CommentsCreated: ${job.returnvalue?.commentsCreated ?? 0} CommentsDeleted: ${job.returnvalue?.commentsDeleted ?? 0} DealSaves: ${job.returnvalue?.dealSaves ?? 0} DealEvents: ${job.returnvalue?.dealEvents ?? 0} DealCreates: ${job.returnvalue?.dealCreates ?? 0} DealAiReviews: ${job.returnvalue?.dealAiReviews ?? 0} NotificationsRead: ${job.returnvalue?.notificationsRead ?? 0} Notifications: ${job.returnvalue?.notifications ?? 0} DealUpdates: ${job.returnvalue?.dealUpdates ?? 0} Audits: ${job.returnvalue?.audits ?? 0} UserUpdates: ${job.returnvalue?.userUpdates ?? 0} UserNotes: ${job.returnvalue?.userNotes ?? 0} DealReportUpdates: ${job.returnvalue?.dealReportUpdates ?? 0} CategoryUpserts: ${job.returnvalue?.categoryUpserts ?? 0} SellerUpserts: ${job.returnvalue?.sellerUpserts ?? 0} SellerDomainUpserts: ${job.returnvalue?.sellerDomainUpserts ?? 0}`
+ )
+ })
+
+ worker.on("failed", (job, err) => {
+ console.error(`❌ DB sync batch failed: ${job?.id}`, err?.message)
+ })
+
+ return worker
+}
+
+module.exports = { startDbSyncWorker }
diff --git a/workers/dealClassification.worker.js b/workers/dealClassification.worker.js
index 20a6d49..5ee015f 100644
--- a/workers/dealClassification.worker.js
+++ b/workers/dealClassification.worker.js
@@ -1,41 +1,63 @@
const { Worker } = require("bullmq")
const { connection } = require("../jobs/dealClassification.queue")
-const dealDB = require("../db/deal.db")
-const dealAiReviewDb = require("../db/dealAiReview.db")
const { classifyDeal } = require("../services/dealClassification.service")
+const { getDealFromRedis, updateDealInRedis } = require("../services/redis/dealCache.service")
+const { queueDealAiReviewUpdate } = require("../services/redis/dbSync.service")
+const { getSellerById } = require("../services/redis/sellerCache.service")
+const dealDB = require("../db/deal.db")
async function handler(job) {
const { dealId } = job.data
if (!dealId) throw new Error("dealId missing")
-const deal = await dealDB.findDeal(
- { id: Number(dealId) },
- {
- select: {
- id: true,
- title: true,
- description: true,
- url: true,
- seller: { select: { name: true } },
- },
+ let deal = await getDealFromRedis(Number(dealId))
+ if (!deal) {
+ deal = await dealDB.findDeal(
+ { id: Number(dealId) },
+ {
+ select: {
+ id: true,
+ title: true,
+ description: true,
+ url: true,
+ seller: { select: { name: true } },
+ customSeller: true,
+ sellerId: true,
+ },
+ }
+ )
}
-)
if (!deal) throw new Error(`Deal not found: ${dealId}`)
+ let sellerName = deal?.seller?.name ?? null
+ if (!sellerName && deal?.sellerId) {
+ const seller = await getSellerById(Number(deal.sellerId))
+ sellerName = seller?.name ?? null
+ }
+ if (!sellerName && deal?.customSeller) sellerName = deal.customSeller
+
const ai = await classifyDeal({
title: deal.title,
description: deal.description,
url: deal.url,
- seller: deal.seller?.name ?? null,
+ seller: sellerName,
})
- await dealAiReviewDb.upsertDealAiReview(deal.id, {
- best_category_id: ai.best_category_id,
- needs_review: ai.needs_review,
- has_issue: ai.has_issue,
- issue_reason: ai.issue_reason,
- issue_type: ai.issue_type,
- })
+ const aiReview = {
+ bestCategoryId: ai.best_category_id,
+ tags: ai.tags || [],
+ needsReview: ai.needs_review,
+ hasIssue: ai.has_issue,
+ issueReason: ai.issue_reason,
+ issueType: ai.issue_type,
+ }
+
+ await updateDealInRedis(deal.id, { aiReview }, { updatedAt: new Date() })
+ queueDealAiReviewUpdate({
+ dealId: deal.id,
+ data: aiReview,
+ updatedAt: new Date().toISOString(),
+ }).catch((err) => console.error("DB sync deal aiReview failed:", err?.message || err))
// İstersen auto-set (şimdilik dursun, id mismatch riskini biliyorsun)
// if (!ai.needs_review && !ai.has_issue) {
diff --git a/workers/hotDealList.worker.js b/workers/hotDealList.worker.js
new file mode 100644
index 0000000..ea626e5
--- /dev/null
+++ b/workers/hotDealList.worker.js
@@ -0,0 +1,137 @@
+const { Worker } = require("bullmq")
+const Redis = require("ioredis")
+const { getRedisConnectionOptions } = require("../services/redis/connection")
+
+const HOT_DEAL_TTL_SECONDS = 12 * 60 * 60
+const HOT_DEAL_LIMIT = 1000
+const HOT_DEAL_WINDOW_DAYS = 3
+const HOT_DAY_WINDOW_DAYS = 1
+const HOT_WEEK_WINDOW_DAYS = 7
+const HOT_MONTH_WINDOW_DAYS = 30
+
+function createRedisClient() {
+ return new Redis(getRedisConnectionOptions())
+}
+function parseSearchResults(results = []) {
+ const ids = [];
+
+ // 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 value = results[i + 1]; // Örn: ["$", "[{\"id\":20,...}]"]
+
+ try {
+ // Dialect 3 formatında JSON her zaman bir array string'i olarak gelir: [0] = "$", [1] = "[{...}]"
+ // JSON.parse(value[1])[0] diyerek direkt objeye ulaşıyoruz.
+ 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
+ const idFromKey = key.split(":")[2];
+ if (idFromKey) ids.push(Number(idFromKey));
+ }
+ }
+ return ids;
+}
+async function buildHotDealListForRange({ windowDays, listKey, latestKey }) {
+ const redis = createRedisClient()
+
+ try {
+ const now = Date.now()
+ const windowMs = Math.floor(Number(windowDays) * 24 * 60 * 60 * 1000)
+ const cutoffMs = now - windowMs
+
+ console.log(`[hot-list] now=${new Date(now).toISOString()} cutoff=${new Date(cutoffMs).toISOString()}`)
+
+ /**
+ * SORGUNUN ANALİZİ:
+ * 1. @status:{ACTIVE} -> Veritabanında 'ACTIVE' (BÜYÜK HARF) olduğundan emin ol.
+ * 2. @createdAtTs:[${cutoffMs} +inf] -> Sayısal aralık.
+ */
+ const query = `@status:{ACTIVE} @createdAtTs:[${cutoffMs} +inf]`
+
+ console.log(`🔍 Redis Query: FT.SEARCH idx:data:deals "${query}" SORTBY score DESC DIALECT 3`)
+
+ const results = await redis.call(
+ "FT.SEARCH",
+ "idx:data:deals",
+ query,
+ "SORTBY", "score", "DESC",
+ "LIMIT", "0", String(HOT_DEAL_LIMIT),
+ "DIALECT", "3",
+ "RETURN", "1", "$"
+ )
+
+ // Redis kaç tane döküman buldu?
+ const totalFound = results[0] || 0
+ console.log(`📊 Redis Toplam Bulunan: ${totalFound}`)
+
+ const dealIds = parseSearchResults(results)
+
+ const runId = String(now)
+ const payload = {
+ id: runId,
+ createdAt: new Date(now).toISOString(),
+ total: dealIds.length,
+ dealIds,
+ }
+
+ const key = `${listKey}:${runId}`
+ await redis.call("JSON.SET", key, "$", JSON.stringify(payload))
+ await redis.expire(key, HOT_DEAL_TTL_SECONDS)
+ await redis.set(latestKey, runId, "EX", HOT_DEAL_TTL_SECONDS)
+
+ return { id: runId, total: dealIds.length }
+ } catch (error) {
+ console.error("❌ buildHotDealList Hatası:", error.message)
+ throw error
+ } finally {
+ redis.disconnect()
+ }
+}
+
+async function handler() {
+ const results = {}
+ results.hot = await buildHotDealListForRange({
+ windowDays: HOT_DEAL_WINDOW_DAYS,
+ listKey: "lists:hot",
+ latestKey: "lists:hot:latest",
+ })
+ results.hotDay = await buildHotDealListForRange({
+ windowDays: HOT_DAY_WINDOW_DAYS,
+ listKey: "lists:hot_day",
+ latestKey: "lists:hot_day:latest",
+ })
+ results.hotWeek = await buildHotDealListForRange({
+ windowDays: HOT_WEEK_WINDOW_DAYS,
+ listKey: "lists:hot_week",
+ latestKey: "lists:hot_week:latest",
+ })
+ results.hotMonth = await buildHotDealListForRange({
+ windowDays: HOT_MONTH_WINDOW_DAYS,
+ listKey: "lists:hot_month",
+ latestKey: "lists:hot_month:latest",
+ })
+ return results
+}
+
+function startHotDealListWorker() {
+ const worker = new Worker("hotdeal-list", handler, {
+ connection: getRedisConnectionOptions(),
+ concurrency: 1,
+ })
+
+ worker.on("completed", (job) => {
+ console.log(
+ `✅ Hot lists done. hot=${job.returnvalue?.hot?.total ?? 0} day=${job.returnvalue?.hotDay?.total ?? 0} week=${job.returnvalue?.hotWeek?.total ?? 0} month=${job.returnvalue?.hotMonth?.total ?? 0}`
+ )
+ })
+
+ worker.on("failed", (job, err) => {
+ console.error(`❌ Hot Deal Worker Hatası!`, err.message)
+ })
+
+ return worker
+}
+
+module.exports = { startHotDealListWorker }
diff --git a/workers/index.js b/workers/index.js
index 083b9b2..da3ee8e 100644
--- a/workers/index.js
+++ b/workers/index.js
@@ -1,9 +1,38 @@
-require("dotenv").config()
+require("dotenv").config()
const { startDealClassificationWorker } = require("./dealClassification.worker")
const { queue } = require("../jobs/dealClassification.queue")
+const { startHotDealListWorker } = require("./hotDealList.worker")
+const { ensureHotDealListRepeatable } = require("../jobs/hotDealList.queue")
+const { startTrendingDealListWorker } = require("./trendingDealList.worker")
+const { ensureTrendingDealListRepeatable } = require("../jobs/trendingDealList.queue")
+const { startNewDealListWorker } = require("./newDealList.worker")
+const { ensureNewDealListRepeatable } = require("../jobs/newDealList.queue")
+const { startDbSyncWorker } = require("./dbSync.worker")
+const { ensureDbSyncRepeatable } = require("../jobs/dbSync.queue")
startDealClassificationWorker()
console.log("Worker started: deal-classification")
+startHotDealListWorker()
+console.log("Worker started: hotdeal-list")
+startTrendingDealListWorker()
+console.log("Worker started: trendingdeal-list")
+startNewDealListWorker()
+console.log("Worker started: newdeal-list")
+startDbSyncWorker()
+console.log("Worker started: db-sync")
+
+ensureHotDealListRepeatable().catch((err) =>
+ console.error("⌠hotdeal repeatable setup failed", err?.message || err)
+)
+ensureTrendingDealListRepeatable().catch((err) =>
+ console.error("⌠trending repeatable setup failed", err?.message || err)
+)
+ensureNewDealListRepeatable().catch((err) =>
+ console.error("⌠newdeal repeatable setup failed", err?.message || err)
+)
+ensureDbSyncRepeatable().catch((err) =>
+ console.error("⌠db-sync repeatable setup failed", err?.message || err)
+)
setInterval(async () => {
const counts = await queue.getJobCounts("waiting", "active", "completed", "failed", "delayed", "paused")
diff --git a/workers/newDealList.worker.js b/workers/newDealList.worker.js
new file mode 100644
index 0000000..324dbb3
--- /dev/null
+++ b/workers/newDealList.worker.js
@@ -0,0 +1,97 @@
+const { Worker } = require("bullmq")
+const Redis = require("ioredis")
+const { getRedisConnectionOptions } = require("../services/redis/connection")
+
+const NEW_DEAL_TTL_SECONDS = 12 * 60 * 60
+const NEW_DEAL_LIMIT = 1000
+
+function createRedisClient() {
+ return new Redis(getRedisConnectionOptions())
+}
+
+function parseSearchResults(results = []) {
+ const ids = []
+ for (let i = 1; i < results.length; i += 2) {
+ const key = results[i]
+ const value = results[i + 1]
+ try {
+ const [deal] = JSON.parse(value[1])
+ ids.push(Number(deal.id))
+ } catch {
+ const idFromKey = key.split(":")[2]
+ if (idFromKey) ids.push(Number(idFromKey))
+ }
+ }
+ return ids
+}
+
+async function buildNewDealList() {
+ const redis = createRedisClient()
+
+ try {
+ const now = Date.now()
+ const query = "@status:{ACTIVE}"
+
+ const results = await redis.call(
+ "FT.SEARCH",
+ "idx:data:deals",
+ query,
+ "SORTBY",
+ "createdAtTs",
+ "DESC",
+ "LIMIT",
+ "0",
+ String(NEW_DEAL_LIMIT),
+ "DIALECT",
+ "3",
+ "RETURN",
+ "1",
+ "$"
+ )
+
+ const dealIds = parseSearchResults(results)
+
+ const runId = String(now)
+ const payload = {
+ id: runId,
+ createdAt: new Date(now).toISOString(),
+ total: dealIds.length,
+ dealIds,
+ }
+
+ const key = `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)
+
+ return { id: runId, total: dealIds.length }
+ } catch (error) {
+ console.error("❌ buildNewDealList Hatası:", error.message)
+ throw error
+ } finally {
+ redis.disconnect()
+ }
+}
+
+async function handler() {
+ return buildNewDealList()
+}
+
+function startNewDealListWorker() {
+ const worker = new Worker("newdeal-list", handler, {
+ connection: getRedisConnectionOptions(),
+ concurrency: 1,
+ })
+
+ worker.on("completed", (job) => {
+ console.log(`✅ New Deal Listesi Bitti. Bulunan ID Sayısı: ${job.returnvalue?.total}`)
+ })
+
+ worker.on("failed", (job, err) => {
+ console.error(`❌ New Deal Worker Hatası!`, err.message)
+ })
+
+ return worker
+}
+
+module.exports = { startNewDealListWorker }
diff --git a/workers/trendingDealList.worker.js b/workers/trendingDealList.worker.js
new file mode 100644
index 0000000..ec1861c
--- /dev/null
+++ b/workers/trendingDealList.worker.js
@@ -0,0 +1,114 @@
+const { Worker } = require("bullmq")
+const Redis = require("ioredis")
+const { getRedisConnectionOptions } = require("../services/redis/connection")
+
+const TRENDING_DEAL_TTL_SECONDS = 12 * 60 * 60
+const TRENDING_DEAL_LIMIT = 1000
+const TRENDING_DEAL_WINDOW_DAYS = 2 // Test için burayı 30 yapıp deneyebilirsin
+
+function createRedisClient() {
+ return new Redis(getRedisConnectionOptions())
+}
+function parseSearchResults(results = []) {
+ const ids = [];
+
+ // 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 value = results[i + 1]; // Örn: ["$", "[{\"id\":20,...}]"]
+
+ try {
+ // Dialect 3 formatında JSON her zaman bir array string'i olarak gelir: [0] = "$", [1] = "[{...}]"
+ // JSON.parse(value[1])[0] diyerek direkt objeye ulaşıyoruz.
+ 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
+ const idFromKey = key.split(":")[2];
+ if (idFromKey) ids.push(Number(idFromKey));
+ }
+ }
+ return ids;
+}
+
+async function buildTrendingDealList() {
+ const redis = createRedisClient()
+
+ try {
+ const now = Date.now()
+ // windowMs'i tam sayıya yuvarlayalım
+ const windowMs = Math.floor(Number(TRENDING_DEAL_WINDOW_DAYS) * 24 * 60 * 60 * 1000)
+ const cutoffMs = now - windowMs
+
+ // DEBUG: Zaman aralığını kontrol edelim
+ console.log(`🕒 Şu an: ${new Date(now).toISOString()}`)
+ console.log(`🕒 Cutoff (Eşik): ${new Date(cutoffMs).toISOString()}`)
+
+ /**
+ * SORGUNUN ANALİZİ:
+ * 1. @status:{ACTIVE} -> Veritabanında 'ACTIVE' (BÜYÜK HARF) olduğundan emin ol.
+ * 2. @createdAtTs:[${cutoffMs} +inf] -> Sayısal aralık.
+ */
+ const query = `@status:{ACTIVE} @createdAtTs:[${cutoffMs} +inf]`
+
+ console.log(`🔍 Redis Query: FT.SEARCH idx:data:deals "${query}" SORTBY score DESC DIALECT 3`)
+
+ const results = await redis.call(
+ "FT.SEARCH",
+ "idx:data:deals",
+ query,
+ "SORTBY", "score", "DESC",
+ "LIMIT", "0", String(TRENDING_DEAL_LIMIT),
+ "DIALECT", "3",
+ "RETURN", "1", "$"
+ )
+
+ // Redis kaç tane döküman buldu?
+ const totalFound = results[0] || 0
+ console.log(`📊 Redis Toplam Bulunan: ${totalFound}`)
+
+ const dealIds = parseSearchResults(results)
+ const runId = String(now)
+ const payload = {
+ id: runId,
+ createdAt: new Date(now).toISOString(),
+ total: dealIds.length,
+ dealIds,
+ }
+
+ const key = `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)
+
+ return { id: runId, total: dealIds.length }
+ } catch (error) {
+ console.error("❌ buildTrendingDealList Hatası:", error.message)
+ throw error
+ } finally {
+ redis.disconnect()
+ }
+}
+
+async function handler() {
+ return buildTrendingDealList()
+}
+
+function startTrendingDealListWorker() {
+ const worker = new Worker("trendingdeal-list", handler, {
+ connection: getRedisConnectionOptions(),
+ concurrency: 1,
+ })
+
+ worker.on("completed", (job) => {
+ console.log(`✅ Trending Deal Listesi Bitti. Bulunan ID Sayısı: ${job.returnvalue?.total}`)
+ })
+
+ worker.on("failed", (job, err) => {
+ console.error(`❌ Trending Deal Worker Hatası!`, err.message)
+ })
+
+ return worker
+}
+
+module.exports = { startTrendingDealListWorker }