son değişiklikler

This commit is contained in:
cureb 2026-01-25 17:50:56 +00:00
parent 4487709bf2
commit e0f3f5d306
53 changed files with 3430 additions and 732 deletions

View File

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

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

View 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,
};

View File

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

View File

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

View File

@ -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) => {
if (!comment.user)
throw new Error("comment.user is missing (include comments.user in query)")
return {
id: comment.id, id: comment.id,
text: comment.text, text: comment.text,
createdAt: comment.createdAt, createdAt: requiredIsoString(comment.createdAt, "comment.createdAt"),
user: { user: {
id: comment.user.id, id: comment.user.id,
username: comment.user.username, username: comment.user.username,
avatarUrl: comment.user.avatarUrl ?? null, avatarUrl: comment.user.avatarUrl ?? null,
}, },
})), }
}),
breadcrumb: mapBreadcrumbToResponse(deal.breadcrumb),
notice: mapNoticeToResponse(firstNotice),
similarDeals: Array.isArray(deal.similarDeals)
? deal.similarDeals.map(mapSimilarDealItem).filter(Boolean)
: [],
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View 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
View 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
View 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();
};
};

View 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
View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
role UserRole @default(USER)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
Deal Deal[] Deal Deal[]
votes DealVote[] votes DealVote[]
comments Comment[] comments Comment[]
companies Seller[] companies Seller[]
domains SellerDomain[] domains SellerDomain[]
dealVoteHistory DealVoteHistory[] 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,19 +69,19 @@ 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
@ -57,9 +90,9 @@ enum AffiliateType{
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("")
@ -70,7 +103,53 @@ enum AffiliateType{
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())
@ -96,9 +175,48 @@ model Deal {
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
votes DealVote[] votes DealVote[]
voteHistory DealVoteHistory[] voteHistory DealVoteHistory[]
notices DealNotice[] @relation("DealNotices")
comments Comment[] comments Comment[]
images DealImage[] // ← yeni ilişki 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)
@ -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
parentId Int?
deletedAt DateTime?
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
deal Deal @relation(fields: [dealId], 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])
} }

View File

@ -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" },
select: { id: true },
})
// ---------- 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, price: 1299.99,
status: DealStatus.ACTIVE, status: DealStatus.ACTIVE,
saletype: SaleType.ONLINE, saletype: SaleType.ONLINE,
affiliateType: AffiliateType.NON_AFFILIATE, affiliateType: AffiliateType.NON_AFFILIATE,
commentCount:1, commentCount: 1,
userId: user.id, userId: user.id,
sellerId: amazon.id, sellerId: amazon.id,
}, categoryId: catSSD?.id ?? 0,
}) // score: randInt(0, 200), // modelinde varsa aç
}
// ---------- DEAL IMAGES ---------- 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)
}) })

View File

@ -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(
account.avatarUploadResponseSchema.parse({
message: "Avatar updated", message: "Avatar updated",
user: updatedUser, 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 })
} }

View File

@ -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, const { mapLoginRequestToLoginInput, mapLoginResultToResponse } = require("../adapters/responses/login.adapter")
mapRegisterResultToResponse, const { mapRegisterRequestToRegisterInput, mapRegisterResultToResponse } = require("../adapters/responses/register.adapter")
} = require("../adapters/responses/register.adapter"); const { mapMeRequestToUserId, mapMeResultToResponse } = require("../adapters/responses/me.adapter")
const {
mapMeRequestToUserId, const { auth } = endpoints
mapMeResultToResponse,
} = require("../adapters/responses/me.adapter"); // NOT: app.jsde cookie-parser olmalı:
// const cookieParser = require("cookie-parser")
// app.use(cookieParser())
function getCookieOptions() {
const isProd = process.env.NODE_ENV === "production"
// DEV: http localhost -> secure false, sameSite lax
if (!isProd) {
return {
httpOnly: true,
secure: false,
sameSite: "lax",
path: "/",
}
}
// PROD: cross-site kullanacaksan (frontend ayrı domain)
return {
httpOnly: true,
secure: true,
sameSite: "none",
path: "/",
}
}
router.post("/register", async (req, res) => { 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 })
}
function clearRefreshCookie(res) {
const opts = getCookieOptions()
res.clearCookie("rt", { ...opts })
}
router.post(
"/register",
validate(auth.registerRequestSchema, "body", "validatedRegisterInput"),
async (req, res) => {
try { try {
const input = mapRegisterRequestToRegisterInput(req.body); const input = mapRegisterRequestToRegisterInput(req.validatedRegisterInput)
const result = await authService.register(input);
res.json(mapRegisterResultToResponse(result)); 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) { } catch (err) {
const status = err.statusCode || 500; const status = err.statusCode || 500
res.status(status).json({ message: err.message || "Kayit islemi basarisiz." })
}
}
)
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({ res.status(status).json({
message: err.message || "Kayıt işlemi başarısız.", message: err.statusCode ? err.message : "Giris islemi basarisiz.",
}); })
}
} }
}); )
router.post("/refresh", async (req, res) => {
router.post("/login", async (req, res) => {
try { try {
const input = mapLoginRequestToLoginInput(req.body); const refreshToken = req.cookies?.rt
const result = await authService.login(input); if (!refreshToken) return res.status(401).json({ message: "Refresh token yok" })
res.json(mapLoginResultToResponse(result));
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) { } catch (err) {
const status = err.statusCode || 500; clearRefreshCookie(res)
res.status(status).json({ message: err.message || "Giriş işlemi başarısız." }); const status = err.statusCode || 401
res.status(status).json({ message: err.message || "Refresh basarisiz" })
} }
}); })
router.post("/logout", async (req, res) => {
router.get("/me", authRequiredMiddleware
, async (req, res) => {
try { try {
const userId = mapMeRequestToUserId(req); const refreshToken = req.cookies?.rt
const user = await authService.getMe(userId);
res.json(mapMeResultToResponse(user)); // logout idempotent olsun
} catch (err) { if (refreshToken) {
const status = err.statusCode || 500; await authService.logout({ refreshToken })
res.status(status).json({
message: err.message || "Sunucu hatası",
});
} }
});
module.exports = router; 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
View 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;

View File

@ -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))
router.get(
"/:dealId",
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) { } catch (err) {
console.log(err.message)
res.status(400).json({ error: err.message }) res.status(400).json({ error: err.message })
} }
})
router.post("/", authRequiredMiddleware
, async (req, res) => {
try {
const { dealId, text } = req.body
const userId = req.user.userId
if (!text?.trim()) return res.status(400).json({ error: "Yorum boş olamaz." })
const comment = await createComment({ dealId, userId, text })
res.json(comment)
} catch (err) {
console.error(err)
res.status(500).json({ error: err.message || "Sunucu hatası" })
} }
}) )
router.delete("/:id", authRequiredMiddleware router.post(
, async (req, res) => { "/",
requireAuth,
validate(comments.commentCreateRequestSchema, "body", "validatedCommentPayload"),
async (req, res) => {
try { try {
const result = await deleteComment(req.params.id, req.user.userId) const { dealId, text, parentId } = req.validatedCommentPayload
res.json(result) const userId = req.auth.userId
const comment = await createComment({ dealId, userId, text, parentId })
const mapped = dealCommentAdapter.mapCommentToDealCommentResponse(comment)
res.json(comments.commentCreateResponseSchema.parse(mapped))
} catch (err) { } catch (err) {
console.error(err) res.status(500).json({ error: err.message || "Sunucu hatasi" })
const status = err.message.includes("yetkin") ? 403 : 404 }
}
)
router.delete(
"/:id",
requireAuth,
validate(comments.commentDeleteRequestSchema, "params", "validatedDeleteComment"),
async (req, res) => {
try {
const { id } = req.validatedDeleteComment
const result = await deleteComment(id, req.auth.userId)
res.json(comments.commentDeleteResponseSchema.parse(result))
} catch (err) {
const status = err.message?.includes("yetkin") ? 403 : 404
res.status(status).json({ error: err.message }) res.status(status).json({ error: err.message })
} }
}) }
)
module.exports = router module.exports = router

View File

@ -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")
const buildViewer = (req) =>
req.auth ? { userId: req.auth.userId, role: req.auth.role } : null
function createListHandler(preset) {
return async (req, res) => {
try { try {
const q = (req.query.q ?? "").toString().trim() const viewer = buildViewer(req)
const page = Number(req.query.page) || 1 const { q, page, limit } = req.validatedDealListQuery
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 payload = await getDeals({
} catch (e) { preset,
console.error(e) q,
res.status(500).json({ error: "Sunucu hatası" }) page,
limit,
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" })
} }
}) }
}
// Public deals of a user (viewer optional; self profile => "MY" else "USER_PUBLIC")
router.get("/search", async (req, res) => { router.get(
"/users/:userName/deals",
optionalAuth,
validate(users.userProfileRequestSchema, "params", "validatedUserProfile"),
listQueryValidator,
async (req, res) => {
try { try {
const query = req.query.q || "" const { userName } = req.validatedUserProfile
const page = Number(req.query.page) || 1 const targetUser = await userDB.findUser(
const limit = 10 { username: userName },
{ select: { id: true } }
)
if (!query.trim()) { 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 }) return res.json({ results: [], total: 0, totalPages: 0, page })
} }
const data = await searchDeals(query, page, limit) const payload = await getDeals({
res.json(mapPaginatedDealsToDealCardResponse(data)) preset: "NEW",
} catch (e) { q,
console.error(e) page,
res.status(500).json({ error: "Sunucu hatası" }) limit,
} viewer: buildViewer(req),
}) })
router.get("/:id", async (req, res) => { //MAPPED const response = deals.dealsListResponseSchema.parse(
try { mapPaginatedDealsToDealCardResponse(payload)
const deal = await getDealById(req.params.id) )
if (!deal) return res.status(404).json({ error: "Deal bulunamadı" }) res.json(response)
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 {
const userId = req.user.userId;
const dealCreateData = mapCreateDealRequestToDealCreateData(req.body, userId);
const deal = await createDeal(dealCreateData, req.files || []);
res.json(deal);
} 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" })
} }
} }
); )
// 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

View File

@ -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,
name: sellerLookup.name,
url: sellerLookup.url ?? null,
},
} }
return res.json({ : { found: false, seller: null }
id: Seller.id, )
name: Seller.name,
})
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" })
} }
}) })

View File

@ -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
router.get(
"/:userName",
validate(users.userProfileRequestSchema, "params", "validatedUserProfile"),
async (req, res) => {
try { try {
const data = await userService.getUserProfileByUsername(req.params.userName) const { userName } = req.validatedUserProfile
res.json(userProfileAdapter.mapUserProfileToResponse(data)) const data = await userService.getUserProfileByUsername(userName)
const response = users.userProfileResponseSchema.parse(
userProfileAdapter.mapUserProfileToResponse(data)
)
res.json(response)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
const status = err.statusCode || 500 const status = err.statusCode || 500
res.status(status).json({ message: err.message || "Profil bilgileri alınamadı." }) res.status(status).json({
message: err.message || "Profil bilgileri alinamadi.",
})
} }
}) }
)
module.exports = router module.exports = router

View File

@ -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) => { "/",
requireAuth,
validate(votes.voteRequestSchema, "body", "validatedVotePayload"),
async (req, res) => {
try { try {
const input = mapVoteRequestToVoteInput(req); const { dealId, voteType } = req.validatedVotePayload
const result = await voteService.voteDeal(input); const result = await voteService.voteDeal({
res.json(result); dealId,
voteType,
userId: req.auth.userId,
})
res.json(votes.voteResponseSchema.parse(result))
} catch (err) { } catch (err) {
const status = err.statusCode || 500; const status = err.statusCode || 500
res.status(status).json({ message: err.message || "Sunucu hatası" }); 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" })
router.get(
"/:dealId",
validate(votes.voteListRequestSchema, "params", "validatedVoteList"),
async (req, res) => {
try {
const { dealId } = req.validatedVoteList
const data = await voteService.getVotes(dealId) const data = await voteService.getVotes(dealId)
res.json(data) res.json(votes.voteListResponseSchema.parse({ votes: data }))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
res.status(500).json({ error: "Sunucu hatası" }) res.status(500).json({ error: "Sunucu hatasi" })
} }
}) }
)
module.exports = router module.exports = router

View File

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

View File

@ -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) + DBde 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 { return {
token,
user: {
id: user.id, id: user.id,
username: user.username, username: user.username,
email: user.email, email: user.email,
avatarUrl: user.avatarUrl ?? null, avatarUrl: user.avatarUrl ?? null,
role: user.role,
}
}
async function login({ email, password, meta = {} }) {
const user = await authDb.findUserByEmail(email)
if (!user) throw httpError(400, "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 {
accessToken,
refreshToken,
user: mapUserPublic(user),
}
}
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,
}; }

View 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,
};

View File

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

View File

@ -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 = {
async function getDeals({ q = "", page = 1, limit = 10, userId = null }) {
const skip = (page - 1) * limit
const queryRaw = (q ?? "").toString().trim()
const query =
queryRaw === "undefined" || queryRaw === "null" ? "" : queryRaw
const where =
query.length > 0
? {
OR: [
{ title: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
],
}
: {}
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 } }, user: { select: { id: true, username: true, avatarUrl: true } },
seller: { select: { id: true, name: true, url: true } },
images: { images: {
orderBy: { order: "asc" }, orderBy: { order: "asc" },
take: 1, take: 1,
select: { imageUrl: true }, select: { imageUrl: true },
}, },
}, }
}),
dealDB.countDeals(where),
])
// auth yoksa myVote=0 function formatDateAsString(value) {
if (!userId) { return value instanceof Date ? value.toISOString() : value ?? null
return { }
page,
total, function clampPagination({ page, limit }) {
totalPages: Math.ceil(total / limit), const rawPage = Number(page)
results: deals.map((d) => ({ ...d, myVote: 0 })), const rawLimit = Number(limit)
} const normalizedPage = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1
let normalizedLimit = Number.isFinite(rawLimit) ? Math.max(1, Math.floor(rawLimit)) : DEFAULT_LIMIT
normalizedLimit = Math.min(MAX_LIMIT, normalizedLimit)
const skip = (normalizedPage - 1) * normalizedLimit
if (skip > MAX_SKIP) {
const err = new Error("PAGE_TOO_DEEP")
err.statusCode = 400
throw err
} }
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 -> DBde 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,

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

View File

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

View File

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

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

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

View 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
View 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)