latest
This commit is contained in:
parent
e0f3f5d306
commit
3a678fec20
|
|
@ -6,7 +6,15 @@ function mapCommentToDealCommentResponse(comment) {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
text: comment.text, // eğer DB'de content ise burada text'e çevir
|
text: comment.text, // eğer DB'de content ise burada text'e çevir
|
||||||
createdAt: formatDateAsString(comment.createdAt),
|
createdAt: formatDateAsString(comment.createdAt),
|
||||||
parentId:comment.parentId,
|
parentId: comment.parentId ?? null,
|
||||||
|
likeCount: Number.isFinite(comment.likeCount) ? comment.likeCount : 0,
|
||||||
|
repliesCount: Number.isFinite(comment.repliesCount)
|
||||||
|
? comment.repliesCount
|
||||||
|
: comment._count?.replies ?? 0,
|
||||||
|
hasReplies: Number.isFinite(comment.repliesCount)
|
||||||
|
? comment.repliesCount > 0
|
||||||
|
: (comment._count?.replies ?? 0) > 0,
|
||||||
|
myLike: Boolean(comment.myLike),
|
||||||
user: {
|
user: {
|
||||||
id: comment.user.id,
|
id: comment.user.id,
|
||||||
username: comment.user.username,
|
username: comment.user.username,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ function mapDealToDealCardResponse(deal) {
|
||||||
title: deal.title,
|
title: deal.title,
|
||||||
description: deal.description || "",
|
description: deal.description || "",
|
||||||
price: deal.price ?? null,
|
price: deal.price ?? null,
|
||||||
|
originalPrice: deal.originalPrice ?? null,
|
||||||
|
shippingPrice: deal.shippingPrice ?? null,
|
||||||
|
|
||||||
score: deal.score,
|
score: deal.score,
|
||||||
commentsCount: deal.commentCount,
|
commentsCount: deal.commentCount,
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ function mapDealToDealDetailResponse(deal) {
|
||||||
description: deal.description || "",
|
description: deal.description || "",
|
||||||
url: deal.url ?? null,
|
url: deal.url ?? null,
|
||||||
price: deal.price ?? null,
|
price: deal.price ?? null,
|
||||||
|
originalPrice: deal.originalPrice ?? null,
|
||||||
|
shippingPrice: deal.shippingPrice ?? null,
|
||||||
score: Number.isFinite(deal.score) ? deal.score : 0,
|
score: Number.isFinite(deal.score) ? deal.score : 0,
|
||||||
|
|
||||||
commentsCount: deal._count?.comments ?? 0,
|
commentsCount: deal._count?.comments ?? 0,
|
||||||
|
|
@ -71,6 +73,10 @@ function mapDealToDealDetailResponse(deal) {
|
||||||
username: deal.user.username,
|
username: deal.user.username,
|
||||||
avatarUrl: deal.user.avatarUrl ?? null,
|
avatarUrl: deal.user.avatarUrl ?? null,
|
||||||
},
|
},
|
||||||
|
userStats: {
|
||||||
|
totalLikes: deal.userStats?.totalLikes ?? 0,
|
||||||
|
totalDeals: deal.userStats?.totalDeals ?? 0,
|
||||||
|
},
|
||||||
|
|
||||||
// ✅ FIX: SellerSummarySchema genelde id ister -> custom seller için -1
|
// ✅ FIX: SellerSummarySchema genelde id ister -> custom seller için -1
|
||||||
seller: deal.seller
|
seller: deal.seller
|
||||||
|
|
@ -95,12 +101,21 @@ function mapDealToDealDetailResponse(deal) {
|
||||||
if (!comment.user)
|
if (!comment.user)
|
||||||
throw new Error("comment.user is missing (include comments.user in query)")
|
throw new Error("comment.user is missing (include comments.user in query)")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
text: comment.text,
|
text: comment.text,
|
||||||
createdAt: requiredIsoString(comment.createdAt, "comment.createdAt"),
|
parentId: comment.parentId ?? null,
|
||||||
user: {
|
likeCount: Number.isFinite(comment.likeCount) ? comment.likeCount : 0,
|
||||||
id: comment.user.id,
|
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,
|
||||||
username: comment.user.username,
|
username: comment.user.username,
|
||||||
avatarUrl: comment.user.avatarUrl ?? null,
|
avatarUrl: comment.user.avatarUrl ?? null,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ function mapMeResultToResponse(user) {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatarUrl: user.avatarUrl ?? null,
|
avatarUrl: user.avatarUrl ?? null,
|
||||||
|
role: user.role,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
14
adapters/responses/sellerDetails.adapter.js
Normal file
14
adapters/responses/sellerDetails.adapter.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
function mapSellerToSellerDetailsResponse(seller) {
|
||||||
|
if (!seller) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: seller.id,
|
||||||
|
name: seller.name,
|
||||||
|
url: seller.url || null,
|
||||||
|
logoUrl: seller.sellerLogo || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mapSellerToSellerDetailsResponse,
|
||||||
|
}
|
||||||
|
|
@ -1,63 +1,90 @@
|
||||||
const prisma = require("./client"); // Prisma client
|
const prisma = require("./client") // Prisma client
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kategoriyi slug'a göre bul
|
* Kategoriyi slug'a gore bul
|
||||||
*/
|
*/
|
||||||
async function findCategoryBySlug(slug, options = {}) {
|
async function findCategoryBySlug(slug, options = {}) {
|
||||||
const s = String(slug ?? "").trim().toLowerCase();
|
const s = String(slug ?? "").trim().toLowerCase()
|
||||||
return prisma.category.findUnique({
|
return prisma.category.findUnique({
|
||||||
where: { slug: s },
|
where: { slug: s },
|
||||||
select: options.select || undefined,
|
select: options.select || undefined,
|
||||||
include: options.include || undefined,
|
include: options.include || undefined,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kategorinin fırsatlarını al
|
* Kategorinin firsatlarini al
|
||||||
* Sayfalama ve filtreler ile fırsatları çekiyoruz
|
* Sayfalama ve filtreler ile firsatlari cekiyoruz
|
||||||
*/
|
*/
|
||||||
async function listCategoryDeals({ where = {}, skip = 0, take = 10 }) {
|
async function listCategoryDeals({ where = {}, skip = 0, take = 10 }) {
|
||||||
return prisma.deal.findMany({
|
return prisma.deal.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
orderBy: { createdAt: "desc" }, // Yeni fırsatlar önce gelsin
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCategoryDescendantIds(categoryId) {
|
||||||
|
const rootId = Number(categoryId)
|
||||||
|
if (!Number.isInteger(rootId) || rootId <= 0) {
|
||||||
|
throw new Error("categoryId must be int")
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set([rootId])
|
||||||
|
let queue = [rootId]
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const children = await prisma.category.findMany({
|
||||||
|
where: { parentId: { in: queue } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const next = []
|
||||||
|
for (const child of children) {
|
||||||
|
if (!seen.has(child.id)) {
|
||||||
|
seen.add(child.id)
|
||||||
|
next.push(child.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queue = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(seen)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCategoryBreadcrumb(categoryId, { includeUndefined = false } = {}) {
|
async function getCategoryBreadcrumb(categoryId, { includeUndefined = false } = {}) {
|
||||||
let currentId = Number(categoryId);
|
let currentId = Number(categoryId)
|
||||||
if (!Number.isInteger(currentId)) throw new Error("categoryId must be int");
|
if (!Number.isInteger(currentId)) throw new Error("categoryId must be int")
|
||||||
|
|
||||||
const path = [];
|
const path = []
|
||||||
const visited = new Set();
|
const visited = new Set()
|
||||||
|
|
||||||
// Bu döngü, root kategoriye kadar gidip breadcrumb oluşturacak
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (visited.has(currentId)) break;
|
if (visited.has(currentId)) break
|
||||||
visited.add(currentId);
|
visited.add(currentId)
|
||||||
|
|
||||||
const cat = await prisma.category.findUnique({
|
const cat = await prisma.category.findUnique({
|
||||||
where: { id: currentId },
|
where: { id: currentId },
|
||||||
select: { id: true, name: true, slug: true, parentId: true }, // Yalnızca gerekli alanları seçiyoruz
|
select: { id: true, name: true, slug: true, parentId: true },
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!cat) break;
|
if (!cat) break
|
||||||
|
|
||||||
// Undefined'ı istersen breadcrumb'ta göstermiyoruz
|
|
||||||
if (includeUndefined || cat.id !== 0) {
|
if (includeUndefined || cat.id !== 0) {
|
||||||
path.push({ id: cat.id, name: cat.name, slug: cat.slug });
|
path.push({ id: cat.id, name: cat.name, slug: cat.slug })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cat.parentId === null || cat.parentId === undefined) break;
|
if (cat.parentId === null || cat.parentId === undefined) break
|
||||||
currentId = cat.parentId; // Bir üst kategoriye geçiyoruz
|
currentId = cat.parentId
|
||||||
}
|
}
|
||||||
|
|
||||||
return path.reverse(); // Kökten başlayarak, kategoriyi en son eklediğimiz için tersine çeviriyoruz
|
return path.reverse()
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getCategoryBreadcrumb,
|
getCategoryBreadcrumb,
|
||||||
findCategoryBySlug,
|
findCategoryBySlug,
|
||||||
listCategoryDeals,
|
listCategoryDeals,
|
||||||
};
|
getCategoryDescendantIds,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,27 @@ function getDb(db) {
|
||||||
return db || prisma
|
return db || prisma
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withDeletedFilter(where = {}, options = {}) {
|
||||||
|
if (options.includeDeleted || Object.prototype.hasOwnProperty.call(where, "deletedAt")) {
|
||||||
|
return where
|
||||||
|
}
|
||||||
|
return { AND: [where, { deletedAt: null }] }
|
||||||
|
}
|
||||||
|
|
||||||
async function findComments(where, options = {}) {
|
async function findComments(where, options = {}) {
|
||||||
return prisma.comment.findMany({
|
return prisma.comment.findMany({
|
||||||
where,
|
where: withDeletedFilter(where, options),
|
||||||
include: options.include || undefined,
|
include: options.include || undefined,
|
||||||
select: options.select || undefined,
|
select: options.select || undefined,
|
||||||
orderBy: options.orderBy || { createdAt: "desc" },
|
orderBy: options.orderBy || { createdAt: "desc" },
|
||||||
|
skip: Number.isInteger(options.skip) ? options.skip : undefined,
|
||||||
|
take: Number.isInteger(options.take) ? options.take : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findComment(where, options = {}) {
|
async function findComment(where, options = {}) {
|
||||||
return prisma.comment.findFirst({
|
return prisma.comment.findFirst({
|
||||||
where,
|
where: withDeletedFilter(where, options),
|
||||||
include: options.include || undefined,
|
include: options.include || undefined,
|
||||||
select: options.select || undefined,
|
select: options.select || undefined,
|
||||||
orderBy: options.orderBy || { createdAt: "desc" },
|
orderBy: options.orderBy || { createdAt: "desc" },
|
||||||
|
|
@ -29,12 +39,18 @@ async function createComment(data, options = {}, db) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteComment(where) {
|
async function deleteComment(where, db) {
|
||||||
return prisma.comment.delete({ where })
|
|
||||||
}
|
|
||||||
async function countComments(where = {}, db) {
|
|
||||||
const p = getDb(db)
|
const p = getDb(db)
|
||||||
return p.comment.count({ where })
|
return p.comment.delete({ where })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function softDeleteComment(where, db) {
|
||||||
|
const p = getDb(db)
|
||||||
|
return p.comment.updateMany({ where, data: { deletedAt: new Date() } })
|
||||||
|
}
|
||||||
|
async function countComments(where = {}, db, options = {}) {
|
||||||
|
const p = getDb(db)
|
||||||
|
return p.comment.count({ where: withDeletedFilter(where, options) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,5 +59,6 @@ module.exports = {
|
||||||
countComments,
|
countComments,
|
||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
|
softDeleteComment,
|
||||||
findComment
|
findComment
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
db/commentLike.db.js
Normal file
61
db/commentLike.db.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
const prisma = require("./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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
findLikesByUserAndCommentIds,
|
||||||
|
setCommentLike,
|
||||||
|
}
|
||||||
146
db/deal.db.js
146
db/deal.db.js
|
|
@ -4,63 +4,9 @@ function getDb(db) {
|
||||||
return db || prisma
|
return db || prisma
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findDeals(where = {}, options = {}, db) {
|
||||||
const DEAL_CARD_INCLUDE = {
|
const p = getDb(db)
|
||||||
user: { select: { id: true, username: true, avatarUrl: true } },
|
return p.deal.findMany({
|
||||||
seller: { select: { id: true, name: true, url: true } },
|
|
||||||
images: {
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
take: 1,
|
|
||||||
select: { imageUrl: true },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDealCards({ where = {}, skip = 0, take = 10, orderBy = [{ createdAt: "desc" }] }) {
|
|
||||||
try {
|
|
||||||
// Prisma sorgusunu çalıştırıyoruz ve genelleştirilmiş formatta alıyoruz
|
|
||||||
return await prisma.deal.findMany({
|
|
||||||
where,
|
|
||||||
skip,
|
|
||||||
take,
|
|
||||||
orderBy,
|
|
||||||
include: DEAL_CARD_INCLUDE, // Her zaman bu alanları dahil ediyoruz
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`Fırsatlar alınırken bir hata oluştu: ${err.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sayfalama ve diğer parametrelerle deal'leri çeker
|
|
||||||
*
|
|
||||||
* @param {object} params - where, skip, take, orderBy parametreleri
|
|
||||||
* @returns {object} - Deal card'leri ve toplam sayıyı döner
|
|
||||||
*/
|
|
||||||
async function getPaginatedDealCards({ where = {}, page = 1, limit = 10, orderBy = [{ createdAt: "desc" }] }) {
|
|
||||||
const pagination = clampPagination({ page, limit })
|
|
||||||
|
|
||||||
// Deal card verilerini ve toplam sayıyı alıyoruz
|
|
||||||
const [deals, total] = await Promise.all([
|
|
||||||
getDealCards({
|
|
||||||
where,
|
|
||||||
skip: pagination.skip,
|
|
||||||
take: pagination.limit,
|
|
||||||
orderBy,
|
|
||||||
}),
|
|
||||||
countDeals(where), // Total count almak için
|
|
||||||
])
|
|
||||||
|
|
||||||
return {
|
|
||||||
page: pagination.page,
|
|
||||||
total,
|
|
||||||
totalPages: Math.ceil(total / pagination.limit),
|
|
||||||
results: deals, // Burada raw data döndürülüyor, map'lemiyoruz
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function findDeals(where = {}, options = {}) {
|
|
||||||
return prisma.deal.findMany({
|
|
||||||
where,
|
where,
|
||||||
include: options.include || undefined,
|
include: options.include || undefined,
|
||||||
select: options.select || undefined,
|
select: options.select || undefined,
|
||||||
|
|
@ -70,39 +16,16 @@ async function findDeals(where = {}, options = {}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findSimilarCandidatesByCategory(categoryId, excludeDealId, { take = 80 } = {}) {
|
async function findSimilarCandidates(where, options = {}, db) {
|
||||||
const safeTake = Math.min(Math.max(Number(take) || 80, 1), 200)
|
const p = getDb(db)
|
||||||
|
const safeTake = Math.min(Math.max(Number(options.take) || 30, 1), 200)
|
||||||
|
|
||||||
return prisma.deal.findMany({
|
return p.deal.findMany({
|
||||||
where: {
|
where,
|
||||||
id: { not: Number(excludeDealId) },
|
orderBy: options.orderBy || [{ score: "desc" }, { createdAt: "desc" }],
|
||||||
status: "ACTIVE",
|
|
||||||
categoryId: Number(categoryId),
|
|
||||||
},
|
|
||||||
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
|
|
||||||
take: safeTake,
|
take: safeTake,
|
||||||
include: {
|
include: options.include || undefined,
|
||||||
seller: { select: { id: true, name: true, url: true } },
|
select: options.select || undefined,
|
||||||
images: { take: 1, orderBy: { order: "asc" }, select: { imageUrl: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findSimilarCandidatesBySeller(sellerId, excludeDealId, { take = 30 } = {}) {
|
|
||||||
const safeTake = Math.min(Math.max(Number(take) || 30, 1), 200)
|
|
||||||
|
|
||||||
return prisma.deal.findMany({
|
|
||||||
where: {
|
|
||||||
id: { not: Number(excludeDealId) },
|
|
||||||
status: "ACTIVE",
|
|
||||||
sellerId: Number(sellerId),
|
|
||||||
},
|
|
||||||
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
|
|
||||||
take: safeTake,
|
|
||||||
include: {
|
|
||||||
seller: { select: { id: true, name: true, url: true } },
|
|
||||||
images: { take: 1, orderBy: { order: "asc" }, select: { imageUrl: true } },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,9 +38,9 @@ async function findDeal(where, options = {}, db) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createDeal(data, options = {}, db) {
|
||||||
async function createDeal(data, options = {}) {
|
const p = getDb(db)
|
||||||
return prisma.deal.create({
|
return p.deal.create({
|
||||||
data,
|
data,
|
||||||
include: options.include || undefined,
|
include: options.include || undefined,
|
||||||
select: options.select || undefined,
|
select: options.select || undefined,
|
||||||
|
|
@ -134,28 +57,32 @@ async function updateDeal(where, data, options = {}, db) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function countDeals(where = {}) {
|
async function countDeals(where = {}, db) {
|
||||||
return prisma.deal.count({ where })
|
const p = getDb(db)
|
||||||
|
return p.deal.count({ where })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findVotes(where = {}, options = {}) {
|
async function findVotes(where = {}, options = {}, db) {
|
||||||
return prisma.dealVote.findMany({
|
const p = getDb(db)
|
||||||
|
return p.dealVote.findMany({
|
||||||
where,
|
where,
|
||||||
include: options.include || undefined,
|
include: options.include || undefined,
|
||||||
select: options.select || undefined,
|
select: options.select || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createVote(data, options = {}) {
|
async function createVote(data, options = {}, db) {
|
||||||
return prisma.dealVote.create({
|
const p = getDb(db)
|
||||||
|
return p.dealVote.create({
|
||||||
data,
|
data,
|
||||||
include: options.include || undefined,
|
include: options.include || undefined,
|
||||||
select: options.select || undefined,
|
select: options.select || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateVote(where, data, options = {}) {
|
async function updateVote(where, data, options = {}, db) {
|
||||||
return prisma.dealVote.update({
|
const p = getDb(db)
|
||||||
|
return p.dealVote.update({
|
||||||
where,
|
where,
|
||||||
data,
|
data,
|
||||||
include: options.include || undefined,
|
include: options.include || undefined,
|
||||||
|
|
@ -163,14 +90,9 @@ async function updateVote(where, data, options = {}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function countVotes(where = {}) {
|
async function countVotes(where = {}, db) {
|
||||||
return prisma.dealVote.count({ where })
|
const p = getDb(db)
|
||||||
}
|
return p.dealVote.count({ where })
|
||||||
async function getDealWithImages(dealId) {
|
|
||||||
return prisma.deal.findUnique({
|
|
||||||
where: { id: dealId },
|
|
||||||
include: { images: { orderBy: { order: "asc" } } },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function aggregateDeals(where = {}, db) {
|
async function aggregateDeals(where = {}, db) {
|
||||||
|
|
@ -182,12 +104,9 @@ async function aggregateDeals(where = {}, db) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
findDeals,
|
findDeals,
|
||||||
aggregateDeals,
|
findSimilarCandidates,
|
||||||
getDealWithImages,
|
|
||||||
findDeal,
|
findDeal,
|
||||||
createDeal,
|
createDeal,
|
||||||
updateDeal,
|
updateDeal,
|
||||||
|
|
@ -196,8 +115,5 @@ module.exports = {
|
||||||
createVote,
|
createVote,
|
||||||
updateVote,
|
updateVote,
|
||||||
countVotes,
|
countVotes,
|
||||||
findSimilarCandidatesByCategory,
|
aggregateDeals,
|
||||||
findSimilarCandidatesBySeller,
|
|
||||||
getDealCards,
|
|
||||||
getPaginatedDealCards
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
318
docs/frontend-api.md
Normal file
318
docs/frontend-api.md
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
# Frontend API Guide (HotTRDeals)
|
||||||
|
|
||||||
|
This file is a frontend-focused summary of current backend routes, auth, and response models.
|
||||||
|
Server entry: `server.js`.
|
||||||
|
|
||||||
|
## Base URL and CORS
|
||||||
|
- Local API base: `http://localhost:3000`
|
||||||
|
- All routes below are relative to `/api`.
|
||||||
|
- CORS in dev allows `http://localhost:5173` and `credentials: true`.
|
||||||
|
|
||||||
|
## Auth summary
|
||||||
|
- Access token is returned in response body: `{ token, user }`.
|
||||||
|
- Send access token via header: `Authorization: Bearer <token>`.
|
||||||
|
- Refresh token is stored in httpOnly cookie named `rt`.
|
||||||
|
- For `/auth/refresh` and `/auth/logout`, the frontend must send cookies (`withCredentials: true`).
|
||||||
|
- Cookie options:
|
||||||
|
- Dev: `secure=false`, `sameSite=lax`
|
||||||
|
- Prod: `secure=true`, `sameSite=none`
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
- Roles: `USER`, `MOD`, `ADMIN`.
|
||||||
|
- `requireRole("MOD")` means only MOD and ADMIN can access.
|
||||||
|
|
||||||
|
## Common response shapes
|
||||||
|
- Errors are inconsistent:
|
||||||
|
- Some endpoints return `{ error: "..." }`
|
||||||
|
- Some endpoints return `{ message: "..." }`
|
||||||
|
- Expect both in frontend error handling.
|
||||||
|
|
||||||
|
## Common models (from adapters/contracts)
|
||||||
|
|
||||||
|
### PublicUserSummary
|
||||||
|
```
|
||||||
|
{
|
||||||
|
id: number,
|
||||||
|
username: string,
|
||||||
|
avatarUrl: string | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PublicUserDetails
|
||||||
|
```
|
||||||
|
{
|
||||||
|
id: number,
|
||||||
|
username: string,
|
||||||
|
avatarUrl: string | null,
|
||||||
|
email: string,
|
||||||
|
createdAt: string (ISO)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AuthUser
|
||||||
|
```
|
||||||
|
{
|
||||||
|
id: number,
|
||||||
|
username: string,
|
||||||
|
email: string,
|
||||||
|
role: "USER" | "MOD" | "ADMIN",
|
||||||
|
avatarUrl: string | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DealCard
|
||||||
|
```
|
||||||
|
{
|
||||||
|
id: number,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
price: number | null,
|
||||||
|
originalPrice?: number | null,
|
||||||
|
shippingPrice?: number | null,
|
||||||
|
score: number,
|
||||||
|
commentsCount: number,
|
||||||
|
url: string | null,
|
||||||
|
status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED",
|
||||||
|
saleType: "ONLINE" | "OFFLINE" | "CODE",
|
||||||
|
affiliateType: "AFFILIATE" | "NON_AFFILIATE" | "USER_AFFILIATE",
|
||||||
|
myVote: -1 | 0 | 1,
|
||||||
|
createdAt: string (ISO),
|
||||||
|
updatedAt: string (ISO),
|
||||||
|
user: PublicUserSummary,
|
||||||
|
seller: { name: string, url: string | null },
|
||||||
|
imageUrl: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DealDetail
|
||||||
|
```
|
||||||
|
{
|
||||||
|
id: number,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
url: string | null,
|
||||||
|
price: number | null,
|
||||||
|
originalPrice?: number | null,
|
||||||
|
shippingPrice?: number | null,
|
||||||
|
score: number,
|
||||||
|
commentsCount: number,
|
||||||
|
status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED",
|
||||||
|
saleType: "ONLINE" | "OFFLINE" | "CODE",
|
||||||
|
affiliateType: "AFFILIATE" | "NON_AFFILIATE" | "USER_AFFILIATE",
|
||||||
|
createdAt: string (ISO),
|
||||||
|
updatedAt: string (ISO),
|
||||||
|
user: PublicUserSummary,
|
||||||
|
seller: { id: number, name: string, url: string | null },
|
||||||
|
images: [{ id: number, imageUrl: string, order: number }],
|
||||||
|
comments: [{ id: number, text: string, createdAt: string, parentId?: number | null, user: PublicUserSummary }],
|
||||||
|
breadcrumb: [{ id: number, name: string, slug: string }],
|
||||||
|
notice: {
|
||||||
|
id: number,
|
||||||
|
dealId: number,
|
||||||
|
title: string,
|
||||||
|
body: string | null,
|
||||||
|
severity: "INFO" | "WARNING" | "DANGER" | "SUCCESS",
|
||||||
|
isActive: boolean,
|
||||||
|
createdBy: number,
|
||||||
|
createdAt: string,
|
||||||
|
updatedAt: string
|
||||||
|
} | null,
|
||||||
|
similarDeals: [{ id: number, title: string, price: number | null, score: number, imageUrl: string, sellerName: string, createdAt: string | null }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DealListResponse
|
||||||
|
```
|
||||||
|
{
|
||||||
|
page: number,
|
||||||
|
total: number,
|
||||||
|
totalPages: number,
|
||||||
|
results: DealCard[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comment (DealComment)
|
||||||
|
```
|
||||||
|
{
|
||||||
|
id: number,
|
||||||
|
text: string,
|
||||||
|
createdAt: string,
|
||||||
|
parentId?: number | null,
|
||||||
|
user: PublicUserSummary
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UserProfile
|
||||||
|
```
|
||||||
|
{
|
||||||
|
user: PublicUserDetails,
|
||||||
|
stats: { totalLikes: number, totalShares: number, totalComments: number, totalDeals: number },
|
||||||
|
deals: DealCard[],
|
||||||
|
comments: [{ ...Comment, deal: { id: number, title: string } }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### VoteResponse
|
||||||
|
```
|
||||||
|
{
|
||||||
|
dealId: number,
|
||||||
|
voteType: -1 | 0 | 1,
|
||||||
|
delta: number,
|
||||||
|
score: number | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### VoteListResponse
|
||||||
|
```
|
||||||
|
{
|
||||||
|
votes: [{ id, dealId, userId, voteType, createdAt, lastVotedAt }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Auth (`/api/auth`)
|
||||||
|
- `POST /register`
|
||||||
|
- Body: `{ username, email, password }`
|
||||||
|
- Response: `{ token, user: AuthUser }` and sets `rt` cookie if refresh token exists.
|
||||||
|
|
||||||
|
- `POST /login`
|
||||||
|
- Body: `{ email, password }`
|
||||||
|
- Response: `{ token, user: AuthUser }` and sets `rt` cookie.
|
||||||
|
|
||||||
|
- `POST /refresh`
|
||||||
|
- Cookie required: `rt`
|
||||||
|
- Response: `{ token, user: AuthUser }` and rotates `rt` cookie.
|
||||||
|
|
||||||
|
- `POST /logout`
|
||||||
|
- Cookie optional: `rt`
|
||||||
|
- Response: `204 No Content`, clears `rt` cookie.
|
||||||
|
|
||||||
|
- `GET /me`
|
||||||
|
- Auth: required
|
||||||
|
- Response: `AuthUser`
|
||||||
|
- Note: current implementation reads `req.user.userId` in adapter, while auth middleware sets `req.auth`. If `req.user` is undefined, this endpoint can fail.
|
||||||
|
|
||||||
|
### Account (`/api/account`)
|
||||||
|
- `POST /avatar`
|
||||||
|
- Auth: required
|
||||||
|
- Multipart: `file` (single)
|
||||||
|
- Validation: JPEG only, max 2MB
|
||||||
|
- Response: `{ message, user: PublicUserSummary }`
|
||||||
|
|
||||||
|
- `GET /me`
|
||||||
|
- Auth: required
|
||||||
|
- Response: `PublicUserDetails`
|
||||||
|
|
||||||
|
### Deals (`/api/deals`)
|
||||||
|
List query params (used in list endpoints):
|
||||||
|
- `q` (string, default "")
|
||||||
|
- `page` (int, default 1)
|
||||||
|
- `limit` (int, default 10, max 100)
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- `GET /users/:userName/deals`
|
||||||
|
- Auth: optional
|
||||||
|
- Response: `DealListResponse`
|
||||||
|
|
||||||
|
- `GET /me/deals`
|
||||||
|
- Auth: required
|
||||||
|
- Response: `DealListResponse`
|
||||||
|
|
||||||
|
- `GET /new`
|
||||||
|
- `GET /hot`
|
||||||
|
- `GET /trending`
|
||||||
|
- `GET /` (same as `/new`)
|
||||||
|
- Auth: optional
|
||||||
|
- Response: `DealListResponse`
|
||||||
|
|
||||||
|
- `GET /search`
|
||||||
|
- Auth: optional
|
||||||
|
- If `q` is empty: `{ results: [], total: 0, totalPages: 0, page }`
|
||||||
|
- Else: `DealListResponse`
|
||||||
|
|
||||||
|
- `GET /top`
|
||||||
|
- Auth: optional
|
||||||
|
- Query: `range=day|week|month` (default `day`), `limit` (default 6, max 20)
|
||||||
|
- Response: `DealCard[]` (array only)
|
||||||
|
|
||||||
|
- `GET /:id`
|
||||||
|
- Response: `DealDetail`
|
||||||
|
|
||||||
|
- `POST /`
|
||||||
|
- Auth: required
|
||||||
|
- Multipart: `images` (array, max 5)
|
||||||
|
- Limits: 10MB per file (upload middleware)
|
||||||
|
- Body fields: `{ title, description?, url?, price?, sellerName? }`
|
||||||
|
- Response: `DealDetail`
|
||||||
|
|
||||||
|
### Category (`/api/category`)
|
||||||
|
- `GET /:slug`
|
||||||
|
- Response: `{ id, name, slug, description, breadcrumb }`
|
||||||
|
|
||||||
|
- `GET /:slug/deals`
|
||||||
|
- Auth: optional
|
||||||
|
- Query: `page`, `limit`, plus filters from query
|
||||||
|
- Response: `DealCard[]` (array only)
|
||||||
|
|
||||||
|
### Seller (`/api/seller`)
|
||||||
|
- `POST /from-link`
|
||||||
|
- Auth: required
|
||||||
|
- Body: `{ url }`
|
||||||
|
- Response: `{ found: boolean, seller: { id, name, url } | null }`
|
||||||
|
|
||||||
|
- `GET /:sellerName`
|
||||||
|
- Response: `{ id, name, url, logoUrl }`
|
||||||
|
|
||||||
|
- `GET /:sellerName/deals`
|
||||||
|
- Auth: optional
|
||||||
|
- Query: `page`, `limit`, `q`
|
||||||
|
- Response: `DealCard[]` (array only)
|
||||||
|
|
||||||
|
### Comments (`/api/comments`)
|
||||||
|
- `GET /:dealId`
|
||||||
|
- Response: `Comment[]`
|
||||||
|
|
||||||
|
- `POST /`
|
||||||
|
- Auth: required
|
||||||
|
- Body: `{ dealId, text, parentId? }`
|
||||||
|
- Response: `Comment`
|
||||||
|
|
||||||
|
- `DELETE /:id`
|
||||||
|
- Auth: required
|
||||||
|
- Response: `{ message: "Yorum silindi." }`
|
||||||
|
|
||||||
|
### Votes (`/api/vote` and `/api/deal-votes`)
|
||||||
|
- `POST /`
|
||||||
|
- Auth: required
|
||||||
|
- Body: `{ dealId, voteType }` where voteType is -1, 0, or 1
|
||||||
|
- Response: `VoteResponse`
|
||||||
|
|
||||||
|
- `GET /:dealId`
|
||||||
|
- Response: `VoteListResponse`
|
||||||
|
|
||||||
|
### Users (`/api/user` and `/api/users`)
|
||||||
|
- `GET /:userName`
|
||||||
|
- Response: `UserProfile`
|
||||||
|
|
||||||
|
### Mod (`/api/mod`) (MOD or ADMIN)
|
||||||
|
- `GET /deals/pending`
|
||||||
|
- Auth + Role: MOD
|
||||||
|
- Query: `page`, `limit`, `q` plus filters
|
||||||
|
- Response: `DealCard[]` (array only)
|
||||||
|
|
||||||
|
- `POST /deals/:id/approve`
|
||||||
|
- `POST /deals/:id/reject`
|
||||||
|
- `POST /deals/:id/expire`
|
||||||
|
- `POST /deals/:id/unexpire`
|
||||||
|
- Auth + Role: MOD
|
||||||
|
- Response: `{ id, status }`
|
||||||
|
|
||||||
|
## Notes for frontend
|
||||||
|
- Some list endpoints return full pagination object, some return only `results` array. Treat them separately:
|
||||||
|
- Full: `/api/deals` list endpoints, `/api/deals/search`, `/api/deals/users/:userName/deals`, `/api/deals/me/deals`
|
||||||
|
- Array only: `/api/deals/top`, `/api/category/:slug/deals`, `/api/seller/:sellerName/deals`, `/api/mod/deals/pending`
|
||||||
|
- Optional-auth endpoints return `401` if a token is provided but invalid.
|
||||||
|
- MyVote is only filled when Authorization header is present.
|
||||||
|
- There are duplicate route prefixes for users and votes. Frontend can use either, but pick one for consistency.
|
||||||
|
|
||||||
|
|
@ -1,239 +1,242 @@
|
||||||
[
|
[
|
||||||
{ "id": 0, "name": "Undefined", "slug": "undefined", "parentId": null },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
{ "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 },
|
|
||||||
{ "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": 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": 55, "name": "PlayStation", "slug": "console-playstation", "parentId": 54 },
|
{ "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": 56, "name": "Xbox", "slug": "console-xbox", "parentId": 54 },
|
{ "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": 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": 54, "name": "Konsollar", "slug": "console", "parentId": 191, "description": "PlayStation, Xbox, Nintendo konsolları; konsol oyunları ve aksesuarları." },
|
||||||
{ "id": 61, "name": "Fotoğraf Makinesi", "slug": "camera-photo", "parentId": 60 },
|
{ "id": 55, "name": "PlayStation", "slug": "console-playstation", "parentId": 54, "description": "PlayStation konsolları, oyunları, üyelikleri ve PlayStation aksesuarları." },
|
||||||
{ "id": 62, "name": "Aksiyon Kamera", "slug": "camera-action", "parentId": 60 },
|
{ "id": 56, "name": "Xbox", "slug": "console-xbox", "parentId": 54, "description": "Xbox konsolları, oyunları, Game Pass/abonelik ve Xbox aksesuarları." },
|
||||||
{ "id": 63, "name": "Lens", "slug": "camera-lens", "parentId": 60 },
|
{ "id": 57, "name": "Nintendo", "slug": "console-nintendo", "parentId": 54, "description": "Nintendo konsolları (Switch vb.), oyunları ve Nintendo aksesuarları." },
|
||||||
{ "id": 64, "name": "Tripod", "slug": "camera-tripod", "parentId": 60 },
|
{ "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": 65, "name": "Akıllı Ev", "slug": "smart-home", "parentId": 1 },
|
{ "id": 60, "name": "Kamera & Fotoğraf", "slug": "camera", "parentId": 1, "description": "Fotoğraf/video çekim ekipmanları; kamera gövdeleri, lensler ve aksesuarlar." },
|
||||||
{ "id": 66, "name": "Güvenlik Kamerası", "slug": "smart-security-camera", "parentId": 65 },
|
{ "id": 61, "name": "Fotoğraf Makinesi", "slug": "camera-photo", "parentId": 60, "description": "DSLR, aynasız ve kompakt fotoğraf makineleri." },
|
||||||
{ "id": 67, "name": "Akıllı Priz", "slug": "smart-plug", "parentId": 65 },
|
{ "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": 68, "name": "Akıllı Ampul", "slug": "smart-bulb", "parentId": 65 },
|
{ "id": 63, "name": "Lens", "slug": "camera-lens", "parentId": 60, "description": "Kamera lensleri; prime/zoom, geniş açı, tele, portre ve benzeri seçenekler." },
|
||||||
{ "id": 69, "name": "Akıllı Sensör", "slug": "smart-sensor", "parentId": 65 },
|
{ "id": 64, "name": "Tripod", "slug": "camera-tripod", "parentId": 60, "description": "Fotoğraf/video için tripod, monopod ve stabil çekim destek ekipmanları." },
|
||||||
|
|
||||||
{ "id": 70, "name": "Ağ Ürünleri", "slug": "pc-networking", "parentId": 6 },
|
{ "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": 71, "name": "Router", "slug": "pc-router", "parentId": 70 },
|
{ "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": 72, "name": "Modem", "slug": "pc-modem", "parentId": 70 },
|
{ "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": 73, "name": "Switch", "slug": "pc-switch", "parentId": 70 },
|
{ "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": 74, "name": "Wi-Fi Extender", "slug": "pc-wifi-extender", "parentId": 70 },
|
{ "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": 75, "name": "Yazıcı & Tarayıcı", "slug": "pc-printing", "parentId": 6 },
|
{ "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": 76, "name": "Yazıcı", "slug": "pc-printer", "parentId": 75 },
|
{ "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": 77, "name": "Toner & Kartuş", "slug": "pc-ink-toner", "parentId": 75 },
|
{ "id": 72, "name": "Modem", "slug": "pc-modem", "parentId": 70, "description": "DSL/VDSL/FTTH uyumlu modemler ve modem-router cihazları." },
|
||||||
{ "id": 78, "name": "Tarayıcı", "slug": "pc-scanner", "parentId": 75 },
|
{ "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": 79, "name": "Dizüstü Bilgisayar", "slug": "pc-laptop", "parentId": 6 },
|
{ "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": 80, "name": "Masaüstü Bilgisayar", "slug": "pc-desktop", "parentId": 6 },
|
{ "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": 81, "name": "Tablet", "slug": "pc-tablet", "parentId": 6 },
|
{ "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": 82, "name": "Depolama", "slug": "pc-storage", "parentId": 6 },
|
{ "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": 83, "name": "Harici Disk", "slug": "pc-external-drive", "parentId": 82 },
|
{ "id": 80, "name": "Masaüstü Bilgisayar", "slug": "pc-desktop", "parentId": 6, "description": "Hazır masaüstü bilgisayarlar ve iş/oyun odaklı sistemler." },
|
||||||
{ "id": 84, "name": "USB Flash", "slug": "pc-usb-drive", "parentId": 82 },
|
{ "id": 81, "name": "Tablet", "slug": "pc-tablet", "parentId": 6, "description": "Android/iPadOS/Windows tabletler ve tablet benzeri cihazlar." },
|
||||||
{ "id": 85, "name": "NAS", "slug": "pc-nas", "parentId": 82 },
|
|
||||||
|
|
||||||
{ "id": 86, "name": "Webcam", "slug": "pc-webcam", "parentId": 12 },
|
{ "id": 82, "name": "Depolama", "slug": "pc-storage", "parentId": 6, "description": "Harici disk, USB bellek, NAS ve diğer depolama çözümleri." },
|
||||||
{ "id": 87, "name": "Hoparlör (PC)", "slug": "pc-speaker", "parentId": 12 },
|
{ "id": 83, "name": "Harici Disk", "slug": "pc-external-drive", "parentId": 82, "description": "Taşınabilir harici HDD/SSD diskler ve yedekleme çözümleri." },
|
||||||
{ "id": 88, "name": "Mikrofon (PC)", "slug": "pc-mic", "parentId": 12 },
|
{ "id": 84, "name": "USB Flash", "slug": "pc-usb-drive", "parentId": 82, "description": "USB bellekler; farklı kapasite ve hız seçenekleri." },
|
||||||
{ "id": 89, "name": "Mousepad", "slug": "pc-mousepad", "parentId": 12 },
|
{ "id": 85, "name": "NAS", "slug": "pc-nas", "parentId": 82, "description": "Ağ üzerinden depolama ve yedekleme için NAS cihazları ve disk kutuları." },
|
||||||
{ "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": 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": 94, "name": "Güç Kaynağı (PSU)", "slug": "pc-psu", "parentId": 7 },
|
{ "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": 95, "name": "Kasa", "slug": "pc-case", "parentId": 7 },
|
{ "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": 96, "name": "Soğutma", "slug": "pc-cooling", "parentId": 7 },
|
{ "id": 93, "name": "Anakart", "slug": "pc-motherboard", "parentId": 7, "description": "İşlemci soketi ve chipset’e göre PC anakartları (ATX/mATX/ITX)." },
|
||||||
{ "id": 97, "name": "Kasa Fanı", "slug": "pc-fan", "parentId": 96 },
|
{ "id": 94, "name": "Güç Kaynağı (PSU)", "slug": "pc-psu", "parentId": 7, "description": "Bilgisayar bileşenlerini besleyen PSU güç kaynakları (80+ sertifikalı vb.)." },
|
||||||
{ "id": 98, "name": "Sıvı Soğutma", "slug": "pc-liquid-cooling", "parentId": 96 },
|
{ "id": 95, "name": "Kasa", "slug": "pc-case", "parentId": 7, "description": "Bilgisayar kasaları; hava akışı, boyut ve tasarıma göre seçenekler." },
|
||||||
|
|
||||||
{ "id": 99, "name": "Parfüm", "slug": "beauty-fragrance", "parentId": 2 },
|
{ "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": 100, "name": "Kadın Parfüm", "slug": "beauty-fragrance-women", "parentId": 99 },
|
{ "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": 101, "name": "Erkek Parfüm", "slug": "beauty-fragrance-men", "parentId": 99 },
|
{ "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": 102, "name": "Saç Bakımı", "slug": "beauty-haircare", "parentId": 2 },
|
{ "id": 99, "name": "Parfüm", "slug": "beauty-fragrance", "parentId": 2, "description": "Kadın/erkek parfümleri, deodorantlar ve koku ürünleri." },
|
||||||
{ "id": 103, "name": "Şampuan", "slug": "beauty-shampoo", "parentId": 102 },
|
{ "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": 104, "name": "Saç Kremi", "slug": "beauty-conditioner", "parentId": 102 },
|
{ "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": 105, "name": "Saç Şekillendirici", "slug": "beauty-hair-styling", "parentId": 102 },
|
|
||||||
|
|
||||||
{ "id": 106, "name": "Kişisel Bakım", "slug": "beauty-personal-care", "parentId": 2 },
|
{ "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": 107, "name": "Deodorant", "slug": "beauty-deodorant", "parentId": 106 },
|
{ "id": 103, "name": "Şampuan", "slug": "beauty-shampoo", "parentId": 102, "description": "Kepek, yağlı/kuru saç, onarıcı ve renk koruyucu şampuan çeşitleri." },
|
||||||
{ "id": 108, "name": "Tıraş Ürünleri", "slug": "beauty-shaving", "parentId": 106 },
|
{ "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": 109, "name": "Ağda / Epilasyon", "slug": "beauty-hair-removal", "parentId": 106 },
|
{ "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": 110, "name": "Serum", "slug": "beauty-skincare-serum", "parentId": 20 },
|
{ "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": 111, "name": "Güneş Kremi", "slug": "beauty-sunscreen", "parentId": 20 },
|
{ "id": 107, "name": "Deodorant", "slug": "beauty-deodorant", "parentId": 106, "description": "Ter kokusunu önlemeye yardımcı roll-on, sprey ve stick deodorantlar." },
|
||||||
{ "id": 112, "name": "Temizleyici", "slug": "beauty-cleanser", "parentId": 20 },
|
{ "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": 113, "name": "Yüz Maskesi", "slug": "beauty-mask", "parentId": 20 },
|
{ "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": 114, "name": "Tonik", "slug": "beauty-toner", "parentId": 20 },
|
|
||||||
|
|
||||||
{ "id": 115, "name": "Temel Gıda", "slug": "food-staples", "parentId": 3 },
|
{ "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": 116, "name": "Makarna", "slug": "food-pasta", "parentId": 115 },
|
{ "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": 117, "name": "Pirinç & Bakliyat", "slug": "food-legumes", "parentId": 115 },
|
{ "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": 118, "name": "Yağ & Sirke (Gıda)", "slug": "food-oil-vinegar", "parentId": 115 },
|
{ "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": 119, "name": "Kahvaltılık", "slug": "food-breakfast", "parentId": 3 },
|
{ "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": 120, "name": "Peynir", "slug": "food-cheese", "parentId": 119 },
|
{ "id": 116, "name": "Makarna", "slug": "food-pasta", "parentId": 115, "description": "Spagetti, penne, erişte ve farklı çeşitlerde makarna ürünleri." },
|
||||||
{ "id": 121, "name": "Zeytin", "slug": "food-olive", "parentId": 119 },
|
{ "id": 117, "name": "Pirinç & Bakliyat", "slug": "food-legumes", "parentId": 115, "description": "Pirinç, bulgur, mercimek, nohut, fasulye ve diğer bakliyatlar." },
|
||||||
{ "id": 122, "name": "Reçel & Bal", "slug": "food-jam-honey", "parentId": 119 },
|
{ "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": 123, "name": "Gazlı İçecek", "slug": "food-soda", "parentId": 24 },
|
{ "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": 124, "name": "Su", "slug": "food-water", "parentId": 24 },
|
{ "id": 120, "name": "Peynir", "slug": "food-cheese", "parentId": 119, "description": "Beyaz peynir, kaşar, tulum ve farklı peynir çeşitleri." },
|
||||||
{ "id": 125, "name": "Enerji İçeceği", "slug": "food-energy", "parentId": 24 },
|
{ "id": 121, "name": "Zeytin", "slug": "food-olive", "parentId": 119, "description": "Siyah/yeşil, çekirdekli/çekirdeksiz ve salamura zeytin çeşitleri." },
|
||||||
{ "id": 126, "name": "Çay", "slug": "food-tea", "parentId": 24 },
|
{ "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": 127, "name": "Dondurulmuş", "slug": "food-frozen", "parentId": 3 },
|
{ "id": 123, "name": "Gazlı İçecek", "slug": "food-soda", "parentId": 24, "description": "Kola, gazoz, aromalı soda ve benzeri gazlı içecekler." },
|
||||||
{ "id": 128, "name": "Et & Tavuk", "slug": "food-meat", "parentId": 3 },
|
{ "id": 124, "name": "Su", "slug": "food-water", "parentId": 24, "description": "Pet şişe, damacana ve aromalı su seçenekleri." },
|
||||||
{ "id": 129, "name": "Tatlı", "slug": "food-dessert", "parentId": 3 },
|
{ "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": 130, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 4 },
|
{ "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": 131, "name": "Araç İçi Elektronik", "slug": "auto-in-car-electronics", "parentId": 130 },
|
{ "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": 132, "name": "Oto Bakım", "slug": "auto-care", "parentId": 4 },
|
{ "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": 133, "name": "Oto Temizlik", "slug": "auto-cleaning", "parentId": 132 },
|
{ "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": 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": 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": 139, "name": "Yemek Masası", "slug": "home-dining-table", "parentId": 138 },
|
{ "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": 140, "name": "Sandalye", "slug": "home-chair", "parentId": 138 },
|
{ "id": 134, "name": "Lastik & Jant", "slug": "auto-tires", "parentId": 4, "description": "Lastik, jant ve ilgili aksesuarlar; mevsimlik lastikler ve bakım ürünleri." },
|
||||||
{ "id": 141, "name": "Koltuk", "slug": "home-sofa", "parentId": 138 },
|
{ "id": 135, "name": "Akü", "slug": "auto-battery", "parentId": 4, "description": "Otomobil aküleri ve akü takviye/şarj ekipmanları." },
|
||||||
{ "id": 142, "name": "Yatak", "slug": "home-bed", "parentId": 138 },
|
{ "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": 143, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 5 },
|
{ "id": 138, "name": "Mobilya", "slug": "home-furniture", "parentId": 5, "description": "Ev mobilyaları; masa, sandalye, koltuk, yatak ve depolama ürünleri." },
|
||||||
{ "id": 144, "name": "Nevresim", "slug": "home-bedding", "parentId": 143 },
|
{ "id": 139, "name": "Yemek Masası", "slug": "home-dining-table", "parentId": 138, "description": "Mutfak/yemek odası için farklı boyut ve malzemelerde yemek masaları." },
|
||||||
{ "id": 145, "name": "Yorgan & Battaniye", "slug": "home-blanket", "parentId": 143 },
|
{ "id": 140, "name": "Sandalye", "slug": "home-chair", "parentId": 138, "description": "Yemek odası, çalışma ve çok amaçlı kullanım için sandalyeler." },
|
||||||
{ "id": 146, "name": "Perde", "slug": "home-curtain", "parentId": 143 },
|
{ "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": 147, "name": "Mutfak", "slug": "home-kitchen", "parentId": 5 },
|
{ "id": 143, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 5, "description": "Nevresim, battaniye, perde ve diğer ev tekstili ürünleri." },
|
||||||
{ "id": 148, "name": "Tencere & Tava", "slug": "home-cookware", "parentId": 147 },
|
{ "id": 144, "name": "Nevresim", "slug": "home-bedding", "parentId": 143, "description": "Nevresim takımları, çarşaflar ve yastık kılıfları." },
|
||||||
{ "id": 149, "name": "Küçük Ev Aletleri", "slug": "home-small-appliances", "parentId": 147 },
|
{ "id": 145, "name": "Yorgan & Battaniye", "slug": "home-blanket", "parentId": 143, "description": "Isı ve konfor sağlayan yorgan, battaniye ve uyku ürünleri." },
|
||||||
{ "id": 150, "name": "Kahve Makinesi", "slug": "home-coffee-machine", "parentId": 149 },
|
{ "id": 146, "name": "Perde", "slug": "home-curtain", "parentId": 143, "description": "Tül, fon ve stor gibi farklı perde çeşitleri ve aksesuarları." },
|
||||||
{ "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": 147, "name": "Mutfak", "slug": "home-kitchen", "parentId": 5, "description": "Mutfak gereçleri, pişirme ekipmanları ve küçük ev aletleri." },
|
||||||
{ "id": 155, "name": "Dekorasyon", "slug": "home-decor", "parentId": 5 },
|
{ "id": 148, "name": "Tencere & Tava", "slug": "home-cookware", "parentId": 147, "description": "Tencere setleri, tava çeşitleri ve pişirme ekipmanları." },
|
||||||
{ "id": 156, "name": "Halı", "slug": "home-rug", "parentId": 155 },
|
{ "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": 157, "name": "Duvar Dekoru", "slug": "home-wall-decor", "parentId": 155 },
|
{ "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": 158, "name": "Temizlik", "slug": "home-cleaning", "parentId": 5 },
|
{ "id": 154, "name": "Aydınlatma", "slug": "home-lighting", "parentId": 5, "description": "Avize, lambader, masa lambası ve LED aydınlatma çözümleri." },
|
||||||
{ "id": 159, "name": "Deterjan", "slug": "home-detergent", "parentId": 158 },
|
{ "id": 155, "name": "Dekorasyon", "slug": "home-decor", "parentId": 5, "description": "Evi kişiselleştiren dekoratif ürünler; aksesuar, tablo, obje ve benzerleri." },
|
||||||
{ "id": 160, "name": "Kağıt Ürünleri", "slug": "home-paper-products", "parentId": 158 },
|
{ "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": 161, "name": "El Aletleri", "slug": "home-tools", "parentId": 5 },
|
{ "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": 162, "name": "Matkap", "slug": "home-drill", "parentId": 161 },
|
{ "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": 163, "name": "Testere", "slug": "home-saw", "parentId": 161 },
|
{ "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": 164, "name": "Vida & Dübel", "slug": "home-hardware", "parentId": 161 },
|
|
||||||
|
|
||||||
{ "id": 165, "name": "Evcil Hayvan", "slug": "pet", "parentId": 5 },
|
{ "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": 166, "name": "Kedi Maması", "slug": "pet-cat-food", "parentId": 165 },
|
{ "id": 162, "name": "Matkap", "slug": "home-drill", "parentId": 161, "description": "Darbeli/darbesiz, şarjlı/kablolu matkap ve vidalama makineleri." },
|
||||||
{ "id": 167, "name": "Köpek Maması", "slug": "pet-dog-food", "parentId": 165 },
|
{ "id": 163, "name": "Testere", "slug": "home-saw", "parentId": 161, "description": "Ahşap/metal kesim için el testereleri ve elektrikli testere çeşitleri." },
|
||||||
{ "id": 168, "name": "Kedi Kumu", "slug": "pet-cat-litter", "parentId": 165 },
|
{ "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": 169, "name": "Kırtasiye & Ofis", "slug": "office", "parentId": 0 },
|
{ "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": 170, "name": "Kağıt & Defter", "slug": "office-paper-notebook", "parentId": 169 },
|
{ "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": 171, "name": "A4 Kağıdı", "slug": "office-a4-paper", "parentId": 170 },
|
{ "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": 172, "name": "Kalem", "slug": "office-pen", "parentId": 169 },
|
{ "id": 168, "name": "Kedi Kumu", "slug": "pet-cat-litter", "parentId": 165, "description": "Topaklanan/silikalı/bitkisel kedi kumları ve koku kontrol çözümleri." },
|
||||||
{ "id": 173, "name": "Okul Çantası", "slug": "office-school-bag", "parentId": 169 },
|
|
||||||
|
|
||||||
{ "id": 174, "name": "Bebek & Çocuk", "slug": "baby", "parentId": 0 },
|
{ "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": 175, "name": "Bebek Bezi", "slug": "baby-diaper", "parentId": 174 },
|
{ "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": 176, "name": "Islak Mendil", "slug": "baby-wipes", "parentId": 174 },
|
{ "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": 177, "name": "Bebek Maması", "slug": "baby-food", "parentId": 174 },
|
{ "id": 172, "name": "Kalem", "slug": "office-pen", "parentId": 169, "description": "Tükenmez, jel, kurşun, marker ve farklı amaçlara uygun kalemler." },
|
||||||
{ "id": 178, "name": "Oyuncak", "slug": "baby-toys", "parentId": 174 },
|
{ "id": 173, "name": "Okul Çantası", "slug": "office-school-bag", "parentId": 169, "description": "Öğrenciler için sırt çantası, beslenme çantası ve okul çantaları." },
|
||||||
|
|
||||||
{ "id": 179, "name": "Spor & Outdoor", "slug": "sports", "parentId": 0 },
|
{ "id": 174, "name": "Bebek & Çocuk", "slug": "baby", "parentId": 0, "description": "Bebek ve çocuk bakım/bez, mama, ıslak mendil ve oyuncak ürünleri." },
|
||||||
{ "id": 180, "name": "Kamp", "slug": "sports-camping", "parentId": 179 },
|
{ "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": 181, "name": "Fitness", "slug": "sports-fitness", "parentId": 179 },
|
{ "id": 176, "name": "Islak Mendil", "slug": "baby-wipes", "parentId": 174, "description": "Bebek bakımı için ıslak mendil; hassas cilt uyumlu seçenekler." },
|
||||||
{ "id": 182, "name": "Bisiklet", "slug": "sports-bicycle", "parentId": 179 },
|
{ "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": 183, "name": "Moda", "slug": "fashion", "parentId": 0 },
|
{ "id": 179, "name": "Spor & Outdoor", "slug": "sports", "parentId": 0, "description": "Spor ekipmanları ve outdoor ürünleri; kamp, fitness, bisiklet ve daha fazlası." },
|
||||||
{ "id": 184, "name": "Ayakkabı", "slug": "fashion-shoes", "parentId": 183 },
|
{ "id": 180, "name": "Kamp", "slug": "sports-camping", "parentId": 179, "description": "Çadır, uyku tulumu, kamp sandalyesi ve kamp ekipmanları." },
|
||||||
{ "id": 185, "name": "Erkek Giyim", "slug": "fashion-men", "parentId": 183 },
|
{ "id": 181, "name": "Fitness", "slug": "sports-fitness", "parentId": 179, "description": "Ağırlık, dambıl, mat ve evde antrenman için fitness ekipmanları." },
|
||||||
{ "id": 186, "name": "Kadın Giyim", "slug": "fashion-women", "parentId": 183 },
|
{ "id": 182, "name": "Bisiklet", "slug": "sports-bicycle", "parentId": 179, "description": "Şehir/dağ/katlanır bisikletler ve bisiklet aksesuarları." },
|
||||||
{ "id": 187, "name": "Çanta", "slug": "fashion-bags", "parentId": 183 },
|
|
||||||
|
{ "id": 183, "name": "Moda", "slug": "fashion", "parentId": 0, "description": "Giyim, ayakkabı ve aksesuar ürünleri; kadın/erkek moda kategorileri." },
|
||||||
|
{ "id": 184, "name": "Ayakkabı", "slug": "fashion-shoes", "parentId": 183, "description": "Spor ayakkabı, günlük ayakkabı ve farklı kullanım amaçlarına uygun modeller." },
|
||||||
|
{ "id": 185, "name": "Erkek Giyim", "slug": "fashion-men", "parentId": 183, "description": "Erkek kıyafetleri; tişört, gömlek, pantolon, mont ve daha fazlası." },
|
||||||
|
{ "id": 186, "name": "Kadın Giyim", "slug": "fashion-women", "parentId": 183, "description": "Kadın kıyafetleri; elbise, bluz, pantolon, mont ve daha fazlası." },
|
||||||
|
{ "id": 187, "name": "Çanta", "slug": "fashion-bags", "parentId": 183, "description": "Sırt çantası, el çantası, valiz ve farklı kullanım amaçlı çantalar." },
|
||||||
|
|
||||||
|
{ "id": 188, "name": "Kitap & Medya", "slug": "books-media", "parentId": 0, "description": "Kitaplar, dijital içerikler, oyun ve medya ürünleri." },
|
||||||
|
{ "id": 189, "name": "Kitap", "slug": "books", "parentId": 188, "description": "Roman, kişisel gelişim, eğitim ve diğer türlerde basılı kitaplar." },
|
||||||
|
{ "id": 190, "name": "Dijital Oyun (Genel)", "slug": "digital-games", "parentId": 191, "description": "PC/konsol platformları için dijital oyunlar, kodlar ve dijital içerikler." },
|
||||||
|
{ "id": 191, "name": "Oyun", "slug": "games", "parentId": 0, "description": "Konsol, PC ve dijital oyun fırsatları; oyun ekipmanları ve abonelikler." }
|
||||||
|
|
||||||
{ "id": 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 }
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
230
prisma/deals.json
Normal file
230
prisma/deals.json
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Category" ADD COLUMN "description" TEXT NOT NULL DEFAULT '';
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Deal" ADD COLUMN "originalPrice" DOUBLE PRECISION,
|
||||||
|
ADD COLUMN "percentOff" DOUBLE PRECISION,
|
||||||
|
ADD COLUMN "shippingPrice" DOUBLE PRECISION;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Seller" ADD COLUMN "sellerLogo" TEXT NOT NULL DEFAULT '';
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Comment" ADD COLUMN "likeCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CommentLike" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"commentId" INTEGER NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "CommentLike_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CommentLike_commentId_idx" ON "CommentLike"("commentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CommentLike_userId_idx" ON "CommentLike"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CommentLike_commentId_userId_key" ON "CommentLike"("commentId", "userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CommentLike" ADD CONSTRAINT "CommentLike_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CommentLike" ADD CONSTRAINT "CommentLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -36,6 +36,7 @@ model User {
|
||||||
dealNotices DealNotice[] @relation("UserDealNotices")
|
dealNotices DealNotice[] @relation("UserDealNotices")
|
||||||
|
|
||||||
refreshTokens RefreshToken[] // <-- bunu ekle
|
refreshTokens RefreshToken[] // <-- bunu ekle
|
||||||
|
commentLikes CommentLike[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
|
|
@ -86,7 +87,6 @@ model SellerDomain {
|
||||||
domain String @unique
|
domain String @unique
|
||||||
sellerId Int
|
sellerId Int
|
||||||
seller Seller @relation(fields: [sellerId], references: [id])
|
seller Seller @relation(fields: [sellerId], references: [id])
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
createdById Int
|
createdById Int
|
||||||
createdBy User @relation(fields: [createdById], references: [id])
|
createdBy User @relation(fields: [createdById], references: [id])
|
||||||
|
|
@ -96,6 +96,7 @@ model Seller {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
url String @default("")
|
url String @default("")
|
||||||
|
sellerLogo String @default("")
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
createdById Int
|
createdById Int
|
||||||
|
|
@ -113,7 +114,7 @@ model Category {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
description String @default("")
|
||||||
parentId Int?
|
parentId Int?
|
||||||
parent Category? @relation("CategoryParent", fields: [parentId], references: [id])
|
parent Category? @relation("CategoryParent", fields: [parentId], references: [id])
|
||||||
children Category[] @relation("CategoryParent")
|
children Category[] @relation("CategoryParent")
|
||||||
|
|
@ -157,7 +158,9 @@ model Deal {
|
||||||
description String?
|
description String?
|
||||||
url String?
|
url String?
|
||||||
price Float?
|
price Float?
|
||||||
|
originalPrice Float?
|
||||||
|
shippingPrice Float?
|
||||||
|
percentOff Float?
|
||||||
userId Int
|
userId Int
|
||||||
score Int @default(0)
|
score Int @default(0)
|
||||||
commentCount Int @default(0)
|
commentCount Int @default(0)
|
||||||
|
|
@ -267,6 +270,7 @@ model Comment {
|
||||||
dealId Int
|
dealId Int
|
||||||
|
|
||||||
parentId Int?
|
parentId Int?
|
||||||
|
likeCount Int @default(0)
|
||||||
|
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
|
@ -275,6 +279,7 @@ model Comment {
|
||||||
|
|
||||||
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull)
|
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull)
|
||||||
replies Comment[] @relation("CommentReplies")
|
replies Comment[] @relation("CommentReplies")
|
||||||
|
likes CommentLike[]
|
||||||
|
|
||||||
@@index([dealId, createdAt])
|
@@index([dealId, createdAt])
|
||||||
@@index([parentId, createdAt])
|
@@index([parentId, createdAt])
|
||||||
|
|
@ -282,6 +287,20 @@ model Comment {
|
||||||
@@index([deletedAt])
|
@@index([deletedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CommentLike {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
commentId Int
|
||||||
|
userId Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([commentId, userId])
|
||||||
|
@@index([commentId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
enum DealAiIssueType {
|
enum DealAiIssueType {
|
||||||
NONE
|
NONE
|
||||||
PROFANITY
|
PROFANITY
|
||||||
|
|
|
||||||
131
prisma/seed.js
131
prisma/seed.js
|
|
@ -1,6 +1,5 @@
|
||||||
// prisma/seed.js
|
// prisma/seed.js
|
||||||
const { PrismaClient, DealStatus, SaleType, AffiliateType } = require("@prisma/client")
|
const { PrismaClient, DealStatus, SaleType, AffiliateType } = require("@prisma/client")
|
||||||
const bcrypt = require("bcryptjs")
|
|
||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
|
|
||||||
|
|
@ -27,6 +26,12 @@ function normalizeSlug(s) {
|
||||||
return String(s ?? "").trim().toLowerCase()
|
return String(s ?? "").trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toNumberOrNull(v) {
|
||||||
|
if (v === null || v === undefined || v === "") return null
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
async function upsertTagBySlug(slug, name) {
|
async function upsertTagBySlug(slug, name) {
|
||||||
const s = normalizeSlug(slug)
|
const s = normalizeSlug(slug)
|
||||||
return prisma.tag.upsert({
|
return prisma.tag.upsert({
|
||||||
|
|
@ -66,6 +71,7 @@ function loadCategoriesJson(filePath) {
|
||||||
id: Number(c.id),
|
id: Number(c.id),
|
||||||
name: String(c.name ?? "").trim(),
|
name: String(c.name ?? "").trim(),
|
||||||
slug: normalizeSlug(c.slug),
|
slug: normalizeSlug(c.slug),
|
||||||
|
description: c.description,
|
||||||
parentId: c.parentId === null || c.parentId === undefined ? null : Number(c.parentId),
|
parentId: c.parentId === null || c.parentId === undefined ? null : Number(c.parentId),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -102,11 +108,13 @@ async function seedCategoriesFromJson(categoriesFilePath) {
|
||||||
update: {
|
update: {
|
||||||
name: c.name,
|
name: c.name,
|
||||||
slug: c.slug,
|
slug: c.slug,
|
||||||
|
description: c.description,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
slug: c.slug,
|
slug: c.slug,
|
||||||
|
description: c.description,
|
||||||
parentId: null,
|
parentId: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -135,56 +143,110 @@ async function seedCategoriesFromJson(categoriesFilePath) {
|
||||||
return { count: categories.length }
|
return { count: categories.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 30 deal seed + her deal'a 3 foto + score 0-200 + tarih dağılımı:
|
function loadDealsJson(filePath) {
|
||||||
// - %70: son 5 gün
|
const raw = fs.readFileSync(filePath, "utf-8")
|
||||||
// - %30: 10 gün önce civarı (9-11 gün arası)
|
const arr = JSON.parse(raw)
|
||||||
async function seedDeals30({ userId, sellerId, categoryId }) {
|
|
||||||
const baseItems = [
|
|
||||||
{ title: "Samsung 990 PRO 1TB NVMe SSD", price: 3299.99, url: "https://example.com/samsung-990pro-1tb", q: "nvme ssd" },
|
|
||||||
{ title: "Logitech MX Master 3S Mouse", price: 2499.9, url: "https://example.com/mx-master-3s", q: "wireless mouse" },
|
|
||||||
{ title: "Sony WH-1000XM5 Kulaklık", price: 9999.0, url: "https://example.com/sony-xm5", q: "headphones" },
|
|
||||||
{ title: "Apple AirPods Pro 2", price: 8499.0, url: "https://example.com/airpods-pro-2", q: "earbuds" },
|
|
||||||
{ title: "Anker 65W GaN Şarj Aleti", price: 899.0, url: "https://example.com/anker-65w-gan", q: "charger" },
|
|
||||||
{ title: "Kindle Paperwhite 16GB", price: 5199.0, url: "https://example.com/kindle-paperwhite", q: "ebook reader" },
|
|
||||||
{ title: 'Dell 27" 144Hz Monitör', price: 7999.0, url: "https://example.com/dell-27-144hz", q: "gaming monitor" },
|
|
||||||
{ title: "TP-Link Wi-Fi 6 Router", price: 1999.0, url: "https://example.com/tplink-wifi6", q: "wifi router" },
|
|
||||||
{ title: "Razer Huntsman Mini Klavye", price: 3499.0, url: "https://example.com/huntsman-mini", q: "mechanical keyboard" },
|
|
||||||
{ title: "WD Elements 2TB Harici Disk", price: 2399.0, url: "https://example.com/wd-elements-2tb", q: "external hard drive" },
|
|
||||||
{ title: "Samsung T7 Shield 1TB SSD", price: 2799.0, url: "https://example.com/samsung-t7-shield", q: "portable ssd" },
|
|
||||||
{ title: "Xiaomi Mi Band 8", price: 1399.0, url: "https://example.com/mi-band-8", q: "smart band" },
|
|
||||||
{ title: "Philips Airfryer 6.2L", price: 5999.0, url: "https://example.com/philips-airfryer", q: "air fryer" },
|
|
||||||
{ title: "Dyson V12 Detect Slim", price: 21999.0, url: "https://example.com/dyson-v12", q: "vacuum cleaner" },
|
|
||||||
{ title: "Nespresso Vertuo Kahve Makinesi", price: 6999.0, url: "https://example.com/nespresso-vertuo", q: "coffee machine" },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 30'a tamamlamak için ikinci bir set üret (title/url benzersiz olsun)
|
if (!Array.isArray(arr)) throw new Error("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ı:
|
||||||
|
// - %70: son 5 gün
|
||||||
|
// - %30: 9-11 gün önce
|
||||||
|
async function seedDealsFromJson({ userId, sellerId, categoryId, dealsFilePath }) {
|
||||||
|
const baseItems = loadDealsJson(dealsFilePath)
|
||||||
|
|
||||||
|
// 30 adet olacak şekilde çoğalt (title/url benzersizleşsin)
|
||||||
const items = []
|
const items = []
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
const base = baseItems[i % baseItems.length]
|
const base = baseItems[i % baseItems.length]
|
||||||
const n = i + 1
|
const n = i + 1
|
||||||
|
|
||||||
|
// price'ı hafif oynat (base price üzerinden)
|
||||||
|
const price = Number((base.price * (0.9 + randInt(0, 30) / 100)).toFixed(2))
|
||||||
|
|
||||||
|
// originalPrice varsa, yeni price'a göre ölçekle (mantık korunur)
|
||||||
|
let originalPrice = null
|
||||||
|
if (base.originalPrice !== null && base.originalPrice !== undefined) {
|
||||||
|
const ratio = base.originalPrice / base.price // >= 1 olmalı
|
||||||
|
originalPrice = Number((price * ratio).toFixed(2))
|
||||||
|
if (originalPrice < price) originalPrice = Number((price * 1.05).toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// shippingPrice varsa bazen aynen, bazen 0/ufak varyasyon (ama null değilse)
|
||||||
|
let shippingPrice = null
|
||||||
|
if (base.shippingPrice !== null && base.shippingPrice !== undefined) {
|
||||||
|
// 70% aynı, 30% küçük oynat
|
||||||
|
if (Math.random() < 0.7) {
|
||||||
|
shippingPrice = Number(base.shippingPrice)
|
||||||
|
} else {
|
||||||
|
const candidates = [0, 19.9, 29.9, 39.9, 49.9, 59.9]
|
||||||
|
shippingPrice = candidates[randInt(0, candidates.length - 1)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
title: `${base.title} #${n}`,
|
title: `${base.title} #${n}`,
|
||||||
price: Number((base.price * (0.9 + (randInt(0, 30) / 100))).toFixed(2)),
|
price,
|
||||||
url: `${base.url}?seed=${n}`,
|
originalPrice,
|
||||||
q: base.q,
|
shippingPrice,
|
||||||
|
url: `${base.url}${base.url.includes("?") ? "&" : "?"}seed=${n}`,
|
||||||
|
q: base.q || "product",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const it = items[i]
|
const it = items[i]
|
||||||
|
|
||||||
// %30'u 9-11 gün önce, %70'i son 5 gün
|
|
||||||
const older = Math.random() < 0.3
|
const older = Math.random() < 0.3
|
||||||
const createdAt = older
|
const createdAt = older
|
||||||
? new Date(Date.now() - randInt(9, 11) * 24 * 60 * 60 * 1000 - randInt(0, 12) * 60 * 60 * 1000)
|
? new Date(Date.now() - randInt(9, 11) * 24 * 60 * 60 * 1000 - randInt(0, 12) * 60 * 60 * 1000)
|
||||||
: randomDateWithinLastDays(5)
|
: randomDateWithinLastDays(5)
|
||||||
|
|
||||||
// Not: modelinde score yoksa score satırını sil
|
|
||||||
const dealData = {
|
const dealData = {
|
||||||
title: it.title,
|
title: it.title,
|
||||||
description: "Seed test deal açıklaması (otomatik üretim).",
|
description: "Seed test deal açıklaması (otomatik üretim).",
|
||||||
url: it.url,
|
url: it.url,
|
||||||
price: it.price,
|
price: it.price,
|
||||||
|
originalPrice: it.originalPrice ?? null,
|
||||||
|
shippingPrice: it.shippingPrice ?? null,
|
||||||
status: DealStatus.ACTIVE,
|
status: DealStatus.ACTIVE,
|
||||||
saletype: SaleType.ONLINE,
|
saletype: SaleType.ONLINE,
|
||||||
affiliateType: AffiliateType.NON_AFFILIATE,
|
affiliateType: AffiliateType.NON_AFFILIATE,
|
||||||
|
|
@ -249,6 +311,7 @@ async function main() {
|
||||||
create: {
|
create: {
|
||||||
name: "Amazon",
|
name: "Amazon",
|
||||||
url: "https://www.amazon.com.tr",
|
url: "https://www.amazon.com.tr",
|
||||||
|
sellerLogo:"https://1000logos.net/wp-content/uploads/2016/10/Amazon-logo-meaning.jpg",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdById: admin.id,
|
createdById: admin.id,
|
||||||
},
|
},
|
||||||
|
|
@ -269,7 +332,7 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- CATEGORIES (FROM JSON) ----------
|
// ---------- CATEGORIES (FROM JSON) ----------
|
||||||
const categoriesFilePath = path.join(__dirname, "", "categories.json")
|
const categoriesFilePath = path.join(__dirname, "categories.json")
|
||||||
const { count } = await seedCategoriesFromJson(categoriesFilePath)
|
const { count } = await seedCategoriesFromJson(categoriesFilePath)
|
||||||
|
|
||||||
const catSSD = await prisma.category.findUnique({
|
const catSSD = await prisma.category.findUnique({
|
||||||
|
|
@ -294,6 +357,8 @@ async function main() {
|
||||||
description: "Test deal açıklaması",
|
description: "Test deal açıklaması",
|
||||||
url: dealUrl,
|
url: dealUrl,
|
||||||
price: 1299.99,
|
price: 1299.99,
|
||||||
|
originalPrice: 1499.99, // örnek
|
||||||
|
shippingPrice: 0, // örnek
|
||||||
status: DealStatus.ACTIVE,
|
status: DealStatus.ACTIVE,
|
||||||
saletype: SaleType.ONLINE,
|
saletype: SaleType.ONLINE,
|
||||||
affiliateType: AffiliateType.NON_AFFILIATE,
|
affiliateType: AffiliateType.NON_AFFILIATE,
|
||||||
|
|
@ -321,11 +386,13 @@ async function main() {
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// ✅ ---------- 30 DEAL ÜRET ----------
|
// ✅ ---------- deals.json’dan 30 DEAL ÜRET ----------
|
||||||
await seedDeals30({
|
const dealsFilePath = path.join(__dirname, "deals.json")
|
||||||
|
await seedDealsFromJson({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
sellerId: amazon.id,
|
sellerId: amazon.id,
|
||||||
categoryId: catSSD?.id ?? 0,
|
categoryId: catSSD?.id ?? 0,
|
||||||
|
dealsFilePath,
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------- VOTE ----------
|
// ---------- VOTE ----------
|
||||||
|
|
@ -347,7 +414,7 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Seed tamamlandı (categories.json yüklendi: ${count} kategori)`)
|
console.log(`✅ Seed tamamlandı (categories.json yüklendi: ${count} kategori)`)
|
||||||
console.log("✅ 30 adet test deal + 3'er görsel + score(0-200) + tarih dağılımı eklendi/güncellendi")
|
console.log("✅ deals.json baz alınarak 30 adet test deal + 3'er görsel + score(0-200) + tarih dağılımı eklendi/güncellendi")
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const categoryService = require("../services/category.service"); // Kategori servisi
|
const categoryService = require("../services/category.service"); // Kategori servisi
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const optionalAuth = require("../middleware/optionalAuth")
|
||||||
const { mapCategoryToCategoryDetailsResponse }=require("../adapters/responses/categoryDetails.adapter")
|
const { mapCategoryToCategoryDetailsResponse }=require("../adapters/responses/categoryDetails.adapter")
|
||||||
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter")
|
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter")
|
||||||
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
|
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
|
||||||
|
|
@ -22,7 +23,10 @@ router.get("/:slug", async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
router.get("/:slug/deals", async (req, res) => {
|
const buildViewer = (req) =>
|
||||||
|
req.auth ? { userId: req.auth.userId, role: req.auth.role } : null
|
||||||
|
|
||||||
|
router.get("/:slug/deals", optionalAuth, async (req, res) => {
|
||||||
const { slug } = req.params;
|
const { slug } = req.params;
|
||||||
const { page = 1, limit = 10, ...filters } = req.query;
|
const { page = 1, limit = 10, ...filters } = req.query;
|
||||||
|
|
||||||
|
|
@ -33,7 +37,12 @@ router.get("/:slug/deals", async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kategorinin fırsatlarını alıyoruz
|
// Kategorinin fırsatlarını alıyoruz
|
||||||
const deals = await categoryService.getDealsByCategoryId(category.id, page, limit, filters);
|
const payload = await categoryService.getDealsByCategoryId(category.id, {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
filters,
|
||||||
|
viewer: buildViewer(req),
|
||||||
|
});
|
||||||
|
|
||||||
const response = mapPaginatedDealsToDealCardResponse(payload)
|
const response = mapPaginatedDealsToDealCardResponse(payload)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const express = require("express")
|
const express = require("express")
|
||||||
const requireAuth = require("../middleware/requireAuth.js")
|
const requireAuth = require("../middleware/requireAuth.js")
|
||||||
|
const optionalAuth = require("../middleware/optionalAuth")
|
||||||
const { validate } = require("../middleware/validate.middleware")
|
const { validate } = require("../middleware/validate.middleware")
|
||||||
const { endpoints } = require("@shared/contracts")
|
const { endpoints } = require("@shared/contracts")
|
||||||
const { createComment, deleteComment } = require("../services/comment.service")
|
const { createComment, deleteComment } = require("../services/comment.service")
|
||||||
|
|
@ -12,13 +13,28 @@ const { comments } = endpoints
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/:dealId",
|
"/:dealId",
|
||||||
|
optionalAuth,
|
||||||
validate(comments.commentListRequestSchema, "params", "validatedDealId"),
|
validate(comments.commentListRequestSchema, "params", "validatedDealId"),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { dealId } = req.validatedDealId
|
const { dealId } = req.validatedDealId
|
||||||
const fetched = await commentService.getCommentsByDealId(dealId)
|
const { parentId, page, limit, sort } = req.query
|
||||||
const mapped = dealCommentAdapter.mapCommentsToDealCommentResponse(fetched)
|
const payload = await commentService.getCommentsByDealId(dealId, {
|
||||||
res.json(comments.commentListResponseSchema.parse(mapped))
|
parentId,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
sort,
|
||||||
|
viewer: req.auth ? { userId: req.auth.userId } : null,
|
||||||
|
})
|
||||||
|
const mapped = dealCommentAdapter.mapCommentsToDealCommentResponse(payload.results)
|
||||||
|
res.json(
|
||||||
|
comments.commentListResponseSchema.parse({
|
||||||
|
page: payload.page,
|
||||||
|
total: payload.total,
|
||||||
|
totalPages: payload.totalPages,
|
||||||
|
results: mapped,
|
||||||
|
})
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message })
|
res.status(400).json({ error: err.message })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
routes/commentLike.routes.js
Normal file
23
routes/commentLike.routes.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
const express = require("express")
|
||||||
|
const requireAuth = require("../middleware/requireAuth")
|
||||||
|
const { setCommentLike } = require("../services/commentLike.service")
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// Body: { commentId: number, like: boolean | 0 | 1 }
|
||||||
|
router.post("/", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { commentId, like } = req.body || {}
|
||||||
|
const result = await setCommentLike({ commentId, userId: req.auth.userId, like })
|
||||||
|
res.json({
|
||||||
|
commentId: Number(commentId),
|
||||||
|
likeCount: result.likeCount,
|
||||||
|
liked: result.liked,
|
||||||
|
delta: result.delta,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err.message || "Like işlemi başarısız" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
|
|
@ -34,6 +34,7 @@ function createListHandler(preset) {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
viewer,
|
viewer,
|
||||||
|
filters: req.query,
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = deals.dealsListResponseSchema.parse(
|
const response = deals.dealsListResponseSchema.parse(
|
||||||
|
|
@ -76,6 +77,7 @@ router.get(
|
||||||
limit,
|
limit,
|
||||||
targetUserId: targetUser.id,
|
targetUserId: targetUser.id,
|
||||||
viewer,
|
viewer,
|
||||||
|
filters: req.query,
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = deals.dealsListResponseSchema.parse(
|
const response = deals.dealsListResponseSchema.parse(
|
||||||
|
|
@ -120,6 +122,7 @@ router.get(
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
viewer: buildViewer(req),
|
viewer: buildViewer(req),
|
||||||
|
filters: req.query,
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = deals.dealsListResponseSchema.parse(
|
const response = deals.dealsListResponseSchema.parse(
|
||||||
|
|
@ -153,6 +156,7 @@ router.get("/top", optionalAuth, async (req, res) => {
|
||||||
page: 1,
|
page: 1,
|
||||||
limit,
|
limit,
|
||||||
viewer,
|
viewer,
|
||||||
|
filters: req.query,
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = deals.dealsListResponseSchema.parse(
|
const response = deals.dealsListResponseSchema.parse(
|
||||||
|
|
@ -173,12 +177,13 @@ router.get("/top", optionalAuth, async (req, res) => {
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
|
optionalAuth,
|
||||||
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
|
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const { id } = req.validatedDealId
|
const { id } = req.validatedDealId
|
||||||
const deal = await getDealById(id)
|
const deal = await getDealById(id, buildViewer(req))
|
||||||
if (!deal) return res.status(404).json({ error: "Deal bulunamadi" })
|
if (!deal) return res.status(404).json({ error: "Deal bulunamadi" })
|
||||||
|
|
||||||
const mapped = mapDealToDealDetailResponse(deal)
|
const mapped = mapDealToDealDetailResponse(deal)
|
||||||
|
|
|
||||||
104
routes/mod.routes.js
Normal file
104
routes/mod.routes.js
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
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 } = require("../services/mod.service")
|
||||||
|
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
|
||||||
|
|
||||||
|
const { deals } = endpoints
|
||||||
|
|
||||||
|
const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery")
|
||||||
|
|
||||||
|
const buildViewer = (req) =>
|
||||||
|
req.auth ? { userId: req.auth.userId, role: req.auth.role } : 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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
res.json({ id: updated.id, status: updated.status })
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.statusCode || 500
|
||||||
|
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
|
|
@ -2,10 +2,20 @@ const express = require("express")
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
const requireAuth = require("../middleware/requireAuth")
|
const requireAuth = require("../middleware/requireAuth")
|
||||||
|
const optionalAuth = require("../middleware/optionalAuth")
|
||||||
|
const { validate } = require("../middleware/validate.middleware")
|
||||||
const { endpoints } = require("@shared/contracts")
|
const { endpoints } = require("@shared/contracts")
|
||||||
const { findSellerFromLink } = require("../services/seller.service")
|
const { getSellerByName, getDealsBySellerName } = require("../services/seller.service")
|
||||||
|
const { findSellerFromLink } = require("../services/sellerLookup.service")
|
||||||
|
const { mapSellerToSellerDetailsResponse } = require("../adapters/responses/sellerDetails.adapter")
|
||||||
|
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
|
||||||
|
|
||||||
const { seller } = endpoints
|
const { seller, deals } = endpoints
|
||||||
|
|
||||||
|
const buildViewer = (req) =>
|
||||||
|
req.auth ? { userId: req.auth.userId, role: req.auth.role } : null
|
||||||
|
|
||||||
|
const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery")
|
||||||
|
|
||||||
router.post("/from-link", requireAuth, async (req, res) => {
|
router.post("/from-link", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -32,4 +42,37 @@ router.post("/from-link", requireAuth, async (req, res) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
res.json(response.results)
|
||||||
|
} catch (e) {
|
||||||
|
const status = e.statusCode || 500
|
||||||
|
res.status(status).json({ error: e.message || "Sunucu hatasi" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|
|
||||||
22
server.js
22
server.js
|
|
@ -13,15 +13,27 @@ const accountSettingsRoutes = require("./routes/accountSettings.routes");
|
||||||
const userRoutes = require("./routes/user.routes");
|
const userRoutes = require("./routes/user.routes");
|
||||||
const sellerRoutes = require("./routes/seller.routes");
|
const sellerRoutes = require("./routes/seller.routes");
|
||||||
const voteRoutes = require("./routes/vote.routes");
|
const voteRoutes = require("./routes/vote.routes");
|
||||||
|
const commentLikeRoutes = require("./routes/commentLike.routes");
|
||||||
const categoryRoutes =require("./routes/category.routes")
|
const categoryRoutes =require("./routes/category.routes")
|
||||||
|
const modRoutes = require("./routes/mod.routes")
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// CORS middleware'ı ile dışardan gelen istekleri kontrol et
|
// CORS middleware'ı ile dışardan gelen istekleri kontrol et
|
||||||
app.use(cors({
|
const allowedOrigins = new Set([
|
||||||
origin: "http://localhost:5173", // Frontend adresi
|
"http://192.168.1.205:3001",
|
||||||
credentials: true, // Cookies'in paylaşıma izin verilmesi
|
"http://localhost:3001",
|
||||||
}));
|
]);
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin(origin, cb) {
|
||||||
|
if (!origin) return cb(null, true);
|
||||||
|
if (allowedOrigins.has(origin)) return cb(null, true);
|
||||||
|
return cb(new Error("CORS_NOT_ALLOWED"));
|
||||||
|
},
|
||||||
|
credentials: true, // Cookies'in paylaşıma izin verilmesi
|
||||||
|
})
|
||||||
|
);
|
||||||
// JSON, URL encoded ve cookies'leri parse etme
|
// JSON, URL encoded ve cookies'leri parse etme
|
||||||
app.use(express.json()); // JSON verisi almak için
|
app.use(express.json()); // JSON verisi almak için
|
||||||
app.use(express.urlencoded({ extended: true })); // URL encoded veriler için
|
app.use(express.urlencoded({ extended: true })); // URL encoded veriler için
|
||||||
|
|
@ -37,6 +49,8 @@ app.use("/api/account", accountSettingsRoutes); // Account settings işlemleri
|
||||||
app.use("/api/user", userRoutes); // Kullanıcı işlemleri
|
app.use("/api/user", userRoutes); // Kullanıcı işlemleri
|
||||||
app.use("/api/seller", sellerRoutes); // Seller işlemleri
|
app.use("/api/seller", sellerRoutes); // Seller işlemleri
|
||||||
app.use("/api/vote", voteRoutes); // Vote işlemleri
|
app.use("/api/vote", voteRoutes); // Vote işlemleri
|
||||||
|
app.use("/api/comment-likes", commentLikeRoutes); // Comment like işlemleri
|
||||||
app.use("/api/category", categoryRoutes);
|
app.use("/api/category", categoryRoutes);
|
||||||
|
app.use("/api/mod", modRoutes);
|
||||||
// Sunucuyu dinlemeye başla
|
// Sunucuyu dinlemeye başla
|
||||||
app.listen(3000, () => console.log("Server running on http://localhost:3000"));
|
app.listen(3000, () => console.log("Server running on http://localhost:3000"));
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,42 @@
|
||||||
const categoryDb = require("../db/category.db"); // DB işlemleri için category.db.js'i import ediyoruz
|
const categoryDb = require("../db/category.db")
|
||||||
const dealDb = require("../db/deal.db");
|
const dealService = require("./deal.service")
|
||||||
/**
|
|
||||||
* Kategoriyi slug'a göre bul
|
|
||||||
* Bu fonksiyon, verilen slug'a sahip kategori bilgilerini döndürür
|
|
||||||
*/
|
|
||||||
async function findCategoryBySlug(slug) {
|
async function findCategoryBySlug(slug) {
|
||||||
try {
|
const normalizedSlug = String(slug || "").trim()
|
||||||
// Kategori bilgisini slug'a göre buluyoruz
|
if (!normalizedSlug) {
|
||||||
const category = await categoryDb.findCategoryBySlug(slug, {
|
throw new Error("INVALID_SLUG")
|
||||||
include: {
|
|
||||||
children: true, // Alt kategorileri de dahil edebiliriz
|
|
||||||
deals: true, // Kategorinin deals ilişkisini alıyoruz
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!category) {
|
|
||||||
throw new Error("Kategori bulunamadı");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kategori breadcrumb'ını alıyoruz
|
|
||||||
const breadcrumb = await categoryDb.getCategoryBreadcrumb(category.id);
|
|
||||||
|
|
||||||
return { category, breadcrumb }; // Kategori ve breadcrumb'ı döndürüyoruz
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`Kategori bulma hatası: ${err.message}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const category = await categoryDb.findCategoryBySlug(normalizedSlug)
|
||||||
|
if (!category) {
|
||||||
|
throw new Error("CATEGORY_NOT_FOUND")
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumb = await categoryDb.getCategoryBreadcrumb(category.id)
|
||||||
|
return { category, breadcrumb }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDealsByCategoryId(categoryId, page = 1, limit = 10, filters = {}) {
|
async function getDealsByCategoryId(categoryId, { page = 1, limit = 10, filters = {}, viewer = null, scope = "USER" } = {}) {
|
||||||
try {
|
const normalizedId = Number(categoryId)
|
||||||
// Sayfalama ve filtreleme için gerekli ayarlamaları yapıyoruz
|
if (!Number.isInteger(normalizedId) || normalizedId <= 0) {
|
||||||
const take = Math.min(Math.max(Number(limit) || 10, 1), 100); // Limit ve sayfa sayısını hesaplıyoruz
|
throw new Error("INVALID_CATEGORY_ID")
|
||||||
const skip = (Math.max(Number(page) || 1, 1) - 1) * take; // Sayfa başlangıcı
|
|
||||||
|
|
||||||
// Kategorinin fırsatlarını almak için veritabanında sorgu yapıyoruz
|
|
||||||
const where = {
|
|
||||||
categoryId: categoryId,
|
|
||||||
...(filters.q && {
|
|
||||||
OR: [
|
|
||||||
{ title: { contains: filters.q, mode: 'insensitive' } },
|
|
||||||
{ description: { contains: filters.q, mode: 'insensitive' } },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
...(filters.status && { status: filters.status }),
|
|
||||||
...(filters.price && { price: { gte: filters.price } }), // Fiyat filtresi
|
|
||||||
// Diğer filtreler de buraya eklenebilir
|
|
||||||
};
|
|
||||||
|
|
||||||
// `getDealCards` fonksiyonunu çağırıyoruz ve sayfalama, filtreleme işlemlerini geçiyoruz
|
|
||||||
const deals = await dealDb.getDealCards({
|
|
||||||
where,
|
|
||||||
skip,
|
|
||||||
take, // Sayfalama işlemi için take parametresini gönderiyoruz
|
|
||||||
});
|
|
||||||
|
|
||||||
return deals; // Fırsatları döndürüyoruz
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`Kategoriye ait fırsatlar alınırken hata: ${err.message}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const categoryIds = await categoryDb.getCategoryDescendantIds(normalizedId)
|
||||||
|
|
||||||
|
return dealService.getDeals({
|
||||||
|
preset: "NEW",
|
||||||
|
q: filters?.q,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
viewer,
|
||||||
|
scope,
|
||||||
|
baseWhere: { categoryId: { in: categoryIds } },
|
||||||
|
filters,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
findCategoryBySlug,
|
findCategoryBySlug,
|
||||||
getDealsByCategoryId,
|
getDealsByCategoryId,
|
||||||
};
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,87 @@ function assertPositiveInt(v, name = "id") {
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCommentsByDealId(dealId) {
|
const DEFAULT_LIMIT = 20
|
||||||
|
const MAX_LIMIT = 50
|
||||||
|
const MAX_SKIP = 5000
|
||||||
|
|
||||||
|
function clampPagination({ page, limit }) {
|
||||||
|
const rawPage = Number(page)
|
||||||
|
const rawLimit = Number(limit)
|
||||||
|
const normalizedPage = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1
|
||||||
|
let normalizedLimit = Number.isFinite(rawLimit) ? Math.max(1, Math.floor(rawLimit)) : DEFAULT_LIMIT
|
||||||
|
normalizedLimit = Math.min(MAX_LIMIT, normalizedLimit)
|
||||||
|
const skip = (normalizedPage - 1) * normalizedLimit
|
||||||
|
if (skip > MAX_SKIP) throw new Error("PAGE_TOO_DEEP")
|
||||||
|
return { page: normalizedPage, limit: normalizedLimit, skip }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseParentId(value) {
|
||||||
|
if (value === undefined) return null
|
||||||
|
if (value === null) return null
|
||||||
|
if (value === "" || value === "null") return null
|
||||||
|
const pid = Number(value)
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) throw new Error("Geçersiz parentId.")
|
||||||
|
return pid
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSort(value) {
|
||||||
|
const normalized = String(value || "new").trim().toLowerCase()
|
||||||
|
if (["top", "best", "liked"].includes(normalized)) return "TOP"
|
||||||
|
return "NEW"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function getCommentsByDealId(dealId, { parentId, page, limit, sort, viewer } = {}) {
|
||||||
const id = Number(dealId)
|
const id = Number(dealId)
|
||||||
|
|
||||||
const deal = await dealDB.findDeal({ id })
|
const deal = await dealDB.findDeal({ id })
|
||||||
if (!deal) throw new Error("Deal bulunamadı.")
|
if (!deal) throw new Error("Deal bulunamadı.")
|
||||||
|
|
||||||
const include = { user: { select: { id:true,username: true, avatarUrl: true } } }
|
const include = {
|
||||||
return commentDB.findComments({ dealId: id }, { include })
|
user: { select: { id: true, username: true, avatarUrl: true } },
|
||||||
|
_count: { select: { replies: true } },
|
||||||
|
}
|
||||||
|
const pagination = clampPagination({ page, limit })
|
||||||
|
const parsedParentId = parseParentId(parentId)
|
||||||
|
const sortMode = normalizeSort(sort)
|
||||||
|
const orderBy =
|
||||||
|
sortMode === "TOP"
|
||||||
|
? [{ likeCount: "desc" }, { createdAt: "desc" }]
|
||||||
|
: [{ createdAt: "desc" }]
|
||||||
|
|
||||||
|
const where = { dealId: id, parentId: parsedParentId }
|
||||||
|
const [results, total] = await Promise.all([
|
||||||
|
commentDB.findComments(where, {
|
||||||
|
include,
|
||||||
|
orderBy,
|
||||||
|
skip: pagination.skip,
|
||||||
|
take: pagination.limit,
|
||||||
|
}),
|
||||||
|
commentDB.countComments(where),
|
||||||
|
])
|
||||||
|
|
||||||
|
let likedIds = new Set()
|
||||||
|
if (viewer?.userId && results.length > 0) {
|
||||||
|
const commentLikeDb = require("../db/commentLike.db")
|
||||||
|
const likes = await commentLikeDb.findLikesByUserAndCommentIds(
|
||||||
|
viewer.userId,
|
||||||
|
results.map((c) => c.id)
|
||||||
|
)
|
||||||
|
likedIds = new Set(likes.map((l) => l.commentId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const enriched = results.map((comment) => ({
|
||||||
|
...comment,
|
||||||
|
myLike: likedIds.has(comment.id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pagination.page,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pagination.limit),
|
||||||
|
results: enriched,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createComment({ dealId, userId, text, parentId = null }) {
|
async function createComment({ dealId, userId, text, parentId = null }) {
|
||||||
|
|
@ -27,8 +100,11 @@ async function createComment({ dealId, userId, text, parentId = null }) {
|
||||||
const include = { user: { select: { id: true, username: true, avatarUrl: true } } }
|
const include = { user: { select: { id: true, username: true, avatarUrl: true } } }
|
||||||
|
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const deal = await dealDB.findDeal({ id: dealId }, {}, tx)
|
const deal = await dealDB.findDeal({ id: dealId }, { select: { id: true, status: true } }, tx)
|
||||||
if (!deal) throw new Error("Deal bulunamadı.")
|
if (!deal) throw new Error("Deal bulunamadı.")
|
||||||
|
if (deal.status !== "ACTIVE" && deal.status !== "EXPIRED") {
|
||||||
|
throw new Error("Bu deal için yorum açılamaz.")
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ Reply ise parent doğrula
|
// ✅ Reply ise parent doğrula
|
||||||
let parent = null
|
let parent = null
|
||||||
|
|
@ -66,15 +142,26 @@ async function createComment({ dealId, userId, text, parentId = null }) {
|
||||||
|
|
||||||
async function deleteComment(commentId, userId) {
|
async function deleteComment(commentId, userId) {
|
||||||
|
|
||||||
const comments = await commentDB.findComments(
|
const comment = await commentDB.findComment(
|
||||||
{ id: commentId },
|
{ id: commentId },
|
||||||
{ select: { userId: true } }
|
{ select: { userId: true, dealId: true, deletedAt: true } }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!comments || comments.length === 0) throw new Error("Yorum bulunamadı.")
|
if (!comment || comment.deletedAt) throw new Error("Yorum bulunamadı.")
|
||||||
if (comments[0].userId !== userId) throw new Error("Bu yorumu silme yetkin yok.")
|
if (comment.userId !== userId) throw new Error("Bu yorumu silme yetkin yok.")
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const result = await commentDB.softDeleteComment({ id: commentId, deletedAt: null }, tx)
|
||||||
|
if (result.count > 0) {
|
||||||
|
await dealDB.updateDeal(
|
||||||
|
{ id: comment.dealId },
|
||||||
|
{ commentCount: { decrement: 1 } },
|
||||||
|
{},
|
||||||
|
tx
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
await commentDB.deleteComment({ id: commentId })
|
|
||||||
return { message: "Yorum silindi." }
|
return { message: "Yorum silindi." }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
30
services/commentLike.service.js
Normal file
30
services/commentLike.service.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
const commentLikeDb = require("../db/commentLike.db")
|
||||||
|
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("Geçersiz commentId.")
|
||||||
|
const shouldLike = parseLike(like)
|
||||||
|
if (shouldLike === null) throw new Error("Geçersiz like.")
|
||||||
|
|
||||||
|
// Ensure comment exists (and not deleted)
|
||||||
|
const existing = await commentDb.findComment({ id: cid }, { select: { id: true } })
|
||||||
|
if (!existing) throw new Error("Yorum bulunamadı.")
|
||||||
|
|
||||||
|
return commentLikeDb.setCommentLike({ commentId: cid, userId, like: shouldLike })
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
setCommentLike,
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// services/deal.service.js
|
// services/deal.service.js
|
||||||
const dealDB = require("../db/deal.db")
|
const dealDB = require("../db/deal.db")
|
||||||
const { findSellerFromLink } = require("./seller.service")
|
const { findSellerFromLink } = require("./sellerLookup.service")
|
||||||
const { makeDetailWebp, makeThumbWebp } = require("../utils/processImage")
|
const { makeDetailWebp, makeThumbWebp } = require("../utils/processImage")
|
||||||
const { v4: uuidv4 } = require("uuid")
|
const { v4: uuidv4 } = require("uuid")
|
||||||
const { uploadImage } = require("./uploadImage.service")
|
const { uploadImage } = require("./uploadImage.service")
|
||||||
|
|
@ -13,7 +13,22 @@ const MAX_LIMIT = 50
|
||||||
const MAX_SKIP = 5000
|
const MAX_SKIP = 5000
|
||||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
const DEAL_LIST_INCLUDE = {
|
const DEAL_CARD_SELECT = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
price: true,
|
||||||
|
originalPrice: true,
|
||||||
|
shippingPrice: true,
|
||||||
|
score: true,
|
||||||
|
commentCount: true,
|
||||||
|
url: true,
|
||||||
|
status: true,
|
||||||
|
saletype: true,
|
||||||
|
affiliateType: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
customSeller: true,
|
||||||
user: { select: { id: true, username: true, avatarUrl: true } },
|
user: { select: { id: true, username: true, avatarUrl: true } },
|
||||||
seller: { select: { id: true, name: true, url: true } },
|
seller: { select: { id: true, name: true, url: true } },
|
||||||
images: {
|
images: {
|
||||||
|
|
@ -23,6 +38,59 @@ const DEAL_LIST_INCLUDE = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEAL_DETAIL_SELECT = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
url: true,
|
||||||
|
price: true,
|
||||||
|
originalPrice: true,
|
||||||
|
shippingPrice: true,
|
||||||
|
score: true,
|
||||||
|
commentCount: true,
|
||||||
|
status: true,
|
||||||
|
saletype: true,
|
||||||
|
affiliateType: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
categoryId: true,
|
||||||
|
sellerId: true,
|
||||||
|
customSeller: true,
|
||||||
|
user: { select: { id: true, username: true, avatarUrl: true } },
|
||||||
|
seller: { select: { id: true, name: true, url: true } },
|
||||||
|
images: { orderBy: { order: "asc" }, select: { id: true, imageUrl: true, order: true } },
|
||||||
|
notices: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
dealId: true,
|
||||||
|
title: true,
|
||||||
|
body: true,
|
||||||
|
severity: true,
|
||||||
|
isActive: true,
|
||||||
|
createdBy: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIMILAR_DEAL_SELECT = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
price: true,
|
||||||
|
score: true,
|
||||||
|
createdAt: true,
|
||||||
|
categoryId: true,
|
||||||
|
sellerId: true,
|
||||||
|
customSeller: true,
|
||||||
|
seller: { select: { name: true } },
|
||||||
|
images: { take: 1, orderBy: { order: "asc" }, select: { imageUrl: true } },
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateAsString(value) {
|
function formatDateAsString(value) {
|
||||||
return value instanceof Date ? value.toISOString() : value ?? null
|
return value instanceof Date ? value.toISOString() : value ?? null
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +122,132 @@ function buildSearchClause(q) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEAL_STATUSES = new Set(["PENDING", "ACTIVE", "EXPIRED", "REJECTED"])
|
||||||
|
const SALE_TYPES = new Set(["ONLINE", "OFFLINE", "CODE"])
|
||||||
|
const AFFILIATE_TYPES = new Set(["AFFILIATE", "NON_AFFILIATE", "USER_AFFILIATE"])
|
||||||
|
|
||||||
|
function normalizeListInput(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.flatMap((item) => String(item).split(","))
|
||||||
|
}
|
||||||
|
if (value === undefined || value === null) return []
|
||||||
|
return String(value).split(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInteger(value) {
|
||||||
|
if (value === undefined || value === null || value === "") return null
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isInteger(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value) {
|
||||||
|
if (value === undefined || value === null || value === "") return null
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoolean(value) {
|
||||||
|
if (typeof value === "boolean") return value
|
||||||
|
if (typeof value === "number") return value === 1 ? true : value === 0 ? false : null
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(value) {
|
||||||
|
if (value === undefined || value === null || value === "") return null
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIdList(value) {
|
||||||
|
const items = normalizeListInput(value)
|
||||||
|
const ids = items
|
||||||
|
.map((item) => parseInteger(String(item).trim()))
|
||||||
|
.filter((item) => item && item > 0)
|
||||||
|
return ids.length ? Array.from(new Set(ids)) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnumList(value, allowedSet) {
|
||||||
|
const items = normalizeListInput(value)
|
||||||
|
const filtered = items
|
||||||
|
.map((item) => String(item).trim().toUpperCase())
|
||||||
|
.filter((item) => allowedSet.has(item))
|
||||||
|
return filtered.length ? Array.from(new Set(filtered)) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFilterWhere(rawFilters = {}, { allowStatus = false } = {}) {
|
||||||
|
if (!rawFilters || typeof rawFilters !== "object") return null
|
||||||
|
|
||||||
|
const clauses = []
|
||||||
|
const categoryIds = parseIdList(rawFilters.categoryId ?? rawFilters.categoryIds)
|
||||||
|
if (categoryIds?.length) {
|
||||||
|
clauses.push({ categoryId: { in: categoryIds } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sellerIds = parseIdList(rawFilters.sellerId ?? rawFilters.sellerIds)
|
||||||
|
if (sellerIds?.length) {
|
||||||
|
clauses.push({ sellerId: { in: sellerIds } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const saleTypes = parseEnumList(rawFilters.saleType, SALE_TYPES)
|
||||||
|
if (saleTypes?.length) {
|
||||||
|
clauses.push({ saletype: { in: saleTypes } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const affiliateTypes = parseEnumList(rawFilters.affiliateType, AFFILIATE_TYPES)
|
||||||
|
if (affiliateTypes?.length) {
|
||||||
|
clauses.push({ affiliateType: { in: affiliateTypes } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowStatus) {
|
||||||
|
const statuses = parseEnumList(rawFilters.status, DEAL_STATUSES)
|
||||||
|
if (statuses?.length) {
|
||||||
|
clauses.push({ status: { in: statuses } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minPrice = parseNumber(rawFilters.minPrice ?? rawFilters.priceMin)
|
||||||
|
const maxPrice = parseNumber(rawFilters.maxPrice ?? rawFilters.priceMax)
|
||||||
|
if (minPrice !== null || maxPrice !== null) {
|
||||||
|
const price = {}
|
||||||
|
if (minPrice !== null) price.gte = minPrice
|
||||||
|
if (maxPrice !== null) price.lte = maxPrice
|
||||||
|
clauses.push({ price })
|
||||||
|
}
|
||||||
|
|
||||||
|
const minScore = parseNumber(rawFilters.minScore)
|
||||||
|
const maxScore = parseNumber(rawFilters.maxScore)
|
||||||
|
if (minScore !== null || maxScore !== null) {
|
||||||
|
const score = {}
|
||||||
|
if (minScore !== null) score.gte = minScore
|
||||||
|
if (maxScore !== null) score.lte = maxScore
|
||||||
|
clauses.push({ score })
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAfter = parseDate(rawFilters.createdAfter ?? rawFilters.from)
|
||||||
|
const createdBefore = parseDate(rawFilters.createdBefore ?? rawFilters.to)
|
||||||
|
if (createdAfter || createdBefore) {
|
||||||
|
const createdAt = {}
|
||||||
|
if (createdAfter) createdAt.gte = createdAfter
|
||||||
|
if (createdBefore) createdAt.lte = createdBefore
|
||||||
|
clauses.push({ createdAt })
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasImage = parseBoolean(rawFilters.hasImage)
|
||||||
|
if (hasImage === true) {
|
||||||
|
clauses.push({ images: { some: {} } })
|
||||||
|
} else if (hasImage === false) {
|
||||||
|
clauses.push({ images: { none: {} } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clauses.length) return null
|
||||||
|
return clauses.length === 1 ? clauses[0] : { AND: clauses }
|
||||||
|
}
|
||||||
|
|
||||||
function buildPresetCriteria(preset, { viewer, targetUserId } = {}) {
|
function buildPresetCriteria(preset, { viewer, targetUserId } = {}) {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
switch (preset) {
|
switch (preset) {
|
||||||
|
|
@ -110,6 +304,9 @@ function buildPresetCriteria(preset, { viewer, targetUserId } = {}) {
|
||||||
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
|
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "RAW": {
|
||||||
|
return { where: {}, orderBy: [{ createdAt: "desc" }] }
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
const err = new Error("INVALID_PRESET")
|
const err = new Error("INVALID_PRESET")
|
||||||
err.statusCode = 400
|
err.statusCode = 400
|
||||||
|
|
@ -155,14 +352,28 @@ function titleOverlapScore(aTitle, bTitle) {
|
||||||
* - seller?: { name }
|
* - seller?: { name }
|
||||||
* - images?: [{ imageUrl }]
|
* - images?: [{ imageUrl }]
|
||||||
*/
|
*/
|
||||||
async function buildSimilarDealsForDetail(targetDeal, { limit = 5 } = {}) {
|
async function buildSimilarDealsForDetail(targetDeal, { limit = 12 } = {}) {
|
||||||
const take = clamp(Number(limit) || 5, 1, 10)
|
const take = clamp(Number(limit) || 12, 1, 12)
|
||||||
|
|
||||||
// Bu 2 DB fonksiyonu: ACTIVE filter + images(take:1) + seller(name) getirmeli
|
// Bu 2 DB fonksiyonu: ACTIVE filter + images(take:1) + seller(name) getirmeli
|
||||||
const [byCategory, bySeller] = await Promise.all([
|
const [byCategory, bySeller] = await Promise.all([
|
||||||
dealDB.findSimilarCandidatesByCategory(targetDeal.categoryId, targetDeal.id, { take: 80 }),
|
dealDB.findSimilarCandidates(
|
||||||
|
{
|
||||||
|
id: { not: Number(targetDeal.id) },
|
||||||
|
status: "ACTIVE",
|
||||||
|
categoryId: Number(targetDeal.categoryId),
|
||||||
|
},
|
||||||
|
{ take: 80, select: SIMILAR_DEAL_SELECT }
|
||||||
|
),
|
||||||
targetDeal.sellerId
|
targetDeal.sellerId
|
||||||
? dealDB.findSimilarCandidatesBySeller(targetDeal.sellerId, targetDeal.id, { take: 30 })
|
? dealDB.findSimilarCandidates(
|
||||||
|
{
|
||||||
|
id: { not: Number(targetDeal.id) },
|
||||||
|
status: "ACTIVE",
|
||||||
|
sellerId: Number(targetDeal.sellerId),
|
||||||
|
},
|
||||||
|
{ take: 30, select: SIMILAR_DEAL_SELECT }
|
||||||
|
)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -210,14 +421,31 @@ async function buildSimilarDealsForDetail(targetDeal, { limit = 5 } = {}) {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDeals({ preset = "NEW", q, page, limit, viewer = null, targetUserId = null }) {
|
async function getDeals({
|
||||||
|
preset = "NEW",
|
||||||
|
q,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
viewer = null,
|
||||||
|
targetUserId = null,
|
||||||
|
filters = null,
|
||||||
|
baseWhere = null,
|
||||||
|
scope = "USER",
|
||||||
|
}) {
|
||||||
const pagination = clampPagination({ page, limit })
|
const pagination = clampPagination({ page, limit })
|
||||||
const { where: presetWhere, orderBy: presetOrder } = buildPresetCriteria(preset, { viewer, targetUserId })
|
const { where: presetWhere, orderBy: presetOrder } = buildPresetCriteria(preset, {
|
||||||
|
viewer,
|
||||||
|
targetUserId,
|
||||||
|
})
|
||||||
const searchClause = buildSearchClause(q)
|
const searchClause = buildSearchClause(q)
|
||||||
|
const allowStatus = preset === "MY" || scope === "MOD"
|
||||||
|
const filterWhere = buildFilterWhere(filters, { allowStatus })
|
||||||
|
|
||||||
const clauses = []
|
const clauses = []
|
||||||
if (presetWhere && Object.keys(presetWhere).length > 0) clauses.push(presetWhere)
|
if (presetWhere && Object.keys(presetWhere).length > 0) clauses.push(presetWhere)
|
||||||
|
if (baseWhere && Object.keys(baseWhere).length > 0) clauses.push(baseWhere)
|
||||||
if (searchClause) clauses.push(searchClause)
|
if (searchClause) clauses.push(searchClause)
|
||||||
|
if (filterWhere) clauses.push(filterWhere)
|
||||||
|
|
||||||
const finalWhere = clauses.length === 0 ? {} : clauses.length === 1 ? clauses[0] : { AND: clauses }
|
const finalWhere = clauses.length === 0 ? {} : clauses.length === 1 ? clauses[0] : { AND: clauses }
|
||||||
const orderBy = presetOrder ?? [{ createdAt: "desc" }]
|
const orderBy = presetOrder ?? [{ createdAt: "desc" }]
|
||||||
|
|
@ -227,7 +455,7 @@ async function getDeals({ preset = "NEW", q, page, limit, viewer = null, targetU
|
||||||
skip: pagination.skip,
|
skip: pagination.skip,
|
||||||
take: pagination.limit,
|
take: pagination.limit,
|
||||||
orderBy,
|
orderBy,
|
||||||
include: DEAL_LIST_INCLUDE,
|
select: DEAL_CARD_SELECT,
|
||||||
}),
|
}),
|
||||||
dealDB.countDeals(finalWhere),
|
dealDB.countDeals(finalWhere),
|
||||||
])
|
])
|
||||||
|
|
@ -256,64 +484,41 @@ async function getDeals({ preset = "NEW", q, page, limit, viewer = null, targetU
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDealById(id) {
|
async function getDealById(id, viewer = null) {
|
||||||
const deal = await dealDB.findDeal(
|
const deal = await dealDB.findDeal(
|
||||||
{ id: Number(id) },
|
{ id: Number(id) },
|
||||||
{
|
{
|
||||||
include: {
|
select: DEAL_DETAIL_SELECT,
|
||||||
seller: { select: { id: true, name: true, url: true } },
|
|
||||||
user: { select: { id: true, username: true, avatarUrl: true } },
|
|
||||||
images: { orderBy: { order: "asc" }, select: { id: true, imageUrl: true, order: true } },
|
|
||||||
notices: {
|
|
||||||
where: { isActive: true },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 1,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
dealId: true,
|
|
||||||
title: true,
|
|
||||||
body: true,
|
|
||||||
severity: true,
|
|
||||||
isActive: true,
|
|
||||||
createdBy: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
comments: {
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
text: true,
|
|
||||||
createdAt: true,
|
|
||||||
user: { select: { id: true, username: true, avatarUrl: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: { select: { comments: true } },
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!deal) return null
|
if (!deal) return null
|
||||||
|
|
||||||
const breadcrumb = await categoryDB.getCategoryBreadcrumb(deal.categoryId, {
|
const [breadcrumb, similarDeals, userStatsAgg] = await Promise.all([
|
||||||
includeUndefined: false,
|
categoryDB.getCategoryBreadcrumb(deal.categoryId, { includeUndefined: false }),
|
||||||
})
|
buildSimilarDealsForDetail(
|
||||||
|
{
|
||||||
|
id: deal.id,
|
||||||
|
title: deal.title,
|
||||||
|
categoryId: deal.categoryId,
|
||||||
|
sellerId: deal.sellerId ?? null,
|
||||||
|
},
|
||||||
|
{ limit: 12 }
|
||||||
|
),
|
||||||
|
deal.user?.id ? dealDB.aggregateDeals({ userId: deal.user.id }) : Promise.resolve(null),
|
||||||
|
])
|
||||||
|
|
||||||
const similarDeals = await buildSimilarDealsForDetail(
|
const userStats = {
|
||||||
{
|
totalLikes: userStatsAgg?._sum?.score ?? 0,
|
||||||
id: deal.id,
|
totalDeals: userStatsAgg?._count?._all ?? 0,
|
||||||
title: deal.title,
|
}
|
||||||
categoryId: deal.categoryId,
|
|
||||||
sellerId: deal.sellerId ?? null,
|
|
||||||
},
|
|
||||||
{ limit: 5 }
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...deal,
|
...deal,
|
||||||
|
comments: [],
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
similarDeals,
|
similarDeals,
|
||||||
|
userStats,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
65
services/mod.service.js
Normal file
65
services/mod.service.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
const dealService = require("./deal.service")
|
||||||
|
const dealDB = require("../db/deal.db")
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 dealDB.findDeal({ id }, { select: { id: true, status: true } })
|
||||||
|
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 updated = await dealDB.updateDeal(
|
||||||
|
{ id },
|
||||||
|
{ status: nextStatus },
|
||||||
|
{ select: { id: true, status: true } }
|
||||||
|
)
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveDeal(dealId) {
|
||||||
|
return updateDealStatus(dealId, "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")
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getPendingDeals,
|
||||||
|
approveDeal,
|
||||||
|
rejectDeal,
|
||||||
|
expireDeal,
|
||||||
|
unexpireDeal,
|
||||||
|
}
|
||||||
|
|
@ -1,38 +1,48 @@
|
||||||
// services/seller/sellerService.js
|
// services/seller/sellerService.js
|
||||||
const { findSellerByDomain } = require("../db/seller.db")
|
const { findSeller } = require("../db/seller.db")
|
||||||
|
const dealService = require("./deal.service")
|
||||||
|
|
||||||
function normalizeDomain(hostname) {
|
function normalizeSellerName(value) {
|
||||||
return hostname.replace(/^www\./, "")
|
return String(value || "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findSellerFromLink(url) {
|
async function getSellerByName(name) {
|
||||||
let hostname
|
const normalized = normalizeSellerName(name)
|
||||||
|
if (!normalized) {
|
||||||
try {
|
const err = new Error("SELLER_NAME_REQUIRED")
|
||||||
hostname = new URL(url).hostname
|
err.statusCode = 400
|
||||||
} catch {
|
throw err
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const domain = normalizeDomain(hostname)
|
return findSeller(
|
||||||
|
{ name: { equals: normalized, mode: "insensitive" } },
|
||||||
|
{ select: { id: true, name: true, url: true, sellerLogo: true } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const seller = await findSellerByDomain(domain)
|
async function getDealsBySellerName(name, { page = 1, limit = 10, filters = {}, viewer = null, scope = "USER" } = {}) {
|
||||||
if (seller) {
|
const seller = await getSellerByName(name)
|
||||||
return seller
|
if (!seller) {
|
||||||
|
const err = new Error("SELLER_NOT_FOUND")
|
||||||
|
err.statusCode = 404
|
||||||
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
const domainParts = domain.split(".")
|
const payload = await dealService.getDeals({
|
||||||
for (let i = 1; i <= domainParts.length - 2; i += 1) {
|
preset: "NEW",
|
||||||
const parentDomain = domainParts.slice(i).join(".")
|
q: filters?.q,
|
||||||
const parentSeller = await findSellerByDomain(parentDomain)
|
page,
|
||||||
if (parentSeller) {
|
limit,
|
||||||
return parentSeller
|
viewer,
|
||||||
}
|
scope,
|
||||||
}
|
baseWhere: { sellerId: seller.id, status: "ACTIVE" },
|
||||||
|
filters,
|
||||||
|
})
|
||||||
|
|
||||||
return null
|
return { seller, payload }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
findSellerFromLink,
|
getSellerByName,
|
||||||
|
getDealsBySellerName,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
services/sellerLookup.service.js
Normal file
37
services/sellerLookup.service.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
const { findSellerByDomain } = require("../db/seller.db")
|
||||||
|
|
||||||
|
function normalizeDomain(hostname) {
|
||||||
|
return hostname.replace(/^www\./, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findSellerFromLink(url) {
|
||||||
|
let hostname
|
||||||
|
|
||||||
|
try {
|
||||||
|
hostname = new URL(url).hostname
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = normalizeDomain(hostname)
|
||||||
|
|
||||||
|
const seller = await findSellerByDomain(domain)
|
||||||
|
if (seller) {
|
||||||
|
return seller
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainParts = domain.split(".")
|
||||||
|
for (let i = 1; i <= domainParts.length - 2; i += 1) {
|
||||||
|
const parentDomain = domainParts.slice(i).join(".")
|
||||||
|
const parentSeller = await findSellerByDomain(parentDomain)
|
||||||
|
if (parentSeller) {
|
||||||
|
return parentSeller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
findSellerFromLink,
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user