Compare commits

..

4 Commits

Author SHA1 Message Date
cureb
5ac0a9e479 latest-before-monorepo 2026-02-09 21:47:55 +00:00
cureb
5eb79565c9 updated 2026-02-07 22:42:02 +00:00
cureb
e380d084d9 manychanges 2026-02-04 06:39:10 +00:00
cureb
3a678fec20 latest 2026-01-29 00:45:52 +00:00
155 changed files with 26643 additions and 1043 deletions

View File

@ -1,14 +1,53 @@
function mapCreateDealRequestToDealCreateData(payload, userId) { const { toSafeRedirectUrl } = require("../../utils/urlSafety")
const { title, description, url, price, sellerName } = payload const {
sanitizeDealDescriptionHtml,
sanitizeOptionalPlainText,
sanitizeRequiredPlainText,
} = require("../../utils/inputSanitizer")
return { function mapCreateDealRequestToDealCreateData(payload, userId) {
const {
title, title,
description: description ?? null, description,
url: url ?? null, url,
price,
originalPrice,
sellerName,
customSeller,
couponCode,
location,
discountType,
discountValue,
} = payload
const normalizedTitle = sanitizeRequiredPlainText(title, { fieldName: "TITLE", maxLength: 300 })
const normalizedDescription = sanitizeDealDescriptionHtml(description)
const normalizedCouponCode = sanitizeOptionalPlainText(couponCode, { maxLength: 120 })
const normalizedLocation = sanitizeOptionalPlainText(location, { maxLength: 150 })
const normalizedSellerName = sanitizeOptionalPlainText(sellerName ?? customSeller, {
maxLength: 120,
})
const normalizedUrl = toSafeRedirectUrl(url)
const hasUrl = Boolean(normalizedUrl)
const saleType = !hasUrl ? "OFFLINE" : normalizedCouponCode ? "CODE" : "ONLINE"
const hasPrice = price != null
const normalizedDiscountType = hasPrice ? null : discountType ?? null
const normalizedDiscountValue = hasPrice ? null : discountValue ?? null
return {
title: normalizedTitle,
description: normalizedDescription,
url: normalizedUrl,
price: price ?? null, price: price ?? null,
originalPrice: originalPrice ?? null,
couponCode: normalizedCouponCode,
location: normalizedLocation,
discountType: normalizedDiscountType,
discountValue: normalizedDiscountValue,
saletype: saleType,
// Burada customSeller yazıyoruz; servis gerektiğinde ilişkilendiriyor. // Burada customSeller yazıyoruz; servis gerektiğinde ilişkilendiriyor.
customSeller: sellerName ?? null, customSeller: normalizedSellerName,
user: { user: {
connect: { id: userId }, connect: { id: userId },

View File

@ -1,16 +1,25 @@
const formatDateAsString = (value) => const formatDateAsString = (value) =>
value instanceof Date ? value.toISOString() : value ?? null value instanceof Date ? value.toISOString() : value ?? null
const { normalizeMediaPath } = require("../../utils/mediaPath")
function mapCommentToDealCommentResponse(comment) { function mapCommentToDealCommentResponse(comment) {
return { return {
id: comment.id, id: comment.id,
text: comment.text, // eğer DB'de content ise burada text'e çevir text: comment.text, // eğer DB'de content ise burada text'e çevir
createdAt: formatDateAsString(comment.createdAt), createdAt: formatDateAsString(comment.createdAt),
parentId:comment.parentId, parentId: comment.parentId ?? null,
likeCount: Number.isFinite(comment.likeCount) ? comment.likeCount : 0,
repliesCount: Number.isFinite(comment.repliesCount)
? comment.repliesCount
: comment._count?.replies ?? 0,
hasReplies: Number.isFinite(comment.repliesCount)
? comment.repliesCount > 0
: (comment._count?.replies ?? 0) > 0,
myLike: Boolean(comment.myLike),
user: { user: {
id: comment.user.id, id: comment.user.id,
username: comment.user.username, username: comment.user.username,
avatarUrl: comment.user.avatarUrl ?? null, avatarUrl: normalizeMediaPath(comment.user.avatarUrl) ?? null,
}, },
} }
} }

View File

@ -1,4 +1,5 @@
const formatDateAsString = (value) => (value instanceof Date ? value.toISOString() : value ?? null) const formatDateAsString = (value) => (value instanceof Date ? value.toISOString() : value ?? null)
const { normalizeMediaPath } = require("../../utils/mediaPath")
function mapDealToDealCardResponse(deal) { function mapDealToDealCardResponse(deal) {
return { return {
@ -6,15 +7,23 @@ function mapDealToDealCardResponse(deal) {
title: deal.title, title: deal.title,
description: deal.description || "", description: deal.description || "",
price: deal.price ?? null, 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,
barcodeId: deal.barcodeId ?? null,
score: deal.score, score: deal.score,
commentsCount: deal.commentCount, commentsCount: deal.commentCount,
url:deal.url, hasLink: Boolean(deal.url),
status: deal.status, status: deal.status,
saleType: deal.saletype, saleType: deal.saletype,
affiliateType: deal.affiliateType, affiliateType: deal.affiliateType,
myVote: deal.myVote ?? 0, myVote: deal.myVote ?? 0,
isSaved: Boolean(deal.isSaved),
createdAt: formatDateAsString(deal.createdAt), createdAt: formatDateAsString(deal.createdAt),
updatedAt: formatDateAsString(deal.updatedAt), updatedAt: formatDateAsString(deal.updatedAt),
@ -22,7 +31,7 @@ function mapDealToDealCardResponse(deal) {
user: { user: {
id: deal.user.id, id: deal.user.id,
username: deal.user.username, username: deal.user.username,
avatarUrl: deal.user.avatarUrl ?? null, avatarUrl: normalizeMediaPath(deal.user.avatarUrl) ?? null,
}, },
seller: deal.seller seller: deal.seller
@ -35,7 +44,7 @@ function mapDealToDealCardResponse(deal) {
url: null, url: null,
}, },
imageUrl: deal.images?.[0]?.imageUrl || "", imageUrl: normalizeMediaPath(deal.images?.[0]?.imageUrl) || "",
} }
} }

View File

@ -1,5 +1,6 @@
// adapters/responses/dealDetail.adapter.js // adapters/responses/dealDetail.adapter.js
const {mapBreadcrumbToResponse} =require( "./breadCrumb.adapter") const {mapBreadcrumbToResponse} =require( "./breadCrumb.adapter")
const { normalizeMediaPath } = require("../../utils/mediaPath")
const formatDateAsString = (value) => const formatDateAsString = (value) =>
value instanceof Date ? value.toISOString() : value ?? null value instanceof Date ? value.toISOString() : value ?? null
@ -35,7 +36,7 @@ function mapSimilarDealItem(d) {
title: d.title, title: d.title,
price: d.price ?? null, price: d.price ?? null,
score: Number.isFinite(d.score) ? d.score : 0, score: Number.isFinite(d.score) ? d.score : 0,
imageUrl: d.imageUrl || "", imageUrl: normalizeMediaPath(d.imageUrl) || "",
sellerName: d.sellerName || "Bilinmiyor", sellerName: d.sellerName || "Bilinmiyor",
createdAt: formatDateAsString(d.createdAt), // SimilarDealSchema: nullable OK createdAt: formatDateAsString(d.createdAt), // SimilarDealSchema: nullable OK
// url: d.url ?? null, // url: d.url ?? null,
@ -53,9 +54,18 @@ function mapDealToDealDetailResponse(deal) {
id: deal.id, id: deal.id,
title: deal.title, title: deal.title,
description: deal.description || "", description: deal.description || "",
url: deal.url ?? null, hasLink: Boolean(deal.url),
price: deal.price ?? null, 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,
barcodeId: deal.barcodeId ?? null,
score: Number.isFinite(deal.score) ? deal.score : 0, score: Number.isFinite(deal.score) ? deal.score : 0,
myVote: deal.myVote ?? 0,
isSaved: Boolean(deal.isSaved),
commentsCount: deal._count?.comments ?? 0, commentsCount: deal._count?.comments ?? 0,
@ -69,7 +79,11 @@ function mapDealToDealDetailResponse(deal) {
user: { user: {
id: deal.user.id, id: deal.user.id,
username: deal.user.username, username: deal.user.username,
avatarUrl: deal.user.avatarUrl ?? null, avatarUrl: normalizeMediaPath(deal.user.avatarUrl) ?? null,
},
userStats: {
totalLikes: deal.userStats?.totalLikes ?? 0,
totalDeals: deal.userStats?.totalDeals ?? 0,
}, },
// ✅ FIX: SellerSummarySchema genelde id ister -> custom seller için -1 // ✅ FIX: SellerSummarySchema genelde id ister -> custom seller için -1
@ -87,7 +101,7 @@ function mapDealToDealDetailResponse(deal) {
images: (deal.images || []).map((img) => ({ images: (deal.images || []).map((img) => ({
id: img.id, id: img.id,
imageUrl: img.imageUrl, imageUrl: normalizeMediaPath(img.imageUrl) || "",
order: img.order, order: img.order,
})), })),
@ -98,11 +112,20 @@ function mapDealToDealDetailResponse(deal) {
return { return {
id: comment.id, id: comment.id,
text: comment.text, text: comment.text,
parentId: comment.parentId ?? null,
likeCount: Number.isFinite(comment.likeCount) ? comment.likeCount : 0,
repliesCount: Number.isFinite(comment.repliesCount)
? comment.repliesCount
: comment._count?.replies ?? 0,
hasReplies: Number.isFinite(comment.repliesCount)
? comment.repliesCount > 0
: (comment._count?.replies ?? 0) > 0,
myLike: Boolean(comment.myLike),
createdAt: requiredIsoString(comment.createdAt, "comment.createdAt"), createdAt: requiredIsoString(comment.createdAt, "comment.createdAt"),
user: { user: {
id: comment.user.id, id: comment.user.id,
username: comment.user.username, username: comment.user.username,
avatarUrl: comment.user.avatarUrl ?? null, avatarUrl: normalizeMediaPath(comment.user.avatarUrl) ?? null,
}, },
} }
}), }),

View File

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

View File

@ -1,3 +1,5 @@
const { normalizeMediaPath } = require("../../utils/mediaPath")
function mapMeRequestToUserId(req) { function mapMeRequestToUserId(req) {
// authMiddleware -> req.user.userId // authMiddleware -> req.user.userId
return req.user.userId; return req.user.userId;
@ -8,7 +10,8 @@ function mapMeResultToResponse(user) {
id: user.id, id: user.id,
username: user.username, username: user.username,
email: user.email, email: user.email,
avatarUrl: user.avatarUrl ?? null, avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null,
role: user.role,
}; };
} }

View File

@ -1,12 +1,13 @@
const formatDateAsString = (value) => const formatDateAsString = (value) =>
value instanceof Date ? value.toISOString() : value ?? null value instanceof Date ? value.toISOString() : value ?? null
const { normalizeMediaPath } = require("../../utils/mediaPath")
// adapters/responses/publicUser.adapter.js // adapters/responses/publicUser.adapter.js
function mapUserToPublicUserSummaryResponse(user) { function mapUserToPublicUserSummaryResponse(user) {
return { return {
id: user.id, id: user.id,
username: user.username, username: user.username,
avatarUrl: user.avatarUrl ?? null, avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null,
} }
} }
@ -14,7 +15,7 @@ function mapUserToPublicUserDetailsResponse(user) {
return { return {
id: user.id, id: user.id,
username: user.username, username: user.username,
avatarUrl: user.avatarUrl ?? null, avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null,
email: user.email, email: user.email,
createdAt: formatDateAsString(user.createdAt), // ISO string createdAt: formatDateAsString(user.createdAt), // ISO string
} }

View File

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

View File

@ -0,0 +1,14 @@
function mapSellerToSellerDetailsResponse(seller) {
if (!seller) return null
return {
id: seller.id,
name: seller.name,
url: seller.url || null,
logoUrl: seller.sellerLogo || null,
}
}
module.exports = {
mapSellerToSellerDetailsResponse,
}

View File

@ -3,13 +3,33 @@ const dealCardAdapter = require("./dealCard.adapter")
const dealCommentAdapter = require("./comment.adapter") const dealCommentAdapter = require("./comment.adapter")
const publicUserAdapter = require("./publicUser.adapter") // yoksa yaz const publicUserAdapter = require("./publicUser.adapter") // yoksa yaz
const userProfileStatsAdapter = require("./userProfileStats.adapter") const userProfileStatsAdapter = require("./userProfileStats.adapter")
const { normalizeMediaPath } = require("../../utils/mediaPath")
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: normalizeMediaPath(item.badge.iconUrl) ?? null,
description: item.badge.description ?? null,
}
: null,
earnedAt: formatDateAsString(item.earnedAt),
}
}
function mapUserProfileToResponse({ user, deals, comments, stats, badges }) {
return { return {
user: publicUserAdapter.mapUserToPublicUserDetailsResponse(user), user: publicUserAdapter.mapUserToPublicUserDetailsResponse(user),
stats: userProfileStatsAdapter.mapUserProfileStatsToResponse(stats), stats: userProfileStatsAdapter.mapUserProfileStatsToResponse(stats),
deals: deals.map(dealCardAdapter.mapDealToDealCardResponse), deals: deals.map(dealCardAdapter.mapDealToDealCardResponse),
comments: comments.map(dealCommentAdapter.mapCommentToUserCommentResponse), 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,63 +1,128 @@
const prisma = require("./client"); // Prisma client const prisma = require("./client") // Prisma client
/** function getDb(db) {
* Kategoriyi slug'a göre bul return db || prisma
*/ }
async function findCategoryBySlug(slug, options = {}) {
const s = String(slug ?? "").trim().toLowerCase(); async function findCategoryById(id, options = {}) {
return prisma.category.findUnique({ const cid = Number(id)
where: { slug: s }, if (!Number.isInteger(cid)) return null
return getDb(options.db).category.findUnique({
where: { id: cid },
select: options.select || undefined, select: options.select || undefined,
include: options.include || undefined, include: options.include || undefined,
}); })
} }
/** /**
* Kategorinin fırsatlarını al * Kategoriyi slug'a gore bul
* Sayfalama ve filtreler ile fırsatları çekiyoruz */
async function findCategoryBySlug(slug, options = {}) {
const s = String(slug ?? "").trim().toLowerCase()
return getDb(options.db).category.findUnique({
where: { slug: s },
select: options.select || undefined,
include: options.include || undefined,
})
}
/**
* Kategorinin firsatlarini al
* Sayfalama ve filtreler ile firsatlari cekiyoruz
*/ */
async function listCategoryDeals({ where = {}, skip = 0, take = 10 }) { async function listCategoryDeals({ where = {}, skip = 0, take = 10 }) {
return prisma.deal.findMany({ return prisma.deal.findMany({
where, where,
skip, skip,
take, take,
orderBy: { createdAt: "desc" }, // Yeni fırsatlar önce gelsin orderBy: { createdAt: "desc" },
}); })
}
async function getCategoryDescendantIds(categoryId) {
const rootId = Number(categoryId)
if (!Number.isInteger(rootId) || rootId <= 0) {
throw new Error("categoryId must be int")
}
const seen = new Set([rootId])
let queue = [rootId]
while (queue.length > 0) {
const children = await prisma.category.findMany({
where: { parentId: { in: queue } },
select: { id: true },
})
const next = []
for (const child of children) {
if (!seen.has(child.id)) {
seen.add(child.id)
next.push(child.id)
}
}
queue = next
}
return Array.from(seen)
} }
async function getCategoryBreadcrumb(categoryId, { includeUndefined = false } = {}) { async function getCategoryBreadcrumb(categoryId, { includeUndefined = false } = {}) {
let currentId = Number(categoryId); let currentId = Number(categoryId)
if (!Number.isInteger(currentId)) throw new Error("categoryId must be int"); if (!Number.isInteger(currentId)) throw new Error("categoryId must be int")
const path = []; const path = []
const visited = new Set(); const visited = new Set()
// Bu döngü, root kategoriye kadar gidip breadcrumb oluşturacak
while (true) { while (true) {
if (visited.has(currentId)) break; if (visited.has(currentId)) break
visited.add(currentId); visited.add(currentId)
const cat = await prisma.category.findUnique({ const cat = await prisma.category.findUnique({
where: { id: currentId }, where: { id: currentId },
select: { id: true, name: true, slug: true, parentId: true }, // Yalnızca gerekli alanları seçiyoruz select: { id: true, name: true, slug: true, parentId: true },
}); })
if (!cat) break; if (!cat) break
// Undefined'ı istersen breadcrumb'ta göstermiyoruz
if (includeUndefined || cat.id !== 0) { if (includeUndefined || cat.id !== 0) {
path.push({ id: cat.id, name: cat.name, slug: cat.slug }); path.push({ id: cat.id, name: cat.name, slug: cat.slug })
} }
if (cat.parentId === null || cat.parentId === undefined) break; if (cat.parentId === null || cat.parentId === undefined) break
currentId = cat.parentId; // Bir üst kategoriye geçiyoruz currentId = cat.parentId
} }
return path.reverse(); // Kökten başlayarak, kategoriyi en son eklediğimiz için tersine çeviriyoruz 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 = { module.exports = {
getCategoryBreadcrumb, getCategoryBreadcrumb,
findCategoryById,
findCategoryBySlug, findCategoryBySlug,
listCategoryDeals, listCategoryDeals,
}; listCategories,
getCategoryDescendantIds,
createCategory,
updateCategory,
}

View File

@ -4,17 +4,27 @@ function getDb(db) {
return db || prisma return db || prisma
} }
function withDeletedFilter(where = {}, options = {}) {
if (options.includeDeleted || Object.prototype.hasOwnProperty.call(where, "deletedAt")) {
return where
}
return { AND: [where, { deletedAt: null }] }
}
async function findComments(where, options = {}) { async function findComments(where, options = {}) {
return prisma.comment.findMany({ return prisma.comment.findMany({
where, where: withDeletedFilter(where, options),
include: options.include || undefined, include: options.include || undefined,
select: options.select || undefined, select: options.select || undefined,
orderBy: options.orderBy || { createdAt: "desc" }, orderBy: options.orderBy || { createdAt: "desc" },
skip: Number.isInteger(options.skip) ? options.skip : undefined,
take: Number.isInteger(options.take) ? options.take : undefined,
}) })
} }
async function findComment(where, options = {}) { async function findComment(where, options = {}) {
return prisma.comment.findFirst({ return prisma.comment.findFirst({
where, where: withDeletedFilter(where, options),
include: options.include || undefined, include: options.include || undefined,
select: options.select || undefined, select: options.select || undefined,
orderBy: options.orderBy || { createdAt: "desc" }, orderBy: options.orderBy || { createdAt: "desc" },
@ -29,12 +39,18 @@ async function createComment(data, options = {}, db) {
}) })
} }
async function deleteComment(where) { async function deleteComment(where, db) {
return prisma.comment.delete({ where })
}
async function countComments(where = {}, db) {
const p = getDb(db) const p = getDb(db)
return p.comment.count({ where }) return p.comment.delete({ where })
}
async function softDeleteComment(where, db) {
const p = getDb(db)
return p.comment.updateMany({ where, data: { deletedAt: new Date() } })
}
async function countComments(where = {}, db, options = {}) {
const p = getDb(db)
return p.comment.count({ where: withDeletedFilter(where, options) })
} }
@ -43,5 +59,6 @@ module.exports = {
countComments, countComments,
createComment, createComment,
deleteComment, deleteComment,
softDeleteComment,
findComment findComment
} }

132
db/commentLike.db.js Normal file
View File

@ -0,0 +1,132 @@
const prisma = require("./client")
const { Prisma } = require("@prisma/client")
async function findLike(commentId, userId, db) {
const p = db || prisma
return p.commentLike.findUnique({
where: { commentId_userId: { commentId, userId } },
})
}
async function findLikesByUserAndCommentIds(userId, commentIds, db) {
const p = db || prisma
return p.commentLike.findMany({
where: { userId, commentId: { in: commentIds } },
select: { commentId: true },
})
}
async function setCommentLike({ commentId, userId, like }) {
return prisma.$transaction(async (tx) => {
const comment = await tx.comment.findUnique({
where: { id: commentId },
select: { id: true, likeCount: true },
})
if (!comment) throw new Error("Yorum bulunamadı.")
const existing = await findLike(commentId, userId, tx)
if (like) {
if (existing) {
return { liked: true, delta: 0, likeCount: comment.likeCount }
}
await tx.commentLike.create({
data: { commentId, userId },
})
const updated = await tx.comment.update({
where: { id: commentId },
data: { likeCount: { increment: 1 } },
select: { likeCount: true },
})
return { liked: true, delta: 1, likeCount: updated.likeCount }
}
if (!existing) {
return { liked: false, delta: 0, likeCount: comment.likeCount }
}
await tx.commentLike.delete({
where: { commentId_userId: { commentId, userId } },
})
const updated = await tx.comment.update({
where: { id: commentId },
data: { likeCount: { decrement: 1 } },
select: { likeCount: true },
})
return { liked: false, delta: -1, likeCount: updated.likeCount }
})
}
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,63 +4,9 @@ function getDb(db) {
return db || prisma return db || prisma
} }
async function findDeals(where = {}, options = {}, db) {
const DEAL_CARD_INCLUDE = { const p = getDb(db)
user: { select: { id: true, username: true, avatarUrl: true } }, return p.deal.findMany({
seller: { select: { id: true, name: true, url: true } },
images: {
orderBy: { order: "asc" },
take: 1,
select: { imageUrl: true },
},
}
async function getDealCards({ where = {}, skip = 0, take = 10, orderBy = [{ createdAt: "desc" }] }) {
try {
// Prisma sorgusunu çalıştırıyoruz ve genelleştirilmiş formatta alıyoruz
return await prisma.deal.findMany({
where,
skip,
take,
orderBy,
include: DEAL_CARD_INCLUDE, // Her zaman bu alanları dahil ediyoruz
})
} catch (err) {
throw new Error(`Fırsatlar alınırken bir hata oluştu: ${err.message}`)
}
}
/**
* Sayfalama ve diğer parametrelerle deal'leri çeker
*
* @param {object} params - where, skip, take, orderBy parametreleri
* @returns {object} - Deal card'leri ve toplam sayıyı döner
*/
async function getPaginatedDealCards({ where = {}, page = 1, limit = 10, orderBy = [{ createdAt: "desc" }] }) {
const pagination = clampPagination({ page, limit })
// Deal card verilerini ve toplam sayıyı alıyoruz
const [deals, total] = await Promise.all([
getDealCards({
where,
skip: pagination.skip,
take: pagination.limit,
orderBy,
}),
countDeals(where), // Total count almak için
])
return {
page: pagination.page,
total,
totalPages: Math.ceil(total / pagination.limit),
results: deals, // Burada raw data döndürülüyor, map'lemiyoruz
}
}
async function findDeals(where = {}, options = {}) {
return prisma.deal.findMany({
where, where,
include: options.include || undefined, include: options.include || undefined,
select: options.select || undefined, select: options.select || undefined,
@ -70,39 +16,16 @@ async function findDeals(where = {}, options = {}) {
}) })
} }
async function findSimilarCandidatesByCategory(categoryId, excludeDealId, { take = 80 } = {}) { async function findSimilarCandidates(where, options = {}, db) {
const safeTake = Math.min(Math.max(Number(take) || 80, 1), 200) const p = getDb(db)
const safeTake = Math.min(Math.max(Number(options.take) || 30, 1), 200)
return prisma.deal.findMany({ return p.deal.findMany({
where: { where,
id: { not: Number(excludeDealId) }, orderBy: options.orderBy || [{ score: "desc" }, { createdAt: "desc" }],
status: "ACTIVE",
categoryId: Number(categoryId),
},
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
take: safeTake, take: safeTake,
include: { include: options.include || undefined,
seller: { select: { id: true, name: true, url: true } }, select: options.select || undefined,
images: { take: 1, orderBy: { order: "asc" }, select: { imageUrl: true } },
},
})
}
async function findSimilarCandidatesBySeller(sellerId, excludeDealId, { take = 30 } = {}) {
const safeTake = Math.min(Math.max(Number(take) || 30, 1), 200)
return prisma.deal.findMany({
where: {
id: { not: Number(excludeDealId) },
status: "ACTIVE",
sellerId: Number(sellerId),
},
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
take: safeTake,
include: {
seller: { select: { id: true, name: true, url: true } },
images: { take: 1, orderBy: { order: "asc" }, select: { imageUrl: true } },
},
}) })
} }
@ -115,9 +38,9 @@ async function findDeal(where, options = {}, db) {
}) })
} }
async function createDeal(data, options = {}, db) {
async function createDeal(data, options = {}) { const p = getDb(db)
return prisma.deal.create({ return p.deal.create({
data, data,
include: options.include || undefined, include: options.include || undefined,
select: options.select || undefined, select: options.select || undefined,
@ -134,28 +57,32 @@ async function updateDeal(where, data, options = {}, db) {
}) })
} }
async function countDeals(where = {}) { async function countDeals(where = {}, db) {
return prisma.deal.count({ where }) const p = getDb(db)
return p.deal.count({ where })
} }
async function findVotes(where = {}, options = {}) { async function findVotes(where = {}, options = {}, db) {
return prisma.dealVote.findMany({ const p = getDb(db)
return p.dealVote.findMany({
where, where,
include: options.include || undefined, include: options.include || undefined,
select: options.select || undefined, select: options.select || undefined,
}) })
} }
async function createVote(data, options = {}) { async function createVote(data, options = {}, db) {
return prisma.dealVote.create({ const p = getDb(db)
return p.dealVote.create({
data, data,
include: options.include || undefined, include: options.include || undefined,
select: options.select || undefined, select: options.select || undefined,
}) })
} }
async function updateVote(where, data, options = {}) { async function updateVote(where, data, options = {}, db) {
return prisma.dealVote.update({ const p = getDb(db)
return p.dealVote.update({
where, where,
data, data,
include: options.include || undefined, include: options.include || undefined,
@ -163,14 +90,9 @@ async function updateVote(where, data, options = {}) {
}) })
} }
async function countVotes(where = {}) { async function countVotes(where = {}, db) {
return prisma.dealVote.count({ where }) const p = getDb(db)
} return p.dealVote.count({ where })
async function getDealWithImages(dealId) {
return prisma.deal.findUnique({
where: { id: dealId },
include: { images: { orderBy: { order: "asc" } } },
});
} }
async function aggregateDeals(where = {}, db) { async function aggregateDeals(where = {}, db) {
@ -182,12 +104,9 @@ async function aggregateDeals(where = {}, db) {
}) })
} }
module.exports = { module.exports = {
findDeals, findDeals,
aggregateDeals, findSimilarCandidates,
getDealWithImages,
findDeal, findDeal,
createDeal, createDeal,
updateDeal, updateDeal,
@ -196,8 +115,5 @@ module.exports = {
createVote, createVote,
updateVote, updateVote,
countVotes, countVotes,
findSimilarCandidatesByCategory, aggregateDeals,
findSimilarCandidatesBySeller,
getDealCards,
getPaginatedDealCards
} }

View File

@ -4,6 +4,7 @@ const prisma = require("./client")
async function upsertDealAiReview(dealId, input = {}) { async function upsertDealAiReview(dealId, input = {}) {
const data = { const data = {
bestCategoryId: input.bestCategoryId ?? input.best_category_id ?? 0, bestCategoryId: input.bestCategoryId ?? input.best_category_id ?? 0,
tags: Array.isArray(input.tags) ? input.tags : [],
needsReview: Boolean(input.needsReview ?? input.needs_review ?? false), needsReview: Boolean(input.needsReview ?? input.needs_review ?? false),
hasIssue: Boolean(input.hasIssue ?? input.has_issue ?? false), hasIssue: Boolean(input.hasIssue ?? input.has_issue ?? false),
issueType: (input.issueType ?? input.issue_type ?? "NONE"), issueType: (input.issueType ?? input.issue_type ?? "NONE"),

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

@ -0,0 +1,134 @@
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 }
}
async function applyDealTotalsBatch(increments = []) {
const data = (Array.isArray(increments) ? increments : []).filter(
(item) => item && Number.isInteger(Number(item.dealId))
)
if (!data.length) return { updated: 0 }
await prisma.$transaction(async (tx) => {
for (const inc of data) {
const dealId = Number(inc.dealId)
if (!Number.isInteger(dealId) || dealId <= 0) continue
await tx.dealAnalyticsTotal.upsert({
where: { dealId },
create: {
dealId,
impressions: Number(inc.impressions || 0),
views: Number(inc.views || 0),
clicks: Number(inc.clicks || 0),
},
update: {
impressions: { increment: Number(inc.impressions || 0) },
views: { increment: Number(inc.views || 0) },
clicks: { increment: Number(inc.clicks || 0) },
},
})
}
})
return { updated: data.length }
}
module.exports = {
ensureTotalsForDealIds,
getTotalsByDealIds,
applyDealEventBatch,
applyDealTotalsBatch,
}

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 = { module.exports = {
findSeller, findSeller,
findSellerByDomain, 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 = { module.exports = {
findUser, findUser,
updateUser, 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,
}

View File

@ -0,0 +1,161 @@
const prisma = require("./client")
const DEFAULT_SATURATION_RATIO = 0.3
const DEFAULT_TX_USER_CHUNK_SIZE = Math.max(
1,
Number(process.env.USER_INTEREST_DB_TX_USER_CHUNK_SIZE) || 200
)
function getDb(db) {
return db || prisma
}
function normalizePositiveInt(value) {
const num = Number(value)
if (!Number.isInteger(num) || num <= 0) return null
return num
}
function normalizePoints(value) {
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) return null
return Math.floor(num)
}
function normalizeScores(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}
return { ...raw }
}
function normalizeSaturationRatio(value) {
const num = Number(value)
if (!Number.isFinite(num)) return DEFAULT_SATURATION_RATIO
if (num <= 0 || num >= 1) return DEFAULT_SATURATION_RATIO
return num
}
function getMaxAllowedBySaturation({ currentCategoryScore, totalScore, ratio }) {
const current = Number(currentCategoryScore) || 0
const total = Number(totalScore) || 0
const otherTotal = Math.max(0, total - current)
if (otherTotal <= 0) return Number.POSITIVE_INFINITY
return Math.floor((otherTotal * ratio) / (1 - ratio))
}
function aggregateIncrements(increments = []) {
const map = new Map()
for (const item of Array.isArray(increments) ? increments : []) {
const userId = normalizePositiveInt(item?.userId)
const categoryId = normalizePositiveInt(item?.categoryId)
const points = normalizePoints(item?.points)
if (!userId || !categoryId || !points) continue
const key = `${userId}:${categoryId}`
map.set(key, (map.get(key) || 0) + points)
}
const groupedByUser = new Map()
for (const [key, points] of map.entries()) {
const [userIdRaw, categoryIdRaw] = key.split(":")
const userId = Number(userIdRaw)
const categoryId = Number(categoryIdRaw)
if (!groupedByUser.has(userId)) groupedByUser.set(userId, [])
groupedByUser.get(userId).push({ categoryId, points })
}
return groupedByUser
}
function chunkEntries(entries = [], size = DEFAULT_TX_USER_CHUNK_SIZE) {
const normalizedSize = Math.max(1, Number(size) || DEFAULT_TX_USER_CHUNK_SIZE)
const chunks = []
for (let i = 0; i < entries.length; i += normalizedSize) {
chunks.push(entries.slice(i, i + normalizedSize))
}
return chunks
}
async function getUserInterestProfile(userId, db) {
const uid = normalizePositiveInt(userId)
if (!uid) return null
const p = getDb(db)
const rows = await p.$queryRawUnsafe(
'SELECT "userId", "categoryScores", "totalScore", "createdAt", "updatedAt" FROM "UserInterestProfile" WHERE "userId" = $1 LIMIT 1',
uid
)
return Array.isArray(rows) && rows.length ? rows[0] : null
}
async function applyInterestIncrementsBatch(increments = [], options = {}, db) {
const groupedByUser = aggregateIncrements(increments)
if (!groupedByUser.size) {
return { updated: 0, appliedPoints: 0 }
}
const saturationRatio = normalizeSaturationRatio(options?.saturationRatio)
let updated = 0
let appliedPoints = 0
const userEntries = Array.from(groupedByUser.entries())
const chunks = chunkEntries(userEntries)
for (const chunk of chunks) {
await getDb(db).$transaction(async (tx) => {
for (const [userId, entries] of chunk) {
await tx.$executeRawUnsafe(
'INSERT INTO "UserInterestProfile" ("userId", "categoryScores", "totalScore", "createdAt", "updatedAt") VALUES ($1, \'{}\'::jsonb, 0, NOW(), NOW()) ON CONFLICT ("userId") DO NOTHING',
userId
)
const rows = await tx.$queryRawUnsafe(
'SELECT "userId", "categoryScores", "totalScore" FROM "UserInterestProfile" WHERE "userId" = $1 FOR UPDATE',
userId
)
const profile = Array.isArray(rows) && rows.length ? rows[0] : null
if (!profile) continue
const scores = normalizeScores(profile.categoryScores)
let totalScore = Number(profile.totalScore || 0)
let changed = false
for (const entry of entries) {
const categoryKey = String(entry.categoryId)
const currentCategoryScore = Number(scores[categoryKey] || 0)
const maxAllowedBySaturation = getMaxAllowedBySaturation({
currentCategoryScore,
totalScore,
ratio: saturationRatio,
})
let nextCategoryScore = currentCategoryScore + entry.points
if (Number.isFinite(maxAllowedBySaturation)) {
nextCategoryScore = Math.min(nextCategoryScore, maxAllowedBySaturation)
}
const applied = Math.max(0, Math.floor(nextCategoryScore - currentCategoryScore))
if (applied <= 0) continue
scores[categoryKey] = currentCategoryScore + applied
totalScore += applied
appliedPoints += applied
changed = true
}
if (!changed) continue
await tx.$executeRawUnsafe(
'UPDATE "UserInterestProfile" SET "categoryScores" = $1::jsonb, "totalScore" = $2, "updatedAt" = NOW() WHERE "userId" = $3',
JSON.stringify(scores),
totalScore,
userId
)
updated += 1
}
})
}
return { updated, appliedPoints }
}
module.exports = {
getUserInterestProfile,
applyInterestIncrementsBatch,
}

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"); const prisma = require("./client");
async function voteDealTx({ dealId, userId, voteType }) { async function voteDealTxWithDb(db, { dealId, userId, voteType, createdAt }) {
return prisma.$transaction(async (db) => { const timestamp = createdAt instanceof Date ? createdAt : createdAt ? new Date(createdAt) : new Date()
const current = await db.dealVote.findUnique({ const current = await db.dealVote.findUnique({
where: { dealId_userId: { dealId, userId } }, where: { dealId_userId: { dealId, userId } },
select: { voteType: true }, select: { voteType: true },
}); })
const oldValue = current ? current.voteType : 0; const oldValue = current ? current.voteType : 0
const delta = voteType - oldValue; const delta = voteType - oldValue
// history (append-only) // history (append-only)
await db.dealVoteHistory.create({ await db.dealVoteHistory.create({
data: { dealId, userId, voteType }, data: { dealId, userId, voteType, createdAt: timestamp },
}); })
// current state // current state
await db.dealVote.upsert({ await db.dealVote.upsert({
@ -22,36 +22,53 @@ async function voteDealTx({ dealId, userId, voteType }) {
dealId, dealId,
userId, userId,
voteType, voteType,
lastVotedAt: new Date(), createdAt: timestamp,
lastVotedAt: timestamp,
}, },
update: { update: {
voteType, voteType,
lastVotedAt: new Date(), lastVotedAt: timestamp,
}, },
}); })
// score delta // score delta
if (delta !== 0) { if (delta !== 0) {
await db.deal.update({ await db.deal.update({
where: { id: dealId }, where: { id: dealId },
data: { score: { increment: delta } }, data: { score: { increment: delta } },
}); })
} }
const deal = await db.deal.findUnique({ const deal = await db.deal.findUnique({
where: { id: dealId }, where: { id: dealId },
select: { score: true }, select: { score: true },
}); })
return { return {
dealId, dealId,
voteType, voteType,
delta, delta,
score: deal?.score ?? null, 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 = { module.exports = {
voteDealTx, voteDealTx,
voteDealBatchTx,
}; };

318
docs/frontend-api.md Normal file
View File

@ -0,0 +1,318 @@
# Frontend API Guide (HotTRDeals)
This file is a frontend-focused summary of current backend routes, auth, and response models.
Server entry: `server.js`.
## Base URL and CORS
- Local API base: `http://localhost:3000`
- All routes below are relative to `/api`.
- CORS in dev allows `http://localhost:5173` and `credentials: true`.
## Auth summary
- Access token is returned in response body: `{ token, user }`.
- Send access token via header: `Authorization: Bearer <token>`.
- Refresh token is stored in httpOnly cookie named `rt`.
- For `/auth/refresh` and `/auth/logout`, the frontend must send cookies (`withCredentials: true`).
- Cookie options:
- Dev: `secure=false`, `sameSite=lax`
- Prod: `secure=true`, `sameSite=none`
## Roles
- Roles: `USER`, `MOD`, `ADMIN`.
- `requireRole("MOD")` means only MOD and ADMIN can access.
## Common response shapes
- Errors are inconsistent:
- Some endpoints return `{ error: "..." }`
- Some endpoints return `{ message: "..." }`
- Expect both in frontend error handling.
## Common models (from adapters/contracts)
### PublicUserSummary
```
{
id: number,
username: string,
avatarUrl: string | null
}
```
### PublicUserDetails
```
{
id: number,
username: string,
avatarUrl: string | null,
email: string,
createdAt: string (ISO)
}
```
### AuthUser
```
{
id: number,
username: string,
email: string,
role: "USER" | "MOD" | "ADMIN",
avatarUrl: string | null
}
```
### DealCard
```
{
id: number,
title: string,
description: string,
price: number | null,
originalPrice?: number | null,
shippingPrice?: number | null,
score: number,
commentsCount: number,
url: string | null,
status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED",
saleType: "ONLINE" | "OFFLINE" | "CODE",
affiliateType: "AFFILIATE" | "NON_AFFILIATE" | "USER_AFFILIATE",
myVote: -1 | 0 | 1,
createdAt: string (ISO),
updatedAt: string (ISO),
user: PublicUserSummary,
seller: { name: string, url: string | null },
imageUrl: string
}
```
### DealDetail
```
{
id: number,
title: string,
description: string,
url: string | null,
price: number | null,
originalPrice?: number | null,
shippingPrice?: number | null,
score: number,
commentsCount: number,
status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED",
saleType: "ONLINE" | "OFFLINE" | "CODE",
affiliateType: "AFFILIATE" | "NON_AFFILIATE" | "USER_AFFILIATE",
createdAt: string (ISO),
updatedAt: string (ISO),
user: PublicUserSummary,
seller: { id: number, name: string, url: string | null },
images: [{ id: number, imageUrl: string, order: number }],
comments: [{ id: number, text: string, createdAt: string, parentId?: number | null, user: PublicUserSummary }],
breadcrumb: [{ id: number, name: string, slug: string }],
notice: {
id: number,
dealId: number,
title: string,
body: string | null,
severity: "INFO" | "WARNING" | "DANGER" | "SUCCESS",
isActive: boolean,
createdBy: number,
createdAt: string,
updatedAt: string
} | null,
similarDeals: [{ id: number, title: string, price: number | null, score: number, imageUrl: string, sellerName: string, createdAt: string | null }]
}
```
### DealListResponse
```
{
page: number,
total: number,
totalPages: number,
results: DealCard[]
}
```
### Comment (DealComment)
```
{
id: number,
text: string,
createdAt: string,
parentId?: number | null,
user: PublicUserSummary
}
```
### UserProfile
```
{
user: PublicUserDetails,
stats: { totalLikes: number, totalShares: number, totalComments: number, totalDeals: number },
deals: DealCard[],
comments: [{ ...Comment, deal: { id: number, title: string } }]
}
```
### VoteResponse
```
{
dealId: number,
voteType: -1 | 0 | 1,
delta: number,
score: number | null
}
```
### VoteListResponse
```
{
votes: [{ id, dealId, userId, voteType, createdAt, lastVotedAt }]
}
```
## Endpoints
### Auth (`/api/auth`)
- `POST /register`
- Body: `{ username, email, password }`
- Response: `{ token, user: AuthUser }` and sets `rt` cookie if refresh token exists.
- `POST /login`
- Body: `{ email, password }`
- Response: `{ token, user: AuthUser }` and sets `rt` cookie.
- `POST /refresh`
- Cookie required: `rt`
- Response: `{ token, user: AuthUser }` and rotates `rt` cookie.
- `POST /logout`
- Cookie optional: `rt`
- Response: `204 No Content`, clears `rt` cookie.
- `GET /me`
- Auth: required
- Response: `AuthUser`
- Note: current implementation reads `req.user.userId` in adapter, while auth middleware sets `req.auth`. If `req.user` is undefined, this endpoint can fail.
### Account (`/api/account`)
- `POST /avatar`
- Auth: required
- Multipart: `file` (single)
- Validation: JPEG only, max 2MB
- Response: `{ message, user: PublicUserSummary }`
- `GET /me`
- Auth: required
- Response: `PublicUserDetails`
### Deals (`/api/deals`)
List query params (used in list endpoints):
- `q` (string, default "")
- `page` (int, default 1)
- `limit` (int, default 10, max 100)
Endpoints:
- `GET /users/:userName/deals`
- Auth: optional
- Response: `DealListResponse`
- `GET /me/deals`
- Auth: required
- Response: `DealListResponse`
- `GET /new`
- `GET /hot`
- `GET /trending`
- `GET /` (same as `/new`)
- Auth: optional
- Response: `DealListResponse`
- `GET /search`
- Auth: optional
- If `q` is empty: `{ results: [], total: 0, totalPages: 0, page }`
- Else: `DealListResponse`
- `GET /top`
- Auth: optional
- Query: `range=day|week|month` (default `day`), `limit` (default 6, max 20)
- Response: `DealCard[]` (array only)
- `GET /:id`
- Response: `DealDetail`
- `POST /`
- Auth: required
- Multipart: `images` (array, max 5)
- Limits: 10MB per file (upload middleware)
- Body fields: `{ title, description?, url?, price?, sellerName? }`
- Response: `DealDetail`
### Category (`/api/category`)
- `GET /:slug`
- Response: `{ id, name, slug, description, breadcrumb }`
- `GET /:slug/deals`
- Auth: optional
- Query: `page`, `limit`, plus filters from query
- Response: `DealCard[]` (array only)
### Seller (`/api/seller`)
- `POST /from-link`
- Auth: required
- Body: `{ url }`
- Response: `{ found: boolean, seller: { id, name, url } | null }`
- `GET /:sellerName`
- Response: `{ id, name, url, logoUrl }`
- `GET /:sellerName/deals`
- Auth: optional
- Query: `page`, `limit`, `q`
- Response: `DealCard[]` (array only)
### Comments (`/api/comments`)
- `GET /:dealId`
- Response: `Comment[]`
- `POST /`
- Auth: required
- Body: `{ dealId, text, parentId? }`
- Response: `Comment`
- `DELETE /:id`
- Auth: required
- Response: `{ message: "Yorum silindi." }`
### Votes (`/api/vote` and `/api/deal-votes`)
- `POST /`
- Auth: required
- Body: `{ dealId, voteType }` where voteType is -1, 0, or 1
- Response: `VoteResponse`
- `GET /:dealId`
- Response: `VoteListResponse`
### Users (`/api/user` and `/api/users`)
- `GET /:userName`
- Response: `UserProfile`
### Mod (`/api/mod`) (MOD or ADMIN)
- `GET /deals/pending`
- Auth + Role: MOD
- Query: `page`, `limit`, `q` plus filters
- Response: `DealCard[]` (array only)
- `POST /deals/:id/approve`
- `POST /deals/:id/reject`
- `POST /deals/:id/expire`
- `POST /deals/:id/unexpire`
- Auth + Role: MOD
- Response: `{ id, status }`
## Notes for frontend
- Some list endpoints return full pagination object, some return only `results` array. Treat them separately:
- Full: `/api/deals` list endpoints, `/api/deals/search`, `/api/deals/users/:userName/deals`, `/api/deals/me/deals`
- Array only: `/api/deals/top`, `/api/category/:slug/deals`, `/api/seller/:sellerName/deals`, `/api/mod/deals/pending`
- Optional-auth endpoints return `401` if a token is provided but invalid.
- MyVote is only filled when Authorization header is present.
- There are duplicate route prefixes for users and votes. Frontend can use either, but pick one for consistency.

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>

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

@ -0,0 +1,21 @@
const { Queue } = require("bullmq")
const { getRedisConnectionOptions } = require("../services/redis/connection")
const connection = getRedisConnectionOptions()
const queue = new Queue("db-sync", { connection })
const DB_SYNC_REPEAT_MS = Math.max(2000, Number(process.env.DB_SYNC_REPEAT_MS) || 10000)
async function ensureDbSyncRepeatable() {
return queue.add(
"db-sync-batch",
{},
{
jobId: "db-sync-batch",
repeat: { every: DB_SYNC_REPEAT_MS },
removeOnComplete: true,
removeOnFail: 200,
}
)
}
module.exports = { queue, connection, ensureDbSyncRepeatable }

View File

@ -1,9 +1,7 @@
const { Queue } = require("bullmq") const { Queue } = require("bullmq")
const { getRedisConnectionOptions } = require("../services/redis/connection")
const connection = { const connection = getRedisConnectionOptions()
host: process.env.REDIS_HOST ,
port: Number(process.env.REDIS_PORT ),
}
const queue = new Queue("deal-classification", { connection }) 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) { function getBearerToken(req) {
const h = req.headers.authorization const h = req.headers.authorization
if (!h) return null if (h) {
const [type, token] = h.split(" ") const [type, token] = h.split(" ")
if (type !== "Bearer" || !token) return null if (type === "Bearer" && token) return token
return token }
const cookieToken = req.cookies?.at
return cookieToken || null
} }
module.exports = function optionalAuth(req, res, next) { 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 jwt = require("jsonwebtoken")
const { getOrCacheUserModeration } = require("../services/redis/userModerationCache.service")
function getBearerToken(req) { function getBearerToken(req) {
const h = req.headers.authorization const h = req.headers.authorization
if (!h) return null if (h) {
const [type, token] = h.split(" ") const [type, token] = h.split(" ")
if (type !== "Bearer" || !token) return null if (type === "Bearer" && token) return 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) const token = getBearerToken(req)
if (!token) return res.status(401).json({ error: "Token yok" }) 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" }) 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() next()
} catch (err) { } catch (err) {
return res.status(401).json({ error: "Token geçersiz" }) 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" })
}
}
}

1908
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,9 +14,10 @@
"license": "ISC", "license": "ISC",
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.985.0",
"@prisma/client": "^6.18.0", "@prisma/client": "^6.18.0",
"@shared/contracts": "file:../Contracts", "@shared/contracts": "file:../Contracts",
"@supabase/supabase-js": "^2.78.0", "axios": "^1.11.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"bullmq": "^5.67.0", "bullmq": "^5.67.0",
"contracts": "^0.4.0", "contracts": "^0.4.0",

View File

@ -1,239 +1,512 @@
[ [
{ "id": 0, "name": "Undefined", "slug": "undefined", "parentId": null }, { "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 }, { "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": 2, "name": "Kozmetik", "slug": "beauty", "parentId": 0 }, { "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": 3, "name": "Gıda", "slug": "food", "parentId": 0 }, { "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": 4, "name": "Oto", "slug": "auto", "parentId": 0 }, { "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": 5, "name": "Ev & Bahçe", "slug": "home-garden", "parentId": 0 }, { "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 }, { "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": 7, "name": "PC Bileşenleri", "slug": "pc-components", "parentId": 6 }, { "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": 8, "name": "RAM", "slug": "pc-ram", "parentId": 7 }, { "id": 112, "name": "Masaüstü Bilgisayarlar", "slug": "desktops", "parentId": 110, "description": "Hazır sistemler, iş istasyonları ve oyun odaklı masaüstü bilgisayarlar." },
{ "id": 9, "name": "SSD", "slug": "pc-ssd", "parentId": 7 }, { "id": 113, "name": "Tabletler", "slug": "tablets", "parentId": 110, "description": "Android, iPadOS ve Windows işletim sistemli tabletler ve aksesuarları." },
{ "id": 10, "name": "CPU", "slug": "pc-cpu", "parentId": 7 }, { "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": 11, "name": "GPU", "slug": "pc-gpu", "parentId": 7 }, { "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 }, { "id": 132, "name": "Ağ Ürünleri", "slug": "networking", "parentId": 100, "description": "Modemler, routerlar, Wi-Fi genişleticiler, switchler ve ağ aksesuarları." },
{ "id": 13, "name": "Klavye", "slug": "pc-keyboard", "parentId": 12 }, { "id": 133, "name": "Modemler & Routerlar", "slug": "modems-routers", "parentId": 132, "description": "ADSL, VDSL, Fiber uyumlu modemler, Wi-Fi 6/7 destekli routerlar." },
{ "id": 14, "name": "Mouse", "slug": "pc-mouse", "parentId": 12 }, { "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": 15, "name": "Monitör", "slug": "pc-monitor", "parentId": 6 }, { "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 }, { "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": 17, "name": "Ruj", "slug": "beauty-lipstick", "parentId": 16 }, { "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": 18, "name": "Fondöten", "slug": "beauty-foundation", "parentId": 16 }, { "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": 19, "name": "Maskara", "slug": "beauty-mascara", "parentId": 16 }, { "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 }, { "id": 140, "name": "Harici Depolama", "slug": "external-storage", "parentId": 100, "description": "Harici diskler, USB bellekler, NAS cihazları ve hafıza kartları." },
{ "id": 21, "name": "Nemlendirici", "slug": "beauty-moisturizer", "parentId": 20 }, { "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 }, { "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": 23, "name": "Çiğköfte", "slug": "food-cigkofte", "parentId": 22 }, { "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 }, { "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": 25, "name": "Kahve", "slug": "food-coffee", "parentId": 24 }, { "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 }, { "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": 27, "name": "Motor Yağı", "slug": "auto-engine-oil", "parentId": 26 }, { "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 }, { "id": 201, "name": "Mobilya", "slug": "furniture", "parentId": 200, "description": "Salon, yatak odası, yemek odası, çalışma odası mobilyaları ve depolama çözümleri." },
{ "id": 29, "name": "Fren Balatası", "slug": "auto-brake-pads", "parentId": 28 }, { "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 }, { "id": 207, "name": "Ev Dekorasyonu", "slug": "home-decor", "parentId": 200, "description": "Halılar, tablolar, aynalar, vazolar, mumlar ve diğer dekoratif objeler." },
{ "id": 31, "name": "Sulama", "slug": "garden-irrigation", "parentId": 30 }, { "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 }, { "id": 212, "name": "Aydınlatma", "slug": "lighting", "parentId": 200, "description": "Avizeler, lambaderler, masa lambaları, spot ışıklar ve LED aydınlatma çözümleri." },
{ "id": 33, "name": "Akıllı Telefon", "slug": "phone-smartphone", "parentId": 32 }, { "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": 34, "name": "Telefon Kılıfı", "slug": "phone-case", "parentId": 32 }, { "id": 214, "name": "Masa Lambaları", "slug": "table-lamps", "parentId": 212, "description": "Çalışma masası, komodin ve okuma için masa lambaları." },
{ "id": 35, "name": "Ekran Koruyucu", "slug": "phone-screen-protector", "parentId": 32 }, { "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": 36, "name": "Şarj & Kablo", "slug": "phone-charging", "parentId": 32 }, { "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": 37, "name": "Powerbank", "slug": "phone-powerbank", "parentId": 32 },
{ "id": 38, "name": "Giyilebilir Teknoloji", "slug": "wearables", "parentId": 1 }, { "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": 39, "name": "Akıllı Saat", "slug": "wearables-smartwatch", "parentId": 38 }, { "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": 40, "name": "Akıllı Bileklik", "slug": "wearables-band", "parentId": 38 }, { "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 }, { "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": 42, "name": "Kulaklık", "slug": "audio-headphones", "parentId": 41 }, { "id": 230, "name": "Buzdolapları", "slug": "refrigerators", "parentId": 229, "description": "No Frost, kombi, gardırop tipi, tek kapılı buzdolabı modelleri." },
{ "id": 43, "name": "TWS Kulaklık", "slug": "audio-tws", "parentId": 42 }, { "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": 44, "name": "Bluetooth Hoparlör", "slug": "audio-bt-speaker", "parentId": 41 }, { "id": 232, "name": "Bulaşık Makineleri", "slug": "dishwashers", "parentId": 229, "description": "Ankastre ve solo bulaşık makinesi modelleri, farklı program ve kapasitelerde." },
{ "id": 45, "name": "Soundbar", "slug": "audio-soundbar", "parentId": 41 }, { "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": 46, "name": "Mikrofon", "slug": "audio-microphone", "parentId": 41 },
{ "id": 47, "name": "Plak / Pikap", "slug": "audio-turntable", "parentId": 41 },
{ "id": 48, "name": "TV & Video", "slug": "tv-video", "parentId": 1 }, { "id": 234, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 200, "description": "Nevresim takımları, yorgan, battaniye, perde, havlu ve yastıklar." },
{ "id": 49, "name": "Televizyon", "slug": "tv", "parentId": 48 }, { "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": 50, "name": "Projeksiyon", "slug": "projector", "parentId": 48 }, { "id": 236, "name": "Yorganlar & Battaniyeler", "slug": "duvets-blankets", "parentId": 234, "description": "Elyaf, pamuk, yün yorganlar, polar, pamuklu ve örgü battaniyeler." },
{ "id": 51, "name": "Medya Oynatıcı", "slug": "tv-media-player", "parentId": 48 }, { "id": 237, "name": "Perdeler & Jaluziler", "slug": "curtains-blinds", "parentId": 234, "description": "Tül, fon, stor, zebra perde modelleri ve jaluziler." },
{ "id": 52, "name": "TV Aksesuarları", "slug": "tv-accessories", "parentId": 48 }, { "id": 238, "name": "Havlu Setleri", "slug": "towel-sets", "parentId": 234, "description": "Banyo, el, yüz ve plaj havlusu setleri." },
{ "id": 53, "name": "Uydu Alıcısı / Receiver", "slug": "tv-receiver", "parentId": 48 }, { "id": 239, "name": "Yastıklar & Minderler", "slug": "pillows-cushions", "parentId": 234, "description": "Uyku yastıkları, dekoratif minderler ve koltuk şalları." },
{ "id": 54, "name": "Oyun Konsolları", "slug": "console", "parentId": 1 }, { "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": 55, "name": "PlayStation", "slug": "console-playstation", "parentId": 54 }, { "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": 56, "name": "Xbox", "slug": "console-xbox", "parentId": 54 }, { "id": 242, "name": "Temizlik Malzemeleri", "slug": "cleaning-supplies", "parentId": 240, "description": "Yüzey temizleyiciler, çamaşır suyu, cam temizleyici, süngerler ve bezler." },
{ "id": 57, "name": "Nintendo", "slug": "console-nintendo", "parentId": 54 }, { "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": 58, "name": "Oyunlar (Konsol)", "slug": "console-games", "parentId": 54 }, { "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": 59, "name": "Konsol Aksesuarları", "slug": "console-accessories", "parentId": 54 },
{ "id": 60, "name": "Kamera & Fotoğraf", "slug": "camera", "parentId": 1 }, { "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": 61, "name": "Fotoğraf Makinesi", "slug": "camera-photo", "parentId": 60 }, { "id": 246, "name": "Bahçe Mobilyaları", "slug": "garden-furniture", "parentId": 245, "description": "Oturma grupları, masalar, sandalyeler, salıncaklar ve şezlonglar." },
{ "id": 62, "name": "Aksiyon Kamera", "slug": "camera-action", "parentId": 60 }, { "id": 247, "name": "Mangallar & Barbeküler", "slug": "bbqs-grills", "parentId": 245, "description": "Kömürlü, gazlı mangallar, elektrikli ızgaralar ve barbekü aksesuarları." },
{ "id": 63, "name": "Lens", "slug": "camera-lens", "parentId": 60 }, { "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": 64, "name": "Tripod", "slug": "camera-tripod", "parentId": 60 }, { "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": 65, "name": "Akıllı Ev", "slug": "smart-home", "parentId": 1 }, { "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": 66, "name": "Güvenlik Kamerası", "slug": "smart-security-camera", "parentId": 65 }, { "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": 67, "name": "Akıllı Priz", "slug": "smart-plug", "parentId": 65 }, { "id": 253, "name": "El Aletleri", "slug": "hand-tools", "parentId": 251, "description": "Tornavida setleri, pense, anahtar takımları, çekiçler ve metreler." },
{ "id": 68, "name": "Akıllı Ampul", "slug": "smart-bulb", "parentId": 65 }, { "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": 69, "name": "Akıllı Sensör", "slug": "smart-sensor", "parentId": 65 }, { "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": 70, "name": "Ağ Ürünleri", "slug": "pc-networking", "parentId": 6 },
{ "id": 71, "name": "Router", "slug": "pc-router", "parentId": 70 },
{ "id": 72, "name": "Modem", "slug": "pc-modem", "parentId": 70 },
{ "id": 73, "name": "Switch", "slug": "pc-switch", "parentId": 70 },
{ "id": 74, "name": "Wi-Fi Extender", "slug": "pc-wifi-extender", "parentId": 70 },
{ "id": 75, "name": "Yazıcı & Tarayıcı", "slug": "pc-printing", "parentId": 6 }, { "id": 301, "name": "Kadın Giyim", "slug": "womens-clothing", "parentId": 300, "description": "Elbiseler, bluzlar, pantolonlar, etekler, dış giyim, iç giyim ve spor giyim." },
{ "id": 76, "name": "Yazıcı", "slug": "pc-printer", "parentId": 75 }, { "id": 302, "name": "Elbiseler", "slug": "dresses", "parentId": 301, "description": "Günlük, abiye, spor, kokteyl elbiseleri ve tulumlar." },
{ "id": 77, "name": "Toner & Kartuş", "slug": "pc-ink-toner", "parentId": 75 }, { "id": 303, "name": "Kadın Üst Giyim", "slug": "womens-tops", "parentId": 301, "description": "Tişörtler, bluzlar, gömlekler, kazaklar, hırkalar ve ceketler." },
{ "id": 78, "name": "Tarayıcı", "slug": "pc-scanner", "parentId": 75 }, { "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": 79, "name": "Dizüstü Bilgisayar", "slug": "pc-laptop", "parentId": 6 }, { "id": 309, "name": "Erkek Giyim", "slug": "mens-clothing", "parentId": 300, "description": "Tişörtler, gömlekler, pantolonlar, dış giyim, iç giyim ve spor giyim." },
{ "id": 80, "name": "Masaüstü Bilgisayar", "slug": "pc-desktop", "parentId": 6 }, { "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": 81, "name": "Tablet", "slug": "pc-tablet", "parentId": 6 }, { "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": 82, "name": "Depolama", "slug": "pc-storage", "parentId": 6 }, { "id": 316, "name": "Ayakkabı", "slug": "footwear", "parentId": 300, "description": "Kadın, erkek ve çocuk ayakkabıları; spor, klasik, bot, sandalet ve terlikler." },
{ "id": 83, "name": "Harici Disk", "slug": "pc-external-drive", "parentId": 82 }, { "id": 317, "name": "Kadın Ayakkabı", "slug": "womens-shoes", "parentId": 316, "description": "Topuklu ayakkabılar, babetler, spor ayakkabılar, sandaletler, botlar ve terlikler." },
{ "id": 84, "name": "USB Flash", "slug": "pc-usb-drive", "parentId": 82 }, { "id": 318, "name": "Erkek Ayakkabı", "slug": "mens-shoes", "parentId": 316, "description": "Klasik ayakkabılar, spor ayakkabılar, botlar, sandaletler ve terlikler." },
{ "id": 85, "name": "NAS", "slug": "pc-nas", "parentId": 82 }, { "id": 319, "name": "Çocuk Ayakkabı", "slug": "kids-shoes", "parentId": 316, "description": "Okul ayakkabıları, spor ayakkabıları, sandaletler ve botlar." },
{ "id": 86, "name": "Webcam", "slug": "pc-webcam", "parentId": 12 }, { "id": 320, "name": "Çanta & Bavul", "slug": "bags-luggage", "parentId": 300, "description": "El çantaları, sırt çantaları, cüzdanlar, valizler ve seyahat çantaları." },
{ "id": 87, "name": "Hoparlör (PC)", "slug": "pc-speaker", "parentId": 12 }, { "id": 321, "name": "El Çantaları", "slug": "handbags", "parentId": 320, "description": "Omuz çantaları, çapraz çantalar, portföyler, clutchlar ve tote çantalar." },
{ "id": 88, "name": "Mikrofon (PC)", "slug": "pc-mic", "parentId": 12 }, { "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": 89, "name": "Mousepad", "slug": "pc-mousepad", "parentId": 12 }, { "id": 323, "name": "Cüzdanlar", "slug": "wallets", "parentId": 320, "description": "Kadın ve erkek cüzdanları, kartlıklar ve bozuk para cüzdanları." },
{ "id": 90, "name": "Dock / USB Hub", "slug": "pc-dock-hub", "parentId": 12 }, { "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": 91, "name": "Laptop Çantası", "slug": "pc-laptop-bag", "parentId": 12 },
{ "id": 92, "name": "Gamepad / Controller", "slug": "pc-controller", "parentId": 12 },
{ "id": 93, "name": "Anakart", "slug": "pc-motherboard", "parentId": 7 }, { "id": 325, "name": "Aksesuarlar", "slug": "accessories", "parentId": 300, "description": "Takı, saat, kemer, şapka, gözlük, eşarp ve diğer moda aksesuarları." },
{ "id": 94, "name": "Güç Kaynağı (PSU)", "slug": "pc-psu", "parentId": 7 }, { "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": 95, "name": "Kasa", "slug": "pc-case", "parentId": 7 }, { "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": 96, "name": "Soğutma", "slug": "pc-cooling", "parentId": 7 },
{ "id": 97, "name": "Kasa Fanı", "slug": "pc-fan", "parentId": 96 },
{ "id": 98, "name": "Sıvı Soğutma", "slug": "pc-liquid-cooling", "parentId": 96 },
{ "id": 99, "name": "Parfüm", "slug": "beauty-fragrance", "parentId": 2 }, { "id": 401, "name": "Makyaj", "slug": "makeup", "parentId": 400, "description": "Yüz, göz, dudak makyaj ürünleri ve makyaj aksesuarları." },
{ "id": 100, "name": "Kadın Parfüm", "slug": "beauty-fragrance-women", "parentId": 99 }, { "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": 101, "name": "Erkek Parfüm", "slug": "beauty-fragrance-men", "parentId": 99 }, { "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": 102, "name": "Saç Bakımı", "slug": "beauty-haircare", "parentId": 2 }, { "id": 406, "name": "Cilt Bakımı", "slug": "skincare", "parentId": 400, "description": "Yüz temizleyiciler, nemlendiriciler, serumlar, maskeler, güneş kremleri ve tonikler." },
{ "id": 103, "name": "Şampuan", "slug": "beauty-shampoo", "parentId": 102 }, { "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": 104, "name": "Saç Kremi", "slug": "beauty-conditioner", "parentId": 102 }, { "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": 105, "name": "Saç Şekillendirici", "slug": "beauty-hair-styling", "parentId": 102 }, { "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": 106, "name": "Kişisel Bakım", "slug": "beauty-personal-care", "parentId": 2 }, { "id": 414, "name": "Saç Bakımı", "slug": "haircare", "parentId": 400, "description": "Şampuan, saç kremi, saç maskesi, saç yağları, şekillendiriciler ve saç boyaları." },
{ "id": 107, "name": "Deodorant", "slug": "beauty-deodorant", "parentId": 106 }, { "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": 108, "name": "Tıraş Ürünleri", "slug": "beauty-shaving", "parentId": 106 }, { "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": 109, "name": "Ağda / Epilasyon", "slug": "beauty-hair-removal", "parentId": 106 }, { "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": 110, "name": "Serum", "slug": "beauty-skincare-serum", "parentId": 20 }, { "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": 111, "name": "Güneş Kremi", "slug": "beauty-sunscreen", "parentId": 20 }, { "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": 112, "name": "Temizleyici", "slug": "beauty-cleanser", "parentId": 20 }, { "id": 422, "name": "Erkek Parfümleri", "slug": "mens-fragrances", "parentId": 420, "description": "Odunsu, baharatlı, fresh, aromatik koku profillerinde erkek parfümleri (EDT/EDP)." },
{ "id": 113, "name": "Yüz Maskesi", "slug": "beauty-mask", "parentId": 20 }, { "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": 114, "name": "Tonik", "slug": "beauty-toner", "parentId": 20 }, { "id": 424, "name": "Deodorantlar & Antiperspirantlar", "slug": "deodorants-antiperspirants", "parentId": 420, "description": "Ter kokusunu önleyen roll-on, sprey ve stick deodorantlar." },
{ "id": 115, "name": "Temel Gıda", "slug": "food-staples", "parentId": 3 }, { "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": 116, "name": "Makarna", "slug": "food-pasta", "parentId": 115 }, { "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": 117, "name": "Pirinç & Bakliyat", "slug": "food-legumes", "parentId": 115 }, { "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": 118, "name": "Yağ & Sirke (Gıda)", "slug": "food-oil-vinegar", "parentId": 115 }, { "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": 119, "name": "Kahvaltılık", "slug": "food-breakfast", "parentId": 3 },
{ "id": 120, "name": "Peynir", "slug": "food-cheese", "parentId": 119 },
{ "id": 121, "name": "Zeytin", "slug": "food-olive", "parentId": 119 },
{ "id": 122, "name": "Reçel & Bal", "slug": "food-jam-honey", "parentId": 119 },
{ "id": 123, "name": "Gazlı İçecek", "slug": "food-soda", "parentId": 24 }, { "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": 124, "name": "Su", "slug": "food-water", "parentId": 24 }, { "id": 502, "name": "Makarna & Erişte", "slug": "pasta-noodles", "parentId": 501, "description": "Spagetti, penne, fiyonk makarna, erişte ve glutensiz makarna çeşitleri." },
{ "id": 125, "name": "Enerji İçeceği", "slug": "food-energy", "parentId": 24 }, { "id": 503, "name": "Pirinç & Bakliyat", "slug": "rice-legumes", "parentId": 501, "description": "Osmancık pirinç, baldo pirinç, bulgur, mercimek, nohut, fasulye ve barbunya." },
{ "id": 126, "name": "Çay", "slug": "food-tea", "parentId": 24 }, { "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": 127, "name": "Dondurulmuş", "slug": "food-frozen", "parentId": 3 }, { "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": 128, "name": "Et & Tavuk", "slug": "food-meat", "parentId": 3 }, { "id": 510, "name": "Meyveler", "slug": "fruits", "parentId": 509, "description": "Mevsimlik meyveler, egzotik meyveler, kurutulmuş meyveler ve meyve püreleri." },
{ "id": 129, "name": "Tatlı", "slug": "food-dessert", "parentId": 3 }, { "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": 130, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 4 }, { "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": 131, "name": "Araç İçi Elektronik", "slug": "auto-in-car-electronics", "parentId": 130 }, { "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": 132, "name": "Oto Bakım", "slug": "auto-care", "parentId": 4 }, { "id": 522, "name": "İçecekler", "slug": "beverages", "parentId": 500, "description": "Kahve, çay, su, gazlı içecekler, meyve suları ve alkollü içecekler." },
{ "id": 133, "name": "Oto Temizlik", "slug": "auto-cleaning", "parentId": 132 }, { "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": 134, "name": "Lastik & Jant", "slug": "auto-tires", "parentId": 4 }, { "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": 135, "name": "Akü", "slug": "auto-battery", "parentId": 4 }, { "id": 525, "name": "Gazlı İçecekler", "slug": "soft-drinks", "parentId": 522, "description": "Kola, gazoz, aromalı sodalar, enerji içecekleri ve soğuk çaylar." },
{ "id": 136, "name": "Oto Aydınlatma", "slug": "auto-lighting", "parentId": 130 }, { "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": 137, "name": "Oto Ses Sistemi", "slug": "auto-audio", "parentId": 130 }, { "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": 138, "name": "Mobilya", "slug": "home-furniture", "parentId": 5 }, { "id": 529, "name": "Organik & Özel Beslenme", "slug": "organic-special-diet", "parentId": 500, "description": "Organik ürünler, glutensiz, şekersiz, vegan ve vejetaryen gıdalar." },
{ "id": 139, "name": "Yemek Masası", "slug": "home-dining-table", "parentId": 138 }, { "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": 140, "name": "Sandalye", "slug": "home-chair", "parentId": 138 }, { "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": 141, "name": "Koltuk", "slug": "home-sofa", "parentId": 138 },
{ "id": 142, "name": "Yatak", "slug": "home-bed", "parentId": 138 },
{ "id": 143, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 5 },
{ "id": 144, "name": "Nevresim", "slug": "home-bedding", "parentId": 143 },
{ "id": 145, "name": "Yorgan & Battaniye", "slug": "home-blanket", "parentId": 143 },
{ "id": 146, "name": "Perde", "slug": "home-curtain", "parentId": 143 },
{ "id": 147, "name": "Mutfak", "slug": "home-kitchen", "parentId": 5 }, { "id": 601, "name": "Oyun Konsolları", "slug": "game-consoles", "parentId": 600, "description": "PlayStation, Xbox, Nintendo Switch, retro konsollar ve el konsolları." },
{ "id": 148, "name": "Tencere & Tava", "slug": "home-cookware", "parentId": 147 }, { "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": 149, "name": "Küçük Ev Aletleri", "slug": "home-small-appliances", "parentId": 147 }, { "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": 150, "name": "Kahve Makinesi", "slug": "home-coffee-machine", "parentId": 149 }, { "id": 604, "name": "Nintendo Konsolları", "slug": "nintendo-consoles", "parentId": 601, "description": "Nintendo Switch, Switch Lite, Switch OLED ve diğer Nintendo el konsolları." },
{ "id": 151, "name": "Blender", "slug": "home-blender", "parentId": 149 }, { "id": 605, "name": "Retro & Mini Konsollar", "slug": "retro-mini-consoles", "parentId": 601, "description": "Nostaljik oyun deneyimi sunan retro konsollar ve mini versiyonları." },
{ "id": 152, "name": "Airfryer", "slug": "home-airfryer", "parentId": 149 }, { "id": 606, "name": "Oyunlar", "slug": "games", "parentId": 600, "description": "Konsol oyunları, PC oyunları, dijital oyun kodları ve abonelikler." },
{ "id": 153, "name": "Süpürge", "slug": "home-vacuum", "parentId": 149 }, { "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": 154, "name": "Aydınlatma", "slug": "home-lighting", "parentId": 5 }, { "id": 612, "name": "Oyun Aksesuarları", "slug": "gaming-accessories", "parentId": 600, "description": "Kontrolcüler, kulaklıklar, direksiyon setleri, VR cihazları, oyun koltukları ve depolama." },
{ "id": 155, "name": "Dekorasyon", "slug": "home-decor", "parentId": 5 }, { "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": 156, "name": "Halı", "slug": "home-rug", "parentId": 155 }, { "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": 157, "name": "Duvar Dekoru", "slug": "home-wall-decor", "parentId": 155 }, { "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": 158, "name": "Temizlik", "slug": "home-cleaning", "parentId": 5 },
{ "id": 159, "name": "Deterjan", "slug": "home-detergent", "parentId": 158 },
{ "id": 160, "name": "Kağıt Ürünleri", "slug": "home-paper-products", "parentId": 158 },
{ "id": 161, "name": "El Aletleri", "slug": "home-tools", "parentId": 5 }, { "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": 162, "name": "Matkap", "slug": "home-drill", "parentId": 161 }, { "id": 702, "name": "Fren Sistemleri", "slug": "brake-systems", "parentId": 701, "description": "Fren balatası, fren diski, fren hidroliği ve kaliperler." },
{ "id": 163, "name": "Testere", "slug": "home-saw", "parentId": 161 }, { "id": 703, "name": "Filtreler", "slug": "filters", "parentId": 701, "description": "Yağ filtresi, hava filtresi, polen filtresi ve yakıt filtresi." },
{ "id": 164, "name": "Vida & Dübel", "slug": "home-hardware", "parentId": 161 }, { "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": 165, "name": "Evcil Hayvan", "slug": "pet", "parentId": 5 }, { "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": 166, "name": "Kedi Maması", "slug": "pet-cat-food", "parentId": 165 }, { "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": 167, "name": "Köpek Maması", "slug": "pet-dog-food", "parentId": 165 }, { "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": 168, "name": "Kedi Kumu", "slug": "pet-cat-litter", "parentId": 165 },
{ "id": 169, "name": "Kırtasiye & Ofis", "slug": "office", "parentId": 0 }, { "id": 710, "name": "Lastik & Jant", "slug": "tires-wheels", "parentId": 700, "description": "Yazlık, kışlık, dört mevsim lastikler, jantlar ve aksesuarları." },
{ "id": 170, "name": "Kağıt & Defter", "slug": "office-paper-notebook", "parentId": 169 }, { "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": 171, "name": "A4 Kağıdı", "slug": "office-a4-paper", "parentId": 170 }, { "id": 712, "name": "Jantlar", "slug": "wheels", "parentId": 710, "description": "Çelik ve alaşım jantlar, jant kapakları ve jant temizleyiciler." },
{ "id": 172, "name": "Kalem", "slug": "office-pen", "parentId": 169 },
{ "id": 173, "name": "Okul Çantası", "slug": "office-school-bag", "parentId": 169 },
{ "id": 174, "name": "Bebek & Çocuk", "slug": "baby", "parentId": 0 }, { "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": 175, "name": "Bebek Bezi", "slug": "baby-diaper", "parentId": 174 }, { "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": 176, "name": "Islak Mendil", "slug": "baby-wipes", "parentId": 174 }, { "id": 715, "name": "İç Temizlik Ürünleri", "slug": "interior-cleaning", "parentId": 713, "description": "Torpidolar, koltuklar, döşemeler, kokpit temizleyiciler ve hava tazeleyiciler." },
{ "id": 177, "name": "Bebek Maması", "slug": "baby-food", "parentId": 174 }, { "id": 716, "name": "Cila & Boya Koruma", "slug": "polish-paint-protection", "parentId": 713, "description": "Araç cilaları, pastalar, seramik kaplama ürünleri ve boya koruyucular." },
{ "id": 178, "name": "Oyuncak", "slug": "baby-toys", "parentId": 174 },
{ "id": 179, "name": "Spor & Outdoor", "slug": "sports", "parentId": 0 }, { "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": 180, "name": "Kamp", "slug": "sports-camping", "parentId": 179 }, { "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": 181, "name": "Fitness", "slug": "sports-fitness", "parentId": 179 }, { "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": 182, "name": "Bisiklet", "slug": "sports-bicycle", "parentId": 179 }, { "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": 183, "name": "Moda", "slug": "fashion", "parentId": 0 }, { "id": 723, "name": "Motosiklet & Scooter", "slug": "motorcycles-scooters", "parentId": 700, "description": "Motosikletler, scooterlar, kasklar, ekipmanlar ve aksesuarları." },
{ "id": 184, "name": "Ayakkabı", "slug": "fashion-shoes", "parentId": 183 }, { "id": 724, "name": "Motosikletler", "slug": "motorcycles", "parentId": 723, "description": "Farklı kategori ve markalarda motosiklet modelleri." },
{ "id": 185, "name": "Erkek Giyim", "slug": "fashion-men", "parentId": 183 }, { "id": 725, "name": "Motosiklet Ekipmanları", "slug": "motorcycle-gear", "parentId": 723, "description": "Kasklar, montlar, eldivenler, pantolonlar ve motosiklet botları." },
{ "id": 186, "name": "Kadın Giyim", "slug": "fashion-women", "parentId": 183 }, { "id": 726, "name": "Motosiklet Aksesuarları", "slug": "motorcycle-accessories", "parentId": 723, "description": "Motosiklet çantaları, koruyucular, zincir yağları ve kilitler." },
{ "id": 187, "name": "Çanta", "slug": "fashion-bags", "parentId": 183 },
{ "id": 188, "name": "Kitap & Medya", "slug": "books-media", "parentId": 0 },
{ "id": 189, "name": "Kitap", "slug": "books", "parentId": 188 }, { "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": 190, "name": "Dijital Oyun (Genel)", "slug": "digital-games", "parentId": 188 } { "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." }
]

7599
prisma/deals.json Normal file

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,2 @@
-- AlterTable
ALTER TABLE "Category" ADD COLUMN "description" TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Deal" ADD COLUMN "originalPrice" DOUBLE PRECISION,
ADD COLUMN "percentOff" DOUBLE PRECISION,
ADD COLUMN "shippingPrice" DOUBLE PRECISION;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Seller" ADD COLUMN "sellerLogo" TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,27 @@
-- AlterTable
ALTER TABLE "Comment" ADD COLUMN "likeCount" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "CommentLike" (
"id" SERIAL NOT NULL,
"commentId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CommentLike_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CommentLike_commentId_idx" ON "CommentLike"("commentId");
-- CreateIndex
CREATE INDEX "CommentLike_userId_idx" ON "CommentLike"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "CommentLike_commentId_userId_key" ON "CommentLike"("commentId", "userId");
-- AddForeignKey
ALTER TABLE "CommentLike" ADD CONSTRAINT "CommentLike_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommentLike" ADD CONSTRAINT "CommentLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

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

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Deal" ADD COLUMN "barcodeId" TEXT;

View File

@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "UserInterestProfile" (
"userId" INTEGER NOT NULL,
"categoryScores" JSONB NOT NULL DEFAULT '{}',
"totalScore" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserInterestProfile_pkey" PRIMARY KEY ("userId")
);
-- CreateIndex
CREATE INDEX "UserInterestProfile_updatedAt_idx" ON "UserInterestProfile"("updatedAt");
-- AddForeignKey
ALTER TABLE "UserInterestProfile" ADD CONSTRAINT "UserInterestProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserInterestProfile" ALTER COLUMN "updatedAt" DROP DEFAULT;

View File

@ -0,0 +1,2 @@
ALTER TABLE "Notification"
ADD COLUMN "extras" JSONB;

View File

@ -23,6 +23,10 @@ model User {
passwordHash String passwordHash String
avatarUrl String? @db.VarChar(512) avatarUrl String? @db.VarChar(512)
role UserRole @default(USER) role UserRole @default(USER)
notificationCount Int @default(0)
mutedUntil DateTime?
suspendedUntil DateTime?
disabledAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@ -36,6 +40,54 @@ model User {
dealNotices DealNotice[] @relation("UserDealNotices") dealNotices DealNotice[] @relation("UserDealNotices")
refreshTokens RefreshToken[] // <-- bunu ekle 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")
interestProfile UserInterestProfile?
}
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 { model RefreshToken {
@ -81,12 +133,16 @@ enum AffiliateType {
USER_AFFILIATE USER_AFFILIATE
} }
enum DiscountType {
PERCENT
AMOUNT
}
model SellerDomain { model SellerDomain {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
domain String @unique domain String @unique
sellerId Int sellerId Int
seller Seller @relation(fields: [sellerId], references: [id]) seller Seller @relation(fields: [sellerId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
createdById Int createdById Int
createdBy User @relation(fields: [createdById], references: [id]) createdBy User @relation(fields: [createdById], references: [id])
@ -96,6 +152,7 @@ model Seller {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
url String @default("") url String @default("")
sellerLogo String @default("")
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
createdById Int createdById Int
@ -113,8 +170,9 @@ model Category {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
slug String @unique slug String @unique
description String @default("")
parentId Int? parentId Int?
isActive Boolean @default(true)
parent Category? @relation("CategoryParent", fields: [parentId], references: [id]) parent Category? @relation("CategoryParent", fields: [parentId], references: [id])
children Category[] @relation("CategoryParent") children Category[] @relation("CategoryParent")
@ -130,6 +188,8 @@ model Tag {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
slug String @unique slug String @unique
name String name String
usageCount Int @default(0)
createdAt DateTime @default(now())
dealTags DealTag[] dealTags DealTag[]
} }
@ -157,7 +217,15 @@ model Deal {
description String? description String?
url String? url String?
price Float? price Float?
originalPrice Float?
shippingPrice Float?
percentOff Float?
couponCode String?
location String?
discountType DiscountType? @default(AMOUNT)
discountValue Float?
maxNotifiedMilestone Int @default(0)
barcodeId String?
userId Int userId Int
score Int @default(0) score Int @default(0)
commentCount Int @default(0) commentCount Int @default(0)
@ -186,10 +254,46 @@ model Deal {
// NEW: tags (multiple, optional) // NEW: tags (multiple, optional)
dealTags DealTag[] dealTags DealTag[]
aiReview DealAiReview? aiReview DealAiReview?
analyticsTotal DealAnalyticsTotal?
events DealEvent[]
savedBy DealSave[]
reports DealReport[]
@@index([categoryId, createdAt]) @@index([categoryId, createdAt])
@@index([userId, 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 { enum DealNoticeSeverity {
INFO INFO
WARNING WARNING
@ -267,6 +371,7 @@ model Comment {
dealId Int dealId Int
parentId Int? parentId Int?
likeCount Int @default(0)
deletedAt DateTime? deletedAt DateTime?
@ -275,13 +380,29 @@ model Comment {
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull) parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull)
replies Comment[] @relation("CommentReplies") replies Comment[] @relation("CommentReplies")
likes CommentLike[]
@@index([dealId, createdAt]) @@index([dealId, createdAt])
@@index([parentId, createdAt]) @@index([parentId, createdAt])
@@index([dealId, parentId, createdAt]) @@index([dealId, parentId, createdAt])
@@index([userId, createdAt])
@@index([deletedAt]) @@index([deletedAt])
} }
model CommentLike {
id Int @id @default(autoincrement())
commentId Int
userId Int
createdAt DateTime @default(now())
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([commentId, userId])
@@index([commentId])
@@index([userId])
}
enum DealAiIssueType { enum DealAiIssueType {
NONE NONE
PROFANITY PROFANITY
@ -291,6 +412,26 @@ enum DealAiIssueType {
OTHER OTHER
} }
enum DealEventType {
IMPRESSION
VIEW
CLICK
}
enum DealReportReason {
EXPIRED
WRONG_PRICE
MISLEADING
SPAM
OTHER
}
enum DealReportStatus {
OPEN
REVIEWED
CLOSED
}
model DealAiReview { model DealAiReview {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
@ -298,6 +439,7 @@ model DealAiReview {
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade) deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
bestCategoryId Int bestCategoryId Int
tags String[] @default([])
needsReview Boolean @default(false) needsReview Boolean @default(false)
hasIssue Boolean @default(false) hasIssue Boolean @default(false)
@ -309,3 +451,72 @@ model DealAiReview {
@@index([needsReview, hasIssue, updatedAt]) @@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 UserInterestProfile {
userId Int @id
categoryScores Json @default("{}")
totalScore Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([updatedAt])
}
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")
extras Json?
createdAt DateTime @default(now())
readAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
}

View File

@ -1,6 +1,5 @@
// prisma/seed.js // prisma/seed.js
const { PrismaClient, DealStatus, SaleType, AffiliateType } = require("@prisma/client") const { PrismaClient, DealStatus, SaleType, AffiliateType } = require("@prisma/client")
const bcrypt = require("bcryptjs")
const fs = require("fs") const fs = require("fs")
const path = require("path") const path = require("path")
@ -10,6 +9,13 @@ function randInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min return Math.floor(Math.random() * (max - min + 1)) + min
} }
function pickRandomCategoryId(categoryIds = [], fallbackCategoryId = 0) {
if (Array.isArray(categoryIds) && categoryIds.length) {
return categoryIds[randInt(0, categoryIds.length - 1)]
}
return fallbackCategoryId
}
// Stabil gerçek foto linkleri (redirect yok, hotlink derdi az) // Stabil gerçek foto linkleri (redirect yok, hotlink derdi az)
function realImage(seed, w = 1200, h = 900) { function realImage(seed, w = 1200, h = 900) {
return `https://picsum.photos/seed/${encodeURIComponent(seed)}/${w}/${h}` return `https://picsum.photos/seed/${encodeURIComponent(seed)}/${w}/${h}`
@ -27,6 +33,18 @@ function normalizeSlug(s) {
return String(s ?? "").trim().toLowerCase() return String(s ?? "").trim().toLowerCase()
} }
function toNumberOrNull(v) {
if (v === null || v === undefined || v === "") return null
const n = Number(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) { async function upsertTagBySlug(slug, name) {
const s = normalizeSlug(slug) const s = normalizeSlug(slug)
return prisma.tag.upsert({ return prisma.tag.upsert({
@ -66,6 +84,7 @@ function loadCategoriesJson(filePath) {
id: Number(c.id), id: Number(c.id),
name: String(c.name ?? "").trim(), name: String(c.name ?? "").trim(),
slug: normalizeSlug(c.slug), slug: normalizeSlug(c.slug),
description: c.description,
parentId: c.parentId === null || c.parentId === undefined ? null : Number(c.parentId), parentId: c.parentId === null || c.parentId === undefined ? null : Number(c.parentId),
})) }))
@ -102,11 +121,13 @@ async function seedCategoriesFromJson(categoriesFilePath) {
update: { update: {
name: c.name, name: c.name,
slug: c.slug, slug: c.slug,
description: c.description,
}, },
create: { create: {
id: c.id, id: c.id,
name: c.name, name: c.name,
slug: c.slug, slug: c.slug,
description: c.description,
parentId: null, parentId: null,
}, },
}) })
@ -135,63 +156,167 @@ async function seedCategoriesFromJson(categoriesFilePath) {
return { count: categories.length } return { count: categories.length }
} }
// 30 deal seed + her deal'a 3 foto + score 0-200 + tarih dağılımı: function loadSellersJson(filePath) {
// - %70: son 5 gün const raw = fs.readFileSync(filePath, "utf-8")
// - %30: 10 gün önce civarı (9-11 gün arası) const arr = JSON.parse(raw)
async function seedDeals30({ userId, sellerId, categoryId }) {
const baseItems = [
{ title: "Samsung 990 PRO 1TB NVMe SSD", price: 3299.99, url: "https://example.com/samsung-990pro-1tb", q: "nvme ssd" },
{ title: "Logitech MX Master 3S Mouse", price: 2499.9, url: "https://example.com/mx-master-3s", q: "wireless mouse" },
{ title: "Sony WH-1000XM5 Kulaklık", price: 9999.0, url: "https://example.com/sony-xm5", q: "headphones" },
{ title: "Apple AirPods Pro 2", price: 8499.0, url: "https://example.com/airpods-pro-2", q: "earbuds" },
{ title: "Anker 65W GaN Şarj Aleti", price: 899.0, url: "https://example.com/anker-65w-gan", q: "charger" },
{ title: "Kindle Paperwhite 16GB", price: 5199.0, url: "https://example.com/kindle-paperwhite", q: "ebook reader" },
{ title: 'Dell 27" 144Hz Monitör', price: 7999.0, url: "https://example.com/dell-27-144hz", q: "gaming monitor" },
{ title: "TP-Link Wi-Fi 6 Router", price: 1999.0, url: "https://example.com/tplink-wifi6", q: "wifi router" },
{ title: "Razer Huntsman Mini Klavye", price: 3499.0, url: "https://example.com/huntsman-mini", q: "mechanical keyboard" },
{ title: "WD Elements 2TB Harici Disk", price: 2399.0, url: "https://example.com/wd-elements-2tb", q: "external hard drive" },
{ title: "Samsung T7 Shield 1TB SSD", price: 2799.0, url: "https://example.com/samsung-t7-shield", q: "portable ssd" },
{ title: "Xiaomi Mi Band 8", price: 1399.0, url: "https://example.com/mi-band-8", q: "smart band" },
{ title: "Philips Airfryer 6.2L", price: 5999.0, url: "https://example.com/philips-airfryer", q: "air fryer" },
{ title: "Dyson V12 Detect Slim", price: 21999.0, url: "https://example.com/dyson-v12", q: "vacuum cleaner" },
{ title: "Nespresso Vertuo Kahve Makinesi", price: 6999.0, url: "https://example.com/nespresso-vertuo", q: "coffee machine" },
]
// 30'a tamamlamak için ikinci bir set üret (title/url benzersiz olsun) 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)
if (!Array.isArray(arr)) throw new Error("deals.json array olmalı")
const items = arr.map((d, idx) => {
const title = String(d.title ?? "").trim()
const url = String(d.url ?? "").trim()
const q = String(d.q ?? "").trim()
const price = toNumberOrNull(d.price)
const originalPrice = toNumberOrNull(d.originalPrice)
const shippingPrice = toNumberOrNull(d.shippingPrice)
if (!title) throw new Error(`deals.json: title boş (index=${idx})`)
if (!url) throw new Error(`deals.json: url boş (index=${idx})`)
if (price === null) throw new Error(`deals.json: price invalid (index=${idx})`)
// Mantık: originalPrice varsa price'dan küçük olamaz
if (originalPrice !== null && originalPrice < price) {
throw new Error(`deals.json: originalPrice < price (index=${idx})`)
}
return {
title,
url,
q,
price,
originalPrice,
shippingPrice,
}
})
// url unique olsun (seed idempotent)
const urlSet = new Set()
for (const it of items) {
if (urlSet.has(it.url)) throw new Error(`deals.json duplicate url: ${it.url}`)
urlSet.add(it.url)
}
return items
}
// deals.jsondan seed + her deala 3 foto + score 0-200 + tarih dağılımı:
// - %70: son 5 gün
// - %30: 9-11 gün önce
async function seedDealsFromJson({ userId, sellerId, categoryIds = [], defaultCategoryId = 0, dealsFilePath }) {
const baseItems = loadDealsJson(dealsFilePath)
// 30 adet olacak şekilde çoğalt (title/url benzersizleşsin)
const items = [] const items = []
for (let i = 0; i < 30; i++) { for (let i = 0; i < 1000; i++) {
const base = baseItems[i % baseItems.length] const base = baseItems[i % baseItems.length]
const n = i + 1 const n = i + 1
// price'ı hafif oynat (base price üzerinden)
const price = Number((base.price * (0.9 + randInt(0, 30) / 100)).toFixed(2))
// originalPrice varsa, yeni price'a göre ölçekle (mantık korunur)
let originalPrice = null
if (base.originalPrice !== null && base.originalPrice !== undefined) {
const ratio = base.originalPrice / base.price // >= 1 olmalı
originalPrice = Number((price * ratio).toFixed(2))
if (originalPrice < price) originalPrice = Number((price * 1.05).toFixed(2))
}
// shippingPrice varsa bazen aynen, bazen 0/ufak varyasyon (ama null değilse)
let shippingPrice = null
if (base.shippingPrice !== null && base.shippingPrice !== undefined) {
// 70% aynı, 30% küçük oynat
if (Math.random() < 0.7) {
shippingPrice = Number(base.shippingPrice)
} else {
const candidates = [0, 19.9, 29.9, 39.9, 49.9, 59.9]
shippingPrice = candidates[randInt(0, candidates.length - 1)]
}
}
items.push({ items.push({
title: `${base.title} #${n}`, title: `${base.title} #${n}`,
price: Number((base.price * (0.9 + (randInt(0, 30) / 100))).toFixed(2)), price,
url: `${base.url}?seed=${n}`, originalPrice,
q: base.q, shippingPrice,
url: `${base.url}${base.url.includes("?") ? "&" : "?"}seed=${n}`,
q: base.q || "product",
}) })
} }
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const it = items[i] const it = items[i]
// %30'u 9-11 gün önce, %70'i son 5 gün
const older = Math.random() < 0.3 const older = Math.random() < 0.3
const createdAt = older const createdAt = older
? new Date(Date.now() - randInt(9, 11) * 24 * 60 * 60 * 1000 - randInt(0, 12) * 60 * 60 * 1000) ? new Date(Date.now() - randInt(9, 11) * 24 * 60 * 60 * 1000 - randInt(0, 12) * 60 * 60 * 1000)
: randomDateWithinLastDays(5) : randomDateWithinLastDays(5)
// Not: modelinde score yoksa score satırını sil
const dealData = { const dealData = {
title: it.title, title: it.title,
description: "Seed test deal açıklaması (otomatik üretim).", description: "Seed test deal açıklaması (otomatik üretim).",
url: it.url, url: it.url,
price: it.price, price: it.price,
originalPrice: it.originalPrice ?? null,
shippingPrice: it.shippingPrice ?? null,
status: DealStatus.ACTIVE, status: DealStatus.ACTIVE,
saletype: SaleType.ONLINE, saletype: SaleType.ONLINE,
affiliateType: AffiliateType.NON_AFFILIATE, affiliateType: AffiliateType.NON_AFFILIATE,
commentCount: randInt(0, 25), commentCount: randInt(0, 25),
userId, userId,
sellerId, sellerId,
categoryId, categoryId: pickRandomCategoryId(categoryIds, defaultCategoryId),
score: randInt(0, 200), score: randInt(0, 200),
createdAt, createdAt,
} }
@ -242,17 +367,11 @@ async function main() {
}, },
}) })
// ---------- SELLER ---------- // ---------- SELLERS (FROM JSON) ----------
const amazon = await prisma.seller.upsert({ const sellersFilePath = path.join(__dirname, "sellers.json")
where: { name: "Amazon" }, await seedSellersFromJson(sellersFilePath, admin.id)
update: { isActive: true }, const amazon = await prisma.seller.findUnique({ where: { name: "Amazon" } })
create: { if (!amazon) throw new Error("Amazon seller bulunamadı (sellers.json)")
name: "Amazon",
url: "https://www.amazon.com.tr",
isActive: true,
createdById: admin.id,
},
})
// ---------- SELLER DOMAINS ---------- // ---------- SELLER DOMAINS ----------
const domains = ["amazon.com", "amazon.com.tr"] const domains = ["amazon.com", "amazon.com.tr"]
@ -269,13 +388,19 @@ async function main() {
} }
// ---------- CATEGORIES (FROM JSON) ---------- // ---------- CATEGORIES (FROM JSON) ----------
const categoriesFilePath = path.join(__dirname, "", "categories.json") const categoriesFilePath = path.join(__dirname, "categories.json")
const { count } = await seedCategoriesFromJson(categoriesFilePath) const { count } = await seedCategoriesFromJson(categoriesFilePath)
const catSSD = await prisma.category.findUnique({ const catSSD = await prisma.category.findUnique({
where: { slug: "pc-ssd" }, where: { slug: "pc-ssd" },
select: { id: true }, select: { id: true },
}) })
const availableCategoryIds = (
await prisma.category.findMany({
where: { isActive: true, id: { gt: 0 } },
select: { id: true },
})
).map((cat) => cat.id)
// ---------- TAGS ---------- // ---------- TAGS ----------
await upsertTagBySlug("ssd", "SSD") await upsertTagBySlug("ssd", "SSD")
@ -294,13 +419,15 @@ async function main() {
description: "Test deal açıklaması", description: "Test deal açıklaması",
url: dealUrl, url: dealUrl,
price: 1299.99, price: 1299.99,
originalPrice: 1499.99, // örnek
shippingPrice: 0, // örnek
status: DealStatus.ACTIVE, status: DealStatus.ACTIVE,
saletype: SaleType.ONLINE, saletype: SaleType.ONLINE,
affiliateType: AffiliateType.NON_AFFILIATE, affiliateType: AffiliateType.NON_AFFILIATE,
commentCount: 1, commentCount: 1,
userId: user.id, userId: user.id,
sellerId: amazon.id, sellerId: amazon.id,
categoryId: catSSD?.id ?? 0, categoryId: pickRandomCategoryId(availableCategoryIds, catSSD?.id ?? 0),
// score: randInt(0, 200), // modelinde varsa aç // score: randInt(0, 200), // modelinde varsa aç
} }
@ -321,11 +448,14 @@ async function main() {
], ],
}) })
// ✅ ---------- 30 DEAL ÜRET ---------- // ✅ ---------- deals.jsondan 30 DEAL ÜRET ----------
await seedDeals30({ const dealsFilePath = path.join(__dirname, "deals.json")
await seedDealsFromJson({
userId: user.id, userId: user.id,
sellerId: amazon.id, sellerId: amazon.id,
categoryId: catSSD?.id ?? 0, categoryIds: availableCategoryIds,
defaultCategoryId: catSSD?.id ?? 0,
dealsFilePath,
}) })
// ---------- VOTE ---------- // ---------- VOTE ----------
@ -347,7 +477,7 @@ async function main() {
} }
console.log(`✅ Seed tamamlandı (categories.json yüklendi: ${count} kategori)`) console.log(`✅ Seed tamamlandı (categories.json yüklendi: ${count} kategori)`)
console.log("✅ 30 adet test deal + 3'er görsel + score(0-200) + tarih dağılımı eklendi/güncellendi") console.log("✅ deals.json baz alınarak 30 adet test deal + 3'er görsel + score(0-200) + tarih dağılımı eklendi/güncellendi")
} }
main() main()

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,16 +1,37 @@
const express = require("express") const express = require("express")
const multer = require("multer") const multer = require("multer")
const requireAuth = require("../middleware/requireAuth.js") 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 { endpoints } = require("@shared/contracts")
const router = express.Router() const router = express.Router()
const upload = multer({ dest: "uploads/" }) const upload = multer({ dest: "uploads/" })
const { updateUserAvatar } = require("../services/avatar.service") const { updateUserAvatar } = require("../services/avatar.service")
const { enqueueAuditFromRequest, buildAuditMeta } = require("../services/audit.service")
const { AUDIT_ACTIONS } = require("../services/auditActions")
const { account } = endpoints const { account } = endpoints
function attachNotificationExtras(validatedList = [], sourceList = []) {
const extrasById = new Map(
(Array.isArray(sourceList) ? sourceList : []).map((item) => [
Number(item?.id),
item?.extras ?? null,
])
)
return (Array.isArray(validatedList) ? validatedList : []).map((item) => ({
...item,
extras: extrasById.get(Number(item?.id)) ?? null,
}))
}
router.post( router.post(
"/avatar", "/avatar",
requireAuth, requireAuth,
@ -18,6 +39,15 @@ router.post(
async (req, res) => { async (req, res) => {
try { try {
const updatedUser = await updateUserAvatar(req.auth.userId, req.file) 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( res.json(
account.avatarUploadResponseSchema.parse({ account.avatarUploadResponseSchema.parse({
@ -35,10 +65,61 @@ router.post(
router.get("/me", requireAuth, async (req, res) => { router.get("/me", requireAuth, async (req, res) => {
try { try {
const user = await getUserProfile(req.auth.userId) const user = await getUserProfile(req.auth.userId)
res.json(account.accountMeResponseSchema.parse(user)) const payload = account.accountMeResponseSchema.parse(user)
payload.notifications = attachNotificationExtras(payload.notifications, user?.notifications)
res.json(payload)
} catch (err) {
res.status(400).json({ error: err.message })
}
})
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)
const validated = account.accountNotificationsListResponseSchema.parse(payload)
validated.results = attachNotificationExtras(validated.results, payload?.results)
res.json(validated)
} 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) { } catch (err) {
res.status(400).json({ error: err.message }) res.status(400).json({ error: err.message })
} }
}) })
module.exports = router module.exports = router

View File

@ -13,32 +13,37 @@ const { mapMeRequestToUserId, mapMeResultToResponse } = require("../adapters/res
const { auth } = endpoints const { auth } = endpoints
// NOT: app.jsde cookie-parser olmalı: // NOT: app.jsde cookie-parser olmali:
// const cookieParser = require("cookie-parser") // const cookieParser = require("cookie-parser")
// app.use(cookieParser()) // app.use(cookieParser())
function getCookieOptions() { function getCookieOptions() {
const isProd = process.env.NODE_ENV === "production" const isProd = process.env.NODE_ENV === "production"
// DEV: http localhost -> secure false, sameSite lax
if (!isProd) {
return { return {
httpOnly: true, httpOnly: true,
secure: false, secure: isProd,
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
} }
} }
// PROD: cross-site kullanacaksan (frontend ayrı domain) function parseExpiresInToMs(value) {
return { if (!value) return 15 * 60 * 1000
httpOnly: true, if (typeof value === "number" && Number.isFinite(value)) return value * 1000
secure: true, const str = String(value).trim().toLowerCase()
sameSite: "none", const match = str.match(/^(\d+)(ms|s|m|h|d)?$/)
path: "/", 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) { function setRefreshCookie(res, refreshToken) {
const opts = getCookieOptions() const opts = getCookieOptions()
@ -46,11 +51,22 @@ function setRefreshCookie(res, refreshToken) {
res.cookie("rt", refreshToken, { ...opts, maxAge: maxAgeMs }) 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) { function clearRefreshCookie(res) {
const opts = getCookieOptions() const opts = getCookieOptions()
res.clearCookie("rt", { ...opts }) res.clearCookie("rt", { ...opts })
} }
function clearAccessCookie(res) {
const opts = getCookieOptions()
res.clearCookie("at", { ...opts })
}
router.post( router.post(
"/register", "/register",
validate(auth.registerRequestSchema, "body", "validatedRegisterInput"), validate(auth.registerRequestSchema, "body", "validatedRegisterInput"),
@ -63,10 +79,10 @@ router.post(
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null }, 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.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)) const response = auth.authResponseSchema.parse(mapRegisterResultToResponse(result))
res.json(response) res.json(response)
} catch (err) { } catch (err) {
@ -88,16 +104,13 @@ router.post(
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null }, meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
}) })
// refresh cookie set // refresh + access cookie set
setRefreshCookie(res, result.refreshToken) setRefreshCookie(res, result.refreshToken)
setAccessCookie(res, result.accessToken)
const response = auth.authResponseSchema.parse(mapLoginResultToResponse(result)) const response = auth.authResponseSchema.parse(mapLoginResultToResponse(result))
res.json(response) res.json(response)
} catch (err) { } 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 const status = err.statusCode || 500
res.status(status).json({ res.status(status).json({
message: err.statusCode ? err.message : "Giris islemi basarisiz.", 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 }, meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
}) })
// rotate -> yeni refresh cookie // rotate -> yeni refresh + access cookie
setRefreshCookie(res, result.refreshToken) setRefreshCookie(res, result.refreshToken)
setAccessCookie(res, result.accessToken)
// body: access + user (adapter refresh'i koymamalı)
const response = auth.authResponseSchema.parse(mapLoginResultToResponse(result)) const response = auth.authResponseSchema.parse(mapLoginResultToResponse(result))
res.json(response) res.json(response)
} catch (err) { } catch (err) {
clearRefreshCookie(res) clearRefreshCookie(res)
clearAccessCookie(res)
const status = err.statusCode || 401 const status = err.statusCode || 401
res.status(status).json({ message: err.message || "Refresh basarisiz" }) res.status(status).json({ message: err.message || "Refresh basarisiz" })
} }
@ -133,15 +147,19 @@ router.post("/logout", async (req, res) => {
try { try {
const refreshToken = req.cookies?.rt const refreshToken = req.cookies?.rt
// logout idempotent olsun
if (refreshToken) { if (refreshToken) {
await authService.logout({ refreshToken }) await authService.logout({
refreshToken,
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
})
} }
clearRefreshCookie(res) clearRefreshCookie(res)
clearAccessCookie(res)
res.status(204).send() res.status(204).send()
} catch (err) { } catch (err) {
clearRefreshCookie(res) clearRefreshCookie(res)
clearAccessCookie(res)
const status = err.statusCode || 500 const status = err.statusCode || 500
res.status(status).json({ message: err.message || "Cikis basarisiz" }) 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) => { router.get("/me", requireAuth, async (req, res) => {
try { try {
const userId = mapMeRequestToUserId(req) // req.auth.userId okumalı const userId = mapMeRequestToUserId(req)
const user = await authService.getMe(userId) const user = await authService.getMe(userId)
const response = auth.meResponseSchema.parse(mapMeResultToResponse(user)) const response = auth.meResponseSchema.parse(mapMeResultToResponse(user))
res.json(response) res.json(response)
@ -160,3 +178,4 @@ router.get("/me", requireAuth, async (req, res) => {
}) })
module.exports = router 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

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

@ -0,0 +1,19 @@
const express = require("express")
const { getCachedImageByKey } = require("../services/redis/linkPreviewImageCache.service")
const router = express.Router()
router.get("/deal_create/:key", async (req, res) => {
try {
const key = req.params.key
const cached = await getCachedImageByKey(key)
if (!cached) return res.status(404).json({ error: "Not found" })
res.setHeader("Content-Type", cached.contentType)
res.setHeader("Cache-Control", "public, max-age=300")
return res.status(200).send(cached.buffer)
} catch (err) {
return res.status(500).json({ error: "Sunucu hatasi" })
}
})
module.exports = router

View File

@ -1,9 +1,13 @@
const express = require("express"); const express = require("express");
const categoryService = require("../services/category.service"); // Kategori servisi const categoryService = require("../services/category.service"); // Kategori servisi
const router = express.Router(); const router = express.Router();
const optionalAuth = require("../middleware/optionalAuth")
const { mapCategoryToCategoryDetailsResponse }=require("../adapters/responses/categoryDetails.adapter") const { mapCategoryToCategoryDetailsResponse }=require("../adapters/responses/categoryDetails.adapter")
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter") const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter")
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter") const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
const { getClientIp } = require("../utils/requestInfo")
const { queueDealImpressions } = require("../services/redis/dealAnalytics.service")
const { trackUserCategoryInterest, USER_INTEREST_ACTIONS } = require("../services/userInterest.service")
router.get("/:slug", async (req, res) => { router.get("/:slug", async (req, res) => {
@ -22,7 +26,10 @@ router.get("/:slug", async (req, res) => {
}); });
router.get("/:slug/deals", async (req, res) => { const buildViewer = (req) =>
req.auth ? { userId: req.auth.userId, role: req.auth.role } : null
router.get("/:slug/deals", optionalAuth, async (req, res) => {
const { slug } = req.params; const { slug } = req.params;
const { page = 1, limit = 10, ...filters } = req.query; const { page = 1, limit = 10, ...filters } = req.query;
@ -33,13 +40,38 @@ router.get("/:slug/deals", async (req, res) => {
} }
// Kategorinin fırsatlarını alıyoruz // Kategorinin fırsatlarını alıyoruz
const deals = await categoryService.getDealsByCategoryId(category.id, page, limit, filters); const payload = await categoryService.getDealsByCategoryId(category.id, {
page,
limit,
filters,
viewer: buildViewer(req),
});
const response = mapPaginatedDealsToDealCardResponse(payload) 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))
if (req.auth?.userId) {
trackUserCategoryInterest({
userId: req.auth.userId,
categoryId: category.id,
action: USER_INTEREST_ACTIONS.CATEGORY_VISIT,
}).catch((err) => console.error("User interest track failed:", err?.message || err))
}
// frontend DealCard[] bekliyor res.json({
res.json(response.results) page: response.page,
total: response.total,
totalPages: response.totalPages,
results: response.results,
minPrice: payload?.minPrice ?? null,
maxPrice: payload?.maxPrice ?? null,
})
} catch (err) { } catch (err) {
res.status(500).json({ error: "Kategoriye ait fırsatlar alınırken bir hata oluştu", message: err.message }); res.status(500).json({ error: "Kategoriye ait fırsatlar alınırken bir hata oluştu", message: err.message });
} }

View File

@ -1,8 +1,12 @@
const express = require("express") const express = require("express")
const requireAuth = require("../middleware/requireAuth.js") const requireAuth = require("../middleware/requireAuth.js")
const requireNotRestricted = require("../middleware/requireNotRestricted")
const optionalAuth = require("../middleware/optionalAuth")
const { validate } = require("../middleware/validate.middleware") const { validate } = require("../middleware/validate.middleware")
const { endpoints } = require("@shared/contracts") const { endpoints } = require("@shared/contracts")
const { createComment, deleteComment } = require("../services/comment.service") 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 dealCommentAdapter = require("../adapters/responses/comment.adapter")
const commentService = require("../services/comment.service") const commentService = require("../services/comment.service")
@ -12,13 +16,28 @@ const { comments } = endpoints
router.get( router.get(
"/:dealId", "/:dealId",
optionalAuth,
validate(comments.commentListRequestSchema, "params", "validatedDealId"), validate(comments.commentListRequestSchema, "params", "validatedDealId"),
async (req, res) => { async (req, res) => {
try { try {
const { dealId } = req.validatedDealId const { dealId } = req.validatedDealId
const fetched = await commentService.getCommentsByDealId(dealId) const { parentId, page, limit, sort } = req.query
const mapped = dealCommentAdapter.mapCommentsToDealCommentResponse(fetched) const payload = await commentService.getCommentsByDealId(dealId, {
res.json(comments.commentListResponseSchema.parse(mapped)) parentId,
page,
limit,
sort,
viewer: req.auth ? { userId: req.auth.userId } : null,
})
const mapped = dealCommentAdapter.mapCommentsToDealCommentResponse(payload.results)
res.json(
comments.commentListResponseSchema.parse({
page: payload.page,
total: payload.total,
totalPages: payload.totalPages,
results: mapped,
})
)
} catch (err) { } catch (err) {
res.status(400).json({ error: err.message }) res.status(400).json({ error: err.message })
} }
@ -28,6 +47,7 @@ router.get(
router.post( router.post(
"/", "/",
requireAuth, requireAuth,
requireNotRestricted({ checkMute: true, checkSuspend: true }),
validate(comments.commentCreateRequestSchema, "body", "validatedCommentPayload"), validate(comments.commentCreateRequestSchema, "body", "validatedCommentPayload"),
async (req, res) => { async (req, res) => {
try { try {
@ -36,6 +56,15 @@ router.post(
const comment = await createComment({ dealId, userId, text, parentId }) const comment = await createComment({ dealId, userId, text, parentId })
const mapped = dealCommentAdapter.mapCommentToDealCommentResponse(comment) 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)) res.json(comments.commentCreateResponseSchema.parse(mapped))
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message || "Sunucu hatasi" }) res.status(500).json({ error: err.message || "Sunucu hatasi" })
@ -51,6 +80,14 @@ router.delete(
try { try {
const { id } = req.validatedDeleteComment const { id } = req.validatedDeleteComment
const result = await deleteComment(id, req.auth.userId) 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)) res.json(comments.commentDeleteResponseSchema.parse(result))
} catch (err) { } catch (err) {
const status = err.message?.includes("yetkin") ? 403 : 404 const status = err.message?.includes("yetkin") ? 403 : 404
@ -60,3 +97,4 @@ router.delete(
) )
module.exports = router module.exports = router

View File

@ -0,0 +1,23 @@
const express = require("express")
const requireAuth = require("../middleware/requireAuth")
const { setCommentLike } = require("../services/commentLike.service")
const router = express.Router()
// Body: { commentId: number, like: boolean | 0 | 1 }
router.post("/", requireAuth, async (req, res) => {
try {
const { commentId, like } = req.body || {}
const result = await setCommentLike({ commentId, userId: req.auth.userId, like })
res.json({
commentId: Number(commentId),
likeCount: result.likeCount,
liked: result.liked,
delta: result.delta,
})
} catch (err) {
res.status(400).json({ error: err.message || "Like işlemi başarısız" })
}
})
module.exports = router

View File

@ -3,20 +3,61 @@ const express = require("express")
const router = express.Router() const router = express.Router()
const requireAuth = require("../middleware/requireAuth") const requireAuth = require("../middleware/requireAuth")
const requireNotRestricted = require("../middleware/requireNotRestricted")
const optionalAuth = require("../middleware/optionalAuth") const optionalAuth = require("../middleware/optionalAuth")
const { upload } = require("../middleware/upload.middleware") const { upload } = require("../middleware/upload.middleware")
const { validate } = require("../middleware/validate.middleware") const { validate } = require("../middleware/validate.middleware")
const { endpoints } = require("@shared/contracts") const { endpoints } = require("@shared/contracts")
const requireApiKey = require("../middleware/requireApiKey")
const userDB = require("../db/user.db") 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 personalizedFeedService = require("../services/personalizedFeed.service")
const { mapCreateDealRequestToDealCreateData } = require("../adapters/requests/dealCreate.adapter") const { mapCreateDealRequestToDealCreateData } = require("../adapters/requests/dealCreate.adapter")
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.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 { trackUserCategoryInterest, USER_INTEREST_ACTIONS } = require("../services/userInterest.service")
const { getOrCacheDeal } = require("../services/redis/dealCache.service")
const { enqueueAuditFromRequest, buildAuditMeta } = require("../services/audit.service")
const { AUDIT_ACTIONS } = require("../services/auditActions")
const { toSafeRedirectUrl } = require("../utils/urlSafety")
const { deals, users } = endpoints const { deals, users } = endpoints
function isUserInterestDebugEnabled() {
const raw = String(process.env.USER_INTEREST_DEBUG || "0").trim().toLowerCase()
return raw === "1" || raw === "true" || raw === "yes" || raw === "on"
}
function parsePage(value) {
const num = Number(value)
if (!Number.isInteger(num) || num < 1) return 1
return num
}
function logUserInterestDebug(label, payload = {}) {
if (!isUserInterestDebugEnabled()) return
try {
console.log(`[user-interest] ${label}`, payload)
} catch {}
}
const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery") const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery")
const buildViewer = (req) => const buildViewer = (req) =>
@ -26,7 +67,7 @@ function createListHandler(preset) {
return async (req, res) => { return async (req, res) => {
try { try {
const viewer = buildViewer(req) const viewer = buildViewer(req)
const { q, page, limit } = req.validatedDealListQuery const { q, page, limit, hotListId, trendingListId } = req.validatedDealListQuery
const payload = await getDeals({ const payload = await getDeals({
preset, preset,
@ -34,11 +75,20 @@ function createListHandler(preset) {
page, page,
limit, limit,
viewer, viewer,
filters: req.query,
hotListId,
trendingListId,
}) })
const response = deals.dealsListResponseSchema.parse( const response = deals.dealsListResponseSchema.parse(
mapPaginatedDealsToDealCardResponse(payload) 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) res.json(response)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -48,6 +98,7 @@ function createListHandler(preset) {
} }
} }
// Public deals of a user (viewer optional; self profile => "MY" else "USER_PUBLIC") // Public deals of a user (viewer optional; self profile => "MY" else "USER_PUBLIC")
router.get( router.get(
"/users/:userName/deals", "/users/:userName/deals",
@ -64,7 +115,7 @@ router.get(
if (!targetUser) return res.status(404).json({ error: "Kullanici bulunamadi" }) 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 viewer = buildViewer(req)
const isSelfProfile = viewer?.userId === targetUser.id const isSelfProfile = viewer?.userId === targetUser.id
const preset = isSelfProfile ? "MY" : "USER_PUBLIC" const preset = isSelfProfile ? "MY" : "USER_PUBLIC"
@ -76,11 +127,20 @@ router.get(
limit, limit,
targetUserId: targetUser.id, targetUserId: targetUser.id,
viewer, viewer,
filters: req.query,
hotListId,
trendingListId,
}) })
const response = deals.dealsListResponseSchema.parse( const response = deals.dealsListResponseSchema.parse(
mapPaginatedDealsToDealCardResponse(payload) 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) res.json(response)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -98,10 +158,262 @@ router.get(
createListHandler("MY") createListHandler("MY")
) )
router.get("/new", optionalAuth, listQueryValidator, createListHandler("NEW")) router.get("/new", requireApiKey, optionalAuth, listQueryValidator, createListHandler("NEW"))
router.get("/hot", optionalAuth, listQueryValidator, createListHandler("HOT")) router.get("/hot", requireApiKey, optionalAuth, listQueryValidator, createListHandler("HOT"))
router.get("/trending", optionalAuth, listQueryValidator, createListHandler("TRENDING")) router.get("/trending", requireApiKey, optionalAuth, listQueryValidator, createListHandler("TRENDING"))
router.get("/", optionalAuth, listQueryValidator, createListHandler("NEW"))
router.get("/for-you", requireApiKey, requireAuth, async (req, res) => {
try {
const page = parsePage(req.query.page)
const payload = await personalizedFeedService.getPersonalizedDeals({
userId: req.auth.userId,
page,
})
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, personalizedListId: payload.personalizedListId ?? null })
} catch (err) {
console.error(err)
const status = err.statusCode || 500
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
})
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",
(req, res, next) => {
logUserInterestDebug("deal-click-request", {
hasApiKeyHeader: Boolean(req.headers?.["x-api-key"]),
hasAuthorizationHeader: Boolean(req.headers?.authorization),
hasAtCookie: Boolean(req.cookies?.at),
dealIdRaw: req.body?.dealId ?? null,
})
return next()
},
requireApiKey,
optionalAuth,
async (req, res) => {
try {
const dealId = Number(req.body?.dealId)
if (!Number.isInteger(dealId) || dealId <= 0) {
logUserInterestDebug("deal-click-skip", {
reason: "invalid_deal_id",
dealIdRaw: req.body?.dealId ?? null,
})
return res.status(400).json({ error: "dealId invalid" })
}
const deal = await getOrCacheDeal(dealId, { ttlSeconds: 15 * 60 })
if (!deal) {
logUserInterestDebug("deal-click-skip", {
reason: "deal_not_found",
dealId,
})
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) {
logUserInterestDebug("deal-click-skip", {
reason: "deal_not_visible_for_user",
dealId,
status: deal.status,
userId: req.auth?.userId ?? null,
})
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)
)
if (userId) {
trackUserCategoryInterest({
userId,
categoryId: deal.categoryId,
action: USER_INTEREST_ACTIONS.DEAL_CLICK,
}).catch((err) => console.error("User interest track failed:", err?.message || err))
logUserInterestDebug("deal-click-track", {
dealId,
userId,
categoryId: deal.categoryId ?? null,
status: deal.status,
})
} else {
logUserInterestDebug("deal-click-skip", {
reason: "missing_auth_user",
dealId,
categoryId: deal.categoryId ?? null,
hasAuthorizationHeader: Boolean(req.headers?.authorization),
hasAtCookie: Boolean(req.cookies?.at),
})
}
const safeUrl = toSafeRedirectUrl(deal.url)
if (!safeUrl) {
return res.status(422).json({ error: "Deal URL gecersiz" })
}
res.json({ url: safeUrl })
} 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( router.get(
"/search", "/search",
@ -109,7 +421,7 @@ router.get(
listQueryValidator, listQueryValidator,
async (req, res) => { async (req, res) => {
try { try {
const { q, page, limit } = req.validatedDealListQuery const { q, page, limit, hotListId, trendingListId } = req.validatedDealListQuery
if (!q || !q.trim()) { if (!q || !q.trim()) {
return res.json({ results: [], total: 0, totalPages: 0, page }) return res.json({ results: [], total: 0, totalPages: 0, page })
} }
@ -120,11 +432,22 @@ router.get(
page, page,
limit, limit,
viewer: buildViewer(req), viewer: buildViewer(req),
filters: req.query,
baseWhere: { status: "ACTIVE" },
hotListId,
trendingListId,
useRedisSearch: true,
}) })
const response = deals.dealsListResponseSchema.parse( const response = deals.dealsListResponseSchema.parse(
mapPaginatedDealsToDealCardResponse(payload) 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) res.json(response)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -153,11 +476,18 @@ router.get("/top", optionalAuth, async (req, res) => {
page: 1, page: 1,
limit, limit,
viewer, viewer,
filters: req.query,
}) })
const response = deals.dealsListResponseSchema.parse( const response = deals.dealsListResponseSchema.parse(
mapPaginatedDealsToDealCardResponse(payload) 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 // frontend DealCard[] bekliyor
res.json(response.results) res.json(response.results)
@ -168,22 +498,50 @@ 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" })
}
}
)
router.get( router.get(
"/:id", "/:id",
optionalAuth,
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"), validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
async (req, res) => { async (req, res) => {
try { try {
const { id } = req.validatedDealId const { id } = req.validatedDealId
const deal = await getDealById(id) const deal = await getDealById(id, buildViewer(req))
if (!deal) return res.status(404).json({ error: "Deal bulunamadi" }) 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))
if (req.auth?.userId) {
trackUserCategoryInterest({
userId: req.auth.userId,
categoryId: deal.categoryId,
action: USER_INTEREST_ACTIONS.DEAL_VIEW,
}).catch((err) => console.error("User interest track failed:", err?.message || err))
}
console.log(mapped) const mapped = mapDealToDealDetailResponse(deal)
res.json(deals.dealDetailResponseSchema.parse(mapped)) res.json(deals.dealDetailResponseSchema.parse(mapped))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -196,6 +554,7 @@ router.get(
router.post( router.post(
"/", "/",
requireAuth, requireAuth,
requireNotRestricted({ checkSuspend: true }),
upload.array("images", 5), upload.array("images", 5),
validate(deals.dealCreateRequestSchema, "body", "validatedDealPayload"), validate(deals.dealCreateRequestSchema, "body", "validatedDealPayload"),
async (req, res) => { async (req, res) => {
@ -208,12 +567,23 @@ router.post(
const deal = await createDeal(dealCreateData, req.files || []) const deal = await createDeal(dealCreateData, req.files || [])
const mapped = mapDealToDealDetailResponse(deal) 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)) res.json(deals.dealCreateResponseSchema.parse(mapped))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
res.status(500).json({ error: "Sunucu hatasi" }) const status = err.statusCode || 500
res.status(status).json({ error: status >= 500 ? "Sunucu hatasi" : err.message })
} }
} }
) )
module.exports = router module.exports = router

802
routes/mod.routes.js Normal file
View File

@ -0,0 +1,802 @@
const express = require("express")
const router = express.Router()
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,
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, 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
const payload = await getPendingDeals({
page,
limit,
filters: { ...req.query, q },
viewer: buildViewer(req),
})
const response = mapPaginatedDealsToDealCardResponse(payload)
res.json(response.results)
} catch (err) {
const status = err.statusCode || 500
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
})
router.post(
"/deals/:id/approve",
requireAuth,
requireRole("MOD"),
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
async (req, res) => {
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
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
}
)
router.post(
"/deals/:id/reject",
requireAuth,
requireRole("MOD"),
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
async (req, res) => {
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
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
}
)
router.post(
"/deals/:id/expire",
requireAuth,
requireRole("MOD"),
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
async (req, res) => {
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
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
}
)
router.post(
"/deals/:id/unexpire",
requireAuth,
requireRole("MOD"),
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
async (req, res) => {
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
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
}
)
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 payload = await dealReportService.listDealReports({
})
res.json(payload)
} catch (err) {
const status = err.statusCode || 500
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
})
router.get("/deals/reports/pending", requireAuth, requireRole("MOD"), async (req, res) => {
try {
const page = Number(req.query.page || 1)
const payload = await dealReportService.getPendingReports({ page })
res.json(payload)
} catch (err) {
const status = err.statusCode || 500
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
})
router.post("/badges", requireAuth, requireRole("MOD"), modBadgeCreateValidator, async (req, res) => {
try {
const badge = await badgeService.createBadge(req.validatedBadgeCreate)
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

@ -2,15 +2,47 @@ const express = require("express")
const router = express.Router() const router = express.Router()
const requireAuth = require("../middleware/requireAuth") const requireAuth = require("../middleware/requireAuth")
const optionalAuth = require("../middleware/optionalAuth")
const { validate } = require("../middleware/validate.middleware")
const { endpoints } = require("@shared/contracts") const { endpoints } = require("@shared/contracts")
const { findSellerFromLink } = 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 { setBarcodeForUrl } = require("../services/redis/linkPreviewCache.service")
const { cacheLinkPreviewImages } = require("../services/linkPreviewImage.service")
const { mapSellerToSellerDetailsResponse } = require("../adapters/responses/sellerDetails.adapter")
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
const { getClientIp } = require("../utils/requestInfo")
const { queueDealImpressions } = require("../services/redis/dealAnalytics.service")
const { seller } = endpoints const { seller, deals } = endpoints
const buildViewer = (req) =>
req.auth ? { userId: req.auth.userId, role: req.auth.role } : null
const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery")
router.post("/from-link", requireAuth, async (req, res) => { router.post("/from-link", requireAuth, async (req, res) => {
try { try {
const sellerUrl = req.body.url const sellerUrl = req.body.url
const sellerLookup = await findSellerFromLink(sellerUrl) if (!sellerUrl) return res.status(400).json({ error: "url parametresi zorunlu" })
const [sellerLookup, initialProduct] = await Promise.all([
findSellerFromLink(sellerUrl),
getProductPreviewFromUrl(sellerUrl),
])
let product = initialProduct
if (product?.barcodeId) {
setBarcodeForUrl(sellerUrl, product.barcodeId, { ttlSeconds: 15 * 60 }).catch((err) =>
console.error("Link preview barcode cache failed:", err?.message || err)
)
}
const baseUrl = `${req.protocol}://${req.get("host")}`
if (product && baseUrl) {
const cached = await cacheLinkPreviewImages({ product, baseUrl })
product = cached.product
}
const response = seller.sellerLookupResponseSchema.parse( const response = seller.sellerLookupResponseSchema.parse(
sellerLookup sellerLookup
@ -21,8 +53,9 @@ router.post("/from-link", requireAuth, async (req, res) => {
name: sellerLookup.name, name: sellerLookup.name,
url: sellerLookup.url ?? null, url: sellerLookup.url ?? null,
}, },
product,
} }
: { found: false, seller: null } : { found: false, seller: null, product }
) )
return res.json(response) return res.json(response)
@ -32,4 +65,60 @@ 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
const sellerInfo = await getSellerByName(sellerName)
if (!sellerInfo) return res.status(404).json({ error: "Seller bulunamadi" })
res.json(mapSellerToSellerDetailsResponse(sellerInfo))
} catch (e) {
const status = e.statusCode || 500
res.status(status).json({ error: e.message || "Sunucu hatasi" })
}
})
router.get("/:sellerName/deals", optionalAuth, listQueryValidator, async (req, res) => {
try {
const sellerName = req.params.sellerName
const { q, page, limit } = req.validatedDealListQuery
const { payload } = await getDealsBySellerName(sellerName, {
page,
limit,
filters: req.query,
viewer: buildViewer(req),
})
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))
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" })
}
})
module.exports = router module.exports = router

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 { makeWebp } = require("../utils/processImage")
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 webpBuffer = await makeWebp(req.file.buffer, { quality: 40 })
const path = `images/dealDescription/${key}.webp`
const url = await uploadImage({
path,
fileBuffer: webpBuffer,
contentType: "image/webp",
})
enqueueAuditFromRequest(
req,
AUDIT_ACTIONS.MEDIA.UPLOAD,
buildAuditMeta({
entityType: "MEDIA",
entityId: path,
extra: { contentType: "image/webp" },
})
)
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 express = require("express")
const router = express.Router() const router = express.Router()
const { validate } = require("../middleware/validate.middleware") const { validate } = require("../middleware/validate.middleware")
const optionalAuth = require("../middleware/optionalAuth")
const userService = require("../services/user.service") const userService = require("../services/user.service")
const userProfileAdapter = require("../adapters/responses/userProfile.adapter") 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 { endpoints } = require("@shared/contracts")
const {
getUserProfileFromRedis,
setUserProfileInRedis,
} = require("../services/redis/userProfileCache.service")
const { users } = endpoints 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( router.get(
"/:userName", "/:userName",
@ -14,12 +29,20 @@ router.get(
async (req, res) => { async (req, res) => {
try { try {
const { userName } = req.validatedUserProfile const { userName } = req.validatedUserProfile
const cached = await getUserProfileFromRedis(userName)
if (cached) return res.json(cached)
const data = await userService.getUserProfileByUsername(userName) const data = await userService.getUserProfileByUsername(userName)
const response = users.userProfileResponseSchema.parse( const response = users.userProfileResponseSchema.parse(
userProfileAdapter.mapUserProfileToResponse(data) 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) { } catch (err) {
console.error(err) console.error(err)
const status = err.statusCode || 500 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 module.exports = router

View File

@ -1,5 +1,6 @@
const express = require("express") const express = require("express")
const requireAuth = require("../middleware/requireAuth") const requireAuth = require("../middleware/requireAuth")
const requireNotRestricted = require("../middleware/requireNotRestricted")
const { validate } = require("../middleware/validate.middleware") const { validate } = require("../middleware/validate.middleware")
const { endpoints } = require("@shared/contracts") const { endpoints } = require("@shared/contracts")
const voteService = require("../services/vote.service") const voteService = require("../services/vote.service")
@ -10,6 +11,7 @@ const { votes } = endpoints
router.post( router.post(
"/", "/",
requireAuth, requireAuth,
requireNotRestricted({ checkSuspend: true }),
validate(votes.voteRequestSchema, "body", "validatedVotePayload"), validate(votes.voteRequestSchema, "body", "validatedVotePayload"),
async (req, res) => { async (req, res) => {
try { try {

View File

@ -1,4 +1,5 @@
const express = require("express"); const express = require("express");
const path = require("path");
const cors = require("cors"); const cors = require("cors");
require("dotenv").config(); require("dotenv").config();
const cookieParser = require("cookie-parser"); const cookieParser = require("cookie-parser");
@ -13,19 +14,46 @@ const accountSettingsRoutes = require("./routes/accountSettings.routes");
const userRoutes = require("./routes/user.routes"); const userRoutes = require("./routes/user.routes");
const sellerRoutes = require("./routes/seller.routes"); const sellerRoutes = require("./routes/seller.routes");
const voteRoutes = require("./routes/vote.routes"); const voteRoutes = require("./routes/vote.routes");
const commentLikeRoutes = require("./routes/commentLike.routes");
const categoryRoutes =require("./routes/category.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 cacheRoutes = require("./routes/cache.routes")
const { ensureDealSearchIndex } = require("./services/redis/searchIndex.service")
const { seedRecentDealsToRedis, seedReferenceDataToRedis } = require("./services/redis/dealIndexing.service")
const { ensureCommentIdCounter } = require("./services/redis/commentId.service")
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(); const app = express();
// CORS middleware'ı ile dışardan gelen istekleri kontrol et app.set("trust proxy", true)
app.use(cors({
origin: "http://localhost:5173", // Frontend adresi
credentials: true, // Cookies'in paylaşıma izin verilmesi
}));
// 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(
cors({
origin(origin, cb) {
if (!origin) return cb(null, true);
if (allowedOrigins.has(origin)) return cb(null, true);
return cb(new Error("CORS_NOT_ALLOWED"));
},
credentials: true, // Cookies'in paylaşıma izin verilmesi
})
);
// JSON, URL encoded ve cookies'leri parse etme // JSON, URL encoded ve cookies'leri parse etme
app.use(express.json()); // JSON verisi almak için app.use(express.json()); // JSON verisi almak için
app.use(express.urlencoded({ extended: true })); // URL encoded veriler 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 // API route'larını tanımlama
app.use("/api/users", userRoutesneedRefactor); // User işlemleri app.use("/api/users", userRoutesneedRefactor); // User işlemleri
@ -37,6 +65,37 @@ app.use("/api/account", accountSettingsRoutes); // Account settings işlemleri
app.use("/api/user", userRoutes); // Kullanıcı işlemleri app.use("/api/user", userRoutes); // Kullanıcı işlemleri
app.use("/api/seller", sellerRoutes); // Seller işlemleri app.use("/api/seller", sellerRoutes); // Seller işlemleri
app.use("/api/vote", voteRoutes); // Vote işlemleri 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/category", categoryRoutes);
// Sunucuyu dinlemeye başla app.use("/api/mod", modRoutes);
app.use("/api/uploads", uploadRoutes);
app.use("/api/badges", badgeRoutes);
app.use("/cache", cacheRoutes);
app.get("/api/openapi.json", (req, res) => {
res.sendFile(path.join(__dirname, "docs", "openapi.json"));
});
app.get("/api/docs", (req, res) => {
res.sendFile(path.join(__dirname, "docs", "swagger.html"));
});
async function startServer() {
try {
await ensureDealSearchIndex()
await seedReferenceDataToRedis()
await ensureDealIdCounter()
const ttlDays = Number(process.env.REDIS_DEAL_TTL_DAYS) || 31
await seedRecentDealsToRedis({ days: 31, ttlDays })
await ensureCommentIdCounter()
} catch (err) {
console.error("Redis init skipped:", err?.message || err)
}
// Sunucuyu dinlemeye ba??la
app.listen(3000, () => console.log("Server running on http://localhost:3000")); 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)
})

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

@ -0,0 +1,324 @@
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")
const { sanitizeOptionalPlainText } = require("../utils/inputSanitizer")
const { normalizeMediaPath } = require("../utils/mediaPath")
function httpError(statusCode, message) {
const err = new Error(message)
err.statusCode = statusCode
return err
}
function normalizeCategoryPayload(input = {}, fallback = {}) {
const name =
input.name !== undefined
? sanitizeOptionalPlainText(input.name, { maxLength: 120 }) || ""
: sanitizeOptionalPlainText(fallback.name, { maxLength: 120 }) || ""
const rawSlug =
input.slug !== undefined
? sanitizeOptionalPlainText(input.slug, { maxLength: 160 }) || ""
: sanitizeOptionalPlainText(fallback.slug, { maxLength: 160 }) || ""
const slug = rawSlug ? slugify(rawSlug) : name ? slugify(name) : fallback.slug
const description =
input.description !== undefined
? sanitizeOptionalPlainText(input.description, { maxLength: 300 }) || ""
: sanitizeOptionalPlainText(fallback.description, { maxLength: 300 }) || ""
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
? sanitizeOptionalPlainText(input.name, { maxLength: 120 }) || ""
: sanitizeOptionalPlainText(fallback.name, { maxLength: 120 }) || ""
const url =
input.url !== undefined
? sanitizeOptionalPlainText(input.url, { maxLength: 500 }) || ""
: sanitizeOptionalPlainText(fallback.url, { maxLength: 500 }) || ""
const sellerLogo =
input.sellerLogo !== undefined
? normalizeMediaPath(sanitizeOptionalPlainText(input.sellerLogo, { maxLength: 500 }) || "") || ""
: normalizeMediaPath(sanitizeOptionalPlainText(fallback.sellerLogo, { maxLength: 500 }) || "") || ""
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: normalizeMediaPath(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 = (sanitizeOptionalPlainText(input.domain, { maxLength: 255 }) || "").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 = (sanitizeOptionalPlainText(input.domain, { maxLength: 255 }) || "").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,130 @@
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: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,
}
} catch (err) {
const openReports = await dealReportDb.countDealReports({ status: "OPEN" })
return {
pendingDeals: null,
openReports,
redis: {
usedMemory: null,
connectedClients: null,
keyspaceHits: null,
keyspaceMisses: null,
},
dbsyncQueues: {},
}
} 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,13 @@ const crypto = require("crypto")
const authDb = require("../db/auth.db") const authDb = require("../db/auth.db")
const refreshTokenDb = require("../db/refreshToken.db") const refreshTokenDb = require("../db/refreshToken.db")
const { queueAuditEvent } = require("./redis/dbSync.service")
const { AUDIT_ACTIONS } = require("./auditActions")
const { buildAuditMeta } = require("./audit.service")
const { sanitizeOptionalPlainText } = require("../utils/inputSanitizer")
const { normalizeMediaPath } = require("../utils/mediaPath")
const REUSE_GRACE_MS = Number(process.env.REFRESH_REUSE_GRACE_MS || 10000)
function httpError(statusCode, message) { function httpError(statusCode, message) {
const err = new Error(message) const err = new Error(message)
@ -12,7 +19,7 @@ function httpError(statusCode, message) {
return err return err
} }
// Access token: kısa ömür // Access token: kisa ömür
function signAccessToken(user) { function signAccessToken(user) {
const jti = crypto.randomUUID() const jti = crypto.randomUUID()
const payload = { const payload = {
@ -26,7 +33,7 @@ function signAccessToken(user) {
return { token, jti } return { token, jti }
} }
// Refresh token: opaque (JWT değil) + DBde hash // Refresh token: opaque (JWT degil) + DB'de hash
function generateRefreshToken() { function generateRefreshToken() {
// 64 byte -> url-safe base64 // 64 byte -> url-safe base64
return crypto.randomBytes(64).toString("base64url") return crypto.randomBytes(64).toString("base64url")
@ -46,17 +53,18 @@ function mapUserPublic(user) {
id: user.id, id: user.id,
username: user.username, username: user.username,
email: user.email, email: user.email,
avatarUrl: user.avatarUrl ?? null, avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null,
role: user.role, role: user.role,
} }
} }
async function login({ email, password, meta = {} }) { async function login({ email, password, meta = {} }) {
const user = await authDb.findUserByEmail(email) 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) 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) const { token: accessToken } = signAccessToken(user)
@ -74,6 +82,15 @@ async function login({ email, password, meta = {} }) {
userAgent: meta.userAgent ?? null, 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 { return {
accessToken, accessToken,
refreshToken, refreshToken,
@ -83,10 +100,15 @@ async function login({ email, password, meta = {} }) {
async function register({ username, email, password, meta = {} }) { async function register({ username, email, password, meta = {} }) {
const existingUser = await authDb.findUserByEmail(email) 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 normalizedUsername = sanitizeOptionalPlainText(username, { maxLength: 18 })
if (!normalizedUsername || normalizedUsername.length < 5) {
throw httpError(400, "Kullanici adi gecersiz.")
}
const passwordHash = await bcrypt.hash(password, 10) const passwordHash = await bcrypt.hash(password, 10)
const user = await authDb.createUser({ username, email, passwordHash }) const user = await authDb.createUser({ username: normalizedUsername, email, passwordHash })
const { token: accessToken } = signAccessToken(user) const { token: accessToken } = signAccessToken(user)
@ -104,6 +126,15 @@ async function register({ username, email, password, meta = {} }) {
userAgent: meta.userAgent ?? null, 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 { return {
accessToken, accessToken,
refreshToken, refreshToken,
@ -122,19 +153,28 @@ async function refresh({ refreshToken, meta = {} }) {
if (!existing) throw httpError(401, "Refresh token geçersiz") 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()) { if (existing.expiresAt && existing.expiresAt.getTime() < Date.now()) {
await refreshTokenDb.revokeRefreshTokenById(existing.id) 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) { 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) await refreshTokenDb.revokeRefreshTokenFamily(existing.familyId)
throw httpError(401, "Refresh token reuse tespit edildi") throw httpError(401, "Refresh token reuse tespit edildi")
} }
}
const user = existing.user const user = existing.user
if (user?.disabledAt) throw httpError(403, "Hesap devre disi.")
const { token: accessToken } = signAccessToken(user) const { token: accessToken } = signAccessToken(user)
const newRefreshToken = generateRefreshToken() const newRefreshToken = generateRefreshToken()
@ -146,13 +186,22 @@ async function refresh({ refreshToken, meta = {} }) {
newToken: { newToken: {
userId: user.id, userId: user.id,
tokenHash: newTokenHash, tokenHash: newTokenHash,
familyId: existing.familyId, // aynı aile familyId: existing.familyId, // ayni aile
jti: newJti, jti: newJti,
expiresAt: refreshExpiresAt(), expiresAt: refreshExpiresAt(),
}, },
meta: { ip: meta.ip ?? null, userAgent: meta.userAgent ?? null }, 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 { return {
accessToken, accessToken,
refreshToken: newRefreshToken, refreshToken: newRefreshToken,
@ -160,13 +209,26 @@ async function refresh({ refreshToken, meta = {} }) {
} }
} }
async function logout({ refreshToken }) { async function logout({ refreshToken, meta = {} }) {
if (!refreshToken) return if (!refreshToken) return
const tokenHash = hashToken(refreshToken) const tokenHash = hashToken(refreshToken)
// token yoksa sessiz geçmek genelde daha iyi (idempotent logout) // token yoksa sessiz geçmek genelde daha iyi (idempotent logout)
try { try {
const existing = await refreshTokenDb.findRefreshTokenByHash(tokenHash, {
select: { userId: true },
})
await refreshTokenDb.revokeRefreshTokenByHash(tokenHash) 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 (_) {} } catch (_) {}
} }
@ -174,7 +236,7 @@ async function getMe(userId) {
const user = await authDb.findUserById(Number(userId), { const user = await authDb.findUserById(Number(userId), {
select: { id: true, username: true, email: true, avatarUrl: true, role: true }, 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 return user
} }

View File

@ -1,8 +1,10 @@
const fs = require("fs") const fs = require("fs")
const { uploadImage } = require("./uploadImage.service") const { uploadImage } = require("./uploadImage.service")
const { makeWebp } = require("../utils/processImage")
const { validateImage } = require("../utils/validateImage") const { validateImage } = require("../utils/validateImage")
const userDB = require("../db/user.db") const userDB = require("../db/user.db")
const { setUserPublicInRedis } = require("./redis/userPublicCache.service")
async function updateUserAvatar(userId, file) { async function updateUserAvatar(userId, file) {
if (!file) { if (!file) {
@ -15,17 +17,19 @@ async function updateUserAvatar(userId, file) {
}) })
const buffer = fs.readFileSync(file.path) const buffer = fs.readFileSync(file.path)
const webpBuffer = await makeWebp(buffer, { quality: 80 })
const imageUrl = await uploadImage({ const imageUrl = await uploadImage({
bucket: "avatars", path: `avatars/${userId}_${Date.now()}.webp`,
path: `${userId}_${Date.now()}.jpg`, fileBuffer: webpBuffer,
fileBuffer: buffer, contentType: "image/webp",
contentType: file.mimetype,
}) })
fs.unlinkSync(file.path) fs.unlinkSync(file.path)
return updateAvatarUrl(userId, imageUrl) const updated = await updateAvatarUrl(userId, imageUrl)
await setUserPublicInRedis(updated, { ttlSeconds: 60 * 60 })
return updated
} }
@ -38,6 +42,13 @@ async function updateAvatarUrl(userId, imageUrl) {
id: true, id: true,
username: true, username: true,
avatarUrl: true, avatarUrl: true,
userBadges: {
orderBy: { earnedAt: "desc" },
select: {
earnedAt: true,
badge: { select: { id: true, name: true, iconUrl: true, description: true } },
},
},
}, },
} }
) )

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

@ -0,0 +1,91 @@
const badgeDb = require("../db/badge.db")
const userBadgeDb = require("../db/userBadge.db")
const userDb = require("../db/user.db")
const { sanitizeOptionalPlainText } = require("../utils/inputSanitizer")
const { normalizeMediaPath } = require("../utils/mediaPath")
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
return sanitizeOptionalPlainText(value, { maxLength: 500 })
}
function normalizeOptionalImagePath(value) {
if (value === undefined) return undefined
const normalized = normalizeMediaPath(value)
return normalized ?? null
}
async function listBadges() {
return badgeDb.listBadges()
}
async function createBadge({ name, iconUrl, description }) {
const normalizedName = sanitizeOptionalPlainText(name, { maxLength: 120 })
if (!normalizedName) throw new Error("Badge adı zorunlu.")
return badgeDb.createBadge({
name: normalizedName,
iconUrl: normalizeOptionalImagePath(iconUrl),
description: normalizeOptionalString(description),
})
}
async function updateBadge(badgeId, { name, iconUrl, description }) {
const id = assertPositiveInt(badgeId, "badgeId")
const data = {}
if (name !== undefined) {
const normalizedName = sanitizeOptionalPlainText(name, { maxLength: 120 })
if (!normalizedName) throw new Error("Badge adı zorunlu.")
data.name = normalizedName
}
if (iconUrl !== undefined) data.iconUrl = normalizeOptionalImagePath(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

@ -1,69 +1,164 @@
const categoryDb = require("../db/category.db"); // DB işlemleri için category.db.js'i import ediyoruz const categoryDb = require("../db/category.db")
const dealDb = require("../db/deal.db"); const dealService = require("./deal.service")
/** const { listCategoriesFromRedis, setCategoriesInRedis, setCategoryInRedis } = require("./redis/categoryCache.service")
* Kategoriyi slug'a göre bul
* Bu fonksiyon, verilen slug'a sahip kategori bilgilerini döndürür function normalizeCategory(category = {}) {
*/ const id = Number(category.id)
if (!Number.isInteger(id) || id < 0) return null
const parentIdRaw = category.parentId
const parentId =
parentIdRaw === null || parentIdRaw === undefined ? null : Number(parentIdRaw)
return {
id,
name: category.name,
slug: String(category.slug || "").trim().toLowerCase(),
parentId: Number.isInteger(parentId) ? parentId : null,
isActive: category.isActive !== undefined ? Boolean(category.isActive) : true,
description: category.description ?? "",
}
}
function buildCategoryMaps(categories = []) {
const byId = new Map()
const bySlug = new Map()
categories.forEach((item) => {
const category = normalizeCategory(item)
if (!category) return
byId.set(category.id, category)
if (category.slug) bySlug.set(category.slug, category)
})
return { byId, bySlug }
}
function getCategoryBreadcrumbFromMap(categoryId, byId, { includeUndefined = false } = {}) {
const currentId = Number(categoryId)
if (!Number.isInteger(currentId)) return []
const path = []
const visited = new Set()
let nextId = currentId
while (true) {
if (visited.has(nextId)) break
visited.add(nextId)
const category = byId.get(nextId)
if (!category) break
if (includeUndefined || category.id !== 0) {
path.push({ id: category.id, name: category.name, slug: category.slug })
}
if (category.parentId === null || category.parentId === undefined) break
nextId = Number(category.parentId)
}
return path.reverse()
}
function getCategoryDescendantIdsFromMap(categoryId, categories = []) {
const rootId = Number(categoryId)
if (!Number.isInteger(rootId) || rootId <= 0) return []
const childrenByParent = new Map()
categories.forEach((item) => {
const category = normalizeCategory(item)
if (!category || category.parentId === null) return
const parentId = Number(category.parentId)
if (!Number.isInteger(parentId)) return
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, [])
childrenByParent.get(parentId).push(category.id)
})
const seen = new Set([rootId])
const queue = [rootId]
while (queue.length) {
const current = queue.shift()
const children = childrenByParent.get(current) || []
children.forEach((childId) => {
if (seen.has(childId)) return
seen.add(childId)
queue.push(childId)
})
}
return Array.from(seen)
}
async function listCategoriesCached() {
let categories = await listCategoriesFromRedis()
if (categories.length) return categories
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
}
async function findCategoryBySlug(slug) { async function findCategoryBySlug(slug) {
try { const normalizedSlug = String(slug || "").trim()
// Kategori bilgisini slug'a göre buluyoruz if (!normalizedSlug) {
const category = await categoryDb.findCategoryBySlug(slug, { throw new Error("INVALID_SLUG")
include: { }
children: true, // Alt kategorileri de dahil edebiliriz
deals: true, // Kategorinin deals ilişkisini alıyoruz
},
});
const categories = await listCategoriesCached()
if (categories.length) {
const { byId, bySlug } = buildCategoryMaps(categories)
const cachedCategory = bySlug.get(normalizedSlug.toLowerCase())
if (cachedCategory) {
const breadcrumb = getCategoryBreadcrumbFromMap(cachedCategory.id, byId)
return { category: cachedCategory, breadcrumb }
}
}
const category = await categoryDb.findCategoryBySlug(normalizedSlug)
if (!category) { if (!category) {
throw new Error("Kategori bulunamadı"); throw new Error("CATEGORY_NOT_FOUND")
} }
// Kategori breadcrumb'ını alıyoruz const normalizedCategory = normalizeCategory(category) || category
const breadcrumb = await categoryDb.getCategoryBreadcrumb(category.id); await setCategoryInRedis(normalizedCategory)
const breadcrumb = await categoryDb.getCategoryBreadcrumb(category.id)
return { category, breadcrumb }; // Kategori ve breadcrumb'ı döndürüyoruz return { category: normalizedCategory, breadcrumb }
} catch (err) {
throw new Error(`Kategori bulma hatası: ${err.message}`);
}
} }
async function getDealsByCategoryId(categoryId, page = 1, limit = 10, filters = {}) { async function getDealsByCategoryId(categoryId, { page = 1, limit = 10, filters = {}, viewer = null, scope = "USER" } = {}) {
try { const normalizedId = Number(categoryId)
// Sayfalama ve filtreleme için gerekli ayarlamaları yapıyoruz if (!Number.isInteger(normalizedId) || normalizedId <= 0) {
const take = Math.min(Math.max(Number(limit) || 10, 1), 100); // Limit ve sayfa sayısını hesaplıyoruz throw new Error("INVALID_CATEGORY_ID")
const skip = (Math.max(Number(page) || 1, 1) - 1) * take; // Sayfa başlangıcı
// Kategorinin fırsatlarını almak için veritabanında sorgu yapıyoruz
const where = {
categoryId: categoryId,
...(filters.q && {
OR: [
{ title: { contains: filters.q, mode: 'insensitive' } },
{ description: { contains: filters.q, mode: 'insensitive' } },
],
}),
...(filters.status && { status: filters.status }),
...(filters.price && { price: { gte: filters.price } }), // Fiyat filtresi
// Diğer filtreler de buraya eklenebilir
};
// `getDealCards` fonksiyonunu çağırıyoruz ve sayfalama, filtreleme işlemlerini geçiyoruz
const deals = await dealDb.getDealCards({
where,
skip,
take, // Sayfalama işlemi için take parametresini gönderiyoruz
});
return deals; // Fırsatları döndürüyoruz
} catch (err) {
throw new Error(`Kategoriye ait fırsatlar alınırken hata: ${err.message}`);
}
} }
let categoryIds = []
const categories = await listCategoriesCached()
if (categories.length) {
categoryIds = getCategoryDescendantIdsFromMap(normalizedId, categories)
}
if (!categoryIds.length) {
categoryIds = await categoryDb.getCategoryDescendantIds(normalizedId)
}
return dealService.getDeals({
preset: "NEW",
q: filters?.q,
page,
limit,
viewer,
scope,
baseWhere: { categoryId: { in: categoryIds } },
filters,
useRedisSearch: true,
})
}
module.exports = { module.exports = {
findCategoryBySlug, findCategoryBySlug,
getDealsByCategoryId, getDealsByCategoryId,
}; }

View File

@ -1,91 +1,229 @@
const dealDB = require("../db/deal.db") const userDB = require("../db/user.db")
const commentDB = require("../db/comment.db") const commentDB = require("../db/comment.db")
const prisma = require("../db/client") const {
addCommentToRedis,
removeCommentFromRedis,
getCommentsForDeal,
} = require("./redis/commentCache.service")
const { getOrCacheDeal, getDealIdByCommentId } = require("./redis/dealCache.service")
const { generateCommentId } = require("./redis/commentId.service")
const {
queueCommentCreate,
queueCommentDelete,
queueNotificationCreate,
} = require("./redis/dbSync.service")
const { publishNotification } = require("./redis/notificationPubsub.service")
const { trackUserCategoryInterest, USER_INTEREST_ACTIONS } = require("./userInterest.service")
const { sanitizeOptionalPlainText } = require("../utils/inputSanitizer")
function assertPositiveInt(v, name = "id") { function parseParentId(value) {
const n = Number(v) if (value === undefined || value === null || value === "" || value === "null") return null
if (!Number.isInteger(n) || n <= 0) throw new Error(`Geçersiz ${name}.`) const pid = Number(value)
return n if (!Number.isInteger(pid) || pid <= 0) throw new Error("Gecersiz parentId.")
return pid
} }
async function getCommentsByDealId(dealId) { function normalizeSort(value) {
const normalized = String(value || "new").trim().toLowerCase()
if (["top", "best", "liked"].includes(normalized)) return "TOP"
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 id = Number(dealId)
const deal = await ensureDealCached(id)
if (!deal) throw new Error("Deal bulunamadi.")
const deal = await dealDB.findDeal({ id }) return getCommentsForDeal({
if (!deal) throw new Error("Deal bulunamadı.") dealId: id,
deal,
const include = { user: { select: { id:true,username: true, avatarUrl: true } } } parentId: parseParentId(parentId),
return commentDB.findComments({ dealId: id }, { include }) page,
} limit,
sort: normalizeSort(sort),
async function createComment({ dealId, userId, text, parentId = null }) { viewerId: viewer?.userId ?? null,
if (!text || typeof text !== "string" || !text.trim()) {
throw new Error("Yorum boş 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 }, {}, tx)
if (!deal) throw new Error("Deal bulunamadı.")
// ✅ 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 comment = await commentDB.createComment(
{
text: trimmed,
userId,
dealId,
parentId: parent ? parent.id : null,
},
{ include },
tx
)
await dealDB.updateDeal(
{ id: dealId },
{ commentCount: { increment: 1 } },
{},
tx
)
return comment
}) })
} }
async function createComment({ dealId, userId, text, parentId = null }) {
const normalizedText = sanitizeOptionalPlainText(text, { maxLength: 2000 })
if (!normalizedText) {
throw new Error("Yorum bos olamaz.")
}
const deal = await ensureDealCached(dealId)
if (!deal) throw new Error("Deal bulunamadi.")
if (deal.status !== "ACTIVE" && deal.status !== "EXPIRED") {
throw new Error("Bu deal icin yorum acilamaz.")
}
let parent = null
if (parentId != null) {
const pid = parseParentId(parentId)
const comments = Array.isArray(deal.comments) ? deal.comments : []
const cachedParent = comments.find((c) => Number(c.id) === Number(pid))
if (!cachedParent || cachedParent.deletedAt) throw new Error("Yanıtlanan yorum bulunamadi.")
if (Number(cachedParent.dealId) !== Number(dealId)) {
throw new Error("Yanıtlanan yorum bu deal'a ait degil.")
}
parent = {
id: cachedParent.id,
dealId: cachedParent.dealId,
userId: Number(cachedParent.userId) || null,
}
}
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: normalizedText,
userId,
dealId,
parentId: parent ? parent.id : null,
createdAt,
likeCount: 0,
repliesCount: 0,
user,
}
await addCommentToRedis({
...comment,
repliesCount: 0,
})
queueCommentCreate({
commentId,
dealId,
userId,
text: normalizedText,
parentId: parent ? parent.id : null,
createdAt: createdAt.toISOString(),
}).catch((err) => console.error("DB sync comment create failed:", err?.message || err))
trackUserCategoryInterest({
userId,
categoryId: deal.categoryId,
action: USER_INTEREST_ACTIONS.COMMENT_CREATE,
}).catch((err) => console.error("User interest track failed:", err?.message || err))
const parentUserId = Number(parent?.userId)
if (
parent &&
Number.isInteger(parentUserId) &&
parentUserId > 0 &&
parentUserId !== Number(userId)
) {
const notificationPayload = {
userId: parentUserId,
message: "Yorumuna cevap geldi.",
type: "COMMENT_REPLY",
extras: {
dealId: Number(dealId),
commentId: Number(commentId),
parentCommentId: Number(parent.id),
},
createdAt: createdAt.toISOString(),
}
queueNotificationCreate(notificationPayload).catch((err) =>
console.error("DB sync comment reply notification failed:", err?.message || err)
)
publishNotification(notificationPayload).catch((err) =>
console.error("Comment reply notification publish failed:", err?.message || err)
)
}
return comment
}
async function deleteComment(commentId, userId) { async function deleteComment(commentId, userId) {
const cid = Number(commentId)
if (!Number.isInteger(cid) || cid <= 0) throw new Error("Gecersiz commentId.")
const comments = await commentDB.findComments( let dealId = await getDealIdByCommentId(cid)
{ id: commentId }, let dbFallback = null
{ select: { userId: true } } if (!dealId) {
dbFallback = await commentDB.findComment(
{ id: cid },
{ select: { id: true, dealId: true, userId: true, parentId: true, deletedAt: true } }
) )
if (!dbFallback || dbFallback.deletedAt) throw new Error("Yorum bulunamadi.")
dealId = dbFallback.dealId
}
if (!comments || comments.length === 0) throw new Error("Yorum bulunamadı.") const deal = await ensureDealCached(dealId)
if (comments[0].userId !== userId) throw new Error("Bu yorumu silme yetkin yok.") 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,
})
await commentDB.deleteComment({ id: commentId })
return { message: "Yorum silindi." } 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 = { module.exports = {
getCommentsByDealId, getCommentsByDealId,
createComment, createComment,
deleteComment, deleteComment,
deleteCommentAsMod,
} }

View File

@ -0,0 +1,61 @@
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
if (typeof value === "number") return value === 1
if (typeof value === "string") {
const normalized = value.trim().toLowerCase()
if (["true", "1", "yes"].includes(normalized)) return true
if (["false", "0", "no"].includes(normalized)) return false
}
return null
}
async function setCommentLike({ commentId, userId, like }) {
const cid = Number(commentId)
if (!Number.isInteger(cid) || cid <= 0) throw new Error("Gecersiz commentId.")
const shouldLike = parseLike(like)
if (shouldLike === null) throw new Error("Gecersiz like.")
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
}
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 = {
setCommentLike,
}

File diff suppressed because it is too large Load Diff

View File

@ -3,42 +3,47 @@ const OpenAI = require("openai")
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const SYSTEM_PROMPT = ` 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. ### 2. CLASSIFICATION & TAGGING
- Do NOT output literal product words. - CATEGORY: Choose exactly ONE category_id that best fits the product.
- 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 [].
Forbidden: ### TAGGING STRATEGY (BRAND, MODEL & LIFESTYLE):
- store/company/seller names - Goal: Create a precise user interest profile using 3 distinct tags.
- promotion/marketing words - Use NATURAL capitalization and spaces.
- generic category words
Max 5 tags total, lowercase. 1. **Brand (Who?):** The manufacturer (e.g., "Apple", "HIQ Nutrition", "Lego").
Review / safety: 2. **Model (What?):** Specific series/model, MAX 2-3 words (e.g., "Creatine Monohydrate", "iPhone 15 Pro", "Star Wars").
- Set needs_review=true if you are not confident about the chosen category OR if the deal text looks problematic. 3. **Lifestyle/Interest (Vibe?):** The root interest that connects different categories (e.g., "spor", "teknoloji", "oyun", "hobi", "moda", "luks", "ev-yasam").
- 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).
Output JSON only: ### RULES:
{ - MAX 3 tags.
"best_category_id": number, - DO NOT include technical specs like "600g", "128GB", "siyah", "44mm".
"needs_review": boolean, - DO NOT use the exact category name (e.g., if category is "Sporcu Besini", don't use "sporcu-besini", use "spor").
"tags": string[], - If no brand/model found, provide only the Lifestyle tag.
"has_issue": boolean,
"issue_type": "NONE" | "PROFANITY" | "PHONE_NUMBER" | "PERSONAL_DATA" | "SPAM" | "OTHER", ### EXAMPLE OUTPUTS:
"issue_reason": string | null - "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 = 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` "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"
const CATEGORY_ENUM = [...Array(191).keys()] // 0..31
function s(x) { function s(x) {
return x == null ? "" : String(x) return x == null ? "" : String(x)
@ -91,7 +96,7 @@ async function classifyDeal({ title, description, url, seller }) {
"issue_reason", "issue_reason",
], ],
properties: { properties: {
best_category_id: { type: "integer", enum: CATEGORY_ENUM }, best_category_id: { type: "integer" },
needs_review: { type: "boolean" }, needs_review: { type: "boolean" },
tags: { type: "array", items: { type: "string" }, maxItems: 5 }, tags: { type: "array", items: { type: "string" }, maxItems: 5 },
has_issue: { type: "boolean" }, has_issue: { type: "boolean" },

View File

@ -0,0 +1,144 @@
const dealDB = require("../db/deal.db")
const dealReportDB = require("../db/dealReport.db")
const { queueDealReportStatusUpdate } = require("./redis/dbSync.service")
const { sanitizeOptionalPlainText } = require("../utils/inputSanitizer")
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: sanitizeOptionalPlainText(note, { maxLength: 500 }),
})
return { reported: true }
}
async function listDealReports({ status = null, dealId = null, userId = null } = {}) {
const skip = 0
const where = {}
const normalizedStatus = normalizeStatus(status)
where.status = normalizedStatus || "OPEN"
if (Number.isInteger(Number(dealId))) where.dealId = Number(dealId)
if (Number.isInteger(Number(userId))) where.userId = Number(userId)
const [total, reports] = await Promise.all([
dealReportDB.countDealReports(where),
dealReportDB.listDealReports(where, {
skip,
orderBy: { createdAt: "asc" },
include: {
deal: { select: { id: true, title: true, status: true } },
user: { select: { id: true, username: true } },
},
}),
])
return {
total,
results: reports,
}
}
async function getPendingReports({ page = 1 } = {}) {
const safePage = normalizePage(page)
const skip = (safePage - 1) * PAGE_LIMIT
const where = { status: "OPEN" }
const [total, reports] = await Promise.all([
dealReportDB.countDealReports(where),
dealReportDB.listDealReports(where, {
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,
getPendingReports,
updateDealReportStatus,
}

View File

@ -0,0 +1,213 @@
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 { trackUserCategoryInterest, USER_INTEREST_ACTIONS } = require("./userInterest.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 } } } },
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))
trackUserCategoryInterest({
userId: uid,
categoryId: deal.categoryId,
action: USER_INTEREST_ACTIONS.DEAL_SAVE,
}).catch((err) => console.error("User interest track 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 }
)
const fallbackMap = new Map()
missingDeals.forEach((deal) => {
const payload = mapDealToRedisJson(deal)
const myVote = 0
fallbackMap.set(Number(deal.id), {
...payload,
user: deal.user ?? null,
seller: deal.seller ?? null,
myVote,
isSaved: true,
})
})
await Promise.all(
missingDeals.map((deal) => {
const payload = mapDealToRedisJson(deal)
return setDealInRedis(deal.id, payload, { ttlSeconds: 15 * 60 })
})
)
const hydrated = await getDealsByIdsFromRedis(missingIds, uid)
hydrated.forEach((d) => cachedMap.set(Number(d.id), d))
if (!hydrated.length && fallbackMap.size) {
fallbackMap.forEach((value, key) => cachedMap.set(key, value))
}
}
const results = pageIds.map((id) => cachedMap.get(id)).filter(Boolean)
return {
page: safePage,
total: mergedIds.length,
totalPages: mergedIds.length ? Math.ceil(mergedIds.length / PAGE_LIMIT) : 0,
results,
}
}
module.exports = {
saveDealForUser,
removeSavedDealForUser,
listSavedDeals,
}

View File

@ -0,0 +1,63 @@
const { cacheImageFromUrl } = require("./redis/linkPreviewImageCache.service")
function extractImageUrlsFromDescription(description, { max = 5 } = {}) {
if (!description || typeof description !== "string") return []
const regex = /<img[^>]+src\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))[^>]*>/gi
const urls = []
let match
while ((match = regex.exec(description)) !== null) {
const src = match[1] || match[2] || match[3] || ""
if (src) urls.push(src)
if (urls.length >= max) break
}
return urls
}
function replaceDescriptionImageUrls(description, urlMap, { maxImages = 5 } = {}) {
if (!description || typeof description !== "string") return description
let seen = 0
const safeMap = urlMap instanceof Map ? urlMap : new Map()
return description.replace(
/<img[^>]+src\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))[^>]*>/gi,
(full, srcDq, srcSq, srcUnq) => {
seen += 1
if (seen > maxImages) return ""
const src = srcDq || srcSq || srcUnq || ""
const next = safeMap.get(src)
if (!next) return full
return full.replace(src, next)
}
)
}
async function cacheLinkPreviewImages({ product, baseUrl } = {}) {
if (!product || typeof product !== "object") return { product }
const images = Array.isArray(product.images) ? product.images : []
const description = product.description || ""
const descImages = extractImageUrlsFromDescription(description, { max: 5 })
const combined = [...images, ...descImages].filter(Boolean)
const unique = Array.from(new Set(combined))
const urlMap = new Map()
for (const url of unique) {
const cached = await cacheImageFromUrl(url, { ttlSeconds: 5 * 60 })
if (cached?.key) {
urlMap.set(url, `${baseUrl}/cache/deal_create/${cached.key}`)
}
}
const nextImages = images.map((url) => urlMap.get(url) || url)
const nextDescription = replaceDescriptionImageUrls(description, urlMap, { maxImages: 5 })
return {
product: {
...product,
images: nextImages,
description: nextDescription,
},
}
}
module.exports = {
cacheLinkPreviewImages,
}

364
services/mod.service.js Normal file
View File

@ -0,0 +1,364 @@
const dealService = require("./deal.service")
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")
const { toSafeRedirectUrl } = require("../utils/urlSafety")
const {
sanitizeDealDescriptionHtml,
sanitizeOptionalPlainText,
sanitizeRequiredPlainText,
} = require("../utils/inputSanitizer")
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({
preset: "RAW",
q: filters?.q,
page,
limit,
viewer,
scope: "MOD",
baseWhere: { status: "PENDING" },
filters,
useRedisSearch: true,
})
}
async function updateDealStatus(dealId, nextStatus) {
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
}
if (deal.status === nextStatus) return { id: deal.id, status: deal.status }
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 { id: deal.id, status: nextStatus }
}
async function approveDeal(dealId) {
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",
extras: {
dealId: Number(id),
},
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) {
return updateDealStatus(dealId, "REJECTED")
}
async function expireDeal(dealId) {
return updateDealStatus(dealId, "EXPIRED")
}
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 = sanitizeRequiredPlainText(input.title, { fieldName: "TITLE", maxLength: 300 })
}
if (input.description !== undefined) {
data.description = sanitizeDealDescriptionHtml(input.description)
}
if (input.url !== undefined) {
if (input.url === null) {
data.url = null
} else {
const safeUrl = toSafeRedirectUrl(input.url)
if (!safeUrl) {
const err = new Error("INVALID_URL")
err.statusCode = 400
throw err
}
data.url = safeUrl
}
}
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 = sanitizeOptionalPlainText(input.couponCode, { maxLength: 120 })
}
if (input.location !== undefined) {
data.location = sanitizeOptionalPlainText(input.location, { maxLength: 150 })
}
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) {
data.customSeller = sanitizeOptionalPlainText(input.customSeller, { maxLength: 120 })
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))
let normalized = updated || existing
if (!normalized?.user) {
const refreshed = await getOrCacheDealForModeration(id)
if (refreshed?.deal) normalized = refreshed.deal
}
const enriched = await enrichDealSeller(normalized)
return normalizeDealForModResponse(enriched)
}
async function listAllCategories() {
return listCategories({
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,151 @@
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")
const { sanitizeOptionalPlainText } = require("../utils/inputSanitizer")
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 = sanitizeOptionalPlainText(note, { maxLength: 1000 })
if (!text) {
const err = new Error("NOTE_REQUIRED")
err.statusCode = 400
throw err
}
queueUserNoteCreate({
userId: uid,
createdById: cid,
note: text,
createdAt: new Date().toISOString(),
}).catch((err) => console.error("DB sync user note failed:", err?.message || err))
return { userId: uid, createdById: cid, note: text }
}
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,327 @@
const { getRedisClient } = require("./redis/client")
const userInterestProfileDb = require("../db/userInterestProfile.db")
const { getCategoryDealIndexKey } = require("./redis/categoryDealIndex.service")
const { getDealsByIdsFromRedis } = require("./redis/hotDealList.service")
const { getNewDealIds } = require("./redis/newDealList.service")
const FEED_KEY_PREFIX = "deals:lists:personalized:user:"
const FEED_TTL_SECONDS = Math.max(60, Number(process.env.PERSONAL_FEED_TTL_SECONDS) || 2 * 60 * 60)
const FEED_REBUILD_THRESHOLD_SECONDS = Math.max(
60,
Number(process.env.PERSONAL_FEED_REBUILD_THRESHOLD_SECONDS) || 60 * 60
)
const FEED_CANDIDATE_LIMIT = Math.max(20, Number(process.env.PERSONAL_FEED_CANDIDATE_LIMIT) || 120)
const FEED_MAX_CATEGORIES = Math.max(1, Number(process.env.PERSONAL_FEED_MAX_CATEGORIES) || 8)
const FEED_PER_CATEGORY_LIMIT = Math.max(5, Number(process.env.PERSONAL_FEED_PER_CATEGORY_LIMIT) || 40)
const FEED_LOOKBACK_DAYS = Math.max(1, Number(process.env.PERSONAL_FEED_LOOKBACK_DAYS) || 30)
const FEED_NOISE_MAX = Math.max(0, Number(process.env.PERSONAL_FEED_NOISE_MAX) || 50)
const FEED_PAGE_LIMIT = 20
function normalizePositiveInt(value) {
const num = Number(value)
if (!Number.isInteger(num) || num <= 0) return null
return num
}
function normalizePagination({ page, limit }) {
const rawPage = Number(page)
const rawLimit = Number(limit)
const safePage = Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1
const safeLimit =
Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 50) : 20
return { page: safePage, limit: safeLimit, skip: (safePage - 1) * safeLimit }
}
function getLatestKey(userId) {
return `${FEED_KEY_PREFIX}${userId}:latest`
}
function getFeedKey(userId, feedId) {
return `${FEED_KEY_PREFIX}${userId}:${feedId}`
}
function getFeedKeyMatchPattern(userId) {
return `${FEED_KEY_PREFIX}${userId}:*`
}
function parseCategoryScores(rawScores) {
if (!rawScores || typeof rawScores !== "object" || Array.isArray(rawScores)) return []
const entries = []
for (const [categoryIdRaw, scoreRaw] of Object.entries(rawScores)) {
const categoryId = normalizePositiveInt(categoryIdRaw)
const score = Number(scoreRaw)
if (!categoryId || !Number.isFinite(score) || score <= 0) continue
entries.push({ categoryId, score })
}
return entries.sort((a, b) => b.score - a.score)
}
function buildFallbackFeedIds(dealIds = []) {
return Array.from(
new Set(
(Array.isArray(dealIds) ? dealIds : [])
.map((id) => Number(id))
.filter((id) => Number.isInteger(id) && id > 0)
)
).slice(0, FEED_CANDIDATE_LIMIT)
}
function computePersonalScore({ categoryScore, dealScore }) {
const safeCategory = Math.max(0, Number(categoryScore) || 0)
const safeDealScore = Math.max(1, Number(dealScore) || 0)
const noise = FEED_NOISE_MAX > 0 ? Math.floor(Math.random() * (FEED_NOISE_MAX + 1)) : 0
return safeCategory * safeDealScore + noise
}
async function getFeedFromRedis(redis, userId) {
const latestId = await redis.get(getLatestKey(userId))
if (!latestId) return null
const key = getFeedKey(userId, latestId)
const raw = await redis.call("JSON.GET", key)
const ttl = Number(await redis.ttl(key))
if (!raw || ttl <= 0) return null
try {
const parsed = JSON.parse(raw)
return {
id: String(parsed.id || latestId),
dealIds: buildFallbackFeedIds(parsed.dealIds || []),
ttl,
}
} catch {
return null
}
}
async function listUserFeedKeys(redis, userId) {
const pattern = getFeedKeyMatchPattern(userId)
const keys = []
let cursor = "0"
do {
const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100)
cursor = String(nextCursor)
if (Array.isArray(batch) && batch.length) {
batch.forEach((key) => {
if (String(key).endsWith(":latest")) return
keys.push(String(key))
})
}
} while (cursor !== "0")
return Array.from(new Set(keys))
}
function extractFeedIdFromKey(userId, key) {
const prefix = `${FEED_KEY_PREFIX}${userId}:`
if (!String(key).startsWith(prefix)) return null
const feedId = String(key).slice(prefix.length)
return feedId || null
}
async function getBestFeedFromRedis(redis, userId) {
const keys = await listUserFeedKeys(redis, userId)
if (!keys.length) return null
const pipeline = redis.pipeline()
keys.forEach((key) => pipeline.ttl(key))
keys.forEach((key) => pipeline.call("JSON.GET", key))
const results = await pipeline.exec()
if (!Array.isArray(results) || !results.length) return null
const ttlResults = results.slice(0, keys.length)
const jsonResults = results.slice(keys.length)
let best = null
keys.forEach((key, idx) => {
try {
const ttl = Number(ttlResults[idx]?.[1] ?? -1)
if (!Number.isFinite(ttl) || ttl <= 0) return
const raw = jsonResults[idx]?.[1]
if (!raw) return
const parsed = JSON.parse(raw)
const dealIds = buildFallbackFeedIds(parsed?.dealIds || [])
const feedId = extractFeedIdFromKey(userId, key) || String(parsed?.id || "")
if (!feedId) return
if (!best || ttl > best.ttl) {
best = {
id: feedId,
dealIds,
ttl,
}
}
} catch {}
})
return best
}
async function setLatestPointer(redis, userId, feedId, ttlSeconds) {
const ttl = Math.max(1, Number(ttlSeconds) || FEED_TTL_SECONDS)
await redis.set(getLatestKey(userId), String(feedId), "EX", ttl)
}
async function collectCandidateIdsFromIndexes(redis, topCategories = []) {
if (!topCategories.length) return new Map()
const cutoffTs = Date.now() - FEED_LOOKBACK_DAYS * 24 * 60 * 60 * 1000
const pipeline = redis.pipeline()
const refs = []
topCategories.forEach((entry) => {
const key = getCategoryDealIndexKey(entry.categoryId)
if (!key) return
pipeline.zrevrangebyscore(key, "+inf", String(cutoffTs), "LIMIT", 0, FEED_PER_CATEGORY_LIMIT)
refs.push(entry)
})
if (!refs.length) return new Map()
const results = await pipeline.exec()
const categoryByDealId = new Map()
results.forEach((result, idx) => {
const [, rawIds] = result || []
const categoryEntry = refs[idx]
const ids = Array.isArray(rawIds) ? rawIds : []
ids.forEach((id) => {
const dealId = Number(id)
if (!Number.isInteger(dealId) || dealId <= 0) return
if (!categoryByDealId.has(dealId)) {
categoryByDealId.set(dealId, categoryEntry)
}
})
})
return categoryByDealId
}
async function buildPersonalizedFeedForUser(redis, userId) {
const profile = await userInterestProfileDb.getUserInterestProfile(userId)
const categories = parseCategoryScores(profile?.categoryScores).slice(0, FEED_MAX_CATEGORIES)
if (!categories.length) {
const fallback = await getNewDealIds({})
return {
id: String(Date.now()),
dealIds: buildFallbackFeedIds(fallback?.dealIds || []),
}
}
const categoryByDealId = await collectCandidateIdsFromIndexes(redis, categories)
const candidateIds = Array.from(categoryByDealId.keys()).slice(0, FEED_CANDIDATE_LIMIT * 3)
if (!candidateIds.length) {
const fallback = await getNewDealIds({})
return {
id: String(Date.now()),
dealIds: buildFallbackFeedIds(fallback?.dealIds || []),
}
}
const deals = await getDealsByIdsFromRedis(candidateIds, userId)
const scored = deals
.filter((deal) => String(deal?.status || "").toUpperCase() === "ACTIVE")
.map((deal) => {
const entry = categoryByDealId.get(Number(deal.id))
const categoryScore = Number(entry?.score || 0)
return {
id: Number(deal.id),
score: computePersonalScore({
categoryScore,
dealScore: Number(deal.score || 0),
}),
}
})
.filter((item) => Number.isInteger(item.id) && item.id > 0)
scored.sort((a, b) => b.score - a.score)
const rankedIds = Array.from(new Set(scored.map((item) => item.id))).slice(0, FEED_CANDIDATE_LIMIT)
if (!rankedIds.length) {
const fallback = await getNewDealIds({})
return {
id: String(Date.now()),
dealIds: buildFallbackFeedIds(fallback?.dealIds || []),
}
}
return {
id: String(Date.now()),
dealIds: rankedIds,
}
}
async function cacheFeed(redis, userId, feed) {
const feedId = String(feed?.id || Date.now())
const dealIds = buildFallbackFeedIds(feed?.dealIds || [])
const key = getFeedKey(userId, feedId)
const payload = {
id: feedId,
createdAt: new Date().toISOString(),
total: dealIds.length,
dealIds,
}
await redis.call("JSON.SET", key, "$", JSON.stringify(payload))
await redis.expire(key, FEED_TTL_SECONDS)
await setLatestPointer(redis, userId, feedId, FEED_TTL_SECONDS)
return { id: feedId, dealIds, ttl: FEED_TTL_SECONDS }
}
async function getOrBuildFeedIds(userId) {
const uid = normalizePositiveInt(userId)
if (!uid) return { id: null, dealIds: [] }
const redis = getRedisClient()
try {
const best = (await getBestFeedFromRedis(redis, uid)) || (await getFeedFromRedis(redis, uid))
if (best && best.ttl >= FEED_REBUILD_THRESHOLD_SECONDS) {
await setLatestPointer(redis, uid, best.id, best.ttl)
return best
}
if (best && best.ttl > 0) {
// Keep current feed as fallback while creating a fresh one.
const built = await buildPersonalizedFeedForUser(redis, uid)
const cached = await cacheFeed(redis, uid, built)
return cached?.dealIds?.length ? cached : best
}
} catch {}
try {
const built = await buildPersonalizedFeedForUser(redis, uid)
return cacheFeed(redis, uid, built)
} catch {
const fallback = await getNewDealIds({})
const dealIds = buildFallbackFeedIds(fallback?.dealIds || [])
const payload = { id: String(Date.now()), dealIds, ttl: 0 }
try {
return cacheFeed(redis, uid, payload)
} catch {
return payload
}
}
}
async function getPersonalizedDeals({
userId,
page = 1,
} = {}) {
const uid = normalizePositiveInt(userId)
if (!uid) return { page: 1, total: 0, totalPages: 0, results: [], personalizedListId: null }
const pagination = normalizePagination({ page, limit: FEED_PAGE_LIMIT })
const feed = await getOrBuildFeedIds(uid)
const ids = Array.isArray(feed?.dealIds) ? feed.dealIds : []
const pageIds = ids.slice(pagination.skip, pagination.skip + pagination.limit)
const deals = await getDealsByIdsFromRedis(pageIds, uid)
return {
page: pagination.page,
total: ids.length,
totalPages: ids.length ? Math.ceil(ids.length / pagination.limit) : 0,
results: deals,
personalizedListId: feed?.id || null,
}
}
module.exports = {
getPersonalizedDeals,
}

View File

@ -0,0 +1,41 @@
const axios = require("axios")
const { requestProductPreviewOverRedis } = require("./redis/scraperRpc.service")
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 transport = String(process.env.SCRAPER_TRANSPORT || "http")
.trim()
.toLowerCase()
if (transport === "redis") {
return requestProductPreviewOverRedis(url, { timeoutMs: 20000 })
}
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 }

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