From e0f3f5d3067be11902ac23e55e8d337879501f1b Mon Sep 17 00:00:00 2001 From: cureb Date: Sun, 25 Jan 2026 17:50:56 +0000 Subject: [PATCH] =?UTF-8?q?son=20de=C4=9Fi=C5=9Fiklikler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adapters/requests/dealCreate.adapter.js | 36 +- adapters/responses/breadCrumb.adapter.js | 15 + adapters/responses/categoryDetails.adapter.js | 16 + adapters/responses/comment.adapter.js | 10 +- adapters/responses/dealCard.adapter.js | 24 +- adapters/responses/dealDetail.adapter.js | 106 ++++- adapters/responses/login.adapter.js | 25 +- adapters/responses/publicUser.adapter.js | 6 +- adapters/responses/register.adapter.js | 17 +- db/category.db.js | 63 +++ db/comment.db.js | 10 +- db/deal.db.js | 95 ++++ db/dealAiReview.db.js | 74 +++ db/refreshToken.db.js | 106 +++++ jobs/dealClassification.queue.js | 24 + middleware/authOptional.middleware.js | 31 -- middleware/authRequired.middleware.js | 19 - middleware/optionalAuth.js | 26 ++ middleware/requireAuth.js | 29 ++ middleware/requireRole.js | 14 + middleware/validate.middleware.js | 17 + package-lock.json | 355 ++++++++++++++ package.json | 5 + prisma/categories.json | 239 ++++++++++ .../20260123184800_user_role/migration.sql | 5 + .../migration.sql | 29 ++ .../migration.sql | 25 + .../migration.sql | 63 +++ .../migration.sql | 26 ++ .../migration.sql | 37 ++ prisma/schema.prisma | 287 +++++++++--- prisma/seed.js | 364 ++++++++++++--- routes/accountSettings.routes.js | 35 +- routes/auth.routes.js | 203 ++++++-- routes/category.routes.js | 49 ++ routes/comment.routes.js | 90 ++-- routes/deal.routes.js | 260 ++++++++--- routes/seller.routes.js | 40 +- routes/user.routes.js | 34 +- routes/vote.routes.js | 60 ++- server.js | 64 +-- services/auth.service.js | 236 +++++++--- services/category.service.js | 69 +++ services/comment.service.js | 34 +- services/deal.service.js | 438 ++++++++++++------ services/dealClassification.service.js | 122 +++++ services/user.service.js | 31 +- services/vote.service.js | 25 +- validators/common.js | 40 ++ validators/dealCreate.validator.js | 21 + validators/dealListQuery.validator.js | 42 ++ workers/dealClassification.worker.js | 60 +++ workers/index.js | 11 + 53 files changed, 3430 insertions(+), 732 deletions(-) create mode 100644 adapters/responses/breadCrumb.adapter.js create mode 100644 adapters/responses/categoryDetails.adapter.js create mode 100644 db/category.db.js create mode 100644 db/dealAiReview.db.js create mode 100644 db/refreshToken.db.js create mode 100644 jobs/dealClassification.queue.js delete mode 100644 middleware/authOptional.middleware.js delete mode 100644 middleware/authRequired.middleware.js create mode 100644 middleware/optionalAuth.js create mode 100644 middleware/requireAuth.js create mode 100644 middleware/requireRole.js create mode 100644 middleware/validate.middleware.js create mode 100644 prisma/categories.json create mode 100644 prisma/migrations/20260123184800_user_role/migration.sql create mode 100644 prisma/migrations/20260124005955_add_deal_notice/migration.sql create mode 100644 prisma/migrations/20260124031835_add_comment_delete/migration.sql create mode 100644 prisma/migrations/20260124164136_add_category_and_tags/migration.sql create mode 100644 prisma/migrations/20260124194106_add_dealaireview_table/migration.sql create mode 100644 prisma/migrations/20260124213232_add_refreshtoken_table/migration.sql create mode 100644 routes/category.routes.js create mode 100644 services/category.service.js create mode 100644 services/dealClassification.service.js create mode 100644 validators/common.js create mode 100644 validators/dealCreate.validator.js create mode 100644 validators/dealListQuery.validator.js create mode 100644 workers/dealClassification.worker.js create mode 100644 workers/index.js diff --git a/adapters/requests/dealCreate.adapter.js b/adapters/requests/dealCreate.adapter.js index 6c6dbdd..977f5c3 100644 --- a/adapters/requests/dealCreate.adapter.js +++ b/adapters/requests/dealCreate.adapter.js @@ -1,33 +1,21 @@ -function mapCreateDealRequestToDealCreateData( - data, - userId -) { - return { - title: data.title, - description: data.description ?? null, - url: data.url ?? null, - price: Number(data.price) ?? null, +function mapCreateDealRequestToDealCreateData(payload, userId) { + const { title, description, url, price, sellerName } = payload - // 🔑 adapter burada seller’ı “custom” gibi yazar - // service bunu düzeltecek - customSeller: data.sellerName, + return { + title, + description: description ?? null, + url: url ?? null, + price: price ?? null, + + // Burada customSeller yazıyoruz; servis gerektiğinde ilişkilendiriyor. + customSeller: sellerName ?? null, user: { connect: { id: userId }, }, -/* - images: data.images?.length - ? { - create: data.images.map((imgUrl, index) => ({ - imageUrl: imgUrl, - order: index, - })), - } - : undefined, - */ + } +} -} -} module.exports = { mapCreateDealRequestToDealCreateData, } diff --git a/adapters/responses/breadCrumb.adapter.js b/adapters/responses/breadCrumb.adapter.js new file mode 100644 index 0000000..eabd21d --- /dev/null +++ b/adapters/responses/breadCrumb.adapter.js @@ -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 + +} \ No newline at end of file diff --git a/adapters/responses/categoryDetails.adapter.js b/adapters/responses/categoryDetails.adapter.js new file mode 100644 index 0000000..c730e17 --- /dev/null +++ b/adapters/responses/categoryDetails.adapter.js @@ -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, +}; diff --git a/adapters/responses/comment.adapter.js b/adapters/responses/comment.adapter.js index f1bb1ef..d8196f8 100644 --- a/adapters/responses/comment.adapter.js +++ b/adapters/responses/comment.adapter.js @@ -1,8 +1,12 @@ +const formatDateAsString = (value) => + value instanceof Date ? value.toISOString() : value ?? null + function mapCommentToDealCommentResponse(comment) { return { id: comment.id, - text: comment.text, // eğer DB'de content ise burada text'e çevir - createdAt: comment.createdAt, + text: comment.text, // eÄŸer DB'de content ise burada text'e çevir + createdAt: formatDateAsString(comment.createdAt), + parentId:comment.parentId, user: { id: comment.user.id, username: comment.user.username, @@ -27,4 +31,4 @@ module.exports = { mapCommentToDealCommentResponse, mapCommentsToDealCommentResponse, mapCommentToUserCommentResponse -} \ No newline at end of file +} diff --git a/adapters/responses/dealCard.adapter.js b/adapters/responses/dealCard.adapter.js index ffca02a..fe1c7ad 100644 --- a/adapters/responses/dealCard.adapter.js +++ b/adapters/responses/dealCard.adapter.js @@ -1,3 +1,5 @@ +const formatDateAsString = (value) => (value instanceof Date ? value.toISOString() : value ?? null) + function mapDealToDealCardResponse(deal) { return { id: deal.id, @@ -7,15 +9,15 @@ function mapDealToDealCardResponse(deal) { score: deal.score, commentsCount: deal.commentCount, - + url:deal.url, status: deal.status, saleType: deal.saletype, affiliateType: deal.affiliateType, - myVote:deal.myVote, + myVote: deal.myVote ?? 0, - createdAt: deal.createdAt, - updatedAt: deal.updatedAt, + createdAt: formatDateAsString(deal.createdAt), + updatedAt: formatDateAsString(deal.updatedAt), user: { id: deal.user.id, @@ -24,11 +26,15 @@ function mapDealToDealCardResponse(deal) { }, seller: deal.seller - ? { name: deal.seller.name, - url:deal.seller.url - } - : { name: deal.customSeller || "" }, - + ? { + name: deal.seller.name, + url: deal.seller.url ?? null, + } + : { + name: deal.customSeller || "", + url: null, + }, + imageUrl: deal.images?.[0]?.imageUrl || "", } } diff --git a/adapters/responses/dealDetail.adapter.js b/adapters/responses/dealDetail.adapter.js index a78ea9d..3496f15 100644 --- a/adapters/responses/dealDetail.adapter.js +++ b/adapters/responses/dealDetail.adapter.js @@ -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) { + 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 { id: deal.id, title: deal.title, description: deal.description || "", url: deal.url ?? null, price: deal.price ?? null, - score: deal.score, + score: Number.isFinite(deal.score) ? deal.score : 0, commentsCount: deal._count?.comments ?? 0, status: deal.status, - saleType: deal.saletype, + saleType: deal.saletype, // ✅ FIX: saletype değil affiliateType: deal.affiliateType, - createdAt: deal.createdAt, - updatedAt: deal.updatedAt, + createdAt: requiredIsoString(deal.createdAt, "deal.createdAt"), + updatedAt: requiredIsoString(deal.updatedAt, "deal.updatedAt"), user: { id: deal.user.id, @@ -22,26 +72,48 @@ function mapDealToDealDetailResponse(deal) { avatarUrl: deal.user.avatarUrl ?? null, }, + // ✅ FIX: SellerSummarySchema genelde id ister -> custom seller için -1 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, imageUrl: img.imageUrl, order: img.order, })), - comments: deal.comments.map((comment) => ({ - id: comment.id, - text: comment.text, - createdAt: comment.createdAt, - user: { - id: comment.user.id, - username: comment.user.username, - avatarUrl: comment.user.avatarUrl ?? null, - }, - })), + comments: (deal.comments || []).map((comment) => { + if (!comment.user) + throw new Error("comment.user is missing (include comments.user in query)") + + return { + id: comment.id, + text: comment.text, + createdAt: requiredIsoString(comment.createdAt, "comment.createdAt"), + user: { + id: comment.user.id, + username: comment.user.username, + avatarUrl: comment.user.avatarUrl ?? null, + }, + } + }), + + breadcrumb: mapBreadcrumbToResponse(deal.breadcrumb), + + notice: mapNoticeToResponse(firstNotice), + + similarDeals: Array.isArray(deal.similarDeals) + ? deal.similarDeals.map(mapSimilarDealItem).filter(Boolean) + : [], } } diff --git a/adapters/responses/login.adapter.js b/adapters/responses/login.adapter.js index 16755b3..7d6a95c 100644 --- a/adapters/responses/login.adapter.js +++ b/adapters/responses/login.adapter.js @@ -1,26 +1,19 @@ -// adapters/login.adapter.js - -function mapLoginRequestToLoginInput(body) { +// adapters/responses/login.adapter.js +function mapLoginRequestToLoginInput(input) { return { - email: (body?.email || "").trim().toLowerCase(), - password: body?.password || "", - }; + email: input.email, + password: input.password, + } } function mapLoginResultToResponse(result) { - // result: { token, user } return { - token: result.token, - user: { - id: result.user.id, - username: result.user.username, - email: result.user.email, - avatarUrl: result.user.avatarUrl ?? null, - }, - }; + token: result.accessToken, // <-- KRİTİK + user: result.user, + } } module.exports = { mapLoginRequestToLoginInput, mapLoginResultToResponse, -}; +} diff --git a/adapters/responses/publicUser.adapter.js b/adapters/responses/publicUser.adapter.js index 28bb341..817a40e 100644 --- a/adapters/responses/publicUser.adapter.js +++ b/adapters/responses/publicUser.adapter.js @@ -1,3 +1,6 @@ +const formatDateAsString = (value) => + value instanceof Date ? value.toISOString() : value ?? null + // adapters/responses/publicUser.adapter.js function mapUserToPublicUserSummaryResponse(user) { return { @@ -12,7 +15,8 @@ function mapUserToPublicUserDetailsResponse(user) { id: user.id, username: user.username, avatarUrl: user.avatarUrl ?? null, - createdAt: user.createdAt, // ISO string olmalı + email: user.email, + createdAt: formatDateAsString(user.createdAt), // ISO string } } diff --git a/adapters/responses/register.adapter.js b/adapters/responses/register.adapter.js index b04e1f6..f73def8 100644 --- a/adapters/responses/register.adapter.js +++ b/adapters/responses/register.adapter.js @@ -1,19 +1,20 @@ -function mapRegisterRequestToRegisterInput(body) { +// adapters/responses/register.adapter.js +function mapRegisterRequestToRegisterInput(input) { return { - username: (body?.username || "").trim(), - email: (body?.email || "").trim().toLowerCase(), - password: body?.password || "", - }; + username: input.username, + email: input.email, + password: input.password, + } } function mapRegisterResultToResponse(result) { return { - token: result.token, + token: result.accessToken, // <-- KRİTİK user: result.user, - }; + } } module.exports = { mapRegisterRequestToRegisterInput, mapRegisterResultToResponse, -}; +} diff --git a/db/category.db.js b/db/category.db.js new file mode 100644 index 0000000..de3c9ff --- /dev/null +++ b/db/category.db.js @@ -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, +}; diff --git a/db/comment.db.js b/db/comment.db.js index 77e82f2..55fe90b 100644 --- a/db/comment.db.js +++ b/db/comment.db.js @@ -12,7 +12,14 @@ async function findComments(where, options = {}) { 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) { const p = getDb(db) return p.comment.create({ @@ -36,4 +43,5 @@ module.exports = { countComments, createComment, deleteComment, + findComment } diff --git a/db/deal.db.js b/db/deal.db.js index f04bb3f..5278138 100644 --- a/db/deal.db.js +++ b/db/deal.db.js @@ -4,6 +4,61 @@ function getDb(db) { 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 = {}) { return prisma.deal.findMany({ 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) { const p = getDb(db) return p.deal.findUnique({ @@ -105,4 +196,8 @@ module.exports = { createVote, updateVote, countVotes, + findSimilarCandidatesByCategory, + findSimilarCandidatesBySeller, + getDealCards, + getPaginatedDealCards } diff --git a/db/dealAiReview.db.js b/db/dealAiReview.db.js new file mode 100644 index 0000000..962458e --- /dev/null +++ b/db/dealAiReview.db.js @@ -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, +} diff --git a/db/refreshToken.db.js b/db/refreshToken.db.js new file mode 100644 index 0000000..b772273 --- /dev/null +++ b/db/refreshToken.db.js @@ -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, +} diff --git a/jobs/dealClassification.queue.js b/jobs/dealClassification.queue.js new file mode 100644 index 0000000..0a24e5c --- /dev/null +++ b/jobs/dealClassification.queue.js @@ -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 } diff --git a/middleware/authOptional.middleware.js b/middleware/authOptional.middleware.js deleted file mode 100644 index 2e98f95..0000000 --- a/middleware/authOptional.middleware.js +++ /dev/null @@ -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" }); - } -}; diff --git a/middleware/authRequired.middleware.js b/middleware/authRequired.middleware.js deleted file mode 100644 index c5051a9..0000000 --- a/middleware/authRequired.middleware.js +++ /dev/null @@ -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" }); - } -}; diff --git a/middleware/optionalAuth.js b/middleware/optionalAuth.js new file mode 100644 index 0000000..eb4b3d2 --- /dev/null +++ b/middleware/optionalAuth.js @@ -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" }) + } +} diff --git a/middleware/requireAuth.js b/middleware/requireAuth.js new file mode 100644 index 0000000..f53fabe --- /dev/null +++ b/middleware/requireAuth.js @@ -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" }) + } +} diff --git a/middleware/requireRole.js b/middleware/requireRole.js new file mode 100644 index 0000000..79c3c03 --- /dev/null +++ b/middleware/requireRole.js @@ -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(); + }; +}; diff --git a/middleware/validate.middleware.js b/middleware/validate.middleware.js new file mode 100644 index 0000000..c066138 --- /dev/null +++ b/middleware/validate.middleware.js @@ -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 } diff --git a/package-lock.json b/package-lock.json index a834af5..e2b8964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,17 @@ "license": "ISC", "dependencies": { "@prisma/client": "^6.18.0", + "@shared/contracts": "file:../Contracts", "@supabase/supabase-js": "^2.78.0", "bcryptjs": "^3.0.2", + "bullmq": "^5.67.0", + "contracts": "^0.4.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "multer": "^2.0.2", + "openai": "^6.16.0", "sharp": "^0.34.5", "uuid": "^13.0.0", "zod": "^4.1.12" @@ -30,6 +35,18 @@ "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": { "version": "0.8.1", "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" } }, + "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -546,6 +569,84 @@ "@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": { "version": "6.19.2", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", @@ -631,6 +732,10 @@ "@prisma/debug": "6.19.2" } }, + "node_modules/@shared/contracts": { + "resolved": "../Contracts", + "link": true + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1011,6 +1116,34 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "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": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -1132,6 +1265,15 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1216,6 +1358,18 @@ "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": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -1225,6 +1379,25 @@ "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": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -1254,6 +1427,18 @@ "dev": true, "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1288,6 +1473,15 @@ "devOptional": true, "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1818,6 +2012,30 @@ "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": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1924,6 +2142,14 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -1955,12 +2181,24 @@ "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": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -1997,6 +2235,15 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "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": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -2086,6 +2333,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", @@ -2156,6 +2434,12 @@ "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": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -2163,6 +2447,21 @@ "devOptional": true, "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": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", @@ -2232,6 +2531,27 @@ "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": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2458,6 +2778,27 @@ "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": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -2727,6 +3068,12 @@ "dev": true, "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2978,6 +3325,14 @@ "dev": true, "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index ddb25b2..ab18bc5 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,17 @@ "type": "commonjs", "dependencies": { "@prisma/client": "^6.18.0", + "@shared/contracts": "file:../Contracts", "@supabase/supabase-js": "^2.78.0", "bcryptjs": "^3.0.2", + "bullmq": "^5.67.0", + "contracts": "^0.4.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "multer": "^2.0.2", + "openai": "^6.16.0", "sharp": "^0.34.5", "uuid": "^13.0.0", "zod": "^4.1.12" diff --git a/prisma/categories.json b/prisma/categories.json new file mode 100644 index 0000000..d3bbef6 --- /dev/null +++ b/prisma/categories.json @@ -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 } +] diff --git a/prisma/migrations/20260123184800_user_role/migration.sql b/prisma/migrations/20260123184800_user_role/migration.sql new file mode 100644 index 0000000..f983606 --- /dev/null +++ b/prisma/migrations/20260123184800_user_role/migration.sql @@ -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'; diff --git a/prisma/migrations/20260124005955_add_deal_notice/migration.sql b/prisma/migrations/20260124005955_add_deal_notice/migration.sql new file mode 100644 index 0000000..2448fb9 --- /dev/null +++ b/prisma/migrations/20260124005955_add_deal_notice/migration.sql @@ -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; diff --git a/prisma/migrations/20260124031835_add_comment_delete/migration.sql b/prisma/migrations/20260124031835_add_comment_delete/migration.sql new file mode 100644 index 0000000..505babd --- /dev/null +++ b/prisma/migrations/20260124031835_add_comment_delete/migration.sql @@ -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; diff --git a/prisma/migrations/20260124164136_add_category_and_tags/migration.sql b/prisma/migrations/20260124164136_add_category_and_tags/migration.sql new file mode 100644 index 0000000..ee0798e --- /dev/null +++ b/prisma/migrations/20260124164136_add_category_and_tags/migration.sql @@ -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; diff --git a/prisma/migrations/20260124194106_add_dealaireview_table/migration.sql b/prisma/migrations/20260124194106_add_dealaireview_table/migration.sql new file mode 100644 index 0000000..ff21246 --- /dev/null +++ b/prisma/migrations/20260124194106_add_dealaireview_table/migration.sql @@ -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; diff --git a/prisma/migrations/20260124213232_add_refreshtoken_table/migration.sql b/prisma/migrations/20260124213232_add_refreshtoken_table/migration.sql new file mode 100644 index 0000000..c9c0ac8 --- /dev/null +++ b/prisma/migrations/20260124213232_add_refreshtoken_table/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2c24115..2a87087 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,9 +1,6 @@ // This is your Prisma schema file, // 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 { provider = "prisma-client-js" } @@ -13,20 +10,56 @@ datasource db { url = env("DATABASE_URL") } +enum UserRole { + USER + MOD + ADMIN +} + model User { - id Int @id @default(autoincrement()) - username String @unique - email String @unique - passwordHash String - avatarUrl String? @db.VarChar(512) - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - Deal Deal[] - votes DealVote[] - comments Comment[] - companies Seller[] - domains SellerDomain[] - dealVoteHistory DealVoteHistory[] + id Int @id @default(autoincrement()) + username String @unique + email String @unique + passwordHash String + avatarUrl String? @db.VarChar(512) + role UserRole @default(USER) + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + Deal Deal[] + votes DealVote[] + comments Comment[] + companies Seller[] + domains SellerDomain[] + dealVoteHistory DealVoteHistory[] + dealNotices DealNotice[] @relation("UserDealNotices") + + refreshTokens RefreshToken[] // <-- bunu ekle +} + +model RefreshToken { + id String @id @default(cuid()) // token kaydı id + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + tokenHash String @unique // refresh token hash (örn sha256) + familyId String // rotation zinciri için aynı aile + jti String // token id (JWT jti / random) + expiresAt DateTime + createdAt DateTime @default(now()) + + revokedAt DateTime? + replacedById String? @unique + replacedBy RefreshToken? @relation("TokenRotation", fields: [replacedById], references: [id]) + replaces RefreshToken? @relation("TokenRotation") + + createdByIp String? + userAgent String? + + @@index([userId]) + @@index([familyId]) + @@index([expiresAt]) } enum DealStatus { @@ -36,69 +69,154 @@ enum DealStatus { REJECTED } -enum SaleType{ +enum SaleType { ONLINE OFFLINE CODE } -enum AffiliateType{ +enum AffiliateType { AFFILIATE NON_AFFILIATE USER_AFFILIATE } - model SellerDomain { - id Int @id @default(autoincrement()) - domain String @unique - sellerId Int - seller Seller @relation(fields: [sellerId], references: [id]) +model SellerDomain { + id Int @id @default(autoincrement()) + domain String @unique + sellerId Int + seller Seller @relation(fields: [sellerId], references: [id]) - createdAt DateTime @default(now()) - createdById Int - createdBy User @relation(fields: [createdById], references: [id]) - } + createdAt DateTime @default(now()) + createdById Int + createdBy User @relation(fields: [createdById], references: [id]) +} - model Seller { - id Int @id @default(autoincrement()) - name String @unique - url String @default("") - isActive Boolean @default(true) - createdAt DateTime @default(now()) - createdById Int +model Seller { + id Int @id @default(autoincrement()) + name String @unique + url String @default("") + isActive Boolean @default(true) + createdAt DateTime @default(now()) + createdById Int - deals Deal[] - createdBy User @relation(fields: [createdById], references: [id]) - domains SellerDomain[] - } + deals Deal[] + createdBy User @relation(fields: [createdById], references: [id]) + 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 { - id Int @id @default(autoincrement()) - title String - description String? - url String? - price Float? + id Int @id @default(autoincrement()) + title String + description String? + url String? + price Float? - userId Int - score Int @default(0) - commentCount Int @default(0) - status DealStatus @default(PENDING) - saletype SaleType @default(ONLINE) + userId Int + score Int @default(0) + commentCount Int @default(0) + status DealStatus @default(PENDING) + saletype SaleType @default(ONLINE) affiliateType AffiliateType @default(NON_AFFILIATE) sellerId Int? customSeller String? - - seller Seller? @relation(fields: [sellerId], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - votes DealVote[] + seller Seller? @relation(fields: [sellerId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id]) + votes DealVote[] voteHistory DealVoteHistory[] - comments Comment[] - images DealImage[] // ← yeni ilişki + notices DealNotice[] @relation("DealNotices") + comments Comment[] + images DealImage[] + + // NEW: category (single) + categoryId Int @default(0) + category Category @relation(fields: [categoryId], references: [id]) + + // NEW: tags (multiple, optional) + dealTags DealTag[] + aiReview DealAiReview? + @@index([categoryId, createdAt]) + @@index([userId, createdAt]) } + +enum DealNoticeSeverity { + INFO + WARNING + DANGER + SUCCESS +} + +model DealNotice { + id Int @id @default(autoincrement()) + dealId Int + title String + body String? + + severity DealNoticeSeverity @default(INFO) + isActive Boolean @default(true) + + createdBy Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + deal Deal @relation("DealNotices", fields: [dealId], references: [id], onDelete: Cascade) + creator User @relation("UserDealNotices", fields: [createdBy], references: [id]) + + @@index([dealId, isActive, createdAt]) + @@index([createdBy]) +} + model DealImage { id Int @id @default(autoincrement()) imageUrl String @db.VarChar(512) @@ -110,11 +228,11 @@ model DealImage { } model DealVote { - id Int @id @default(autoincrement()) - dealId Int - userId Int - voteType Int @default(0) // -1,0,1 - createdAt DateTime @default(now()) + id Int @id @default(autoincrement()) + dealId Int + userId Int + voteType Int @default(0) // -1,0,1 + createdAt DateTime @default(now()) lastVotedAt DateTime @default(now()) // her vote değişiminde set edeceğiz deal Deal @relation(fields: [dealId], references: [id]) @@ -139,14 +257,55 @@ model DealVoteHistory { @@index([createdAt]) } - model Comment { id Int @id @default(autoincrement()) text String createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + userId Int dealId Int - user User @relation(fields: [userId], references: [id]) - deal Deal @relation(fields: [dealId], references: [id]) + parentId Int? + + deletedAt DateTime? + + user User @relation(fields: [userId], references: [id]) + deal Deal @relation(fields: [dealId], references: [id]) + + parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull) + replies Comment[] @relation("CommentReplies") + + @@index([dealId, createdAt]) + @@index([parentId, createdAt]) + @@index([dealId, parentId, createdAt]) + @@index([deletedAt]) +} + +enum DealAiIssueType { + NONE + PROFANITY + PHONE_NUMBER + PERSONAL_DATA + SPAM + OTHER +} + +model DealAiReview { + id Int @id @default(autoincrement()) + + dealId Int @unique + deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade) + + bestCategoryId Int + needsReview Boolean @default(false) + + hasIssue Boolean @default(false) + issueType DealAiIssueType @default(NONE) + issueReason String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([needsReview, hasIssue, updatedAt]) } \ No newline at end of file diff --git a/prisma/seed.js b/prisma/seed.js index d6567a8..9e40d25 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -1,51 +1,265 @@ -const { PrismaClient, DealStatus, SaleType, AffiliateType } = require('@prisma/client') -const bcrypt = require("bcryptjs"); +// prisma/seed.js +const { PrismaClient, DealStatus, SaleType, AffiliateType } = require("@prisma/client") +const bcrypt = require("bcryptjs") +const fs = require("fs") +const path = require("path") 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() { - const password = 'test' - const hashedPassword = await bcrypt.hash(password, 10) + const hashedPassword = "$2b$10$PVfLq2NmcGmKbhE5VK3yNeVj46O/1w2p/2BNu4h1CYacqSgkCcoCW" // ---------- USERS ---------- const admin = await prisma.user.upsert({ - where: { email: 'test' }, + where: { email: "test" }, update: {}, create: { - username: 'test', - email: 'test', + username: "test", + email: "test", passwordHash: hashedPassword, + role: "ADMIN", }, }) const user = await prisma.user.upsert({ - where: { email: 'test2' }, + where: { email: "test2" }, update: {}, create: { - username: 'test2', - email: 'test2', + username: "test2", + email: "test2", passwordHash: hashedPassword, + role: "USER", }, }) - // ---------- Seller ---------- + // ---------- SELLER ---------- const amazon = await prisma.seller.upsert({ - where: { name: 'Amazon' }, - update: {}, + where: { name: "Amazon" }, + update: { isActive: true }, create: { - name: 'Amazon', + name: "Amazon", + url: "https://www.amazon.com.tr", isActive: true, createdById: admin.id, }, }) - // ---------- Seller DOMAINS ---------- - const domains = ['amazon.com', 'amazon.com.tr'] - + // ---------- SELLER DOMAINS ---------- + const domains = ["amazon.com", "amazon.com.tr"] for (const domain of domains) { - await prisma.SellerDomain.upsert({ + await prisma.sellerDomain.upsert({ where: { domain }, - update: {}, + update: { sellerId: amazon.id }, create: { domain, sellerId: amazon.id, @@ -54,68 +268,90 @@ async function main() { }) } - // ---------- DEAL ---------- - const deal = await prisma.deal.create({ - data: { - title: 'Samsung SSD 1TB', - description: 'Test deal açıklaması', - url: 'https://www.amazon.com.tr/dp/test', - price: 1299.99, - status: DealStatus.ACTIVE, - saletype: SaleType.ONLINE, - affiliateType: AffiliateType.NON_AFFILIATE, - commentCount:1, - userId: user.id, - sellerId: amazon.id, - }, + // ---------- CATEGORIES (FROM JSON) ---------- + const categoriesFilePath = path.join(__dirname, "", "categories.json") + const { count } = await seedCategoriesFromJson(categoriesFilePath) + + const catSSD = await prisma.category.findUnique({ + where: { slug: "pc-ssd" }, + select: { id: true }, }) - // ---------- DEAL IMAGES ---------- + // ---------- TAGS ---------- + await upsertTagBySlug("ssd", "SSD") + await upsertTagBySlug("nvme", "NVMe") + await upsertTagBySlug("1tb", "1TB") + + // ---------- DEAL (tek örnek) ---------- + const dealUrl = "https://www.amazon.com.tr/dp/test" + const existing = await prisma.deal.findFirst({ + where: { url: dealUrl }, + select: { id: true }, + }) + + const dealData = { + title: "Samsung SSD 1TB", + description: "Test deal açıklaması", + url: dealUrl, + price: 1299.99, + status: DealStatus.ACTIVE, + saletype: SaleType.ONLINE, + affiliateType: AffiliateType.NON_AFFILIATE, + commentCount: 1, + userId: user.id, + sellerId: amazon.id, + categoryId: catSSD?.id ?? 0, + // score: randInt(0, 200), // modelinde varsa aç + } + + const deal = existing + ? await prisma.deal.update({ where: { id: existing.id }, data: dealData }) + : await prisma.deal.create({ data: dealData }) + + // ---------- DEAL TAGS ---------- + await attachTagsToDeal(deal.id, ["ssd", "nvme", "1tb"]) + + // ---------- DEAL IMAGES (tek örnek) ---------- + await prisma.dealImage.deleteMany({ where: { dealId: deal.id } }) await prisma.dealImage.createMany({ data: [ - { - dealId: deal.id, - imageUrl: 'https://placehold.co/600x400', - order: 0, - }, - { - dealId: deal.id, - imageUrl: 'https://placehold.co/600x401', - order: 1, - }, + { dealId: deal.id, imageUrl: realImage("nvme-ssd-single-1"), order: 0 }, + { dealId: deal.id, imageUrl: realImage("nvme-ssd-single-2"), order: 1 }, + { dealId: deal.id, imageUrl: realImage("nvme-ssd-single-3"), order: 2 }, ], }) + // ✅ ---------- 30 DEAL ÜRET ---------- + await seedDeals30({ + userId: user.id, + sellerId: amazon.id, + categoryId: catSSD?.id ?? 0, + }) + // ---------- VOTE ---------- await prisma.dealVote.upsert({ - where: { - dealId_userId: { - dealId: deal.id, - userId: admin.id, - }, - }, - update: {}, - create: { - dealId: deal.id, - userId: admin.id, - voteType: 1, - }, + where: { dealId_userId: { dealId: deal.id, userId: admin.id } }, + update: { voteType: 1, lastVotedAt: new Date() }, + create: { dealId: deal.id, userId: admin.id, voteType: 1 }, }) // ---------- COMMENT ---------- - await prisma.comment.create({ - data: { - text: 'Gerçekten iyi fırsat', - userId: admin.id, - dealId: deal.id, - }, + const hasComment = await prisma.comment.findFirst({ + where: { dealId: deal.id, userId: admin.id, text: "Gerçekten iyi fırsat" }, + select: { id: true }, }) + 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() - .catch(err => { + .catch((err) => { console.error(err) process.exit(1) }) diff --git a/routes/accountSettings.routes.js b/routes/accountSettings.routes.js index 62e0cd5..6c9cee6 100644 --- a/routes/accountSettings.routes.js +++ b/routes/accountSettings.routes.js @@ -1,33 +1,30 @@ const express = require("express") const multer = require("multer") -const fs = require("fs") -const { uploadProfileImage } = require("../services/supabaseUpload.service") -const { validateImage } = require("../utils/validateImage") -const authRequiredMiddleware = require("../middleware/authRequired.middleware") -const authOptionalMiddleware = require("../middleware/authOptional.middleware") +const requireAuth = require("../middleware/requireAuth.js") const { getUserProfile } = require("../services/profile.service") +const { endpoints } = require("@shared/contracts") const router = express.Router() const upload = multer({ dest: "uploads/" }) const { updateUserAvatar } = require("../services/avatar.service") +const { account } = endpoints + router.post( "/avatar", - authRequiredMiddleware -, + requireAuth, upload.single("file"), async (req, res) => { try { - const updatedUser = await updateUserAvatar( - req.user.userId, - req.file - ) + const updatedUser = await updateUserAvatar(req.auth.userId, req.file) - res.json({ - message: "Avatar updated", - user: updatedUser, - }) + res.json( + account.avatarUploadResponseSchema.parse({ + message: "Avatar updated", + user: updatedUser, + }) + ) } catch (err) { console.error(err) res.status(400).json({ error: err.message }) @@ -35,12 +32,10 @@ router.post( } ) - -router.get("/me", authRequiredMiddleware -, async (req, res) => { +router.get("/me", requireAuth, async (req, res) => { try { - const user = await getUserProfile(req.user.id) - res.json(user) + const user = await getUserProfile(req.auth.userId) + res.json(account.accountMeResponseSchema.parse(user)) } catch (err) { res.status(400).json({ error: err.message }) } diff --git a/routes/auth.routes.js b/routes/auth.routes.js index dc5584b..8cfa113 100644 --- a/routes/auth.routes.js +++ b/routes/auth.routes.js @@ -1,61 +1,162 @@ -const express = require("express"); -const authRequiredMiddleware - = require("../middleware/authRequired.middleware"); -const authService=require("../services/auth.service") -const router = express.Router(); +// routes/auth.js +const express = require("express") +const router = express.Router() -const { - mapLoginRequestToLoginInput, - mapLoginResultToResponse, -} = require("../adapters/responses/login.adapter"); -const { - mapRegisterRequestToRegisterInput, - mapRegisterResultToResponse, -} = require("../adapters/responses/register.adapter"); -const { - mapMeRequestToUserId, - mapMeResultToResponse, -} = require("../adapters/responses/me.adapter"); +const requireAuth = require("../middleware/requireAuth.js") +const { validate } = require("../middleware/validate.middleware") +const authService = require("../services/auth.service") +const { endpoints } = require("@shared/contracts") +const { mapLoginRequestToLoginInput, mapLoginResultToResponse } = require("../adapters/responses/login.adapter") +const { mapRegisterRequestToRegisterInput, mapRegisterResultToResponse } = require("../adapters/responses/register.adapter") +const { mapMeRequestToUserId, mapMeResultToResponse } = require("../adapters/responses/me.adapter") -router.post("/register", async (req, res) => { - try { - const input = mapRegisterRequestToRegisterInput(req.body); - const result = await authService.register(input); - res.json(mapRegisterResultToResponse(result)); - } catch (err) { - const status = err.statusCode || 500; - res.status(status).json({ - message: err.message || "Kayıt işlemi başarısız.", - }); +const { auth } = endpoints + +// NOT: app.js’de cookie-parser olmalı: +// const cookieParser = require("cookie-parser") +// app.use(cookieParser()) + +function getCookieOptions() { + const isProd = process.env.NODE_ENV === "production" + + // DEV: http localhost -> secure false, sameSite lax + if (!isProd) { + return { + httpOnly: true, + secure: false, + sameSite: "lax", + path: "/", + } } -}); - -router.post("/login", async (req, res) => { - try { - const input = mapLoginRequestToLoginInput(req.body); - const result = await authService.login(input); - res.json(mapLoginResultToResponse(result)); - } catch (err) { - const status = err.statusCode || 500; - res.status(status).json({ message: err.message || "Giriş işlemi başarısız." }); + // PROD: cross-site kullanacaksan (frontend ayrı domain) + return { + httpOnly: true, + secure: true, + sameSite: "none", + path: "/", } -}); +} +function setRefreshCookie(res, refreshToken) { + const opts = getCookieOptions() + const maxAgeMs = Number(process.env.REFRESH_COOKIE_MAX_AGE_MS || 1000 * 60 * 60 * 24 * 30) + res.cookie("rt", refreshToken, { ...opts, maxAge: maxAgeMs }) +} -router.get("/me", authRequiredMiddleware -, async (req, res) => { - try { - const userId = mapMeRequestToUserId(req); - const user = await authService.getMe(userId); - res.json(mapMeResultToResponse(user)); - } catch (err) { - const status = err.statusCode || 500; - res.status(status).json({ - message: err.message || "Sunucu hatası", - }); +function clearRefreshCookie(res) { + const opts = getCookieOptions() + res.clearCookie("rt", { ...opts }) +} + +router.post( + "/register", + validate(auth.registerRequestSchema, "body", "validatedRegisterInput"), + async (req, res) => { + try { + const input = mapRegisterRequestToRegisterInput(req.validatedRegisterInput) + + const result = await authService.register({ + ...input, + meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null }, + }) + + // refresh cookie set + if (result.refreshToken) setRefreshCookie(res, result.refreshToken) + + // response body: access + user (adapter refresh'i koymamalı) + const response = auth.authResponseSchema.parse(mapRegisterResultToResponse(result)) + res.json(response) + } catch (err) { + const status = err.statusCode || 500 + res.status(status).json({ message: err.message || "Kayit islemi basarisiz." }) + } } -}); -module.exports = router; +) + +router.post( + "/login", + validate(auth.loginRequestSchema, "body", "validatedLoginInput"), + async (req, res) => { + try { + const input = mapLoginRequestToLoginInput(req.validatedLoginInput) + + const result = await authService.login({ + ...input, + meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null }, + }) + + // refresh cookie set + setRefreshCookie(res, result.refreshToken) + + const response = auth.authResponseSchema.parse(mapLoginResultToResponse(result)) + res.json(response) + } catch (err) { + console.error("LOGIN ERROR:", err) // <-- ekle + console.error("LOGIN ERROR MSG:", err?.message) + console.error("LOGIN ERROR STACK:", err?.stack) + + const status = err.statusCode || 500 + res.status(status).json({ + message: err.statusCode ? err.message : "Giris islemi basarisiz.", + }) +} + } +) + +router.post("/refresh", async (req, res) => { + try { + const refreshToken = req.cookies?.rt + if (!refreshToken) return res.status(401).json({ message: "Refresh token yok" }) + + const result = await authService.refresh({ + refreshToken, + meta: { ip: req.ip, userAgent: req.headers["user-agent"] || null }, + }) + + // rotate -> yeni refresh cookie + setRefreshCookie(res, result.refreshToken) + + // body: access + user (adapter refresh'i koymamalı) + const response = auth.authResponseSchema.parse(mapLoginResultToResponse(result)) + res.json(response) + } catch (err) { + clearRefreshCookie(res) + const status = err.statusCode || 401 + res.status(status).json({ message: err.message || "Refresh basarisiz" }) + } +}) + +router.post("/logout", async (req, res) => { + try { + const refreshToken = req.cookies?.rt + + // logout idempotent olsun + if (refreshToken) { + await authService.logout({ refreshToken }) + } + + clearRefreshCookie(res) + res.status(204).send() + } catch (err) { + clearRefreshCookie(res) + const status = err.statusCode || 500 + res.status(status).json({ message: err.message || "Cikis basarisiz" }) + } +}) + +router.get("/me", requireAuth, async (req, res) => { + try { + const userId = mapMeRequestToUserId(req) // req.auth.userId okumalı + const user = await authService.getMe(userId) + const response = auth.meResponseSchema.parse(mapMeResultToResponse(user)) + res.json(response) + } catch (err) { + const status = err.statusCode || 500 + res.status(status).json({ message: err.message || "Sunucu hatasi" }) + } +}) + +module.exports = router diff --git a/routes/category.routes.js b/routes/category.routes.js new file mode 100644 index 0000000..aecaeef --- /dev/null +++ b/routes/category.routes.js @@ -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; diff --git a/routes/comment.routes.js b/routes/comment.routes.js index c2079df..45cde2f 100644 --- a/routes/comment.routes.js +++ b/routes/comment.routes.js @@ -1,54 +1,62 @@ const express = require("express") -const authRequiredMiddleware = require("../middleware/authRequired.middleware") -const authOptionalMiddleware = require("../middleware/authOptional.middleware") -const { - getCommentsByDealId, - createComment, - deleteComment, -} = require("../services/comment.service") +const requireAuth = require("../middleware/requireAuth.js") +const { validate } = require("../middleware/validate.middleware") +const { endpoints } = require("@shared/contracts") +const { createComment, deleteComment } = require("../services/comment.service") - -const dealCommentAdapter=require("../adapters/responses/comment.adapter") -const commentService=require("../services/comment.service") +const dealCommentAdapter = require("../adapters/responses/comment.adapter") +const commentService = require("../services/comment.service") const router = express.Router() -router.get("/:dealId", async (req, res) => { - try { - const dealId = Number(req.params.dealId) - const comments = await commentService.getCommentsByDealId(dealId) - res.json(dealCommentAdapter.mapCommentsToDealCommentResponse(comments)) +const { comments } = endpoints - } catch (err) { - console.log(err.message) - res.status(400).json({ error: err.message }) +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) { + 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." }) +router.post( + "/", + requireAuth, + validate(comments.commentCreateRequestSchema, "body", "validatedCommentPayload"), + async (req, res) => { + try { + const { dealId, text, parentId } = req.validatedCommentPayload + const userId = req.auth.userId - const comment = await createComment({ dealId, userId, text }) - res.json(comment) - } catch (err) { - console.error(err) - res.status(500).json({ error: err.message || "Sunucu hatası" }) + const comment = await createComment({ dealId, userId, text, parentId }) + const mapped = dealCommentAdapter.mapCommentToDealCommentResponse(comment) + res.json(comments.commentCreateResponseSchema.parse(mapped)) + } catch (err) { + res.status(500).json({ error: err.message || "Sunucu hatasi" }) + } } -}) +) -router.delete("/:id", authRequiredMiddleware -, async (req, res) => { - try { - const result = await deleteComment(req.params.id, req.user.userId) - res.json(result) - } catch (err) { - console.error(err) - const status = err.message.includes("yetkin") ? 403 : 404 - res.status(status).json({ error: err.message }) +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 }) + } } -}) +) module.exports = router diff --git a/routes/deal.routes.js b/routes/deal.routes.js index 5e64ad9..6856924 100644 --- a/routes/deal.routes.js +++ b/routes/deal.routes.js @@ -1,75 +1,219 @@ +// routes/deals.js const express = require("express") 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 { mapDealToDealCardResponse,mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter") +const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter") +const { deals, users } = endpoints -router.get("/", authOptionalMiddleware, async (req, res) => { - try { - const q = (req.query.q ?? "").toString().trim() - const page = Number(req.query.page) || 1 - const limit = Number(req.query.limit) || 10 - const userId = req.user?.userId ?? null - const data = await getDeals({ q, page, limit, userId }) - - res.json(mapPaginatedDealsToDealCardResponse(data)) - } catch (e) { - console.error(e) - res.status(500).json({ error: "Sunucu hatası" }) - } -}) +const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery") +const buildViewer = (req) => + req.auth ? { userId: req.auth.userId, role: req.auth.role } : null -router.get("/search", async (req, res) => { - try { - const query = req.query.q || "" - const page = Number(req.query.page) || 1 - const limit = 10 - - if (!query.trim()) { - return res.json({ results: [], total: 0, totalPages: 0, page }) - } - - const data = await searchDeals(query, page, limit) - res.json(mapPaginatedDealsToDealCardResponse(data)) - } catch (e) { - console.error(e) - res.status(500).json({ error: "Sunucu hatası" }) - } -}) - -router.get("/:id", async (req, res) => { //MAPPED - try { - const deal = await getDealById(req.params.id) - if (!deal) return res.status(404).json({ error: "Deal bulunamadı" }) - console.log(mapDealToDealDetailResponse(deal)) - res.json(mapDealToDealDetailResponse(deal)) - } catch (e) { - console.error(e) - res.status(500).json({ error: "Sunucu hatası" }) - } -}) - -router.post( "/", authRequiredMiddleware, upload.array("images", 5), async (req, res) => { +function createListHandler(preset) { + return 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); + const viewer = buildViewer(req) + const { q, page, limit } = req.validatedDealListQuery + + const payload = await getDeals({ + preset, + q, + page, + limit, + viewer, + }) + + const response = deals.dealsListResponseSchema.parse( + mapPaginatedDealsToDealCardResponse(payload) + ) + res.json(response) } catch (err) { - console.error(err); - res.status(500).json({ error: "Sunucu hatası" }); + 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( + "/users/:userName/deals", + optionalAuth, + validate(users.userProfileRequestSchema, "params", "validatedUserProfile"), + listQueryValidator, + async (req, res) => { + try { + const { userName } = req.validatedUserProfile + const targetUser = await userDB.findUser( + { username: userName }, + { select: { id: true } } + ) + + if (!targetUser) return res.status(404).json({ error: "Kullanici bulunamadi" }) + + const { q, page, limit } = req.validatedDealListQuery + const viewer = buildViewer(req) + const isSelfProfile = viewer?.userId === targetUser.id + const preset = isSelfProfile ? "MY" : "USER_PUBLIC" + + const payload = await getDeals({ + preset, + q, + page, + limit, + targetUserId: targetUser.id, + viewer, + }) + + const response = deals.dealsListResponseSchema.parse( + mapPaginatedDealsToDealCardResponse(payload) + ) + res.json(response) + } catch (err) { + console.error(err) + const status = err.statusCode || 500 + res.status(status).json({ error: err.message || "Sunucu hatasi" }) + } + } +) + +// My deals (auth required) +router.get( + "/me/deals", + requireAuth, + listQueryValidator, + createListHandler("MY") +) + +router.get("/new", optionalAuth, listQueryValidator, createListHandler("NEW")) +router.get("/hot", optionalAuth, listQueryValidator, createListHandler("HOT")) +router.get("/trending", optionalAuth, listQueryValidator, createListHandler("TRENDING")) +router.get("/", optionalAuth, listQueryValidator, createListHandler("NEW")) + +router.get( + "/search", + optionalAuth, + listQueryValidator, + async (req, res) => { + try { + const { q, page, limit } = req.validatedDealListQuery + if (!q || !q.trim()) { + return res.json({ results: [], total: 0, totalPages: 0, page }) + } + + const payload = await getDeals({ + preset: "NEW", + q, + page, + limit, + viewer: buildViewer(req), + }) + + const response = deals.dealsListResponseSchema.parse( + mapPaginatedDealsToDealCardResponse(payload) + ) + res.json(response) + } catch (err) { + console.error(err) + const status = err.statusCode || 500 + res.status(status).json({ error: err.message || "Sunucu hatasi" }) + } + } +) +// TOP deals (daily/weekly/monthly) - viewer optional +router.get("/top", optionalAuth, async (req, res) => { + try { + const viewer = buildViewer(req) + + const range = String(req.query.range || "day") + const limitRaw = Number(req.query.limit ?? 6) + const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(20, limitRaw)) : 6 + + let preset = "HOT_DAY" + if (range === "week") preset = "HOT_WEEK" + else if (range === "month") preset = "HOT_MONTH" + else if (range !== "day") return res.status(400).json({ error: "range invalid" }) + + const payload = await getDeals({ + preset, + q: null, + page: 1, + limit, + viewer, + }) + + const response = deals.dealsListResponseSchema.parse( + mapPaginatedDealsToDealCardResponse(payload) + ) + + // frontend DealCard[] bekliyor + res.json(response.results) + } catch (err) { + console.error(err) + const status = err.statusCode || 500 + res.status(status).json({ error: err.message || "Sunucu hatasi" }) + } +}) + +router.get( + "/:id", + validate(deals.dealDetailRequestSchema, "params", "validatedDealId"), + async (req, res) => { + try { + + const { id } = req.validatedDealId + const deal = await getDealById(id) + if (!deal) return res.status(404).json({ error: "Deal bulunamadi" }) + + const mapped = mapDealToDealDetailResponse(deal) + + console.log(mapped) + res.json(deals.dealDetailResponseSchema.parse(mapped)) + } catch (err) { + console.error(err) + res.status(500).json({ error: "Sunucu hatasi" }) + } + } +) + +// Create deal (auth required) +router.post( + "/", + requireAuth, + upload.array("images", 5), + validate(deals.dealCreateRequestSchema, "body", "validatedDealPayload"), + async (req, res) => { + try { + const userId = req.auth.userId + const dealCreateData = mapCreateDealRequestToDealCreateData( + req.validatedDealPayload, + userId + ) + + const deal = await createDeal(dealCreateData, req.files || []) + const mapped = mapDealToDealDetailResponse(deal) + res.json(deals.dealCreateResponseSchema.parse(mapped)) + } catch (err) { + console.error(err) + res.status(500).json({ error: "Sunucu hatasi" }) + } + } +) + module.exports = router diff --git a/routes/seller.routes.js b/routes/seller.routes.js index 6fae30d..856ef81 100644 --- a/routes/seller.routes.js +++ b/routes/seller.routes.js @@ -1,30 +1,34 @@ const express = require("express") 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 { seller } = endpoints -router.post("/from-link", authRequiredMiddleware -, async (req, res) => { +router.post("/from-link", requireAuth, async (req, res) => { try { - const sellerUrl = req.body.url - const Seller = await findSellerFromLink(sellerUrl) + const sellerUrl = req.body.url + const sellerLookup = await findSellerFromLink(sellerUrl) - if (!Seller) { - return res.json({ - sellerId: -1, - sellerName: null, - }) - } - return res.json({ - id: Seller.id, - name: Seller.name, - }) - + const response = seller.sellerLookupResponseSchema.parse( + sellerLookup + ? { + found: true, + seller: { + id: sellerLookup.id, + name: sellerLookup.name, + url: sellerLookup.url ?? null, + }, + } + : { found: false, seller: null } + ) + + return res.json(response) } catch (e) { console.error(e) - res.status(500).json({ error: "Sunucu hatası" }) + res.status(500).json({ error: "Sunucu hatasi" }) } }) diff --git a/routes/user.routes.js b/routes/user.routes.js index 4b8b9ec..6bee424 100644 --- a/routes/user.routes.js +++ b/routes/user.routes.js @@ -1,19 +1,33 @@ // routes/user.js const express = require("express") const router = express.Router() - +const { validate } = require("../middleware/validate.middleware") const userService = require("../services/user.service") const userProfileAdapter = require("../adapters/responses/userProfile.adapter") +const { endpoints } = require("@shared/contracts") -router.get("/:userName", async (req, res) => { - try { - const data = await userService.getUserProfileByUsername(req.params.userName) - res.json(userProfileAdapter.mapUserProfileToResponse(data)) - } catch (err) { - console.error(err) - const status = err.statusCode || 500 - res.status(status).json({ message: err.message || "Profil bilgileri alınamadı." }) +const { users } = endpoints + +router.get( + "/:userName", + validate(users.userProfileRequestSchema, "params", "validatedUserProfile"), + async (req, res) => { + try { + const { userName } = req.validatedUserProfile + const data = await userService.getUserProfileByUsername(userName) + + const response = users.userProfileResponseSchema.parse( + userProfileAdapter.mapUserProfileToResponse(data) + ) + res.json(response) + } catch (err) { + console.error(err) + const status = err.statusCode || 500 + res.status(status).json({ + message: err.message || "Profil bilgileri alinamadi.", + }) + } } -}) +) module.exports = router diff --git a/routes/vote.routes.js b/routes/vote.routes.js index c3260f6..d707b04 100644 --- a/routes/vote.routes.js +++ b/routes/vote.routes.js @@ -1,35 +1,45 @@ const express = require("express") -const authRequiredMiddleware = require("../middleware/authRequired.middleware") -const authOptionalMiddleware = require("../middleware/authOptional.middleware") +const requireAuth = require("../middleware/requireAuth") +const { validate } = require("../middleware/validate.middleware") +const { endpoints } = require("@shared/contracts") const voteService = require("../services/vote.service") -const {mapVoteRequestToVoteInput,mapVoteResultToResponse}=require("../adapters/responses/vote.adapter") const router = express.Router() +const { votes } = endpoints -router.post("/", authRequiredMiddleware -, async (req, res) => { - try { - const input = mapVoteRequestToVoteInput(req); - const result = await voteService.voteDeal(input); - res.json(result); - } catch (err) { - const status = err.statusCode || 500; - res.status(status).json({ message: err.message || "Sunucu hatası" }); +router.post( + "/", + requireAuth, + validate(votes.voteRequestSchema, "body", "validatedVotePayload"), + async (req, res) => { + try { + const { dealId, voteType } = req.validatedVotePayload + const result = await voteService.voteDeal({ + dealId, + voteType, + userId: req.auth.userId, + }) + res.json(votes.voteResponseSchema.parse(result)) + } catch (err) { + const status = err.statusCode || 500 + res.status(status).json({ message: err.message || "Sunucu hatasi" }) + } } -}); -// Belirli deal için oyları çek -router.get("/:dealId", async (req, res) => { - try { - const dealId = Number(req.params.dealId) - if (isNaN(dealId) || dealId <= 0) - return res.status(400).json({ error: "Geçersiz dealId" }) +) - const data = await voteService.getVotes(dealId) - res.json(data) - } catch (err) { - console.error(err) - res.status(500).json({ error: "Sunucu hatası" }) +router.get( + "/:dealId", + validate(votes.voteListRequestSchema, "params", "validatedVoteList"), + async (req, res) => { + try { + const { dealId } = req.validatedVoteList + const data = await voteService.getVotes(dealId) + res.json(votes.voteListResponseSchema.parse({ votes: data })) + } catch (err) { + console.error(err) + res.status(500).json({ error: "Sunucu hatasi" }) + } } -}) +) module.exports = router diff --git a/server.js b/server.js index ba9a4f4..23a74c3 100644 --- a/server.js +++ b/server.js @@ -1,30 +1,42 @@ -const express = require("express") -const cors = require("cors") -require("dotenv").config() +const express = require("express"); +const cors = require("cors"); +require("dotenv").config(); +const cookieParser = require("cookie-parser"); -const userRoutesneedRefactor = require("./routes/user.routes") -const dealRoutes = require("./routes/deal.routes") -const authRoutes = require("./routes/auth.routes") -const dealVoteRoutes = require("./routes/vote.routes") -const commentRoutes = require("./routes/comment.routes") -const accountSettingsRoutes = require("./routes/accountSettings.routes") -const userRoutes = require("./routes/user.routes") -const sellerRoutes = require("./routes/seller.routes") -const voteRoutes=require("./routes/vote.routes") -const app = express() +// Rotaların import edilmesi +const userRoutesneedRefactor = require("./routes/user.routes"); +const dealRoutes = require("./routes/deal.routes"); +const authRoutes = require("./routes/auth.routes"); +const dealVoteRoutes = require("./routes/vote.routes"); +const commentRoutes = require("./routes/comment.routes"); +const accountSettingsRoutes = require("./routes/accountSettings.routes"); +const userRoutes = require("./routes/user.routes"); +const sellerRoutes = require("./routes/seller.routes"); +const voteRoutes = require("./routes/vote.routes"); +const categoryRoutes =require("./routes/category.routes") +const app = express(); -app.use(cors()) -app.use(express.json()) -app.use(express.urlencoded({ extended: true })) +// CORS middleware'ı ile dışardan gelen istekleri kontrol et +app.use(cors({ + origin: "http://localhost:5173", // Frontend adresi + credentials: true, // Cookies'in paylaşıma izin verilmesi +})); -app.use("/api/users", userRoutesneedRefactor) -app.use("/api/deals", dealRoutes) -app.use("/api/auth", authRoutes) -app.use("/api/deal-votes", dealVoteRoutes) -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) +// JSON, URL encoded ve cookies'leri parse etme +app.use(express.json()); // JSON verisi almak için +app.use(express.urlencoded({ extended: true })); // URL encoded veriler için +app.use(cookieParser()); // Cookies'leri çözümlemek için -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")); diff --git a/services/auth.service.js b/services/auth.service.js index 7f39f00..9259128 100644 --- a/services/auth.service.js +++ b/services/auth.service.js @@ -1,85 +1,187 @@ -const bcrypt = require("bcryptjs"); -const generateToken = require("../utils/generateToken"); -const authDb = require("../db/auth.db"); +// services/auth.service.js +const bcrypt = require("bcryptjs") +const jwt = require("jsonwebtoken") +const crypto = require("crypto") -async function login({ email, password }) { - const user = await authDb.findUserByEmail(email); +const authDb = require("../db/auth.db") +const refreshTokenDb = require("../db/refreshToken.db") - if (!user) { - const err = new Error("Kullanıcı bulunamadı."); - err.statusCode = 400; - throw 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, - }, - }; +function httpError(statusCode, message) { + const err = new Error(message) + err.statusCode = statusCode + return err } -async function register({ username, email, password }) { - const existingUser = await authDb.findUserByEmail(email); - if (existingUser) { - const err = new Error("Bu e-posta zaten kayıtlı."); - err.statusCode = 400; - throw err; +// Access token: kısa ömür +function signAccessToken(user) { + const jti = crypto.randomUUID() + const payload = { + sub: String(user.id), + 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({ - username, - email, - passwordHash, - }); +// Refresh token: opaque (JWT değil) + DB’de hash +function generateRefreshToken() { + // 64 byte -> url-safe base64 + return crypto.randomBytes(64).toString("base64url") +} - const token = generateToken(user.id); +function hashToken(token) { + return crypto.createHash("sha256").update(token).digest("hex") +} + +function refreshExpiresAt() { + const days = Number(process.env.REFRESH_TOKEN_DAYS || 30) + return new Date(Date.now() + days * 24 * 60 * 60 * 1000) +} + +function mapUserPublic(user) { + return { + id: user.id, + username: user.username, + email: user.email, + avatarUrl: user.avatarUrl ?? null, + role: user.role, + } +} + +async function login({ email, password, meta = {} }) { + const user = await authDb.findUserByEmail(email) + if (!user) throw httpError(400, "Kullanıcı bulunamadı.") + + const isMatch = await bcrypt.compare(password, user.passwordHash) + if (!isMatch) throw httpError(401, "Şifre hatalı.") + + const { token: accessToken } = signAccessToken(user) + + const refreshToken = generateRefreshToken() + const tokenHash = hashToken(refreshToken) + const familyId = crypto.randomUUID() + const jti = crypto.randomUUID() + + await refreshTokenDb.createRefreshToken(user.id, { + tokenHash, + familyId, + jti, + expiresAt: refreshExpiresAt(), + createdByIp: meta.ip ?? null, + userAgent: meta.userAgent ?? null, + }) return { - token, - user: { - id: user.id, - username: user.username, - email: user.email, - avatarUrl: user.avatarUrl ?? null, + 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) { - const user = await authDb.findUserById(userId, { - select: { - id: true, - username: true, - email: true, - avatarUrl: true, - }, - }); - - if (!user) { - const err = new Error("Kullanıcı bulunamadı"); - err.statusCode = 404; - throw err; - } - - return user; + const user = await authDb.findUserById(Number(userId), { + select: { id: true, username: true, email: true, avatarUrl: true, role: true }, + }) + if (!user) throw httpError(404, "Kullanıcı bulunamadı") + return user } -module.exports = { + +module.exports = { login, register, + refresh, + logout, getMe, -}; +} diff --git a/services/category.service.js b/services/category.service.js new file mode 100644 index 0000000..cdb87c1 --- /dev/null +++ b/services/category.service.js @@ -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, +}; diff --git a/services/comment.service.js b/services/comment.service.js index 1d015aa..2db84ae 100644 --- a/services/comment.service.js +++ b/services/comment.service.js @@ -18,19 +18,36 @@ async function getCommentsByDealId(dealId) { return commentDB.findComments({ dealId: id }, { include }) } -async function createComment({ dealId, userId, text }) { - if (!text || typeof text !== "string" || !text.trim()) +async function createComment({ dealId, userId, text, parentId = null }) { + if (!text || typeof text !== "string" || !text.trim()) { throw new Error("Yorum boş olamaz.") + } 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) => { const deal = await dealDB.findDeal({ id: dealId }, {}, tx) if (!deal) throw new Error("Deal bulunamadı.") + // ✅ Reply ise parent doğrula + let parent = null + if (parentId != null) { + const pid = Number(parentId) + if (!Number.isFinite(pid) || pid <= 0) throw new Error("Geçersiz parentId.") + + parent = await commentDB.findComment({ id: pid }, { select: { id: true, dealId: true } }, tx) + if (!parent) throw new Error("Yanıtlanan yorum bulunamadı.") + if (parent.dealId !== dealId) throw new Error("Yanıtlanan yorum bu deal'a ait değil.") + } + const comment = await commentDB.createComment( - { text: trimmed, userId, dealId }, + { + text: trimmed, + userId, + dealId, + parentId: parent ? parent.id : null, + }, { include }, tx ) @@ -46,19 +63,18 @@ async function createComment({ dealId, userId, text }) { }) } + async function deleteComment(commentId, userId) { - const cId = assertPositiveInt(commentId, "commentId") - const uId = assertPositiveInt(userId, "userId") const comments = await commentDB.findComments( - { id: cId }, + { id: commentId }, { select: { userId: true } } ) 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." } } diff --git a/services/deal.service.js b/services/deal.service.js index 6697d11..2c2edbf 100644 --- a/services/deal.service.js +++ b/services/deal.service.js @@ -1,109 +1,283 @@ +// services/deal.service.js 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 { makeDetailWebp, makeThumbWebp } = require("../utils/processImage"); -const { v4: uuidv4 } = require("uuid"); -const {uploadImage}=require("./uploadImage.service") +const DEFAULT_LIMIT = 20 +const MAX_LIMIT = 50 +const MAX_SKIP = 5000 +const MS_PER_DAY = 24 * 60 * 60 * 1000 -const dealImageDB = require("../db/dealImage.db"); +const DEAL_LIST_INCLUDE = { + user: { select: { id: true, username: true, avatarUrl: true } }, + seller: { select: { id: true, name: true, url: true } }, + images: { + orderBy: { order: "asc" }, + take: 1, + select: { imageUrl: true }, + }, +} -async function getDeals({ q = "", page = 1, limit = 10, userId = null }) { - const skip = (page - 1) * limit +function formatDateAsString(value) { + return value instanceof Date ? value.toISOString() : value ?? null +} - 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 } }, - images: { - orderBy: { order: "asc" }, - take: 1, - select: { imageUrl: true }, - }, - }, - }), - dealDB.countDeals(where), - ]) - - // auth yoksa myVote=0 - if (!userId) { - return { - page, - total, - totalPages: Math.ceil(total / limit), - results: deals.map((d) => ({ ...d, myVote: 0 })), - } +function clampPagination({ page, limit }) { + const rawPage = Number(page) + const rawLimit = Number(limit) + const normalizedPage = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1 + let normalizedLimit = Number.isFinite(rawLimit) ? Math.max(1, Math.floor(rawLimit)) : DEFAULT_LIMIT + normalizedLimit = Math.min(MAX_LIMIT, normalizedLimit) + const skip = (normalizedPage - 1) * normalizedLimit + if (skip > MAX_SKIP) { + 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) - - const votes = await dealDB.findVotes( - { userId, dealId: { in: dealIds } }, - { select: { dealId: true, voteType: true } } - ) - - const voteByDealId = new Map(votes.map((v) => [v.dealId, v.voteType])) - +function buildSearchClause(q) { + if (q === undefined || q === null) return null + const normalized = String(q).trim() + if (!normalized) return null return { - page, - total, - totalPages: Math.ceil(total / limit), - results: deals.map((d) => ({ - ...d, - myVote: voteByDealId.get(d.id) ?? 0, - })), + OR: [ + { title: { contains: normalized, mode: "insensitive" } }, + { description: { contains: normalized, mode: "insensitive" } }, + ], } } +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) { - const deal=await dealDB.findDeal( + const deal = await dealDB.findDeal( { id: Number(id) }, { include: { - seller:{ - select: { - name:true, - url:true - }, - }, - user: { + seller: { select: { id: true, name: true, url: true } }, + user: { select: { id: true, username: true, avatarUrl: true } }, + images: { orderBy: { order: "asc" }, select: { id: true, imageUrl: true, order: true } }, + notices: { + where: { isActive: true }, + orderBy: { createdAt: "desc" }, + take: 1, select: { id: true, - username: true, - avatarUrl: true, - }, - }, - seller: { - select: { - id: true, - name: true, - }, - }, - images: { - orderBy: { order: "asc" }, - select: { - id: true, - imageUrl: true, - order: true, + dealId: true, + title: true, + body: true, + severity: true, + isActive: true, + createdBy: true, + createdAt: true, + updatedAt: true, }, }, comments: { @@ -112,90 +286,88 @@ async function getDealById(id) { id: true, text: true, createdAt: true, - user: { - select: { - id: true, - username: true, - avatarUrl: true, - }, - }, - }, - }, - _count: { - select: { - comments: true, + user: { select: { id: true, username: true, avatarUrl: 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 = []) { - // seller bağlama if (dealCreateData.url) { - const seller = await findSellerFromLink(dealCreateData.url); + const seller = await findSellerFromLink(dealCreateData.url) if (seller) { - dealCreateData.seller = { connect: { id: seller.id } }; - dealCreateData.customSeller = null; + dealCreateData.seller = { connect: { id: seller.id } } + dealCreateData.customSeller = null } } - // 1) Deal oluştur - const deal = await dealDB.createDeal(dealCreateData); - - // 2) Önce image işle + upload - const rows = []; + const deal = await dealDB.createDeal(dealCreateData) + const rows = [] for (let i = 0; i < files.length && i < 5; i++) { - const file = files[i]; - const order = i; + const file = files[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 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 detailBuffer = await makeDetailWebp(file.buffer) const detailUrl = await uploadImage({ bucket: BUCKET, path: detailPath, fileBuffer: detailBuffer, contentType: "image/webp", - }); + }) if (order === 0) { - const thumbBuffer = await makeThumbWebp(file.buffer); + const thumbBuffer = await makeThumbWebp(file.buffer) await uploadImage({ bucket: BUCKET, path: thumbPath, fileBuffer: thumbBuffer, contentType: "image/webp", - }); + }) } - rows.push({ dealId: deal.id, order, imageUrl: detailUrl }); + rows.push({ dealId: deal.id, order, imageUrl: detailUrl }) } - // 3) Uploadlar bitti -> DB’de tek seferde yaz if (rows.length > 0) { - await dealImageDB.createManyDealImages(rows); + await dealImageDB.createManyDealImages(rows) } - // 4) Deal + images dön - return dealDB.getDealWithImages(deal.id); + await enqueueDealClassification({ dealId: deal.id }) + + return getDealById(deal.id) } - - - - - - module.exports = { getDeals, getDealById, diff --git a/services/dealClassification.service.js b/services/dealClassification.service.js new file mode 100644 index 0000000..ea97fea --- /dev/null +++ b/services/dealClassification.service.js @@ -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 } diff --git a/services/user.service.js b/services/user.service.js index 4cf777d..f71929e 100644 --- a/services/user.service.js +++ b/services/user.service.js @@ -2,6 +2,7 @@ const userDB = require("../db/user.db") const dealDB = require("../db/deal.db") const commentDB = require("../db/comment.db") +const dealService = require("./deal.service") async function getUserProfileByUsername(userName) { const username = String(userName).trim() @@ -9,7 +10,7 @@ async function getUserProfileByUsername(userName) { const user = await userDB.findUser( { username }, - { select: { id: true, username: true, avatarUrl: true, createdAt: true } } + { select: { id: true, username: true, email: true, avatarUrl: true, createdAt: true } } ) if (!user) { @@ -18,21 +19,9 @@ async function getUserProfileByUsername(userName) { throw err } - const [dealAgg, totalComments, deals, comments] = await Promise.all([ + const [dealAgg, totalComments, comments] = await Promise.all([ dealDB.aggregateDeals({ 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( { 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 = { totalLikes: dealAgg?._sum?.score ?? 0, totalComments: totalComments ?? 0, - totalShares: dealAgg?._count?._all ?? 0, + totalShares: totalDeals, + totalDeals, } - return { user, stats, deals, comments } + return { user, stats, deals: userDeals.results, comments } } diff --git a/services/vote.service.js b/services/vote.service.js index 15714aa..9b74cf1 100644 --- a/services/vote.service.js +++ b/services/vote.service.js @@ -16,4 +16,27 @@ async function voteDeal({ 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 }; diff --git a/validators/common.js b/validators/common.js new file mode 100644 index 0000000..b3e0875 --- /dev/null +++ b/validators/common.js @@ -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, +} diff --git a/validators/dealCreate.validator.js b/validators/dealCreate.validator.js new file mode 100644 index 0000000..f107ef4 --- /dev/null +++ b/validators/dealCreate.validator.js @@ -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, +} diff --git a/validators/dealListQuery.validator.js b/validators/dealListQuery.validator.js new file mode 100644 index 0000000..56e1a7d --- /dev/null +++ b/validators/dealListQuery.validator.js @@ -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, +} diff --git a/workers/dealClassification.worker.js b/workers/dealClassification.worker.js new file mode 100644 index 0000000..20a6d49 --- /dev/null +++ b/workers/dealClassification.worker.js @@ -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 } diff --git a/workers/index.js b/workers/index.js new file mode 100644 index 0000000..083b9b2 --- /dev/null +++ b/workers/index.js @@ -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)