manychanges

This commit is contained in:
cureb 2026-02-04 06:39:10 +00:00
parent 3a678fec20
commit e380d084d9
123 changed files with 20338 additions and 670 deletions

View File

@ -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 },

View File

@ -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),

View File

@ -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,

View File

@ -8,7 +8,6 @@ function mapLoginRequestToLoginInput(input) {
function mapLoginResultToResponse(result) {
return {
token: result.accessToken, // <-- KRİTİK
user: result.user,
}
}

View File

@ -9,7 +9,6 @@ function mapRegisterRequestToRegisterInput(input) {
function mapRegisterResultToResponse(result) {
return {
token: result.accessToken, // <-- KRİTİK
user: result.user,
}
}

View File

@ -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) : [],
}
}

34
agents.md Normal file
View File

@ -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?"

39
db/badge.db.js Normal file
View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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"),

103
db/dealAnalytics.db.js Normal file
View File

@ -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,
}

54
db/dealReport.db.js Normal file
View File

@ -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,
}

52
db/dealSave.db.js Normal file
View File

@ -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,
}

27
db/notification.db.js Normal file
View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

31
db/userBadge.db.js Normal file
View File

@ -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,
}

38
db/userNote.db.js Normal file
View File

@ -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,
}

View File

@ -1,19 +1,19 @@
const prisma = require("./client");
async function voteDealTx({ dealId, userId, voteType }) {
return prisma.$transaction(async (db) => {
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 },
});
data: { dealId, userId, voteType, createdAt: timestamp },
})
// current state
await db.dealVote.upsert({
@ -22,36 +22,53 @@ async function voteDealTx({ dealId, userId, voteType }) {
dealId,
userId,
voteType,
lastVotedAt: new Date(),
createdAt: timestamp,
lastVotedAt: timestamp,
},
update: {
voteType,
lastVotedAt: new Date(),
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,
};

1413
docs/openapi.json Normal file

File diff suppressed because it is too large Load Diff

21
docs/swagger.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HotTRDeals API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.ui = SwaggerUIBundle({
url: '/api/openapi.json',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis],
});
</script>
</body>
</html>

20
jobs/dbSync.queue.js Normal file
View File

@ -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 }

View File

@ -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 })

20
jobs/hotDealList.queue.js Normal file
View File

@ -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 }

20
jobs/newDealList.queue.js Normal file
View File

@ -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 }

View File

@ -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 }

View File

@ -2,10 +2,12 @@ const jwt = require("jsonwebtoken")
function getBearerToken(req) {
const h = req.headers.authorization
if (!h) return null
if (h) {
const [type, token] = h.split(" ")
if (type !== "Bearer" || !token) return null
return token
if (type === "Bearer" && token) return token
}
const cookieToken = req.cookies?.at
return cookieToken || null
}
module.exports = function optionalAuth(req, res, next) {

View File

@ -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

View File

@ -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
if (h) {
const [type, token] = h.split(" ")
if (type !== "Bearer" || !token) return null
return token
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" })

View File

@ -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" })
}
}
}

132
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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": "TVye 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 chipsete 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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": 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." }
{ "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": 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ı." }
]

242
prisma/categories_org.json Normal file
View File

@ -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": "TVye 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 chipsete 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." }
]

File diff suppressed because it is too large Load Diff

231
prisma/deals_org.json Normal file
View File

@ -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"
}
]

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Deal" ADD COLUMN "maxNotifiedMilestone" INTEGER NOT NULL DEFAULT 0;

View File

@ -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;

View File

@ -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';

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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])
}

View File

@ -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"]

92
prisma/sellers.json Normal file
View File

@ -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"
}
]

View File

@ -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

View File

@ -13,32 +13,37 @@ const { mapMeRequestToUserId, mapMeResultToResponse } = require("../adapters/res
const { auth } = endpoints
// NOT: app.jsde 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,
secure: isProd,
sameSite: "lax",
path: "/",
}
}
// PROD: cross-site kullanacaksan (frontend ayrı domain)
return {
httpOnly: true,
secure: true,
sameSite: "none",
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.",
})
}
}
}
)
@ -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

19
routes/badge.routes.js Normal file
View File

@ -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

View File

@ -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 });
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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" })

55
routes/upload.routes.js Normal file
View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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)
})

306
services/admin.service.js Normal file
View File

@ -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,
}

View File

@ -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 }

51
services/audit.service.js Normal file
View File

@ -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,
}

43
services/auditActions.js Normal file
View File

@ -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,
}

View File

@ -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) + DBde 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) {
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
}

View File

@ -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 } },
},
},
},
}
)

84
services/badge.service.js Normal file
View File

@ -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,
}

View File

@ -33,6 +33,7 @@ async function getDealsByCategoryId(categoryId, { page = 1, limit = 10, filters
scope,
baseWhere: { categoryId: { in: categoryIds } },
filters,
useRedisSearch: true,
})
}

View File

@ -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 } } }
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ı.")
const deal = await ensureDealCached(dealId)
if (!deal) throw new Error("Deal bulunamadi.")
if (deal.status !== "ACTIVE" && deal.status !== "EXPIRED") {
throw new Error("Bu deal için yorum açılamaz.")
throw new Error("Bu deal icin yorum acilamaz.")
}
// ✅ 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.")
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 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 }
}
const comment = await commentDB.createComment(
{
text: trimmed,
const user = await userDB.findUser(
{ id: userId },
{ select: { id: true, username: true, avatarUrl: true } }
)
if (!user) throw new Error("Kullanici bulunamadi.")
const createdAt = new Date()
const commentId = await generateCommentId()
const comment = {
id: commentId,
text: text.trim(),
userId,
dealId,
parentId: parent ? parent.id : null,
},
{ include },
tx
)
createdAt,
likeCount: 0,
repliesCount: 0,
user,
}
await dealDB.updateDeal(
{ id: dealId },
{ commentCount: { increment: 1 } },
{},
tx
)
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 } }
)
if (!comment || comment.deletedAt) throw new Error("Yorum bulunamadı.")
if (comment.userId !== userId) throw new Error("Bu yorumu silme yetkin yok.")
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
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.")
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,
}

View File

@ -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 = {

View File

@ -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,8 +237,22 @@ function buildFilterWhere(rawFilters = {}, { allowStatus = false } = {}) {
const saleTypes = parseEnumList(rawFilters.saleType, SALE_TYPES)
if (saleTypes?.length) {
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)
if (affiliateTypes?.length) {
@ -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,
}

View File

@ -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" },

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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 }

View File

@ -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." }
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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 }

14
services/redis/client.js Normal file
View File

@ -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 }

View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

Some files were not shown because too many files have changed in this diff Show More