Compare commits
No commits in common. "5ac0a9e47960d20e4de621cbfaf697bb95a30601" and "e0f3f5d3067be11902ac23e55e8d337879501f1b" have entirely different histories.
5ac0a9e479
...
e0f3f5d306
|
|
@ -1,53 +1,14 @@
|
|||
const { toSafeRedirectUrl } = require("../../utils/urlSafety")
|
||||
const {
|
||||
sanitizeDealDescriptionHtml,
|
||||
sanitizeOptionalPlainText,
|
||||
sanitizeRequiredPlainText,
|
||||
} = require("../../utils/inputSanitizer")
|
||||
|
||||
function mapCreateDealRequestToDealCreateData(payload, userId) {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
price,
|
||||
originalPrice,
|
||||
sellerName,
|
||||
customSeller,
|
||||
couponCode,
|
||||
location,
|
||||
discountType,
|
||||
discountValue,
|
||||
} = payload
|
||||
const { title, description, url, price, sellerName } = 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,
|
||||
title,
|
||||
description: description ?? null,
|
||||
url: url ?? 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.
|
||||
customSeller: normalizedSellerName,
|
||||
customSeller: sellerName ?? null,
|
||||
|
||||
user: {
|
||||
connect: { id: userId },
|
||||
|
|
|
|||
|
|
@ -1,25 +1,16 @@
|
|||
const formatDateAsString = (value) =>
|
||||
value instanceof Date ? value.toISOString() : value ?? null
|
||||
const { normalizeMediaPath } = require("../../utils/mediaPath")
|
||||
|
||||
function mapCommentToDealCommentResponse(comment) {
|
||||
return {
|
||||
id: comment.id,
|
||||
text: comment.text, // eğer DB'de content ise burada text'e çevir
|
||||
createdAt: formatDateAsString(comment.createdAt),
|
||||
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),
|
||||
parentId:comment.parentId,
|
||||
user: {
|
||||
id: comment.user.id,
|
||||
username: comment.user.username,
|
||||
avatarUrl: normalizeMediaPath(comment.user.avatarUrl) ?? null,
|
||||
avatarUrl: comment.user.avatarUrl ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
const formatDateAsString = (value) => (value instanceof Date ? value.toISOString() : value ?? null)
|
||||
const { normalizeMediaPath } = require("../../utils/mediaPath")
|
||||
|
||||
function mapDealToDealCardResponse(deal) {
|
||||
return {
|
||||
|
|
@ -7,23 +6,15 @@ function mapDealToDealCardResponse(deal) {
|
|||
title: deal.title,
|
||||
description: deal.description || "",
|
||||
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,
|
||||
commentsCount: deal.commentCount,
|
||||
hasLink: Boolean(deal.url),
|
||||
url:deal.url,
|
||||
status: deal.status,
|
||||
saleType: deal.saletype,
|
||||
affiliateType: deal.affiliateType,
|
||||
|
||||
myVote: deal.myVote ?? 0,
|
||||
isSaved: Boolean(deal.isSaved),
|
||||
|
||||
createdAt: formatDateAsString(deal.createdAt),
|
||||
updatedAt: formatDateAsString(deal.updatedAt),
|
||||
|
|
@ -31,7 +22,7 @@ function mapDealToDealCardResponse(deal) {
|
|||
user: {
|
||||
id: deal.user.id,
|
||||
username: deal.user.username,
|
||||
avatarUrl: normalizeMediaPath(deal.user.avatarUrl) ?? null,
|
||||
avatarUrl: deal.user.avatarUrl ?? null,
|
||||
},
|
||||
|
||||
seller: deal.seller
|
||||
|
|
@ -44,7 +35,7 @@ function mapDealToDealCardResponse(deal) {
|
|||
url: null,
|
||||
},
|
||||
|
||||
imageUrl: normalizeMediaPath(deal.images?.[0]?.imageUrl) || "",
|
||||
imageUrl: deal.images?.[0]?.imageUrl || "",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// adapters/responses/dealDetail.adapter.js
|
||||
const {mapBreadcrumbToResponse} =require( "./breadCrumb.adapter")
|
||||
const { normalizeMediaPath } = require("../../utils/mediaPath")
|
||||
|
||||
const formatDateAsString = (value) =>
|
||||
value instanceof Date ? value.toISOString() : value ?? null
|
||||
|
|
@ -36,7 +35,7 @@ function mapSimilarDealItem(d) {
|
|||
title: d.title,
|
||||
price: d.price ?? null,
|
||||
score: Number.isFinite(d.score) ? d.score : 0,
|
||||
imageUrl: normalizeMediaPath(d.imageUrl) || "",
|
||||
imageUrl: d.imageUrl || "",
|
||||
sellerName: d.sellerName || "Bilinmiyor",
|
||||
createdAt: formatDateAsString(d.createdAt), // SimilarDealSchema: nullable OK
|
||||
// url: d.url ?? null,
|
||||
|
|
@ -54,18 +53,9 @@ function mapDealToDealDetailResponse(deal) {
|
|||
id: deal.id,
|
||||
title: deal.title,
|
||||
description: deal.description || "",
|
||||
hasLink: Boolean(deal.url),
|
||||
url: deal.url ?? 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,
|
||||
myVote: deal.myVote ?? 0,
|
||||
isSaved: Boolean(deal.isSaved),
|
||||
|
||||
commentsCount: deal._count?.comments ?? 0,
|
||||
|
||||
|
|
@ -79,11 +69,7 @@ function mapDealToDealDetailResponse(deal) {
|
|||
user: {
|
||||
id: deal.user.id,
|
||||
username: deal.user.username,
|
||||
avatarUrl: normalizeMediaPath(deal.user.avatarUrl) ?? null,
|
||||
},
|
||||
userStats: {
|
||||
totalLikes: deal.userStats?.totalLikes ?? 0,
|
||||
totalDeals: deal.userStats?.totalDeals ?? 0,
|
||||
avatarUrl: deal.user.avatarUrl ?? null,
|
||||
},
|
||||
|
||||
// ✅ FIX: SellerSummarySchema genelde id ister -> custom seller için -1
|
||||
|
|
@ -101,7 +87,7 @@ function mapDealToDealDetailResponse(deal) {
|
|||
|
||||
images: (deal.images || []).map((img) => ({
|
||||
id: img.id,
|
||||
imageUrl: normalizeMediaPath(img.imageUrl) || "",
|
||||
imageUrl: img.imageUrl,
|
||||
order: img.order,
|
||||
})),
|
||||
|
||||
|
|
@ -109,23 +95,14 @@ function mapDealToDealDetailResponse(deal) {
|
|||
if (!comment.user)
|
||||
throw new Error("comment.user is missing (include comments.user in query)")
|
||||
|
||||
return {
|
||||
id: comment.id,
|
||||
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"),
|
||||
user: {
|
||||
id: comment.user.id,
|
||||
return {
|
||||
id: comment.id,
|
||||
text: comment.text,
|
||||
createdAt: requiredIsoString(comment.createdAt, "comment.createdAt"),
|
||||
user: {
|
||||
id: comment.user.id,
|
||||
username: comment.user.username,
|
||||
avatarUrl: normalizeMediaPath(comment.user.avatarUrl) ?? null,
|
||||
avatarUrl: comment.user.avatarUrl ?? null,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ function mapLoginRequestToLoginInput(input) {
|
|||
|
||||
function mapLoginResultToResponse(result) {
|
||||
return {
|
||||
token: result.accessToken, // <-- KRİTİK
|
||||
user: result.user,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
const { normalizeMediaPath } = require("../../utils/mediaPath")
|
||||
|
||||
function mapMeRequestToUserId(req) {
|
||||
// authMiddleware -> req.user.userId
|
||||
return req.user.userId;
|
||||
|
|
@ -10,8 +8,7 @@ function mapMeResultToResponse(user) {
|
|||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null,
|
||||
role: user.role,
|
||||
avatarUrl: user.avatarUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
const formatDateAsString = (value) =>
|
||||
value instanceof Date ? value.toISOString() : value ?? null
|
||||
const { normalizeMediaPath } = require("../../utils/mediaPath")
|
||||
|
||||
// adapters/responses/publicUser.adapter.js
|
||||
function mapUserToPublicUserSummaryResponse(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null,
|
||||
avatarUrl: user.avatarUrl ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -15,7 +14,7 @@ function mapUserToPublicUserDetailsResponse(user) {
|
|||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null,
|
||||
avatarUrl: user.avatarUrl ?? null,
|
||||
email: user.email,
|
||||
createdAt: formatDateAsString(user.createdAt), // ISO string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ function mapRegisterRequestToRegisterInput(input) {
|
|||
|
||||
function mapRegisterResultToResponse(result) {
|
||||
return {
|
||||
token: result.accessToken, // <-- KRİTİK
|
||||
user: result.user,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -3,33 +3,13 @@ const dealCardAdapter = require("./dealCard.adapter")
|
|||
const dealCommentAdapter = require("./comment.adapter")
|
||||
const publicUserAdapter = require("./publicUser.adapter") // yoksa yaz
|
||||
const userProfileStatsAdapter = require("./userProfileStats.adapter")
|
||||
const { normalizeMediaPath } = require("../../utils/mediaPath")
|
||||
|
||||
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 }) {
|
||||
function mapUserProfileToResponse({ user, deals, comments, stats }) {
|
||||
return {
|
||||
user: publicUserAdapter.mapUserToPublicUserDetailsResponse(user),
|
||||
stats: userProfileStatsAdapter.mapUserProfileStatsToResponse(stats),
|
||||
deals: deals.map(dealCardAdapter.mapDealToDealCardResponse),
|
||||
comments: comments.map(dealCommentAdapter.mapCommentToUserCommentResponse),
|
||||
badges: Array.isArray(badges) ? badges.map(mapUserBadgeToResponse).filter(Boolean) : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
34
agents.md
34
agents.md
|
|
@ -1,34 +0,0 @@
|
|||
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?"
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,128 +1,63 @@
|
|||
const prisma = require("./client") // Prisma client
|
||||
|
||||
function getDb(db) {
|
||||
return db || prisma
|
||||
}
|
||||
|
||||
async function findCategoryById(id, options = {}) {
|
||||
const cid = Number(id)
|
||||
if (!Number.isInteger(cid)) return null
|
||||
return getDb(options.db).category.findUnique({
|
||||
where: { id: cid },
|
||||
select: options.select || undefined,
|
||||
include: options.include || undefined,
|
||||
})
|
||||
}
|
||||
const prisma = require("./client"); // Prisma client
|
||||
|
||||
/**
|
||||
* Kategoriyi slug'a gore bul
|
||||
* Kategoriyi slug'a göre bul
|
||||
*/
|
||||
async function findCategoryBySlug(slug, options = {}) {
|
||||
const s = String(slug ?? "").trim().toLowerCase()
|
||||
return getDb(options.db).category.findUnique({
|
||||
const s = String(slug ?? "").trim().toLowerCase();
|
||||
return prisma.category.findUnique({
|
||||
where: { slug: s },
|
||||
select: options.select || undefined,
|
||||
include: options.include || undefined,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorinin firsatlarini al
|
||||
* Sayfalama ve filtreler ile firsatlari cekiyoruz
|
||||
* Kategorinin fırsatlarını al
|
||||
* Sayfalama ve filtreler ile fırsatları çekiyoruz
|
||||
*/
|
||||
async function listCategoryDeals({ where = {}, skip = 0, take = 10 }) {
|
||||
return prisma.deal.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
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)
|
||||
orderBy: { createdAt: "desc" }, // Yeni fırsatlar önce gelsin
|
||||
});
|
||||
}
|
||||
|
||||
async function getCategoryBreadcrumb(categoryId, { includeUndefined = false } = {}) {
|
||||
let currentId = Number(categoryId)
|
||||
if (!Number.isInteger(currentId)) throw new Error("categoryId must be int")
|
||||
let currentId = Number(categoryId);
|
||||
if (!Number.isInteger(currentId)) throw new Error("categoryId must be int");
|
||||
|
||||
const path = []
|
||||
const visited = new Set()
|
||||
const path = [];
|
||||
const visited = new Set();
|
||||
|
||||
// Bu döngü, root kategoriye kadar gidip breadcrumb oluşturacak
|
||||
while (true) {
|
||||
if (visited.has(currentId)) break
|
||||
visited.add(currentId)
|
||||
if (visited.has(currentId)) break;
|
||||
visited.add(currentId);
|
||||
|
||||
const cat = await prisma.category.findUnique({
|
||||
where: { id: currentId },
|
||||
select: { id: true, name: true, slug: true, parentId: true },
|
||||
})
|
||||
select: { id: true, name: true, slug: true, parentId: true }, // Yalnızca gerekli alanları seçiyoruz
|
||||
});
|
||||
|
||||
if (!cat) break
|
||||
if (!cat) break;
|
||||
|
||||
// Undefined'ı istersen breadcrumb'ta göstermiyoruz
|
||||
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
|
||||
currentId = cat.parentId
|
||||
if (cat.parentId === null || cat.parentId === undefined) break;
|
||||
currentId = cat.parentId; // Bir üst kategoriye geçiyoruz
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
return path.reverse(); // Kökten başlayarak, kategoriyi en son eklediğimiz için tersine çeviriyoruz
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCategoryBreadcrumb,
|
||||
findCategoryById,
|
||||
findCategoryBySlug,
|
||||
listCategoryDeals,
|
||||
listCategories,
|
||||
getCategoryDescendantIds,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,27 +4,17 @@ function getDb(db) {
|
|||
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 = {}) {
|
||||
return prisma.comment.findMany({
|
||||
where: withDeletedFilter(where, options),
|
||||
where,
|
||||
include: options.include || undefined,
|
||||
select: options.select || undefined,
|
||||
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 = {}) {
|
||||
return prisma.comment.findFirst({
|
||||
where: withDeletedFilter(where, options),
|
||||
where,
|
||||
include: options.include || undefined,
|
||||
select: options.select || undefined,
|
||||
orderBy: options.orderBy || { createdAt: "desc" },
|
||||
|
|
@ -39,18 +29,12 @@ async function createComment(data, options = {}, db) {
|
|||
})
|
||||
}
|
||||
|
||||
async function deleteComment(where, db) {
|
||||
const p = getDb(db)
|
||||
return p.comment.delete({ where })
|
||||
async function deleteComment(where) {
|
||||
return prisma.comment.delete({ where })
|
||||
}
|
||||
|
||||
async function softDeleteComment(where, db) {
|
||||
async function countComments(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) })
|
||||
return p.comment.count({ where })
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -59,6 +43,5 @@ module.exports = {
|
|||
countComments,
|
||||
createComment,
|
||||
deleteComment,
|
||||
softDeleteComment,
|
||||
findComment
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
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,
|
||||
}
|
||||
146
db/deal.db.js
146
db/deal.db.js
|
|
@ -4,9 +4,63 @@ function getDb(db) {
|
|||
return db || prisma
|
||||
}
|
||||
|
||||
async function findDeals(where = {}, options = {}, db) {
|
||||
const p = getDb(db)
|
||||
return p.deal.findMany({
|
||||
|
||||
const DEAL_CARD_INCLUDE = {
|
||||
user: { select: { id: true, username: true, avatarUrl: true } },
|
||||
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,
|
||||
include: options.include || undefined,
|
||||
select: options.select || undefined,
|
||||
|
|
@ -16,16 +70,39 @@ async function findDeals(where = {}, options = {}, db) {
|
|||
})
|
||||
}
|
||||
|
||||
async function findSimilarCandidates(where, options = {}, db) {
|
||||
const p = getDb(db)
|
||||
const safeTake = Math.min(Math.max(Number(options.take) || 30, 1), 200)
|
||||
async function findSimilarCandidatesByCategory(categoryId, excludeDealId, { take = 80 } = {}) {
|
||||
const safeTake = Math.min(Math.max(Number(take) || 80, 1), 200)
|
||||
|
||||
return p.deal.findMany({
|
||||
where,
|
||||
orderBy: options.orderBy || [{ score: "desc" }, { createdAt: "desc" }],
|
||||
return prisma.deal.findMany({
|
||||
where: {
|
||||
id: { not: Number(excludeDealId) },
|
||||
status: "ACTIVE",
|
||||
categoryId: Number(categoryId),
|
||||
},
|
||||
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
|
||||
take: safeTake,
|
||||
include: options.include || undefined,
|
||||
select: options.select || undefined,
|
||||
include: {
|
||||
seller: { select: { id: true, name: true, url: true } },
|
||||
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 } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -38,9 +115,9 @@ async function findDeal(where, options = {}, db) {
|
|||
})
|
||||
}
|
||||
|
||||
async function createDeal(data, options = {}, db) {
|
||||
const p = getDb(db)
|
||||
return p.deal.create({
|
||||
|
||||
async function createDeal(data, options = {}) {
|
||||
return prisma.deal.create({
|
||||
data,
|
||||
include: options.include || undefined,
|
||||
select: options.select || undefined,
|
||||
|
|
@ -57,32 +134,28 @@ async function updateDeal(where, data, options = {}, db) {
|
|||
})
|
||||
}
|
||||
|
||||
async function countDeals(where = {}, db) {
|
||||
const p = getDb(db)
|
||||
return p.deal.count({ where })
|
||||
async function countDeals(where = {}) {
|
||||
return prisma.deal.count({ where })
|
||||
}
|
||||
|
||||
async function findVotes(where = {}, options = {}, db) {
|
||||
const p = getDb(db)
|
||||
return p.dealVote.findMany({
|
||||
async function findVotes(where = {}, options = {}) {
|
||||
return prisma.dealVote.findMany({
|
||||
where,
|
||||
include: options.include || undefined,
|
||||
select: options.select || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function createVote(data, options = {}, db) {
|
||||
const p = getDb(db)
|
||||
return p.dealVote.create({
|
||||
async function createVote(data, options = {}) {
|
||||
return prisma.dealVote.create({
|
||||
data,
|
||||
include: options.include || undefined,
|
||||
select: options.select || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function updateVote(where, data, options = {}, db) {
|
||||
const p = getDb(db)
|
||||
return p.dealVote.update({
|
||||
async function updateVote(where, data, options = {}) {
|
||||
return prisma.dealVote.update({
|
||||
where,
|
||||
data,
|
||||
include: options.include || undefined,
|
||||
|
|
@ -90,9 +163,14 @@ async function updateVote(where, data, options = {}, db) {
|
|||
})
|
||||
}
|
||||
|
||||
async function countVotes(where = {}, db) {
|
||||
const p = getDb(db)
|
||||
return p.dealVote.count({ where })
|
||||
async function countVotes(where = {}) {
|
||||
return prisma.dealVote.count({ where })
|
||||
}
|
||||
async function getDealWithImages(dealId) {
|
||||
return prisma.deal.findUnique({
|
||||
where: { id: dealId },
|
||||
include: { images: { orderBy: { order: "asc" } } },
|
||||
});
|
||||
}
|
||||
|
||||
async function aggregateDeals(where = {}, db) {
|
||||
|
|
@ -104,9 +182,12 @@ async function aggregateDeals(where = {}, db) {
|
|||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
module.exports = {
|
||||
findDeals,
|
||||
findSimilarCandidates,
|
||||
aggregateDeals,
|
||||
getDealWithImages,
|
||||
findDeal,
|
||||
createDeal,
|
||||
updateDeal,
|
||||
|
|
@ -115,5 +196,8 @@ module.exports = {
|
|||
createVote,
|
||||
updateVote,
|
||||
countVotes,
|
||||
aggregateDeals,
|
||||
findSimilarCandidatesByCategory,
|
||||
findSimilarCandidatesBySeller,
|
||||
getDealCards,
|
||||
getPaginatedDealCards
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ const prisma = require("./client")
|
|||
async function upsertDealAiReview(dealId, input = {}) {
|
||||
const data = {
|
||||
bestCategoryId: input.bestCategoryId ?? input.best_category_id ?? 0,
|
||||
tags: Array.isArray(input.tags) ? input.tags : [],
|
||||
needsReview: Boolean(input.needsReview ?? input.needs_review ?? false),
|
||||
hasIssue: Boolean(input.hasIssue ?? input.has_issue ?? false),
|
||||
issueType: (input.issueType ?? input.issue_type ?? "NONE"),
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -21,30 +21,8 @@ async function findSellerByDomain(domain) {
|
|||
})
|
||||
}
|
||||
|
||||
async function findSellers(where = {}, options = {}) {
|
||||
return prisma.seller.findMany({
|
||||
where,
|
||||
include: options.include || undefined,
|
||||
select: options.select || undefined,
|
||||
orderBy: options.orderBy || { name: "asc" },
|
||||
})
|
||||
}
|
||||
|
||||
async function createSeller(data) {
|
||||
return prisma.seller.create({ data })
|
||||
}
|
||||
|
||||
async function updateSeller(id, data) {
|
||||
return prisma.seller.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findSeller,
|
||||
findSellerByDomain,
|
||||
findSellers,
|
||||
createSeller,
|
||||
updateSeller,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,20 +18,7 @@ async function updateUser(where, data, options = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
async function findUsersByIds(ids = [], options = {}) {
|
||||
const normalized = Array.from(
|
||||
new Set((Array.isArray(ids) ? ids : []).map((id) => Number(id)).filter((id) => Number.isInteger(id) && id > 0))
|
||||
)
|
||||
if (!normalized.length) return []
|
||||
return prisma.user.findMany({
|
||||
where: { id: { in: normalized } },
|
||||
include: options.include || undefined,
|
||||
select: options.select || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findUser,
|
||||
updateUser,
|
||||
findUsersByIds,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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,
|
||||
}
|
||||
113
db/vote.db.js
113
db/vote.db.js
|
|
@ -1,74 +1,57 @@
|
|||
const prisma = require("./client");
|
||||
|
||||
async function voteDealTxWithDb(db, { dealId, userId, voteType, createdAt }) {
|
||||
const timestamp = createdAt instanceof Date ? createdAt : createdAt ? new Date(createdAt) : new Date()
|
||||
const current = await db.dealVote.findUnique({
|
||||
where: { dealId_userId: { dealId, userId } },
|
||||
select: { voteType: true },
|
||||
})
|
||||
|
||||
const oldValue = current ? current.voteType : 0
|
||||
const delta = voteType - oldValue
|
||||
|
||||
// history (append-only)
|
||||
await db.dealVoteHistory.create({
|
||||
data: { dealId, userId, voteType, createdAt: timestamp },
|
||||
})
|
||||
|
||||
// current state
|
||||
await db.dealVote.upsert({
|
||||
where: { dealId_userId: { dealId, userId } },
|
||||
create: {
|
||||
dealId,
|
||||
userId,
|
||||
voteType,
|
||||
createdAt: timestamp,
|
||||
lastVotedAt: timestamp,
|
||||
},
|
||||
update: {
|
||||
voteType,
|
||||
lastVotedAt: timestamp,
|
||||
},
|
||||
})
|
||||
|
||||
// score delta
|
||||
if (delta !== 0) {
|
||||
await db.deal.update({
|
||||
where: { id: dealId },
|
||||
data: { score: { increment: delta } },
|
||||
})
|
||||
}
|
||||
|
||||
const deal = await db.deal.findUnique({
|
||||
where: { id: dealId },
|
||||
select: { score: true },
|
||||
})
|
||||
|
||||
return {
|
||||
dealId,
|
||||
voteType,
|
||||
delta,
|
||||
score: deal?.score ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function voteDealTx({ dealId, userId, voteType, createdAt }) {
|
||||
return prisma.$transaction((db) =>
|
||||
voteDealTxWithDb(db, { dealId, userId, voteType, createdAt })
|
||||
)
|
||||
}
|
||||
|
||||
async function voteDealBatchTx(items = []) {
|
||||
if (!items.length) return { count: 0 }
|
||||
async function voteDealTx({ dealId, userId, voteType }) {
|
||||
return prisma.$transaction(async (db) => {
|
||||
for (const item of items) {
|
||||
await voteDealTxWithDb(db, item)
|
||||
const current = await db.dealVote.findUnique({
|
||||
where: { dealId_userId: { dealId, userId } },
|
||||
select: { voteType: true },
|
||||
});
|
||||
|
||||
const oldValue = current ? current.voteType : 0;
|
||||
const delta = voteType - oldValue;
|
||||
|
||||
// history (append-only)
|
||||
await db.dealVoteHistory.create({
|
||||
data: { dealId, userId, voteType },
|
||||
});
|
||||
|
||||
// current state
|
||||
await db.dealVote.upsert({
|
||||
where: { dealId_userId: { dealId, userId } },
|
||||
create: {
|
||||
dealId,
|
||||
userId,
|
||||
voteType,
|
||||
lastVotedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
voteType,
|
||||
lastVotedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// score delta
|
||||
if (delta !== 0) {
|
||||
await db.deal.update({
|
||||
where: { id: dealId },
|
||||
data: { score: { increment: delta } },
|
||||
});
|
||||
}
|
||||
return { count: items.length }
|
||||
})
|
||||
|
||||
const deal = await db.deal.findUnique({
|
||||
where: { id: dealId },
|
||||
select: { score: true },
|
||||
});
|
||||
|
||||
return {
|
||||
dealId,
|
||||
voteType,
|
||||
delta,
|
||||
score: deal?.score ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
voteDealTx,
|
||||
voteDealBatchTx,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,318 +0,0 @@
|
|||
# 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
1413
docs/openapi.json
File diff suppressed because it is too large
Load Diff
|
|
@ -1,21 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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 }
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
const { Queue } = require("bullmq")
|
||||
const { getRedisConnectionOptions } = require("../services/redis/connection")
|
||||
|
||||
const connection = getRedisConnectionOptions()
|
||||
const connection = {
|
||||
host: process.env.REDIS_HOST ,
|
||||
port: Number(process.env.REDIS_PORT ),
|
||||
}
|
||||
|
||||
const queue = new Queue("deal-classification", { connection })
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
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 }
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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 }
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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 }
|
||||
|
|
@ -2,12 +2,10 @@ const jwt = require("jsonwebtoken")
|
|||
|
||||
function getBearerToken(req) {
|
||||
const h = req.headers.authorization
|
||||
if (h) {
|
||||
const [type, token] = h.split(" ")
|
||||
if (type === "Bearer" && token) return token
|
||||
}
|
||||
const cookieToken = req.cookies?.at
|
||||
return cookieToken || null
|
||||
if (!h) return null
|
||||
const [type, token] = h.split(" ")
|
||||
if (type !== "Bearer" || !token) return null
|
||||
return token
|
||||
}
|
||||
|
||||
module.exports = function optionalAuth(req, res, next) {
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,17 +1,14 @@
|
|||
const jwt = require("jsonwebtoken")
|
||||
const { getOrCacheUserModeration } = require("../services/redis/userModerationCache.service")
|
||||
|
||||
function getBearerToken(req) {
|
||||
const h = req.headers.authorization
|
||||
if (h) {
|
||||
const [type, token] = h.split(" ")
|
||||
if (type === "Bearer" && token) return token
|
||||
}
|
||||
const cookieToken = req.cookies?.at
|
||||
return cookieToken || null
|
||||
if (!h) return null
|
||||
const [type, token] = h.split(" ")
|
||||
if (type !== "Bearer" || !token) return null
|
||||
return token
|
||||
}
|
||||
|
||||
module.exports = async function requireAuth(req, res, next) {
|
||||
module.exports = function requireAuth(req, res, next) {
|
||||
const token = getBearerToken(req)
|
||||
if (!token) return res.status(401).json({ error: "Token yok" })
|
||||
|
||||
|
|
@ -25,12 +22,6 @@ module.exports = async function requireAuth(req, res, next) {
|
|||
}
|
||||
|
||||
if (!req.auth.userId) return res.status(401).json({ error: "Token geçersiz" })
|
||||
|
||||
const moderation = await getOrCacheUserModeration(req.auth.userId)
|
||||
if (moderation?.disabledAt) {
|
||||
return res.status(403).json({ error: "Hesap devre disi" })
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: "Token geçersiz" })
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
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
1908
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -14,10 +14,9 @@
|
|||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.985.0",
|
||||
"@prisma/client": "^6.18.0",
|
||||
"@shared/contracts": "file:../Contracts",
|
||||
"axios": "^1.11.0",
|
||||
"@supabase/supabase-js": "^2.78.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"bullmq": "^5.67.0",
|
||||
"contracts": "^0.4.0",
|
||||
|
|
|
|||
|
|
@ -1,512 +1,239 @@
|
|||
[
|
||||
{ "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": 101, "name": "Telefon & Aksesuarları", "slug": "phone-accessories", "parentId": 100, "description": "Akıllı telefonlar, kılıflar, şarj cihazları, powerbankler, ekran koruyucular ve mobil aksesuarlar." },
|
||||
{ "id": 102, "name": "Akıllı Telefonlar", "slug": "smartphones", "parentId": 101, "description": "iOS ve Android işletim sistemli, farklı marka ve modelde akıllı telefonlar." },
|
||||
{ "id": 103, "name": "Telefon Kılıfları & Kapaklar", "slug": "phone-cases-covers", "parentId": 101, "description": "Silikon, deri, sert kapak gibi farklı malzeme ve tasarımlarda telefon kılıfları." },
|
||||
{ "id": 104, "name": "Şarj Aletleri & Kablolar", "slug": "chargers-cables", "parentId": 101, "description": "Hızlı şarj adaptörleri, USB-C, Lightning, Micro USB kablolar ve kablosuz şarj cihazları." },
|
||||
{ "id": 105, "name": "Powerbankler", "slug": "powerbanks", "parentId": 101, "description": "Taşınabilir şarj cihazları; farklı kapasite ve hızlı şarj özelliklerine sahip modeller." },
|
||||
{ "id": 106, "name": "Ekran Koruyucular", "slug": "screen-protectors", "parentId": 101, "description": "Temperli cam, plastik film ve sıvı ekran koruyucular." },
|
||||
{ "id": 107, "name": "Giyilebilir Teknoloji", "slug": "wearable-tech", "parentId": 100, "description": "Akıllı saatler, fitness takip bileklikleri ve diğer giyilebilir akıllı cihazlar." },
|
||||
{ "id": 108, "name": "Akıllı Saatler", "slug": "smartwatches", "parentId": 107, "description": "Bildirim, sağlık takibi, spor modları ve uygulama desteği sunan akıllı saatler." },
|
||||
{ "id": 109, "name": "Fitness Takip Bileklikleri", "slug": "fitness-trackers", "parentId": 107, "description": "Adım, uyku, nabız, kalori gibi metrikleri izleyen akıllı bileklikler." },
|
||||
|
||||
{ "id": 110, "name": "Bilgisayar & Laptop", "slug": "computers-laptops", "parentId": 100, "description": "Dizüstü ve masaüstü bilgisayarlar, tabletler, bilgisayar bileşenleri ve çevre birimleri." },
|
||||
{ "id": 111, "name": "Dizüstü Bilgisayarlar", "slug": "laptops", "parentId": 110, "description": "Oyun, iş, öğrenci ve günlük kullanıma uygun dizüstü bilgisayarlar." },
|
||||
{ "id": 112, "name": "Masaüstü Bilgisayarlar", "slug": "desktops", "parentId": 110, "description": "Hazır sistemler, iş istasyonları ve oyun odaklı masaüstü bilgisayarlar." },
|
||||
{ "id": 113, "name": "Tabletler", "slug": "tablets", "parentId": 110, "description": "Android, iPadOS ve Windows işletim sistemli tabletler ve aksesuarları." },
|
||||
{ "id": 114, "name": "Bilgisayar Bileşenleri", "slug": "pc-components", "parentId": 110, "description": "İşlemci, ekran kartı, RAM, depolama, anakart, PSU ve kasa gibi bilgisayar parçaları." },
|
||||
{ "id": 115, "name": "İşlemciler (CPU)", "slug": "cpus", "parentId": 114, "description": "Intel ve AMD markalı, farklı çekirdek ve performans seviyelerine sahip işlemciler." },
|
||||
{ "id": 116, "name": "Ekran Kartları (GPU)", "slug": "gpus", "parentId": 114, "description": "NVIDIA GeForce ve AMD Radeon serisi ekran kartları; oyun ve grafik tasarımı için." },
|
||||
{ "id": 117, "name": "RAM Bellekleri", "slug": "ram", "parentId": 114, "description": "DDR4, DDR5 standartlarında, farklı hız ve kapasitelerdeki bellek modülleri." },
|
||||
{ "id": 118, "name": "Dahili Depolama", "slug": "internal-storage", "parentId": 114, "description": "SSD (NVMe, SATA) ve HDD dahili depolama birimleri; hızlı ve geniş kapasite seçenekleri." },
|
||||
{ "id": 119, "name": "Anakartlar", "slug": "motherboards", "parentId": 114, "description": "Intel ve AMD yonga setli, farklı form faktörlerinde (ATX, Micro ATX) anakartlar." },
|
||||
{ "id": 120, "name": "Güç Kaynakları (PSU)", "slug": "psus", "parentId": 114, "description": "Bilgisayar bileşenlerini besleyen, farklı Watt ve 80 PLUS sertifikalı güç kaynakları." },
|
||||
{ "id": 121, "name": "Bilgisayar Kasaları", "slug": "pc-cases", "parentId": 114, "description": "Midi Tower, Full Tower, mini ITX boyutlarında, hava akışı optimize edilmiş kasalar." },
|
||||
{ "id": 122, "name": "Soğutma Sistemleri", "slug": "cooling-systems", "parentId": 114, "description": "CPU hava soğutucuları, sıvı soğutma (AIO) sistemleri ve kasa fanları." },
|
||||
{ "id": 123, "name": "Çevre Birimleri & Aksesuarlar", "slug": "peripherals-accessories", "parentId": 110, "description": "Monitörler, klavyeler, fareler, web kameraları, hoparlörler ve diğer PC aksesuarları." },
|
||||
{ "id": 124, "name": "Monitörler", "slug": "monitors", "parentId": 123, "description": "Oyun monitörleri (yüksek yenileme hızı), profesyonel ve günlük kullanıma uygun ekranlar." },
|
||||
{ "id": 125, "name": "Klavyeler", "slug": "keyboards", "parentId": 123, "description": "Mekanik, membran, oyuncu, ergonomik ve kablosuz klavye modelleri." },
|
||||
{ "id": 126, "name": "Fareler & Mousepadler", "slug": "mice-mousepads", "parentId": 123, "description": "Oyuncu, optik, lazer, kablolu/kablosuz fareler ve farklı boyutlarda mousepadler." },
|
||||
{ "id": 127, "name": "Web Kameraları", "slug": "webcams", "parentId": 123, "description": "Full HD, 2K, 4K çözünürlüklü web kameraları; yayın ve video konferans için." },
|
||||
{ "id": 128, "name": "Bilgisayar Hoparlörleri", "slug": "pc-speakers", "parentId": 123, "description": "2.0, 2.1, 5.1 kanal masaüstü hoparlör sistemleri ve soundbarlar." },
|
||||
{ "id": 129, "name": "Bilgisayar Mikrofonları", "slug": "pc-microphones", "parentId": 123, "description": "Yayıncı, oyuncu, podcast ve toplantı için PC uyumlu mikrofonlar." },
|
||||
{ "id": 130, "name": "USB Hublar & Dock İstasyonları", "slug": "usb-hubs-docks", "parentId": 123, "description": "Port çoğaltıcılar, Type-C hublar ve laptop dock istasyonları." },
|
||||
{ "id": 131, "name": "Laptop Çantaları & Kılıfları", "slug": "laptop-bags-sleeves", "parentId": 123, "description": "Dizüstü bilgisayar taşıma çantaları, sırt çantaları ve koruyucu kılıflar." },
|
||||
|
||||
{ "id": 132, "name": "Ağ Ürünleri", "slug": "networking", "parentId": 100, "description": "Modemler, routerlar, Wi-Fi genişleticiler, switchler ve ağ aksesuarları." },
|
||||
{ "id": 133, "name": "Modemler & Routerlar", "slug": "modems-routers", "parentId": 132, "description": "ADSL, VDSL, Fiber uyumlu modemler, Wi-Fi 6/7 destekli routerlar." },
|
||||
{ "id": 134, "name": "Menzil Genişleticiler & Mesh Sistemler", "slug": "wifi-extenders-mesh", "parentId": 132, "description": "Kablosuz ağ menzilini artıran repeaterlar ve tüm evi kapsayan Mesh Wi-Fi sistemleri." },
|
||||
{ "id": 135, "name": "Ağ Switchleri", "slug": "network-switches", "parentId": 132, "description": "Kablolu ağ bağlantılarını çoğaltan ve yöneten switch cihazları." },
|
||||
|
||||
{ "id": 136, "name": "Yazıcı & Tarayıcı", "slug": "printers-scanners", "parentId": 100, "description": "Ev ve ofis kullanımı için yazıcılar, tarayıcılar, toner ve kartuşlar." },
|
||||
{ "id": 137, "name": "Yazıcılar", "slug": "printers", "parentId": 136, "description": "Lazer, mürekkep püskürtmeli, çok fonksiyonlu yazıcılar ve fotoğraf yazıcıları." },
|
||||
{ "id": 138, "name": "Toner & Kartuş", "slug": "ink-toner", "parentId": 136, "description": "Yazıcılar için orijinal ve uyumlu tonerler, mürekkep kartuşları." },
|
||||
{ "id": 139, "name": "Tarayıcılar", "slug": "scanners", "parentId": 136, "description": "Belge ve fotoğraf taraması için flatbed ve ADF (Otomatik Belge Besleyici) tarayıcılar." },
|
||||
|
||||
{ "id": 140, "name": "Harici Depolama", "slug": "external-storage", "parentId": 100, "description": "Harici diskler, USB bellekler, NAS cihazları ve hafıza kartları." },
|
||||
{ "id": 141, "name": "Harici Diskler", "slug": "external-drives", "parentId": 140, "description": "Taşınabilir HDD ve SSD harici depolama cihazları, farklı kapasite ve hızlarda." },
|
||||
{ "id": 142, "name": "USB Bellekler", "slug": "usb-flash-drives", "parentId": 140, "description": "Farklı kapasite ve USB standartlarında (2.0, 3.0, 3.1) USB bellekler." },
|
||||
{ "id": 143, "name": "NAS Cihazları", "slug": "nas-devices", "parentId": 140, "description": "Ağa bağlı depolama (NAS) sunucuları ve kişisel bulut çözümleri." },
|
||||
{ "id": 144, "name": "Hafıza Kartları", "slug": "memory-cards", "parentId": 140, "description": "SD kart, Micro SD kart ve CF kart gibi farklı cihazlar için hafıza kartları." },
|
||||
|
||||
{ "id": 145, "name": "Ses & Görüntü Sistemleri", "slug": "audio-video-systems", "parentId": 100, "description": "Kulaklıklar, hoparlörler, televizyonlar, soundbarlar, medya oynatıcılar ve pikaplar." },
|
||||
{ "id": 146, "name": "Kulaklıklar", "slug": "headphones-earbuds", "parentId": 145, "description": "Kulak üstü, kulak içi, kablolu, kablosuz, gürültü engelleme özellikli kulaklıklar." },
|
||||
{ "id": 147, "name": "TWS Kulaklıklar", "slug": "tws-earbuds", "parentId": 146, "description": "Tam kablosuz (True Wireless Stereo) kulak içi kulaklıklar ve şarj kutuları." },
|
||||
{ "id": 148, "name": "Hoparlörler", "slug": "speakers", "parentId": 145, "description": "Bluetooth hoparlörler, parti hoparlörleri, ev sinema sistemleri ve soundbarlar." },
|
||||
{ "id": 149, "name": "Bluetooth Hoparlörler", "slug": "bluetooth-speakers", "parentId": 148, "description": "Taşınabilir, suya dayanıklı ve şarj edilebilir Bluetooth hoparlörler." },
|
||||
{ "id": 150, "name": "Soundbarlar", "slug": "soundbars", "parentId": 148, "description": "Televizyon sesini iyileştiren, subwoofer'lı veya tek parça soundbar sistemleri." },
|
||||
{ "id": 151, "name": "Televizyonlar", "slug": "televisions", "parentId": 145, "description": "LED, QLED, OLED, Smart TV teknolojili, farklı boyut ve çözünürlükteki televizyonlar." },
|
||||
{ "id": 152, "name": "Projeksiyon Cihazları", "slug": "projectors", "parentId": 145, "description": "Ev sineması, iş sunumları ve dış mekan kullanımı için projeksiyon cihazları." },
|
||||
{ "id": 153, "name": "Medya Oynatıcılar", "slug": "media-players", "parentId": 145, "description": "Android TV Box, Apple TV, Fire TV Stick gibi akış ve medya oynatıcı cihazlar." },
|
||||
{ "id": 154, "name": "TV Aksesuarları", "slug": "tv-accessories", "parentId": 145, "description": "Uzaktan kumandalar, TV askı aparatları, HDMI kabloları ve uydu alıcıları." },
|
||||
{ "id": 155, "name": "Pikaplar & Plaklar", "slug": "turntables-vinyl", "parentId": 145, "description": "Analog müzik deneyimi için pikaplar, vinyl plaklar ve plak temizleme setleri." },
|
||||
|
||||
{ "id": 156, "name": "Kameralar & Fotoğrafçılık", "slug": "cameras-photography", "parentId": 100, "description": "DSLR, aynasız, aksiyon kameraları, drone'lar, lensler ve fotoğraf aksesuarları." },
|
||||
{ "id": 157, "name": "Fotoğraf Makineleri", "slug": "photo-cameras", "parentId": 156, "description": "DSLR, aynasız, kompakt ve anında baskı yapan fotoğraf makineleri." },
|
||||
{ "id": 158, "name": "Aksiyon Kameraları", "slug": "action-cameras", "parentId": 156, "description": "GoPro ve benzeri suya, darbelere dayanıklı, hareketli çekime uygun aksiyon kameraları." },
|
||||
{ "id": 159, "name": "Kamera Lensleri", "slug": "camera-lenses", "parentId": 156, "description": "Prime, zoom, geniş açı, tele ve makro lensler; farklı marka ve modellerde." },
|
||||
{ "id": 160, "name": "Dronelar", "slug": "drones", "parentId": 156, "description": "Hava fotoğrafçılığı, video çekimi ve eğlence amaçlı dronelar ve yedek parçaları." },
|
||||
{ "id": 161, "name": "Tripodlar & Stabilizatörler", "slug": "tripods-stabilizers", "parentId": 156, "description": "Fotoğraf ve video çekimi için tripodlar, monopodlar ve gimbal stabilizatörler." },
|
||||
{ "id": 162, "name": "Kamera Aksesuarları", "slug": "camera-accessories", "parentId": 156, "description": "Kamera çantaları, filtreler, harici flaşlar, bataryalar ve temizlik kitleri." },
|
||||
|
||||
{ "id": 163, "name": "Akıllı Ev Sistemleri", "slug": "smart-home-systems", "parentId": 100, "description": "Akıllı aydınlatma, prizler, güvenlik kameraları, sensörler ve ev otomasyon cihazları." },
|
||||
{ "id": 164, "name": "Akıllı Aydınlatma", "slug": "smart-lighting", "parentId": 163, "description": "Wi-Fi/Zigbee bağlantılı akıllı ampuller, LED şeritler ve aydınlatma sistemleri." },
|
||||
{ "id": 165, "name": "Akıllı Prizler", "slug": "smart-plugs", "parentId": 163, "description": "Uygulama ile kontrol edilebilen, zamanlayıcı ve enerji tüketimi takibi özellikli akıllı prizler." },
|
||||
{ "id": 166, "name": "Akıllı Güvenlik Kameraları", "slug": "smart-security-cameras", "parentId": 163, "description": "Ev ve ofis için iç/dış mekan IP güvenlik kameraları, bebek monitörleri." },
|
||||
{ "id": 167, "name": "Akıllı Sensörler", "slug": "smart-sensors", "parentId": 163, "description": "Hareket, kapı/pencere, sıcaklık/nem, duman ve su kaçağı sensörleri." },
|
||||
{ "id": 168, "name": "Akıllı Termostatlar", "slug": "smart-thermostats", "parentId": 163, "description": "Enerji tasarrufu sağlayan, uzaktan kontrol edilebilir akıllı termostatlar." },
|
||||
{ "id": 169, "name": "Sesli Asistanlar & Akıllı Ekranlar", "slug": "voice-assistants-smart-displays", "parentId": 163, "description": "Google Nest, Amazon Echo gibi sesli asistan cihazları ve akıllı ekranlar." },
|
||||
|
||||
{ "id": 201, "name": "Mobilya", "slug": "furniture", "parentId": 200, "description": "Salon, yatak odası, yemek odası, çalışma odası mobilyaları ve depolama çözümleri." },
|
||||
{ "id": 202, "name": "Oturma Odası Mobilyaları", "slug": "living-room-furniture", "parentId": 201, "description": "Koltuk takımları, kanepeler, berjerler, TV üniteleri ve orta sehpalar." },
|
||||
{ "id": 203, "name": "Yatak Odası Mobilyaları", "slug": "bedroom-furniture", "parentId": 201, "description": "Yatak, baza, komodin, gardırop, şifonyer ve makyaj masaları." },
|
||||
{ "id": 204, "name": "Yemek Odası Mobilyaları", "slug": "dining-room-furniture", "parentId": 201, "description": "Yemek masaları, sandalyeler, konsollar ve vitrinler." },
|
||||
{ "id": 205, "name": "Çalışma Odası Mobilyaları", "slug": "home-office-furniture", "parentId": 201, "description": "Çalışma masaları, ofis koltukları, kitaplıklar ve dosya dolapları." },
|
||||
{ "id": 206, "name": "Depolama & Düzenleme", "slug": "storage-organization", "parentId": 201, "description": "Raflar, dolaplar, çekmeceler, kutular ve ev düzenleme ürünleri." },
|
||||
|
||||
{ "id": 207, "name": "Ev Dekorasyonu", "slug": "home-decor", "parentId": 200, "description": "Halılar, tablolar, aynalar, vazolar, mumlar ve diğer dekoratif objeler." },
|
||||
{ "id": 208, "name": "Halılar & Kilimler", "slug": "rugs-carpets", "parentId": 207, "description": "Salon, yatak odası, mutfak ve koridor için halılar, kilimler ve paspaslar." },
|
||||
{ "id": 209, "name": "Duvar Dekorasyonu", "slug": "wall-decor", "parentId": 207, "description": "Kanvas tablolar, duvar aynaları, dekoratif raflar ve duvar saatleri." },
|
||||
{ "id": 210, "name": "Dekoratif Objeler", "slug": "decorative-objects", "parentId": 207, "description": "Vazolar, biblolar, heykeller, şamdanlar ve fotoğraf çerçeveleri." },
|
||||
{ "id": 211, "name": "Mumlar & Oda Kokuları", "slug": "candles-room-fragrance", "parentId": 207, "description": "Dekoratif mumlar, kokulu mumlar, difüzörler ve oda spreyleri." },
|
||||
|
||||
{ "id": 212, "name": "Aydınlatma", "slug": "lighting", "parentId": 200, "description": "Avizeler, lambaderler, masa lambaları, spot ışıklar ve LED aydınlatma çözümleri." },
|
||||
{ "id": 213, "name": "Avizeler & Sarkıtlar", "slug": "chandeliers-pendants", "parentId": 212, "description": "Salon, yemek odası ve mutfak için avizeler, sarkıt lambalar ve aplikler." },
|
||||
{ "id": 214, "name": "Masa Lambaları", "slug": "table-lamps", "parentId": 212, "description": "Çalışma masası, komodin ve okuma için masa lambaları." },
|
||||
{ "id": 215, "name": "Lambaderler", "slug": "floor-lamps", "parentId": 212, "description": "Oturma odası, köşe ve genel aydınlatma için modern ve klasik lambaderler." },
|
||||
{ "id": 216, "name": "LED Aydınlatma", "slug": "led-lighting", "parentId": 212, "description": "LED ampuller, şerit LED'ler, spot aydınlatmalar ve akıllı LED çözümleri." },
|
||||
|
||||
{ "id": 217, "name": "Mutfak & Yemek", "slug": "kitchen-dining", "parentId": 200, "description": "Tencere/tava, yemek takımları, çatal-bıçak setleri, küçük ev aletleri ve mutfak gereçleri." },
|
||||
{ "id": 218, "name": "Tencere & Tava Setleri", "slug": "cookware-sets", "parentId": 217, "description": "Granit, döküm, çelik, teflon tencere ve tava setleri, fırın kapları." },
|
||||
{ "id": 219, "name": "Yemek & Kahvaltı Takımları", "slug": "dinner-breakfast-sets", "parentId": 217, "description": "Porselen, seramik, cam yemek takımları ve kahvaltı setleri." },
|
||||
{ "id": 220, "name": "Çatal & Bıçak Setleri", "slug": "cutlery-sets", "parentId": 217, "description": "Paslanmaz çelik çatal, bıçak, kaşık setleri ve servis takımları." },
|
||||
{ "id": 221, "name": "Bardak & Kadeh Takımları", "slug": "glassware-sets", "parentId": 217, "description": "Su bardakları, çay bardakları, kahve fincanları, kadehler ve kupalar." },
|
||||
{ "id": 222, "name": "Mutfak Gereçleri", "slug": "kitchen-utensils", "parentId": 217, "description": "Bıçak setleri, kepçe, spatula, kesme tahtaları, rende ve mutfak tartıları." },
|
||||
{ "id": 223, "name": "Saklama Kapları & Termoslar", "slug": "food-storage-thermoses", "parentId": 217, "description": "Yiyecek saklama kapları, erzak kapları, termoslar ve beslenme çantaları." },
|
||||
{ "id": 224, "name": "Küçük Ev Aletleri", "slug": "small-appliances", "parentId": 217, "description": "Kahve makineleri, blenderlar, tost makineleri, airfryerlar ve mikrodalga fırınlar." },
|
||||
{ "id": 225, "name": "Kahve Makineleri", "slug": "coffee-machines", "parentId": 224, "description": "Filtre kahve, espresso, kapsül ve Türk kahvesi makineleri." },
|
||||
{ "id": 226, "name": "Blenderlar & Mutfak Robotları", "slug": "blenders-food-processors", "parentId": 224, "description": "El blenderları, smoothie blenderlar, mutfak robotları ve mikserler." },
|
||||
{ "id": 227, "name": "Airfryerlar", "slug": "airfryers", "parentId": 224, "description": "Sağlıklı ve az yağlı pişirme için airfryer cihazları ve aksesuarları." },
|
||||
{ "id": 228, "name": "Tost Makineleri & Fritözler", "slug": "toasters-fryers", "parentId": 224, "description": "Ekmek kızartma makineleri, tost makineleri, derin yağ fritözleri." },
|
||||
|
||||
{ "id": 229, "name": "Beyaz Eşya", "slug": "large-appliances", "parentId": 200, "description": "Buzdolapları, çamaşır makineleri, bulaşık makineleri, fırınlar ve ocaklar." },
|
||||
{ "id": 230, "name": "Buzdolapları", "slug": "refrigerators", "parentId": 229, "description": "No Frost, kombi, gardırop tipi, tek kapılı buzdolabı modelleri." },
|
||||
{ "id": 231, "name": "Çamaşır Makineleri & Kurutucular", "slug": "washing-machines-dryers", "parentId": 229, "description": "Önden/üstten yüklemeli çamaşır makineleri, kurutma makineleri ve kurutmalı çamaşır makineleri." },
|
||||
{ "id": 232, "name": "Bulaşık Makineleri", "slug": "dishwashers", "parentId": 229, "description": "Ankastre ve solo bulaşık makinesi modelleri, farklı program ve kapasitelerde." },
|
||||
{ "id": 233, "name": "Fırınlar & Ocaklar", "slug": "ovens-hobs", "parentId": 229, "description": "Ankastre fırınlar, set üstü ocaklar, mikrodalga fırınlar ve davlumbazlar." },
|
||||
|
||||
{ "id": 234, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 200, "description": "Nevresim takımları, yorgan, battaniye, perde, havlu ve yastıklar." },
|
||||
{ "id": 235, "name": "Nevresim Takımları", "slug": "bedding-sets", "parentId": 234, "description": "Tek kişilik, çift kişilik nevresim takımları, çarşaflar ve yastık kılıfları." },
|
||||
{ "id": 236, "name": "Yorganlar & Battaniyeler", "slug": "duvets-blankets", "parentId": 234, "description": "Elyaf, pamuk, yün yorganlar, polar, pamuklu ve örgü battaniyeler." },
|
||||
{ "id": 237, "name": "Perdeler & Jaluziler", "slug": "curtains-blinds", "parentId": 234, "description": "Tül, fon, stor, zebra perde modelleri ve jaluziler." },
|
||||
{ "id": 238, "name": "Havlu Setleri", "slug": "towel-sets", "parentId": 234, "description": "Banyo, el, yüz ve plaj havlusu setleri." },
|
||||
{ "id": 239, "name": "Yastıklar & Minderler", "slug": "pillows-cushions", "parentId": 234, "description": "Uyku yastıkları, dekoratif minderler ve koltuk şalları." },
|
||||
|
||||
{ "id": 240, "name": "Temizlik & Çamaşır Bakımı", "slug": "cleaning-laundry-care", "parentId": 200, "description": "Süpürgeler, temizlik malzemeleri, deterjanlar ve ütü ürünleri." },
|
||||
{ "id": 241, "name": "Süpürgeler", "slug": "vacuum-cleaners", "parentId": 240, "description": "Robot süpürgeler, dikey süpürgeler, toz torbalı/torbasız süpürgeler ve buharlı temizleyiciler." },
|
||||
{ "id": 242, "name": "Temizlik Malzemeleri", "slug": "cleaning-supplies", "parentId": 240, "description": "Yüzey temizleyiciler, çamaşır suyu, cam temizleyici, süngerler ve bezler." },
|
||||
{ "id": 243, "name": "Çamaşır Deterjanları & Yumuşatıcılar", "slug": "laundry-detergents-softeners", "parentId": 240, "description": "Sıvı, toz çamaşır deterjanları, yumuşatıcılar ve leke çıkarıcılar." },
|
||||
{ "id": 244, "name": "Ütü & Ütü Masaları", "slug": "irons-ironing-boards", "parentId": 240, "description": "Buharlı ütüler, kazanlı ütüler, seyahat ütüleri ve ütü masaları." },
|
||||
|
||||
{ "id": 245, "name": "Bahçe & Dış Mekan", "slug": "garden-outdoor", "parentId": 200, "description": "Bahçe mobilyaları, mangallar, bahçe aletleri, sulama sistemleri ve bitki bakımı." },
|
||||
{ "id": 246, "name": "Bahçe Mobilyaları", "slug": "garden-furniture", "parentId": 245, "description": "Oturma grupları, masalar, sandalyeler, salıncaklar ve şezlonglar." },
|
||||
{ "id": 247, "name": "Mangallar & Barbeküler", "slug": "bbqs-grills", "parentId": 245, "description": "Kömürlü, gazlı mangallar, elektrikli ızgaralar ve barbekü aksesuarları." },
|
||||
{ "id": 248, "name": "Bahçe Aletleri", "slug": "gardening-tools", "parentId": 245, "description": "Çim biçme makineleri, budama makasları, tırmıklar, kürekler ve el aletleri setleri." },
|
||||
{ "id": 249, "name": "Sulama Sistemleri", "slug": "irrigation-systems", "parentId": 245, "description": "Bahçe hortumları, damla sulama setleri, sprinklerlar ve sulama tabancaları." },
|
||||
{ "id": 250, "name": "Bitki Bakımı & Tohum", "slug": "plant-care-seeds", "parentId": 245, "description": "Saksı bitkileri, çiçek tohumları, gübreler ve bitki besinleri." },
|
||||
|
||||
{ "id": 251, "name": "Kendin Yap & El Aletleri", "slug": "diy-tools", "parentId": 200, "description": "Matkaplar, testereler, el aletleri, hırdavat ürünleri ve iş güvenliği ekipmanları." },
|
||||
{ "id": 252, "name": "Elektrikli El Aletleri", "slug": "power-tools", "parentId": 251, "description": "Şarjlı matkap, darbeli matkap, dekupaj testere, zımpara makineleri ve spiral taşlama makineleri." },
|
||||
{ "id": 253, "name": "El Aletleri", "slug": "hand-tools", "parentId": 251, "description": "Tornavida setleri, pense, anahtar takımları, çekiçler ve metreler." },
|
||||
{ "id": 254, "name": "Hırdavat & Bağlantı Elemanları", "slug": "hardware-fasteners", "parentId": 251, "description": "Vida, dübel, somun, cıvata, menteşe ve yapıştırıcılar." },
|
||||
{ "id": 255, "name": "İş Güvenliği Ekipmanları", "slug": "safety-equipment", "parentId": 251, "description": "İş eldivenleri, koruyucu gözlükler, kulaklıklar ve iş ayakkabıları." },
|
||||
|
||||
|
||||
{ "id": 301, "name": "Kadın Giyim", "slug": "womens-clothing", "parentId": 300, "description": "Elbiseler, bluzlar, pantolonlar, etekler, dış giyim, iç giyim ve spor giyim." },
|
||||
{ "id": 302, "name": "Elbiseler", "slug": "dresses", "parentId": 301, "description": "Günlük, abiye, spor, kokteyl elbiseleri ve tulumlar." },
|
||||
{ "id": 303, "name": "Kadın Üst Giyim", "slug": "womens-tops", "parentId": 301, "description": "Tişörtler, bluzlar, gömlekler, kazaklar, hırkalar ve ceketler." },
|
||||
{ "id": 304, "name": "Kadın Alt Giyim", "slug": "womens-bottoms", "parentId": 301, "description": "Pantolonlar, jeanler, etekler, şortlar ve taytlar." },
|
||||
{ "id": 305, "name": "Kadın Dış Giyim", "slug": "womens-outerwear", "parentId": 301, "description": "Montlar, kabanlar, trençkotlar, yelekler ve blazer ceketler." },
|
||||
{ "id": 306, "name": "Kadın İç Giyim & Gecelik", "slug": "womens-underwear-nightwear", "parentId": 301, "description": "Sütyen, külot, pijama, gecelik, sabahlık ve korse modelleri." },
|
||||
{ "id": 307, "name": "Kadın Spor Giyim", "slug": "womens-sportswear", "parentId": 301, "description": "Spor taytları, spor sütyenleri, eşofman takımları ve spor tişörtleri." },
|
||||
{ "id": 308, "name": "Kadın Mayo & Bikini", "slug": "womens-swimwear", "parentId": 301, "description": "Mayo, bikini, tankini, pareo ve plaj giyim ürünleri." },
|
||||
|
||||
{ "id": 309, "name": "Erkek Giyim", "slug": "mens-clothing", "parentId": 300, "description": "Tişörtler, gömlekler, pantolonlar, dış giyim, iç giyim ve spor giyim." },
|
||||
{ "id": 310, "name": "Erkek Üst Giyim", "slug": "mens-tops", "parentId": 309, "description": "Tişörtler, polo yaka tişörtler, gömlekler, kazaklar, hırkalar ve sweatshirtler." },
|
||||
{ "id": 311, "name": "Erkek Alt Giyim", "slug": "mens-bottoms", "parentId": 309, "description": "Pantolonlar, jeanler, şortlar, eşofmanlar ve bermudalar." },
|
||||
{ "id": 312, "name": "Erkek Dış Giyim", "slug": "mens-outerwear", "parentId": 309, "description": "Montlar, kabanlar, ceketler, yelekler ve deri ceketler." },
|
||||
{ "id": 313, "name": "Erkek İç Giyim & Çorap", "slug": "mens-underwear-socks", "parentId": 309, "description": "Boxer, atlet, slip, külot ve çorap modelleri." },
|
||||
{ "id": 314, "name": "Erkek Spor Giyim", "slug": "mens-sportswear", "parentId": 309, "description": "Eşofman takımları, spor tişörtleri, şortlar ve eşofman altları." },
|
||||
{ "id": 315, "name": "Erkek Mayo & Şort", "slug": "mens-swimwear", "parentId": 309, "description": "Deniz şortları, mayolar ve plaj havluları." },
|
||||
|
||||
{ "id": 316, "name": "Ayakkabı", "slug": "footwear", "parentId": 300, "description": "Kadın, erkek ve çocuk ayakkabıları; spor, klasik, bot, sandalet ve terlikler." },
|
||||
{ "id": 317, "name": "Kadın Ayakkabı", "slug": "womens-shoes", "parentId": 316, "description": "Topuklu ayakkabılar, babetler, spor ayakkabılar, sandaletler, botlar ve terlikler." },
|
||||
{ "id": 318, "name": "Erkek Ayakkabı", "slug": "mens-shoes", "parentId": 316, "description": "Klasik ayakkabılar, spor ayakkabılar, botlar, sandaletler ve terlikler." },
|
||||
{ "id": 319, "name": "Çocuk Ayakkabı", "slug": "kids-shoes", "parentId": 316, "description": "Okul ayakkabıları, spor ayakkabıları, sandaletler ve botlar." },
|
||||
|
||||
{ "id": 320, "name": "Çanta & Bavul", "slug": "bags-luggage", "parentId": 300, "description": "El çantaları, sırt çantaları, cüzdanlar, valizler ve seyahat çantaları." },
|
||||
{ "id": 321, "name": "El Çantaları", "slug": "handbags", "parentId": 320, "description": "Omuz çantaları, çapraz çantalar, portföyler, clutchlar ve tote çantalar." },
|
||||
{ "id": 322, "name": "Sırt Çantaları", "slug": "backpacks", "parentId": 320, "description": "Günlük kullanım, okul, spor, laptop ve seyahat sırt çantaları." },
|
||||
{ "id": 323, "name": "Cüzdanlar", "slug": "wallets", "parentId": 320, "description": "Kadın ve erkek cüzdanları, kartlıklar ve bozuk para cüzdanları." },
|
||||
{ "id": 324, "name": "Seyahat Bavulları & Valizler", "slug": "travel-luggage", "parentId": 320, "description": "Kabin boyu, orta boy, büyük boy valizler ve seyahat setleri." },
|
||||
|
||||
{ "id": 325, "name": "Aksesuarlar", "slug": "accessories", "parentId": 300, "description": "Takı, saat, kemer, şapka, gözlük, eşarp ve diğer moda aksesuarları." },
|
||||
{ "id": 326, "name": "Takı & Mücevher", "slug": "jewelry", "parentId": 325, "description": "Kolye, küpe, bileklik, yüzük, broş ve setler; altın, gümüş, pırlanta." },
|
||||
{ "id": 327, "name": "Saatler", "slug": "watches", "parentId": 325, "description": "Kol saatleri; analog, dijital, otomatik ve akıllı saatler." },
|
||||
{ "id": 328, "name": "Kemerler", "slug": "belts", "parentId": 325, "description": "Deri, kumaş, kadın ve erkek kemer modelleri." },
|
||||
{ "id": 329, "name": "Şapkalar & Bereler", "slug": "hats-beanies", "parentId": 325, "description": "Kasket, şapka, bere, bandana ve atkı setleri." },
|
||||
{ "id": 330, "name": "Güneş Gözlükleri", "slug": "sunglasses", "parentId": 325, "description": "Kadın, erkek ve çocuk güneş gözlükleri; farklı marka ve modellerde." },
|
||||
{ "id": 331, "name": "Eşarp & Şallar", "slug": "scarves-shawls", "parentId": 325, "description": "İpek, pamuk, yün eşarplar ve şal modelleri." },
|
||||
{ "id": 332, "name": "Eldivenler", "slug": "gloves", "parentId": 325, "description": "Deri, yün, polar, spor eldivenleri ve dokunmatik ekran uyumlu eldivenler." },
|
||||
|
||||
|
||||
{ "id": 401, "name": "Makyaj", "slug": "makeup", "parentId": 400, "description": "Yüz, göz, dudak makyaj ürünleri ve makyaj aksesuarları." },
|
||||
{ "id": 402, "name": "Yüz Makyajı", "slug": "face-makeup", "parentId": 401, "description": "Fondöten, kapatıcı, pudra, allık, bronzer, aydınlatıcı ve makyaj bazları." },
|
||||
{ "id": 403, "name": "Göz Makyajı", "slug": "eye-makeup", "parentId": 401, "description": "Maskara, eyeliner, far paletleri, kaş kalemi, göz kalemi ve kirpik." },
|
||||
{ "id": 404, "name": "Dudak Makyajı", "slug": "lip-makeup", "parentId": 401, "description": "Ruj, dudak parlatıcısı, dudak kalemi, dudak balmı ve dudak nemlendiricileri." },
|
||||
{ "id": 405, "name": "Makyaj Fırçaları & Aksesuarları", "slug": "makeup-brushes-tools", "parentId": 401, "description": "Makyaj fırça setleri, süngerler, makyaj çantaları ve makyaj temizleme ürünleri." },
|
||||
|
||||
{ "id": 406, "name": "Cilt Bakımı", "slug": "skincare", "parentId": 400, "description": "Yüz temizleyiciler, nemlendiriciler, serumlar, maskeler, güneş kremleri ve tonikler." },
|
||||
{ "id": 407, "name": "Yüz Temizleyiciler", "slug": "face-cleansers", "parentId": 406, "description": "Jel, köpük, yağ bazlı, misel su ve peeling etkili yüz temizleyiciler." },
|
||||
{ "id": 408, "name": "Nemlendiriciler", "slug": "moisturizers", "parentId": 406, "description": "Yüz ve vücut nemlendiricileri, kremler, losyonlar ve yağlar; farklı cilt tiplerine özel." },
|
||||
{ "id": 409, "name": "Serumlar & Özel Bakım", "slug": "serums-special-care", "parentId": 406, "description": "Hyaluronik asit, C vitamini, retinol, niasinamid serumları ve leke/akne tedavileri." },
|
||||
{ "id": 410, "name": "Yüz Maskeleri", "slug": "face-masks", "parentId": 406, "description": "Kil maskeleri, kağıt maskeler, uyku maskeleri ve nemlendirici maskeler." },
|
||||
{ "id": 411, "name": "Güneş Kremleri & Güneş Sonrası", "slug": "sunscreen-after-sun", "parentId": 406, "description": "UVA/UVB korumalı yüz ve vücut güneş kremleri (SPF), güneş sonrası losyonları." },
|
||||
{ "id": 412, "name": "Tonikler & Esanslar", "slug": "toners-essences", "parentId": 406, "description": "Cilt dengeleyici, gözenek sıkılaştırıcı tonikler ve besleyici esanslar." },
|
||||
{ "id": 413, "name": "Göz Çevresi Bakımı", "slug": "eye-care", "parentId": 406, "description": "Göz kremleri, serumlar, morluk ve torba karşıtı ürünler." },
|
||||
|
||||
{ "id": 414, "name": "Saç Bakımı", "slug": "haircare", "parentId": 400, "description": "Şampuan, saç kremi, saç maskesi, saç yağları, şekillendiriciler ve saç boyaları." },
|
||||
{ "id": 415, "name": "Şampuanlar & Saç Kremleri", "slug": "shampoo-conditioner", "parentId": 414, "description": "Kepek, yağlı/kuru saç, onarıcı, renk koruyucu ve hacim veren şampuan/saç kremleri." },
|
||||
{ "id": 416, "name": "Saç Maskeleri & Bakım Yağları", "slug": "hair-masks-oils", "parentId": 414, "description": "Saç dökülmesine karşı, besleyici, onarıcı maskeler ve argan yağı, hindistan cevizi yağı gibi bakım yağları." },
|
||||
{ "id": 417, "name": "Saç Şekillendiriciler", "slug": "hair-styling", "parentId": 414, "description": "Saç spreyi, wax, jöle, köpük, ısıya karşı koruyucu spreyler ve şekillendirici kremler." },
|
||||
{ "id": 418, "name": "Saç Boyaları & Renk Açıcılar", "slug": "hair-color-lighteners", "parentId": 414, "description": "Kalıcı, yarı kalıcı saç boyaları, bitkisel boyalar ve renk açıcı ürünler." },
|
||||
{ "id": 419, "name": "Saç Şekillendirme Cihazları", "slug": "hair-styling-tools", "parentId": 414, "description": "Saç kurutma makineleri, düzleştiriciler, maşalar, fön fırçaları ve saç fırçaları." },
|
||||
|
||||
{ "id": 420, "name": "Parfümler & Deodorantlar", "slug": "fragrances-deodorants", "parentId": 400, "description": "Kadın ve erkek parfümleri, kolonyalar, vücut spreyleri ve deodorantlar." },
|
||||
{ "id": 421, "name": "Kadın Parfümleri", "slug": "womens-fragrances", "parentId": 420, "description": "Çiçeksi, oryantal, fresh, odunsu koku profillerinde kadın parfümleri (EDT/EDP)." },
|
||||
{ "id": 422, "name": "Erkek Parfümleri", "slug": "mens-fragrances", "parentId": 420, "description": "Odunsu, baharatlı, fresh, aromatik koku profillerinde erkek parfümleri (EDT/EDP)." },
|
||||
{ "id": 423, "name": "Vücut Spreyleri & Kolonyalar", "slug": "body-mists-colognes", "parentId": 420, "description": "Hafif ve ferahlatıcı vücut spreyleri, kolonyalar ve eau de toilette'ler." },
|
||||
{ "id": 424, "name": "Deodorantlar & Antiperspirantlar", "slug": "deodorants-antiperspirants", "parentId": 420, "description": "Ter kokusunu önleyen roll-on, sprey ve stick deodorantlar." },
|
||||
|
||||
{ "id": 425, "name": "Kişisel Hijyen & Bakım", "slug": "personal-hygiene-care", "parentId": 400, "description": "Duş jelleri, sabunlar, ağız bakım ürünleri, tıraş/epilasyon ürünleri ve el/ayak bakımı." },
|
||||
{ "id": 426, "name": "Duş & Banyo Ürünleri", "slug": "shower-bath-products", "parentId": 425, "description": "Duş jelleri, sabunlar, banyo köpükleri, peelingler ve vücut fırçaları." },
|
||||
{ "id": 427, "name": "Ağız Bakım Ürünleri", "slug": "oral-care", "parentId": 425, "description": "Diş macunları, diş fırçaları, ağız gargaraları, diş ipleri ve dil temizleyiciler." },
|
||||
{ "id": 428, "name": "Tıraş & Epilasyon", "slug": "shaving-hair-removal", "parentId": 425, "description": "Tıraş bıçakları, tıraş köpükleri/jelleri, aftershave, epilatörler ve ağda ürünleri." },
|
||||
{ "id": 429, "name": "El & Ayak Bakımı", "slug": "hand-foot-care", "parentId": 425, "description": "El kremleri, ayak maskeleri, tırnak makası, törpü ve manikür/pedikür setleri." },
|
||||
{ "id": 430, "name": "Men's Grooming", "slug": "mens-grooming", "parentId": 425, "description": "Erkeklere özel cilt, saç, sakal bakımı ve tıraş ürünleri." },
|
||||
|
||||
|
||||
{ "id": 501, "name": "Temel Gıda & Kuru Gıda", "slug": "pantry-dry-food", "parentId": 500, "description": "Makarna, pirinç, bakliyat, un, yağ, salça, baharat ve konserveler." },
|
||||
{ "id": 502, "name": "Makarna & Erişte", "slug": "pasta-noodles", "parentId": 501, "description": "Spagetti, penne, fiyonk makarna, erişte ve glutensiz makarna çeşitleri." },
|
||||
{ "id": 503, "name": "Pirinç & Bakliyat", "slug": "rice-legumes", "parentId": 501, "description": "Osmancık pirinç, baldo pirinç, bulgur, mercimek, nohut, fasulye ve barbunya." },
|
||||
{ "id": 504, "name": "Un & Fırıncılık Malzemeleri", "slug": "flour-baking-supplies", "parentId": 501, "description": "Buğday unu, tam buğday unu, mısır unu, kabartma tozu, vanilya ve instant maya." },
|
||||
{ "id": 505, "name": "Yağlar & Sirkeler", "slug": "oils-vinegars", "parentId": 501, "description": "Zeytinyağı, ayçiçek yağı, mısırözü yağı, üzüm sirkesi, elma sirkesi ve nar ekşisi." },
|
||||
{ "id": 506, "name": "Soslar & Salçalar", "slug": "sauces-pastes", "parentId": 501, "description": "Domates salçası, biber salçası, ketçap, mayonez, hardal, acı soslar ve turşular." },
|
||||
{ "id": 507, "name": "Baharatlar & Otlar", "slug": "spices-herbs", "parentId": 501, "description": "Karabiber, kimyon, pul biber, nane, kekik, zerdeçal ve köri gibi baharatlar." },
|
||||
{ "id": 508, "name": "Konserveler & Hazır Yemekler", "slug": "canned-ready-meals", "parentId": 501, "description": "Ton balığı, fasulye konservesi, bezelye konservesi, hazır çorbalar ve paket yemekler." },
|
||||
|
||||
{ "id": 509, "name": "Taze Ürünler", "slug": "fresh-produce", "parentId": 500, "description": "Meyveler, sebzeler, et, tavuk, balık, şarküteri ürünleri ve süt ürünleri." },
|
||||
{ "id": 510, "name": "Meyveler", "slug": "fruits", "parentId": 509, "description": "Mevsimlik meyveler, egzotik meyveler, kurutulmuş meyveler ve meyve püreleri." },
|
||||
{ "id": 511, "name": "Sebzeler", "slug": "vegetables", "parentId": 509, "description": "Yeşillikler, kök sebzeler, salatalık, domates ve organik sebzeler." },
|
||||
{ "id": 512, "name": "Et & Tavuk Ürünleri", "slug": "meat-poultry-products", "parentId": 509, "description": "Dana eti, kuzu eti, tavuk eti, hindi eti; kıyma, kuşbaşı, pirzola ve fileto." },
|
||||
{ "id": 513, "name": "Balık & Deniz Ürünleri", "slug": "fish-seafood", "parentId": 509, "description": "Somon, levrek, çipura, alabalık, karides, midye ve diğer deniz ürünleri." },
|
||||
{ "id": 514, "name": "Şarküteri & Kahvaltılık", "slug": "delicatessen-breakfast", "parentId": 509, "description": "Peynir çeşitleri, zeytin, tereyağı, yumurta, sucuk, salam, sosis ve pastırma." },
|
||||
{ "id": 515, "name": "Süt & Süt Ürünleri", "slug": "dairy-products", "parentId": 509, "description": "Süt, yoğurt, ayran, kefir, krema, peynir ve bitkisel süt alternatifleri." },
|
||||
|
||||
{ "id": 516, "name": "Atıştırmalıklar & Şekerlemeler", "slug": "snacks-confectionery", "parentId": 500, "description": "Cips, kuruyemiş, bisküvi, çikolata, şekerleme ve dondurulmuş tatlılar." },
|
||||
{ "id": 517, "name": "Cips & Kraker", "slug": "crisps-crackers", "parentId": 516, "description": "Patates cipsi, mısır cipsi, aromalı krakerler ve galetalar." },
|
||||
{ "id": 518, "name": "Bisküvi & Kurabiyeler", "slug": "biscuits-cookies", "parentId": 516, "description": "Çikolatalı, kremalı, tuzlu bisküviler, kekler ve kurabiyeler." },
|
||||
{ "id": 519, "name": "Çikolata & Şekerlemeler", "slug": "chocolate-sweets", "parentId": 516, "description": "Sütlü, bitter, beyaz çikolatalar, gofretler, barlar, sakızlar ve şekerler." },
|
||||
{ "id": 520, "name": "Kuruyemişler & Kuru Meyveler", "slug": "nuts-dried-fruits", "parentId": 516, "description": "Fındık, fıstık, badem, ceviz, kaju, kuru kayısı, kuru incir ve kuru üzüm." },
|
||||
{ "id": 521, "name": "Dondurma & Dondurulmuş Tatlılar", "slug": "ice-cream-frozen-desserts", "parentId": 516, "description": "Kutu, çubuk, külah dondurmalar, dondurulmuş pastalar ve tatlılar." },
|
||||
|
||||
{ "id": 522, "name": "İçecekler", "slug": "beverages", "parentId": 500, "description": "Kahve, çay, su, gazlı içecekler, meyve suları ve alkollü içecekler." },
|
||||
{ "id": 523, "name": "Kahve Çeşitleri", "slug": "coffee-varieties", "parentId": 522, "description": "Türk kahvesi, filtre kahve, espresso, granül kahve, kapsül kahve ve çekirdek kahve." },
|
||||
{ "id": 524, "name": "Çay Çeşitleri", "slug": "tea-varieties", "parentId": 522, "description": "Siyah çay, yeşil çay, bitki çayları, meyve çayları ve özel harmanlar." },
|
||||
{ "id": 525, "name": "Gazlı İçecekler", "slug": "soft-drinks", "parentId": 522, "description": "Kola, gazoz, aromalı sodalar, enerji içecekleri ve soğuk çaylar." },
|
||||
{ "id": 526, "name": "Meyve Suları & Nektarlar", "slug": "juices-nectars", "parentId": 522, "description": "Doğal meyve suları, konsantre meyve suları, taze sıkılmış meyve suları ve nektarlar." },
|
||||
{ "id": 527, "name": "Su & Maden Suyu", "slug": "water-mineral-water", "parentId": 522, "description": "Pet şişe su, damacana su, aromalı su ve doğal maden suları." },
|
||||
{ "id": 528, "name": "Alkollü İçecekler", "slug": "alcoholic-beverages", "parentId": 522, "description": "Bira, şarap, viski, votka, rakı ve diğer alkollü içecekler (Yasal düzenlemelere göre)." },
|
||||
|
||||
{ "id": 529, "name": "Organik & Özel Beslenme", "slug": "organic-special-diet", "parentId": 500, "description": "Organik ürünler, glutensiz, şekersiz, vegan ve vejetaryen gıdalar." },
|
||||
{ "id": 530, "name": "Dondurulmuş Gıdalar", "slug": "frozen-foods", "parentId": 500, "description": "Dondurulmuş sebzeler, meyveler, hazır yemekler, hamur işleri ve deniz ürünleri." },
|
||||
{ "id": 531, "name": "Bebek & Çocuk Mamaları (Gıda)", "slug": "baby-kids-food-groceries", "parentId": 500, "description": "Bebek mamaları, ek gıdalar, püreler ve çocuklara özel sağlıklı atıştırmalıklar." },
|
||||
|
||||
|
||||
{ "id": 601, "name": "Oyun Konsolları", "slug": "game-consoles", "parentId": 600, "description": "PlayStation, Xbox, Nintendo Switch, retro konsollar ve el konsolları." },
|
||||
{ "id": 602, "name": "PlayStation Konsolları", "slug": "playstation-consoles", "parentId": 601, "description": "PlayStation 5, PlayStation 4 ve önceki nesil konsollar ile özel sürümler." },
|
||||
{ "id": 603, "name": "Xbox Konsolları", "slug": "xbox-consoles", "parentId": 601, "description": "Xbox Series X/S, Xbox One ve önceki nesil konsollar ile özel sürümler." },
|
||||
{ "id": 604, "name": "Nintendo Konsolları", "slug": "nintendo-consoles", "parentId": 601, "description": "Nintendo Switch, Switch Lite, Switch OLED ve diğer Nintendo el konsolları." },
|
||||
{ "id": 605, "name": "Retro & Mini Konsollar", "slug": "retro-mini-consoles", "parentId": 601, "description": "Nostaljik oyun deneyimi sunan retro konsollar ve mini versiyonları." },
|
||||
{ "id": 606, "name": "Oyunlar", "slug": "games", "parentId": 600, "description": "Konsol oyunları, PC oyunları, dijital oyun kodları ve abonelikler." },
|
||||
{ "id": 607, "name": "PlayStation Oyunları", "slug": "playstation-games", "parentId": 606, "description": "PS5 ve PS4 için fiziksel ve dijital oyunlar; farklı tür ve indirimli fırsatlar." },
|
||||
{ "id": 608, "name": "Xbox Oyunları", "slug": "xbox-games", "parentId": 606, "description": "Xbox Series X/S ve Xbox One için fiziksel ve dijital oyunlar; Game Pass fırsatları." },
|
||||
{ "id": 609, "name": "Nintendo Oyunları", "slug": "nintendo-games", "parentId": 606, "description": "Nintendo Switch ve diğer Nintendo konsolları için oyun kartuşları ve eShop oyunları." },
|
||||
{ "id": 610, "name": "PC Oyunları", "slug": "pc-games", "parentId": 606, "description": "Steam, Epic Games, Origin ve diğer platformlar için dijital/fiziksel PC oyunları." },
|
||||
{ "id": 611, "name": "Dijital Oyun Kodları & Abonelikler", "slug": "digital-game-codes-subscriptions", "parentId": 606, "description": "PlayStation Plus, Xbox Game Pass, Nintendo eShop kodları ve oyun içi satın alımlar." },
|
||||
|
||||
{ "id": 612, "name": "Oyun Aksesuarları", "slug": "gaming-accessories", "parentId": 600, "description": "Kontrolcüler, kulaklıklar, direksiyon setleri, VR cihazları, oyun koltukları ve depolama." },
|
||||
{ "id": 613, "name": "Oyun Kontrolcüler", "slug": "game-controllers", "parentId": 612, "description": "PlayStation DualSense, Xbox Wireless Controller, Nintendo Joy-Con ve Pro Controller gibi kontrolcüler." },
|
||||
{ "id": 614, "name": "Oyun Kulaklıkları", "slug": "gaming-headsets", "parentId": 612, "description": "Surround ses, gürültü engelleme ve yüksek kaliteli mikrofonlu oyun kulaklıkları." },
|
||||
{ "id": 615, "name": "Oyun Direksiyonları & Joystickler", "slug": "gaming-wheels-joysticks", "parentId": 612, "description": "Yarış simülasyonları için direksiyon setleri ve uçuş simülasyonları için joystickler." },
|
||||
{ "id": 616, "name": "VR (Sanal Gerçeklik) Cihazları", "slug": "vr-devices", "parentId": 612, "description": "Oculus Quest, PlayStation VR gibi sanal gerçeklik başlıkları ve aksesuarları." },
|
||||
{ "id": 617, "name": "Oyun Depolama Birimleri", "slug": "gaming-storage", "parentId": 612, "description": "Konsollar ve PC için harici SSD'ler, HDD'ler ve oyun depolama kartları." },
|
||||
{ "id": 618, "name": "Oyun Koltukları & Masaları", "slug": "gaming-chairs-desks", "parentId": 600, "description": "Ergonomik oyun koltukları, oyuncu masaları ve monitör standları." },
|
||||
|
||||
|
||||
{ "id": 701, "name": "Oto Yedek Parça", "slug": "auto-spare-parts", "parentId": 700, "description": "Fren sistemleri, filtreler, motor parçaları, aydınlatma ve silecekler gibi araç yedek parçaları." },
|
||||
{ "id": 702, "name": "Fren Sistemleri", "slug": "brake-systems", "parentId": 701, "description": "Fren balatası, fren diski, fren hidroliği ve kaliperler." },
|
||||
{ "id": 703, "name": "Filtreler", "slug": "filters", "parentId": 701, "description": "Yağ filtresi, hava filtresi, polen filtresi ve yakıt filtresi." },
|
||||
{ "id": 704, "name": "Motor Parçaları", "slug": "engine-parts", "parentId": 701, "description": "Buji, ateşleme bobini, triger seti, kayışlar, contalar ve motor kulakları." },
|
||||
{ "id": 705, "name": "Oto Aydınlatma", "slug": "auto-lighting", "parentId": 701, "description": "Far ampulü, LED farlar, stop lambaları, sinyal lambaları ve sis farları." },
|
||||
{ "id": 706, "name": "Silecekler", "slug": "wipers", "parentId": 701, "description": "Ön ve arka silecekler, silecek motorları ve silecek suyu." },
|
||||
|
||||
{ "id": 707, "name": "Motor Yağları & Sıvılar", "slug": "engine-oils-fluids", "parentId": 700, "description": "Motor yağı, şanzıman yağı, antifriz, fren hidroliği ve direksiyon yağı." },
|
||||
{ "id": 708, "name": "Motor Yağları", "slug": "engine-oils", "parentId": 707, "description": "Sentetik, yarı sentetik, mineral motor yağları; farklı viskozite ve onaylara sahip." },
|
||||
{ "id": 709, "name": "Antifriz & Soğutma Sıvıları", "slug": "antifreeze-coolants", "parentId": 707, "description": "Motor soğutma sistemleri için antifriz ve soğutma sıvıları." },
|
||||
|
||||
{ "id": 710, "name": "Lastik & Jant", "slug": "tires-wheels", "parentId": 700, "description": "Yazlık, kışlık, dört mevsim lastikler, jantlar ve aksesuarları." },
|
||||
{ "id": 711, "name": "Otomobil Lastikleri", "slug": "car-tires", "parentId": 710, "description": "Yazlık, kışlık ve dört mevsim otomobil lastikleri; farklı marka ve ebatlarda." },
|
||||
{ "id": 712, "name": "Jantlar", "slug": "wheels", "parentId": 710, "description": "Çelik ve alaşım jantlar, jant kapakları ve jant temizleyiciler." },
|
||||
|
||||
{ "id": 713, "name": "Oto Bakım & Temizlik", "slug": "auto-care-cleaning", "parentId": 700, "description": "Araç yıkama, parlatma, iç ve dış temizlik ürünleri, cila ve boya koruma." },
|
||||
{ "id": 714, "name": "Dış Temizlik Ürünleri", "slug": "exterior-cleaning", "parentId": 713, "description": "Oto şampuanı, jant temizleyici, lastik parlatıcı, cam suyu ve böcek temizleyici." },
|
||||
{ "id": 715, "name": "İç Temizlik Ürünleri", "slug": "interior-cleaning", "parentId": 713, "description": "Torpidolar, koltuklar, döşemeler, kokpit temizleyiciler ve hava tazeleyiciler." },
|
||||
{ "id": 716, "name": "Cila & Boya Koruma", "slug": "polish-paint-protection", "parentId": 713, "description": "Araç cilaları, pastalar, seramik kaplama ürünleri ve boya koruyucular." },
|
||||
|
||||
{ "id": 717, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 700, "description": "Araç içi/dışı aksesuarlar, ses sistemleri, kamera, navigasyon ve oto güvenlik ürünleri." },
|
||||
{ "id": 718, "name": "Araç İçi Elektronik", "slug": "in-car-electronics", "parentId": 717, "description": "Araç içi kamera (Dashcam), multimedya sistemleri, şarj cihazları, FM transmitterler." },
|
||||
{ "id": 719, "name": "Oto Ses Sistemleri", "slug": "car-audio-systems", "parentId": 717, "description": "Teyp, hoparlör, amfi, subwoofer ve araç içi eğlence sistemleri." },
|
||||
{ "id": 720, "name": "Navigasyon Cihazları", "slug": "navigation-devices", "parentId": 717, "description": "GPS navigasyon cihazları ve harita güncellemeleri." },
|
||||
{ "id": 721, "name": "Araç İçi Düzenleyiciler", "slug": "car-organizers", "parentId": 717, "description": "Bagaj düzenleyici, koltuk arkası organizer, telefon tutucular ve çöp kutuları." },
|
||||
{ "id": 722, "name": "Oto Güvenlik & Konfor", "slug": "auto-safety-comfort", "parentId": 717, "description": "Park sensörü, alarm sistemleri, koltuk kılıfları ve direksiyon kılıfları." },
|
||||
|
||||
{ "id": 723, "name": "Motosiklet & Scooter", "slug": "motorcycles-scooters", "parentId": 700, "description": "Motosikletler, scooterlar, kasklar, ekipmanlar ve aksesuarları." },
|
||||
{ "id": 724, "name": "Motosikletler", "slug": "motorcycles", "parentId": 723, "description": "Farklı kategori ve markalarda motosiklet modelleri." },
|
||||
{ "id": 725, "name": "Motosiklet Ekipmanları", "slug": "motorcycle-gear", "parentId": 723, "description": "Kasklar, montlar, eldivenler, pantolonlar ve motosiklet botları." },
|
||||
{ "id": 726, "name": "Motosiklet Aksesuarları", "slug": "motorcycle-accessories", "parentId": 723, "description": "Motosiklet çantaları, koruyucular, zincir yağları ve kilitler." },
|
||||
|
||||
|
||||
{ "id": 801, "name": "Fitness & Kardiyo", "slug": "fitness-cardio", "parentId": 800, "description": "Ağırlıklar, koşu bantları, egzersiz bisikletleri, fitness aksesuarları ve evde egzersiz ürünleri." },
|
||||
{ "id": 802, "name": "Ağırlık & Dambıl Setleri", "slug": "weights-dumbbells", "parentId": 801, "description": "Krom, döküm ağırlıklar, ayarlanabilir dambıl setleri ve barfiks barları." },
|
||||
{ "id": 803, "name": "Kardiyo Ekipmanları", "slug": "cardio-equipment", "parentId": 801, "description": "Koşu bantları, eliptik bisikletler, egzersiz bisikletleri, kürek makineleri ve stepperlar." },
|
||||
{ "id": 804, "name": "Fitness Aksesuarları", "slug": "fitness-accessories", "parentId": 801, "description": "Yoga matları, pilates topları, direnç bantları, atlama ipleri, el yayı ve ağırlık eldivenleri." },
|
||||
{ "id": 805, "name": "Evde Egzersiz", "slug": "home-workout", "parentId": 801, "description": "Mekik aletleri, şınav barları, kapı barları ve çok fonksiyonlu egzersiz aletleri." },
|
||||
|
||||
{ "id": 806, "name": "Bisiklet", "slug": "cycling", "parentId": 800, "description": "Dağ, şehir, yol, katlanır ve elektrikli bisikletler, bisiklet aksesuarları ve giyim." },
|
||||
{ "id": 807, "name": "Bisiklet Çeşitleri", "slug": "bicycles", "parentId": 806, "description": "Yol bisikletleri, dağ bisikletleri, şehir bisikletleri, elektrikli bisikletler ve çocuk bisikletleri." },
|
||||
{ "id": 808, "name": "Bisiklet Aksesuarları", "slug": "cycling-accessories", "parentId": 806, "description": "Bisiklet kaskları, kilitler, ışıklar, pompalar, suluklar, bisiklet çantaları ve tamir kitleri." },
|
||||
{ "id": 809, "name": "Bisiklet Giyim", "slug": "cycling-apparel", "parentId": 806, "description": "Bisiklet formaları, şortları, eldivenleri, ayakkabıları ve termal giyim ürünleri." },
|
||||
|
||||
{ "id": 810, "name": "Kamp & Doğa Sporları", "slug": "camping-outdoor-sports", "parentId": 800, "description": "Çadır, uyku tulumu, kamp mobilyaları, yürüyüş ekipmanları ve outdoor giyim." },
|
||||
{ "id": 811, "name": "Çadırlar", "slug": "tents", "parentId": 810, "description": "Tek kişilik, iki kişilik, aile boyu kamp çadırları ve plaj çadırları." },
|
||||
{ "id": 812, "name": "Uyku Tulumları & Matlar", "slug": "sleeping-bags-mats", "parentId": 810, "description": "Farklı sıcaklık derecelerine uygun uyku tulumları ve kamp matları/yatakları." },
|
||||
{ "id": 813, "name": "Kamp Mobilyaları", "slug": "camping-furniture", "parentId": 810, "description": "Kamp sandalyeleri, masaları, katlanabilir dolaplar ve portatif ocaklar." },
|
||||
{ "id": 814, "name": "Yürüyüş & Trekking Ekipmanları", "slug": "hiking-trekking-gear", "parentId": 810, "description": "Sırt çantaları, yürüyüş batonları, su mataraları, pusulalar ve GPS cihazları." },
|
||||
{ "id": 815, "name": "Outdoor Giyim & Ayakkabı", "slug": "outdoor-apparel-footwear", "parentId": 810, "description": "Su geçirmez montlar, pantolonlar, termal içlikler, outdoor ayakkabıları ve botlar." },
|
||||
{ "id": 816, "name": "Kamp Aksesuarları", "slug": "camping-accessories", "parentId": 810, "description": "El fenerleri, kafa lambaları, kamp lambaları, ateş başlatıcılar ve çok amaçlı aletler." },
|
||||
|
||||
{ "id": 817, "name": "Su Sporları", "slug": "water-sports", "parentId": 800, "description": "Yüzme, dalış, sörf, kano, kürek ve plaj sporları ekipmanları." },
|
||||
{ "id": 818, "name": "Yüzme Ekipmanları", "slug": "swimming-gear", "parentId": 817, "description": "Mayo, şort, yüzme gözlüğü, bone, palet, can yeleği ve deniz yatağı." },
|
||||
{ "id": 819, "name": "Dalış & Şnorkel Ekipmanları", "slug": "diving-snorkeling-gear", "parentId": 817, "description": "Dalış maskesi, şnorkel, palet, dalış elbisesi ve dalış bilgisayarları." },
|
||||
{ "id": 820, "name": "Sörf & Kano", "slug": "surfing-kayaking", "parentId": 817, "description": "Sörf tahtaları, paddleboardlar, kanolar ve kürekler." },
|
||||
|
||||
{ "id": 821, "name": "Takım Sporları", "slug": "team-sports", "parentId": 800, "description": "Futbol, basketbol, voleybol, tenis ve diğer takım sporları ürünleri." },
|
||||
{ "id": 822, "name": "Futbol Malzemeleri", "slug": "football-gear", "parentId": 821, "description": "Futbol topu, forma, krampon, kaleci eldiveni, tekmelik ve antrenman ekipmanları." },
|
||||
{ "id": 823, "name": "Basketbol Malzemeleri", "slug": "basketball-gear", "parentId": 821, "description": "Basketbol topu, forma, şort, pota ve basketbol ayakkabıları." },
|
||||
{ "id": 824, "name": "Tenis & Raket Sporları", "slug": "tennis-racket-sports", "parentId": 821, "description": "Tenis raketi, top, tenis ayakkabısı, badminton ve masa tenisi ekipmanları." },
|
||||
|
||||
{ "id": 826, "name": "Protez & Ortez (Spor)", "slug": "sports-prosthetics-orthotics", "parentId": 800, "description": "Dizlik, bileklik, bel korsesi, dirseklik ve diğer sporcu destek ürünleri." },
|
||||
|
||||
|
||||
{ "id": 901, "name": "Bebek Bakımı", "slug": "baby-care", "parentId": 900, "description": "Bebek bezleri, ıslak mendiller, şampuanlar, cilt bakım ürünleri ve banyo setleri." },
|
||||
{ "id": 902, "name": "Bebek Bezleri", "slug": "baby-diapers", "parentId": 901, "description": "Yenidoğan, farklı beden ve külot bez seçenekleri, ekolojik bebek bezleri." },
|
||||
{ "id": 903, "name": "Islak Mendiller", "slug": "baby-wipes", "parentId": 901, "description": "Hassas ciltler için, parfümsüz, su bazlı ve doğal içerikli ıslak mendiller." },
|
||||
{ "id": 904, "name": "Bebek Şampuan & Sabun", "slug": "baby-shampoo-soap", "parentId": 901, "description": "Göz yakmayan, hipoalerjenik formüllü bebek şampuanları ve sabunları." },
|
||||
{ "id": 905, "name": "Bebek Cilt Bakımı", "slug": "baby-skincare", "parentId": 901, "description": "Bebek yağları, losyonları, pişik kremleri, güneş kremleri ve masaj yağları." },
|
||||
{ "id": 906, "name": "Bebek Banyo Ürünleri", "slug": "baby-bath-products", "parentId": 901, "description": "Bebek küvetleri, banyo termometreleri, banyo oyuncakları ve havluları." },
|
||||
|
||||
{ "id": 907, "name": "Bebek Beslenmesi", "slug": "baby-feeding", "parentId": 900, "description": "Bebek mamaları, ek gıdalar, biberonlar, emzikler, mama sandalyeleri ve beslenme aksesuarları." },
|
||||
{ "id": 908, "name": "Bebek Mamaları", "slug": "baby-formulas", "parentId": 907, "description": "Formül mamalar, devam sütleri, özel ihtiyaç mamaları ve organik bebek mamaları." },
|
||||
{ "id": 909, "name": "Ek Gıdalar & Püreler", "slug": "baby-food-purees", "parentId": 907, "description": "Meyve, sebze püreleri, tahıllı kaşık mamaları, bebek kahvaltıları ve atıştırmalıklar." },
|
||||
{ "id": 910, "name": "Biberonlar & Emzikler", "slug": "bottles-pacifiers", "parentId": 907, "description": "Farklı boy ve emzik ucu çeşitlerinde biberonlar, emzikler, emzik zincirleri ve biberon ısıtıcıları." },
|
||||
{ "id": 911, "name": "Mama Sandalyeleri", "slug": "high-chairs", "parentId": 907, "description": "Katlanabilir, ayarlanabilir, portatif mama sandalyeleri ve booster koltuklar." },
|
||||
{ "id": 912, "name": "Beslenme Aksesuarları", "slug": "feeding-accessories", "parentId": 907, "description": "Mama önlükleri, tabaklar, kaşık setleri, sterilizatörler ve mama saklama kapları." },
|
||||
|
||||
{ "id": 913, "name": "Bebek Araç Gereçleri", "slug": "baby-gear", "parentId": 900, "description": "Bebek arabaları, oto koltukları, ana kucakları, yürüteçler ve park yatakları." },
|
||||
{ "id": 914, "name": "Bebek Arabaları", "slug": "strollers", "parentId": 913, "description": "Tekli, ikiz, baston tip, travel sistem bebek arabaları ve pusetler." },
|
||||
{ "id": 915, "name": "Oto Koltukları", "slug": "car-seats", "parentId": 913, "description": "Yenidoğan, isofix, yükseltici oto koltukları; farklı yaş ve ağırlık grupları için." },
|
||||
{ "id": 916, "name": "Ana Kucakları & Kangurular", "slug": "bouncers-carriers", "parentId": 913, "description": "Bebekleri taşımak için ana kucakları, salıncaklar ve ergonomik kangurular." },
|
||||
{ "id": 917, "name": "Yürüteçler & Aktivite Merkezleri", "slug": "walkers-activity-centers", "parentId": 913, "description": "Bebek yürüteçleri, aktivite masaları ve oyun merkezleri." },
|
||||
{ "id": 918, "name": "Park Yatakları & Seyahat Yatakları", "slug": "playards-travel-cribs", "parentId": 913, "description": "Katlanabilir park yatakları, seyahat yatakları ve oyun parkları." },
|
||||
|
||||
{ "id": 919, "name": "Oyuncaklar & Eğitici Ürünler", "slug": "toys-educational", "parentId": 900, "description": "Bebek oyuncakları, eğitici oyuncaklar, kutu oyunları, yapbozlar ve dış mekan oyuncakları." },
|
||||
{ "id": 920, "name": "Bebek Oyuncakları", "slug": "baby-toys", "parentId": 919, "description": "Diş kaşıyıcı, çıngırak, uyku arkadaşı, oyun halısı, dönence ve aktivite küpleri." },
|
||||
{ "id": 921, "name": "Eğitici Oyuncaklar", "slug": "educational-toys", "parentId": 919, "description": "Ahşap oyuncaklar, yapım setleri, zeka geliştirici oyunlar, bilim kitleri ve robotik oyuncaklar." },
|
||||
{ "id": 922, "name": "Kutu Oyunları", "slug": "board-games", "parentId": 919, "description": "Çocuk ve aile için strateji, bilgi, şans oyunları ve klasik kutu oyunları." },
|
||||
{ "id": 923, "name": "Yapbozlar", "slug": "puzzles", "parentId": 919, "description": "Çocuk ve yetişkinler için farklı parça sayılarında yapbozlar, 3D yapbozlar." },
|
||||
{ "id": 924, "name": "Dış Mekan Oyuncakları", "slug": "outdoor-toys", "parentId": 919, "description": "Salıncak, kaydırak, kum havuzu, bisiklet, scooter, top ve bahçe oyuncakları." },
|
||||
|
||||
{ "id": 925, "name": "Çocuk Giyim & Ayakkabı", "slug": "kids-clothing-shoes", "parentId": 900, "description": "Bebek, çocuk ve genç giyim, ayakkabı, dış giyim ve aksesuarlar." },
|
||||
{ "id": 926, "name": "Bebek Giyim", "slug": "baby-clothing", "parentId": 925, "description": "Tulumlar, zıbınlar, bodyler, elbiseler, pantolonlar ve dış giyim ürünleri." },
|
||||
{ "id": 927, "name": "Çocuk Giyim", "slug": "kids-clothing", "parentId": 925, "description": "Elbiseler, tişörtler, pantolonlar, montlar, eşofman takımları ve kostümler." },
|
||||
{ "id": 928, "name": "Çocuk Ayakkabı", "slug": "kids-footwear", "parentId": 925, "description": "Spor ayakkabılar, sandaletler, botlar, ev ayakkabıları ve okul ayakkabıları." },
|
||||
{ "id": 929, "name": "Çocuk Aksesuarları", "slug": "kids-accessories", "parentId": 925, "description": "Çanta, şapka, eldiven, atkı, saç aksesuarları ve kemerler." },
|
||||
|
||||
{ "id": 930, "name": "Bebek Odası Mobilyaları", "slug": "nursery-furniture", "parentId": 900, "description": "Bebek yatakları, beşikler, şifonyerler, gardıroplar ve emzirme koltukları." },
|
||||
{ "id": 931, "name": "Bebek Yatakları & Beşikler", "slug": "cots-cribs", "parentId": 930, "description": "Sabit, sallanır beşikler, anne yanı yatakları ve büyüyebilen bebek yatakları." },
|
||||
{ "id": 932, "name": "Şifonyerler & Gardıroplar", "slug": "dressers-wardrobes", "parentId": 930, "description": "Bebek odası şifonyerleri, gardıroplar ve alt değiştirme üniteleri." },
|
||||
|
||||
|
||||
{ "id": 1001, "name": "Kitaplar", "slug": "books", "parentId": 1000, "description": "Roman, kişisel gelişim, tarih, bilim, çocuk kitapları, e-kitaplar ve sesli kitaplar." },
|
||||
{ "id": 1002, "name": "Edebiyat & Roman", "slug": "literature-novels", "parentId": 1001, "description": "Türk ve dünya edebiyatından klasikler, çağdaş romanlar, öykü ve şiir kitapları." },
|
||||
{ "id": 1003, "name": "Kişisel Gelişim & Psikoloji", "slug": "personal-development-psychology", "parentId": 1001, "description": "Motivasyon, farkındalık, psikoloji, felsefe, iş ve kariyer gelişim kitapları." },
|
||||
{ "id": 1004, "name": "Tarih & Bilim", "slug": "history-science", "parentId": 1001, "description": "Tarih kitapları, bilimsel araştırmalar, popüler bilim ve belgesel kitapları." },
|
||||
{ "id": 1005, "name": "Çocuk & Genç Kitapları", "slug": "kids-young-adult-books", "parentId": 1001, "description": "Masal, hikaye, boyama kitapları, eğitici çocuk kitapları ve gençlik romanları." },
|
||||
{ "id": 1006, "name": "E-Kitaplar & Sesli Kitaplar", "slug": "ebooks-audiobooks", "parentId": 1001, "description": "Dijital formatta kitaplar, e-kitap okuyucular ve sesli kitap platformları abonelikleri." },
|
||||
{ "id": 1007, "name": "Ders Kitapları & Eğitim", "slug": "textbooks-education", "parentId": 1001, "description": "Okul müfredatına uygun ders kitapları, yardımcı kaynaklar, test kitapları ve yabancı dil eğitim kitapları." },
|
||||
{ "id": 1008, "name": "Hobi & Sanat Kitapları", "slug": "hobby-art-books", "parentId": 1001, "description": "Yemek tarifleri, el işleri, resim, fotoğrafçılık ve diğer hobi alanlarına yönelik kitaplar." },
|
||||
|
||||
{ "id": 1009, "name": "Filmler & TV Dizileri", "slug": "movies-tv-series", "parentId": 1000, "description": "DVD, Blu-ray filmler, dizi kutu setleri ve dijital platform abonelikleri." },
|
||||
{ "id": 1010, "name": "DVD & Blu-ray Filmler", "slug": "dvd-blu-ray-movies", "parentId": 1009, "description": "Farklı türlerde film ve dizi koleksiyonları, 4K Ultra HD filmler." },
|
||||
{ "id": 1011, "name": "Akış Platformları Abonelikleri", "slug": "streaming-subscriptions", "parentId": 1009, "description": "Netflix, Disney+, Amazon Prime Video, Exxen gibi video akış platformu üyelikleri." },
|
||||
|
||||
{ "id": 1012, "name": "Müzik", "slug": "music", "parentId": 1000, "description": "CD'ler, plaklar, müzik aletleri ve dijital müzik abonelikleri." },
|
||||
{ "id": 1013, "name": "CD'ler & Plaklar", "slug": "cds-vinyls", "parentId": 1012, "description": "Farklı sanatçı ve müzik türlerinde CD albümler, vinyl plaklar ve kasetler." },
|
||||
{ "id": 1014, "name": "Müzik Enstrümanları", "slug": "musical-instruments", "parentId": 1012, "description": "Gitarlar, piyano, bateri, keman, flüt ve diğer müzik aletleri." },
|
||||
{ "id": 1015, "name": "Dijital Müzik Abonelikleri", "slug": "digital-music-subscriptions", "parentId": 1012, "description": "Spotify, Apple Music, YouTube Music gibi müzik platformu üyelikleri." },
|
||||
|
||||
{ "id": 1016, "name": "Dergi & Gazete", "slug": "magazines-newspapers", "parentId": 1000, "description": "Popüler dergiler, sektörel yayınlar, çocuk dergileri ve gazete abonelikleri." },
|
||||
{ "id": 1017, "name": "Çizgi Roman & Manga", "slug": "comics-manga", "parentId": 1000, "description": "Türkçe ve yabancı çizgi romanlar, manga serileri ve grafik romanlar." },
|
||||
|
||||
|
||||
{ "id": 1101, "name": "Yazım Gereçleri", "slug": "writing-instruments", "parentId": 1100, "description": "Kalemler, uçlar, mürekkepler, fosforlu kalemler ve kalem setleri." },
|
||||
{ "id": 1102, "name": "Kalemler", "slug": "pens", "parentId": 1101, "description": "Tükenmez kalem, jel kalem, kurşun kalem, dolma kalem, roller kalem ve markörler." },
|
||||
{ "id": 1103, "name": "Defter & Ajanda", "slug": "notebooks-planners", "parentId": 1100, "description": "Çizgili, kareli, defterler, ajandalar, not defterleri, spiralli defterler ve günlükler." },
|
||||
{ "id": 1104, "name": "Kağıt Ürünleri", "slug": "paper-products", "parentId": 1100, "description": "A4 kağıt, renkli kağıtlar, kartonlar, zarflar, etiketler ve bloknotlar." },
|
||||
{ "id": 1105, "name": "A4 Kağıdı", "slug": "a4-paper", "parentId": 1104, "description": "Yazıcı ve fotokopi için farklı gramaj ve kalitelerde A4 kağıdı paketleri." },
|
||||
{ "id": 1106, "name": "Ofis Malzemeleri", "slug": "office-supplies", "parentId": 1100, "description": "Zımba, delgeç, ataş, klasör, dosya, bant, makas, yapıştırıcı ve masaüstü düzenleyiciler." },
|
||||
{ "id": 1107, "name": "Hesap Makineleri", "slug": "calculators", "parentId": 1100, "description": "Bilimsel hesap makineleri, finansal hesap makineleri ve masaüstü hesap makineleri." },
|
||||
{ "id": 1108, "name": "Okul Çantaları & Malzemeleri", "slug": "school-bags-supplies", "parentId": 1100, "description": "Sırt çantaları, beslenme çantaları, kalem kutuları, okul setleri ve eğitim gereçleri." },
|
||||
{ "id": 1109, "name": "Sanat & Hobi Malzemeleri", "slug": "art-craft-supplies", "parentId": 1100, "description": "Boyalar, fırçalar, tuval, çizim setleri, modelleme malzemeleri ve el işi kitleri." },
|
||||
|
||||
|
||||
{ "id": 1201, "name": "İnternet & İletişim", "slug": "internet-communication", "parentId": 1200, "description": "Genişbant internet, mobil hat tarifeleri, ev telefonu paketleri ve uydu/TV yayın hizmetleri." },
|
||||
{ "id": 1202, "name": "Genişbant İnternet Paketleri", "slug": "broadband-packages", "parentId": 1201, "description": "Fiber, ADSL, VDSL internet servis sağlayıcı fırsatları ve kampanyaları." },
|
||||
{ "id": 1203, "name": "Mobil Tarife & Paketler", "slug": "mobile-plans", "parentId": 1201, "description": "Farklı operatörlerin mobil internet, konuşma ve SMS paketleri, faturalı/faturasız hatlar." },
|
||||
{ "id": 1204, "name": "Ev Telefonu Hizmetleri", "slug": "home-phone-services", "parentId": 1201, "description": "Sabit hat ve VoIP telefon hizmeti fırsatları." },
|
||||
{ "id": 1205, "name": "Uydu & TV Yayın Hizmetleri", "slug": "satellite-tv-services", "parentId": 1201, "description": "Digiturk, D-Smart, Tivibu gibi platformların üyelik ve paket fırsatları." },
|
||||
|
||||
{ "id": 1206, "name": "Seyahat Fırsatları", "slug": "travel-deals", "parentId": 1200, "description": "Uçak bileti, otel, tatil paketleri, araç kiralama, kruvaziyer turları ve yurt dışı turlar." },
|
||||
{ "id": 1207, "name": "Uçak Biletleri", "slug": "flight-tickets", "parentId": 1206, "description": "Yurt içi ve yurt dışı ucuz uçak bileti fırsatları, kampanyalı biletler." },
|
||||
{ "id": 1208, "name": "Otel & Konaklama", "slug": "hotels-accommodation", "parentId": 1206, "description": "Şehir otelleri, tatil köyleri, butik oteller, pansiyonlar ve daire kiralama." },
|
||||
{ "id": 1209, "name": "Tatil Paketleri", "slug": "holiday-packages", "parentId": 1206, "description": "Erken rezervasyon ve son dakika tatil paketleri, her şey dahil konseptler." },
|
||||
{ "id": 1210, "name": "Araç Kiralama", "slug": "car-rental", "parentId": 1206, "description": "Yurt içi ve yurt dışı farklı araç modelleri için kiralama hizmetleri." },
|
||||
{ "id": 1211, "name": "Kruvaziyer Turları", "slug": "cruises", "parentId": 1206, "description": "Akdeniz, Ege, Karayipler gibi farklı rotalarda gemi turları ve cruise fırsatları." },
|
||||
{ "id": 1212, "name": "Yurt Dışı Turlar", "slug": "international-tours", "parentId": 1206, "description": "Avrupa, Asya, Amerika turları, kültür turları ve macera turları." },
|
||||
|
||||
{ "id": 1213, "name": "Deneyimler & Etkinlikler", "slug": "experiences-events", "parentId": 1200, "description": "Konser, tiyatro bileti, workshop, spa, spor etkinlikleri ve macera aktiviteleri." },
|
||||
{ "id": 1214, "name": "Restoran & Yemek Fırsatları", "slug": "restaurant-dining-deals", "parentId": 1200, "description": "İndirimli menüler, yemek kuponları, popüler restoranlarda özel kampanyalar." },
|
||||
{ "id": 1215, "name": "Eğitim & Kurslar", "slug": "education-courses", "parentId": 1200, "description": "Online eğitimler, dil kursları, hobi atölyeleri, sertifika programları ve üniversite dersleri." },
|
||||
{ "id": 1216, "name": "Ev Hizmetleri", "slug": "home-services", "parentId": 1200, "description": "Temizlik, tesisat, tadilat, bakım, onarım, taşımacılık ve haşere kontrol hizmetleri." },
|
||||
{ "id": 1217, "name": "Sağlık & Güzellik Hizmetleri", "slug": "health-beauty-services", "parentId": 1200, "description": "Spa, masaj, cilt bakımı, saç kesimi, manikür/pedikür, lazer epilasyon fırsatları." },
|
||||
|
||||
|
||||
{ "id": 1301, "name": "Vitaminler & Takviyeler", "slug": "vitamins-supplements", "parentId": 1300, "description": "Multivitaminler, D vitamini, omega-3, probiyotikler, mineral ve bitkisel takviyeler." },
|
||||
{ "id": 1302, "name": "Multivitaminler", "slug": "multivitamins", "parentId": 1301, "description": "Genel sağlık, bağışıklık sistemi, enerji ve zindelik için multivitamin kompleksleri." },
|
||||
{ "id": 1303, "name": "Mineraller", "slug": "minerals", "parentId": 1301, "description": "Çinko, magnezyum, demir, kalsiyum, selenyum gibi mineral takviyeleri." },
|
||||
{ "id": 1304, "name": "Bitkisel Takviyeler", "slug": "herbal-supplements", "parentId": 1301, "description": "Ginseng, zerdeçal, ekinezya, propolis, yeşil çay ekstresi gibi bitkisel destek ürünleri." },
|
||||
{ "id": 1305, "name": "Probiyotikler & Sindirim", "slug": "probiotics-digestion", "parentId": 1301, "description": "Sindirim sistemi sağlığı ve bağırsak florası desteği için probiyotik ve prebiyotik takviyeler." },
|
||||
{ "id": 1306, "name": "Omega-3 & Balık Yağı", "slug": "omega-3-fish-oil", "parentId": 1301, "description": "Kalp, beyin ve göz sağlığı için balık yağı ve diğer Omega-3 takviyeleri." },
|
||||
|
||||
{ "id": 1307, "name": "Sporcu Besinleri", "slug": "sports-nutrition", "parentId": 1300, "description": "Protein tozları, kreatin, BCAA, amino asitler, enerji barları ve sporcu içecekleri." },
|
||||
{ "id": 1308, "name": "Protein Tozları", "slug": "protein-powders", "parentId": 1307, "description": "Whey protein, kazein, vegan protein, izole protein tozları; kas gelişimi ve onarımı için." },
|
||||
{ "id": 1309, "name": "Kreatin & Amino Asitler", "slug": "creatine-amino-acids", "parentId": 1307, "description": "Kreatin, BCAA, glutamin, arjinin gibi sporcu performansını destekleyici takviyeler." },
|
||||
{ "id": 1310, "name": "Enerji & Performans Ürünleri", "slug": "energy-performance", "parentId": 1307, "description": "Pre-workout, enerji jelleri, karbonhidrat tozları ve sporcu içecekleri." },
|
||||
|
||||
{ "id": 1311, "name": "İlk Yardım & Medikal Malzemeler", "slug": "first-aid-medical", "parentId": 1300, "description": "Yara bandı, gazlı bez, antiseptik, ağrı kesici, ateş ölçer ve tansiyon aleti." },
|
||||
{ "id": 1312, "name": "Ağrı Kesiciler & Reçetesiz İlaçlar", "slug": "pain-relief-otc-meds", "parentId": 1311, "description": "Parasetamol, ibuprofen içeren reçetesiz ağrı kesiciler, soğuk algınlığı ve grip ilaçları." },
|
||||
{ "id": 1313, "name": "Yara Bakım Ürünleri", "slug": "wound-care", "parentId": 1311, "description": "Yara bantları, gazlı bezler, steril pedler, antiseptik spreyler, yara kremleri ve sargı bezleri." },
|
||||
{ "id": 1314, "name": "Ateş Ölçerler & Tansiyon Aletleri", "slug": "thermometers-bp-monitors", "parentId": 1311, "description": "Dijital ateş ölçerler, temassız termometreler, manuel ve dijital tansiyon aletleri." },
|
||||
{ "id": 1315, "name": "Tıbbi Cihazlar & Ortezler", "slug": "medical-devices-orthotics", "parentId": 1311, "description": "Nebulizatörler, şeker ölçüm cihazları, dizlik, bileklik, bel korsesi ve destek ürünleri." },
|
||||
|
||||
{ "id": 1316, "name": "Göz Sağlığı Ürünleri", "slug": "eye-health-products", "parentId": 1300, "description": "Kontakt lens, lens solüsyonları, göz damlaları ve optik gözlükler." },
|
||||
{ "id": 1317, "name": "Kontakt Lensler", "slug": "contact-lenses", "parentId": 1316, "description": "Günlük, aylık, yıllık kontakt lensler, astigmatlı lensler ve renkli lensler." },
|
||||
{ "id": 1318, "name": "Lens Solüsyonları", "slug": "lens-solutions", "parentId": 1316, "description": "Tüm lens türleri için temizleme, dezenfekte etme ve saklama solüsyonları." },
|
||||
{ "id": 1319, "name": "Optik Gözlükler & Çerçeveler", "slug": "optical-glasses-frames", "parentId": 1316, "description": "Reçeteli ve reçetesiz optik gözlük çerçeveleri, okuma gözlükleri." },
|
||||
|
||||
{ "id": 1320, "name": "Zayıflama & Diyet Ürünleri", "slug": "weight-loss-diet", "parentId": 1300, "description": "Zayıflama çayları, takviyeleri, diyet yemekleri, protein barları ve shake'ler." },
|
||||
{ "id": 1321, "name": "Ağız & Diş Sağlığı (Gelişmiş)", "slug": "oral-dental-health-advanced", "parentId": 1300, "description": "Elektrikli diş fırçaları, ağız duşları, diş beyazlatıcı ürünler ve profesyonel ağız bakım setleri." },
|
||||
|
||||
{ "id": 1401, "name": "Kedi Ürünleri", "slug": "cat-supplies", "parentId": 1400, "description": "Kedi mamaları, kumları, oyuncakları, bakım ürünleri, yatakları ve taşıma çantaları." },
|
||||
{ "id": 1402, "name": "Kedi Mamaları", "slug": "cat-food", "parentId": 1401, "description": "Kuru ve yaş kedi mamaları, özel diyet mamaları, yavru/yetişkin mamaları ve ödül mamaları." },
|
||||
{ "id": 1403, "name": "Kedi Kumları & Tuvaletleri", "slug": "cat-litter-trays", "parentId": 1401, "description": "Topaklanan, silika, bitkisel kedi kumları, kedi tuvaletleri ve kum kürekleri." },
|
||||
{ "id": 1404, "name": "Kedi Oyuncakları", "slug": "cat-toys", "parentId": 1401, "description": "Tüy topları, lazer oyuncaklar, interaktif oyuncaklar, tırmalama tahtaları ve aktivite merkezleri." },
|
||||
{ "id": 1405, "name": "Kedi Bakım Ürünleri", "slug": "cat-grooming", "parentId": 1401, "description": "Kedi şampuanları, taraklar, tüy toplayıcılar, tırnak makasları ve pire damlaları." },
|
||||
{ "id": 1406, "name": "Kedi Yatakları & Taşıma", "slug": "cat-beds-carriers", "parentId": 1401, "description": "Kedi yatakları, minderler, seyahat çantaları, taşıma kafesleri ve kedi evleri." },
|
||||
|
||||
{ "id": 1407, "name": "Köpek Ürünleri", "slug": "dog-supplies", "parentId": 1400, "description": "Köpek mamaları, tasmaları, yatakları, oyuncakları, bakım ürünleri ve eğitim malzemeleri." },
|
||||
{ "id": 1408, "name": "Köpek Mamaları", "slug": "dog-food", "parentId": 1407, "description": "Kuru ve yaş köpek mamaları, özel diyet mamaları, yavru/yetişkin mamaları ve ödül mamaları." },
|
||||
{ "id": 1409, "name": "Köpek Tasmaları & Yaka", "slug": "dog-collars-leashes", "parentId": 1407, "description": "Yürüyüş tasmaları, eğitim tasmaları, göğüs tasması, boyunluklar ve yaka aksesuarları." },
|
||||
{ "id": 1410, "name": "Köpek Yatakları & Kulübeleri", "slug": "dog-beds-kennels", "parentId": 1407, "description": "Köpek yatakları, minderler, seyahat kafesleri, kulübeler ve bahçe evleri." },
|
||||
{ "id": 1411, "name": "Köpek Oyuncakları", "slug": "dog-toys", "parentId": 1407, "description": "Çiğneme oyuncakları, top, frizbi, interaktif oyuncaklar ve zeka geliştirici oyuncaklar." },
|
||||
{ "id": 1412, "name": "Köpek Bakım Ürünleri", "slug": "dog-grooming", "parentId": 1407, "description": "Köpek şampuanları, taraklar, fırçalar, tırnak makasları ve pati bakım ürünleri." },
|
||||
{ "id": 1413, "name": "Köpek Eğitim Malzemeleri", "slug": "dog-training-supplies", "parentId": 1407, "description": "Eğitim ödülleri, tıkırdatıcılar, eğitim setleri ve engeller." },
|
||||
|
||||
{ "id": 1414, "name": "Küçük Evcil Hayvan Ürünleri", "slug": "small-pet-supplies", "parentId": 1400, "description": "Kafesler, mamalar, altlıklar ve oyuncaklar (kuş, hamster, tavşan, balık vb.)." },
|
||||
{ "id": 1415, "name": "Kuş Ürünleri", "slug": "bird-supplies", "parentId": 1414, "description": "Kuş kafesleri, yemler, suluklar, oyuncaklar ve kuş kumları." },
|
||||
{ "id": 1416, "name": "Hamster & Kemirgen Ürünleri", "slug": "hamster-rodent-supplies", "parentId": 1414, "description": "Hamster kafesleri, yemler, altlıklar, tüneller ve oyuncaklar." },
|
||||
{ "id": 1417, "name": "Akvaryum & Balık Ürünleri", "slug": "aquarium-fish-supplies", "parentId": 1414, "description": "Akvaryumlar, balık yemleri, filtreler, ısıtıcılar, aydınlatma ve akvaryum dekorları." },
|
||||
|
||||
{ "id": 1501, "name": "Mağaza Hediye Kartları", "slug": "retailer-gift-cards", "parentId": 1500, "description": "Büyük perakende zincirleri, online mağazalar ve marka mağazaları için hediye kartları." },
|
||||
{ "id": 1502, "name": "Deneyim Hediye Kartları", "slug": "experience-gift-cards", "parentId": 1500, "description": "Spa, masaj, macera parkı, workshop, uçuş deneyimi gibi özel deneyimler için hediye kartları." },
|
||||
{ "id": 1503, "name": "Restoran Hediye Kartları", "slug": "restaurant-gift-cards", "parentId": 1500, "description": "Zincir restoranlar, kafeler ve popüler yemek mekanları için hediye kartları." },
|
||||
{ "id": 1504, "name": "Dijital Hediye Kartları", "slug": "digital-gift-cards", "parentId": 1500, "description": "Çeşitli platform ve mağazalar için dijital olarak gönderilebilir hediye kodları ve e-hediye kartları." },
|
||||
{ "id": 1505, "name": "İndirim Kuponları & Fırsat Kodları", "slug": "discount-vouchers-codes", "parentId": 1500, "description": "Farklı ürün ve hizmetlerde geçerli indirim, kampanya ve promosyon kodları." },
|
||||
|
||||
|
||||
{ "id": 1601, "name": "Kredi Kartları", "slug": "credit-cards", "parentId": 1600, "description": "Farklı bankaların kredi kartı teklifleri, avantajları, puan kampanyaları ve özel indirimler." },
|
||||
{ "id": 1602, "name": "Bankacılık Hizmetleri", "slug": "banking-services", "parentId": 1600, "description": "Yeni hesap açma, mevduat faizleri, yatırım ürünleri ve diğer bankacılık fırsatları." },
|
||||
{ "id": 1603, "name": "Sigorta Teklifleri", "slug": "insurance-offers", "parentId": 1600, "description": "Araç, konut, seyahat, sağlık, hayat ve tamamlayıcı sağlık sigortası kampanyaları." },
|
||||
{ "id": 1604, "name": "Araç Sigortası", "slug": "car-insurance", "parentId": 1603, "description": "Kasko ve zorunlu trafik sigortası teklifleri; farklı sigorta şirketlerinden karşılaştırmalı fiyatlar." },
|
||||
{ "id": 1605, "name": "Konut Sigortası", "slug": "home-insurance", "parentId": 1603, "description": "DASK (Zorunlu Deprem Sigortası) ve konut sigortası poliçeleri, ek teminatlar." },
|
||||
{ "id": 1606, "name": "Seyahat Sigortası", "slug": "travel-insurance", "parentId": 1603, "description": "Yurt içi ve yurt dışı seyahat sağlık sigortası, seyahat iptali teminatları." },
|
||||
{ "id": 1607, "name": "Sağlık & Hayat Sigortası", "slug": "health-life-insurance", "parentId": 1603, "description": "Özel sağlık sigortası, tamamlayıcı sağlık sigortası, hayat sigortası ve bireysel emeklilik ürünleri." },
|
||||
{ "id": 1608, "name": "Krediler & Mortgage", "slug": "loans-mortgages", "parentId": 1600, "description": "İhtiyaç kredisi, konut kredisi, taşıt kredisi ve mortgage faiz oranları ve başvuru fırsatları." }
|
||||
{ "id": 0, "name": "Undefined", "slug": "undefined", "parentId": null },
|
||||
|
||||
{ "id": 1, "name": "Elektronik", "slug": "electronics", "parentId": 0 },
|
||||
{ "id": 2, "name": "Kozmetik", "slug": "beauty", "parentId": 0 },
|
||||
{ "id": 3, "name": "Gıda", "slug": "food", "parentId": 0 },
|
||||
{ "id": 4, "name": "Oto", "slug": "auto", "parentId": 0 },
|
||||
{ "id": 5, "name": "Ev & Bahçe", "slug": "home-garden", "parentId": 0 },
|
||||
|
||||
{ "id": 6, "name": "Bilgisayar", "slug": "computers", "parentId": 1 },
|
||||
{ "id": 7, "name": "PC Bileşenleri", "slug": "pc-components", "parentId": 6 },
|
||||
{ "id": 8, "name": "RAM", "slug": "pc-ram", "parentId": 7 },
|
||||
{ "id": 9, "name": "SSD", "slug": "pc-ssd", "parentId": 7 },
|
||||
{ "id": 10, "name": "CPU", "slug": "pc-cpu", "parentId": 7 },
|
||||
{ "id": 11, "name": "GPU", "slug": "pc-gpu", "parentId": 7 },
|
||||
|
||||
{ "id": 12, "name": "Bilgisayar Aksesuarları", "slug": "pc-peripherals", "parentId": 6 },
|
||||
{ "id": 13, "name": "Klavye", "slug": "pc-keyboard", "parentId": 12 },
|
||||
{ "id": 14, "name": "Mouse", "slug": "pc-mouse", "parentId": 12 },
|
||||
{ "id": 15, "name": "Monitör", "slug": "pc-monitor", "parentId": 6 },
|
||||
|
||||
{ "id": 16, "name": "Makyaj", "slug": "beauty-makeup", "parentId": 2 },
|
||||
{ "id": 17, "name": "Ruj", "slug": "beauty-lipstick", "parentId": 16 },
|
||||
{ "id": 18, "name": "Fondöten", "slug": "beauty-foundation", "parentId": 16 },
|
||||
{ "id": 19, "name": "Maskara", "slug": "beauty-mascara", "parentId": 16 },
|
||||
|
||||
{ "id": 20, "name": "Cilt Bakımı", "slug": "beauty-skincare", "parentId": 2 },
|
||||
{ "id": 21, "name": "Nemlendirici", "slug": "beauty-moisturizer", "parentId": 20 },
|
||||
|
||||
{ "id": 22, "name": "Atıştırmalık", "slug": "food-snacks", "parentId": 3 },
|
||||
{ "id": 23, "name": "Çiğköfte", "slug": "food-cigkofte", "parentId": 22 },
|
||||
|
||||
{ "id": 24, "name": "İçecek", "slug": "food-beverages", "parentId": 3 },
|
||||
{ "id": 25, "name": "Kahve", "slug": "food-coffee", "parentId": 24 },
|
||||
|
||||
{ "id": 26, "name": "Yağlar", "slug": "auto-oils", "parentId": 4 },
|
||||
{ "id": 27, "name": "Motor Yağı", "slug": "auto-engine-oil", "parentId": 26 },
|
||||
|
||||
{ "id": 28, "name": "Oto Parçaları", "slug": "auto-parts", "parentId": 4 },
|
||||
{ "id": 29, "name": "Fren Balatası", "slug": "auto-brake-pads", "parentId": 28 },
|
||||
|
||||
{ "id": 30, "name": "Bahçe", "slug": "home-garden-garden", "parentId": 5 },
|
||||
{ "id": 31, "name": "Sulama", "slug": "garden-irrigation", "parentId": 30 },
|
||||
|
||||
{ "id": 32, "name": "Telefon & Aksesuarları", "slug": "phone", "parentId": 1 },
|
||||
{ "id": 33, "name": "Akıllı Telefon", "slug": "phone-smartphone", "parentId": 32 },
|
||||
{ "id": 34, "name": "Telefon Kılıfı", "slug": "phone-case", "parentId": 32 },
|
||||
{ "id": 35, "name": "Ekran Koruyucu", "slug": "phone-screen-protector", "parentId": 32 },
|
||||
{ "id": 36, "name": "Şarj & Kablo", "slug": "phone-charging", "parentId": 32 },
|
||||
{ "id": 37, "name": "Powerbank", "slug": "phone-powerbank", "parentId": 32 },
|
||||
|
||||
{ "id": 38, "name": "Giyilebilir Teknoloji", "slug": "wearables", "parentId": 1 },
|
||||
{ "id": 39, "name": "Akıllı Saat", "slug": "wearables-smartwatch", "parentId": 38 },
|
||||
{ "id": 40, "name": "Akıllı Bileklik", "slug": "wearables-band", "parentId": 38 },
|
||||
|
||||
{ "id": 41, "name": "Ses & Audio", "slug": "audio", "parentId": 1 },
|
||||
{ "id": 42, "name": "Kulaklık", "slug": "audio-headphones", "parentId": 41 },
|
||||
{ "id": 43, "name": "TWS Kulaklık", "slug": "audio-tws", "parentId": 42 },
|
||||
{ "id": 44, "name": "Bluetooth Hoparlör", "slug": "audio-bt-speaker", "parentId": 41 },
|
||||
{ "id": 45, "name": "Soundbar", "slug": "audio-soundbar", "parentId": 41 },
|
||||
{ "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": 49, "name": "Televizyon", "slug": "tv", "parentId": 48 },
|
||||
{ "id": 50, "name": "Projeksiyon", "slug": "projector", "parentId": 48 },
|
||||
{ "id": 51, "name": "Medya Oynatıcı", "slug": "tv-media-player", "parentId": 48 },
|
||||
{ "id": 52, "name": "TV Aksesuarları", "slug": "tv-accessories", "parentId": 48 },
|
||||
{ "id": 53, "name": "Uydu Alıcısı / Receiver", "slug": "tv-receiver", "parentId": 48 },
|
||||
|
||||
{ "id": 54, "name": "Oyun Konsolları", "slug": "console", "parentId": 1 },
|
||||
{ "id": 55, "name": "PlayStation", "slug": "console-playstation", "parentId": 54 },
|
||||
{ "id": 56, "name": "Xbox", "slug": "console-xbox", "parentId": 54 },
|
||||
{ "id": 57, "name": "Nintendo", "slug": "console-nintendo", "parentId": 54 },
|
||||
{ "id": 58, "name": "Oyunlar (Konsol)", "slug": "console-games", "parentId": 54 },
|
||||
{ "id": 59, "name": "Konsol Aksesuarları", "slug": "console-accessories", "parentId": 54 },
|
||||
|
||||
{ "id": 60, "name": "Kamera & Fotoğraf", "slug": "camera", "parentId": 1 },
|
||||
{ "id": 61, "name": "Fotoğraf Makinesi", "slug": "camera-photo", "parentId": 60 },
|
||||
{ "id": 62, "name": "Aksiyon Kamera", "slug": "camera-action", "parentId": 60 },
|
||||
{ "id": 63, "name": "Lens", "slug": "camera-lens", "parentId": 60 },
|
||||
{ "id": 64, "name": "Tripod", "slug": "camera-tripod", "parentId": 60 },
|
||||
|
||||
{ "id": 65, "name": "Akıllı Ev", "slug": "smart-home", "parentId": 1 },
|
||||
{ "id": 66, "name": "Güvenlik Kamerası", "slug": "smart-security-camera", "parentId": 65 },
|
||||
{ "id": 67, "name": "Akıllı Priz", "slug": "smart-plug", "parentId": 65 },
|
||||
{ "id": 68, "name": "Akıllı Ampul", "slug": "smart-bulb", "parentId": 65 },
|
||||
{ "id": 69, "name": "Akıllı Sensör", "slug": "smart-sensor", "parentId": 65 },
|
||||
|
||||
{ "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": 76, "name": "Yazıcı", "slug": "pc-printer", "parentId": 75 },
|
||||
{ "id": 77, "name": "Toner & Kartuş", "slug": "pc-ink-toner", "parentId": 75 },
|
||||
{ "id": 78, "name": "Tarayıcı", "slug": "pc-scanner", "parentId": 75 },
|
||||
|
||||
{ "id": 79, "name": "Dizüstü Bilgisayar", "slug": "pc-laptop", "parentId": 6 },
|
||||
{ "id": 80, "name": "Masaüstü Bilgisayar", "slug": "pc-desktop", "parentId": 6 },
|
||||
{ "id": 81, "name": "Tablet", "slug": "pc-tablet", "parentId": 6 },
|
||||
|
||||
{ "id": 82, "name": "Depolama", "slug": "pc-storage", "parentId": 6 },
|
||||
{ "id": 83, "name": "Harici Disk", "slug": "pc-external-drive", "parentId": 82 },
|
||||
{ "id": 84, "name": "USB Flash", "slug": "pc-usb-drive", "parentId": 82 },
|
||||
{ "id": 85, "name": "NAS", "slug": "pc-nas", "parentId": 82 },
|
||||
|
||||
{ "id": 86, "name": "Webcam", "slug": "pc-webcam", "parentId": 12 },
|
||||
{ "id": 87, "name": "Hoparlör (PC)", "slug": "pc-speaker", "parentId": 12 },
|
||||
{ "id": 88, "name": "Mikrofon (PC)", "slug": "pc-mic", "parentId": 12 },
|
||||
{ "id": 89, "name": "Mousepad", "slug": "pc-mousepad", "parentId": 12 },
|
||||
{ "id": 90, "name": "Dock / USB Hub", "slug": "pc-dock-hub", "parentId": 12 },
|
||||
{ "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": 94, "name": "Güç Kaynağı (PSU)", "slug": "pc-psu", "parentId": 7 },
|
||||
{ "id": 95, "name": "Kasa", "slug": "pc-case", "parentId": 7 },
|
||||
|
||||
{ "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": 100, "name": "Kadın Parfüm", "slug": "beauty-fragrance-women", "parentId": 99 },
|
||||
{ "id": 101, "name": "Erkek Parfüm", "slug": "beauty-fragrance-men", "parentId": 99 },
|
||||
|
||||
{ "id": 102, "name": "Saç Bakımı", "slug": "beauty-haircare", "parentId": 2 },
|
||||
{ "id": 103, "name": "Şampuan", "slug": "beauty-shampoo", "parentId": 102 },
|
||||
{ "id": 104, "name": "Saç Kremi", "slug": "beauty-conditioner", "parentId": 102 },
|
||||
{ "id": 105, "name": "Saç Şekillendirici", "slug": "beauty-hair-styling", "parentId": 102 },
|
||||
|
||||
{ "id": 106, "name": "Kişisel Bakım", "slug": "beauty-personal-care", "parentId": 2 },
|
||||
{ "id": 107, "name": "Deodorant", "slug": "beauty-deodorant", "parentId": 106 },
|
||||
{ "id": 108, "name": "Tıraş Ürünleri", "slug": "beauty-shaving", "parentId": 106 },
|
||||
{ "id": 109, "name": "Ağda / Epilasyon", "slug": "beauty-hair-removal", "parentId": 106 },
|
||||
|
||||
{ "id": 110, "name": "Serum", "slug": "beauty-skincare-serum", "parentId": 20 },
|
||||
{ "id": 111, "name": "Güneş Kremi", "slug": "beauty-sunscreen", "parentId": 20 },
|
||||
{ "id": 112, "name": "Temizleyici", "slug": "beauty-cleanser", "parentId": 20 },
|
||||
{ "id": 113, "name": "Yüz Maskesi", "slug": "beauty-mask", "parentId": 20 },
|
||||
{ "id": 114, "name": "Tonik", "slug": "beauty-toner", "parentId": 20 },
|
||||
|
||||
{ "id": 115, "name": "Temel Gıda", "slug": "food-staples", "parentId": 3 },
|
||||
{ "id": 116, "name": "Makarna", "slug": "food-pasta", "parentId": 115 },
|
||||
{ "id": 117, "name": "Pirinç & Bakliyat", "slug": "food-legumes", "parentId": 115 },
|
||||
{ "id": 118, "name": "Yağ & Sirke (Gıda)", "slug": "food-oil-vinegar", "parentId": 115 },
|
||||
|
||||
{ "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": 124, "name": "Su", "slug": "food-water", "parentId": 24 },
|
||||
{ "id": 125, "name": "Enerji İçeceği", "slug": "food-energy", "parentId": 24 },
|
||||
{ "id": 126, "name": "Çay", "slug": "food-tea", "parentId": 24 },
|
||||
|
||||
{ "id": 127, "name": "Dondurulmuş", "slug": "food-frozen", "parentId": 3 },
|
||||
{ "id": 128, "name": "Et & Tavuk", "slug": "food-meat", "parentId": 3 },
|
||||
{ "id": 129, "name": "Tatlı", "slug": "food-dessert", "parentId": 3 },
|
||||
|
||||
{ "id": 130, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 4 },
|
||||
{ "id": 131, "name": "Araç İçi Elektronik", "slug": "auto-in-car-electronics", "parentId": 130 },
|
||||
|
||||
{ "id": 132, "name": "Oto Bakım", "slug": "auto-care", "parentId": 4 },
|
||||
{ "id": 133, "name": "Oto Temizlik", "slug": "auto-cleaning", "parentId": 132 },
|
||||
{ "id": 134, "name": "Lastik & Jant", "slug": "auto-tires", "parentId": 4 },
|
||||
{ "id": 135, "name": "Akü", "slug": "auto-battery", "parentId": 4 },
|
||||
{ "id": 136, "name": "Oto Aydınlatma", "slug": "auto-lighting", "parentId": 130 },
|
||||
{ "id": 137, "name": "Oto Ses Sistemi", "slug": "auto-audio", "parentId": 130 },
|
||||
|
||||
{ "id": 138, "name": "Mobilya", "slug": "home-furniture", "parentId": 5 },
|
||||
{ "id": 139, "name": "Yemek Masası", "slug": "home-dining-table", "parentId": 138 },
|
||||
{ "id": 140, "name": "Sandalye", "slug": "home-chair", "parentId": 138 },
|
||||
{ "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": 148, "name": "Tencere & Tava", "slug": "home-cookware", "parentId": 147 },
|
||||
{ "id": 149, "name": "Küçük Ev Aletleri", "slug": "home-small-appliances", "parentId": 147 },
|
||||
{ "id": 150, "name": "Kahve Makinesi", "slug": "home-coffee-machine", "parentId": 149 },
|
||||
{ "id": 151, "name": "Blender", "slug": "home-blender", "parentId": 149 },
|
||||
{ "id": 152, "name": "Airfryer", "slug": "home-airfryer", "parentId": 149 },
|
||||
{ "id": 153, "name": "Süpürge", "slug": "home-vacuum", "parentId": 149 },
|
||||
|
||||
{ "id": 154, "name": "Aydınlatma", "slug": "home-lighting", "parentId": 5 },
|
||||
{ "id": 155, "name": "Dekorasyon", "slug": "home-decor", "parentId": 5 },
|
||||
{ "id": 156, "name": "Halı", "slug": "home-rug", "parentId": 155 },
|
||||
{ "id": 157, "name": "Duvar Dekoru", "slug": "home-wall-decor", "parentId": 155 },
|
||||
|
||||
{ "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": 162, "name": "Matkap", "slug": "home-drill", "parentId": 161 },
|
||||
{ "id": 163, "name": "Testere", "slug": "home-saw", "parentId": 161 },
|
||||
{ "id": 164, "name": "Vida & Dübel", "slug": "home-hardware", "parentId": 161 },
|
||||
|
||||
{ "id": 165, "name": "Evcil Hayvan", "slug": "pet", "parentId": 5 },
|
||||
{ "id": 166, "name": "Kedi Maması", "slug": "pet-cat-food", "parentId": 165 },
|
||||
{ "id": 167, "name": "Köpek Maması", "slug": "pet-dog-food", "parentId": 165 },
|
||||
{ "id": 168, "name": "Kedi Kumu", "slug": "pet-cat-litter", "parentId": 165 },
|
||||
|
||||
{ "id": 169, "name": "Kırtasiye & Ofis", "slug": "office", "parentId": 0 },
|
||||
{ "id": 170, "name": "Kağıt & Defter", "slug": "office-paper-notebook", "parentId": 169 },
|
||||
{ "id": 171, "name": "A4 Kağıdı", "slug": "office-a4-paper", "parentId": 170 },
|
||||
{ "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": 175, "name": "Bebek Bezi", "slug": "baby-diaper", "parentId": 174 },
|
||||
{ "id": 176, "name": "Islak Mendil", "slug": "baby-wipes", "parentId": 174 },
|
||||
{ "id": 177, "name": "Bebek Maması", "slug": "baby-food", "parentId": 174 },
|
||||
{ "id": 178, "name": "Oyuncak", "slug": "baby-toys", "parentId": 174 },
|
||||
|
||||
{ "id": 179, "name": "Spor & Outdoor", "slug": "sports", "parentId": 0 },
|
||||
{ "id": 180, "name": "Kamp", "slug": "sports-camping", "parentId": 179 },
|
||||
{ "id": 181, "name": "Fitness", "slug": "sports-fitness", "parentId": 179 },
|
||||
{ "id": 182, "name": "Bisiklet", "slug": "sports-bicycle", "parentId": 179 },
|
||||
|
||||
{ "id": 183, "name": "Moda", "slug": "fashion", "parentId": 0 },
|
||||
{ "id": 184, "name": "Ayakkabı", "slug": "fashion-shoes", "parentId": 183 },
|
||||
{ "id": 185, "name": "Erkek Giyim", "slug": "fashion-men", "parentId": 183 },
|
||||
{ "id": 186, "name": "Kadın Giyim", "slug": "fashion-women", "parentId": 183 },
|
||||
{ "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": 190, "name": "Dijital Oyun (Genel)", "slug": "digital-games", "parentId": 188 }
|
||||
]
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
[
|
||||
{ "id": 0, "name": "Undefined", "slug": "undefined", "parentId": null, "description": "Henüz sınıflandırılmamış içerikler için geçici kategori." },
|
||||
|
||||
{ "id": 1, "name": "Elektronik", "slug": "electronics", "parentId": 0, "description": "Telefon, bilgisayar, TV, ses sistemleri ve diğer elektronik ürünler." },
|
||||
{ "id": 2, "name": "Kozmetik", "slug": "beauty", "parentId": 0, "description": "Makyaj, cilt bakımı, saç bakımı, parfüm ve kişisel bakım ürünleri." },
|
||||
{ "id": 3, "name": "Gıda", "slug": "food", "parentId": 0, "description": "Atıştırmalık, içecek, temel gıda ve market ürünleri." },
|
||||
{ "id": 4, "name": "Oto", "slug": "auto", "parentId": 0, "description": "Araç bakım, yağ, yedek parça ve oto aksesuar ürünleri." },
|
||||
{ "id": 5, "name": "Ev & Bahçe", "slug": "home-garden", "parentId": 0, "description": "Ev ihtiyaçları, dekorasyon, temizlik ve bahçe ürünleri." },
|
||||
|
||||
{ "id": 6, "name": "Bilgisayar", "slug": "computers", "parentId": 1, "description": "Masaüstü/dizüstü bilgisayarlar, tabletler ve bilgisayar ekipmanları." },
|
||||
{ "id": 7, "name": "PC Bileşenleri", "slug": "pc-components", "parentId": 6, "description": "Bilgisayar toplama/yükseltme için işlemci, ekran kartı, RAM, depolama vb." },
|
||||
{ "id": 8, "name": "RAM", "slug": "pc-ram", "parentId": 7, "description": "Bilgisayar performansını artırmaya yönelik bellek modülleri." },
|
||||
{ "id": 9, "name": "SSD", "slug": "pc-ssd", "parentId": 7, "description": "Hızlı depolama çözümleri (NVMe/SATA) SSD diskler." },
|
||||
{ "id": 10, "name": "CPU", "slug": "pc-cpu", "parentId": 7, "description": "Bilgisayar işlemcileri; performans, oyun ve iş kullanımına yönelik modeller." },
|
||||
{ "id": 11, "name": "GPU", "slug": "pc-gpu", "parentId": 7, "description": "Ekran kartları; oyun, grafik tasarım ve video işleme için." },
|
||||
|
||||
{ "id": 12, "name": "Bilgisayar Aksesuarları", "slug": "pc-peripherals", "parentId": 6, "description": "Klavye, mouse, webcam, mikrofon, mousepad gibi çevre birimleri." },
|
||||
{ "id": 13, "name": "Klavye", "slug": "pc-keyboard", "parentId": 12, "description": "Mekanik/membran, oyuncu ve ofis kullanımına uygun klavyeler." },
|
||||
{ "id": 14, "name": "Mouse", "slug": "pc-mouse", "parentId": 12, "description": "Kablolu/kablosuz, oyuncu ve günlük kullanım mouse modelleri." },
|
||||
{ "id": 15, "name": "Monitör", "slug": "pc-monitor", "parentId": 6, "description": "Bilgisayar monitörleri; oyun, ofis ve profesyonel kullanım seçenekleri." },
|
||||
|
||||
{ "id": 16, "name": "Makyaj", "slug": "beauty-makeup", "parentId": 2, "description": "Ruj, fondöten, maskara ve diğer makyaj ürünleri." },
|
||||
{ "id": 17, "name": "Ruj", "slug": "beauty-lipstick", "parentId": 16, "description": "Mat, parlak, likit ve farklı renk seçeneklerinde dudak ürünleri." },
|
||||
{ "id": 18, "name": "Fondöten", "slug": "beauty-foundation", "parentId": 16, "description": "Cilt tonunu eşitleyen; mat/parlak bitişli fondöten ürünleri." },
|
||||
{ "id": 19, "name": "Maskara", "slug": "beauty-mascara", "parentId": 16, "description": "Kirpiklere hacim, uzunluk ve kıvrım kazandıran maskaralar." },
|
||||
|
||||
{ "id": 20, "name": "Cilt Bakımı", "slug": "beauty-skincare", "parentId": 2, "description": "Nemlendirici, temizleyici, serum, güneş kremi gibi cilt bakım ürünleri." },
|
||||
{ "id": 21, "name": "Nemlendirici", "slug": "beauty-moisturizer", "parentId": 20, "description": "Cildi nemlendirip bariyeri destekleyen yüz/vücut nemlendiricileri." },
|
||||
|
||||
{ "id": 22, "name": "Atıştırmalık", "slug": "food-snacks", "parentId": 3, "description": "Cips, kuruyemiş, bisküvi, çikolata ve benzeri atıştırmalıklar." },
|
||||
{ "id": 23, "name": "Çiğköfte", "slug": "food-cigkofte", "parentId": 22, "description": "Hazır çiğköfte ürünleri ve çiğköfte setleri." },
|
||||
|
||||
{ "id": 24, "name": "İçecek", "slug": "food-beverages", "parentId": 3, "description": "Kahve, çay, su, gazlı içecek ve diğer içecek ürünleri." },
|
||||
{ "id": 25, "name": "Kahve", "slug": "food-coffee", "parentId": 24, "description": "Çekirdek/öğütülmüş, kapsül ve hazır kahve çeşitleri." },
|
||||
|
||||
{ "id": 26, "name": "Yağlar", "slug": "auto-oils", "parentId": 4, "description": "Motor yağı ve araç için kullanılan diğer yağ çeşitleri." },
|
||||
{ "id": 27, "name": "Motor Yağı", "slug": "auto-engine-oil", "parentId": 26, "description": "Motoru koruyan; farklı viskozite ve onaylara sahip motor yağları." },
|
||||
|
||||
{ "id": 28, "name": "Oto Parçaları", "slug": "auto-parts", "parentId": 4, "description": "Fren, filtre, aydınlatma ve diğer araç yedek parça ürünleri." },
|
||||
{ "id": 29, "name": "Fren Balatası", "slug": "auto-brake-pads", "parentId": 28, "description": "Araç fren sistemi için ön/arka fren balatası ürünleri." },
|
||||
|
||||
{ "id": 30, "name": "Bahçe", "slug": "home-garden-garden", "parentId": 5, "description": "Bahçe bakımı, sulama ve dış mekân düzenleme ürünleri." },
|
||||
{ "id": 31, "name": "Sulama", "slug": "garden-irrigation", "parentId": 30, "description": "Hortum, damla sulama, sprinkler ve sulama ekipmanları." },
|
||||
|
||||
{ "id": 32, "name": "Telefon & Aksesuarları", "slug": "phone", "parentId": 1, "description": "Akıllı telefonlar ve telefonla ilgili tüm aksesuarlar." },
|
||||
{ "id": 33, "name": "Akıllı Telefon", "slug": "phone-smartphone", "parentId": 32, "description": "Android/iOS akıllı telefonlar ve farklı marka/model seçenekleri." },
|
||||
{ "id": 34, "name": "Telefon Kılıfı", "slug": "phone-case", "parentId": 32, "description": "Cihazı koruyan silikon, sert kapak, cüzdan tipi telefon kılıfları." },
|
||||
{ "id": 35, "name": "Ekran Koruyucu", "slug": "phone-screen-protector", "parentId": 32, "description": "Cam/film ekran koruyucular; çizilme ve darbe koruması sağlar." },
|
||||
{ "id": 36, "name": "Şarj & Kablo", "slug": "phone-charging", "parentId": 32, "description": "Şarj adaptörü, kablo, hızlı şarj ekipmanları ve aksesuarları." },
|
||||
{ "id": 37, "name": "Powerbank", "slug": "phone-powerbank", "parentId": 32, "description": "Taşınabilir şarj cihazları; farklı kapasite ve hızlı şarj destekleri." },
|
||||
|
||||
{ "id": 38, "name": "Giyilebilir Teknoloji", "slug": "wearables", "parentId": 1, "description": "Akıllı saat, bileklik ve sağlık/aktivite takibi yapan cihazlar." },
|
||||
{ "id": 39, "name": "Akıllı Saat", "slug": "wearables-smartwatch", "parentId": 38, "description": "Bildirim, sağlık takibi ve uygulama desteği sunan akıllı saatler." },
|
||||
{ "id": 40, "name": "Akıllı Bileklik", "slug": "wearables-band", "parentId": 38, "description": "Adım, uyku, nabız gibi metrikleri takip eden akıllı bileklikler." },
|
||||
|
||||
{ "id": 41, "name": "Ses & Audio", "slug": "audio", "parentId": 1, "description": "Kulaklık, hoparlör, mikrofon, soundbar ve ses ekipmanları." },
|
||||
{ "id": 42, "name": "Kulaklık", "slug": "audio-headphones", "parentId": 41, "description": "Kulak üstü, kulak içi, kablolu/kablosuz kulaklık modelleri." },
|
||||
{ "id": 43, "name": "TWS Kulaklık", "slug": "audio-tws", "parentId": 42, "description": "Tam kablosuz (True Wireless) kulak içi kulaklıklar." },
|
||||
{ "id": 44, "name": "Bluetooth Hoparlör", "slug": "audio-bt-speaker", "parentId": 41, "description": "Taşınabilir kablosuz hoparlörler; ev ve dış mekân kullanımı için." },
|
||||
{ "id": 45, "name": "Soundbar", "slug": "audio-soundbar", "parentId": 41, "description": "TV için daha güçlü ve net ses sağlayan soundbar sistemleri." },
|
||||
{ "id": 46, "name": "Mikrofon", "slug": "audio-microphone", "parentId": 41, "description": "Yayın, toplantı ve kayıt amaçlı masaüstü/yalaka mikrofonlar." },
|
||||
{ "id": 47, "name": "Plak / Pikap", "slug": "audio-turntable", "parentId": 41, "description": "Vinyl plak ve pikap ürünleri; analog müzik ekipmanları." },
|
||||
|
||||
{ "id": 48, "name": "TV & Video", "slug": "tv-video", "parentId": 1, "description": "Televizyonlar, projeksiyonlar, medya oynatıcılar ve TV aksesuarları." },
|
||||
{ "id": 49, "name": "Televizyon", "slug": "tv", "parentId": 48, "description": "LED/QLED/OLED televizyonlar; farklı boyut ve çözünürlük seçenekleri." },
|
||||
{ "id": 50, "name": "Projeksiyon", "slug": "projector", "parentId": 48, "description": "Ev sineması ve sunum amaçlı projeksiyon cihazları." },
|
||||
|
||||
{ "id": 51, "name": "Medya Oynatıcı", "slug": "tv-media-player", "parentId": 48, "description": "TV’ye bağlanıp uygulama/film/dizi oynatmayı sağlayan medya cihazları (Android TV box vb.)." },
|
||||
{ "id": 52, "name": "TV Aksesuarları", "slug": "tv-accessories", "parentId": 48, "description": "TV için kumanda, askı aparatı, kablo, stand ve benzeri yardımcı aksesuarlar." },
|
||||
{ "id": 53, "name": "Uydu Alıcısı / Receiver", "slug": "tv-receiver", "parentId": 48, "description": "Uydu yayını izlemek için receiver/uydu alıcısı ve ilgili cihazlar." },
|
||||
|
||||
{ "id": 54, "name": "Konsollar", "slug": "console", "parentId": 191, "description": "PlayStation, Xbox, Nintendo konsolları; konsol oyunları ve aksesuarları." },
|
||||
{ "id": 55, "name": "PlayStation", "slug": "console-playstation", "parentId": 54, "description": "PlayStation konsolları, oyunları, üyelikleri ve PlayStation aksesuarları." },
|
||||
{ "id": 56, "name": "Xbox", "slug": "console-xbox", "parentId": 54, "description": "Xbox konsolları, oyunları, Game Pass/abonelik ve Xbox aksesuarları." },
|
||||
{ "id": 57, "name": "Nintendo", "slug": "console-nintendo", "parentId": 54, "description": "Nintendo konsolları (Switch vb.), oyunları ve Nintendo aksesuarları." },
|
||||
{ "id": 58, "name": "Oyunlar (Konsol)", "slug": "console-games", "parentId": 54, "description": "Konsollar için fiziksel/dijital oyunlar ve oyun içerikleri." },
|
||||
{ "id": 59, "name": "Konsol Aksesuarları", "slug": "console-accessories", "parentId": 54, "description": "Kollar, şarj istasyonları, kulaklıklar, taşıma çantaları ve diğer konsol aksesuarları." },
|
||||
|
||||
{ "id": 60, "name": "Kamera & Fotoğraf", "slug": "camera", "parentId": 1, "description": "Fotoğraf/video çekim ekipmanları; kamera gövdeleri, lensler ve aksesuarlar." },
|
||||
{ "id": 61, "name": "Fotoğraf Makinesi", "slug": "camera-photo", "parentId": 60, "description": "DSLR, aynasız ve kompakt fotoğraf makineleri." },
|
||||
{ "id": 62, "name": "Aksiyon Kamera", "slug": "camera-action", "parentId": 60, "description": "GoPro tarzı dayanıklı, suya dayanıklı ve hareketli çekime uygun aksiyon kameraları." },
|
||||
{ "id": 63, "name": "Lens", "slug": "camera-lens", "parentId": 60, "description": "Kamera lensleri; prime/zoom, geniş açı, tele, portre ve benzeri seçenekler." },
|
||||
{ "id": 64, "name": "Tripod", "slug": "camera-tripod", "parentId": 60, "description": "Fotoğraf/video için tripod, monopod ve stabil çekim destek ekipmanları." },
|
||||
|
||||
{ "id": 65, "name": "Akıllı Ev", "slug": "smart-home", "parentId": 1, "description": "Ev otomasyonu ürünleri; aydınlatma, priz, sensör ve güvenlik çözümleri." },
|
||||
{ "id": 66, "name": "Güvenlik Kamerası", "slug": "smart-security-camera", "parentId": 65, "description": "Ev/ofis için IP kamera, iç/dış kamera ve izleme sistemleri." },
|
||||
{ "id": 67, "name": "Akıllı Priz", "slug": "smart-plug", "parentId": 65, "description": "Uygulama ile kontrol edilen, zamanlayıcı/enerji takibi sunan akıllı prizler." },
|
||||
{ "id": 68, "name": "Akıllı Ampul", "slug": "smart-bulb", "parentId": 65, "description": "Renk/ışık şiddeti kontrolü yapılabilen, Wi-Fi/Zigbee akıllı ampuller." },
|
||||
{ "id": 69, "name": "Akıllı Sensör", "slug": "smart-sensor", "parentId": 65, "description": "Kapı/pencere, hareket, sıcaklık/nem gibi verileri ölçen akıllı sensörler." },
|
||||
|
||||
{ "id": 70, "name": "Ağ Ürünleri", "slug": "pc-networking", "parentId": 6, "description": "İnternet ve yerel ağ kurulum ürünleri; router, modem, switch, menzil genişletici." },
|
||||
{ "id": 71, "name": "Router", "slug": "pc-router", "parentId": 70, "description": "Kablosuz ağ dağıtımı için router cihazları (Wi-Fi 5/6/6E/7 vb.)." },
|
||||
{ "id": 72, "name": "Modem", "slug": "pc-modem", "parentId": 70, "description": "DSL/VDSL/FTTH uyumlu modemler ve modem-router cihazları." },
|
||||
{ "id": 73, "name": "Switch", "slug": "pc-switch", "parentId": 70, "description": "Kablolu ağ için port çoğaltan network switch cihazları." },
|
||||
{ "id": 74, "name": "Wi-Fi Extender", "slug": "pc-wifi-extender", "parentId": 70, "description": "Kablosuz ağ menzilini artıran repeater/extender ve mesh uyumlu cihazlar." },
|
||||
|
||||
{ "id": 75, "name": "Yazıcı & Tarayıcı", "slug": "pc-printing", "parentId": 6, "description": "Ev/ofis baskı ve tarama ürünleri; yazıcı, tarayıcı ve sarf malzemeleri." },
|
||||
{ "id": 76, "name": "Yazıcı", "slug": "pc-printer", "parentId": 75, "description": "Lazer/mürekkep püskürtmeli yazıcılar ve çok fonksiyonlu cihazlar." },
|
||||
{ "id": 77, "name": "Toner & Kartuş", "slug": "pc-ink-toner", "parentId": 75, "description": "Yazıcılar için toner, kartuş, mürekkep ve ilgili sarf malzemeleri." },
|
||||
{ "id": 78, "name": "Tarayıcı", "slug": "pc-scanner", "parentId": 75, "description": "Belge ve fotoğraf taraması için flatbed/ADF tarayıcı cihazları." },
|
||||
|
||||
{ "id": 79, "name": "Dizüstü Bilgisayar", "slug": "pc-laptop", "parentId": 6, "description": "Taşınabilir dizüstü bilgisayarlar; günlük, oyun ve iş amaçlı modeller." },
|
||||
{ "id": 80, "name": "Masaüstü Bilgisayar", "slug": "pc-desktop", "parentId": 6, "description": "Hazır masaüstü bilgisayarlar ve iş/oyun odaklı sistemler." },
|
||||
{ "id": 81, "name": "Tablet", "slug": "pc-tablet", "parentId": 6, "description": "Android/iPadOS/Windows tabletler ve tablet benzeri cihazlar." },
|
||||
|
||||
{ "id": 82, "name": "Depolama", "slug": "pc-storage", "parentId": 6, "description": "Harici disk, USB bellek, NAS ve diğer depolama çözümleri." },
|
||||
{ "id": 83, "name": "Harici Disk", "slug": "pc-external-drive", "parentId": 82, "description": "Taşınabilir harici HDD/SSD diskler ve yedekleme çözümleri." },
|
||||
{ "id": 84, "name": "USB Flash", "slug": "pc-usb-drive", "parentId": 82, "description": "USB bellekler; farklı kapasite ve hız seçenekleri." },
|
||||
{ "id": 85, "name": "NAS", "slug": "pc-nas", "parentId": 82, "description": "Ağ üzerinden depolama ve yedekleme için NAS cihazları ve disk kutuları." },
|
||||
|
||||
{ "id": 86, "name": "Webcam", "slug": "pc-webcam", "parentId": 12, "description": "Görüntülü görüşme ve yayın için web kameraları (1080p/2K/4K vb.)." },
|
||||
{ "id": 87, "name": "Hoparlör (PC)", "slug": "pc-speaker", "parentId": 12, "description": "Bilgisayar için masaüstü hoparlör sistemleri ve ses çözümleri." },
|
||||
{ "id": 88, "name": "Mikrofon (PC)", "slug": "pc-mic", "parentId": 12, "description": "Oyun, yayın, toplantı ve kayıt için PC uyumlu mikrofonlar." },
|
||||
{ "id": 89, "name": "Mousepad", "slug": "pc-mousepad", "parentId": 12, "description": "Mouse kullanımını iyileştiren, farklı boyut ve yüzey tiplerinde mousepadler." },
|
||||
{ "id": 90, "name": "Dock / USB Hub", "slug": "pc-dock-hub", "parentId": 12, "description": "Port çoğaltma için USB hub ve laptop dock istasyonları." },
|
||||
{ "id": 91, "name": "Laptop Çantası", "slug": "pc-laptop-bag", "parentId": 12, "description": "Dizüstü bilgisayar taşıma çantaları, kılıflar ve koruyucu çantalar." },
|
||||
{ "id": 92, "name": "Gamepad / Controller", "slug": "pc-controller", "parentId": 12, "description": "PC ile uyumlu oyun kolları ve kontrolcü aksesuarları." },
|
||||
|
||||
{ "id": 93, "name": "Anakart", "slug": "pc-motherboard", "parentId": 7, "description": "İşlemci soketi ve chipset’e göre PC anakartları (ATX/mATX/ITX)." },
|
||||
{ "id": 94, "name": "Güç Kaynağı (PSU)", "slug": "pc-psu", "parentId": 7, "description": "Bilgisayar bileşenlerini besleyen PSU güç kaynakları (80+ sertifikalı vb.)." },
|
||||
{ "id": 95, "name": "Kasa", "slug": "pc-case", "parentId": 7, "description": "Bilgisayar kasaları; hava akışı, boyut ve tasarıma göre seçenekler." },
|
||||
|
||||
{ "id": 96, "name": "Soğutma", "slug": "pc-cooling", "parentId": 7, "description": "CPU/GPU ve kasa soğutma çözümleri; fanlar, sıvı soğutma ve aksesuarlar." },
|
||||
{ "id": 97, "name": "Kasa Fanı", "slug": "pc-fan", "parentId": 96, "description": "Kasa içi hava akışı için fanlar (RGB/PWM vb. seçenekler)." },
|
||||
{ "id": 98, "name": "Sıvı Soğutma", "slug": "pc-liquid-cooling", "parentId": 96, "description": "AIO ve özel loop sıvı soğutma çözümleri ve bileşenleri." },
|
||||
|
||||
{ "id": 99, "name": "Parfüm", "slug": "beauty-fragrance", "parentId": 2, "description": "Kadın/erkek parfümleri, deodorantlar ve koku ürünleri." },
|
||||
{ "id": 100, "name": "Kadın Parfüm", "slug": "beauty-fragrance-women", "parentId": 99, "description": "Kadınlara yönelik parfümler; EDT/EDP ve farklı koku profilleri." },
|
||||
{ "id": 101, "name": "Erkek Parfüm", "slug": "beauty-fragrance-men", "parentId": 99, "description": "Erkeklere yönelik parfümler; EDT/EDP, fresh/odunsu/baharatlı koku seçenekleri." },
|
||||
|
||||
{ "id": 102, "name": "Saç Bakımı", "slug": "beauty-haircare", "parentId": 2, "description": "Saç temizliği, onarımı ve şekillendirme için saç bakım ürünleri." },
|
||||
{ "id": 103, "name": "Şampuan", "slug": "beauty-shampoo", "parentId": 102, "description": "Kepek, yağlı/kuru saç, onarıcı ve renk koruyucu şampuan çeşitleri." },
|
||||
{ "id": 104, "name": "Saç Kremi", "slug": "beauty-conditioner", "parentId": 102, "description": "Saçı yumuşatan, kolay tarama sağlayan ve bakım yapan saç kremleri." },
|
||||
{ "id": 105, "name": "Saç Şekillendirici", "slug": "beauty-hair-styling", "parentId": 102, "description": "Wax, jel, köpük, sprey ve ısı koruyucu gibi şekillendirici ürünler." },
|
||||
|
||||
{ "id": 106, "name": "Kişisel Bakım", "slug": "beauty-personal-care", "parentId": 2, "description": "Günlük hijyen ve bakım ürünleri; deodorant, tıraş ve epilasyon gibi." },
|
||||
{ "id": 107, "name": "Deodorant", "slug": "beauty-deodorant", "parentId": 106, "description": "Ter kokusunu önlemeye yardımcı roll-on, sprey ve stick deodorantlar." },
|
||||
{ "id": 108, "name": "Tıraş Ürünleri", "slug": "beauty-shaving", "parentId": 106, "description": "Tıraş köpüğü/jeli, losyon, aftershave ve tıraş bıçağı ürünleri." },
|
||||
{ "id": 109, "name": "Ağda / Epilasyon", "slug": "beauty-hair-removal", "parentId": 106, "description": "Ağda bantları, ağda ürünleri, epilatör ve tüy alma yardımcıları." },
|
||||
|
||||
{ "id": 110, "name": "Serum", "slug": "beauty-skincare-serum", "parentId": 20, "description": "Leke, nem, anti-aging ve aydınlatma için yoğun içerikli cilt serumları." },
|
||||
{ "id": 111, "name": "Güneş Kremi", "slug": "beauty-sunscreen", "parentId": 20, "description": "UVA/UVB koruması sağlayan yüz ve vücut güneş koruyucuları (SPF)." },
|
||||
{ "id": 112, "name": "Temizleyici", "slug": "beauty-cleanser", "parentId": 20, "description": "Jel, köpük, yağ bazlı ve micellar gibi yüz temizleme ürünleri." },
|
||||
{ "id": 113, "name": "Yüz Maskesi", "slug": "beauty-mask", "parentId": 20, "description": "Kil, kağıt ve gece maskeleri; nem, arındırma ve bakım amaçlı." },
|
||||
{ "id": 114, "name": "Tonik", "slug": "beauty-toner", "parentId": 20, "description": "Cildi dengeleyen, gözenek görünümünü destekleyen tonik ürünleri." },
|
||||
|
||||
{ "id": 115, "name": "Temel Gıda", "slug": "food-staples", "parentId": 3, "description": "Günlük mutfak ihtiyaçları; makarna, bakliyat, yağ ve benzeri temel ürünler." },
|
||||
{ "id": 116, "name": "Makarna", "slug": "food-pasta", "parentId": 115, "description": "Spagetti, penne, erişte ve farklı çeşitlerde makarna ürünleri." },
|
||||
{ "id": 117, "name": "Pirinç & Bakliyat", "slug": "food-legumes", "parentId": 115, "description": "Pirinç, bulgur, mercimek, nohut, fasulye ve diğer bakliyatlar." },
|
||||
{ "id": 118, "name": "Yağ & Sirke (Gıda)", "slug": "food-oil-vinegar", "parentId": 115, "description": "Zeytinyağı, ayçiçek yağı ve çeşitli sirke türleri gibi ürünler." },
|
||||
|
||||
{ "id": 119, "name": "Kahvaltılık", "slug": "food-breakfast", "parentId": 3, "description": "Peynir, zeytin, reçel, bal ve diğer kahvaltılık ürünler." },
|
||||
{ "id": 120, "name": "Peynir", "slug": "food-cheese", "parentId": 119, "description": "Beyaz peynir, kaşar, tulum ve farklı peynir çeşitleri." },
|
||||
{ "id": 121, "name": "Zeytin", "slug": "food-olive", "parentId": 119, "description": "Siyah/yeşil, çekirdekli/çekirdeksiz ve salamura zeytin çeşitleri." },
|
||||
{ "id": 122, "name": "Reçel & Bal", "slug": "food-jam-honey", "parentId": 119, "description": "Kahvaltılık reçeller, marmelatlar, bal ve benzeri tatlandırıcı ürünler." },
|
||||
|
||||
{ "id": 123, "name": "Gazlı İçecek", "slug": "food-soda", "parentId": 24, "description": "Kola, gazoz, aromalı soda ve benzeri gazlı içecekler." },
|
||||
{ "id": 124, "name": "Su", "slug": "food-water", "parentId": 24, "description": "Pet şişe, damacana ve aromalı su seçenekleri." },
|
||||
{ "id": 125, "name": "Enerji İçeceği", "slug": "food-energy", "parentId": 24, "description": "Enerji içecekleri; farklı hacim ve kafein/taurin içerikli seçenekler." },
|
||||
{ "id": 126, "name": "Çay", "slug": "food-tea", "parentId": 24, "description": "Siyah çay, yeşil çay, bitki çayları ve aromalı çay çeşitleri." },
|
||||
|
||||
{ "id": 127, "name": "Dondurulmuş", "slug": "food-frozen", "parentId": 3, "description": "Dondurulmuş gıdalar; sebze, hazır ürünler ve dondurulmuş atıştırmalıklar." },
|
||||
{ "id": 128, "name": "Et & Tavuk", "slug": "food-meat", "parentId": 3, "description": "Kırmızı et, tavuk ve işlenmiş et ürünleri; paketli market seçenekleri." },
|
||||
{ "id": 129, "name": "Tatlı", "slug": "food-dessert", "parentId": 3, "description": "Pastane/market tatlıları, çikolata bazlı ürünler ve tatlı çeşitleri." },
|
||||
|
||||
{ "id": 130, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 4, "description": "Araç içi/dışı kullanım için aksesuarlar; düzenleyici, tutucu, bakım setleri vb." },
|
||||
{ "id": 131, "name": "Araç İçi Elektronik", "slug": "auto-in-car-electronics", "parentId": 130, "description": "Araç içi kamera, multimedya, şarj cihazı, FM transmitter gibi elektronik ürünler." },
|
||||
|
||||
{ "id": 132, "name": "Oto Bakım", "slug": "auto-care", "parentId": 4, "description": "Araç bakım ürünleri; cila, wax, kaplama, temizlik ve koruma çözümleri." },
|
||||
{ "id": 133, "name": "Oto Temizlik", "slug": "auto-cleaning", "parentId": 132, "description": "İç/dış temizlik ürünleri; şampuan, köpük, bez, fırça ve temizleyiciler." },
|
||||
{ "id": 134, "name": "Lastik & Jant", "slug": "auto-tires", "parentId": 4, "description": "Lastik, jant ve ilgili aksesuarlar; mevsimlik lastikler ve bakım ürünleri." },
|
||||
{ "id": 135, "name": "Akü", "slug": "auto-battery", "parentId": 4, "description": "Otomobil aküleri ve akü takviye/şarj ekipmanları." },
|
||||
{ "id": 136, "name": "Oto Aydınlatma", "slug": "auto-lighting", "parentId": 130, "description": "Far ampulü, LED dönüşüm kitleri ve araç iç/dış aydınlatma ürünleri." },
|
||||
{ "id": 137, "name": "Oto Ses Sistemi", "slug": "auto-audio", "parentId": 130, "description": "Teyp, hoparlör, amfi, subwoofer ve araç ses sistemi ekipmanları." },
|
||||
|
||||
{ "id": 138, "name": "Mobilya", "slug": "home-furniture", "parentId": 5, "description": "Ev mobilyaları; masa, sandalye, koltuk, yatak ve depolama ürünleri." },
|
||||
{ "id": 139, "name": "Yemek Masası", "slug": "home-dining-table", "parentId": 138, "description": "Mutfak/yemek odası için farklı boyut ve malzemelerde yemek masaları." },
|
||||
{ "id": 140, "name": "Sandalye", "slug": "home-chair", "parentId": 138, "description": "Yemek odası, çalışma ve çok amaçlı kullanım için sandalyeler." },
|
||||
{ "id": 141, "name": "Koltuk", "slug": "home-sofa", "parentId": 138, "description": "Oturma odası için koltuk, kanepe ve oturma grubu ürünleri." },
|
||||
{ "id": 142, "name": "Yatak", "slug": "home-bed", "parentId": 138, "description": "Tek/çift kişilik yatak bazası, karyola ve yatak sistemleri." },
|
||||
|
||||
{ "id": 143, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 5, "description": "Nevresim, battaniye, perde ve diğer ev tekstili ürünleri." },
|
||||
{ "id": 144, "name": "Nevresim", "slug": "home-bedding", "parentId": 143, "description": "Nevresim takımları, çarşaflar ve yastık kılıfları." },
|
||||
{ "id": 145, "name": "Yorgan & Battaniye", "slug": "home-blanket", "parentId": 143, "description": "Isı ve konfor sağlayan yorgan, battaniye ve uyku ürünleri." },
|
||||
{ "id": 146, "name": "Perde", "slug": "home-curtain", "parentId": 143, "description": "Tül, fon ve stor gibi farklı perde çeşitleri ve aksesuarları." },
|
||||
|
||||
{ "id": 147, "name": "Mutfak", "slug": "home-kitchen", "parentId": 5, "description": "Mutfak gereçleri, pişirme ekipmanları ve küçük ev aletleri." },
|
||||
{ "id": 148, "name": "Tencere & Tava", "slug": "home-cookware", "parentId": 147, "description": "Tencere setleri, tava çeşitleri ve pişirme ekipmanları." },
|
||||
{ "id": 149, "name": "Küçük Ev Aletleri", "slug": "home-small-appliances", "parentId": 147, "description": "Mutfakta kullanılan küçük elektrikli aletler; kahve makinesi, blender vb." },
|
||||
{ "id": 150, "name": "Kahve Makinesi", "slug": "home-coffee-machine", "parentId": 149, "description": "Filtre, espresso, kapsül ve Türk kahvesi makineleri." },
|
||||
{ "id": 151, "name": "Blender", "slug": "home-blender", "parentId": 149, "description": "Smoothie, çorba ve karıştırma işlemleri için blender ve el blender setleri." },
|
||||
{ "id": 152, "name": "Airfryer", "slug": "home-airfryer", "parentId": 149, "description": "Az yağ ile pişirme yapmaya yarayan airfryer cihazları ve aksesuarları." },
|
||||
{ "id": 153, "name": "Süpürge", "slug": "home-vacuum", "parentId": 149, "description": "Dikey, toz torbalı/torbasız ve robot süpürge dahil ev süpürgeleri." },
|
||||
|
||||
{ "id": 154, "name": "Aydınlatma", "slug": "home-lighting", "parentId": 5, "description": "Avize, lambader, masa lambası ve LED aydınlatma çözümleri." },
|
||||
{ "id": 155, "name": "Dekorasyon", "slug": "home-decor", "parentId": 5, "description": "Evi kişiselleştiren dekoratif ürünler; aksesuar, tablo, obje ve benzerleri." },
|
||||
{ "id": 156, "name": "Halı", "slug": "home-rug", "parentId": 155, "description": "Salon, koridor ve oda için halılar; farklı ölçü ve materyal seçenekleri." },
|
||||
{ "id": 157, "name": "Duvar Dekoru", "slug": "home-wall-decor", "parentId": 155, "description": "Tablo, raf, ayna, sticker ve benzeri duvar dekor ürünleri." },
|
||||
|
||||
{ "id": 158, "name": "Temizlik", "slug": "home-cleaning", "parentId": 5, "description": "Ev temizliği için ürünler; deterjan, bez, sünger ve temizlik ekipmanları." },
|
||||
{ "id": 159, "name": "Deterjan", "slug": "home-detergent", "parentId": 158, "description": "Çamaşır, bulaşık ve yüzey temizliği için deterjan ve temizlik kimyasalları." },
|
||||
{ "id": 160, "name": "Kağıt Ürünleri", "slug": "home-paper-products", "parentId": 158, "description": "Tuvalet kağıdı, kağıt havlu, peçete ve benzeri kağıt temizlik ürünleri." },
|
||||
|
||||
{ "id": 161, "name": "El Aletleri", "slug": "home-tools", "parentId": 5, "description": "Ev ve hobi işleri için el aletleri, tamir ve montaj ekipmanları." },
|
||||
{ "id": 162, "name": "Matkap", "slug": "home-drill", "parentId": 161, "description": "Darbeli/darbesiz, şarjlı/kablolu matkap ve vidalama makineleri." },
|
||||
{ "id": 163, "name": "Testere", "slug": "home-saw", "parentId": 161, "description": "Ahşap/metal kesim için el testereleri ve elektrikli testere çeşitleri." },
|
||||
{ "id": 164, "name": "Vida & Dübel", "slug": "home-hardware", "parentId": 161, "description": "Montaj ve sabitleme için vida, dübel, bağlantı elemanları ve setler." },
|
||||
|
||||
{ "id": 165, "name": "Evcil Hayvan", "slug": "pet", "parentId": 5, "description": "Kedi, köpek ve diğer evcil hayvanlar için mama, bakım ve ihtiyaç ürünleri." },
|
||||
{ "id": 166, "name": "Kedi Maması", "slug": "pet-cat-food", "parentId": 165, "description": "Yavru/yetişkin kedi için kuru/yaş mama ve özel diyet mamaları." },
|
||||
{ "id": 167, "name": "Köpek Maması", "slug": "pet-dog-food", "parentId": 165, "description": "Yavru/yetişkin köpek için kuru/yaş mama ve özel ihtiyaç mamaları." },
|
||||
{ "id": 168, "name": "Kedi Kumu", "slug": "pet-cat-litter", "parentId": 165, "description": "Topaklanan/silikalı/bitkisel kedi kumları ve koku kontrol çözümleri." },
|
||||
|
||||
{ "id": 169, "name": "Kırtasiye & Ofis", "slug": "office", "parentId": 0, "description": "Okul ve ofis ihtiyaçları; kağıt ürünleri, yazım gereçleri ve aksesuarlar." },
|
||||
{ "id": 170, "name": "Kağıt & Defter", "slug": "office-paper-notebook", "parentId": 169, "description": "Defter, ajanda, not kağıdı ve farklı türde kağıt ürünleri." },
|
||||
{ "id": 171, "name": "A4 Kağıdı", "slug": "office-a4-paper", "parentId": 170, "description": "Yazıcı ve fotokopi için A4 kağıt; farklı gramaj ve kalite seçenekleri." },
|
||||
{ "id": 172, "name": "Kalem", "slug": "office-pen", "parentId": 169, "description": "Tükenmez, jel, kurşun, marker ve farklı amaçlara uygun kalemler." },
|
||||
{ "id": 173, "name": "Okul Çantası", "slug": "office-school-bag", "parentId": 169, "description": "Öğrenciler için sırt çantası, beslenme çantası ve okul çantaları." },
|
||||
|
||||
{ "id": 174, "name": "Bebek & Çocuk", "slug": "baby", "parentId": 0, "description": "Bebek ve çocuk bakım/bez, mama, ıslak mendil ve oyuncak ürünleri." },
|
||||
{ "id": 175, "name": "Bebek Bezi", "slug": "baby-diaper", "parentId": 174, "description": "Yeni doğan ve farklı bedenlerde bebek bezleri, külot bez seçenekleri." },
|
||||
{ "id": 176, "name": "Islak Mendil", "slug": "baby-wipes", "parentId": 174, "description": "Bebek bakımı için ıslak mendil; hassas cilt uyumlu seçenekler." },
|
||||
{ "id": 177, "name": "Bebek Maması", "slug": "baby-food", "parentId": 174, "description": "Bebekler için mama, ek gıda ve püre ürünleri." },
|
||||
{ "id": 178, "name": "Oyuncak", "slug": "baby-toys", "parentId": 174, "description": "Bebek ve çocuklar için eğitici, zeka ve oyun oyuncakları." },
|
||||
|
||||
{ "id": 179, "name": "Spor & Outdoor", "slug": "sports", "parentId": 0, "description": "Spor ekipmanları ve outdoor ürünleri; kamp, fitness, bisiklet ve daha fazlası." },
|
||||
{ "id": 180, "name": "Kamp", "slug": "sports-camping", "parentId": 179, "description": "Çadır, uyku tulumu, kamp sandalyesi ve kamp ekipmanları." },
|
||||
{ "id": 181, "name": "Fitness", "slug": "sports-fitness", "parentId": 179, "description": "Ağırlık, dambıl, mat ve evde antrenman için fitness ekipmanları." },
|
||||
{ "id": 182, "name": "Bisiklet", "slug": "sports-bicycle", "parentId": 179, "description": "Şehir/dağ/katlanır bisikletler ve bisiklet aksesuarları." },
|
||||
|
||||
{ "id": 183, "name": "Moda", "slug": "fashion", "parentId": 0, "description": "Giyim, ayakkabı ve aksesuar ürünleri; kadın/erkek moda kategorileri." },
|
||||
{ "id": 184, "name": "Ayakkabı", "slug": "fashion-shoes", "parentId": 183, "description": "Spor ayakkabı, günlük ayakkabı ve farklı kullanım amaçlarına uygun modeller." },
|
||||
{ "id": 185, "name": "Erkek Giyim", "slug": "fashion-men", "parentId": 183, "description": "Erkek kıyafetleri; tişört, gömlek, pantolon, mont ve daha fazlası." },
|
||||
{ "id": 186, "name": "Kadın Giyim", "slug": "fashion-women", "parentId": 183, "description": "Kadın kıyafetleri; elbise, bluz, pantolon, mont ve daha fazlası." },
|
||||
{ "id": 187, "name": "Çanta", "slug": "fashion-bags", "parentId": 183, "description": "Sırt çantası, el çantası, valiz ve farklı kullanım amaçlı çantalar." },
|
||||
|
||||
{ "id": 188, "name": "Kitap & Medya", "slug": "books-media", "parentId": 0, "description": "Kitaplar, dijital içerikler, oyun ve medya ürünleri." },
|
||||
{ "id": 189, "name": "Kitap", "slug": "books", "parentId": 188, "description": "Roman, kişisel gelişim, eğitim ve diğer türlerde basılı kitaplar." },
|
||||
{ "id": 190, "name": "Dijital Oyun (Genel)", "slug": "digital-games", "parentId": 191, "description": "PC/konsol platformları için dijital oyunlar, kodlar ve dijital içerikler." },
|
||||
{ "id": 191, "name": "Oyun", "slug": "games", "parentId": 0, "description": "Konsol, PC ve dijital oyun fırsatları; oyun ekipmanları ve abonelikler." }
|
||||
|
||||
]
|
||||
7599
prisma/deals.json
7599
prisma/deals.json
File diff suppressed because it is too large
Load Diff
|
|
@ -1,231 +0,0 @@
|
|||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
|
||||
]
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Category" ADD COLUMN "description" TEXT NOT NULL DEFAULT '';
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Deal" ADD COLUMN "originalPrice" DOUBLE PRECISION,
|
||||
ADD COLUMN "percentOff" DOUBLE PRECISION,
|
||||
ADD COLUMN "shippingPrice" DOUBLE PRECISION;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Seller" ADD COLUMN "sellerLogo" TEXT NOT NULL DEFAULT '';
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Deal" ADD COLUMN "maxNotifiedMilestone" INTEGER NOT NULL DEFAULT 0;
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
/*
|
||||
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';
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
-- 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);
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Deal" ADD COLUMN "barcodeId" TEXT;
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "UserInterestProfile" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
ALTER TABLE "Notification"
|
||||
ADD COLUMN "extras" JSONB;
|
||||
|
|
@ -23,10 +23,6 @@ model User {
|
|||
passwordHash String
|
||||
avatarUrl String? @db.VarChar(512)
|
||||
role UserRole @default(USER)
|
||||
notificationCount Int @default(0)
|
||||
mutedUntil DateTime?
|
||||
suspendedUntil DateTime?
|
||||
disabledAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
|
@ -40,54 +36,6 @@ model User {
|
|||
dealNotices DealNotice[] @relation("UserDealNotices")
|
||||
|
||||
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 {
|
||||
|
|
@ -133,16 +81,12 @@ enum AffiliateType {
|
|||
USER_AFFILIATE
|
||||
}
|
||||
|
||||
enum DiscountType {
|
||||
PERCENT
|
||||
AMOUNT
|
||||
}
|
||||
|
||||
model SellerDomain {
|
||||
id Int @id @default(autoincrement())
|
||||
domain String @unique
|
||||
sellerId Int
|
||||
seller Seller @relation(fields: [sellerId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
createdById Int
|
||||
createdBy User @relation(fields: [createdById], references: [id])
|
||||
|
|
@ -152,7 +96,6 @@ model Seller {
|
|||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
url String @default("")
|
||||
sellerLogo String @default("")
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
createdById Int
|
||||
|
|
@ -170,9 +113,8 @@ model Category {
|
|||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
slug String @unique
|
||||
description String @default("")
|
||||
|
||||
parentId Int?
|
||||
isActive Boolean @default(true)
|
||||
parent Category? @relation("CategoryParent", fields: [parentId], references: [id])
|
||||
children Category[] @relation("CategoryParent")
|
||||
|
||||
|
|
@ -188,8 +130,6 @@ model Tag {
|
|||
id Int @id @default(autoincrement())
|
||||
slug String @unique
|
||||
name String
|
||||
usageCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
dealTags DealTag[]
|
||||
}
|
||||
|
|
@ -217,15 +157,7 @@ model Deal {
|
|||
description String?
|
||||
url String?
|
||||
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
|
||||
score Int @default(0)
|
||||
commentCount Int @default(0)
|
||||
|
|
@ -254,46 +186,10 @@ model Deal {
|
|||
// NEW: tags (multiple, optional)
|
||||
dealTags DealTag[]
|
||||
aiReview DealAiReview?
|
||||
analyticsTotal DealAnalyticsTotal?
|
||||
events DealEvent[]
|
||||
savedBy DealSave[]
|
||||
reports DealReport[]
|
||||
@@index([categoryId, createdAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
model DealSave {
|
||||
userId Int
|
||||
dealId Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, dealId])
|
||||
@@index([userId, createdAt])
|
||||
@@index([dealId])
|
||||
}
|
||||
|
||||
model DealReport {
|
||||
id Int @id @default(autoincrement())
|
||||
dealId Int
|
||||
userId Int
|
||||
reason DealReportReason
|
||||
note String?
|
||||
status DealReportStatus @default(OPEN)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([dealId, userId])
|
||||
@@index([dealId, createdAt])
|
||||
@@index([userId, createdAt])
|
||||
@@index([status, createdAt])
|
||||
}
|
||||
|
||||
enum DealNoticeSeverity {
|
||||
INFO
|
||||
WARNING
|
||||
|
|
@ -371,7 +267,6 @@ model Comment {
|
|||
dealId Int
|
||||
|
||||
parentId Int?
|
||||
likeCount Int @default(0)
|
||||
|
||||
deletedAt DateTime?
|
||||
|
||||
|
|
@ -380,29 +275,13 @@ model Comment {
|
|||
|
||||
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
replies Comment[] @relation("CommentReplies")
|
||||
likes CommentLike[]
|
||||
|
||||
@@index([dealId, createdAt])
|
||||
@@index([parentId, createdAt])
|
||||
@@index([dealId, parentId, createdAt])
|
||||
@@index([userId, createdAt])
|
||||
@@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 {
|
||||
NONE
|
||||
PROFANITY
|
||||
|
|
@ -412,26 +291,6 @@ enum DealAiIssueType {
|
|||
OTHER
|
||||
}
|
||||
|
||||
enum DealEventType {
|
||||
IMPRESSION
|
||||
VIEW
|
||||
CLICK
|
||||
}
|
||||
|
||||
enum DealReportReason {
|
||||
EXPIRED
|
||||
WRONG_PRICE
|
||||
MISLEADING
|
||||
SPAM
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum DealReportStatus {
|
||||
OPEN
|
||||
REVIEWED
|
||||
CLOSED
|
||||
}
|
||||
|
||||
model DealAiReview {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
|
|
@ -439,7 +298,6 @@ model DealAiReview {
|
|||
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||
|
||||
bestCategoryId Int
|
||||
tags String[] @default([])
|
||||
needsReview Boolean @default(false)
|
||||
|
||||
hasIssue Boolean @default(false)
|
||||
|
|
@ -451,72 +309,3 @@ model DealAiReview {
|
|||
|
||||
@@index([needsReview, hasIssue, updatedAt])
|
||||
}
|
||||
|
||||
model AuditEvent {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int?
|
||||
action String
|
||||
ip String?
|
||||
userAgent String?
|
||||
meta Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([action, createdAt])
|
||||
}
|
||||
|
||||
model 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])
|
||||
}
|
||||
|
|
|
|||
222
prisma/seed.js
222
prisma/seed.js
|
|
@ -1,5 +1,6 @@
|
|||
// prisma/seed.js
|
||||
const { PrismaClient, DealStatus, SaleType, AffiliateType } = require("@prisma/client")
|
||||
const bcrypt = require("bcryptjs")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
|
|
@ -9,13 +10,6 @@ function randInt(min, max) {
|
|||
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)
|
||||
function realImage(seed, w = 1200, h = 900) {
|
||||
return `https://picsum.photos/seed/${encodeURIComponent(seed)}/${w}/${h}`
|
||||
|
|
@ -33,18 +27,6 @@ function normalizeSlug(s) {
|
|||
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) {
|
||||
const s = normalizeSlug(slug)
|
||||
return prisma.tag.upsert({
|
||||
|
|
@ -84,7 +66,6 @@ function loadCategoriesJson(filePath) {
|
|||
id: Number(c.id),
|
||||
name: String(c.name ?? "").trim(),
|
||||
slug: normalizeSlug(c.slug),
|
||||
description: c.description,
|
||||
parentId: c.parentId === null || c.parentId === undefined ? null : Number(c.parentId),
|
||||
}))
|
||||
|
||||
|
|
@ -121,13 +102,11 @@ async function seedCategoriesFromJson(categoriesFilePath) {
|
|||
update: {
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
description: c.description,
|
||||
},
|
||||
create: {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
description: c.description,
|
||||
parentId: null,
|
||||
},
|
||||
})
|
||||
|
|
@ -156,167 +135,63 @@ async function seedCategoriesFromJson(categoriesFilePath) {
|
|||
return { count: categories.length }
|
||||
}
|
||||
|
||||
function loadSellersJson(filePath) {
|
||||
const raw = fs.readFileSync(filePath, "utf-8")
|
||||
const arr = JSON.parse(raw)
|
||||
|
||||
if (!Array.isArray(arr)) throw new Error("sellers.json array olmalı")
|
||||
|
||||
const sellers = arr.map((s) => ({
|
||||
name: String(s.name ?? "").trim(),
|
||||
url: String(s.url ?? "").trim(),
|
||||
sellerLogo: String(s.sellerLogo ?? "").trim(),
|
||||
isActive: s.isActive === undefined ? true : Boolean(s.isActive),
|
||||
createdAt: toDateOrNull(s.createdAt),
|
||||
createdById: toNumberOrNull(s.createdById),
|
||||
}))
|
||||
|
||||
for (const s of sellers) {
|
||||
if (!s.name) throw new Error("Seller name boÅŸ olamaz")
|
||||
}
|
||||
|
||||
return sellers
|
||||
}
|
||||
|
||||
async function seedSellersFromJson(filePath, fallbackCreatedById) {
|
||||
const sellers = loadSellersJson(filePath)
|
||||
let count = 0
|
||||
|
||||
for (const s of sellers) {
|
||||
const createdById = s.createdById ?? fallbackCreatedById
|
||||
if (!createdById) throw new Error(`Seller createdById eksik: ${s.name}`)
|
||||
|
||||
const createData = {
|
||||
name: s.name,
|
||||
url: s.url,
|
||||
sellerLogo: s.sellerLogo,
|
||||
isActive: s.isActive,
|
||||
createdById,
|
||||
}
|
||||
if (s.createdAt) createData.createdAt = s.createdAt
|
||||
|
||||
await prisma.seller.upsert({
|
||||
where: { name: s.name },
|
||||
update: { url: s.url, sellerLogo: s.sellerLogo, isActive: s.isActive },
|
||||
create: createData,
|
||||
})
|
||||
count++
|
||||
}
|
||||
|
||||
return { count }
|
||||
}
|
||||
|
||||
function loadDealsJson(filePath) {
|
||||
const raw = fs.readFileSync(filePath, "utf-8")
|
||||
const arr = JSON.parse(raw)
|
||||
|
||||
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.json’dan seed + her deal’a 3 foto + score 0-200 + tarih dağılımı:
|
||||
// 30 deal seed + her deal'a 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: 10 gün önce civarı (9-11 gün arası)
|
||||
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 adet olacak şekilde çoğalt (title/url benzersizleşsin)
|
||||
// 30'a tamamlamak için ikinci bir set üret (title/url benzersiz olsun)
|
||||
const items = []
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const base = baseItems[i % baseItems.length]
|
||||
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({
|
||||
title: `${base.title} #${n}`,
|
||||
price,
|
||||
originalPrice,
|
||||
shippingPrice,
|
||||
url: `${base.url}${base.url.includes("?") ? "&" : "?"}seed=${n}`,
|
||||
q: base.q || "product",
|
||||
price: Number((base.price * (0.9 + (randInt(0, 30) / 100))).toFixed(2)),
|
||||
url: `${base.url}?seed=${n}`,
|
||||
q: base.q,
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; 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 createdAt = older
|
||||
? new Date(Date.now() - randInt(9, 11) * 24 * 60 * 60 * 1000 - randInt(0, 12) * 60 * 60 * 1000)
|
||||
: randomDateWithinLastDays(5)
|
||||
|
||||
// Not: modelinde score yoksa score satırını sil
|
||||
const dealData = {
|
||||
title: it.title,
|
||||
description: "Seed test deal açıklaması (otomatik üretim).",
|
||||
url: it.url,
|
||||
price: it.price,
|
||||
originalPrice: it.originalPrice ?? null,
|
||||
shippingPrice: it.shippingPrice ?? null,
|
||||
status: DealStatus.ACTIVE,
|
||||
saletype: SaleType.ONLINE,
|
||||
affiliateType: AffiliateType.NON_AFFILIATE,
|
||||
commentCount: randInt(0, 25),
|
||||
userId,
|
||||
sellerId,
|
||||
categoryId: pickRandomCategoryId(categoryIds, defaultCategoryId),
|
||||
categoryId,
|
||||
score: randInt(0, 200),
|
||||
createdAt,
|
||||
}
|
||||
|
|
@ -367,11 +242,17 @@ async function main() {
|
|||
},
|
||||
})
|
||||
|
||||
// ---------- SELLERS (FROM JSON) ----------
|
||||
const sellersFilePath = path.join(__dirname, "sellers.json")
|
||||
await seedSellersFromJson(sellersFilePath, admin.id)
|
||||
const amazon = await prisma.seller.findUnique({ where: { name: "Amazon" } })
|
||||
if (!amazon) throw new Error("Amazon seller bulunamadı (sellers.json)")
|
||||
// ---------- SELLER ----------
|
||||
const amazon = await prisma.seller.upsert({
|
||||
where: { name: "Amazon" },
|
||||
update: { isActive: true },
|
||||
create: {
|
||||
name: "Amazon",
|
||||
url: "https://www.amazon.com.tr",
|
||||
isActive: true,
|
||||
createdById: admin.id,
|
||||
},
|
||||
})
|
||||
|
||||
// ---------- SELLER DOMAINS ----------
|
||||
const domains = ["amazon.com", "amazon.com.tr"]
|
||||
|
|
@ -388,19 +269,13 @@ async function main() {
|
|||
}
|
||||
|
||||
// ---------- CATEGORIES (FROM JSON) ----------
|
||||
const categoriesFilePath = path.join(__dirname, "categories.json")
|
||||
const categoriesFilePath = path.join(__dirname, "", "categories.json")
|
||||
const { count } = await seedCategoriesFromJson(categoriesFilePath)
|
||||
|
||||
const catSSD = await prisma.category.findUnique({
|
||||
where: { slug: "pc-ssd" },
|
||||
select: { id: true },
|
||||
})
|
||||
const availableCategoryIds = (
|
||||
await prisma.category.findMany({
|
||||
where: { isActive: true, id: { gt: 0 } },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((cat) => cat.id)
|
||||
|
||||
// ---------- TAGS ----------
|
||||
await upsertTagBySlug("ssd", "SSD")
|
||||
|
|
@ -419,15 +294,13 @@ async function main() {
|
|||
description: "Test deal açıklaması",
|
||||
url: dealUrl,
|
||||
price: 1299.99,
|
||||
originalPrice: 1499.99, // örnek
|
||||
shippingPrice: 0, // örnek
|
||||
status: DealStatus.ACTIVE,
|
||||
saletype: SaleType.ONLINE,
|
||||
affiliateType: AffiliateType.NON_AFFILIATE,
|
||||
commentCount: 1,
|
||||
userId: user.id,
|
||||
sellerId: amazon.id,
|
||||
categoryId: pickRandomCategoryId(availableCategoryIds, catSSD?.id ?? 0),
|
||||
categoryId: catSSD?.id ?? 0,
|
||||
// score: randInt(0, 200), // modelinde varsa aç
|
||||
}
|
||||
|
||||
|
|
@ -448,14 +321,11 @@ async function main() {
|
|||
],
|
||||
})
|
||||
|
||||
// ✅ ---------- deals.json’dan 30 DEAL ÜRET ----------
|
||||
const dealsFilePath = path.join(__dirname, "deals.json")
|
||||
await seedDealsFromJson({
|
||||
// ✅ ---------- 30 DEAL ÜRET ----------
|
||||
await seedDeals30({
|
||||
userId: user.id,
|
||||
sellerId: amazon.id,
|
||||
categoryIds: availableCategoryIds,
|
||||
defaultCategoryId: catSSD?.id ?? 0,
|
||||
dealsFilePath,
|
||||
categoryId: catSSD?.id ?? 0,
|
||||
})
|
||||
|
||||
// ---------- VOTE ----------
|
||||
|
|
@ -477,7 +347,7 @@ async function main() {
|
|||
}
|
||||
|
||||
console.log(`✅ Seed tamamlandı (categories.json yüklendi: ${count} kategori)`)
|
||||
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")
|
||||
console.log("✅ 30 adet test deal + 3'er görsel + score(0-200) + tarih dağılımı eklendi/güncellendi")
|
||||
}
|
||||
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,37 +1,16 @@
|
|||
const express = require("express")
|
||||
const multer = require("multer")
|
||||
const requireAuth = require("../middleware/requireAuth.js")
|
||||
const {
|
||||
getUserProfile,
|
||||
markAllNotificationsRead,
|
||||
getUserNotificationsPage,
|
||||
changePassword,
|
||||
} = require("../services/profile.service")
|
||||
const { getUserProfile } = require("../services/profile.service")
|
||||
const { endpoints } = require("@shared/contracts")
|
||||
|
||||
const router = express.Router()
|
||||
const upload = multer({ dest: "uploads/" })
|
||||
|
||||
const { updateUserAvatar } = require("../services/avatar.service")
|
||||
const { enqueueAuditFromRequest, buildAuditMeta } = require("../services/audit.service")
|
||||
const { AUDIT_ACTIONS } = require("../services/auditActions")
|
||||
|
||||
const { account } = endpoints
|
||||
|
||||
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(
|
||||
"/avatar",
|
||||
requireAuth,
|
||||
|
|
@ -39,15 +18,6 @@ router.post(
|
|||
async (req, res) => {
|
||||
try {
|
||||
const updatedUser = await updateUserAvatar(req.auth.userId, req.file)
|
||||
enqueueAuditFromRequest(
|
||||
req,
|
||||
AUDIT_ACTIONS.ACCOUNT.AVATAR_UPDATE,
|
||||
buildAuditMeta({
|
||||
entityType: "USER",
|
||||
entityId: req.auth.userId,
|
||||
after: { avatarUrl: updatedUser.avatarUrl ?? null },
|
||||
})
|
||||
)
|
||||
|
||||
res.json(
|
||||
account.avatarUploadResponseSchema.parse({
|
||||
|
|
@ -65,61 +35,10 @@ router.post(
|
|||
router.get("/me", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = await getUserProfile(req.auth.userId)
|
||||
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))
|
||||
res.json(account.accountMeResponseSchema.parse(user))
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
|
|
|
|||
|
|
@ -13,37 +13,32 @@ const { mapMeRequestToUserId, mapMeResultToResponse } = require("../adapters/res
|
|||
|
||||
const { auth } = endpoints
|
||||
|
||||
// NOT: app.js’de cookie-parser olmali:
|
||||
// NOT: app.js’de cookie-parser olmalı:
|
||||
// const cookieParser = require("cookie-parser")
|
||||
// app.use(cookieParser())
|
||||
|
||||
function getCookieOptions() {
|
||||
const isProd = process.env.NODE_ENV === "production"
|
||||
|
||||
// DEV: http localhost -> secure false, sameSite lax
|
||||
if (!isProd) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
}
|
||||
}
|
||||
|
||||
// PROD: cross-site kullanacaksan (frontend ayrı domain)
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProd,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
sameSite: "none",
|
||||
path: "/",
|
||||
}
|
||||
}
|
||||
|
||||
function parseExpiresInToMs(value) {
|
||||
if (!value) return 15 * 60 * 1000
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value * 1000
|
||||
const str = String(value).trim().toLowerCase()
|
||||
const match = str.match(/^(\d+)(ms|s|m|h|d)?$/)
|
||||
if (!match) return 15 * 60 * 1000
|
||||
const n = Number(match[1])
|
||||
const unit = match[2] || "s"
|
||||
const mult =
|
||||
unit === "ms" ? 1 :
|
||||
unit === "s" ? 1000 :
|
||||
unit === "m" ? 60 * 1000 :
|
||||
unit === "h" ? 60 * 60 * 1000 :
|
||||
unit === "d" ? 24 * 60 * 60 * 1000 :
|
||||
1000
|
||||
return n * mult
|
||||
}
|
||||
|
||||
function setRefreshCookie(res, refreshToken) {
|
||||
const opts = getCookieOptions()
|
||||
|
|
@ -51,22 +46,11 @@ function setRefreshCookie(res, refreshToken) {
|
|||
res.cookie("rt", refreshToken, { ...opts, maxAge: maxAgeMs })
|
||||
}
|
||||
|
||||
function setAccessCookie(res, accessToken) {
|
||||
const opts = getCookieOptions()
|
||||
const maxAgeMs = parseExpiresInToMs(process.env.ACCESS_TOKEN_EXPIRES_IN || "15m")
|
||||
res.cookie("at", accessToken, { ...opts, maxAge: maxAgeMs })
|
||||
}
|
||||
|
||||
function clearRefreshCookie(res) {
|
||||
const opts = getCookieOptions()
|
||||
res.clearCookie("rt", { ...opts })
|
||||
}
|
||||
|
||||
function clearAccessCookie(res) {
|
||||
const opts = getCookieOptions()
|
||||
res.clearCookie("at", { ...opts })
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/register",
|
||||
validate(auth.registerRequestSchema, "body", "validatedRegisterInput"),
|
||||
|
|
@ -79,10 +63,10 @@ router.post(
|
|||
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
|
||||
})
|
||||
|
||||
// refresh + access cookie set
|
||||
// refresh cookie set
|
||||
if (result.refreshToken) setRefreshCookie(res, result.refreshToken)
|
||||
if (result.accessToken) setAccessCookie(res, result.accessToken)
|
||||
|
||||
// response body: access + user (adapter refresh'i koymamalı)
|
||||
const response = auth.authResponseSchema.parse(mapRegisterResultToResponse(result))
|
||||
res.json(response)
|
||||
} catch (err) {
|
||||
|
|
@ -104,18 +88,21 @@ router.post(
|
|||
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
|
||||
})
|
||||
|
||||
// refresh + access cookie set
|
||||
// refresh cookie set
|
||||
setRefreshCookie(res, result.refreshToken)
|
||||
setAccessCookie(res, result.accessToken)
|
||||
|
||||
const response = auth.authResponseSchema.parse(mapLoginResultToResponse(result))
|
||||
res.json(response)
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500
|
||||
res.status(status).json({
|
||||
message: err.statusCode ? err.message : "Giris islemi basarisiz.",
|
||||
})
|
||||
}
|
||||
console.error("LOGIN ERROR:", err) // <-- ekle
|
||||
console.error("LOGIN ERROR MSG:", err?.message)
|
||||
console.error("LOGIN ERROR STACK:", err?.stack)
|
||||
|
||||
const status = err.statusCode || 500
|
||||
res.status(status).json({
|
||||
message: err.statusCode ? err.message : "Giris islemi basarisiz.",
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -129,15 +116,14 @@ router.post("/refresh", async (req, res) => {
|
|||
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
|
||||
})
|
||||
|
||||
// rotate -> yeni refresh + access cookie
|
||||
// rotate -> yeni refresh cookie
|
||||
setRefreshCookie(res, result.refreshToken)
|
||||
setAccessCookie(res, result.accessToken)
|
||||
|
||||
// body: access + user (adapter refresh'i koymamalı)
|
||||
const response = auth.authResponseSchema.parse(mapLoginResultToResponse(result))
|
||||
res.json(response)
|
||||
} catch (err) {
|
||||
clearRefreshCookie(res)
|
||||
clearAccessCookie(res)
|
||||
const status = err.statusCode || 401
|
||||
res.status(status).json({ message: err.message || "Refresh basarisiz" })
|
||||
}
|
||||
|
|
@ -147,19 +133,15 @@ router.post("/logout", async (req, res) => {
|
|||
try {
|
||||
const refreshToken = req.cookies?.rt
|
||||
|
||||
// logout idempotent olsun
|
||||
if (refreshToken) {
|
||||
await authService.logout({
|
||||
refreshToken,
|
||||
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
|
||||
})
|
||||
await authService.logout({ refreshToken })
|
||||
}
|
||||
|
||||
clearRefreshCookie(res)
|
||||
clearAccessCookie(res)
|
||||
res.status(204).send()
|
||||
} catch (err) {
|
||||
clearRefreshCookie(res)
|
||||
clearAccessCookie(res)
|
||||
const status = err.statusCode || 500
|
||||
res.status(status).json({ message: err.message || "Cikis basarisiz" })
|
||||
}
|
||||
|
|
@ -167,7 +149,7 @@ router.post("/logout", async (req, res) => {
|
|||
|
||||
router.get("/me", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const userId = mapMeRequestToUserId(req)
|
||||
const userId = mapMeRequestToUserId(req) // req.auth.userId okumalı
|
||||
const user = await authService.getMe(userId)
|
||||
const response = auth.meResponseSchema.parse(mapMeResultToResponse(user))
|
||||
res.json(response)
|
||||
|
|
@ -178,4 +160,3 @@ router.get("/me", requireAuth, async (req, res) => {
|
|||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
const express = require("express");
|
||||
const categoryService = require("../services/category.service"); // Kategori servisi
|
||||
const router = express.Router();
|
||||
const optionalAuth = require("../middleware/optionalAuth")
|
||||
const { mapCategoryToCategoryDetailsResponse }=require("../adapters/responses/categoryDetails.adapter")
|
||||
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter")
|
||||
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
|
||||
const { getClientIp } = require("../utils/requestInfo")
|
||||
const { queueDealImpressions } = require("../services/redis/dealAnalytics.service")
|
||||
const { trackUserCategoryInterest, USER_INTEREST_ACTIONS } = require("../services/userInterest.service")
|
||||
|
||||
|
||||
router.get("/:slug", async (req, res) => {
|
||||
|
|
@ -26,10 +22,7 @@ router.get("/:slug", 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) => {
|
||||
router.get("/:slug/deals", async (req, res) => {
|
||||
const { slug } = req.params;
|
||||
const { page = 1, limit = 10, ...filters } = req.query;
|
||||
|
||||
|
|
@ -40,38 +33,13 @@ router.get("/:slug/deals", optionalAuth, async (req, res) => {
|
|||
}
|
||||
|
||||
// Kategorinin fırsatlarını alıyoruz
|
||||
const payload = await categoryService.getDealsByCategoryId(category.id, {
|
||||
page,
|
||||
limit,
|
||||
filters,
|
||||
viewer: buildViewer(req),
|
||||
});
|
||||
const deals = await categoryService.getDealsByCategoryId(category.id, page, limit, filters);
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
res.json({
|
||||
page: response.page,
|
||||
total: response.total,
|
||||
totalPages: response.totalPages,
|
||||
results: response.results,
|
||||
minPrice: payload?.minPrice ?? null,
|
||||
maxPrice: payload?.maxPrice ?? null,
|
||||
})
|
||||
// frontend DealCard[] bekliyor
|
||||
res.json(response.results)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Kategoriye ait fırsatlar alınırken bir hata oluştu", message: err.message });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
const express = require("express")
|
||||
const requireAuth = require("../middleware/requireAuth.js")
|
||||
const requireNotRestricted = require("../middleware/requireNotRestricted")
|
||||
const optionalAuth = require("../middleware/optionalAuth")
|
||||
const { validate } = require("../middleware/validate.middleware")
|
||||
const { endpoints } = require("@shared/contracts")
|
||||
const { createComment, deleteComment } = require("../services/comment.service")
|
||||
const { enqueueAuditFromRequest, buildAuditMeta } = require("../services/audit.service")
|
||||
const { AUDIT_ACTIONS } = require("../services/auditActions")
|
||||
|
||||
const dealCommentAdapter = require("../adapters/responses/comment.adapter")
|
||||
const commentService = require("../services/comment.service")
|
||||
|
|
@ -16,28 +12,13 @@ const { comments } = endpoints
|
|||
|
||||
router.get(
|
||||
"/:dealId",
|
||||
optionalAuth,
|
||||
validate(comments.commentListRequestSchema, "params", "validatedDealId"),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { dealId } = req.validatedDealId
|
||||
const { parentId, page, limit, sort } = req.query
|
||||
const payload = await commentService.getCommentsByDealId(dealId, {
|
||||
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,
|
||||
})
|
||||
)
|
||||
const fetched = await commentService.getCommentsByDealId(dealId)
|
||||
const mapped = dealCommentAdapter.mapCommentsToDealCommentResponse(fetched)
|
||||
res.json(comments.commentListResponseSchema.parse(mapped))
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message })
|
||||
}
|
||||
|
|
@ -47,7 +28,6 @@ router.get(
|
|||
router.post(
|
||||
"/",
|
||||
requireAuth,
|
||||
requireNotRestricted({ checkMute: true, checkSuspend: true }),
|
||||
validate(comments.commentCreateRequestSchema, "body", "validatedCommentPayload"),
|
||||
async (req, res) => {
|
||||
try {
|
||||
|
|
@ -56,15 +36,6 @@ router.post(
|
|||
|
||||
const comment = await createComment({ dealId, userId, text, parentId })
|
||||
const mapped = dealCommentAdapter.mapCommentToDealCommentResponse(comment)
|
||||
enqueueAuditFromRequest(
|
||||
req,
|
||||
AUDIT_ACTIONS.COMMENT.CREATE,
|
||||
buildAuditMeta({
|
||||
entityType: "COMMENT",
|
||||
entityId: comment.id,
|
||||
extra: { dealId, parentId: parentId ?? null },
|
||||
})
|
||||
)
|
||||
res.json(comments.commentCreateResponseSchema.parse(mapped))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message || "Sunucu hatasi" })
|
||||
|
|
@ -80,14 +51,6 @@ router.delete(
|
|||
try {
|
||||
const { id } = req.validatedDeleteComment
|
||||
const result = await deleteComment(id, req.auth.userId)
|
||||
enqueueAuditFromRequest(
|
||||
req,
|
||||
AUDIT_ACTIONS.COMMENT.DELETE,
|
||||
buildAuditMeta({
|
||||
entityType: "COMMENT",
|
||||
entityId: Number(id),
|
||||
})
|
||||
)
|
||||
res.json(comments.commentDeleteResponseSchema.parse(result))
|
||||
} catch (err) {
|
||||
const status = err.message?.includes("yetkin") ? 403 : 404
|
||||
|
|
@ -97,4 +60,3 @@ router.delete(
|
|||
)
|
||||
|
||||
module.exports = router
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
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
|
||||
|
|
@ -3,61 +3,20 @@ const express = require("express")
|
|||
const router = express.Router()
|
||||
|
||||
const requireAuth = require("../middleware/requireAuth")
|
||||
const requireNotRestricted = require("../middleware/requireNotRestricted")
|
||||
const optionalAuth = require("../middleware/optionalAuth")
|
||||
const { upload } = require("../middleware/upload.middleware")
|
||||
const { validate } = require("../middleware/validate.middleware")
|
||||
const { endpoints } = require("@shared/contracts")
|
||||
const requireApiKey = require("../middleware/requireApiKey")
|
||||
|
||||
const userDB = require("../db/user.db")
|
||||
const {
|
||||
getDeals,
|
||||
getDealById,
|
||||
createDeal,
|
||||
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 { getDeals, getDealById, createDeal } = require("../services/deal.service")
|
||||
|
||||
const { mapCreateDealRequestToDealCreateData } = require("../adapters/requests/dealCreate.adapter")
|
||||
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.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 { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
|
||||
|
||||
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 buildViewer = (req) =>
|
||||
|
|
@ -67,7 +26,7 @@ function createListHandler(preset) {
|
|||
return async (req, res) => {
|
||||
try {
|
||||
const viewer = buildViewer(req)
|
||||
const { q, page, limit, hotListId, trendingListId } = req.validatedDealListQuery
|
||||
const { q, page, limit } = req.validatedDealListQuery
|
||||
|
||||
const payload = await getDeals({
|
||||
preset,
|
||||
|
|
@ -75,20 +34,11 @@ function createListHandler(preset) {
|
|||
page,
|
||||
limit,
|
||||
viewer,
|
||||
filters: req.query,
|
||||
hotListId,
|
||||
trendingListId,
|
||||
})
|
||||
|
||||
const response = deals.dealsListResponseSchema.parse(
|
||||
mapPaginatedDealsToDealCardResponse(payload)
|
||||
)
|
||||
const dealIds = payload?.results?.map((deal) => deal.id) || []
|
||||
queueDealImpressions({
|
||||
dealIds,
|
||||
userId: req.auth?.userId ?? null,
|
||||
ip: getClientIp(req),
|
||||
}).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
|
||||
res.json(response)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
|
@ -98,7 +48,6 @@ function createListHandler(preset) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// Public deals of a user (viewer optional; self profile => "MY" else "USER_PUBLIC")
|
||||
router.get(
|
||||
"/users/:userName/deals",
|
||||
|
|
@ -115,7 +64,7 @@ router.get(
|
|||
|
||||
if (!targetUser) return res.status(404).json({ error: "Kullanici bulunamadi" })
|
||||
|
||||
const { q, page, limit, hotListId, trendingListId } = req.validatedDealListQuery
|
||||
const { q, page, limit } = req.validatedDealListQuery
|
||||
const viewer = buildViewer(req)
|
||||
const isSelfProfile = viewer?.userId === targetUser.id
|
||||
const preset = isSelfProfile ? "MY" : "USER_PUBLIC"
|
||||
|
|
@ -127,20 +76,11 @@ router.get(
|
|||
limit,
|
||||
targetUserId: targetUser.id,
|
||||
viewer,
|
||||
filters: req.query,
|
||||
hotListId,
|
||||
trendingListId,
|
||||
})
|
||||
|
||||
const response = deals.dealsListResponseSchema.parse(
|
||||
mapPaginatedDealsToDealCardResponse(payload)
|
||||
)
|
||||
const dealIds = payload?.results?.map((deal) => deal.id) || []
|
||||
queueDealImpressions({
|
||||
dealIds,
|
||||
userId: req.auth?.userId ?? null,
|
||||
ip: getClientIp(req),
|
||||
}).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
|
||||
res.json(response)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
|
@ -158,262 +98,10 @@ router.get(
|
|||
createListHandler("MY")
|
||||
)
|
||||
|
||||
router.get("/new", requireApiKey, optionalAuth, listQueryValidator, createListHandler("NEW"))
|
||||
router.get("/hot", requireApiKey, optionalAuth, listQueryValidator, createListHandler("HOT"))
|
||||
router.get("/trending", requireApiKey, optionalAuth, listQueryValidator, createListHandler("TRENDING"))
|
||||
|
||||
router.get("/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("/new", optionalAuth, listQueryValidator, createListHandler("NEW"))
|
||||
router.get("/hot", optionalAuth, listQueryValidator, createListHandler("HOT"))
|
||||
router.get("/trending", optionalAuth, listQueryValidator, createListHandler("TRENDING"))
|
||||
router.get("/", optionalAuth, listQueryValidator, createListHandler("NEW"))
|
||||
|
||||
router.get(
|
||||
"/search",
|
||||
|
|
@ -421,7 +109,7 @@ router.get(
|
|||
listQueryValidator,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { q, page, limit, hotListId, trendingListId } = req.validatedDealListQuery
|
||||
const { q, page, limit } = req.validatedDealListQuery
|
||||
if (!q || !q.trim()) {
|
||||
return res.json({ results: [], total: 0, totalPages: 0, page })
|
||||
}
|
||||
|
|
@ -432,22 +120,11 @@ router.get(
|
|||
page,
|
||||
limit,
|
||||
viewer: buildViewer(req),
|
||||
filters: req.query,
|
||||
baseWhere: { status: "ACTIVE" },
|
||||
hotListId,
|
||||
trendingListId,
|
||||
useRedisSearch: true,
|
||||
})
|
||||
|
||||
const response = deals.dealsListResponseSchema.parse(
|
||||
mapPaginatedDealsToDealCardResponse(payload)
|
||||
)
|
||||
const dealIds = payload?.results?.map((deal) => deal.id) || []
|
||||
queueDealImpressions({
|
||||
dealIds,
|
||||
userId: req.auth?.userId ?? null,
|
||||
ip: getClientIp(req),
|
||||
}).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
|
||||
res.json(response)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
|
@ -476,18 +153,11 @@ router.get("/top", optionalAuth, async (req, res) => {
|
|||
page: 1,
|
||||
limit,
|
||||
viewer,
|
||||
filters: req.query,
|
||||
})
|
||||
|
||||
const response = deals.dealsListResponseSchema.parse(
|
||||
mapPaginatedDealsToDealCardResponse(payload)
|
||||
)
|
||||
const dealIds = payload?.results?.map((deal) => deal.id) || []
|
||||
queueDealImpressions({
|
||||
dealIds,
|
||||
userId: req.auth?.userId ?? null,
|
||||
ip: getClientIp(req),
|
||||
}).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
|
||||
|
||||
// frontend DealCard[] bekliyor
|
||||
res.json(response.results)
|
||||
|
|
@ -498,50 +168,22 @@ router.get("/top", optionalAuth, async (req, res) => {
|
|||
}
|
||||
})
|
||||
|
||||
router.post(
|
||||
"/engagement",
|
||||
requireAuth,
|
||||
validate(deals.dealEngagementRequestSchema, "body", "validatedEngagement"),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.validatedEngagement
|
||||
const viewer = buildViewer(req)
|
||||
const engagement = await getDealEngagement(ids, viewer)
|
||||
res.json(deals.dealEngagementResponseSchema.parse(engagement))
|
||||
} catch (err) {
|
||||
const status = err.statusCode || 500
|
||||
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
router.get(
|
||||
"/:id",
|
||||
optionalAuth,
|
||||
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
|
||||
async (req, res) => {
|
||||
try {
|
||||
|
||||
const { id } = req.validatedDealId
|
||||
const deal = await getDealById(id, buildViewer(req))
|
||||
const deal = await getDealById(id)
|
||||
if (!deal) return res.status(404).json({ error: "Deal bulunamadi" })
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
const mapped = mapDealToDealDetailResponse(deal)
|
||||
|
||||
console.log(mapped)
|
||||
res.json(deals.dealDetailResponseSchema.parse(mapped))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
|
@ -554,7 +196,6 @@ router.get(
|
|||
router.post(
|
||||
"/",
|
||||
requireAuth,
|
||||
requireNotRestricted({ checkSuspend: true }),
|
||||
upload.array("images", 5),
|
||||
validate(deals.dealCreateRequestSchema, "body", "validatedDealPayload"),
|
||||
async (req, res) => {
|
||||
|
|
@ -567,23 +208,12 @@ router.post(
|
|||
|
||||
const deal = await createDeal(dealCreateData, req.files || [])
|
||||
const mapped = mapDealToDealDetailResponse(deal)
|
||||
enqueueAuditFromRequest(
|
||||
req,
|
||||
AUDIT_ACTIONS.DEAL.CREATE,
|
||||
buildAuditMeta({
|
||||
entityType: "DEAL",
|
||||
entityId: deal.id,
|
||||
after: { title: deal.title },
|
||||
})
|
||||
)
|
||||
res.json(deals.dealCreateResponseSchema.parse(mapped))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
const status = err.statusCode || 500
|
||||
res.status(status).json({ error: status >= 500 ? "Sunucu hatasi" : err.message })
|
||||
res.status(500).json({ error: "Sunucu hatasi" })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
module.exports = router
|
||||
|
||||
|
|
|
|||
|
|
@ -1,802 +0,0 @@
|
|||
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
|
||||
|
|
@ -2,47 +2,15 @@ const express = require("express")
|
|||
const router = express.Router()
|
||||
|
||||
const requireAuth = require("../middleware/requireAuth")
|
||||
const optionalAuth = require("../middleware/optionalAuth")
|
||||
const { validate } = require("../middleware/validate.middleware")
|
||||
const { endpoints } = require("@shared/contracts")
|
||||
const { getSellerByName, getDealsBySellerName, getActiveSellers } = require("../services/seller.service")
|
||||
const { findSellerFromLink } = require("../services/sellerLookup.service")
|
||||
const { getProductPreviewFromUrl } = require("../services/productPreview.service")
|
||||
const { setBarcodeForUrl } = require("../services/redis/linkPreviewCache.service")
|
||||
const { cacheLinkPreviewImages } = require("../services/linkPreviewImage.service")
|
||||
const { mapSellerToSellerDetailsResponse } = require("../adapters/responses/sellerDetails.adapter")
|
||||
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
|
||||
const { getClientIp } = require("../utils/requestInfo")
|
||||
const { queueDealImpressions } = require("../services/redis/dealAnalytics.service")
|
||||
const { findSellerFromLink } = require("../services/seller.service")
|
||||
|
||||
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")
|
||||
const { seller } = endpoints
|
||||
|
||||
router.post("/from-link", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sellerUrl = req.body.url
|
||||
if (!sellerUrl) return res.status(400).json({ error: "url parametresi zorunlu" })
|
||||
const [sellerLookup, 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 sellerLookup = await findSellerFromLink(sellerUrl)
|
||||
|
||||
const response = seller.sellerLookupResponseSchema.parse(
|
||||
sellerLookup
|
||||
|
|
@ -53,9 +21,8 @@ router.post("/from-link", requireAuth, async (req, res) => {
|
|||
name: sellerLookup.name,
|
||||
url: sellerLookup.url ?? null,
|
||||
},
|
||||
product,
|
||||
}
|
||||
: { found: false, seller: null, product }
|
||||
: { found: false, seller: null }
|
||||
)
|
||||
|
||||
return res.json(response)
|
||||
|
|
@ -65,60 +32,4 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
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
|
||||
|
||||
|
|
@ -2,26 +2,11 @@
|
|||
const express = require("express")
|
||||
const router = express.Router()
|
||||
const { validate } = require("../middleware/validate.middleware")
|
||||
const optionalAuth = require("../middleware/optionalAuth")
|
||||
const userService = require("../services/user.service")
|
||||
const userProfileAdapter = require("../adapters/responses/userProfile.adapter")
|
||||
const commentAdapter = require("../adapters/responses/comment.adapter")
|
||||
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
|
||||
const { endpoints } = require("@shared/contracts")
|
||||
const {
|
||||
getUserProfileFromRedis,
|
||||
setUserProfileInRedis,
|
||||
} = require("../services/redis/userProfileCache.service")
|
||||
|
||||
const { users } = endpoints
|
||||
const PROFILE_PAGE_SIZE = 15
|
||||
const PROFILE_CACHE_TTL_SECONDS = 60
|
||||
|
||||
function parsePage(value) {
|
||||
const num = Number(value)
|
||||
if (!Number.isInteger(num) || num < 1) return 1
|
||||
return num
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/:userName",
|
||||
|
|
@ -29,20 +14,12 @@ router.get(
|
|||
async (req, res) => {
|
||||
try {
|
||||
const { userName } = req.validatedUserProfile
|
||||
const cached = await getUserProfileFromRedis(userName)
|
||||
if (cached) return res.json(cached)
|
||||
const data = await userService.getUserProfileByUsername(userName)
|
||||
|
||||
const response = users.userProfileResponseSchema.parse(
|
||||
userProfileAdapter.mapUserProfileToResponse(data)
|
||||
)
|
||||
const payload = {
|
||||
...response,
|
||||
dealsPagination: data.dealsPagination,
|
||||
commentsPagination: data.commentsPagination,
|
||||
}
|
||||
await setUserProfileInRedis(userName, payload, { ttlSeconds: PROFILE_CACHE_TTL_SECONDS })
|
||||
res.json(payload)
|
||||
res.json(response)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
const status = err.statusCode || 500
|
||||
|
|
@ -53,58 +30,4 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
const express = require("express")
|
||||
const requireAuth = require("../middleware/requireAuth")
|
||||
const requireNotRestricted = require("../middleware/requireNotRestricted")
|
||||
const { validate } = require("../middleware/validate.middleware")
|
||||
const { endpoints } = require("@shared/contracts")
|
||||
const voteService = require("../services/vote.service")
|
||||
|
|
@ -11,7 +10,6 @@ const { votes } = endpoints
|
|||
router.post(
|
||||
"/",
|
||||
requireAuth,
|
||||
requireNotRestricted({ checkSuspend: true }),
|
||||
validate(votes.voteRequestSchema, "body", "validatedVotePayload"),
|
||||
async (req, res) => {
|
||||
try {
|
||||
|
|
|
|||
73
server.js
73
server.js
|
|
@ -1,5 +1,4 @@
|
|||
const express = require("express");
|
||||
const path = require("path");
|
||||
const cors = require("cors");
|
||||
require("dotenv").config();
|
||||
const cookieParser = require("cookie-parser");
|
||||
|
|
@ -14,46 +13,19 @@ const accountSettingsRoutes = require("./routes/accountSettings.routes");
|
|||
const userRoutes = require("./routes/user.routes");
|
||||
const sellerRoutes = require("./routes/seller.routes");
|
||||
const voteRoutes = require("./routes/vote.routes");
|
||||
const commentLikeRoutes = require("./routes/commentLike.routes");
|
||||
const categoryRoutes =require("./routes/category.routes")
|
||||
const modRoutes = require("./routes/mod.routes")
|
||||
const uploadRoutes = require("./routes/upload.routes")
|
||||
const badgeRoutes = require("./routes/badge.routes")
|
||||
const 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();
|
||||
|
||||
app.set("trust proxy", true)
|
||||
|
||||
// CORS middleware'ı ile dışardan gelen istekleri kontrol et
|
||||
const allowedOrigins = new Set([
|
||||
"http://192.168.1.205:3001",
|
||||
"http://localhost:3001",
|
||||
"http://localhost:3000",
|
||||
]);
|
||||
app.use(cors({
|
||||
origin: "http://localhost:5173", // Frontend adresi
|
||||
credentials: true, // Cookies'in paylaşıma izin verilmesi
|
||||
}));
|
||||
|
||||
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
|
||||
app.use(express.json()); // JSON verisi almak için
|
||||
app.use(express.urlencoded({ extended: true })); // URL encoded veriler için
|
||||
app.use(cookieParser());
|
||||
app.use(requestContextMiddleware) // Cookies'leri çözümlemek için
|
||||
|
||||
app.use(cookieParser()); // Cookies'leri çözümlemek için
|
||||
|
||||
// API route'larını tanımlama
|
||||
app.use("/api/users", userRoutesneedRefactor); // User işlemleri
|
||||
|
|
@ -65,37 +37,6 @@ app.use("/api/account", accountSettingsRoutes); // Account settings işlemleri
|
|||
app.use("/api/user", userRoutes); // Kullanıcı işlemleri
|
||||
app.use("/api/seller", sellerRoutes); // Seller 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/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"));
|
||||
}
|
||||
|
||||
startServer().catch((err) => {
|
||||
console.error("Server failed to start:", err?.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
// Sunucuyu dinlemeye başla
|
||||
app.listen(3000, () => console.log("Server running on http://localhost:3000"));
|
||||
|
|
|
|||
|
|
@ -1,324 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
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 }
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -5,13 +5,6 @@ const crypto = require("crypto")
|
|||
|
||||
const authDb = require("../db/auth.db")
|
||||
const refreshTokenDb = require("../db/refreshToken.db")
|
||||
const { queueAuditEvent } = require("./redis/dbSync.service")
|
||||
const { AUDIT_ACTIONS } = require("./auditActions")
|
||||
const { buildAuditMeta } = require("./audit.service")
|
||||
const { 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) {
|
||||
const err = new Error(message)
|
||||
|
|
@ -19,7 +12,7 @@ function httpError(statusCode, message) {
|
|||
return err
|
||||
}
|
||||
|
||||
// Access token: kisa ömür
|
||||
// Access token: kısa ömür
|
||||
function signAccessToken(user) {
|
||||
const jti = crypto.randomUUID()
|
||||
const payload = {
|
||||
|
|
@ -33,7 +26,7 @@ function signAccessToken(user) {
|
|||
return { token, jti }
|
||||
}
|
||||
|
||||
// Refresh token: opaque (JWT degil) + DB'de hash
|
||||
// Refresh token: opaque (JWT değil) + DB’de hash
|
||||
function generateRefreshToken() {
|
||||
// 64 byte -> url-safe base64
|
||||
return crypto.randomBytes(64).toString("base64url")
|
||||
|
|
@ -53,18 +46,17 @@ function mapUserPublic(user) {
|
|||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null,
|
||||
avatarUrl: user.avatarUrl ?? null,
|
||||
role: user.role,
|
||||
}
|
||||
}
|
||||
|
||||
async function login({ email, password, meta = {} }) {
|
||||
const user = await authDb.findUserByEmail(email)
|
||||
if (!user) throw httpError(400, "Kullanici bulunamadi.")
|
||||
if (user.disabledAt) throw httpError(403, "Hesap devre disi.")
|
||||
if (!user) throw httpError(400, "Kullanıcı bulunamadı.")
|
||||
|
||||
const isMatch = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!isMatch) throw httpError(401, "Sifre hatali.")
|
||||
if (!isMatch) throw httpError(401, "Şifre hatalı.")
|
||||
|
||||
const { token: accessToken } = signAccessToken(user)
|
||||
|
||||
|
|
@ -82,15 +74,6 @@ async function login({ email, password, meta = {} }) {
|
|||
userAgent: meta.userAgent ?? null,
|
||||
})
|
||||
|
||||
queueAuditEvent({
|
||||
userId: user.id,
|
||||
action: AUDIT_ACTIONS.AUTH.LOGIN_SUCCESS,
|
||||
ip: meta.ip ?? null,
|
||||
userAgent: meta.userAgent ?? null,
|
||||
meta: buildAuditMeta({ entityType: "USER", entityId: user.id }),
|
||||
createdAt: new Date().toISOString(),
|
||||
}).catch((err) => console.error("Audit queue login failed:", err?.message || err))
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
|
|
@ -100,15 +83,10 @@ async function login({ email, password, meta = {} }) {
|
|||
|
||||
async function register({ username, email, password, meta = {} }) {
|
||||
const existingUser = await authDb.findUserByEmail(email)
|
||||
if (existingUser) throw httpError(400, "Bu e-posta zaten kayitli.")
|
||||
|
||||
const normalizedUsername = sanitizeOptionalPlainText(username, { maxLength: 18 })
|
||||
if (!normalizedUsername || normalizedUsername.length < 5) {
|
||||
throw httpError(400, "Kullanici adi gecersiz.")
|
||||
}
|
||||
if (existingUser) throw httpError(400, "Bu e-posta zaten kayıtlı.")
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
const user = await authDb.createUser({ username: normalizedUsername, email, passwordHash })
|
||||
const user = await authDb.createUser({ username, email, passwordHash })
|
||||
|
||||
const { token: accessToken } = signAccessToken(user)
|
||||
|
||||
|
|
@ -126,15 +104,6 @@ async function register({ username, email, password, meta = {} }) {
|
|||
userAgent: meta.userAgent ?? null,
|
||||
})
|
||||
|
||||
queueAuditEvent({
|
||||
userId: user.id,
|
||||
action: AUDIT_ACTIONS.AUTH.REGISTER,
|
||||
ip: meta.ip ?? null,
|
||||
userAgent: meta.userAgent ?? null,
|
||||
meta: buildAuditMeta({ entityType: "USER", entityId: user.id }),
|
||||
createdAt: new Date().toISOString(),
|
||||
}).catch((err) => console.error("Audit queue register failed:", err?.message || err))
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
|
|
@ -153,28 +122,19 @@ async function refresh({ refreshToken, meta = {} }) {
|
|||
|
||||
if (!existing) throw httpError(401, "Refresh token geçersiz")
|
||||
|
||||
// süresi geçmis
|
||||
// süresi geçmiş
|
||||
if (existing.expiresAt && existing.expiresAt.getTime() < Date.now()) {
|
||||
await refreshTokenDb.revokeRefreshTokenById(existing.id)
|
||||
throw httpError(401, "Refresh token süresi dolmus")
|
||||
throw httpError(401, "Refresh token süresi dolmuş")
|
||||
}
|
||||
|
||||
// reuse tespiti: revoke edilmis token tekrar gelirse -> tüm aileyi kapat
|
||||
// reuse tespiti: revoke edilmiş token tekrar gelirse -> tüm aileyi kapat
|
||||
if (existing.revokedAt) {
|
||||
const revokedAt = existing.revokedAt instanceof Date ? existing.revokedAt : new Date(existing.revokedAt)
|
||||
const withinGrace =
|
||||
existing.replacedById &&
|
||||
revokedAt &&
|
||||
Date.now() - revokedAt.getTime() <= REUSE_GRACE_MS
|
||||
|
||||
if (!withinGrace) {
|
||||
await refreshTokenDb.revokeRefreshTokenFamily(existing.familyId)
|
||||
throw httpError(401, "Refresh token reuse tespit edildi")
|
||||
}
|
||||
await refreshTokenDb.revokeRefreshTokenFamily(existing.familyId)
|
||||
throw httpError(401, "Refresh token reuse tespit edildi")
|
||||
}
|
||||
|
||||
const user = existing.user
|
||||
if (user?.disabledAt) throw httpError(403, "Hesap devre disi.")
|
||||
const { token: accessToken } = signAccessToken(user)
|
||||
|
||||
const newRefreshToken = generateRefreshToken()
|
||||
|
|
@ -186,22 +146,13 @@ async function refresh({ refreshToken, meta = {} }) {
|
|||
newToken: {
|
||||
userId: user.id,
|
||||
tokenHash: newTokenHash,
|
||||
familyId: existing.familyId, // ayni aile
|
||||
familyId: existing.familyId, // aynı aile
|
||||
jti: newJti,
|
||||
expiresAt: refreshExpiresAt(),
|
||||
},
|
||||
meta: { ip: meta.ip ?? null, userAgent: meta.userAgent ?? null },
|
||||
})
|
||||
|
||||
queueAuditEvent({
|
||||
userId: user.id,
|
||||
action: AUDIT_ACTIONS.AUTH.REFRESH,
|
||||
ip: meta.ip ?? null,
|
||||
userAgent: meta.userAgent ?? null,
|
||||
meta: buildAuditMeta({ entityType: "USER", entityId: user.id }),
|
||||
createdAt: new Date().toISOString(),
|
||||
}).catch((err) => console.error("Audit queue refresh failed:", err?.message || err))
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
|
|
@ -209,26 +160,13 @@ async function refresh({ refreshToken, meta = {} }) {
|
|||
}
|
||||
}
|
||||
|
||||
async function logout({ refreshToken, meta = {} }) {
|
||||
async function logout({ refreshToken }) {
|
||||
if (!refreshToken) return
|
||||
const tokenHash = hashToken(refreshToken)
|
||||
|
||||
// token yoksa sessiz geçmek genelde daha iyi (idempotent logout)
|
||||
try {
|
||||
const existing = await refreshTokenDb.findRefreshTokenByHash(tokenHash, {
|
||||
select: { userId: true },
|
||||
})
|
||||
await refreshTokenDb.revokeRefreshTokenByHash(tokenHash)
|
||||
if (existing?.userId) {
|
||||
queueAuditEvent({
|
||||
userId: existing.userId,
|
||||
action: AUDIT_ACTIONS.AUTH.LOGOUT,
|
||||
ip: meta.ip ?? null,
|
||||
userAgent: meta.userAgent ?? null,
|
||||
meta: buildAuditMeta({ entityType: "USER", entityId: existing.userId }),
|
||||
createdAt: new Date().toISOString(),
|
||||
}).catch((err) => console.error("Audit queue logout failed:", err?.message || err))
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
|
@ -236,7 +174,7 @@ async function getMe(userId) {
|
|||
const user = await authDb.findUserById(Number(userId), {
|
||||
select: { id: true, username: true, email: true, avatarUrl: true, role: true },
|
||||
})
|
||||
if (!user) throw httpError(404, "Kullanici bulunamadi")
|
||||
if (!user) throw httpError(404, "Kullanıcı bulunamadı")
|
||||
return user
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
const fs = require("fs")
|
||||
const { uploadImage } = require("./uploadImage.service")
|
||||
const { makeWebp } = require("../utils/processImage")
|
||||
const { validateImage } = require("../utils/validateImage")
|
||||
|
||||
const userDB = require("../db/user.db")
|
||||
const { setUserPublicInRedis } = require("./redis/userPublicCache.service")
|
||||
|
||||
async function updateUserAvatar(userId, file) {
|
||||
if (!file) {
|
||||
|
|
@ -17,19 +15,17 @@ async function updateUserAvatar(userId, file) {
|
|||
})
|
||||
|
||||
const buffer = fs.readFileSync(file.path)
|
||||
const webpBuffer = await makeWebp(buffer, { quality: 80 })
|
||||
|
||||
const imageUrl = await uploadImage({
|
||||
path: `avatars/${userId}_${Date.now()}.webp`,
|
||||
fileBuffer: webpBuffer,
|
||||
contentType: "image/webp",
|
||||
bucket: "avatars",
|
||||
path: `${userId}_${Date.now()}.jpg`,
|
||||
fileBuffer: buffer,
|
||||
contentType: file.mimetype,
|
||||
})
|
||||
|
||||
fs.unlinkSync(file.path)
|
||||
|
||||
const updated = await updateAvatarUrl(userId, imageUrl)
|
||||
await setUserPublicInRedis(updated, { ttlSeconds: 60 * 60 })
|
||||
return updated
|
||||
return updateAvatarUrl(userId, imageUrl)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -42,13 +38,6 @@ async function updateAvatarUrl(userId, imageUrl) {
|
|||
id: true,
|
||||
username: true,
|
||||
avatarUrl: true,
|
||||
userBadges: {
|
||||
orderBy: { earnedAt: "desc" },
|
||||
select: {
|
||||
earnedAt: true,
|
||||
badge: { select: { id: true, name: true, iconUrl: true, description: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,164 +1,69 @@
|
|||
const categoryDb = require("../db/category.db")
|
||||
const dealService = require("./deal.service")
|
||||
const { listCategoriesFromRedis, setCategoriesInRedis, setCategoryInRedis } = require("./redis/categoryCache.service")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const categoryDb = require("../db/category.db"); // DB işlemleri için category.db.js'i import ediyoruz
|
||||
const dealDb = require("../db/deal.db");
|
||||
/**
|
||||
* Kategoriyi slug'a göre bul
|
||||
* Bu fonksiyon, verilen slug'a sahip kategori bilgilerini döndürür
|
||||
*/
|
||||
async function findCategoryBySlug(slug) {
|
||||
const normalizedSlug = String(slug || "").trim()
|
||||
if (!normalizedSlug) {
|
||||
throw new Error("INVALID_SLUG")
|
||||
}
|
||||
try {
|
||||
// Kategori bilgisini slug'a göre buluyoruz
|
||||
const category = await categoryDb.findCategoryBySlug(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 }
|
||||
if (!category) {
|
||||
throw new Error("Kategori bulunamadı");
|
||||
}
|
||||
}
|
||||
|
||||
const category = await categoryDb.findCategoryBySlug(normalizedSlug)
|
||||
if (!category) {
|
||||
throw new Error("CATEGORY_NOT_FOUND")
|
||||
}
|
||||
// Kategori breadcrumb'ını alıyoruz
|
||||
const breadcrumb = await categoryDb.getCategoryBreadcrumb(category.id);
|
||||
|
||||
const normalizedCategory = normalizeCategory(category) || category
|
||||
await setCategoryInRedis(normalizedCategory)
|
||||
const breadcrumb = await categoryDb.getCategoryBreadcrumb(category.id)
|
||||
return { category: normalizedCategory, breadcrumb }
|
||||
return { category, breadcrumb }; // Kategori ve breadcrumb'ı döndürüyoruz
|
||||
} catch (err) {
|
||||
throw new Error(`Kategori bulma hatası: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getDealsByCategoryId(categoryId, { page = 1, limit = 10, filters = {}, viewer = null, scope = "USER" } = {}) {
|
||||
const normalizedId = Number(categoryId)
|
||||
if (!Number.isInteger(normalizedId) || normalizedId <= 0) {
|
||||
throw new Error("INVALID_CATEGORY_ID")
|
||||
}
|
||||
async function getDealsByCategoryId(categoryId, page = 1, limit = 10, filters = {}) {
|
||||
try {
|
||||
// Sayfalama ve filtreleme için gerekli ayarlamaları yapıyoruz
|
||||
const take = Math.min(Math.max(Number(limit) || 10, 1), 100); // Limit ve sayfa sayısını hesaplıyoruz
|
||||
const skip = (Math.max(Number(page) || 1, 1) - 1) * take; // Sayfa başlangıcı
|
||||
|
||||
let categoryIds = []
|
||||
const categories = await listCategoriesCached()
|
||||
if (categories.length) {
|
||||
categoryIds = getCategoryDescendantIdsFromMap(normalizedId, categories)
|
||||
}
|
||||
if (!categoryIds.length) {
|
||||
categoryIds = await categoryDb.getCategoryDescendantIds(normalizedId)
|
||||
}
|
||||
// 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
|
||||
};
|
||||
|
||||
return dealService.getDeals({
|
||||
preset: "NEW",
|
||||
q: filters?.q,
|
||||
page,
|
||||
limit,
|
||||
viewer,
|
||||
scope,
|
||||
baseWhere: { categoryId: { in: categoryIds } },
|
||||
filters,
|
||||
useRedisSearch: true,
|
||||
})
|
||||
// `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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
module.exports = {
|
||||
findCategoryBySlug,
|
||||
getDealsByCategoryId,
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,229 +1,91 @@
|
|||
const userDB = require("../db/user.db")
|
||||
const dealDB = require("../db/deal.db")
|
||||
const commentDB = require("../db/comment.db")
|
||||
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")
|
||||
const prisma = require("../db/client")
|
||||
|
||||
function parseParentId(value) {
|
||||
if (value === undefined || value === null || value === "" || value === "null") return null
|
||||
const pid = Number(value)
|
||||
if (!Number.isInteger(pid) || pid <= 0) throw new Error("Gecersiz parentId.")
|
||||
return pid
|
||||
function assertPositiveInt(v, name = "id") {
|
||||
const n = Number(v)
|
||||
if (!Number.isInteger(n) || n <= 0) throw new Error(`Geçersiz ${name}.`)
|
||||
return n
|
||||
}
|
||||
|
||||
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 } = {}) {
|
||||
async function getCommentsByDealId(dealId) {
|
||||
const id = Number(dealId)
|
||||
const deal = await ensureDealCached(id)
|
||||
if (!deal) throw new Error("Deal bulunamadi.")
|
||||
|
||||
return getCommentsForDeal({
|
||||
dealId: id,
|
||||
deal,
|
||||
parentId: parseParentId(parentId),
|
||||
page,
|
||||
limit,
|
||||
sort: normalizeSort(sort),
|
||||
viewerId: viewer?.userId ?? null,
|
||||
})
|
||||
const deal = await dealDB.findDeal({ id })
|
||||
if (!deal) throw new Error("Deal bulunamadı.")
|
||||
|
||||
const include = { user: { select: { id:true,username: true, avatarUrl: true } } }
|
||||
return commentDB.findComments({ dealId: id }, { include })
|
||||
}
|
||||
|
||||
async function createComment({ dealId, userId, text, parentId = null }) {
|
||||
const normalizedText = sanitizeOptionalPlainText(text, { maxLength: 2000 })
|
||||
if (!normalizedText) {
|
||||
throw new Error("Yorum bos olamaz.")
|
||||
if (!text || typeof text !== "string" || !text.trim()) {
|
||||
throw new Error("Yorum boş 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.")
|
||||
}
|
||||
const trimmed = text.trim()
|
||||
const include = { user: { select: { id: true, username: true, avatarUrl: true } } }
|
||||
|
||||
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.")
|
||||
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.")
|
||||
}
|
||||
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),
|
||||
const comment = await commentDB.createComment(
|
||||
{
|
||||
text: trimmed,
|
||||
userId,
|
||||
dealId,
|
||||
parentId: parent ? parent.id : null,
|
||||
},
|
||||
createdAt: createdAt.toISOString(),
|
||||
}
|
||||
queueNotificationCreate(notificationPayload).catch((err) =>
|
||||
console.error("DB sync comment reply notification failed:", err?.message || err)
|
||||
{ include },
|
||||
tx
|
||||
)
|
||||
publishNotification(notificationPayload).catch((err) =>
|
||||
console.error("Comment reply notification publish failed:", err?.message || err)
|
||||
)
|
||||
}
|
||||
|
||||
return comment
|
||||
await dealDB.updateDeal(
|
||||
{ id: dealId },
|
||||
{ commentCount: { increment: 1 } },
|
||||
{},
|
||||
tx
|
||||
)
|
||||
|
||||
return comment
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
async function deleteComment(commentId, userId) {
|
||||
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 comments = await commentDB.findComments(
|
||||
{ id: commentId },
|
||||
{ select: { userId: true } }
|
||||
)
|
||||
|
||||
const deal = await ensureDealCached(dealId)
|
||||
if (!deal) throw new Error("Yorum bulunamadi.")
|
||||
|
||||
const comments = Array.isArray(deal.comments) ? deal.comments : []
|
||||
const comment = comments.find((c) => Number(c.id) === cid)
|
||||
const effective = comment || dbFallback
|
||||
if (!effective || effective.deletedAt) throw new Error("Yorum bulunamadi.")
|
||||
if (Number(effective.userId) !== Number(userId)) throw new Error("Bu yorumu silme yetkin yok.")
|
||||
|
||||
queueCommentDelete({
|
||||
commentId: cid,
|
||||
dealId: effective.dealId,
|
||||
createdAt: new Date().toISOString(),
|
||||
}).catch((err) => console.error("DB sync comment delete failed:", err?.message || err))
|
||||
|
||||
await removeCommentFromRedis({
|
||||
commentId: cid,
|
||||
dealId: effective.dealId,
|
||||
})
|
||||
if (!comments || comments.length === 0) throw new Error("Yorum bulunamadı.")
|
||||
if (comments[0].userId !== userId) throw new Error("Bu yorumu silme yetkin yok.")
|
||||
|
||||
await commentDB.deleteComment({ id: commentId })
|
||||
return { message: "Yorum silindi." }
|
||||
}
|
||||
|
||||
async function deleteCommentAsMod(commentId) {
|
||||
const cid = Number(commentId)
|
||||
if (!Number.isInteger(cid) || cid <= 0) throw new Error("Gecersiz commentId.")
|
||||
async function commentChange(length,dealId){
|
||||
|
||||
let dealId = await getDealIdByCommentId(cid)
|
||||
let dbFallback = null
|
||||
if (!dealId) {
|
||||
dbFallback = await commentDB.findComment(
|
||||
{ id: cid },
|
||||
{ select: { id: true, dealId: true, userId: true, parentId: true, deletedAt: true } }
|
||||
)
|
||||
if (!dbFallback || dbFallback.deletedAt) throw new Error("Yorum bulunamadi.")
|
||||
dealId = dbFallback.dealId
|
||||
}
|
||||
|
||||
const deal = await ensureDealCached(dealId)
|
||||
if (!deal) throw new Error("Yorum bulunamadi.")
|
||||
|
||||
const comments = Array.isArray(deal.comments) ? deal.comments : []
|
||||
const comment = comments.find((c) => Number(c.id) === cid)
|
||||
const effective = comment || dbFallback
|
||||
if (!effective || effective.deletedAt) throw new Error("Yorum bulunamadi.")
|
||||
|
||||
queueCommentDelete({
|
||||
commentId: cid,
|
||||
dealId: effective.dealId,
|
||||
createdAt: new Date().toISOString(),
|
||||
}).catch((err) => console.error("DB sync comment delete failed:", err?.message || err))
|
||||
|
||||
await removeCommentFromRedis({
|
||||
commentId: cid,
|
||||
dealId: effective.dealId,
|
||||
})
|
||||
|
||||
return { message: "Yorum silindi." }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCommentsByDealId,
|
||||
createComment,
|
||||
deleteComment,
|
||||
deleteCommentAsMod,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
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
|
|
@ -3,47 +3,42 @@ const OpenAI = require("openai")
|
|||
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
const SYSTEM_PROMPT = `
|
||||
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.
|
||||
Classify the deal into exactly ONE category_id and optionally suggest up to 5 tags.
|
||||
|
||||
### 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
|
||||
Tags are NOT keyword repeats. Tags must represent INTENT/AUDIENCE/USE-CASE.
|
||||
|
||||
### 2. CLASSIFICATION & TAGGING
|
||||
- CATEGORY: Choose exactly ONE category_id that best fits the product.
|
||||
- Prefer audience or use-case tags such as: okul, ofis, is, gaming, kamp, mutfak, temizlik, araba, bahce, bebek, evcil-hayvan, fitness.
|
||||
- Do NOT output literal product words.
|
||||
- You MAY infer relevant intent/audience tags even if not explicitly written, as long as the inference is strong and widely accepted.
|
||||
- Avoid weak guesses: if the intent/audience is not clear, set needs_review=true and tags can be [].
|
||||
|
||||
### TAGGING STRATEGY (BRAND, MODEL & LIFESTYLE):
|
||||
- Goal: Create a precise user interest profile using 3 distinct tags.
|
||||
- Use NATURAL capitalization and spaces.
|
||||
Forbidden:
|
||||
- store/company/seller names
|
||||
- promotion/marketing words
|
||||
- generic category words
|
||||
|
||||
1. **Brand (Who?):** The manufacturer (e.g., "Apple", "HIQ Nutrition", "Lego").
|
||||
2. **Model (What?):** Specific series/model, MAX 2-3 words (e.g., "Creatine Monohydrate", "iPhone 15 Pro", "Star Wars").
|
||||
3. **Lifestyle/Interest (Vibe?):** The root interest that connects different categories (e.g., "spor", "teknoloji", "oyun", "hobi", "moda", "luks", "ev-yasam").
|
||||
Max 5 tags total, lowercase.
|
||||
Review / safety:
|
||||
- Set needs_review=true if you are not confident about the chosen category OR if the deal text looks problematic.
|
||||
- If unclear/unrelated, use best_category_id=0 and needs_review=true.
|
||||
- Set has_issue=true if the text contains profanity, harassment, hate, explicit sexual content, doxxing/personal data, scams/phishing, or clear spam.
|
||||
- If has_issue=true, briefly explain in issue_reason (short, generic, no quotes).
|
||||
|
||||
### RULES:
|
||||
- MAX 3 tags.
|
||||
- DO NOT include technical specs like "600g", "128GB", "siyah", "44mm".
|
||||
- DO NOT use the exact category name (e.g., if category is "Sporcu Besini", don't use "sporcu-besini", use "spor").
|
||||
- If no brand/model found, provide only the Lifestyle tag.
|
||||
|
||||
### EXAMPLE OUTPUTS:
|
||||
- "HIQ NUTRITION HIQ Creatine %100 Monohydrate XL 600g" -> ["HIQ Nutrition", "Creatine Monohydrate", "spor"]
|
||||
- "Apple iPhone 15 Pro 128GB" -> ["Apple", "iPhone 15 Pro", "teknoloji"]
|
||||
- "Lego Star Wars Millennium Falcon Seti" -> ["Lego", "Star Wars", "hobi"]
|
||||
- "Versace Erkek Kol Saati" -> ["Versace", "VRSCVE", "luks"]
|
||||
- "Nike Air Max Ayakkabı" -> ["Nike", "Air Max", "spor"].
|
||||
Output JSON only:
|
||||
{
|
||||
"best_category_id": number,
|
||||
"needs_review": boolean,
|
||||
"tags": string[],
|
||||
"has_issue": boolean,
|
||||
"issue_type": "NONE" | "PROFANITY" | "PHONE_NUMBER" | "PERSONAL_DATA" | "SPAM" | "OTHER",
|
||||
"issue_reason": string | null
|
||||
}
|
||||
`
|
||||
|
||||
const TAXONOMY_LINE =
|
||||
"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"
|
||||
`TAXONOMY:0 undefined;1 electronics;2 beauty;3 food;4 auto;5 home-garden;6 computers;7 pc-components;8 pc-ram;9 pc-ssd;10 pc-cpu;11 pc-gpu;12 pc-peripherals;13 pc-keyboard;14 pc-mouse;15 pc-monitor;16 beauty-makeup;17 beauty-lipstick;18 beauty-foundation;19 beauty-mascara;20 beauty-skincare;21 beauty-moisturizer;22 food-snacks;23 food-cigkofte;24 food-beverages;25 food-coffee;26 auto-oils;27 auto-engine-oil;28 auto-parts;29 auto-brake-pads;30 home-garden-garden;31 garden-irrigation;32 phone;33 phone-smartphone;34 phone-case;35 phone-screen-protector;36 phone-charging;37 phone-powerbank;38 wearables;39 wearables-smartwatch;40 wearables-band;41 audio;42 audio-headphones;43 audio-tws;44 audio-bt-speaker;45 audio-soundbar;46 audio-microphone;47 audio-turntable;48 tv-video;49 tv;50 projector;51 tv-media-player;52 tv-accessories;53 tv-receiver;54 console;55 console-playstation;56 console-xbox;57 console-nintendo;58 console-games;59 console-accessories;60 camera;61 camera-photo;62 camera-action;63 camera-lens;64 camera-tripod;65 smart-home;66 smart-security-camera;67 smart-plug;68 smart-bulb;69 smart-sensor;70 pc-networking;71 pc-router;72 pc-modem;73 pc-switch;74 pc-wifi-extender;75 pc-printing;76 pc-printer;77 pc-ink-toner;78 pc-scanner;79 pc-laptop;80 pc-desktop;81 pc-tablet;82 pc-storage;83 pc-external-drive;84 pc-usb-drive;85 pc-nas;86 pc-webcam;87 pc-speaker;88 pc-mic;89 pc-mousepad;90 pc-dock-hub;91 pc-laptop-bag;92 pc-controller;93 pc-motherboard;94 pc-psu;95 pc-case;96 pc-cooling;97 pc-fan;98 pc-liquid-cooling;99 beauty-fragrance;100 beauty-fragrance-women;101 beauty-fragrance-men;102 beauty-haircare;103 beauty-shampoo;104 beauty-conditioner;105 beauty-hair-styling;106 beauty-personal-care;107 beauty-deodorant;108 beauty-shaving;109 beauty-hair-removal;110 beauty-skincare-serum;111 beauty-sunscreen;112 beauty-cleanser;113 beauty-mask;114 beauty-toner;115 food-staples;116 food-pasta;117 food-legumes;118 food-oil-vinegar;119 food-breakfast;120 food-cheese;121 food-olive;122 food-jam-honey;123 food-soda;124 food-water;125 food-energy;126 food-tea;127 food-frozen;128 food-meat;129 food-dessert;130 auto-accessories;131 auto-in-car-electronics;132 auto-care;133 auto-cleaning;134 auto-tires;135 auto-battery;136 auto-lighting;137 auto-audio;138 home-furniture;139 home-dining-table;140 home-chair;141 home-sofa;142 home-bed;143 home-textile;144 home-bedding;145 home-blanket;146 home-curtain;147 home-kitchen;148 home-cookware;149 home-small-appliances;150 home-coffee-machine;151 home-blender;152 home-airfryer;153 home-vacuum;154 home-lighting;155 home-decor;156 home-rug;157 home-wall-decor;158 home-cleaning;159 home-detergent;160 home-paper-products;161 home-tools;162 home-drill;163 home-saw;164 home-hardware;165 pet;166 pet-cat-food;167 pet-dog-food;168 pet-cat-litter;169 office;170 office-paper-notebook;171 office-a4-paper;172 office-pen;173 office-school-bag;174 baby;175 baby-diaper;176 baby-wipes;177 baby-food;178 baby-toys;179 sports;180 sports-camping;181 sports-fitness;182 sports-bicycle;183 fashion;184 fashion-shoes;185 fashion-men;186 fashion-women;187 fashion-bags;188 books-media;189 books;190 digital-games`
|
||||
|
||||
const CATEGORY_ENUM = [...Array(191).keys()] // 0..31
|
||||
|
||||
function s(x) {
|
||||
return x == null ? "" : String(x)
|
||||
|
|
@ -96,7 +91,7 @@ async function classifyDeal({ title, description, url, seller }) {
|
|||
"issue_reason",
|
||||
],
|
||||
properties: {
|
||||
best_category_id: { type: "integer" },
|
||||
best_category_id: { type: "integer", enum: CATEGORY_ENUM },
|
||||
needs_review: { type: "boolean" },
|
||||
tags: { type: "array", items: { type: "string" }, maxItems: 5 },
|
||||
has_issue: { type: "boolean" },
|
||||
|
|
|
|||
|
|
@ -1,144 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,364 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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
Loading…
Reference in New Issue
Block a user