son değişiklikler
This commit is contained in:
parent
4487709bf2
commit
e0f3f5d306
|
|
@ -1,33 +1,21 @@
|
||||||
function mapCreateDealRequestToDealCreateData(
|
function mapCreateDealRequestToDealCreateData(payload, userId) {
|
||||||
data,
|
const { title, description, url, price, sellerName } = payload
|
||||||
userId
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
title: data.title,
|
|
||||||
description: data.description ?? null,
|
|
||||||
url: data.url ?? null,
|
|
||||||
price: Number(data.price) ?? null,
|
|
||||||
|
|
||||||
// 🔑 adapter burada seller’ı “custom” gibi yazar
|
return {
|
||||||
// service bunu düzeltecek
|
title,
|
||||||
customSeller: data.sellerName,
|
description: description ?? null,
|
||||||
|
url: url ?? null,
|
||||||
|
price: price ?? null,
|
||||||
|
|
||||||
|
// Burada customSeller yazıyoruz; servis gerektiğinde ilişkilendiriyor.
|
||||||
|
customSeller: sellerName ?? null,
|
||||||
|
|
||||||
user: {
|
user: {
|
||||||
connect: { id: userId },
|
connect: { id: userId },
|
||||||
},
|
},
|
||||||
/*
|
}
|
||||||
images: data.images?.length
|
}
|
||||||
? {
|
|
||||||
create: data.images.map((imgUrl, index) => ({
|
|
||||||
imageUrl: imgUrl,
|
|
||||||
order: index,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
*/
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
mapCreateDealRequestToDealCreateData,
|
mapCreateDealRequestToDealCreateData,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
adapters/responses/breadCrumb.adapter.js
Normal file
15
adapters/responses/breadCrumb.adapter.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
function mapBreadcrumbToResponse(breadcrumb) {
|
||||||
|
if (!Array.isArray(breadcrumb)) return []
|
||||||
|
return breadcrumb
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
slug: c.slug,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports={
|
||||||
|
mapBreadcrumbToResponse
|
||||||
|
|
||||||
|
}
|
||||||
16
adapters/responses/categoryDetails.adapter.js
Normal file
16
adapters/responses/categoryDetails.adapter.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
|
||||||
|
const {mapBreadcrumbToResponse} =require( "./breadCrumb.adapter")
|
||||||
|
|
||||||
|
function mapCategoryToCategoryDetailsResponse(category, breadcrumb) {
|
||||||
|
return {
|
||||||
|
id: category.id,
|
||||||
|
name: category.name,
|
||||||
|
slug: category.slug,
|
||||||
|
description: category.description || "Açıklama bulunmuyor", // Kategorinin açıklaması varsa, yoksa varsayılan mesaj
|
||||||
|
breadcrumb: mapBreadcrumbToResponse(breadcrumb), // breadcrumb'ı uygun formatta döndürüyoruz
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mapCategoryToCategoryDetailsResponse,
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
|
const formatDateAsString = (value) =>
|
||||||
|
value instanceof Date ? value.toISOString() : value ?? null
|
||||||
|
|
||||||
function mapCommentToDealCommentResponse(comment) {
|
function mapCommentToDealCommentResponse(comment) {
|
||||||
return {
|
return {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
text: comment.text, // eğer DB'de content ise burada text'e çevir
|
text: comment.text, // eğer DB'de content ise burada text'e çevir
|
||||||
createdAt: comment.createdAt,
|
createdAt: formatDateAsString(comment.createdAt),
|
||||||
|
parentId:comment.parentId,
|
||||||
user: {
|
user: {
|
||||||
id: comment.user.id,
|
id: comment.user.id,
|
||||||
username: comment.user.username,
|
username: comment.user.username,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
const formatDateAsString = (value) => (value instanceof Date ? value.toISOString() : value ?? null)
|
||||||
|
|
||||||
function mapDealToDealCardResponse(deal) {
|
function mapDealToDealCardResponse(deal) {
|
||||||
return {
|
return {
|
||||||
id: deal.id,
|
id: deal.id,
|
||||||
|
|
@ -7,15 +9,15 @@ function mapDealToDealCardResponse(deal) {
|
||||||
|
|
||||||
score: deal.score,
|
score: deal.score,
|
||||||
commentsCount: deal.commentCount,
|
commentsCount: deal.commentCount,
|
||||||
|
url:deal.url,
|
||||||
status: deal.status,
|
status: deal.status,
|
||||||
saleType: deal.saletype,
|
saleType: deal.saletype,
|
||||||
affiliateType: deal.affiliateType,
|
affiliateType: deal.affiliateType,
|
||||||
|
|
||||||
myVote:deal.myVote,
|
myVote: deal.myVote ?? 0,
|
||||||
|
|
||||||
createdAt: deal.createdAt,
|
createdAt: formatDateAsString(deal.createdAt),
|
||||||
updatedAt: deal.updatedAt,
|
updatedAt: formatDateAsString(deal.updatedAt),
|
||||||
|
|
||||||
user: {
|
user: {
|
||||||
id: deal.user.id,
|
id: deal.user.id,
|
||||||
|
|
@ -24,10 +26,14 @@ function mapDealToDealCardResponse(deal) {
|
||||||
},
|
},
|
||||||
|
|
||||||
seller: deal.seller
|
seller: deal.seller
|
||||||
? { name: deal.seller.name,
|
? {
|
||||||
url:deal.seller.url
|
name: deal.seller.name,
|
||||||
}
|
url: deal.seller.url ?? null,
|
||||||
: { name: deal.customSeller || "" },
|
}
|
||||||
|
: {
|
||||||
|
name: deal.customSeller || "",
|
||||||
|
url: null,
|
||||||
|
},
|
||||||
|
|
||||||
imageUrl: deal.images?.[0]?.imageUrl || "",
|
imageUrl: deal.images?.[0]?.imageUrl || "",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,70 @@
|
||||||
|
// adapters/responses/dealDetail.adapter.js
|
||||||
|
const {mapBreadcrumbToResponse} =require( "./breadCrumb.adapter")
|
||||||
|
|
||||||
|
const formatDateAsString = (value) =>
|
||||||
|
value instanceof Date ? value.toISOString() : value ?? null
|
||||||
|
|
||||||
|
const requiredIsoString = (value, fieldName) => {
|
||||||
|
if (value instanceof Date) return value.toISOString()
|
||||||
|
if (typeof value === "string" && value.length) return value
|
||||||
|
throw new Error(`${fieldName} is missing (undefined/null)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapNoticeToResponse(notice) {
|
||||||
|
if (!notice) return null
|
||||||
|
return {
|
||||||
|
id: notice.id,
|
||||||
|
title: notice.title,
|
||||||
|
dealId: notice.dealId,
|
||||||
|
body: notice.body ?? null,
|
||||||
|
severity: notice.severity,
|
||||||
|
isActive: notice.isActive,
|
||||||
|
createdBy: notice.createdBy,
|
||||||
|
createdAt: requiredIsoString(notice.createdAt, "notice.createdAt"),
|
||||||
|
updatedAt: requiredIsoString(notice.updatedAt, "notice.updatedAt"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// minimal similardeal -> response
|
||||||
|
function mapSimilarDealItem(d) {
|
||||||
|
if (!d) return null
|
||||||
|
return {
|
||||||
|
id: d.id,
|
||||||
|
title: d.title,
|
||||||
|
price: d.price ?? null,
|
||||||
|
score: Number.isFinite(d.score) ? d.score : 0,
|
||||||
|
imageUrl: d.imageUrl || "",
|
||||||
|
sellerName: d.sellerName || "Bilinmiyor",
|
||||||
|
createdAt: formatDateAsString(d.createdAt), // SimilarDealSchema: nullable OK
|
||||||
|
// url: d.url ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function mapDealToDealDetailResponse(deal) {
|
function mapDealToDealDetailResponse(deal) {
|
||||||
|
if (!deal) return null
|
||||||
|
|
||||||
|
const firstNotice = Array.isArray(deal.notices) ? deal.notices[0] : null
|
||||||
|
|
||||||
|
if (!deal.user) throw new Error("deal.user is missing (include user in query)")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: deal.id,
|
id: deal.id,
|
||||||
title: deal.title,
|
title: deal.title,
|
||||||
description: deal.description || "",
|
description: deal.description || "",
|
||||||
url: deal.url ?? null,
|
url: deal.url ?? null,
|
||||||
price: deal.price ?? null,
|
price: deal.price ?? null,
|
||||||
score: deal.score,
|
score: Number.isFinite(deal.score) ? deal.score : 0,
|
||||||
|
|
||||||
commentsCount: deal._count?.comments ?? 0,
|
commentsCount: deal._count?.comments ?? 0,
|
||||||
|
|
||||||
status: deal.status,
|
status: deal.status,
|
||||||
saleType: deal.saletype,
|
saleType: deal.saletype, // ✅ FIX: saletype değil
|
||||||
affiliateType: deal.affiliateType,
|
affiliateType: deal.affiliateType,
|
||||||
|
|
||||||
createdAt: deal.createdAt,
|
createdAt: requiredIsoString(deal.createdAt, "deal.createdAt"),
|
||||||
updatedAt: deal.updatedAt,
|
updatedAt: requiredIsoString(deal.updatedAt, "deal.updatedAt"),
|
||||||
|
|
||||||
user: {
|
user: {
|
||||||
id: deal.user.id,
|
id: deal.user.id,
|
||||||
|
|
@ -22,26 +72,48 @@ function mapDealToDealDetailResponse(deal) {
|
||||||
avatarUrl: deal.user.avatarUrl ?? null,
|
avatarUrl: deal.user.avatarUrl ?? null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ✅ FIX: SellerSummarySchema genelde id ister -> custom seller için -1
|
||||||
seller: deal.seller
|
seller: deal.seller
|
||||||
? { id: deal.seller.id, name: deal.seller.name }
|
? {
|
||||||
: { name: deal.customSeller || "Bilinmiyor" },
|
id: deal.seller.id,
|
||||||
|
name: deal.seller.name,
|
||||||
|
url: deal.seller.url ?? null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: -1,
|
||||||
|
name: deal.customSeller || "Bilinmiyor",
|
||||||
|
url: null,
|
||||||
|
},
|
||||||
|
|
||||||
images: deal.images.map((img) => ({
|
images: (deal.images || []).map((img) => ({
|
||||||
id: img.id,
|
id: img.id,
|
||||||
imageUrl: img.imageUrl,
|
imageUrl: img.imageUrl,
|
||||||
order: img.order,
|
order: img.order,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
comments: deal.comments.map((comment) => ({
|
comments: (deal.comments || []).map((comment) => {
|
||||||
id: comment.id,
|
if (!comment.user)
|
||||||
text: comment.text,
|
throw new Error("comment.user is missing (include comments.user in query)")
|
||||||
createdAt: comment.createdAt,
|
|
||||||
user: {
|
return {
|
||||||
id: comment.user.id,
|
id: comment.id,
|
||||||
username: comment.user.username,
|
text: comment.text,
|
||||||
avatarUrl: comment.user.avatarUrl ?? null,
|
createdAt: requiredIsoString(comment.createdAt, "comment.createdAt"),
|
||||||
},
|
user: {
|
||||||
})),
|
id: comment.user.id,
|
||||||
|
username: comment.user.username,
|
||||||
|
avatarUrl: comment.user.avatarUrl ?? null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
breadcrumb: mapBreadcrumbToResponse(deal.breadcrumb),
|
||||||
|
|
||||||
|
notice: mapNoticeToResponse(firstNotice),
|
||||||
|
|
||||||
|
similarDeals: Array.isArray(deal.similarDeals)
|
||||||
|
? deal.similarDeals.map(mapSimilarDealItem).filter(Boolean)
|
||||||
|
: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,19 @@
|
||||||
// adapters/login.adapter.js
|
// adapters/responses/login.adapter.js
|
||||||
|
function mapLoginRequestToLoginInput(input) {
|
||||||
function mapLoginRequestToLoginInput(body) {
|
|
||||||
return {
|
return {
|
||||||
email: (body?.email || "").trim().toLowerCase(),
|
email: input.email,
|
||||||
password: body?.password || "",
|
password: input.password,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapLoginResultToResponse(result) {
|
function mapLoginResultToResponse(result) {
|
||||||
// result: { token, user }
|
|
||||||
return {
|
return {
|
||||||
token: result.token,
|
token: result.accessToken, // <-- KRİTİK
|
||||||
user: {
|
user: result.user,
|
||||||
id: result.user.id,
|
}
|
||||||
username: result.user.username,
|
|
||||||
email: result.user.email,
|
|
||||||
avatarUrl: result.user.avatarUrl ?? null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
mapLoginRequestToLoginInput,
|
mapLoginRequestToLoginInput,
|
||||||
mapLoginResultToResponse,
|
mapLoginResultToResponse,
|
||||||
};
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
const formatDateAsString = (value) =>
|
||||||
|
value instanceof Date ? value.toISOString() : value ?? null
|
||||||
|
|
||||||
// adapters/responses/publicUser.adapter.js
|
// adapters/responses/publicUser.adapter.js
|
||||||
function mapUserToPublicUserSummaryResponse(user) {
|
function mapUserToPublicUserSummaryResponse(user) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -12,7 +15,8 @@ function mapUserToPublicUserDetailsResponse(user) {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatarUrl: user.avatarUrl ?? null,
|
avatarUrl: user.avatarUrl ?? null,
|
||||||
createdAt: user.createdAt, // ISO string olmalı
|
email: user.email,
|
||||||
|
createdAt: formatDateAsString(user.createdAt), // ISO string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
function mapRegisterRequestToRegisterInput(body) {
|
// adapters/responses/register.adapter.js
|
||||||
|
function mapRegisterRequestToRegisterInput(input) {
|
||||||
return {
|
return {
|
||||||
username: (body?.username || "").trim(),
|
username: input.username,
|
||||||
email: (body?.email || "").trim().toLowerCase(),
|
email: input.email,
|
||||||
password: body?.password || "",
|
password: input.password,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapRegisterResultToResponse(result) {
|
function mapRegisterResultToResponse(result) {
|
||||||
return {
|
return {
|
||||||
token: result.token,
|
token: result.accessToken, // <-- KRİTİK
|
||||||
user: result.user,
|
user: result.user,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
mapRegisterRequestToRegisterInput,
|
mapRegisterRequestToRegisterInput,
|
||||||
mapRegisterResultToResponse,
|
mapRegisterResultToResponse,
|
||||||
};
|
}
|
||||||
|
|
|
||||||
63
db/category.db.js
Normal file
63
db/category.db.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
const prisma = require("./client"); // Prisma client
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kategoriyi slug'a göre bul
|
||||||
|
*/
|
||||||
|
async function findCategoryBySlug(slug, options = {}) {
|
||||||
|
const s = String(slug ?? "").trim().toLowerCase();
|
||||||
|
return prisma.category.findUnique({
|
||||||
|
where: { slug: s },
|
||||||
|
select: options.select || undefined,
|
||||||
|
include: options.include || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kategorinin fırsatlarını al
|
||||||
|
* Sayfalama ve filtreler ile fırsatları çekiyoruz
|
||||||
|
*/
|
||||||
|
async function listCategoryDeals({ where = {}, skip = 0, take = 10 }) {
|
||||||
|
return prisma.deal.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
orderBy: { createdAt: "desc" }, // Yeni fırsatlar önce gelsin
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCategoryBreadcrumb(categoryId, { includeUndefined = false } = {}) {
|
||||||
|
let currentId = Number(categoryId);
|
||||||
|
if (!Number.isInteger(currentId)) throw new Error("categoryId must be int");
|
||||||
|
|
||||||
|
const path = [];
|
||||||
|
const visited = new Set();
|
||||||
|
|
||||||
|
// Bu döngü, root kategoriye kadar gidip breadcrumb oluşturacak
|
||||||
|
while (true) {
|
||||||
|
if (visited.has(currentId)) break;
|
||||||
|
visited.add(currentId);
|
||||||
|
|
||||||
|
const cat = await prisma.category.findUnique({
|
||||||
|
where: { id: currentId },
|
||||||
|
select: { id: true, name: true, slug: true, parentId: true }, // Yalnızca gerekli alanları seçiyoruz
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cat) break;
|
||||||
|
|
||||||
|
// Undefined'ı istersen breadcrumb'ta göstermiyoruz
|
||||||
|
if (includeUndefined || cat.id !== 0) {
|
||||||
|
path.push({ id: cat.id, name: cat.name, slug: cat.slug });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cat.parentId === null || cat.parentId === undefined) break;
|
||||||
|
currentId = cat.parentId; // Bir üst kategoriye geçiyoruz
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.reverse(); // Kökten başlayarak, kategoriyi en son eklediğimiz için tersine çeviriyoruz
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getCategoryBreadcrumb,
|
||||||
|
findCategoryBySlug,
|
||||||
|
listCategoryDeals,
|
||||||
|
};
|
||||||
|
|
@ -12,7 +12,14 @@ async function findComments(where, options = {}) {
|
||||||
orderBy: options.orderBy || { createdAt: "desc" },
|
orderBy: options.orderBy || { createdAt: "desc" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
async function findComment(where, options = {}) {
|
||||||
|
return prisma.comment.findFirst({
|
||||||
|
where,
|
||||||
|
include: options.include || undefined,
|
||||||
|
select: options.select || undefined,
|
||||||
|
orderBy: options.orderBy || { createdAt: "desc" },
|
||||||
|
})
|
||||||
|
}
|
||||||
async function createComment(data, options = {}, db) {
|
async function createComment(data, options = {}, db) {
|
||||||
const p = getDb(db)
|
const p = getDb(db)
|
||||||
return p.comment.create({
|
return p.comment.create({
|
||||||
|
|
@ -36,4 +43,5 @@ module.exports = {
|
||||||
countComments,
|
countComments,
|
||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
|
findComment
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,61 @@ function getDb(db) {
|
||||||
return db || prisma
|
return db || prisma
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const DEAL_CARD_INCLUDE = {
|
||||||
|
user: { select: { id: true, username: true, avatarUrl: true } },
|
||||||
|
seller: { select: { id: true, name: true, url: true } },
|
||||||
|
images: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
take: 1,
|
||||||
|
select: { imageUrl: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDealCards({ where = {}, skip = 0, take = 10, orderBy = [{ createdAt: "desc" }] }) {
|
||||||
|
try {
|
||||||
|
// Prisma sorgusunu çalıştırıyoruz ve genelleştirilmiş formatta alıyoruz
|
||||||
|
return await prisma.deal.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
orderBy,
|
||||||
|
include: DEAL_CARD_INCLUDE, // Her zaman bu alanları dahil ediyoruz
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Fırsatlar alınırken bir hata oluştu: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sayfalama ve diğer parametrelerle deal'leri çeker
|
||||||
|
*
|
||||||
|
* @param {object} params - where, skip, take, orderBy parametreleri
|
||||||
|
* @returns {object} - Deal card'leri ve toplam sayıyı döner
|
||||||
|
*/
|
||||||
|
async function getPaginatedDealCards({ where = {}, page = 1, limit = 10, orderBy = [{ createdAt: "desc" }] }) {
|
||||||
|
const pagination = clampPagination({ page, limit })
|
||||||
|
|
||||||
|
// Deal card verilerini ve toplam sayıyı alıyoruz
|
||||||
|
const [deals, total] = await Promise.all([
|
||||||
|
getDealCards({
|
||||||
|
where,
|
||||||
|
skip: pagination.skip,
|
||||||
|
take: pagination.limit,
|
||||||
|
orderBy,
|
||||||
|
}),
|
||||||
|
countDeals(where), // Total count almak için
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pagination.page,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pagination.limit),
|
||||||
|
results: deals, // Burada raw data döndürülüyor, map'lemiyoruz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function findDeals(where = {}, options = {}) {
|
async function findDeals(where = {}, options = {}) {
|
||||||
return prisma.deal.findMany({
|
return prisma.deal.findMany({
|
||||||
where,
|
where,
|
||||||
|
|
@ -15,6 +70,42 @@ async function findDeals(where = {}, options = {}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findSimilarCandidatesByCategory(categoryId, excludeDealId, { take = 80 } = {}) {
|
||||||
|
const safeTake = Math.min(Math.max(Number(take) || 80, 1), 200)
|
||||||
|
|
||||||
|
return prisma.deal.findMany({
|
||||||
|
where: {
|
||||||
|
id: { not: Number(excludeDealId) },
|
||||||
|
status: "ACTIVE",
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
},
|
||||||
|
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 } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function findDeal(where, options = {}, db) {
|
async function findDeal(where, options = {}, db) {
|
||||||
const p = getDb(db)
|
const p = getDb(db)
|
||||||
return p.deal.findUnique({
|
return p.deal.findUnique({
|
||||||
|
|
@ -105,4 +196,8 @@ module.exports = {
|
||||||
createVote,
|
createVote,
|
||||||
updateVote,
|
updateVote,
|
||||||
countVotes,
|
countVotes,
|
||||||
|
findSimilarCandidatesByCategory,
|
||||||
|
findSimilarCandidatesBySeller,
|
||||||
|
getDealCards,
|
||||||
|
getPaginatedDealCards
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
db/dealAiReview.db.js
Normal file
74
db/dealAiReview.db.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// db/dealAiReview.db.js
|
||||||
|
const prisma = require("./client")
|
||||||
|
|
||||||
|
async function upsertDealAiReview(dealId, input = {}) {
|
||||||
|
const data = {
|
||||||
|
bestCategoryId: input.bestCategoryId ?? input.best_category_id ?? 0,
|
||||||
|
needsReview: Boolean(input.needsReview ?? input.needs_review ?? false),
|
||||||
|
hasIssue: Boolean(input.hasIssue ?? input.has_issue ?? false),
|
||||||
|
issueType: (input.issueType ?? input.issue_type ?? "NONE"),
|
||||||
|
issueReason: input.issueReason ?? input.issue_reason ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.dealAiReview.upsert({
|
||||||
|
where: { dealId },
|
||||||
|
update: data,
|
||||||
|
create: { dealId, ...data },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDealAiReviewByDealId(dealId, options = {}) {
|
||||||
|
return prisma.dealAiReview.findUnique({
|
||||||
|
where: { dealId },
|
||||||
|
select: options.select || undefined,
|
||||||
|
include: options.include || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDealAiReviewByDealId(dealId) {
|
||||||
|
return prisma.dealAiReview.delete({
|
||||||
|
where: { dealId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDealsNeedingAiReview({ page = 1, limit = 50 } = {}) {
|
||||||
|
const take = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||||
|
const skip = (Math.max(Number(page) || 1, 1) - 1) * take
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
OR: [{ needsReview: true }, { hasIssue: true }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
prisma.dealAiReview.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ updatedAt: "desc" }],
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
include: {
|
||||||
|
deal: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
url: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
userId: true,
|
||||||
|
categoryId: true,
|
||||||
|
sellerId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.dealAiReview.count({ where }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { items, total, page: Math.max(Number(page) || 1, 1), limit: take }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
upsertDealAiReview,
|
||||||
|
findDealAiReviewByDealId,
|
||||||
|
deleteDealAiReviewByDealId,
|
||||||
|
listDealsNeedingAiReview,
|
||||||
|
}
|
||||||
106
db/refreshToken.db.js
Normal file
106
db/refreshToken.db.js
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
// db/refreshToken.db.js
|
||||||
|
const prisma = require("./client")
|
||||||
|
|
||||||
|
function toDate(x) {
|
||||||
|
if (!x) return null
|
||||||
|
if (x instanceof Date) return x
|
||||||
|
const d = new Date(x)
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRefreshToken(userId, input = {}) {
|
||||||
|
const data = {
|
||||||
|
userId: Number(userId),
|
||||||
|
tokenHash: input.tokenHash, // required
|
||||||
|
familyId: input.familyId, // required
|
||||||
|
jti: input.jti, // required
|
||||||
|
expiresAt: toDate(input.expiresAt) || new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
|
||||||
|
createdByIp: input.createdByIp ?? null,
|
||||||
|
userAgent: input.userAgent ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.refreshToken.create({ data })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findRefreshTokenByHash(tokenHash, options = {}) {
|
||||||
|
return prisma.refreshToken.findUnique({
|
||||||
|
where: { tokenHash },
|
||||||
|
select: options.select || undefined,
|
||||||
|
include: options.include || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeRefreshTokenById(id, meta = {}) {
|
||||||
|
return prisma.refreshToken.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
revokedAt: meta.revokedAt ?? new Date(),
|
||||||
|
// optional audit
|
||||||
|
createdByIp: meta.createdByIp ?? undefined,
|
||||||
|
userAgent: meta.userAgent ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeRefreshTokenByHash(tokenHash, meta = {}) {
|
||||||
|
return prisma.refreshToken.update({
|
||||||
|
where: { tokenHash },
|
||||||
|
data: {
|
||||||
|
revokedAt: meta.revokedAt ?? new Date(),
|
||||||
|
createdByIp: meta.createdByIp ?? undefined,
|
||||||
|
userAgent: meta.userAgent ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotation: eski token -> revoked + replacedById set, yeni token create
|
||||||
|
async function rotateRefreshToken({ oldId, newToken = {}, meta = {} }) {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.refreshToken.create({
|
||||||
|
data: {
|
||||||
|
userId: Number(newToken.userId),
|
||||||
|
tokenHash: newToken.tokenHash,
|
||||||
|
familyId: newToken.familyId,
|
||||||
|
jti: newToken.jti,
|
||||||
|
expiresAt: toDate(newToken.expiresAt),
|
||||||
|
createdByIp: meta.createdByIp ?? null,
|
||||||
|
userAgent: meta.userAgent ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const revoked = await tx.refreshToken.update({
|
||||||
|
where: { id: oldId },
|
||||||
|
data: {
|
||||||
|
revokedAt: meta.revokedAt ?? new Date(),
|
||||||
|
replacedById: created.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { created, revoked }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse tespiti / güvenlik: aynı ailedeki tüm tokenları revoke et
|
||||||
|
async function revokeRefreshTokenFamily(familyId) {
|
||||||
|
return prisma.refreshToken.updateMany({
|
||||||
|
where: { familyId, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeAllUserRefreshTokens(userId) {
|
||||||
|
return prisma.refreshToken.updateMany({
|
||||||
|
where: { userId: Number(userId), revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRefreshToken,
|
||||||
|
findRefreshTokenByHash,
|
||||||
|
revokeRefreshTokenById,
|
||||||
|
revokeRefreshTokenByHash,
|
||||||
|
rotateRefreshToken,
|
||||||
|
revokeRefreshTokenFamily,
|
||||||
|
revokeAllUserRefreshTokens,
|
||||||
|
}
|
||||||
24
jobs/dealClassification.queue.js
Normal file
24
jobs/dealClassification.queue.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
const { Queue } = require("bullmq")
|
||||||
|
|
||||||
|
const connection = {
|
||||||
|
host: process.env.REDIS_HOST ,
|
||||||
|
port: Number(process.env.REDIS_PORT ),
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = new Queue("deal-classification", { connection })
|
||||||
|
|
||||||
|
async function enqueueDealClassification({ dealId }) {
|
||||||
|
return queue.add(
|
||||||
|
"classify-deal",
|
||||||
|
{ dealId },
|
||||||
|
{
|
||||||
|
jobId: `deal-${dealId}`, // aynı deal için duplicate engeller
|
||||||
|
attempts: 5,
|
||||||
|
backoff: { type: "exponential", delay: 5000 },
|
||||||
|
removeOnComplete: 1000,
|
||||||
|
removeOnFail: 2000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { enqueueDealClassification, connection, queue }
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
const jwt = require("jsonwebtoken");
|
|
||||||
|
|
||||||
module.exports = (req, res, next) => {
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
|
|
||||||
// token yoksa normal devam
|
|
||||||
if (!authHeader) {
|
|
||||||
req.user = null;
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = authHeader.split(" ");
|
|
||||||
const token = parts.length === 2 ? parts[1] : null;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
req.user = null;
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
req.user = {
|
|
||||||
...decoded,
|
|
||||||
userId: Number(decoded.userId),
|
|
||||||
};
|
|
||||||
return next();
|
|
||||||
} catch (err) {
|
|
||||||
// token varsa ama bozuksa => 401 (tercih)
|
|
||||||
return res.status(401).json({ error: "Token geçersiz" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
const jwt = require("jsonwebtoken");
|
|
||||||
|
|
||||||
module.exports = (req, res, next) => {
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
console.log("Authorization Header:", authHeader); // <---
|
|
||||||
|
|
||||||
if (!authHeader) return res.status(401).json({ error: "Token yok" });
|
|
||||||
|
|
||||||
const token = authHeader.split(" ")[1];
|
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
console.log("Decoded Token:", decoded); // <---
|
|
||||||
req.user = decoded;
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("JWT verify error:", err.message);
|
|
||||||
return res.status(401).json({ error: "Token geçersiz" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
26
middleware/optionalAuth.js
Normal file
26
middleware/optionalAuth.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
const jwt = require("jsonwebtoken")
|
||||||
|
|
||||||
|
function getBearerToken(req) {
|
||||||
|
const h = req.headers.authorization
|
||||||
|
if (!h) return null
|
||||||
|
const [type, token] = h.split(" ")
|
||||||
|
if (type !== "Bearer" || !token) return null
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function optionalAuth(req, res, next) {
|
||||||
|
const token = getBearerToken(req)
|
||||||
|
if (!token) return next()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET)
|
||||||
|
req.auth = {
|
||||||
|
userId: typeof decoded.sub === "string" ? Number(decoded.sub) : decoded.sub,
|
||||||
|
role: decoded.role,
|
||||||
|
jti: decoded.jti,
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ error: "Token geçersiz" })
|
||||||
|
}
|
||||||
|
}
|
||||||
29
middleware/requireAuth.js
Normal file
29
middleware/requireAuth.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
const jwt = require("jsonwebtoken")
|
||||||
|
|
||||||
|
function getBearerToken(req) {
|
||||||
|
const h = req.headers.authorization
|
||||||
|
if (!h) return null
|
||||||
|
const [type, token] = h.split(" ")
|
||||||
|
if (type !== "Bearer" || !token) return null
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function requireAuth(req, res, next) {
|
||||||
|
const token = getBearerToken(req)
|
||||||
|
if (!token) return res.status(401).json({ error: "Token yok" })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET)
|
||||||
|
|
||||||
|
req.auth = {
|
||||||
|
userId: typeof decoded.sub === "string" ? Number(decoded.sub) : decoded.sub,
|
||||||
|
role: decoded.role,
|
||||||
|
jti: decoded.jti,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.auth.userId) return res.status(401).json({ error: "Token geçersiz" })
|
||||||
|
next()
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ error: "Token geçersiz" })
|
||||||
|
}
|
||||||
|
}
|
||||||
14
middleware/requireRole.js
Normal file
14
middleware/requireRole.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// middleware/requireRole.js
|
||||||
|
const roleRank = { USER: 1, MOD: 2, ADMIN: 3 };
|
||||||
|
|
||||||
|
module.exports = function requireRole(minRole = "USER") {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.auth) return res.status(401).json({ error: "Token yok" });
|
||||||
|
|
||||||
|
const userRole = req.auth.role || "USER";
|
||||||
|
if ((roleRank[userRole] || 0) < (roleRank[minRole] || 0)) {
|
||||||
|
return res.status(403).json({ error: "Yetkisiz" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
17
middleware/validate.middleware.js
Normal file
17
middleware/validate.middleware.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
function validate(schema, source = "body", key = "validated") {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const target = req[source]
|
||||||
|
const result = schema.safeParse(target)
|
||||||
|
if (!result.success) {
|
||||||
|
const { fieldErrors } = result.error.flatten()
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Geçersiz veri",
|
||||||
|
details: fieldErrors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
req[key] = result.data
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validate }
|
||||||
355
package-lock.json
generated
355
package-lock.json
generated
|
|
@ -10,12 +10,17 @@
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.18.0",
|
"@prisma/client": "^6.18.0",
|
||||||
|
"@shared/contracts": "file:../Contracts",
|
||||||
"@supabase/supabase-js": "^2.78.0",
|
"@supabase/supabase-js": "^2.78.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
|
"bullmq": "^5.67.0",
|
||||||
|
"contracts": "^0.4.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"openai": "^6.16.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
|
|
@ -30,6 +35,18 @@
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../Contracts": {
|
||||||
|
"name": "@shared/contracts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"rimraf": "^6.0.0",
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@cspotcode/source-map-support": {
|
"node_modules/@cspotcode/source-map-support": {
|
||||||
"version": "0.8.1",
|
"version": "0.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||||
|
|
@ -518,6 +535,12 @@
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ioredis/commands": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/resolve-uri": {
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
|
@ -546,6 +569,84 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.2",
|
"version": "6.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
|
||||||
|
|
@ -631,6 +732,10 @@
|
||||||
"@prisma/debug": "6.19.2"
|
"@prisma/debug": "6.19.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@shared/contracts": {
|
||||||
|
"resolved": "../Contracts",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
|
@ -1011,6 +1116,34 @@
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bullmq": {
|
||||||
|
"version": "5.67.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.67.0.tgz",
|
||||||
|
"integrity": "sha512-8oLrD+8uZOkNtMbqd1Ok6asZEGJ4+wKQhD0BZJTUg78vjxtVJ0DCzFm8Vcq7RpzAH/U4YHombz79y2SWHzgd4g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron-parser": "4.9.0",
|
||||||
|
"ioredis": "5.9.2",
|
||||||
|
"msgpackr": "1.11.5",
|
||||||
|
"node-abort-controller": "3.1.1",
|
||||||
|
"semver": "7.7.3",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"uuid": "11.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/uuid": {
|
||||||
|
"version": "11.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/esm/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/busboy": {
|
"node_modules/busboy": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
|
@ -1132,6 +1265,15 @@
|
||||||
"consola": "^3.2.3"
|
"consola": "^3.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -1216,6 +1358,18 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/contracts": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/contracts/-/contracts-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-1VdEcGnt4Dk+G/mccfIJYuJZ52uIYMSSBfdQiXXuIhP8e1dNnUima7p9XBzGH+xgX2N88oS3bmeEj2DVTWk/4Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"JSV": "3.5.0",
|
||||||
|
"validator": "0.2.9"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
|
@ -1225,6 +1379,25 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
|
|
@ -1254,6 +1427,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "4.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
|
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
@ -1288,6 +1473,15 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
|
@ -1818,6 +2012,30 @@
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ioredis": {
|
||||||
|
"version": "5.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
|
||||||
|
"integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "1.5.0",
|
||||||
|
"cluster-key-slot": "^1.1.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"lodash.defaults": "^4.2.0",
|
||||||
|
"lodash.isarguments": "^3.1.0",
|
||||||
|
"redis-errors": "^1.2.0",
|
||||||
|
"redis-parser": "^3.0.0",
|
||||||
|
"standard-as-callback": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.22.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
|
@ -1924,6 +2142,14 @@
|
||||||
"npm": ">=6"
|
"npm": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/JSV": {
|
||||||
|
"version": "3.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/JSV/-/JSV-3.5.0.tgz",
|
||||||
|
"integrity": "sha512-Pf3yCqcNQ2B+VaTA0Gr2pvvjNxaSTEM+H1WTHIcVGOT6sAqtnHgUXF2Eav5Q89jEXBMpo365gOYqlJ/Aa7PuIQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jwa": {
|
"node_modules/jwa": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
|
@ -1955,12 +2181,24 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.defaults": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isarguments": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.isboolean": {
|
"node_modules/lodash.isboolean": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
|
@ -1997,6 +2235,15 @@
|
||||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/make-error": {
|
"node_modules/make-error": {
|
||||||
"version": "1.3.6",
|
"version": "1.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||||
|
|
@ -2086,6 +2333,37 @@
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "1.11.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
|
||||||
|
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||||
|
|
@ -2156,6 +2434,12 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-abort-controller": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-fetch-native": {
|
"node_modules/node-fetch-native": {
|
||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
|
|
@ -2163,6 +2447,21 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nypm": {
|
"node_modules/nypm": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
|
||||||
|
|
@ -2232,6 +2531,27 @@
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openai": {
|
||||||
|
"version": "6.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openai/-/openai-6.16.0.tgz",
|
||||||
|
"integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"openai": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.25 || ^4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ws": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
|
@ -2458,6 +2778,27 @@
|
||||||
"node": ">= 10.13.0"
|
"node": ">= 10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redis-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"redis-errors": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/regexp-tree": {
|
"node_modules/regexp-tree": {
|
||||||
"version": "0.1.27",
|
"version": "0.1.27",
|
||||||
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
|
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
|
||||||
|
|
@ -2727,6 +3068,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standard-as-callback": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
|
@ -2978,6 +3325,14 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/validator": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/validator/-/validator-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-oJX8zS3BwKwvW+PD+z+B250JzoLVyhwpqUQ8mRen/eAVGAZOmA/178UY0oTV8TL8+xQ/v3Ev1QqOm9RqKyYLDg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,17 @@
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.18.0",
|
"@prisma/client": "^6.18.0",
|
||||||
|
"@shared/contracts": "file:../Contracts",
|
||||||
"@supabase/supabase-js": "^2.78.0",
|
"@supabase/supabase-js": "^2.78.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
|
"bullmq": "^5.67.0",
|
||||||
|
"contracts": "^0.4.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"openai": "^6.16.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
|
|
|
||||||
239
prisma/categories.json
Normal file
239
prisma/categories.json
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
[
|
||||||
|
{ "id": 0, "name": "Undefined", "slug": "undefined", "parentId": null },
|
||||||
|
|
||||||
|
{ "id": 1, "name": "Elektronik", "slug": "electronics", "parentId": 0 },
|
||||||
|
{ "id": 2, "name": "Kozmetik", "slug": "beauty", "parentId": 0 },
|
||||||
|
{ "id": 3, "name": "Gıda", "slug": "food", "parentId": 0 },
|
||||||
|
{ "id": 4, "name": "Oto", "slug": "auto", "parentId": 0 },
|
||||||
|
{ "id": 5, "name": "Ev & Bahçe", "slug": "home-garden", "parentId": 0 },
|
||||||
|
|
||||||
|
{ "id": 6, "name": "Bilgisayar", "slug": "computers", "parentId": 1 },
|
||||||
|
{ "id": 7, "name": "PC Bileşenleri", "slug": "pc-components", "parentId": 6 },
|
||||||
|
{ "id": 8, "name": "RAM", "slug": "pc-ram", "parentId": 7 },
|
||||||
|
{ "id": 9, "name": "SSD", "slug": "pc-ssd", "parentId": 7 },
|
||||||
|
{ "id": 10, "name": "CPU", "slug": "pc-cpu", "parentId": 7 },
|
||||||
|
{ "id": 11, "name": "GPU", "slug": "pc-gpu", "parentId": 7 },
|
||||||
|
|
||||||
|
{ "id": 12, "name": "Bilgisayar Aksesuarları", "slug": "pc-peripherals", "parentId": 6 },
|
||||||
|
{ "id": 13, "name": "Klavye", "slug": "pc-keyboard", "parentId": 12 },
|
||||||
|
{ "id": 14, "name": "Mouse", "slug": "pc-mouse", "parentId": 12 },
|
||||||
|
{ "id": 15, "name": "Monitör", "slug": "pc-monitor", "parentId": 6 },
|
||||||
|
|
||||||
|
{ "id": 16, "name": "Makyaj", "slug": "beauty-makeup", "parentId": 2 },
|
||||||
|
{ "id": 17, "name": "Ruj", "slug": "beauty-lipstick", "parentId": 16 },
|
||||||
|
{ "id": 18, "name": "Fondöten", "slug": "beauty-foundation", "parentId": 16 },
|
||||||
|
{ "id": 19, "name": "Maskara", "slug": "beauty-mascara", "parentId": 16 },
|
||||||
|
|
||||||
|
{ "id": 20, "name": "Cilt Bakımı", "slug": "beauty-skincare", "parentId": 2 },
|
||||||
|
{ "id": 21, "name": "Nemlendirici", "slug": "beauty-moisturizer", "parentId": 20 },
|
||||||
|
|
||||||
|
{ "id": 22, "name": "Atıştırmalık", "slug": "food-snacks", "parentId": 3 },
|
||||||
|
{ "id": 23, "name": "Çiğköfte", "slug": "food-cigkofte", "parentId": 22 },
|
||||||
|
|
||||||
|
{ "id": 24, "name": "İçecek", "slug": "food-beverages", "parentId": 3 },
|
||||||
|
{ "id": 25, "name": "Kahve", "slug": "food-coffee", "parentId": 24 },
|
||||||
|
|
||||||
|
{ "id": 26, "name": "Yağlar", "slug": "auto-oils", "parentId": 4 },
|
||||||
|
{ "id": 27, "name": "Motor Yağı", "slug": "auto-engine-oil", "parentId": 26 },
|
||||||
|
|
||||||
|
{ "id": 28, "name": "Oto Parçaları", "slug": "auto-parts", "parentId": 4 },
|
||||||
|
{ "id": 29, "name": "Fren Balatası", "slug": "auto-brake-pads", "parentId": 28 },
|
||||||
|
|
||||||
|
{ "id": 30, "name": "Bahçe", "slug": "home-garden-garden", "parentId": 5 },
|
||||||
|
{ "id": 31, "name": "Sulama", "slug": "garden-irrigation", "parentId": 30 },
|
||||||
|
|
||||||
|
{ "id": 32, "name": "Telefon & Aksesuarları", "slug": "phone", "parentId": 1 },
|
||||||
|
{ "id": 33, "name": "Akıllı Telefon", "slug": "phone-smartphone", "parentId": 32 },
|
||||||
|
{ "id": 34, "name": "Telefon Kılıfı", "slug": "phone-case", "parentId": 32 },
|
||||||
|
{ "id": 35, "name": "Ekran Koruyucu", "slug": "phone-screen-protector", "parentId": 32 },
|
||||||
|
{ "id": 36, "name": "Şarj & Kablo", "slug": "phone-charging", "parentId": 32 },
|
||||||
|
{ "id": 37, "name": "Powerbank", "slug": "phone-powerbank", "parentId": 32 },
|
||||||
|
|
||||||
|
{ "id": 38, "name": "Giyilebilir Teknoloji", "slug": "wearables", "parentId": 1 },
|
||||||
|
{ "id": 39, "name": "Akıllı Saat", "slug": "wearables-smartwatch", "parentId": 38 },
|
||||||
|
{ "id": 40, "name": "Akıllı Bileklik", "slug": "wearables-band", "parentId": 38 },
|
||||||
|
|
||||||
|
{ "id": 41, "name": "Ses & Audio", "slug": "audio", "parentId": 1 },
|
||||||
|
{ "id": 42, "name": "Kulaklık", "slug": "audio-headphones", "parentId": 41 },
|
||||||
|
{ "id": 43, "name": "TWS Kulaklık", "slug": "audio-tws", "parentId": 42 },
|
||||||
|
{ "id": 44, "name": "Bluetooth Hoparlör", "slug": "audio-bt-speaker", "parentId": 41 },
|
||||||
|
{ "id": 45, "name": "Soundbar", "slug": "audio-soundbar", "parentId": 41 },
|
||||||
|
{ "id": 46, "name": "Mikrofon", "slug": "audio-microphone", "parentId": 41 },
|
||||||
|
{ "id": 47, "name": "Plak / Pikap", "slug": "audio-turntable", "parentId": 41 },
|
||||||
|
|
||||||
|
{ "id": 48, "name": "TV & Video", "slug": "tv-video", "parentId": 1 },
|
||||||
|
{ "id": 49, "name": "Televizyon", "slug": "tv", "parentId": 48 },
|
||||||
|
{ "id": 50, "name": "Projeksiyon", "slug": "projector", "parentId": 48 },
|
||||||
|
{ "id": 51, "name": "Medya Oynatıcı", "slug": "tv-media-player", "parentId": 48 },
|
||||||
|
{ "id": 52, "name": "TV Aksesuarları", "slug": "tv-accessories", "parentId": 48 },
|
||||||
|
{ "id": 53, "name": "Uydu Alıcısı / Receiver", "slug": "tv-receiver", "parentId": 48 },
|
||||||
|
|
||||||
|
{ "id": 54, "name": "Oyun Konsolları", "slug": "console", "parentId": 1 },
|
||||||
|
{ "id": 55, "name": "PlayStation", "slug": "console-playstation", "parentId": 54 },
|
||||||
|
{ "id": 56, "name": "Xbox", "slug": "console-xbox", "parentId": 54 },
|
||||||
|
{ "id": 57, "name": "Nintendo", "slug": "console-nintendo", "parentId": 54 },
|
||||||
|
{ "id": 58, "name": "Oyunlar (Konsol)", "slug": "console-games", "parentId": 54 },
|
||||||
|
{ "id": 59, "name": "Konsol Aksesuarları", "slug": "console-accessories", "parentId": 54 },
|
||||||
|
|
||||||
|
{ "id": 60, "name": "Kamera & Fotoğraf", "slug": "camera", "parentId": 1 },
|
||||||
|
{ "id": 61, "name": "Fotoğraf Makinesi", "slug": "camera-photo", "parentId": 60 },
|
||||||
|
{ "id": 62, "name": "Aksiyon Kamera", "slug": "camera-action", "parentId": 60 },
|
||||||
|
{ "id": 63, "name": "Lens", "slug": "camera-lens", "parentId": 60 },
|
||||||
|
{ "id": 64, "name": "Tripod", "slug": "camera-tripod", "parentId": 60 },
|
||||||
|
|
||||||
|
{ "id": 65, "name": "Akıllı Ev", "slug": "smart-home", "parentId": 1 },
|
||||||
|
{ "id": 66, "name": "Güvenlik Kamerası", "slug": "smart-security-camera", "parentId": 65 },
|
||||||
|
{ "id": 67, "name": "Akıllı Priz", "slug": "smart-plug", "parentId": 65 },
|
||||||
|
{ "id": 68, "name": "Akıllı Ampul", "slug": "smart-bulb", "parentId": 65 },
|
||||||
|
{ "id": 69, "name": "Akıllı Sensör", "slug": "smart-sensor", "parentId": 65 },
|
||||||
|
|
||||||
|
{ "id": 70, "name": "Ağ Ürünleri", "slug": "pc-networking", "parentId": 6 },
|
||||||
|
{ "id": 71, "name": "Router", "slug": "pc-router", "parentId": 70 },
|
||||||
|
{ "id": 72, "name": "Modem", "slug": "pc-modem", "parentId": 70 },
|
||||||
|
{ "id": 73, "name": "Switch", "slug": "pc-switch", "parentId": 70 },
|
||||||
|
{ "id": 74, "name": "Wi-Fi Extender", "slug": "pc-wifi-extender", "parentId": 70 },
|
||||||
|
|
||||||
|
{ "id": 75, "name": "Yazıcı & Tarayıcı", "slug": "pc-printing", "parentId": 6 },
|
||||||
|
{ "id": 76, "name": "Yazıcı", "slug": "pc-printer", "parentId": 75 },
|
||||||
|
{ "id": 77, "name": "Toner & Kartuş", "slug": "pc-ink-toner", "parentId": 75 },
|
||||||
|
{ "id": 78, "name": "Tarayıcı", "slug": "pc-scanner", "parentId": 75 },
|
||||||
|
|
||||||
|
{ "id": 79, "name": "Dizüstü Bilgisayar", "slug": "pc-laptop", "parentId": 6 },
|
||||||
|
{ "id": 80, "name": "Masaüstü Bilgisayar", "slug": "pc-desktop", "parentId": 6 },
|
||||||
|
{ "id": 81, "name": "Tablet", "slug": "pc-tablet", "parentId": 6 },
|
||||||
|
|
||||||
|
{ "id": 82, "name": "Depolama", "slug": "pc-storage", "parentId": 6 },
|
||||||
|
{ "id": 83, "name": "Harici Disk", "slug": "pc-external-drive", "parentId": 82 },
|
||||||
|
{ "id": 84, "name": "USB Flash", "slug": "pc-usb-drive", "parentId": 82 },
|
||||||
|
{ "id": 85, "name": "NAS", "slug": "pc-nas", "parentId": 82 },
|
||||||
|
|
||||||
|
{ "id": 86, "name": "Webcam", "slug": "pc-webcam", "parentId": 12 },
|
||||||
|
{ "id": 87, "name": "Hoparlör (PC)", "slug": "pc-speaker", "parentId": 12 },
|
||||||
|
{ "id": 88, "name": "Mikrofon (PC)", "slug": "pc-mic", "parentId": 12 },
|
||||||
|
{ "id": 89, "name": "Mousepad", "slug": "pc-mousepad", "parentId": 12 },
|
||||||
|
{ "id": 90, "name": "Dock / USB Hub", "slug": "pc-dock-hub", "parentId": 12 },
|
||||||
|
{ "id": 91, "name": "Laptop Çantası", "slug": "pc-laptop-bag", "parentId": 12 },
|
||||||
|
{ "id": 92, "name": "Gamepad / Controller", "slug": "pc-controller", "parentId": 12 },
|
||||||
|
|
||||||
|
{ "id": 93, "name": "Anakart", "slug": "pc-motherboard", "parentId": 7 },
|
||||||
|
{ "id": 94, "name": "Güç Kaynağı (PSU)", "slug": "pc-psu", "parentId": 7 },
|
||||||
|
{ "id": 95, "name": "Kasa", "slug": "pc-case", "parentId": 7 },
|
||||||
|
|
||||||
|
{ "id": 96, "name": "Soğutma", "slug": "pc-cooling", "parentId": 7 },
|
||||||
|
{ "id": 97, "name": "Kasa Fanı", "slug": "pc-fan", "parentId": 96 },
|
||||||
|
{ "id": 98, "name": "Sıvı Soğutma", "slug": "pc-liquid-cooling", "parentId": 96 },
|
||||||
|
|
||||||
|
{ "id": 99, "name": "Parfüm", "slug": "beauty-fragrance", "parentId": 2 },
|
||||||
|
{ "id": 100, "name": "Kadın Parfüm", "slug": "beauty-fragrance-women", "parentId": 99 },
|
||||||
|
{ "id": 101, "name": "Erkek Parfüm", "slug": "beauty-fragrance-men", "parentId": 99 },
|
||||||
|
|
||||||
|
{ "id": 102, "name": "Saç Bakımı", "slug": "beauty-haircare", "parentId": 2 },
|
||||||
|
{ "id": 103, "name": "Şampuan", "slug": "beauty-shampoo", "parentId": 102 },
|
||||||
|
{ "id": 104, "name": "Saç Kremi", "slug": "beauty-conditioner", "parentId": 102 },
|
||||||
|
{ "id": 105, "name": "Saç Şekillendirici", "slug": "beauty-hair-styling", "parentId": 102 },
|
||||||
|
|
||||||
|
{ "id": 106, "name": "Kişisel Bakım", "slug": "beauty-personal-care", "parentId": 2 },
|
||||||
|
{ "id": 107, "name": "Deodorant", "slug": "beauty-deodorant", "parentId": 106 },
|
||||||
|
{ "id": 108, "name": "Tıraş Ürünleri", "slug": "beauty-shaving", "parentId": 106 },
|
||||||
|
{ "id": 109, "name": "Ağda / Epilasyon", "slug": "beauty-hair-removal", "parentId": 106 },
|
||||||
|
|
||||||
|
{ "id": 110, "name": "Serum", "slug": "beauty-skincare-serum", "parentId": 20 },
|
||||||
|
{ "id": 111, "name": "Güneş Kremi", "slug": "beauty-sunscreen", "parentId": 20 },
|
||||||
|
{ "id": 112, "name": "Temizleyici", "slug": "beauty-cleanser", "parentId": 20 },
|
||||||
|
{ "id": 113, "name": "Yüz Maskesi", "slug": "beauty-mask", "parentId": 20 },
|
||||||
|
{ "id": 114, "name": "Tonik", "slug": "beauty-toner", "parentId": 20 },
|
||||||
|
|
||||||
|
{ "id": 115, "name": "Temel Gıda", "slug": "food-staples", "parentId": 3 },
|
||||||
|
{ "id": 116, "name": "Makarna", "slug": "food-pasta", "parentId": 115 },
|
||||||
|
{ "id": 117, "name": "Pirinç & Bakliyat", "slug": "food-legumes", "parentId": 115 },
|
||||||
|
{ "id": 118, "name": "Yağ & Sirke (Gıda)", "slug": "food-oil-vinegar", "parentId": 115 },
|
||||||
|
|
||||||
|
{ "id": 119, "name": "Kahvaltılık", "slug": "food-breakfast", "parentId": 3 },
|
||||||
|
{ "id": 120, "name": "Peynir", "slug": "food-cheese", "parentId": 119 },
|
||||||
|
{ "id": 121, "name": "Zeytin", "slug": "food-olive", "parentId": 119 },
|
||||||
|
{ "id": 122, "name": "Reçel & Bal", "slug": "food-jam-honey", "parentId": 119 },
|
||||||
|
|
||||||
|
{ "id": 123, "name": "Gazlı İçecek", "slug": "food-soda", "parentId": 24 },
|
||||||
|
{ "id": 124, "name": "Su", "slug": "food-water", "parentId": 24 },
|
||||||
|
{ "id": 125, "name": "Enerji İçeceği", "slug": "food-energy", "parentId": 24 },
|
||||||
|
{ "id": 126, "name": "Çay", "slug": "food-tea", "parentId": 24 },
|
||||||
|
|
||||||
|
{ "id": 127, "name": "Dondurulmuş", "slug": "food-frozen", "parentId": 3 },
|
||||||
|
{ "id": 128, "name": "Et & Tavuk", "slug": "food-meat", "parentId": 3 },
|
||||||
|
{ "id": 129, "name": "Tatlı", "slug": "food-dessert", "parentId": 3 },
|
||||||
|
|
||||||
|
{ "id": 130, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 4 },
|
||||||
|
{ "id": 131, "name": "Araç İçi Elektronik", "slug": "auto-in-car-electronics", "parentId": 130 },
|
||||||
|
|
||||||
|
{ "id": 132, "name": "Oto Bakım", "slug": "auto-care", "parentId": 4 },
|
||||||
|
{ "id": 133, "name": "Oto Temizlik", "slug": "auto-cleaning", "parentId": 132 },
|
||||||
|
{ "id": 134, "name": "Lastik & Jant", "slug": "auto-tires", "parentId": 4 },
|
||||||
|
{ "id": 135, "name": "Akü", "slug": "auto-battery", "parentId": 4 },
|
||||||
|
{ "id": 136, "name": "Oto Aydınlatma", "slug": "auto-lighting", "parentId": 130 },
|
||||||
|
{ "id": 137, "name": "Oto Ses Sistemi", "slug": "auto-audio", "parentId": 130 },
|
||||||
|
|
||||||
|
{ "id": 138, "name": "Mobilya", "slug": "home-furniture", "parentId": 5 },
|
||||||
|
{ "id": 139, "name": "Yemek Masası", "slug": "home-dining-table", "parentId": 138 },
|
||||||
|
{ "id": 140, "name": "Sandalye", "slug": "home-chair", "parentId": 138 },
|
||||||
|
{ "id": 141, "name": "Koltuk", "slug": "home-sofa", "parentId": 138 },
|
||||||
|
{ "id": 142, "name": "Yatak", "slug": "home-bed", "parentId": 138 },
|
||||||
|
|
||||||
|
{ "id": 143, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 5 },
|
||||||
|
{ "id": 144, "name": "Nevresim", "slug": "home-bedding", "parentId": 143 },
|
||||||
|
{ "id": 145, "name": "Yorgan & Battaniye", "slug": "home-blanket", "parentId": 143 },
|
||||||
|
{ "id": 146, "name": "Perde", "slug": "home-curtain", "parentId": 143 },
|
||||||
|
|
||||||
|
{ "id": 147, "name": "Mutfak", "slug": "home-kitchen", "parentId": 5 },
|
||||||
|
{ "id": 148, "name": "Tencere & Tava", "slug": "home-cookware", "parentId": 147 },
|
||||||
|
{ "id": 149, "name": "Küçük Ev Aletleri", "slug": "home-small-appliances", "parentId": 147 },
|
||||||
|
{ "id": 150, "name": "Kahve Makinesi", "slug": "home-coffee-machine", "parentId": 149 },
|
||||||
|
{ "id": 151, "name": "Blender", "slug": "home-blender", "parentId": 149 },
|
||||||
|
{ "id": 152, "name": "Airfryer", "slug": "home-airfryer", "parentId": 149 },
|
||||||
|
{ "id": 153, "name": "Süpürge", "slug": "home-vacuum", "parentId": 149 },
|
||||||
|
|
||||||
|
{ "id": 154, "name": "Aydınlatma", "slug": "home-lighting", "parentId": 5 },
|
||||||
|
{ "id": 155, "name": "Dekorasyon", "slug": "home-decor", "parentId": 5 },
|
||||||
|
{ "id": 156, "name": "Halı", "slug": "home-rug", "parentId": 155 },
|
||||||
|
{ "id": 157, "name": "Duvar Dekoru", "slug": "home-wall-decor", "parentId": 155 },
|
||||||
|
|
||||||
|
{ "id": 158, "name": "Temizlik", "slug": "home-cleaning", "parentId": 5 },
|
||||||
|
{ "id": 159, "name": "Deterjan", "slug": "home-detergent", "parentId": 158 },
|
||||||
|
{ "id": 160, "name": "Kağıt Ürünleri", "slug": "home-paper-products", "parentId": 158 },
|
||||||
|
|
||||||
|
{ "id": 161, "name": "El Aletleri", "slug": "home-tools", "parentId": 5 },
|
||||||
|
{ "id": 162, "name": "Matkap", "slug": "home-drill", "parentId": 161 },
|
||||||
|
{ "id": 163, "name": "Testere", "slug": "home-saw", "parentId": 161 },
|
||||||
|
{ "id": 164, "name": "Vida & Dübel", "slug": "home-hardware", "parentId": 161 },
|
||||||
|
|
||||||
|
{ "id": 165, "name": "Evcil Hayvan", "slug": "pet", "parentId": 5 },
|
||||||
|
{ "id": 166, "name": "Kedi Maması", "slug": "pet-cat-food", "parentId": 165 },
|
||||||
|
{ "id": 167, "name": "Köpek Maması", "slug": "pet-dog-food", "parentId": 165 },
|
||||||
|
{ "id": 168, "name": "Kedi Kumu", "slug": "pet-cat-litter", "parentId": 165 },
|
||||||
|
|
||||||
|
{ "id": 169, "name": "Kırtasiye & Ofis", "slug": "office", "parentId": 0 },
|
||||||
|
{ "id": 170, "name": "Kağıt & Defter", "slug": "office-paper-notebook", "parentId": 169 },
|
||||||
|
{ "id": 171, "name": "A4 Kağıdı", "slug": "office-a4-paper", "parentId": 170 },
|
||||||
|
{ "id": 172, "name": "Kalem", "slug": "office-pen", "parentId": 169 },
|
||||||
|
{ "id": 173, "name": "Okul Çantası", "slug": "office-school-bag", "parentId": 169 },
|
||||||
|
|
||||||
|
{ "id": 174, "name": "Bebek & Çocuk", "slug": "baby", "parentId": 0 },
|
||||||
|
{ "id": 175, "name": "Bebek Bezi", "slug": "baby-diaper", "parentId": 174 },
|
||||||
|
{ "id": 176, "name": "Islak Mendil", "slug": "baby-wipes", "parentId": 174 },
|
||||||
|
{ "id": 177, "name": "Bebek Maması", "slug": "baby-food", "parentId": 174 },
|
||||||
|
{ "id": 178, "name": "Oyuncak", "slug": "baby-toys", "parentId": 174 },
|
||||||
|
|
||||||
|
{ "id": 179, "name": "Spor & Outdoor", "slug": "sports", "parentId": 0 },
|
||||||
|
{ "id": 180, "name": "Kamp", "slug": "sports-camping", "parentId": 179 },
|
||||||
|
{ "id": 181, "name": "Fitness", "slug": "sports-fitness", "parentId": 179 },
|
||||||
|
{ "id": 182, "name": "Bisiklet", "slug": "sports-bicycle", "parentId": 179 },
|
||||||
|
|
||||||
|
{ "id": 183, "name": "Moda", "slug": "fashion", "parentId": 0 },
|
||||||
|
{ "id": 184, "name": "Ayakkabı", "slug": "fashion-shoes", "parentId": 183 },
|
||||||
|
{ "id": 185, "name": "Erkek Giyim", "slug": "fashion-men", "parentId": 183 },
|
||||||
|
{ "id": 186, "name": "Kadın Giyim", "slug": "fashion-women", "parentId": 183 },
|
||||||
|
{ "id": 187, "name": "Çanta", "slug": "fashion-bags", "parentId": 183 },
|
||||||
|
|
||||||
|
{ "id": 188, "name": "Kitap & Medya", "slug": "books-media", "parentId": 0 },
|
||||||
|
{ "id": 189, "name": "Kitap", "slug": "books", "parentId": 188 },
|
||||||
|
{ "id": 190, "name": "Dijital Oyun (Genel)", "slug": "digital-games", "parentId": 188 }
|
||||||
|
]
|
||||||
5
prisma/migrations/20260123184800_user_role/migration.sql
Normal file
5
prisma/migrations/20260123184800_user_role/migration.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserRole" AS ENUM ('USER', 'MOD', 'ADMIN');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "role" "UserRole" NOT NULL DEFAULT 'USER';
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DealNoticeSeverity" AS ENUM ('INFO', 'WARNING', 'DANGER', 'SUCCESS');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DealNotice" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"dealId" INTEGER NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body" TEXT,
|
||||||
|
"severity" "DealNoticeSeverity" NOT NULL DEFAULT 'INFO',
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdBy" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DealNotice_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DealNotice_dealId_isActive_createdAt_idx" ON "DealNotice"("dealId", "isActive", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DealNotice_createdBy_idx" ON "DealNotice"("createdBy");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DealNotice" ADD CONSTRAINT "DealNotice_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DealNotice" ADD CONSTRAINT "DealNotice_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `updatedAt` to the `Comment` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Comment" ADD COLUMN "deletedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "parentId" INTEGER,
|
||||||
|
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Comment_dealId_createdAt_idx" ON "Comment"("dealId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Comment_parentId_createdAt_idx" ON "Comment"("parentId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Comment_dealId_parentId_createdAt_idx" ON "Comment"("dealId", "parentId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Comment_deletedAt_idx" ON "Comment"("deletedAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Comment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Deal" ADD COLUMN "categoryId" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Category" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"parentId" INTEGER,
|
||||||
|
|
||||||
|
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Tag" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DealTag" (
|
||||||
|
"dealId" INTEGER NOT NULL,
|
||||||
|
"tagId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "DealTag_pkey" PRIMARY KEY ("dealId","tagId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Category_parentId_idx" ON "Category"("parentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Tag_slug_key" ON "Tag"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DealTag_tagId_dealId_idx" ON "DealTag"("tagId", "dealId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DealTag_dealId_idx" ON "DealTag"("dealId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Deal_categoryId_createdAt_idx" ON "Deal"("categoryId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Deal_userId_createdAt_idx" ON "Deal"("userId", "createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Category" ADD CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DealTag" ADD CONSTRAINT "DealTag_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DealTag" ADD CONSTRAINT "DealTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Deal" ADD CONSTRAINT "Deal_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DealAiIssueType" AS ENUM ('NONE', 'PROFANITY', 'PHONE_NUMBER', 'PERSONAL_DATA', 'SPAM', 'OTHER');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DealAiReview" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"dealId" INTEGER NOT NULL,
|
||||||
|
"bestCategoryId" INTEGER NOT NULL,
|
||||||
|
"needsReview" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"hasIssue" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"issueType" "DealAiIssueType" NOT NULL DEFAULT 'NONE',
|
||||||
|
"issueReason" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DealAiReview_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DealAiReview_dealId_key" ON "DealAiReview"("dealId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DealAiReview_needsReview_hasIssue_updatedAt_idx" ON "DealAiReview"("needsReview", "hasIssue", "updatedAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DealAiReview" ADD CONSTRAINT "DealAiReview_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RefreshToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"familyId" TEXT NOT NULL,
|
||||||
|
"jti" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
"replacedById" TEXT,
|
||||||
|
"createdByIp" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RefreshToken_replacedById_key" ON "RefreshToken"("replacedById");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RefreshToken_familyId_idx" ON "RefreshToken"("familyId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RefreshToken_expiresAt_idx" ON "RefreshToken"("expiresAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_replacedById_fkey" FOREIGN KEY ("replacedById") REFERENCES "RefreshToken"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
// This is your Prisma schema file,
|
// This is your Prisma schema file,
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
|
||||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
@ -13,20 +10,56 @@ datasource db {
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
USER
|
||||||
|
MOD
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String @unique
|
username String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
avatarUrl String? @db.VarChar(512)
|
avatarUrl String? @db.VarChar(512)
|
||||||
createdAt DateTime @default(now())
|
role UserRole @default(USER)
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
|
||||||
Deal Deal[]
|
createdAt DateTime @default(now())
|
||||||
votes DealVote[]
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
comments Comment[]
|
|
||||||
companies Seller[]
|
Deal Deal[]
|
||||||
domains SellerDomain[]
|
votes DealVote[]
|
||||||
dealVoteHistory DealVoteHistory[]
|
comments Comment[]
|
||||||
|
companies Seller[]
|
||||||
|
domains SellerDomain[]
|
||||||
|
dealVoteHistory DealVoteHistory[]
|
||||||
|
dealNotices DealNotice[] @relation("UserDealNotices")
|
||||||
|
|
||||||
|
refreshTokens RefreshToken[] // <-- bunu ekle
|
||||||
|
}
|
||||||
|
|
||||||
|
model RefreshToken {
|
||||||
|
id String @id @default(cuid()) // token kaydı id
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
tokenHash String @unique // refresh token hash (örn sha256)
|
||||||
|
familyId String // rotation zinciri için aynı aile
|
||||||
|
jti String // token id (JWT jti / random)
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
revokedAt DateTime?
|
||||||
|
replacedById String? @unique
|
||||||
|
replacedBy RefreshToken? @relation("TokenRotation", fields: [replacedById], references: [id])
|
||||||
|
replaces RefreshToken? @relation("TokenRotation")
|
||||||
|
|
||||||
|
createdByIp String?
|
||||||
|
userAgent String?
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([familyId])
|
||||||
|
@@index([expiresAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DealStatus {
|
enum DealStatus {
|
||||||
|
|
@ -36,69 +69,154 @@ enum DealStatus {
|
||||||
REJECTED
|
REJECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SaleType{
|
enum SaleType {
|
||||||
ONLINE
|
ONLINE
|
||||||
OFFLINE
|
OFFLINE
|
||||||
CODE
|
CODE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AffiliateType{
|
enum AffiliateType {
|
||||||
AFFILIATE
|
AFFILIATE
|
||||||
NON_AFFILIATE
|
NON_AFFILIATE
|
||||||
USER_AFFILIATE
|
USER_AFFILIATE
|
||||||
}
|
}
|
||||||
|
|
||||||
model SellerDomain {
|
model SellerDomain {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
domain String @unique
|
domain String @unique
|
||||||
sellerId Int
|
sellerId Int
|
||||||
seller Seller @relation(fields: [sellerId], references: [id])
|
seller Seller @relation(fields: [sellerId], references: [id])
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
createdById Int
|
createdById Int
|
||||||
createdBy User @relation(fields: [createdById], references: [id])
|
createdBy User @relation(fields: [createdById], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Seller {
|
model Seller {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
url String @default("")
|
url String @default("")
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
createdById Int
|
createdById Int
|
||||||
|
|
||||||
deals Deal[]
|
deals Deal[]
|
||||||
createdBy User @relation(fields: [createdById], references: [id])
|
createdBy User @relation(fields: [createdById], references: [id])
|
||||||
domains SellerDomain[]
|
domains SellerDomain[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NEW: Category (self-parent tree)
|
||||||
|
* NOTE: You want Deal.categoryId default 0 -> you MUST create a Category row with id=0 ("Undefined")
|
||||||
|
*/
|
||||||
|
model Category {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
|
||||||
|
parentId Int?
|
||||||
|
parent Category? @relation("CategoryParent", fields: [parentId], references: [id])
|
||||||
|
children Category[] @relation("CategoryParent")
|
||||||
|
|
||||||
|
deals Deal[]
|
||||||
|
|
||||||
|
@@index([parentId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NEW: Tag (canonical)
|
||||||
|
*/
|
||||||
|
model Tag {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
slug String @unique
|
||||||
|
name String
|
||||||
|
|
||||||
|
dealTags DealTag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NEW: Join table Deal <-> Tag (many-to-many)
|
||||||
|
*/
|
||||||
|
model DealTag {
|
||||||
|
dealId Int
|
||||||
|
tagId Int
|
||||||
|
|
||||||
|
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||||
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@id([dealId, tagId])
|
||||||
|
@@index([tagId, dealId]) // tag -> deals hızlı
|
||||||
|
@@index([dealId]) // deal -> tags hızlı
|
||||||
|
}
|
||||||
|
|
||||||
model Deal {
|
model Deal {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
url String?
|
url String?
|
||||||
price Float?
|
price Float?
|
||||||
|
|
||||||
userId Int
|
userId Int
|
||||||
score Int @default(0)
|
score Int @default(0)
|
||||||
commentCount Int @default(0)
|
commentCount Int @default(0)
|
||||||
status DealStatus @default(PENDING)
|
status DealStatus @default(PENDING)
|
||||||
saletype SaleType @default(ONLINE)
|
saletype SaleType @default(ONLINE)
|
||||||
affiliateType AffiliateType @default(NON_AFFILIATE)
|
affiliateType AffiliateType @default(NON_AFFILIATE)
|
||||||
|
|
||||||
sellerId Int?
|
sellerId Int?
|
||||||
customSeller String?
|
customSeller String?
|
||||||
|
|
||||||
seller Seller? @relation(fields: [sellerId], references: [id])
|
seller Seller? @relation(fields: [sellerId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
votes DealVote[]
|
votes DealVote[]
|
||||||
voteHistory DealVoteHistory[]
|
voteHistory DealVoteHistory[]
|
||||||
comments Comment[]
|
notices DealNotice[] @relation("DealNotices")
|
||||||
images DealImage[] // ← yeni ilişki
|
comments Comment[]
|
||||||
|
images DealImage[]
|
||||||
|
|
||||||
|
// NEW: category (single)
|
||||||
|
categoryId Int @default(0)
|
||||||
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
|
|
||||||
|
// NEW: tags (multiple, optional)
|
||||||
|
dealTags DealTag[]
|
||||||
|
aiReview DealAiReview?
|
||||||
|
@@index([categoryId, createdAt])
|
||||||
|
@@index([userId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DealNoticeSeverity {
|
||||||
|
INFO
|
||||||
|
WARNING
|
||||||
|
DANGER
|
||||||
|
SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
model DealNotice {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
dealId Int
|
||||||
|
title String
|
||||||
|
body String?
|
||||||
|
|
||||||
|
severity DealNoticeSeverity @default(INFO)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
createdBy Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
deal Deal @relation("DealNotices", fields: [dealId], references: [id], onDelete: Cascade)
|
||||||
|
creator User @relation("UserDealNotices", fields: [createdBy], references: [id])
|
||||||
|
|
||||||
|
@@index([dealId, isActive, createdAt])
|
||||||
|
@@index([createdBy])
|
||||||
|
}
|
||||||
|
|
||||||
model DealImage {
|
model DealImage {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
imageUrl String @db.VarChar(512)
|
imageUrl String @db.VarChar(512)
|
||||||
|
|
@ -110,11 +228,11 @@ model DealImage {
|
||||||
}
|
}
|
||||||
|
|
||||||
model DealVote {
|
model DealVote {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
dealId Int
|
dealId Int
|
||||||
userId Int
|
userId Int
|
||||||
voteType Int @default(0) // -1,0,1
|
voteType Int @default(0) // -1,0,1
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
lastVotedAt DateTime @default(now()) // her vote değişiminde set edeceğiz
|
lastVotedAt DateTime @default(now()) // her vote değişiminde set edeceğiz
|
||||||
|
|
||||||
deal Deal @relation(fields: [dealId], references: [id])
|
deal Deal @relation(fields: [dealId], references: [id])
|
||||||
|
|
@ -139,14 +257,55 @@ model DealVoteHistory {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
text String
|
text String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
userId Int
|
userId Int
|
||||||
dealId Int
|
dealId Int
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
parentId Int?
|
||||||
deal Deal @relation(fields: [dealId], references: [id])
|
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
deal Deal @relation(fields: [dealId], references: [id])
|
||||||
|
|
||||||
|
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull)
|
||||||
|
replies Comment[] @relation("CommentReplies")
|
||||||
|
|
||||||
|
@@index([dealId, createdAt])
|
||||||
|
@@index([parentId, createdAt])
|
||||||
|
@@index([dealId, parentId, createdAt])
|
||||||
|
@@index([deletedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DealAiIssueType {
|
||||||
|
NONE
|
||||||
|
PROFANITY
|
||||||
|
PHONE_NUMBER
|
||||||
|
PERSONAL_DATA
|
||||||
|
SPAM
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
model DealAiReview {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
|
||||||
|
dealId Int @unique
|
||||||
|
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
bestCategoryId Int
|
||||||
|
needsReview Boolean @default(false)
|
||||||
|
|
||||||
|
hasIssue Boolean @default(false)
|
||||||
|
issueType DealAiIssueType @default(NONE)
|
||||||
|
issueReason String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([needsReview, hasIssue, updatedAt])
|
||||||
}
|
}
|
||||||
364
prisma/seed.js
364
prisma/seed.js
|
|
@ -1,51 +1,265 @@
|
||||||
const { PrismaClient, DealStatus, SaleType, AffiliateType } = require('@prisma/client')
|
// prisma/seed.js
|
||||||
const bcrypt = require("bcryptjs");
|
const { PrismaClient, DealStatus, SaleType, AffiliateType } = require("@prisma/client")
|
||||||
|
const bcrypt = require("bcryptjs")
|
||||||
|
const fs = require("fs")
|
||||||
|
const path = require("path")
|
||||||
|
|
||||||
const prisma = new PrismaClient()
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
function randInt(min, max) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stabil gerçek foto linkleri (redirect yok, hotlink derdi az)
|
||||||
|
function realImage(seed, w = 1200, h = 900) {
|
||||||
|
return `https://picsum.photos/seed/${encodeURIComponent(seed)}/${w}/${h}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Son N gün içinde random tarih
|
||||||
|
function randomDateWithinLastDays(days = 5) {
|
||||||
|
const now = Date.now()
|
||||||
|
const maxBack = days * 24 * 60 * 60 * 1000
|
||||||
|
const offset = randInt(0, maxBack)
|
||||||
|
return new Date(now - offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSlug(s) {
|
||||||
|
return String(s ?? "").trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertTagBySlug(slug, name) {
|
||||||
|
const s = normalizeSlug(slug)
|
||||||
|
return prisma.tag.upsert({
|
||||||
|
where: { slug: s },
|
||||||
|
update: { name },
|
||||||
|
create: { slug: s, name },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachTagsToDeal(dealId, tagSlugs) {
|
||||||
|
const unique = [...new Set((tagSlugs ?? []).map(normalizeSlug).filter(Boolean))]
|
||||||
|
if (!unique.length) return
|
||||||
|
|
||||||
|
const tags = await prisma.$transaction(
|
||||||
|
unique.map((slug) =>
|
||||||
|
prisma.tag.upsert({
|
||||||
|
where: { slug },
|
||||||
|
update: {},
|
||||||
|
create: { slug, name: slug },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await prisma.dealTag.createMany({
|
||||||
|
data: tags.map((t) => ({ dealId, tagId: t.id })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCategoriesJson(filePath) {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf-8")
|
||||||
|
const arr = JSON.parse(raw)
|
||||||
|
|
||||||
|
if (!Array.isArray(arr)) throw new Error("categories.json array olmalı")
|
||||||
|
|
||||||
|
const cats = arr.map((c) => ({
|
||||||
|
id: Number(c.id),
|
||||||
|
name: String(c.name ?? "").trim(),
|
||||||
|
slug: normalizeSlug(c.slug),
|
||||||
|
parentId: c.parentId === null || c.parentId === undefined ? null : Number(c.parentId),
|
||||||
|
}))
|
||||||
|
|
||||||
|
for (const c of cats) {
|
||||||
|
if (!Number.isInteger(c.id)) throw new Error(`Category id invalid: ${c.id}`)
|
||||||
|
if (!c.name) throw new Error(`Category name boş olamaz (id=${c.id})`)
|
||||||
|
if (!c.slug) throw new Error(`Category slug boş olamaz (id=${c.id})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const has0 = cats.some((c) => c.id === 0)
|
||||||
|
if (!has0) {
|
||||||
|
cats.unshift({ id: 0, name: "Undefined", slug: "undefined", parentId: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const idSet = new Set()
|
||||||
|
const slugSet = new Set()
|
||||||
|
for (const c of cats) {
|
||||||
|
if (idSet.has(c.id)) throw new Error(`categories.json duplicate id: ${c.id}`)
|
||||||
|
idSet.add(c.id)
|
||||||
|
if (slugSet.has(c.slug)) throw new Error(`categories.json duplicate slug: ${c.slug}`)
|
||||||
|
slugSet.add(c.slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cats
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedCategoriesFromJson(categoriesFilePath) {
|
||||||
|
const categories = loadCategoriesJson(categoriesFilePath)
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
categories.map((c) =>
|
||||||
|
prisma.category.upsert({
|
||||||
|
where: { id: c.id },
|
||||||
|
update: {
|
||||||
|
name: c.name,
|
||||||
|
slug: c.slug,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
slug: c.slug,
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{ timeout: 60_000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
categories.map((c) =>
|
||||||
|
prisma.category.update({
|
||||||
|
where: { id: c.id },
|
||||||
|
data: { parentId: c.parentId },
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{ timeout: 60_000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
SELECT setval(
|
||||||
|
pg_get_serial_sequence('"Category"', 'id'),
|
||||||
|
COALESCE((SELECT MAX(id) FROM "Category"), 0) + 1,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
return { count: categories.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30 deal seed + her deal'a 3 foto + score 0-200 + tarih dağılımı:
|
||||||
|
// - %70: son 5 gün
|
||||||
|
// - %30: 10 gün önce civarı (9-11 gün arası)
|
||||||
|
async function seedDeals30({ userId, sellerId, categoryId }) {
|
||||||
|
const baseItems = [
|
||||||
|
{ title: "Samsung 990 PRO 1TB NVMe SSD", price: 3299.99, url: "https://example.com/samsung-990pro-1tb", q: "nvme ssd" },
|
||||||
|
{ title: "Logitech MX Master 3S Mouse", price: 2499.9, url: "https://example.com/mx-master-3s", q: "wireless mouse" },
|
||||||
|
{ title: "Sony WH-1000XM5 Kulaklık", price: 9999.0, url: "https://example.com/sony-xm5", q: "headphones" },
|
||||||
|
{ title: "Apple AirPods Pro 2", price: 8499.0, url: "https://example.com/airpods-pro-2", q: "earbuds" },
|
||||||
|
{ title: "Anker 65W GaN Şarj Aleti", price: 899.0, url: "https://example.com/anker-65w-gan", q: "charger" },
|
||||||
|
{ title: "Kindle Paperwhite 16GB", price: 5199.0, url: "https://example.com/kindle-paperwhite", q: "ebook reader" },
|
||||||
|
{ title: 'Dell 27" 144Hz Monitör', price: 7999.0, url: "https://example.com/dell-27-144hz", q: "gaming monitor" },
|
||||||
|
{ title: "TP-Link Wi-Fi 6 Router", price: 1999.0, url: "https://example.com/tplink-wifi6", q: "wifi router" },
|
||||||
|
{ title: "Razer Huntsman Mini Klavye", price: 3499.0, url: "https://example.com/huntsman-mini", q: "mechanical keyboard" },
|
||||||
|
{ title: "WD Elements 2TB Harici Disk", price: 2399.0, url: "https://example.com/wd-elements-2tb", q: "external hard drive" },
|
||||||
|
{ title: "Samsung T7 Shield 1TB SSD", price: 2799.0, url: "https://example.com/samsung-t7-shield", q: "portable ssd" },
|
||||||
|
{ title: "Xiaomi Mi Band 8", price: 1399.0, url: "https://example.com/mi-band-8", q: "smart band" },
|
||||||
|
{ title: "Philips Airfryer 6.2L", price: 5999.0, url: "https://example.com/philips-airfryer", q: "air fryer" },
|
||||||
|
{ title: "Dyson V12 Detect Slim", price: 21999.0, url: "https://example.com/dyson-v12", q: "vacuum cleaner" },
|
||||||
|
{ title: "Nespresso Vertuo Kahve Makinesi", price: 6999.0, url: "https://example.com/nespresso-vertuo", q: "coffee machine" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 30'a tamamlamak için ikinci bir set üret (title/url benzersiz olsun)
|
||||||
|
const items = []
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const base = baseItems[i % baseItems.length]
|
||||||
|
const n = i + 1
|
||||||
|
items.push({
|
||||||
|
title: `${base.title} #${n}`,
|
||||||
|
price: Number((base.price * (0.9 + (randInt(0, 30) / 100))).toFixed(2)),
|
||||||
|
url: `${base.url}?seed=${n}`,
|
||||||
|
q: base.q,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const it = items[i]
|
||||||
|
|
||||||
|
// %30'u 9-11 gün önce, %70'i son 5 gün
|
||||||
|
const older = Math.random() < 0.3
|
||||||
|
const createdAt = older
|
||||||
|
? new Date(Date.now() - randInt(9, 11) * 24 * 60 * 60 * 1000 - randInt(0, 12) * 60 * 60 * 1000)
|
||||||
|
: randomDateWithinLastDays(5)
|
||||||
|
|
||||||
|
// Not: modelinde score yoksa score satırını sil
|
||||||
|
const dealData = {
|
||||||
|
title: it.title,
|
||||||
|
description: "Seed test deal açıklaması (otomatik üretim).",
|
||||||
|
url: it.url,
|
||||||
|
price: it.price,
|
||||||
|
status: DealStatus.ACTIVE,
|
||||||
|
saletype: SaleType.ONLINE,
|
||||||
|
affiliateType: AffiliateType.NON_AFFILIATE,
|
||||||
|
commentCount: randInt(0, 25),
|
||||||
|
userId,
|
||||||
|
sellerId,
|
||||||
|
categoryId,
|
||||||
|
score: randInt(0, 200),
|
||||||
|
createdAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.deal.findFirst({
|
||||||
|
where: { url: it.url },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const deal = existing
|
||||||
|
? await prisma.deal.update({ where: { id: existing.id }, data: dealData })
|
||||||
|
: await prisma.deal.create({ data: dealData })
|
||||||
|
|
||||||
|
await prisma.dealImage.deleteMany({ where: { dealId: deal.id } })
|
||||||
|
await prisma.dealImage.createMany({
|
||||||
|
data: [
|
||||||
|
{ dealId: deal.id, imageUrl: realImage(`${it.q}-${i}-1`), order: 0 },
|
||||||
|
{ dealId: deal.id, imageUrl: realImage(`${it.q}-${i}-2`), order: 1 },
|
||||||
|
{ dealId: deal.id, imageUrl: realImage(`${it.q}-${i}-3`), order: 2 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const password = 'test'
|
const hashedPassword = "$2b$10$PVfLq2NmcGmKbhE5VK3yNeVj46O/1w2p/2BNu4h1CYacqSgkCcoCW"
|
||||||
const hashedPassword = await bcrypt.hash(password, 10)
|
|
||||||
|
|
||||||
// ---------- USERS ----------
|
// ---------- USERS ----------
|
||||||
const admin = await prisma.user.upsert({
|
const admin = await prisma.user.upsert({
|
||||||
where: { email: 'test' },
|
where: { email: "test" },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
username: 'test',
|
username: "test",
|
||||||
email: 'test',
|
email: "test",
|
||||||
passwordHash: hashedPassword,
|
passwordHash: hashedPassword,
|
||||||
|
role: "ADMIN",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
where: { email: 'test2' },
|
where: { email: "test2" },
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
username: 'test2',
|
username: "test2",
|
||||||
email: 'test2',
|
email: "test2",
|
||||||
passwordHash: hashedPassword,
|
passwordHash: hashedPassword,
|
||||||
|
role: "USER",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------- Seller ----------
|
// ---------- SELLER ----------
|
||||||
const amazon = await prisma.seller.upsert({
|
const amazon = await prisma.seller.upsert({
|
||||||
where: { name: 'Amazon' },
|
where: { name: "Amazon" },
|
||||||
update: {},
|
update: { isActive: true },
|
||||||
create: {
|
create: {
|
||||||
name: 'Amazon',
|
name: "Amazon",
|
||||||
|
url: "https://www.amazon.com.tr",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
createdById: admin.id,
|
createdById: admin.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------- Seller DOMAINS ----------
|
// ---------- SELLER DOMAINS ----------
|
||||||
const domains = ['amazon.com', 'amazon.com.tr']
|
const domains = ["amazon.com", "amazon.com.tr"]
|
||||||
|
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
await prisma.SellerDomain.upsert({
|
await prisma.sellerDomain.upsert({
|
||||||
where: { domain },
|
where: { domain },
|
||||||
update: {},
|
update: { sellerId: amazon.id },
|
||||||
create: {
|
create: {
|
||||||
domain,
|
domain,
|
||||||
sellerId: amazon.id,
|
sellerId: amazon.id,
|
||||||
|
|
@ -54,68 +268,90 @@ async function main() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- DEAL ----------
|
// ---------- CATEGORIES (FROM JSON) ----------
|
||||||
const deal = await prisma.deal.create({
|
const categoriesFilePath = path.join(__dirname, "", "categories.json")
|
||||||
data: {
|
const { count } = await seedCategoriesFromJson(categoriesFilePath)
|
||||||
title: 'Samsung SSD 1TB',
|
|
||||||
description: 'Test deal açıklaması',
|
const catSSD = await prisma.category.findUnique({
|
||||||
url: 'https://www.amazon.com.tr/dp/test',
|
where: { slug: "pc-ssd" },
|
||||||
price: 1299.99,
|
select: { id: true },
|
||||||
status: DealStatus.ACTIVE,
|
|
||||||
saletype: SaleType.ONLINE,
|
|
||||||
affiliateType: AffiliateType.NON_AFFILIATE,
|
|
||||||
commentCount:1,
|
|
||||||
userId: user.id,
|
|
||||||
sellerId: amazon.id,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------- DEAL IMAGES ----------
|
// ---------- TAGS ----------
|
||||||
|
await upsertTagBySlug("ssd", "SSD")
|
||||||
|
await upsertTagBySlug("nvme", "NVMe")
|
||||||
|
await upsertTagBySlug("1tb", "1TB")
|
||||||
|
|
||||||
|
// ---------- DEAL (tek örnek) ----------
|
||||||
|
const dealUrl = "https://www.amazon.com.tr/dp/test"
|
||||||
|
const existing = await prisma.deal.findFirst({
|
||||||
|
where: { url: dealUrl },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const dealData = {
|
||||||
|
title: "Samsung SSD 1TB",
|
||||||
|
description: "Test deal açıklaması",
|
||||||
|
url: dealUrl,
|
||||||
|
price: 1299.99,
|
||||||
|
status: DealStatus.ACTIVE,
|
||||||
|
saletype: SaleType.ONLINE,
|
||||||
|
affiliateType: AffiliateType.NON_AFFILIATE,
|
||||||
|
commentCount: 1,
|
||||||
|
userId: user.id,
|
||||||
|
sellerId: amazon.id,
|
||||||
|
categoryId: catSSD?.id ?? 0,
|
||||||
|
// score: randInt(0, 200), // modelinde varsa aç
|
||||||
|
}
|
||||||
|
|
||||||
|
const deal = existing
|
||||||
|
? await prisma.deal.update({ where: { id: existing.id }, data: dealData })
|
||||||
|
: await prisma.deal.create({ data: dealData })
|
||||||
|
|
||||||
|
// ---------- DEAL TAGS ----------
|
||||||
|
await attachTagsToDeal(deal.id, ["ssd", "nvme", "1tb"])
|
||||||
|
|
||||||
|
// ---------- DEAL IMAGES (tek örnek) ----------
|
||||||
|
await prisma.dealImage.deleteMany({ where: { dealId: deal.id } })
|
||||||
await prisma.dealImage.createMany({
|
await prisma.dealImage.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{ dealId: deal.id, imageUrl: realImage("nvme-ssd-single-1"), order: 0 },
|
||||||
dealId: deal.id,
|
{ dealId: deal.id, imageUrl: realImage("nvme-ssd-single-2"), order: 1 },
|
||||||
imageUrl: 'https://placehold.co/600x400',
|
{ dealId: deal.id, imageUrl: realImage("nvme-ssd-single-3"), order: 2 },
|
||||||
order: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dealId: deal.id,
|
|
||||||
imageUrl: 'https://placehold.co/600x401',
|
|
||||||
order: 1,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ✅ ---------- 30 DEAL ÜRET ----------
|
||||||
|
await seedDeals30({
|
||||||
|
userId: user.id,
|
||||||
|
sellerId: amazon.id,
|
||||||
|
categoryId: catSSD?.id ?? 0,
|
||||||
|
})
|
||||||
|
|
||||||
// ---------- VOTE ----------
|
// ---------- VOTE ----------
|
||||||
await prisma.dealVote.upsert({
|
await prisma.dealVote.upsert({
|
||||||
where: {
|
where: { dealId_userId: { dealId: deal.id, userId: admin.id } },
|
||||||
dealId_userId: {
|
update: { voteType: 1, lastVotedAt: new Date() },
|
||||||
dealId: deal.id,
|
create: { dealId: deal.id, userId: admin.id, voteType: 1 },
|
||||||
userId: admin.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
dealId: deal.id,
|
|
||||||
userId: admin.id,
|
|
||||||
voteType: 1,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------- COMMENT ----------
|
// ---------- COMMENT ----------
|
||||||
await prisma.comment.create({
|
const hasComment = await prisma.comment.findFirst({
|
||||||
data: {
|
where: { dealId: deal.id, userId: admin.id, text: "Gerçekten iyi fırsat" },
|
||||||
text: 'Gerçekten iyi fırsat',
|
select: { id: true },
|
||||||
userId: admin.id,
|
|
||||||
dealId: deal.id,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
if (!hasComment) {
|
||||||
|
await prisma.comment.create({
|
||||||
|
data: { text: "Gerçekten iyi fırsat", userId: admin.id, dealId: deal.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
console.log('✅ Seed tamamlandı')
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
.catch(err => {
|
.catch((err) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,30 @@
|
||||||
const express = require("express")
|
const express = require("express")
|
||||||
const multer = require("multer")
|
const multer = require("multer")
|
||||||
const fs = require("fs")
|
const requireAuth = require("../middleware/requireAuth.js")
|
||||||
const { uploadProfileImage } = require("../services/supabaseUpload.service")
|
|
||||||
const { validateImage } = require("../utils/validateImage")
|
|
||||||
const authRequiredMiddleware = require("../middleware/authRequired.middleware")
|
|
||||||
const authOptionalMiddleware = require("../middleware/authOptional.middleware")
|
|
||||||
const { getUserProfile } = require("../services/profile.service")
|
const { getUserProfile } = require("../services/profile.service")
|
||||||
|
const { endpoints } = require("@shared/contracts")
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const upload = multer({ dest: "uploads/" })
|
const upload = multer({ dest: "uploads/" })
|
||||||
|
|
||||||
const { updateUserAvatar } = require("../services/avatar.service")
|
const { updateUserAvatar } = require("../services/avatar.service")
|
||||||
|
|
||||||
|
const { account } = endpoints
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/avatar",
|
"/avatar",
|
||||||
authRequiredMiddleware
|
requireAuth,
|
||||||
,
|
|
||||||
upload.single("file"),
|
upload.single("file"),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const updatedUser = await updateUserAvatar(
|
const updatedUser = await updateUserAvatar(req.auth.userId, req.file)
|
||||||
req.user.userId,
|
|
||||||
req.file
|
|
||||||
)
|
|
||||||
|
|
||||||
res.json({
|
res.json(
|
||||||
message: "Avatar updated",
|
account.avatarUploadResponseSchema.parse({
|
||||||
user: updatedUser,
|
message: "Avatar updated",
|
||||||
})
|
user: updatedUser,
|
||||||
|
})
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
res.status(400).json({ error: err.message })
|
res.status(400).json({ error: err.message })
|
||||||
|
|
@ -35,12 +32,10 @@ router.post(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
router.get("/me", requireAuth, async (req, res) => {
|
||||||
router.get("/me", authRequiredMiddleware
|
|
||||||
, async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const user = await getUserProfile(req.user.id)
|
const user = await getUserProfile(req.auth.userId)
|
||||||
res.json(user)
|
res.json(account.accountMeResponseSchema.parse(user))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message })
|
res.status(400).json({ error: err.message })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,162 @@
|
||||||
const express = require("express");
|
// routes/auth.js
|
||||||
const authRequiredMiddleware
|
const express = require("express")
|
||||||
= require("../middleware/authRequired.middleware");
|
const router = express.Router()
|
||||||
const authService=require("../services/auth.service")
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
const {
|
const requireAuth = require("../middleware/requireAuth.js")
|
||||||
mapLoginRequestToLoginInput,
|
const { validate } = require("../middleware/validate.middleware")
|
||||||
mapLoginResultToResponse,
|
const authService = require("../services/auth.service")
|
||||||
} = require("../adapters/responses/login.adapter");
|
const { endpoints } = require("@shared/contracts")
|
||||||
const {
|
|
||||||
mapRegisterRequestToRegisterInput,
|
|
||||||
mapRegisterResultToResponse,
|
|
||||||
} = require("../adapters/responses/register.adapter");
|
|
||||||
const {
|
|
||||||
mapMeRequestToUserId,
|
|
||||||
mapMeResultToResponse,
|
|
||||||
} = require("../adapters/responses/me.adapter");
|
|
||||||
|
|
||||||
|
const { mapLoginRequestToLoginInput, mapLoginResultToResponse } = require("../adapters/responses/login.adapter")
|
||||||
|
const { mapRegisterRequestToRegisterInput, mapRegisterResultToResponse } = require("../adapters/responses/register.adapter")
|
||||||
|
const { mapMeRequestToUserId, mapMeResultToResponse } = require("../adapters/responses/me.adapter")
|
||||||
|
|
||||||
router.post("/register", async (req, res) => {
|
const { auth } = endpoints
|
||||||
try {
|
|
||||||
const input = mapRegisterRequestToRegisterInput(req.body);
|
// NOT: app.js’de cookie-parser olmalı:
|
||||||
const result = await authService.register(input);
|
// const cookieParser = require("cookie-parser")
|
||||||
res.json(mapRegisterResultToResponse(result));
|
// app.use(cookieParser())
|
||||||
} catch (err) {
|
|
||||||
const status = err.statusCode || 500;
|
function getCookieOptions() {
|
||||||
res.status(status).json({
|
const isProd = process.env.NODE_ENV === "production"
|
||||||
message: err.message || "Kayıt işlemi başarısız.",
|
|
||||||
});
|
// DEV: http localhost -> secure false, sameSite lax
|
||||||
|
if (!isProd) {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
|
// PROD: cross-site kullanacaksan (frontend ayrı domain)
|
||||||
router.post("/login", async (req, res) => {
|
return {
|
||||||
try {
|
httpOnly: true,
|
||||||
const input = mapLoginRequestToLoginInput(req.body);
|
secure: true,
|
||||||
const result = await authService.login(input);
|
sameSite: "none",
|
||||||
res.json(mapLoginResultToResponse(result));
|
path: "/",
|
||||||
} catch (err) {
|
|
||||||
const status = err.statusCode || 500;
|
|
||||||
res.status(status).json({ message: err.message || "Giriş işlemi başarısız." });
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function setRefreshCookie(res, refreshToken) {
|
||||||
|
const opts = getCookieOptions()
|
||||||
|
const maxAgeMs = Number(process.env.REFRESH_COOKIE_MAX_AGE_MS || 1000 * 60 * 60 * 24 * 30)
|
||||||
|
res.cookie("rt", refreshToken, { ...opts, maxAge: maxAgeMs })
|
||||||
|
}
|
||||||
|
|
||||||
router.get("/me", authRequiredMiddleware
|
function clearRefreshCookie(res) {
|
||||||
, async (req, res) => {
|
const opts = getCookieOptions()
|
||||||
try {
|
res.clearCookie("rt", { ...opts })
|
||||||
const userId = mapMeRequestToUserId(req);
|
}
|
||||||
const user = await authService.getMe(userId);
|
|
||||||
res.json(mapMeResultToResponse(user));
|
router.post(
|
||||||
} catch (err) {
|
"/register",
|
||||||
const status = err.statusCode || 500;
|
validate(auth.registerRequestSchema, "body", "validatedRegisterInput"),
|
||||||
res.status(status).json({
|
async (req, res) => {
|
||||||
message: err.message || "Sunucu hatası",
|
try {
|
||||||
});
|
const input = mapRegisterRequestToRegisterInput(req.validatedRegisterInput)
|
||||||
|
|
||||||
|
const result = await authService.register({
|
||||||
|
...input,
|
||||||
|
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
|
||||||
|
})
|
||||||
|
|
||||||
|
// refresh cookie set
|
||||||
|
if (result.refreshToken) setRefreshCookie(res, result.refreshToken)
|
||||||
|
|
||||||
|
// response body: access + user (adapter refresh'i koymamalı)
|
||||||
|
const response = auth.authResponseSchema.parse(mapRegisterResultToResponse(result))
|
||||||
|
res.json(response)
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.statusCode || 500
|
||||||
|
res.status(status).json({ message: err.message || "Kayit islemi basarisiz." })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
)
|
||||||
module.exports = router;
|
|
||||||
|
router.post(
|
||||||
|
"/login",
|
||||||
|
validate(auth.loginRequestSchema, "body", "validatedLoginInput"),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const input = mapLoginRequestToLoginInput(req.validatedLoginInput)
|
||||||
|
|
||||||
|
const result = await authService.login({
|
||||||
|
...input,
|
||||||
|
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
|
||||||
|
})
|
||||||
|
|
||||||
|
// refresh cookie set
|
||||||
|
setRefreshCookie(res, result.refreshToken)
|
||||||
|
|
||||||
|
const response = auth.authResponseSchema.parse(mapLoginResultToResponse(result))
|
||||||
|
res.json(response)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("LOGIN ERROR:", err) // <-- ekle
|
||||||
|
console.error("LOGIN ERROR MSG:", err?.message)
|
||||||
|
console.error("LOGIN ERROR STACK:", err?.stack)
|
||||||
|
|
||||||
|
const status = err.statusCode || 500
|
||||||
|
res.status(status).json({
|
||||||
|
message: err.statusCode ? err.message : "Giris islemi basarisiz.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post("/refresh", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const refreshToken = req.cookies?.rt
|
||||||
|
if (!refreshToken) return res.status(401).json({ message: "Refresh token yok" })
|
||||||
|
|
||||||
|
const result = await authService.refresh({
|
||||||
|
refreshToken,
|
||||||
|
meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null },
|
||||||
|
})
|
||||||
|
|
||||||
|
// rotate -> yeni refresh cookie
|
||||||
|
setRefreshCookie(res, result.refreshToken)
|
||||||
|
|
||||||
|
// body: access + user (adapter refresh'i koymamalı)
|
||||||
|
const response = auth.authResponseSchema.parse(mapLoginResultToResponse(result))
|
||||||
|
res.json(response)
|
||||||
|
} catch (err) {
|
||||||
|
clearRefreshCookie(res)
|
||||||
|
const status = err.statusCode || 401
|
||||||
|
res.status(status).json({ message: err.message || "Refresh basarisiz" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post("/logout", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const refreshToken = req.cookies?.rt
|
||||||
|
|
||||||
|
// logout idempotent olsun
|
||||||
|
if (refreshToken) {
|
||||||
|
await authService.logout({ refreshToken })
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRefreshCookie(res)
|
||||||
|
res.status(204).send()
|
||||||
|
} catch (err) {
|
||||||
|
clearRefreshCookie(res)
|
||||||
|
const status = err.statusCode || 500
|
||||||
|
res.status(status).json({ message: err.message || "Cikis basarisiz" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get("/me", requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = mapMeRequestToUserId(req) // req.auth.userId okumalı
|
||||||
|
const user = await authService.getMe(userId)
|
||||||
|
const response = auth.meResponseSchema.parse(mapMeResultToResponse(user))
|
||||||
|
res.json(response)
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.statusCode || 500
|
||||||
|
res.status(status).json({ message: err.message || "Sunucu hatasi" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
|
|
|
||||||
49
routes/category.routes.js
Normal file
49
routes/category.routes.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
const express = require("express");
|
||||||
|
const categoryService = require("../services/category.service"); // Kategori servisi
|
||||||
|
const router = express.Router();
|
||||||
|
const { mapCategoryToCategoryDetailsResponse }=require("../adapters/responses/categoryDetails.adapter")
|
||||||
|
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter")
|
||||||
|
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
|
||||||
|
|
||||||
|
|
||||||
|
router.get("/:slug", async (req, res) => {
|
||||||
|
const { slug } = req.params; // URL parametresinden slug alıyoruz
|
||||||
|
try {
|
||||||
|
|
||||||
|
const { category, breadcrumb } = await categoryService.findCategoryBySlug(slug); // Servisten kategori ve breadcrumb bilgilerini alıyoruz
|
||||||
|
if (!category) {
|
||||||
|
return res.status(404).json({ error: "Kategori bulunamadı" });
|
||||||
|
}
|
||||||
|
const response = mapCategoryToCategoryDetailsResponse(category, breadcrumb); // Adapter ile veriyi dönüştürüyoruz
|
||||||
|
res.json(response);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: "Kategori detayları alınırken bir hata oluştu", message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
router.get("/:slug/deals", async (req, res) => {
|
||||||
|
const { slug } = req.params;
|
||||||
|
const { page = 1, limit = 10, ...filters } = req.query;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { category } = await categoryService.findCategoryBySlug(slug);
|
||||||
|
if (!category) {
|
||||||
|
return res.status(404).json({ error: "Kategori bulunamadı" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kategorinin fırsatlarını alıyoruz
|
||||||
|
const deals = await categoryService.getDealsByCategoryId(category.id, page, limit, filters);
|
||||||
|
|
||||||
|
const response = mapPaginatedDealsToDealCardResponse(payload)
|
||||||
|
|
||||||
|
|
||||||
|
// frontend DealCard[] bekliyor
|
||||||
|
res.json(response.results)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: "Kategoriye ait fırsatlar alınırken bir hata oluştu", message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -1,54 +1,62 @@
|
||||||
const express = require("express")
|
const express = require("express")
|
||||||
const authRequiredMiddleware = require("../middleware/authRequired.middleware")
|
const requireAuth = require("../middleware/requireAuth.js")
|
||||||
const authOptionalMiddleware = require("../middleware/authOptional.middleware")
|
const { validate } = require("../middleware/validate.middleware")
|
||||||
const {
|
const { endpoints } = require("@shared/contracts")
|
||||||
getCommentsByDealId,
|
const { createComment, deleteComment } = require("../services/comment.service")
|
||||||
createComment,
|
|
||||||
deleteComment,
|
|
||||||
} = require("../services/comment.service")
|
|
||||||
|
|
||||||
|
const dealCommentAdapter = require("../adapters/responses/comment.adapter")
|
||||||
const dealCommentAdapter=require("../adapters/responses/comment.adapter")
|
const commentService = require("../services/comment.service")
|
||||||
const commentService=require("../services/comment.service")
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
router.get("/:dealId", async (req, res) => {
|
const { comments } = endpoints
|
||||||
try {
|
|
||||||
const dealId = Number(req.params.dealId)
|
|
||||||
const comments = await commentService.getCommentsByDealId(dealId)
|
|
||||||
res.json(dealCommentAdapter.mapCommentsToDealCommentResponse(comments))
|
|
||||||
|
|
||||||
} catch (err) {
|
router.get(
|
||||||
console.log(err.message)
|
"/:dealId",
|
||||||
res.status(400).json({ error: err.message })
|
validate(comments.commentListRequestSchema, "params", "validatedDealId"),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { dealId } = req.validatedDealId
|
||||||
|
const fetched = await commentService.getCommentsByDealId(dealId)
|
||||||
|
const mapped = dealCommentAdapter.mapCommentsToDealCommentResponse(fetched)
|
||||||
|
res.json(comments.commentListResponseSchema.parse(mapped))
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err.message })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
router.post("/", authRequiredMiddleware
|
router.post(
|
||||||
, async (req, res) => {
|
"/",
|
||||||
try {
|
requireAuth,
|
||||||
const { dealId, text } = req.body
|
validate(comments.commentCreateRequestSchema, "body", "validatedCommentPayload"),
|
||||||
const userId = req.user.userId
|
async (req, res) => {
|
||||||
if (!text?.trim()) return res.status(400).json({ error: "Yorum boş olamaz." })
|
try {
|
||||||
|
const { dealId, text, parentId } = req.validatedCommentPayload
|
||||||
|
const userId = req.auth.userId
|
||||||
|
|
||||||
const comment = await createComment({ dealId, userId, text })
|
const comment = await createComment({ dealId, userId, text, parentId })
|
||||||
res.json(comment)
|
const mapped = dealCommentAdapter.mapCommentToDealCommentResponse(comment)
|
||||||
} catch (err) {
|
res.json(comments.commentCreateResponseSchema.parse(mapped))
|
||||||
console.error(err)
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message || "Sunucu hatası" })
|
res.status(500).json({ error: err.message || "Sunucu hatasi" })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
router.delete("/:id", authRequiredMiddleware
|
router.delete(
|
||||||
, async (req, res) => {
|
"/:id",
|
||||||
try {
|
requireAuth,
|
||||||
const result = await deleteComment(req.params.id, req.user.userId)
|
validate(comments.commentDeleteRequestSchema, "params", "validatedDeleteComment"),
|
||||||
res.json(result)
|
async (req, res) => {
|
||||||
} catch (err) {
|
try {
|
||||||
console.error(err)
|
const { id } = req.validatedDeleteComment
|
||||||
const status = err.message.includes("yetkin") ? 403 : 404
|
const result = await deleteComment(id, req.auth.userId)
|
||||||
res.status(status).json({ error: err.message })
|
res.json(comments.commentDeleteResponseSchema.parse(result))
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.message?.includes("yetkin") ? 403 : 404
|
||||||
|
res.status(status).json({ error: err.message })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,219 @@
|
||||||
|
// routes/deals.js
|
||||||
const express = require("express")
|
const express = require("express")
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const { getDeals, getDealById, createDeal,searchDeals } = require("../services/deal.service")
|
|
||||||
const authRequiredMiddleware = require("../middleware/authRequired.middleware")
|
|
||||||
const authOptionalMiddleware = require("../middleware/authOptional.middleware")
|
|
||||||
const { upload } = require("../middleware/upload.middleware");
|
|
||||||
|
|
||||||
|
const requireAuth = require("../middleware/requireAuth")
|
||||||
|
const optionalAuth = require("../middleware/optionalAuth")
|
||||||
|
const { upload } = require("../middleware/upload.middleware")
|
||||||
|
const { validate } = require("../middleware/validate.middleware")
|
||||||
|
const { endpoints } = require("@shared/contracts")
|
||||||
|
|
||||||
const {mapCreateDealRequestToDealCreateData} =require("../adapters/requests/dealCreate.adapter")
|
const userDB = require("../db/user.db")
|
||||||
|
const { getDeals, getDealById, createDeal } = require("../services/deal.service")
|
||||||
|
|
||||||
|
const { mapCreateDealRequestToDealCreateData } = require("../adapters/requests/dealCreate.adapter")
|
||||||
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter")
|
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter")
|
||||||
const { mapDealToDealCardResponse,mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
|
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
|
||||||
|
|
||||||
|
const { deals, users } = endpoints
|
||||||
|
|
||||||
router.get("/", authOptionalMiddleware, async (req, res) => {
|
const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery")
|
||||||
try {
|
|
||||||
const q = (req.query.q ?? "").toString().trim()
|
|
||||||
const page = Number(req.query.page) || 1
|
|
||||||
const limit = Number(req.query.limit) || 10
|
|
||||||
const userId = req.user?.userId ?? null
|
|
||||||
const data = await getDeals({ q, page, limit, userId })
|
|
||||||
|
|
||||||
res.json(mapPaginatedDealsToDealCardResponse(data))
|
const buildViewer = (req) =>
|
||||||
} catch (e) {
|
req.auth ? { userId: req.auth.userId, role: req.auth.role } : null
|
||||||
console.error(e)
|
|
||||||
res.status(500).json({ error: "Sunucu hatası" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
function createListHandler(preset) {
|
||||||
router.get("/search", async (req, res) => {
|
return async (req, res) => {
|
||||||
try {
|
|
||||||
const query = req.query.q || ""
|
|
||||||
const page = Number(req.query.page) || 1
|
|
||||||
const limit = 10
|
|
||||||
|
|
||||||
if (!query.trim()) {
|
|
||||||
return res.json({ results: [], total: 0, totalPages: 0, page })
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await searchDeals(query, page, limit)
|
|
||||||
res.json(mapPaginatedDealsToDealCardResponse(data))
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
res.status(500).json({ error: "Sunucu hatası" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
router.get("/:id", async (req, res) => { //MAPPED
|
|
||||||
try {
|
|
||||||
const deal = await getDealById(req.params.id)
|
|
||||||
if (!deal) return res.status(404).json({ error: "Deal bulunamadı" })
|
|
||||||
console.log(mapDealToDealDetailResponse(deal))
|
|
||||||
res.json(mapDealToDealDetailResponse(deal))
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
res.status(500).json({ error: "Sunucu hatası" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
router.post( "/", authRequiredMiddleware, upload.array("images", 5), async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const userId = req.user.userId;
|
const viewer = buildViewer(req)
|
||||||
const dealCreateData = mapCreateDealRequestToDealCreateData(req.body, userId);
|
const { q, page, limit } = req.validatedDealListQuery
|
||||||
const deal = await createDeal(dealCreateData, req.files || []);
|
|
||||||
res.json(deal);
|
const payload = await getDeals({
|
||||||
|
preset,
|
||||||
|
q,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
viewer,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = deals.dealsListResponseSchema.parse(
|
||||||
|
mapPaginatedDealsToDealCardResponse(payload)
|
||||||
|
)
|
||||||
|
res.json(response)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err)
|
||||||
res.status(500).json({ error: "Sunucu hatası" });
|
const status = err.statusCode || 500
|
||||||
|
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
|
// Public deals of a user (viewer optional; self profile => "MY" else "USER_PUBLIC")
|
||||||
|
router.get(
|
||||||
|
"/users/:userName/deals",
|
||||||
|
optionalAuth,
|
||||||
|
validate(users.userProfileRequestSchema, "params", "validatedUserProfile"),
|
||||||
|
listQueryValidator,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userName } = req.validatedUserProfile
|
||||||
|
const targetUser = await userDB.findUser(
|
||||||
|
{ username: userName },
|
||||||
|
{ select: { id: true } }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!targetUser) return res.status(404).json({ error: "Kullanici bulunamadi" })
|
||||||
|
|
||||||
|
const { q, page, limit } = req.validatedDealListQuery
|
||||||
|
const viewer = buildViewer(req)
|
||||||
|
const isSelfProfile = viewer?.userId === targetUser.id
|
||||||
|
const preset = isSelfProfile ? "MY" : "USER_PUBLIC"
|
||||||
|
|
||||||
|
const payload = await getDeals({
|
||||||
|
preset,
|
||||||
|
q,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
targetUserId: targetUser.id,
|
||||||
|
viewer,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = deals.dealsListResponseSchema.parse(
|
||||||
|
mapPaginatedDealsToDealCardResponse(payload)
|
||||||
|
)
|
||||||
|
res.json(response)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
const status = err.statusCode || 500
|
||||||
|
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// My deals (auth required)
|
||||||
|
router.get(
|
||||||
|
"/me/deals",
|
||||||
|
requireAuth,
|
||||||
|
listQueryValidator,
|
||||||
|
createListHandler("MY")
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get("/new", optionalAuth, listQueryValidator, createListHandler("NEW"))
|
||||||
|
router.get("/hot", optionalAuth, listQueryValidator, createListHandler("HOT"))
|
||||||
|
router.get("/trending", optionalAuth, listQueryValidator, createListHandler("TRENDING"))
|
||||||
|
router.get("/", optionalAuth, listQueryValidator, createListHandler("NEW"))
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/search",
|
||||||
|
optionalAuth,
|
||||||
|
listQueryValidator,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { q, page, limit } = req.validatedDealListQuery
|
||||||
|
if (!q || !q.trim()) {
|
||||||
|
return res.json({ results: [], total: 0, totalPages: 0, page })
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await getDeals({
|
||||||
|
preset: "NEW",
|
||||||
|
q,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
viewer: buildViewer(req),
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = deals.dealsListResponseSchema.parse(
|
||||||
|
mapPaginatedDealsToDealCardResponse(payload)
|
||||||
|
)
|
||||||
|
res.json(response)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
const status = err.statusCode || 500
|
||||||
|
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// TOP deals (daily/weekly/monthly) - viewer optional
|
||||||
|
router.get("/top", optionalAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const viewer = buildViewer(req)
|
||||||
|
|
||||||
|
const range = String(req.query.range || "day")
|
||||||
|
const limitRaw = Number(req.query.limit ?? 6)
|
||||||
|
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(20, limitRaw)) : 6
|
||||||
|
|
||||||
|
let preset = "HOT_DAY"
|
||||||
|
if (range === "week") preset = "HOT_WEEK"
|
||||||
|
else if (range === "month") preset = "HOT_MONTH"
|
||||||
|
else if (range !== "day") return res.status(400).json({ error: "range invalid" })
|
||||||
|
|
||||||
|
const payload = await getDeals({
|
||||||
|
preset,
|
||||||
|
q: null,
|
||||||
|
page: 1,
|
||||||
|
limit,
|
||||||
|
viewer,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = deals.dealsListResponseSchema.parse(
|
||||||
|
mapPaginatedDealsToDealCardResponse(payload)
|
||||||
|
)
|
||||||
|
|
||||||
|
// frontend DealCard[] bekliyor
|
||||||
|
res.json(response.results)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
const status = err.statusCode || 500
|
||||||
|
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:id",
|
||||||
|
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const { id } = req.validatedDealId
|
||||||
|
const deal = await getDealById(id)
|
||||||
|
if (!deal) return res.status(404).json({ error: "Deal bulunamadi" })
|
||||||
|
|
||||||
|
const mapped = mapDealToDealDetailResponse(deal)
|
||||||
|
|
||||||
|
console.log(mapped)
|
||||||
|
res.json(deals.dealDetailResponseSchema.parse(mapped))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ error: "Sunucu hatasi" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create deal (auth required)
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
requireAuth,
|
||||||
|
upload.array("images", 5),
|
||||||
|
validate(deals.dealCreateRequestSchema, "body", "validatedDealPayload"),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.auth.userId
|
||||||
|
const dealCreateData = mapCreateDealRequestToDealCreateData(
|
||||||
|
req.validatedDealPayload,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
|
||||||
|
const deal = await createDeal(dealCreateData, req.files || [])
|
||||||
|
const mapped = mapDealToDealDetailResponse(deal)
|
||||||
|
res.json(deals.dealCreateResponseSchema.parse(mapped))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ error: "Sunucu hatasi" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,34 @@
|
||||||
const express = require("express")
|
const express = require("express")
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const authRequiredMiddleware = require("../middleware/authRequired.middleware")
|
|
||||||
const authOptionalMiddleware = require("../middleware/authOptional.middleware")
|
const requireAuth = require("../middleware/requireAuth")
|
||||||
|
const { endpoints } = require("@shared/contracts")
|
||||||
const { findSellerFromLink } = require("../services/seller.service")
|
const { findSellerFromLink } = require("../services/seller.service")
|
||||||
|
|
||||||
|
const { seller } = endpoints
|
||||||
|
|
||||||
router.post("/from-link", authRequiredMiddleware
|
router.post("/from-link", requireAuth, async (req, res) => {
|
||||||
, async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const sellerUrl = req.body.url
|
const sellerUrl = req.body.url
|
||||||
const Seller = await findSellerFromLink(sellerUrl)
|
const sellerLookup = await findSellerFromLink(sellerUrl)
|
||||||
|
|
||||||
if (!Seller) {
|
const response = seller.sellerLookupResponseSchema.parse(
|
||||||
return res.json({
|
sellerLookup
|
||||||
sellerId: -1,
|
? {
|
||||||
sellerName: null,
|
found: true,
|
||||||
})
|
seller: {
|
||||||
}
|
id: sellerLookup.id,
|
||||||
return res.json({
|
name: sellerLookup.name,
|
||||||
id: Seller.id,
|
url: sellerLookup.url ?? null,
|
||||||
name: Seller.name,
|
},
|
||||||
})
|
}
|
||||||
|
: { found: false, seller: null }
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json(response)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
res.status(500).json({ error: "Sunucu hatası" })
|
res.status(500).json({ error: "Sunucu hatasi" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,33 @@
|
||||||
// routes/user.js
|
// routes/user.js
|
||||||
const express = require("express")
|
const express = require("express")
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
const { validate } = require("../middleware/validate.middleware")
|
||||||
const userService = require("../services/user.service")
|
const userService = require("../services/user.service")
|
||||||
const userProfileAdapter = require("../adapters/responses/userProfile.adapter")
|
const userProfileAdapter = require("../adapters/responses/userProfile.adapter")
|
||||||
|
const { endpoints } = require("@shared/contracts")
|
||||||
|
|
||||||
router.get("/:userName", async (req, res) => {
|
const { users } = endpoints
|
||||||
try {
|
|
||||||
const data = await userService.getUserProfileByUsername(req.params.userName)
|
router.get(
|
||||||
res.json(userProfileAdapter.mapUserProfileToResponse(data))
|
"/:userName",
|
||||||
} catch (err) {
|
validate(users.userProfileRequestSchema, "params", "validatedUserProfile"),
|
||||||
console.error(err)
|
async (req, res) => {
|
||||||
const status = err.statusCode || 500
|
try {
|
||||||
res.status(status).json({ message: err.message || "Profil bilgileri alınamadı." })
|
const { userName } = req.validatedUserProfile
|
||||||
|
const data = await userService.getUserProfileByUsername(userName)
|
||||||
|
|
||||||
|
const response = users.userProfileResponseSchema.parse(
|
||||||
|
userProfileAdapter.mapUserProfileToResponse(data)
|
||||||
|
)
|
||||||
|
res.json(response)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
const status = err.statusCode || 500
|
||||||
|
res.status(status).json({
|
||||||
|
message: err.message || "Profil bilgileri alinamadi.",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,45 @@
|
||||||
const express = require("express")
|
const express = require("express")
|
||||||
const authRequiredMiddleware = require("../middleware/authRequired.middleware")
|
const requireAuth = require("../middleware/requireAuth")
|
||||||
const authOptionalMiddleware = require("../middleware/authOptional.middleware")
|
const { validate } = require("../middleware/validate.middleware")
|
||||||
|
const { endpoints } = require("@shared/contracts")
|
||||||
const voteService = require("../services/vote.service")
|
const voteService = require("../services/vote.service")
|
||||||
const {mapVoteRequestToVoteInput,mapVoteResultToResponse}=require("../adapters/responses/vote.adapter")
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
const { votes } = endpoints
|
||||||
|
|
||||||
router.post("/", authRequiredMiddleware
|
router.post(
|
||||||
, async (req, res) => {
|
"/",
|
||||||
try {
|
requireAuth,
|
||||||
const input = mapVoteRequestToVoteInput(req);
|
validate(votes.voteRequestSchema, "body", "validatedVotePayload"),
|
||||||
const result = await voteService.voteDeal(input);
|
async (req, res) => {
|
||||||
res.json(result);
|
try {
|
||||||
} catch (err) {
|
const { dealId, voteType } = req.validatedVotePayload
|
||||||
const status = err.statusCode || 500;
|
const result = await voteService.voteDeal({
|
||||||
res.status(status).json({ message: err.message || "Sunucu hatası" });
|
dealId,
|
||||||
|
voteType,
|
||||||
|
userId: req.auth.userId,
|
||||||
|
})
|
||||||
|
res.json(votes.voteResponseSchema.parse(result))
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.statusCode || 500
|
||||||
|
res.status(status).json({ message: err.message || "Sunucu hatasi" })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
)
|
||||||
// Belirli deal için oyları çek
|
|
||||||
router.get("/:dealId", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const dealId = Number(req.params.dealId)
|
|
||||||
if (isNaN(dealId) || dealId <= 0)
|
|
||||||
return res.status(400).json({ error: "Geçersiz dealId" })
|
|
||||||
|
|
||||||
const data = await voteService.getVotes(dealId)
|
router.get(
|
||||||
res.json(data)
|
"/:dealId",
|
||||||
} catch (err) {
|
validate(votes.voteListRequestSchema, "params", "validatedVoteList"),
|
||||||
console.error(err)
|
async (req, res) => {
|
||||||
res.status(500).json({ error: "Sunucu hatası" })
|
try {
|
||||||
|
const { dealId } = req.validatedVoteList
|
||||||
|
const data = await voteService.getVotes(dealId)
|
||||||
|
res.json(votes.voteListResponseSchema.parse({ votes: data }))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ error: "Sunucu hatasi" })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|
|
||||||
64
server.js
64
server.js
|
|
@ -1,30 +1,42 @@
|
||||||
const express = require("express")
|
const express = require("express");
|
||||||
const cors = require("cors")
|
const cors = require("cors");
|
||||||
require("dotenv").config()
|
require("dotenv").config();
|
||||||
|
const cookieParser = require("cookie-parser");
|
||||||
|
|
||||||
const userRoutesneedRefactor = require("./routes/user.routes")
|
// Rotaların import edilmesi
|
||||||
const dealRoutes = require("./routes/deal.routes")
|
const userRoutesneedRefactor = require("./routes/user.routes");
|
||||||
const authRoutes = require("./routes/auth.routes")
|
const dealRoutes = require("./routes/deal.routes");
|
||||||
const dealVoteRoutes = require("./routes/vote.routes")
|
const authRoutes = require("./routes/auth.routes");
|
||||||
const commentRoutes = require("./routes/comment.routes")
|
const dealVoteRoutes = require("./routes/vote.routes");
|
||||||
const accountSettingsRoutes = require("./routes/accountSettings.routes")
|
const commentRoutes = require("./routes/comment.routes");
|
||||||
const userRoutes = require("./routes/user.routes")
|
const accountSettingsRoutes = require("./routes/accountSettings.routes");
|
||||||
const sellerRoutes = require("./routes/seller.routes")
|
const userRoutes = require("./routes/user.routes");
|
||||||
const voteRoutes=require("./routes/vote.routes")
|
const sellerRoutes = require("./routes/seller.routes");
|
||||||
const app = express()
|
const voteRoutes = require("./routes/vote.routes");
|
||||||
|
const categoryRoutes =require("./routes/category.routes")
|
||||||
|
const app = express();
|
||||||
|
|
||||||
app.use(cors())
|
// CORS middleware'ı ile dışardan gelen istekleri kontrol et
|
||||||
app.use(express.json())
|
app.use(cors({
|
||||||
app.use(express.urlencoded({ extended: true }))
|
origin: "http://localhost:5173", // Frontend adresi
|
||||||
|
credentials: true, // Cookies'in paylaşıma izin verilmesi
|
||||||
|
}));
|
||||||
|
|
||||||
app.use("/api/users", userRoutesneedRefactor)
|
// JSON, URL encoded ve cookies'leri parse etme
|
||||||
app.use("/api/deals", dealRoutes)
|
app.use(express.json()); // JSON verisi almak için
|
||||||
app.use("/api/auth", authRoutes)
|
app.use(express.urlencoded({ extended: true })); // URL encoded veriler için
|
||||||
app.use("/api/deal-votes", dealVoteRoutes)
|
app.use(cookieParser()); // Cookies'leri çözümlemek için
|
||||||
app.use("/api/comments", commentRoutes)
|
|
||||||
app.use("/api/account", accountSettingsRoutes)
|
|
||||||
app.use("/api/user", userRoutes)
|
|
||||||
app.use("/api/seller", sellerRoutes)
|
|
||||||
app.use("/api/vote", voteRoutes)
|
|
||||||
|
|
||||||
app.listen(3000, () => console.log("Server running on http://localhost:3000"))
|
// API route'larını tanımlama
|
||||||
|
app.use("/api/users", userRoutesneedRefactor); // User işlemleri
|
||||||
|
app.use("/api/deals", dealRoutes); // Deal işlemleri
|
||||||
|
app.use("/api/auth", authRoutes); // Auth işlemleri (login, register vs.)
|
||||||
|
app.use("/api/deal-votes", dealVoteRoutes); // Deal oy işlemleri
|
||||||
|
app.use("/api/comments", commentRoutes); // Comment işlemleri
|
||||||
|
app.use("/api/account", accountSettingsRoutes); // Account settings işlemleri
|
||||||
|
app.use("/api/user", userRoutes); // Kullanıcı işlemleri
|
||||||
|
app.use("/api/seller", sellerRoutes); // Seller işlemleri
|
||||||
|
app.use("/api/vote", voteRoutes); // Vote işlemleri
|
||||||
|
app.use("/api/category", categoryRoutes);
|
||||||
|
// Sunucuyu dinlemeye başla
|
||||||
|
app.listen(3000, () => console.log("Server running on http://localhost:3000"));
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,187 @@
|
||||||
const bcrypt = require("bcryptjs");
|
// services/auth.service.js
|
||||||
const generateToken = require("../utils/generateToken");
|
const bcrypt = require("bcryptjs")
|
||||||
const authDb = require("../db/auth.db");
|
const jwt = require("jsonwebtoken")
|
||||||
|
const crypto = require("crypto")
|
||||||
|
|
||||||
async function login({ email, password }) {
|
const authDb = require("../db/auth.db")
|
||||||
const user = await authDb.findUserByEmail(email);
|
const refreshTokenDb = require("../db/refreshToken.db")
|
||||||
|
|
||||||
if (!user) {
|
function httpError(statusCode, message) {
|
||||||
const err = new Error("Kullanıcı bulunamadı.");
|
const err = new Error(message)
|
||||||
err.statusCode = 400;
|
err.statusCode = statusCode
|
||||||
throw err;
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
const isMatch = await bcrypt.compare(password, user.passwordHash);
|
|
||||||
if (!isMatch) {
|
|
||||||
const err = new Error("Şifre hatalı.");
|
|
||||||
err.statusCode = 401;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = generateToken(user.id);
|
|
||||||
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
avatarUrl: user.avatarUrl,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register({ username, email, password }) {
|
// Access token: kısa ömür
|
||||||
const existingUser = await authDb.findUserByEmail(email);
|
function signAccessToken(user) {
|
||||||
if (existingUser) {
|
const jti = crypto.randomUUID()
|
||||||
const err = new Error("Bu e-posta zaten kayıtlı.");
|
const payload = {
|
||||||
err.statusCode = 400;
|
sub: String(user.id),
|
||||||
throw err;
|
role: user.role, // USER|MOD|ADMIN
|
||||||
|
jti,
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
const expiresIn = process.env.ACCESS_TOKEN_EXPIRES_IN || "15m"
|
||||||
|
const token = jwt.sign(payload, process.env.JWT_ACCESS_SECRET, { expiresIn })
|
||||||
|
return { token, jti }
|
||||||
|
}
|
||||||
|
|
||||||
const user = await authDb.createUser({
|
// Refresh token: opaque (JWT değil) + DB’de hash
|
||||||
username,
|
function generateRefreshToken() {
|
||||||
email,
|
// 64 byte -> url-safe base64
|
||||||
passwordHash,
|
return crypto.randomBytes(64).toString("base64url")
|
||||||
});
|
}
|
||||||
|
|
||||||
const token = generateToken(user.id);
|
function hashToken(token) {
|
||||||
|
return crypto.createHash("sha256").update(token).digest("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshExpiresAt() {
|
||||||
|
const days = Number(process.env.REFRESH_TOKEN_DAYS || 30)
|
||||||
|
return new Date(Date.now() + days * 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUserPublic(user) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
avatarUrl: user.avatarUrl ?? null,
|
||||||
|
role: user.role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login({ email, password, meta = {} }) {
|
||||||
|
const user = await authDb.findUserByEmail(email)
|
||||||
|
if (!user) throw httpError(400, "Kullanıcı bulunamadı.")
|
||||||
|
|
||||||
|
const isMatch = await bcrypt.compare(password, user.passwordHash)
|
||||||
|
if (!isMatch) throw httpError(401, "Şifre hatalı.")
|
||||||
|
|
||||||
|
const { token: accessToken } = signAccessToken(user)
|
||||||
|
|
||||||
|
const refreshToken = generateRefreshToken()
|
||||||
|
const tokenHash = hashToken(refreshToken)
|
||||||
|
const familyId = crypto.randomUUID()
|
||||||
|
const jti = crypto.randomUUID()
|
||||||
|
|
||||||
|
await refreshTokenDb.createRefreshToken(user.id, {
|
||||||
|
tokenHash,
|
||||||
|
familyId,
|
||||||
|
jti,
|
||||||
|
expiresAt: refreshExpiresAt(),
|
||||||
|
createdByIp: meta.ip ?? null,
|
||||||
|
userAgent: meta.userAgent ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
accessToken,
|
||||||
user: {
|
refreshToken,
|
||||||
id: user.id,
|
user: mapUserPublic(user),
|
||||||
username: user.username,
|
}
|
||||||
email: user.email,
|
}
|
||||||
avatarUrl: user.avatarUrl ?? null,
|
|
||||||
|
async function register({ username, email, password, meta = {} }) {
|
||||||
|
const existingUser = await authDb.findUserByEmail(email)
|
||||||
|
if (existingUser) throw httpError(400, "Bu e-posta zaten kayıtlı.")
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10)
|
||||||
|
const user = await authDb.createUser({ username, email, passwordHash })
|
||||||
|
|
||||||
|
const { token: accessToken } = signAccessToken(user)
|
||||||
|
|
||||||
|
const refreshToken = generateRefreshToken()
|
||||||
|
const tokenHash = hashToken(refreshToken)
|
||||||
|
const familyId = crypto.randomUUID()
|
||||||
|
const jti = crypto.randomUUID()
|
||||||
|
|
||||||
|
await refreshTokenDb.createRefreshToken(user.id, {
|
||||||
|
tokenHash,
|
||||||
|
familyId,
|
||||||
|
jti,
|
||||||
|
expiresAt: refreshExpiresAt(),
|
||||||
|
createdByIp: meta.ip ?? null,
|
||||||
|
userAgent: meta.userAgent ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: mapUserPublic(user),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh: rotate + reuse tespiti
|
||||||
|
async function refresh({ refreshToken, meta = {} }) {
|
||||||
|
if (!refreshToken) throw httpError(401, "Refresh token yok")
|
||||||
|
|
||||||
|
const tokenHash = hashToken(refreshToken)
|
||||||
|
const existing = await refreshTokenDb.findRefreshTokenByHash(tokenHash, {
|
||||||
|
include: { user: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) throw httpError(401, "Refresh token geçersiz")
|
||||||
|
|
||||||
|
// süresi geçmiş
|
||||||
|
if (existing.expiresAt && existing.expiresAt.getTime() < Date.now()) {
|
||||||
|
await refreshTokenDb.revokeRefreshTokenById(existing.id)
|
||||||
|
throw httpError(401, "Refresh token süresi dolmuş")
|
||||||
|
}
|
||||||
|
|
||||||
|
// reuse tespiti: revoke edilmiş token tekrar gelirse -> tüm aileyi kapat
|
||||||
|
if (existing.revokedAt) {
|
||||||
|
await refreshTokenDb.revokeRefreshTokenFamily(existing.familyId)
|
||||||
|
throw httpError(401, "Refresh token reuse tespit edildi")
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = existing.user
|
||||||
|
const { token: accessToken } = signAccessToken(user)
|
||||||
|
|
||||||
|
const newRefreshToken = generateRefreshToken()
|
||||||
|
const newTokenHash = hashToken(newRefreshToken)
|
||||||
|
const newJti = crypto.randomUUID()
|
||||||
|
|
||||||
|
await refreshTokenDb.rotateRefreshToken({
|
||||||
|
oldId: existing.id,
|
||||||
|
newToken: {
|
||||||
|
userId: user.id,
|
||||||
|
tokenHash: newTokenHash,
|
||||||
|
familyId: existing.familyId, // aynı aile
|
||||||
|
jti: newJti,
|
||||||
|
expiresAt: refreshExpiresAt(),
|
||||||
},
|
},
|
||||||
};
|
meta: { ip: meta.ip ?? null, userAgent: meta.userAgent ?? null },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
user: mapUserPublic(user),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout({ refreshToken }) {
|
||||||
|
if (!refreshToken) return
|
||||||
|
const tokenHash = hashToken(refreshToken)
|
||||||
|
|
||||||
|
// token yoksa sessiz geçmek genelde daha iyi (idempotent logout)
|
||||||
|
try {
|
||||||
|
await refreshTokenDb.revokeRefreshTokenByHash(tokenHash)
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMe(userId) {
|
async function getMe(userId) {
|
||||||
const user = await authDb.findUserById(userId, {
|
const user = await authDb.findUserById(Number(userId), {
|
||||||
select: {
|
select: { id: true, username: true, email: true, avatarUrl: true, role: true },
|
||||||
id: true,
|
})
|
||||||
username: true,
|
if (!user) throw httpError(404, "Kullanıcı bulunamadı")
|
||||||
email: true,
|
return user
|
||||||
avatarUrl: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
const err = new Error("Kullanıcı bulunamadı");
|
|
||||||
err.statusCode = 404;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
|
refresh,
|
||||||
|
logout,
|
||||||
getMe,
|
getMe,
|
||||||
};
|
}
|
||||||
|
|
|
||||||
69
services/category.service.js
Normal file
69
services/category.service.js
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
const categoryDb = require("../db/category.db"); // DB işlemleri için category.db.js'i import ediyoruz
|
||||||
|
const dealDb = require("../db/deal.db");
|
||||||
|
/**
|
||||||
|
* Kategoriyi slug'a göre bul
|
||||||
|
* Bu fonksiyon, verilen slug'a sahip kategori bilgilerini döndürür
|
||||||
|
*/
|
||||||
|
async function findCategoryBySlug(slug) {
|
||||||
|
try {
|
||||||
|
// Kategori bilgisini slug'a göre buluyoruz
|
||||||
|
const category = await categoryDb.findCategoryBySlug(slug, {
|
||||||
|
include: {
|
||||||
|
children: true, // Alt kategorileri de dahil edebiliriz
|
||||||
|
deals: true, // Kategorinin deals ilişkisini alıyoruz
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDealsByCategoryId(categoryId, page = 1, limit = 10, filters = {}) {
|
||||||
|
try {
|
||||||
|
// Sayfalama ve filtreleme için gerekli ayarlamaları yapıyoruz
|
||||||
|
const take = Math.min(Math.max(Number(limit) || 10, 1), 100); // Limit ve sayfa sayısını hesaplıyoruz
|
||||||
|
const skip = (Math.max(Number(page) || 1, 1) - 1) * take; // Sayfa başlangıcı
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
findCategoryBySlug,
|
||||||
|
getDealsByCategoryId,
|
||||||
|
};
|
||||||
|
|
@ -18,19 +18,36 @@ async function getCommentsByDealId(dealId) {
|
||||||
return commentDB.findComments({ dealId: id }, { include })
|
return commentDB.findComments({ dealId: id }, { include })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createComment({ dealId, userId, text }) {
|
async function createComment({ dealId, userId, text, parentId = null }) {
|
||||||
if (!text || typeof text !== "string" || !text.trim())
|
if (!text || typeof text !== "string" || !text.trim()) {
|
||||||
throw new Error("Yorum boş olamaz.")
|
throw new Error("Yorum boş olamaz.")
|
||||||
|
}
|
||||||
|
|
||||||
const trimmed = text.trim()
|
const trimmed = text.trim()
|
||||||
const include = { user: { select: { 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 }, {}, tx)
|
||||||
if (!deal) throw new Error("Deal bulunamadı.")
|
if (!deal) throw new Error("Deal bulunamadı.")
|
||||||
|
|
||||||
|
// ✅ Reply ise parent doğrula
|
||||||
|
let parent = null
|
||||||
|
if (parentId != null) {
|
||||||
|
const pid = Number(parentId)
|
||||||
|
if (!Number.isFinite(pid) || pid <= 0) throw new Error("Geçersiz parentId.")
|
||||||
|
|
||||||
|
parent = await commentDB.findComment({ id: pid }, { select: { id: true, dealId: true } }, tx)
|
||||||
|
if (!parent) throw new Error("Yanıtlanan yorum bulunamadı.")
|
||||||
|
if (parent.dealId !== dealId) throw new Error("Yanıtlanan yorum bu deal'a ait değil.")
|
||||||
|
}
|
||||||
|
|
||||||
const comment = await commentDB.createComment(
|
const comment = await commentDB.createComment(
|
||||||
{ text: trimmed, userId, dealId },
|
{
|
||||||
|
text: trimmed,
|
||||||
|
userId,
|
||||||
|
dealId,
|
||||||
|
parentId: parent ? parent.id : null,
|
||||||
|
},
|
||||||
{ include },
|
{ include },
|
||||||
tx
|
tx
|
||||||
)
|
)
|
||||||
|
|
@ -46,19 +63,18 @@ async function createComment({ dealId, userId, text }) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function deleteComment(commentId, userId) {
|
async function deleteComment(commentId, userId) {
|
||||||
const cId = assertPositiveInt(commentId, "commentId")
|
|
||||||
const uId = assertPositiveInt(userId, "userId")
|
|
||||||
|
|
||||||
const comments = await commentDB.findComments(
|
const comments = await commentDB.findComments(
|
||||||
{ id: cId },
|
{ id: commentId },
|
||||||
{ select: { userId: true } }
|
{ select: { userId: true } }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!comments || comments.length === 0) throw new Error("Yorum bulunamadı.")
|
if (!comments || comments.length === 0) throw new Error("Yorum bulunamadı.")
|
||||||
if (comments[0].userId !== uId) throw new Error("Bu yorumu silme yetkin yok.")
|
if (comments[0].userId !== userId) throw new Error("Bu yorumu silme yetkin yok.")
|
||||||
|
|
||||||
await commentDB.deleteComment({ id: cId })
|
await commentDB.deleteComment({ id: commentId })
|
||||||
return { message: "Yorum silindi." }
|
return { message: "Yorum silindi." }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,283 @@
|
||||||
|
// services/deal.service.js
|
||||||
const dealDB = require("../db/deal.db")
|
const dealDB = require("../db/deal.db")
|
||||||
|
const { findSellerFromLink } = require("./seller.service")
|
||||||
|
const { makeDetailWebp, makeThumbWebp } = require("../utils/processImage")
|
||||||
|
const { v4: uuidv4 } = require("uuid")
|
||||||
|
const { uploadImage } = require("./uploadImage.service")
|
||||||
|
const categoryDB = require("../db/category.db")
|
||||||
|
const dealImageDB = require("../db/dealImage.db")
|
||||||
|
const { enqueueDealClassification } = require("../jobs/dealClassification.queue")
|
||||||
|
|
||||||
const { findSellerFromLink, } = require("./seller.service")
|
const DEFAULT_LIMIT = 20
|
||||||
const { makeDetailWebp, makeThumbWebp } = require("../utils/processImage");
|
const MAX_LIMIT = 50
|
||||||
const { v4: uuidv4 } = require("uuid");
|
const MAX_SKIP = 5000
|
||||||
const {uploadImage}=require("./uploadImage.service")
|
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
const dealImageDB = require("../db/dealImage.db");
|
const DEAL_LIST_INCLUDE = {
|
||||||
|
user: { select: { id: true, username: true, avatarUrl: true } },
|
||||||
|
seller: { select: { id: true, name: true, url: true } },
|
||||||
|
images: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
take: 1,
|
||||||
|
select: { imageUrl: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
async function getDeals({ q = "", page = 1, limit = 10, userId = null }) {
|
function formatDateAsString(value) {
|
||||||
const skip = (page - 1) * limit
|
return value instanceof Date ? value.toISOString() : value ?? null
|
||||||
|
}
|
||||||
|
|
||||||
const queryRaw = (q ?? "").toString().trim()
|
function clampPagination({ page, limit }) {
|
||||||
const query =
|
const rawPage = Number(page)
|
||||||
queryRaw === "undefined" || queryRaw === "null" ? "" : queryRaw
|
const rawLimit = Number(limit)
|
||||||
|
const normalizedPage = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1
|
||||||
const where =
|
let normalizedLimit = Number.isFinite(rawLimit) ? Math.max(1, Math.floor(rawLimit)) : DEFAULT_LIMIT
|
||||||
query.length > 0
|
normalizedLimit = Math.min(MAX_LIMIT, normalizedLimit)
|
||||||
? {
|
const skip = (normalizedPage - 1) * normalizedLimit
|
||||||
OR: [
|
if (skip > MAX_SKIP) {
|
||||||
{ title: { contains: query, mode: "insensitive" } },
|
const err = new Error("PAGE_TOO_DEEP")
|
||||||
{ description: { contains: query, mode: "insensitive" } },
|
err.statusCode = 400
|
||||||
],
|
throw err
|
||||||
}
|
|
||||||
: {}
|
|
||||||
|
|
||||||
const [deals, total] = await Promise.all([
|
|
||||||
dealDB.findDeals(where, {
|
|
||||||
skip,
|
|
||||||
take: limit,
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
include: {
|
|
||||||
seller: { select: { name: true, url: true } },
|
|
||||||
user: { select: { id: true, username: true, avatarUrl: true } },
|
|
||||||
images: {
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
take: 1,
|
|
||||||
select: { imageUrl: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
dealDB.countDeals(where),
|
|
||||||
])
|
|
||||||
|
|
||||||
// auth yoksa myVote=0
|
|
||||||
if (!userId) {
|
|
||||||
return {
|
|
||||||
page,
|
|
||||||
total,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
results: deals.map((d) => ({ ...d, myVote: 0 })),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return { page: normalizedPage, limit: normalizedLimit, skip }
|
||||||
|
}
|
||||||
|
|
||||||
const dealIds = deals.map((d) => d.id)
|
function buildSearchClause(q) {
|
||||||
|
if (q === undefined || q === null) return null
|
||||||
const votes = await dealDB.findVotes(
|
const normalized = String(q).trim()
|
||||||
{ userId, dealId: { in: dealIds } },
|
if (!normalized) return null
|
||||||
{ select: { dealId: true, voteType: true } }
|
|
||||||
)
|
|
||||||
|
|
||||||
const voteByDealId = new Map(votes.map((v) => [v.dealId, v.voteType]))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page,
|
OR: [
|
||||||
total,
|
{ title: { contains: normalized, mode: "insensitive" } },
|
||||||
totalPages: Math.ceil(total / limit),
|
{ description: { contains: normalized, mode: "insensitive" } },
|
||||||
results: deals.map((d) => ({
|
],
|
||||||
...d,
|
|
||||||
myVote: voteByDealId.get(d.id) ?? 0,
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPresetCriteria(preset, { viewer, targetUserId } = {}) {
|
||||||
|
const now = new Date()
|
||||||
|
switch (preset) {
|
||||||
|
case "NEW":
|
||||||
|
return { where: { status: "ACTIVE" }, orderBy: [{ createdAt: "desc" }] }
|
||||||
|
case "HOT": {
|
||||||
|
const cutoff = new Date(now.getTime() - 3 * MS_PER_DAY)
|
||||||
|
return {
|
||||||
|
where: { status: "ACTIVE", createdAt: { gte: cutoff } },
|
||||||
|
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "TRENDING": {
|
||||||
|
const cutoff = new Date(now.getTime() - 2 * MS_PER_DAY)
|
||||||
|
return {
|
||||||
|
where: { status: "ACTIVE", createdAt: { gte: cutoff } },
|
||||||
|
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "MY": {
|
||||||
|
if (!viewer?.userId) {
|
||||||
|
const err = new Error("AUTH_REQUIRED")
|
||||||
|
err.statusCode = 401
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return { where: { userId: viewer.userId }, orderBy: [{ createdAt: "desc" }] }
|
||||||
|
}
|
||||||
|
case "USER_PUBLIC": {
|
||||||
|
if (!targetUserId) {
|
||||||
|
const err = new Error("TARGET_USER_REQUIRED")
|
||||||
|
err.statusCode = 400
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return { where: { userId: targetUserId, status: "ACTIVE" }, orderBy: [{ createdAt: "desc" }] }
|
||||||
|
}
|
||||||
|
case "HOT_DAY": {
|
||||||
|
const cutoff = new Date(now.getTime() - 1 * MS_PER_DAY)
|
||||||
|
return {
|
||||||
|
where: { status: "ACTIVE", createdAt: { gte: cutoff } },
|
||||||
|
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "HOT_WEEK": {
|
||||||
|
const cutoff = new Date(now.getTime() - 7 * MS_PER_DAY)
|
||||||
|
return {
|
||||||
|
where: { status: "ACTIVE", createdAt: { gte: cutoff } },
|
||||||
|
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "HOT_MONTH": {
|
||||||
|
const cutoff = new Date(now.getTime() - 30 * MS_PER_DAY)
|
||||||
|
return {
|
||||||
|
where: { status: "ACTIVE", createdAt: { gte: cutoff } },
|
||||||
|
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const err = new Error("INVALID_PRESET")
|
||||||
|
err.statusCode = 400
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------
|
||||||
|
// Similar deals helpers (tagsiz, lightweight)
|
||||||
|
// --------------------
|
||||||
|
function clamp(n, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeTitle(title = "") {
|
||||||
|
return String(title)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9çğıöşü\s]/gi, " ")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((w) => w.length >= 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleOverlapScore(aTitle, bTitle) {
|
||||||
|
const a = tokenizeTitle(aTitle)
|
||||||
|
const b = tokenizeTitle(bTitle)
|
||||||
|
if (!a.length || !b.length) return 0
|
||||||
|
|
||||||
|
const aset = new Set(a)
|
||||||
|
const bset = new Set(b)
|
||||||
|
let hit = 0
|
||||||
|
for (const w of bset) if (aset.has(w)) hit++
|
||||||
|
|
||||||
|
const denom = Math.min(aset.size, bset.size) || 1
|
||||||
|
return hit / denom // 0..1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SimilarDeals: DealCard değil, minimal summary döndürür.
|
||||||
|
* Beklenen candidate shape:
|
||||||
|
* - id, title, price, score, createdAt, categoryId, sellerId, customSeller
|
||||||
|
* - seller?: { name }
|
||||||
|
* - images?: [{ imageUrl }]
|
||||||
|
*/
|
||||||
|
async function buildSimilarDealsForDetail(targetDeal, { limit = 5 } = {}) {
|
||||||
|
const take = clamp(Number(limit) || 5, 1, 10)
|
||||||
|
|
||||||
|
// Bu 2 DB fonksiyonu: ACTIVE filter + images(take:1) + seller(name) getirmeli
|
||||||
|
const [byCategory, bySeller] = await Promise.all([
|
||||||
|
dealDB.findSimilarCandidatesByCategory(targetDeal.categoryId, targetDeal.id, { take: 80 }),
|
||||||
|
targetDeal.sellerId
|
||||||
|
? dealDB.findSimilarCandidatesBySeller(targetDeal.sellerId, targetDeal.id, { take: 30 })
|
||||||
|
: Promise.resolve([]),
|
||||||
|
])
|
||||||
|
|
||||||
|
const dedup = new Map()
|
||||||
|
for (const d of [...byCategory, ...bySeller]) dedup.set(d.id, d)
|
||||||
|
const candidates = Array.from(dedup.values())
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
const scored = candidates.map((d) => {
|
||||||
|
const sameCategory = d.categoryId === targetDeal.categoryId
|
||||||
|
const sameSeller = Boolean(targetDeal.sellerId && d.sellerId === targetDeal.sellerId)
|
||||||
|
|
||||||
|
const titleSim = titleOverlapScore(targetDeal.title, d.title) // 0..1
|
||||||
|
const titlePoints = Math.round(titleSim * 25)
|
||||||
|
|
||||||
|
const scoreVal = Number.isFinite(d.score) ? d.score : 0
|
||||||
|
const scorePoints = clamp(Math.round(scoreVal / 10), 0, 25)
|
||||||
|
|
||||||
|
const ageDays = Math.floor((now - new Date(d.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
const recencyPoints = ageDays <= 3 ? 10 : ageDays <= 10 ? 6 : ageDays <= 30 ? 3 : 0
|
||||||
|
|
||||||
|
const rank =
|
||||||
|
(sameCategory ? 60 : 0) +
|
||||||
|
(sameSeller ? 25 : 0) +
|
||||||
|
titlePoints +
|
||||||
|
scorePoints +
|
||||||
|
recencyPoints
|
||||||
|
|
||||||
|
return { d, rank }
|
||||||
|
})
|
||||||
|
|
||||||
|
scored.sort((a, b) => b.rank - a.rank)
|
||||||
|
|
||||||
|
return scored.slice(0, take).map(({ d }) => ({
|
||||||
|
id: d.id,
|
||||||
|
title: d.title,
|
||||||
|
price: d.price ?? null,
|
||||||
|
score: Number.isFinite(d.score) ? d.score : 0,
|
||||||
|
imageUrl: d.images?.[0]?.imageUrl || "",
|
||||||
|
sellerName: d.seller?.name || d.customSeller || "Bilinmiyor",
|
||||||
|
createdAt: formatDateAsString(d.createdAt),
|
||||||
|
// url istersen:
|
||||||
|
// url: d.url ?? null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDeals({ preset = "NEW", q, page, limit, viewer = null, targetUserId = null }) {
|
||||||
|
const pagination = clampPagination({ page, limit })
|
||||||
|
const { where: presetWhere, orderBy: presetOrder } = buildPresetCriteria(preset, { viewer, targetUserId })
|
||||||
|
const searchClause = buildSearchClause(q)
|
||||||
|
|
||||||
|
const clauses = []
|
||||||
|
if (presetWhere && Object.keys(presetWhere).length > 0) clauses.push(presetWhere)
|
||||||
|
if (searchClause) clauses.push(searchClause)
|
||||||
|
|
||||||
|
const finalWhere = clauses.length === 0 ? {} : clauses.length === 1 ? clauses[0] : { AND: clauses }
|
||||||
|
const orderBy = presetOrder ?? [{ createdAt: "desc" }]
|
||||||
|
|
||||||
|
const [deals, total] = await Promise.all([
|
||||||
|
dealDB.findDeals(finalWhere, {
|
||||||
|
skip: pagination.skip,
|
||||||
|
take: pagination.limit,
|
||||||
|
orderBy,
|
||||||
|
include: DEAL_LIST_INCLUDE,
|
||||||
|
}),
|
||||||
|
dealDB.countDeals(finalWhere),
|
||||||
|
])
|
||||||
|
|
||||||
|
const dealIds = deals.map((d) => d.id)
|
||||||
|
const voteByDealId = new Map()
|
||||||
|
|
||||||
|
if (viewer?.userId && dealIds.length > 0) {
|
||||||
|
const votes = await dealDB.findVotes(
|
||||||
|
{ userId: viewer.userId, dealId: { in: dealIds } },
|
||||||
|
{ select: { dealId: true, voteType: true } }
|
||||||
|
)
|
||||||
|
votes.forEach((vote) => voteByDealId.set(vote.dealId, vote.voteType))
|
||||||
|
}
|
||||||
|
|
||||||
|
const enriched = deals.map((deal) => ({
|
||||||
|
...deal,
|
||||||
|
myVote: voteByDealId.get(deal.id) ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: pagination.page,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pagination.limit),
|
||||||
|
results: enriched,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getDealById(id) {
|
async function getDealById(id) {
|
||||||
const deal=await dealDB.findDeal(
|
const deal = await dealDB.findDeal(
|
||||||
{ id: Number(id) },
|
{ id: Number(id) },
|
||||||
{
|
{
|
||||||
include: {
|
include: {
|
||||||
seller:{
|
seller: { select: { id: true, name: true, url: true } },
|
||||||
select: {
|
user: { select: { id: true, username: true, avatarUrl: true } },
|
||||||
name:true,
|
images: { orderBy: { order: "asc" }, select: { id: true, imageUrl: true, order: true } },
|
||||||
url:true
|
notices: {
|
||||||
},
|
where: { isActive: true },
|
||||||
},
|
orderBy: { createdAt: "desc" },
|
||||||
user: {
|
take: 1,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
dealId: true,
|
||||||
avatarUrl: true,
|
title: true,
|
||||||
},
|
body: true,
|
||||||
},
|
severity: true,
|
||||||
seller: {
|
isActive: true,
|
||||||
select: {
|
createdBy: true,
|
||||||
id: true,
|
createdAt: true,
|
||||||
name: true,
|
updatedAt: true,
|
||||||
},
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
imageUrl: true,
|
|
||||||
order: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
comments: {
|
comments: {
|
||||||
|
|
@ -112,90 +286,88 @@ async function getDealById(id) {
|
||||||
id: true,
|
id: true,
|
||||||
text: true,
|
text: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
user: {
|
user: { select: { id: true, username: true, avatarUrl: true } },
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
avatarUrl: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
comments: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
_count: { select: { comments: true } },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return deal
|
if (!deal) return null
|
||||||
|
|
||||||
|
const breadcrumb = await categoryDB.getCategoryBreadcrumb(deal.categoryId, {
|
||||||
|
includeUndefined: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const similarDeals = await buildSimilarDealsForDetail(
|
||||||
|
{
|
||||||
|
id: deal.id,
|
||||||
|
title: deal.title,
|
||||||
|
categoryId: deal.categoryId,
|
||||||
|
sellerId: deal.sellerId ?? null,
|
||||||
|
},
|
||||||
|
{ limit: 5 }
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...deal,
|
||||||
|
breadcrumb,
|
||||||
|
similarDeals,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function createDeal(dealCreateData, files = []) {
|
async function createDeal(dealCreateData, files = []) {
|
||||||
// seller bağlama
|
|
||||||
if (dealCreateData.url) {
|
if (dealCreateData.url) {
|
||||||
const seller = await findSellerFromLink(dealCreateData.url);
|
const seller = await findSellerFromLink(dealCreateData.url)
|
||||||
if (seller) {
|
if (seller) {
|
||||||
dealCreateData.seller = { connect: { id: seller.id } };
|
dealCreateData.seller = { connect: { id: seller.id } }
|
||||||
dealCreateData.customSeller = null;
|
dealCreateData.customSeller = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Deal oluştur
|
const deal = await dealDB.createDeal(dealCreateData)
|
||||||
const deal = await dealDB.createDeal(dealCreateData);
|
|
||||||
|
|
||||||
// 2) Önce image işle + upload
|
|
||||||
const rows = [];
|
|
||||||
|
|
||||||
|
const rows = []
|
||||||
for (let i = 0; i < files.length && i < 5; i++) {
|
for (let i = 0; i < files.length && i < 5; i++) {
|
||||||
const file = files[i];
|
const file = files[i]
|
||||||
const order = i;
|
const order = i
|
||||||
|
const key = uuidv4()
|
||||||
|
const basePath = `deals/${deal.id}/${key}`
|
||||||
|
const detailPath = `${basePath}_detail.webp`
|
||||||
|
const thumbPath = `${basePath}_thumb.webp`
|
||||||
|
const BUCKET = "deal"
|
||||||
|
|
||||||
const key = uuidv4();
|
const detailBuffer = await makeDetailWebp(file.buffer)
|
||||||
const basePath = `deals/${deal.id}/${key}`;
|
|
||||||
const detailPath = `${basePath}_detail.webp`;
|
|
||||||
const thumbPath = `${basePath}_thumb.webp`;
|
|
||||||
const BUCKET="deal";
|
|
||||||
|
|
||||||
const detailBuffer = await makeDetailWebp(file.buffer);
|
|
||||||
const detailUrl = await uploadImage({
|
const detailUrl = await uploadImage({
|
||||||
bucket: BUCKET,
|
bucket: BUCKET,
|
||||||
path: detailPath,
|
path: detailPath,
|
||||||
fileBuffer: detailBuffer,
|
fileBuffer: detailBuffer,
|
||||||
contentType: "image/webp",
|
contentType: "image/webp",
|
||||||
});
|
})
|
||||||
|
|
||||||
if (order === 0) {
|
if (order === 0) {
|
||||||
const thumbBuffer = await makeThumbWebp(file.buffer);
|
const thumbBuffer = await makeThumbWebp(file.buffer)
|
||||||
await uploadImage({
|
await uploadImage({
|
||||||
bucket: BUCKET,
|
bucket: BUCKET,
|
||||||
path: thumbPath,
|
path: thumbPath,
|
||||||
fileBuffer: thumbBuffer,
|
fileBuffer: thumbBuffer,
|
||||||
contentType: "image/webp",
|
contentType: "image/webp",
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.push({ dealId: deal.id, order, imageUrl: detailUrl });
|
rows.push({ dealId: deal.id, order, imageUrl: detailUrl })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Uploadlar bitti -> DB’de tek seferde yaz
|
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
await dealImageDB.createManyDealImages(rows);
|
await dealImageDB.createManyDealImages(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Deal + images dön
|
await enqueueDealClassification({ dealId: deal.id })
|
||||||
return dealDB.getDealWithImages(deal.id);
|
|
||||||
|
return getDealById(deal.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getDeals,
|
getDeals,
|
||||||
getDealById,
|
getDealById,
|
||||||
|
|
|
||||||
122
services/dealClassification.service.js
Normal file
122
services/dealClassification.service.js
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
// services/dealClassification.service.js
|
||||||
|
const OpenAI = require("openai")
|
||||||
|
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `
|
||||||
|
Classify the deal into exactly ONE category_id and optionally suggest up to 5 tags.
|
||||||
|
|
||||||
|
Tags are NOT keyword repeats. Tags must represent INTENT/AUDIENCE/USE-CASE.
|
||||||
|
|
||||||
|
- Prefer audience or use-case tags such as: okul, ofis, is, gaming, kamp, mutfak, temizlik, araba, bahce, bebek, evcil-hayvan, fitness.
|
||||||
|
- Do NOT output literal product words.
|
||||||
|
- You MAY infer relevant intent/audience tags even if not explicitly written, as long as the inference is strong and widely accepted.
|
||||||
|
- Avoid weak guesses: if the intent/audience is not clear, set needs_review=true and tags can be [].
|
||||||
|
|
||||||
|
Forbidden:
|
||||||
|
- store/company/seller names
|
||||||
|
- promotion/marketing words
|
||||||
|
- generic category words
|
||||||
|
|
||||||
|
Max 5 tags total, lowercase.
|
||||||
|
Review / safety:
|
||||||
|
- Set needs_review=true if you are not confident about the chosen category OR if the deal text looks problematic.
|
||||||
|
- If unclear/unrelated, use best_category_id=0 and needs_review=true.
|
||||||
|
- Set has_issue=true if the text contains profanity, harassment, hate, explicit sexual content, doxxing/personal data, scams/phishing, or clear spam.
|
||||||
|
- If has_issue=true, briefly explain in issue_reason (short, generic, no quotes).
|
||||||
|
|
||||||
|
Output JSON only:
|
||||||
|
{
|
||||||
|
"best_category_id": number,
|
||||||
|
"needs_review": boolean,
|
||||||
|
"tags": string[],
|
||||||
|
"has_issue": boolean,
|
||||||
|
"issue_type": "NONE" | "PROFANITY" | "PHONE_NUMBER" | "PERSONAL_DATA" | "SPAM" | "OTHER",
|
||||||
|
"issue_reason": string | null
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const TAXONOMY_LINE =
|
||||||
|
`TAXONOMY:0 undefined;1 electronics;2 beauty;3 food;4 auto;5 home-garden;6 computers;7 pc-components;8 pc-ram;9 pc-ssd;10 pc-cpu;11 pc-gpu;12 pc-peripherals;13 pc-keyboard;14 pc-mouse;15 pc-monitor;16 beauty-makeup;17 beauty-lipstick;18 beauty-foundation;19 beauty-mascara;20 beauty-skincare;21 beauty-moisturizer;22 food-snacks;23 food-cigkofte;24 food-beverages;25 food-coffee;26 auto-oils;27 auto-engine-oil;28 auto-parts;29 auto-brake-pads;30 home-garden-garden;31 garden-irrigation;32 phone;33 phone-smartphone;34 phone-case;35 phone-screen-protector;36 phone-charging;37 phone-powerbank;38 wearables;39 wearables-smartwatch;40 wearables-band;41 audio;42 audio-headphones;43 audio-tws;44 audio-bt-speaker;45 audio-soundbar;46 audio-microphone;47 audio-turntable;48 tv-video;49 tv;50 projector;51 tv-media-player;52 tv-accessories;53 tv-receiver;54 console;55 console-playstation;56 console-xbox;57 console-nintendo;58 console-games;59 console-accessories;60 camera;61 camera-photo;62 camera-action;63 camera-lens;64 camera-tripod;65 smart-home;66 smart-security-camera;67 smart-plug;68 smart-bulb;69 smart-sensor;70 pc-networking;71 pc-router;72 pc-modem;73 pc-switch;74 pc-wifi-extender;75 pc-printing;76 pc-printer;77 pc-ink-toner;78 pc-scanner;79 pc-laptop;80 pc-desktop;81 pc-tablet;82 pc-storage;83 pc-external-drive;84 pc-usb-drive;85 pc-nas;86 pc-webcam;87 pc-speaker;88 pc-mic;89 pc-mousepad;90 pc-dock-hub;91 pc-laptop-bag;92 pc-controller;93 pc-motherboard;94 pc-psu;95 pc-case;96 pc-cooling;97 pc-fan;98 pc-liquid-cooling;99 beauty-fragrance;100 beauty-fragrance-women;101 beauty-fragrance-men;102 beauty-haircare;103 beauty-shampoo;104 beauty-conditioner;105 beauty-hair-styling;106 beauty-personal-care;107 beauty-deodorant;108 beauty-shaving;109 beauty-hair-removal;110 beauty-skincare-serum;111 beauty-sunscreen;112 beauty-cleanser;113 beauty-mask;114 beauty-toner;115 food-staples;116 food-pasta;117 food-legumes;118 food-oil-vinegar;119 food-breakfast;120 food-cheese;121 food-olive;122 food-jam-honey;123 food-soda;124 food-water;125 food-energy;126 food-tea;127 food-frozen;128 food-meat;129 food-dessert;130 auto-accessories;131 auto-in-car-electronics;132 auto-care;133 auto-cleaning;134 auto-tires;135 auto-battery;136 auto-lighting;137 auto-audio;138 home-furniture;139 home-dining-table;140 home-chair;141 home-sofa;142 home-bed;143 home-textile;144 home-bedding;145 home-blanket;146 home-curtain;147 home-kitchen;148 home-cookware;149 home-small-appliances;150 home-coffee-machine;151 home-blender;152 home-airfryer;153 home-vacuum;154 home-lighting;155 home-decor;156 home-rug;157 home-wall-decor;158 home-cleaning;159 home-detergent;160 home-paper-products;161 home-tools;162 home-drill;163 home-saw;164 home-hardware;165 pet;166 pet-cat-food;167 pet-dog-food;168 pet-cat-litter;169 office;170 office-paper-notebook;171 office-a4-paper;172 office-pen;173 office-school-bag;174 baby;175 baby-diaper;176 baby-wipes;177 baby-food;178 baby-toys;179 sports;180 sports-camping;181 sports-fitness;182 sports-bicycle;183 fashion;184 fashion-shoes;185 fashion-men;186 fashion-women;187 fashion-bags;188 books-media;189 books;190 digital-games`
|
||||||
|
|
||||||
|
const CATEGORY_ENUM = [...Array(191).keys()] // 0..31
|
||||||
|
|
||||||
|
function s(x) {
|
||||||
|
return x == null ? "" : String(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTags(tags) {
|
||||||
|
const arr = Array.isArray(tags) ? tags : []
|
||||||
|
const cleaned = arr
|
||||||
|
.map((t) => String(t).trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 5)
|
||||||
|
return [...new Set(cleaned)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOutputJson(resp) {
|
||||||
|
const text = resp.output_text ?? resp.output?.[0]?.content?.[0]?.text
|
||||||
|
if (!text) throw new Error("OpenAI response text missing")
|
||||||
|
return JSON.parse(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function classifyDeal({ title, description, url, seller }) {
|
||||||
|
const userText = [
|
||||||
|
TAXONOMY_LINE,
|
||||||
|
`title: ${s(title)}`,
|
||||||
|
`description: ${s(description)}`,
|
||||||
|
`url: ${s(url)}`,
|
||||||
|
`seller: ${s(seller)}`,
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const resp = await client.responses.create({
|
||||||
|
model: "gpt-5-nano",
|
||||||
|
input: [
|
||||||
|
{ role: "system", content: SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content: userText },
|
||||||
|
],
|
||||||
|
text: {
|
||||||
|
format: {
|
||||||
|
type: "json_schema",
|
||||||
|
name: "deal_classification_v1",
|
||||||
|
strict: true,
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: [
|
||||||
|
"best_category_id",
|
||||||
|
"needs_review",
|
||||||
|
"tags",
|
||||||
|
"has_issue",
|
||||||
|
"issue_type",
|
||||||
|
"issue_reason",
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
best_category_id: { type: "integer", enum: CATEGORY_ENUM },
|
||||||
|
needs_review: { type: "boolean" },
|
||||||
|
tags: { type: "array", items: { type: "string" }, maxItems: 5 },
|
||||||
|
has_issue: { type: "boolean" },
|
||||||
|
issue_type: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["NONE", "PROFANITY", "PHONE_NUMBER", "PERSONAL_DATA", "SPAM", "OTHER"],
|
||||||
|
},
|
||||||
|
issue_reason: { type: ["string", "null"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
const parsed = parseOutputJson(resp)
|
||||||
|
|
||||||
|
return {
|
||||||
|
best_category_id: parsed.best_category_id ?? 0,
|
||||||
|
needs_review: Boolean(parsed.needs_review),
|
||||||
|
has_issue: Boolean(parsed.has_issue),
|
||||||
|
issue_type: parsed.issue_type ?? "NONE",
|
||||||
|
issue_reason: parsed.issue_reason ?? null,
|
||||||
|
tags: normalizeTags(parsed.tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { classifyDeal }
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
const userDB = require("../db/user.db")
|
const userDB = require("../db/user.db")
|
||||||
const dealDB = require("../db/deal.db")
|
const dealDB = require("../db/deal.db")
|
||||||
const commentDB = require("../db/comment.db")
|
const commentDB = require("../db/comment.db")
|
||||||
|
const dealService = require("./deal.service")
|
||||||
|
|
||||||
async function getUserProfileByUsername(userName) {
|
async function getUserProfileByUsername(userName) {
|
||||||
const username = String(userName).trim()
|
const username = String(userName).trim()
|
||||||
|
|
@ -9,7 +10,7 @@ async function getUserProfileByUsername(userName) {
|
||||||
|
|
||||||
const user = await userDB.findUser(
|
const user = await userDB.findUser(
|
||||||
{ username },
|
{ username },
|
||||||
{ select: { id: true, username: true, avatarUrl: true, createdAt: true } }
|
{ select: { id: true, username: true, email: true, avatarUrl: true, createdAt: true } }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
@ -18,21 +19,9 @@ async function getUserProfileByUsername(userName) {
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
const [dealAgg, totalComments, deals, comments] = await Promise.all([
|
const [dealAgg, totalComments, comments] = await Promise.all([
|
||||||
dealDB.aggregateDeals({ userId: user.id }),
|
dealDB.aggregateDeals({ userId: user.id }),
|
||||||
commentDB.countComments({ userId: user.id }),
|
commentDB.countComments({ userId: user.id }),
|
||||||
dealDB.findDeals(
|
|
||||||
{ userId: user.id },
|
|
||||||
{
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 20,
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, username: true, avatarUrl: true } },
|
|
||||||
seller: { select: { name: true, url: true } },
|
|
||||||
images: { orderBy: { order: "asc" }, take: 1, select: { imageUrl: true } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
),
|
|
||||||
commentDB.findComments(
|
commentDB.findComments(
|
||||||
{ userId: user.id },
|
{ userId: user.id },
|
||||||
{
|
{
|
||||||
|
|
@ -46,14 +35,24 @@ async function getUserProfileByUsername(userName) {
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const userDeals = await dealService.getDeals({
|
||||||
|
preset: "USER_PUBLIC",
|
||||||
|
targetUserId: user.id,
|
||||||
|
viewer: null,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalDeals = dealAgg?._count?._all ?? 0
|
||||||
const stats = {
|
const stats = {
|
||||||
totalLikes: dealAgg?._sum?.score ?? 0,
|
totalLikes: dealAgg?._sum?.score ?? 0,
|
||||||
totalComments: totalComments ?? 0,
|
totalComments: totalComments ?? 0,
|
||||||
totalShares: dealAgg?._count?._all ?? 0,
|
totalShares: totalDeals,
|
||||||
|
totalDeals,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return { user, stats, deals, comments }
|
return { user, stats, deals: userDeals.results, comments }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,27 @@ async function voteDeal({ dealId, userId, voteType }) {
|
||||||
return voteDb.voteDealTx({ dealId, userId, voteType });
|
return voteDb.voteDealTx({ dealId, userId, voteType });
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { voteDeal };
|
async function getVotes(dealId) {
|
||||||
|
const votes = await voteDb.findVotes(
|
||||||
|
{ dealId },
|
||||||
|
{
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
dealId: true,
|
||||||
|
userId: true,
|
||||||
|
voteType: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastVotedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return votes.map((vote) => ({
|
||||||
|
...vote,
|
||||||
|
createdAt: vote.createdAt.toISOString(),
|
||||||
|
lastVotedAt: vote.lastVotedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { voteDeal, getVotes };
|
||||||
|
|
|
||||||
40
validators/common.js
Normal file
40
validators/common.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
const { z } = require("zod")
|
||||||
|
|
||||||
|
const normalizeStringValue = (value) => {
|
||||||
|
if (value === undefined || value === null) return null
|
||||||
|
if (typeof value !== "string") return value
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed === "" ? null : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionalTrimmedString = () =>
|
||||||
|
z.preprocess((value) => normalizeStringValue(value), z.string().or(z.null()))
|
||||||
|
|
||||||
|
const optionalUrlString = () =>
|
||||||
|
z.preprocess(
|
||||||
|
(value) => {
|
||||||
|
const normalized = normalizeStringValue(value)
|
||||||
|
return normalized === null ? null : normalized
|
||||||
|
},
|
||||||
|
z.string().url().or(z.null())
|
||||||
|
)
|
||||||
|
|
||||||
|
const optionalPrice = () =>
|
||||||
|
z.preprocess(
|
||||||
|
(value) => {
|
||||||
|
if (value === undefined || value === null) return null
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.replace(",", ".").trim()
|
||||||
|
return normalized === "" ? null : Number(normalized)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
z.number().nonnegative().or(z.null())
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizeStringValue,
|
||||||
|
optionalTrimmedString,
|
||||||
|
optionalUrlString,
|
||||||
|
optionalPrice,
|
||||||
|
}
|
||||||
21
validators/dealCreate.validator.js
Normal file
21
validators/dealCreate.validator.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
const { z } = require("zod")
|
||||||
|
const {
|
||||||
|
optionalTrimmedString,
|
||||||
|
optionalUrlString,
|
||||||
|
optionalPrice,
|
||||||
|
} = require("./common")
|
||||||
|
|
||||||
|
const createDealPayloadSchema = z.object({
|
||||||
|
title: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Başlık boş olamaz" })
|
||||||
|
.transform((value) => value.trim()),
|
||||||
|
description: optionalTrimmedString().optional(),
|
||||||
|
url: optionalUrlString().optional(),
|
||||||
|
price: optionalPrice().optional(),
|
||||||
|
sellerName: optionalTrimmedString().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createDealPayloadSchema,
|
||||||
|
}
|
||||||
42
validators/dealListQuery.validator.js
Normal file
42
validators/dealListQuery.validator.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
const { z } = require("zod")
|
||||||
|
|
||||||
|
const { normalizeStringValue } = require("./common")
|
||||||
|
|
||||||
|
const normalizeQueryString = (value) => {
|
||||||
|
if (value === undefined || value === null) return ""
|
||||||
|
if (typeof value !== "string") return value
|
||||||
|
const trimmed = normalizeStringValue(value)
|
||||||
|
return trimmed === null ? "" : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsePositiveInteger = (rawValue) => {
|
||||||
|
if (rawValue === undefined || rawValue === null || rawValue === "") return undefined
|
||||||
|
const candidate =
|
||||||
|
typeof rawValue === "string" ? rawValue.trim() : rawValue
|
||||||
|
if (candidate === "") return undefined
|
||||||
|
const integerValue = Number(candidate)
|
||||||
|
return Number.isInteger(integerValue) ? integerValue : rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSchema = z
|
||||||
|
.preprocess((value) => parsePositiveInteger(value), z.number().int().min(1).max(1000000))
|
||||||
|
.default(1)
|
||||||
|
|
||||||
|
const limitSchema = z
|
||||||
|
.preprocess((value) => parsePositiveInteger(value), z.number().int().min(1).max(100))
|
||||||
|
.default(10)
|
||||||
|
|
||||||
|
const dealListQuerySchema = z.object({
|
||||||
|
q: z
|
||||||
|
.preprocess(
|
||||||
|
(value) => normalizeQueryString(value),
|
||||||
|
z.string().max(200, { message: "Arama sorgusu 200 karakteri geçemez" })
|
||||||
|
)
|
||||||
|
.default(""),
|
||||||
|
page: pageSchema,
|
||||||
|
limit: limitSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dealListQuerySchema,
|
||||||
|
}
|
||||||
60
workers/dealClassification.worker.js
Normal file
60
workers/dealClassification.worker.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
const { Worker } = require("bullmq")
|
||||||
|
const { connection } = require("../jobs/dealClassification.queue")
|
||||||
|
const dealDB = require("../db/deal.db")
|
||||||
|
const dealAiReviewDb = require("../db/dealAiReview.db")
|
||||||
|
const { classifyDeal } = require("../services/dealClassification.service")
|
||||||
|
|
||||||
|
async function handler(job) {
|
||||||
|
const { dealId } = job.data
|
||||||
|
if (!dealId) throw new Error("dealId missing")
|
||||||
|
|
||||||
|
const deal = await dealDB.findDeal(
|
||||||
|
{ id: Number(dealId) },
|
||||||
|
{
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
url: true,
|
||||||
|
seller: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!deal) throw new Error(`Deal not found: ${dealId}`)
|
||||||
|
|
||||||
|
const ai = await classifyDeal({
|
||||||
|
title: deal.title,
|
||||||
|
description: deal.description,
|
||||||
|
url: deal.url,
|
||||||
|
seller: deal.seller?.name ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
await dealAiReviewDb.upsertDealAiReview(deal.id, {
|
||||||
|
best_category_id: ai.best_category_id,
|
||||||
|
needs_review: ai.needs_review,
|
||||||
|
has_issue: ai.has_issue,
|
||||||
|
issue_reason: ai.issue_reason,
|
||||||
|
issue_type: ai.issue_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
// İstersen auto-set (şimdilik dursun, id mismatch riskini biliyorsun)
|
||||||
|
// if (!ai.needs_review && !ai.has_issue) {
|
||||||
|
// await dealDB.updateDealById(deal.id, { categoryId: ai.best_category_id })
|
||||||
|
// }
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDealClassificationWorker() {
|
||||||
|
const worker = new Worker("deal-classification", handler, {
|
||||||
|
connection,
|
||||||
|
concurrency: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
worker.on("completed", (job) => console.log("✅ job completed", job.id))
|
||||||
|
worker.on("failed", (job, err) => console.error("❌ job failed", job?.id, err?.message))
|
||||||
|
|
||||||
|
return worker
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startDealClassificationWorker }
|
||||||
11
workers/index.js
Normal file
11
workers/index.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
require("dotenv").config()
|
||||||
|
const { startDealClassificationWorker } = require("./dealClassification.worker")
|
||||||
|
const { queue } = require("../jobs/dealClassification.queue")
|
||||||
|
|
||||||
|
startDealClassificationWorker()
|
||||||
|
console.log("Worker started: deal-classification")
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
const counts = await queue.getJobCounts("waiting", "active", "completed", "failed", "delayed", "paused")
|
||||||
|
console.log("queue counts:", counts)
|
||||||
|
}, 3000)
|
||||||
Loading…
Reference in New Issue
Block a user