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 }