diff --git a/adapters/responses/comment.adapter.js b/adapters/responses/comment.adapter.js index d8196f8..ff78337 100644 --- a/adapters/responses/comment.adapter.js +++ b/adapters/responses/comment.adapter.js @@ -6,7 +6,15 @@ function mapCommentToDealCommentResponse(comment) { id: comment.id, text: comment.text, // eÄŸer DB'de content ise burada text'e çevir createdAt: formatDateAsString(comment.createdAt), - parentId:comment.parentId, + parentId: comment.parentId ?? null, + likeCount: Number.isFinite(comment.likeCount) ? comment.likeCount : 0, + repliesCount: Number.isFinite(comment.repliesCount) + ? comment.repliesCount + : comment._count?.replies ?? 0, + hasReplies: Number.isFinite(comment.repliesCount) + ? comment.repliesCount > 0 + : (comment._count?.replies ?? 0) > 0, + myLike: Boolean(comment.myLike), user: { id: comment.user.id, username: comment.user.username, diff --git a/adapters/responses/dealCard.adapter.js b/adapters/responses/dealCard.adapter.js index fe1c7ad..d1cb903 100644 --- a/adapters/responses/dealCard.adapter.js +++ b/adapters/responses/dealCard.adapter.js @@ -6,6 +6,8 @@ function mapDealToDealCardResponse(deal) { title: deal.title, description: deal.description || "", price: deal.price ?? null, + originalPrice: deal.originalPrice ?? null, + shippingPrice: deal.shippingPrice ?? null, score: deal.score, commentsCount: deal.commentCount, diff --git a/adapters/responses/dealDetail.adapter.js b/adapters/responses/dealDetail.adapter.js index 3496f15..7d1288b 100644 --- a/adapters/responses/dealDetail.adapter.js +++ b/adapters/responses/dealDetail.adapter.js @@ -55,6 +55,8 @@ function mapDealToDealDetailResponse(deal) { description: deal.description || "", url: deal.url ?? null, price: deal.price ?? null, + originalPrice: deal.originalPrice ?? null, + shippingPrice: deal.shippingPrice ?? null, score: Number.isFinite(deal.score) ? deal.score : 0, commentsCount: deal._count?.comments ?? 0, @@ -71,6 +73,10 @@ function mapDealToDealDetailResponse(deal) { username: deal.user.username, avatarUrl: deal.user.avatarUrl ?? null, }, + userStats: { + totalLikes: deal.userStats?.totalLikes ?? 0, + totalDeals: deal.userStats?.totalDeals ?? 0, + }, // ✅ FIX: SellerSummarySchema genelde id ister -> custom seller için -1 seller: deal.seller @@ -95,12 +101,21 @@ function mapDealToDealDetailResponse(deal) { if (!comment.user) throw new Error("comment.user is missing (include comments.user in query)") - return { - id: comment.id, - text: comment.text, - createdAt: requiredIsoString(comment.createdAt, "comment.createdAt"), - user: { - id: comment.user.id, + return { + id: comment.id, + text: comment.text, + parentId: comment.parentId ?? null, + likeCount: Number.isFinite(comment.likeCount) ? comment.likeCount : 0, + repliesCount: Number.isFinite(comment.repliesCount) + ? comment.repliesCount + : comment._count?.replies ?? 0, + hasReplies: Number.isFinite(comment.repliesCount) + ? comment.repliesCount > 0 + : (comment._count?.replies ?? 0) > 0, + myLike: Boolean(comment.myLike), + createdAt: requiredIsoString(comment.createdAt, "comment.createdAt"), + user: { + id: comment.user.id, username: comment.user.username, avatarUrl: comment.user.avatarUrl ?? null, }, diff --git a/adapters/responses/me.adapter.js b/adapters/responses/me.adapter.js index f7361e9..e896722 100644 --- a/adapters/responses/me.adapter.js +++ b/adapters/responses/me.adapter.js @@ -9,6 +9,7 @@ function mapMeResultToResponse(user) { username: user.username, email: user.email, avatarUrl: user.avatarUrl ?? null, + role: user.role, }; } diff --git a/adapters/responses/sellerDetails.adapter.js b/adapters/responses/sellerDetails.adapter.js new file mode 100644 index 0000000..2ed9fe6 --- /dev/null +++ b/adapters/responses/sellerDetails.adapter.js @@ -0,0 +1,14 @@ +function mapSellerToSellerDetailsResponse(seller) { + if (!seller) return null + + return { + id: seller.id, + name: seller.name, + url: seller.url || null, + logoUrl: seller.sellerLogo || null, + } +} + +module.exports = { + mapSellerToSellerDetailsResponse, +} diff --git a/db/category.db.js b/db/category.db.js index de3c9ff..525c08b 100644 --- a/db/category.db.js +++ b/db/category.db.js @@ -1,63 +1,90 @@ -const prisma = require("./client"); // Prisma client +const prisma = require("./client") // Prisma client /** - * Kategoriyi slug'a göre bul + * Kategoriyi slug'a gore bul */ async function findCategoryBySlug(slug, options = {}) { - const s = String(slug ?? "").trim().toLowerCase(); + 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 + * Kategorinin firsatlarini al + * Sayfalama ve filtreler ile firsatlari cekiyoruz */ async function listCategoryDeals({ where = {}, skip = 0, take = 10 }) { return prisma.deal.findMany({ where, skip, take, - orderBy: { createdAt: "desc" }, // Yeni fırsatlar önce gelsin - }); + orderBy: { createdAt: "desc" }, + }) +} + +async function getCategoryDescendantIds(categoryId) { + const rootId = Number(categoryId) + if (!Number.isInteger(rootId) || rootId <= 0) { + throw new Error("categoryId must be int") + } + + const seen = new Set([rootId]) + let queue = [rootId] + + while (queue.length > 0) { + const children = await prisma.category.findMany({ + where: { parentId: { in: queue } }, + select: { id: true }, + }) + + const next = [] + for (const child of children) { + if (!seen.has(child.id)) { + seen.add(child.id) + next.push(child.id) + } + } + queue = next + } + + return Array.from(seen) } async function getCategoryBreadcrumb(categoryId, { includeUndefined = false } = {}) { - let currentId = Number(categoryId); - if (!Number.isInteger(currentId)) throw new Error("categoryId must be int"); + let currentId = Number(categoryId) + if (!Number.isInteger(currentId)) throw new Error("categoryId must be int") - const path = []; - const visited = new Set(); + const path = [] + const visited = new Set() - // Bu döngü, root kategoriye kadar gidip breadcrumb oluşturacak while (true) { - if (visited.has(currentId)) break; - visited.add(currentId); + if (visited.has(currentId)) break + visited.add(currentId) const cat = await prisma.category.findUnique({ where: { id: currentId }, - select: { id: true, name: true, slug: true, parentId: true }, // Yalnızca gerekli alanları seçiyoruz - }); + select: { id: true, name: true, slug: true, parentId: true }, + }) - if (!cat) break; + if (!cat) break - // Undefined'ı istersen breadcrumb'ta göstermiyoruz if (includeUndefined || cat.id !== 0) { - path.push({ id: cat.id, name: cat.name, slug: cat.slug }); + path.push({ id: cat.id, name: cat.name, slug: cat.slug }) } - if (cat.parentId === null || cat.parentId === undefined) break; - currentId = cat.parentId; // Bir üst kategoriye geçiyoruz + if (cat.parentId === null || cat.parentId === undefined) break + currentId = cat.parentId } - return path.reverse(); // Kökten başlayarak, kategoriyi en son eklediğimiz için tersine çeviriyoruz + return path.reverse() } module.exports = { getCategoryBreadcrumb, findCategoryBySlug, listCategoryDeals, -}; + getCategoryDescendantIds, +} diff --git a/db/comment.db.js b/db/comment.db.js index 55fe90b..da31186 100644 --- a/db/comment.db.js +++ b/db/comment.db.js @@ -4,17 +4,27 @@ function getDb(db) { return db || prisma } +function withDeletedFilter(where = {}, options = {}) { + if (options.includeDeleted || Object.prototype.hasOwnProperty.call(where, "deletedAt")) { + return where + } + return { AND: [where, { deletedAt: null }] } +} + async function findComments(where, options = {}) { return prisma.comment.findMany({ - where, + where: withDeletedFilter(where, options), include: options.include || undefined, select: options.select || undefined, orderBy: options.orderBy || { createdAt: "desc" }, + skip: Number.isInteger(options.skip) ? options.skip : undefined, + take: Number.isInteger(options.take) ? options.take : undefined, }) } + async function findComment(where, options = {}) { return prisma.comment.findFirst({ - where, + where: withDeletedFilter(where, options), include: options.include || undefined, select: options.select || undefined, orderBy: options.orderBy || { createdAt: "desc" }, @@ -29,12 +39,18 @@ async function createComment(data, options = {}, db) { }) } -async function deleteComment(where) { - return prisma.comment.delete({ where }) -} -async function countComments(where = {}, db) { +async function deleteComment(where, db) { const p = getDb(db) - return p.comment.count({ where }) + return p.comment.delete({ where }) +} + +async function softDeleteComment(where, db) { + const p = getDb(db) + return p.comment.updateMany({ where, data: { deletedAt: new Date() } }) +} +async function countComments(where = {}, db, options = {}) { + const p = getDb(db) + return p.comment.count({ where: withDeletedFilter(where, options) }) } @@ -43,5 +59,6 @@ module.exports = { countComments, createComment, deleteComment, + softDeleteComment, findComment } diff --git a/db/commentLike.db.js b/db/commentLike.db.js new file mode 100644 index 0000000..93036d7 --- /dev/null +++ b/db/commentLike.db.js @@ -0,0 +1,61 @@ +const prisma = require("./client") + +async function findLike(commentId, userId, db) { + const p = db || prisma + return p.commentLike.findUnique({ + where: { commentId_userId: { commentId, userId } }, + }) +} + +async function findLikesByUserAndCommentIds(userId, commentIds, db) { + const p = db || prisma + return p.commentLike.findMany({ + where: { userId, commentId: { in: commentIds } }, + select: { commentId: true }, + }) +} + +async function setCommentLike({ commentId, userId, like }) { + return prisma.$transaction(async (tx) => { + const comment = await tx.comment.findUnique({ + where: { id: commentId }, + select: { id: true, likeCount: true }, + }) + if (!comment) throw new Error("Yorum bulunamadı.") + + const existing = await findLike(commentId, userId, tx) + + if (like) { + if (existing) { + return { liked: true, delta: 0, likeCount: comment.likeCount } + } + await tx.commentLike.create({ + data: { commentId, userId }, + }) + const updated = await tx.comment.update({ + where: { id: commentId }, + data: { likeCount: { increment: 1 } }, + select: { likeCount: true }, + }) + return { liked: true, delta: 1, likeCount: updated.likeCount } + } + + if (!existing) { + return { liked: false, delta: 0, likeCount: comment.likeCount } + } + await tx.commentLike.delete({ + where: { commentId_userId: { commentId, userId } }, + }) + const updated = await tx.comment.update({ + where: { id: commentId }, + data: { likeCount: { decrement: 1 } }, + select: { likeCount: true }, + }) + return { liked: false, delta: -1, likeCount: updated.likeCount } + }) +} + +module.exports = { + findLikesByUserAndCommentIds, + setCommentLike, +} diff --git a/db/deal.db.js b/db/deal.db.js index 5278138..dfb496a 100644 --- a/db/deal.db.js +++ b/db/deal.db.js @@ -4,63 +4,9 @@ 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({ +async function findDeals(where = {}, options = {}, db) { + const p = getDb(db) + return p.deal.findMany({ where, include: options.include || undefined, select: options.select || undefined, @@ -70,39 +16,16 @@ async function findDeals(where = {}, options = {}) { }) } -async function findSimilarCandidatesByCategory(categoryId, excludeDealId, { take = 80 } = {}) { - const safeTake = Math.min(Math.max(Number(take) || 80, 1), 200) +async function findSimilarCandidates(where, options = {}, db) { + const p = getDb(db) + const safeTake = Math.min(Math.max(Number(options.take) || 30, 1), 200) - return prisma.deal.findMany({ - where: { - id: { not: Number(excludeDealId) }, - status: "ACTIVE", - categoryId: Number(categoryId), - }, - orderBy: [{ score: "desc" }, { createdAt: "desc" }], + return p.deal.findMany({ + where, + orderBy: options.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 } }, - }, + include: options.include || undefined, + select: options.select || undefined, }) } @@ -115,9 +38,9 @@ async function findDeal(where, options = {}, db) { }) } - -async function createDeal(data, options = {}) { - return prisma.deal.create({ +async function createDeal(data, options = {}, db) { + const p = getDb(db) + return p.deal.create({ data, include: options.include || undefined, select: options.select || undefined, @@ -130,32 +53,36 @@ async function updateDeal(where, data, options = {}, db) { where, data, include: options.include || undefined, - select: options.select || undefined, + select: options.select || undefined, }) } -async function countDeals(where = {}) { - return prisma.deal.count({ where }) +async function countDeals(where = {}, db) { + const p = getDb(db) + return p.deal.count({ where }) } -async function findVotes(where = {}, options = {}) { - return prisma.dealVote.findMany({ +async function findVotes(where = {}, options = {}, db) { + const p = getDb(db) + return p.dealVote.findMany({ where, include: options.include || undefined, select: options.select || undefined, }) } -async function createVote(data, options = {}) { - return prisma.dealVote.create({ +async function createVote(data, options = {}, db) { + const p = getDb(db) + return p.dealVote.create({ data, include: options.include || undefined, select: options.select || undefined, }) } -async function updateVote(where, data, options = {}) { - return prisma.dealVote.update({ +async function updateVote(where, data, options = {}, db) { + const p = getDb(db) + return p.dealVote.update({ where, data, include: options.include || undefined, @@ -163,14 +90,9 @@ async function updateVote(where, data, options = {}) { }) } -async function countVotes(where = {}) { - return prisma.dealVote.count({ where }) -} -async function getDealWithImages(dealId) { - return prisma.deal.findUnique({ - where: { id: dealId }, - include: { images: { orderBy: { order: "asc" } } }, - }); +async function countVotes(where = {}, db) { + const p = getDb(db) + return p.dealVote.count({ where }) } async function aggregateDeals(where = {}, db) { @@ -182,12 +104,9 @@ async function aggregateDeals(where = {}, db) { }) } - - module.exports = { findDeals, - aggregateDeals, - getDealWithImages, + findSimilarCandidates, findDeal, createDeal, updateDeal, @@ -196,8 +115,5 @@ module.exports = { createVote, updateVote, countVotes, - findSimilarCandidatesByCategory, - findSimilarCandidatesBySeller, - getDealCards, - getPaginatedDealCards + aggregateDeals, } diff --git a/docs/frontend-api.md b/docs/frontend-api.md new file mode 100644 index 0000000..fd43385 --- /dev/null +++ b/docs/frontend-api.md @@ -0,0 +1,318 @@ +# Frontend API Guide (HotTRDeals) + +This file is a frontend-focused summary of current backend routes, auth, and response models. +Server entry: `server.js`. + +## Base URL and CORS +- Local API base: `http://localhost:3000` +- All routes below are relative to `/api`. +- CORS in dev allows `http://localhost:5173` and `credentials: true`. + +## Auth summary +- Access token is returned in response body: `{ token, user }`. +- Send access token via header: `Authorization: Bearer `. +- Refresh token is stored in httpOnly cookie named `rt`. +- For `/auth/refresh` and `/auth/logout`, the frontend must send cookies (`withCredentials: true`). +- Cookie options: + - Dev: `secure=false`, `sameSite=lax` + - Prod: `secure=true`, `sameSite=none` + +## Roles +- Roles: `USER`, `MOD`, `ADMIN`. +- `requireRole("MOD")` means only MOD and ADMIN can access. + +## Common response shapes +- Errors are inconsistent: + - Some endpoints return `{ error: "..." }` + - Some endpoints return `{ message: "..." }` +- Expect both in frontend error handling. + +## Common models (from adapters/contracts) + +### PublicUserSummary +``` +{ + id: number, + username: string, + avatarUrl: string | null +} +``` + +### PublicUserDetails +``` +{ + id: number, + username: string, + avatarUrl: string | null, + email: string, + createdAt: string (ISO) +} +``` + +### AuthUser +``` +{ + id: number, + username: string, + email: string, + role: "USER" | "MOD" | "ADMIN", + avatarUrl: string | null +} +``` + +### DealCard +``` +{ + id: number, + title: string, + description: string, + price: number | null, + originalPrice?: number | null, + shippingPrice?: number | null, + score: number, + commentsCount: number, + url: string | null, + status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED", + saleType: "ONLINE" | "OFFLINE" | "CODE", + affiliateType: "AFFILIATE" | "NON_AFFILIATE" | "USER_AFFILIATE", + myVote: -1 | 0 | 1, + createdAt: string (ISO), + updatedAt: string (ISO), + user: PublicUserSummary, + seller: { name: string, url: string | null }, + imageUrl: string +} +``` + +### DealDetail +``` +{ + id: number, + title: string, + description: string, + url: string | null, + price: number | null, + originalPrice?: number | null, + shippingPrice?: number | null, + score: number, + commentsCount: number, + status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED", + saleType: "ONLINE" | "OFFLINE" | "CODE", + affiliateType: "AFFILIATE" | "NON_AFFILIATE" | "USER_AFFILIATE", + createdAt: string (ISO), + updatedAt: string (ISO), + user: PublicUserSummary, + seller: { id: number, name: string, url: string | null }, + images: [{ id: number, imageUrl: string, order: number }], + comments: [{ id: number, text: string, createdAt: string, parentId?: number | null, user: PublicUserSummary }], + breadcrumb: [{ id: number, name: string, slug: string }], + notice: { + id: number, + dealId: number, + title: string, + body: string | null, + severity: "INFO" | "WARNING" | "DANGER" | "SUCCESS", + isActive: boolean, + createdBy: number, + createdAt: string, + updatedAt: string + } | null, + similarDeals: [{ id: number, title: string, price: number | null, score: number, imageUrl: string, sellerName: string, createdAt: string | null }] +} +``` + +### DealListResponse +``` +{ + page: number, + total: number, + totalPages: number, + results: DealCard[] +} +``` + +### Comment (DealComment) +``` +{ + id: number, + text: string, + createdAt: string, + parentId?: number | null, + user: PublicUserSummary +} +``` + +### UserProfile +``` +{ + user: PublicUserDetails, + stats: { totalLikes: number, totalShares: number, totalComments: number, totalDeals: number }, + deals: DealCard[], + comments: [{ ...Comment, deal: { id: number, title: string } }] +} +``` + +### VoteResponse +``` +{ + dealId: number, + voteType: -1 | 0 | 1, + delta: number, + score: number | null +} +``` + +### VoteListResponse +``` +{ + votes: [{ id, dealId, userId, voteType, createdAt, lastVotedAt }] +} +``` + +## Endpoints + +### Auth (`/api/auth`) +- `POST /register` + - Body: `{ username, email, password }` + - Response: `{ token, user: AuthUser }` and sets `rt` cookie if refresh token exists. + +- `POST /login` + - Body: `{ email, password }` + - Response: `{ token, user: AuthUser }` and sets `rt` cookie. + +- `POST /refresh` + - Cookie required: `rt` + - Response: `{ token, user: AuthUser }` and rotates `rt` cookie. + +- `POST /logout` + - Cookie optional: `rt` + - Response: `204 No Content`, clears `rt` cookie. + +- `GET /me` + - Auth: required + - Response: `AuthUser` + - Note: current implementation reads `req.user.userId` in adapter, while auth middleware sets `req.auth`. If `req.user` is undefined, this endpoint can fail. + +### Account (`/api/account`) +- `POST /avatar` + - Auth: required + - Multipart: `file` (single) + - Validation: JPEG only, max 2MB + - Response: `{ message, user: PublicUserSummary }` + +- `GET /me` + - Auth: required + - Response: `PublicUserDetails` + +### Deals (`/api/deals`) +List query params (used in list endpoints): +- `q` (string, default "") +- `page` (int, default 1) +- `limit` (int, default 10, max 100) + +Endpoints: +- `GET /users/:userName/deals` + - Auth: optional + - Response: `DealListResponse` + +- `GET /me/deals` + - Auth: required + - Response: `DealListResponse` + +- `GET /new` +- `GET /hot` +- `GET /trending` +- `GET /` (same as `/new`) + - Auth: optional + - Response: `DealListResponse` + +- `GET /search` + - Auth: optional + - If `q` is empty: `{ results: [], total: 0, totalPages: 0, page }` + - Else: `DealListResponse` + +- `GET /top` + - Auth: optional + - Query: `range=day|week|month` (default `day`), `limit` (default 6, max 20) + - Response: `DealCard[]` (array only) + +- `GET /:id` + - Response: `DealDetail` + +- `POST /` + - Auth: required + - Multipart: `images` (array, max 5) + - Limits: 10MB per file (upload middleware) + - Body fields: `{ title, description?, url?, price?, sellerName? }` + - Response: `DealDetail` + +### Category (`/api/category`) +- `GET /:slug` + - Response: `{ id, name, slug, description, breadcrumb }` + +- `GET /:slug/deals` + - Auth: optional + - Query: `page`, `limit`, plus filters from query + - Response: `DealCard[]` (array only) + +### Seller (`/api/seller`) +- `POST /from-link` + - Auth: required + - Body: `{ url }` + - Response: `{ found: boolean, seller: { id, name, url } | null }` + +- `GET /:sellerName` + - Response: `{ id, name, url, logoUrl }` + +- `GET /:sellerName/deals` + - Auth: optional + - Query: `page`, `limit`, `q` + - Response: `DealCard[]` (array only) + +### Comments (`/api/comments`) +- `GET /:dealId` + - Response: `Comment[]` + +- `POST /` + - Auth: required + - Body: `{ dealId, text, parentId? }` + - Response: `Comment` + +- `DELETE /:id` + - Auth: required + - Response: `{ message: "Yorum silindi." }` + +### Votes (`/api/vote` and `/api/deal-votes`) +- `POST /` + - Auth: required + - Body: `{ dealId, voteType }` where voteType is -1, 0, or 1 + - Response: `VoteResponse` + +- `GET /:dealId` + - Response: `VoteListResponse` + +### Users (`/api/user` and `/api/users`) +- `GET /:userName` + - Response: `UserProfile` + +### Mod (`/api/mod`) (MOD or ADMIN) +- `GET /deals/pending` + - Auth + Role: MOD + - Query: `page`, `limit`, `q` plus filters + - Response: `DealCard[]` (array only) + +- `POST /deals/:id/approve` +- `POST /deals/:id/reject` +- `POST /deals/:id/expire` +- `POST /deals/:id/unexpire` + - Auth + Role: MOD + - Response: `{ id, status }` + +## Notes for frontend +- Some list endpoints return full pagination object, some return only `results` array. Treat them separately: + - Full: `/api/deals` list endpoints, `/api/deals/search`, `/api/deals/users/:userName/deals`, `/api/deals/me/deals` + - Array only: `/api/deals/top`, `/api/category/:slug/deals`, `/api/seller/:sellerName/deals`, `/api/mod/deals/pending` +- Optional-auth endpoints return `401` if a token is provided but invalid. +- MyVote is only filled when Authorization header is present. +- There are duplicate route prefixes for users and votes. Frontend can use either, but pick one for consistency. + diff --git a/prisma/categories.json b/prisma/categories.json index d3bbef6..5e945a0 100644 --- a/prisma/categories.json +++ b/prisma/categories.json @@ -1,239 +1,242 @@ [ - { "id": 0, "name": "Undefined", "slug": "undefined", "parentId": null }, + { "id": 0, "name": "Undefined", "slug": "undefined", "parentId": null, "description": "Henüz sınıflandırılmamış içerikler için geçici kategori." }, - { "id": 1, "name": "Elektronik", "slug": "electronics", "parentId": 0 }, - { "id": 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": 1, "name": "Elektronik", "slug": "electronics", "parentId": 0, "description": "Telefon, bilgisayar, TV, ses sistemleri ve diğer elektronik ürünler." }, + { "id": 2, "name": "Kozmetik", "slug": "beauty", "parentId": 0, "description": "Makyaj, cilt bakımı, saç bakımı, parfüm ve kişisel bakım ürünleri." }, + { "id": 3, "name": "Gıda", "slug": "food", "parentId": 0, "description": "Atıştırmalık, içecek, temel gıda ve market ürünleri." }, + { "id": 4, "name": "Oto", "slug": "auto", "parentId": 0, "description": "Araç bakım, yağ, yedek parça ve oto aksesuar ürünleri." }, + { "id": 5, "name": "Ev & Bahçe", "slug": "home-garden", "parentId": 0, "description": "Ev ihtiyaçları, dekorasyon, temizlik ve bahçe ürünleri." }, - { "id": 6, "name": "Bilgisayar", "slug": "computers", "parentId": 1 }, - { "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": 6, "name": "Bilgisayar", "slug": "computers", "parentId": 1, "description": "Masaüstü/dizüstü bilgisayarlar, tabletler ve bilgisayar ekipmanları." }, + { "id": 7, "name": "PC Bileşenleri", "slug": "pc-components", "parentId": 6, "description": "Bilgisayar toplama/yükseltme için işlemci, ekran kartı, RAM, depolama vb." }, + { "id": 8, "name": "RAM", "slug": "pc-ram", "parentId": 7, "description": "Bilgisayar performansını artırmaya yönelik bellek modülleri." }, + { "id": 9, "name": "SSD", "slug": "pc-ssd", "parentId": 7, "description": "Hızlı depolama çözümleri (NVMe/SATA) SSD diskler." }, + { "id": 10, "name": "CPU", "slug": "pc-cpu", "parentId": 7, "description": "Bilgisayar işlemcileri; performans, oyun ve iş kullanımına yönelik modeller." }, + { "id": 11, "name": "GPU", "slug": "pc-gpu", "parentId": 7, "description": "Ekran kartları; oyun, grafik tasarım ve video işleme için." }, - { "id": 12, "name": "Bilgisayar Aksesuarları", "slug": "pc-peripherals", "parentId": 6 }, - { "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": 12, "name": "Bilgisayar Aksesuarları", "slug": "pc-peripherals", "parentId": 6, "description": "Klavye, mouse, webcam, mikrofon, mousepad gibi çevre birimleri." }, + { "id": 13, "name": "Klavye", "slug": "pc-keyboard", "parentId": 12, "description": "Mekanik/membran, oyuncu ve ofis kullanımına uygun klavyeler." }, + { "id": 14, "name": "Mouse", "slug": "pc-mouse", "parentId": 12, "description": "Kablolu/kablosuz, oyuncu ve günlük kullanım mouse modelleri." }, + { "id": 15, "name": "Monitör", "slug": "pc-monitor", "parentId": 6, "description": "Bilgisayar monitörleri; oyun, ofis ve profesyonel kullanım seçenekleri." }, - { "id": 16, "name": "Makyaj", "slug": "beauty-makeup", "parentId": 2 }, - { "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": 16, "name": "Makyaj", "slug": "beauty-makeup", "parentId": 2, "description": "Ruj, fondöten, maskara ve diğer makyaj ürünleri." }, + { "id": 17, "name": "Ruj", "slug": "beauty-lipstick", "parentId": 16, "description": "Mat, parlak, likit ve farklı renk seçeneklerinde dudak ürünleri." }, + { "id": 18, "name": "Fondöten", "slug": "beauty-foundation", "parentId": 16, "description": "Cilt tonunu eşitleyen; mat/parlak bitişli fondöten ürünleri." }, + { "id": 19, "name": "Maskara", "slug": "beauty-mascara", "parentId": 16, "description": "Kirpiklere hacim, uzunluk ve kıvrım kazandıran maskaralar." }, - { "id": 20, "name": "Cilt Bakımı", "slug": "beauty-skincare", "parentId": 2 }, - { "id": 21, "name": "Nemlendirici", "slug": "beauty-moisturizer", "parentId": 20 }, + { "id": 20, "name": "Cilt Bakımı", "slug": "beauty-skincare", "parentId": 2, "description": "Nemlendirici, temizleyici, serum, güneş kremi gibi cilt bakım ürünleri." }, + { "id": 21, "name": "Nemlendirici", "slug": "beauty-moisturizer", "parentId": 20, "description": "Cildi nemlendirip bariyeri destekleyen yüz/vücut nemlendiricileri." }, - { "id": 22, "name": "Atıştırmalık", "slug": "food-snacks", "parentId": 3 }, - { "id": 23, "name": "Çiğköfte", "slug": "food-cigkofte", "parentId": 22 }, + { "id": 22, "name": "Atıştırmalık", "slug": "food-snacks", "parentId": 3, "description": "Cips, kuruyemiş, bisküvi, çikolata ve benzeri atıştırmalıklar." }, + { "id": 23, "name": "Çiğköfte", "slug": "food-cigkofte", "parentId": 22, "description": "Hazır çiğköfte ürünleri ve çiğköfte setleri." }, - { "id": 24, "name": "İçecek", "slug": "food-beverages", "parentId": 3 }, - { "id": 25, "name": "Kahve", "slug": "food-coffee", "parentId": 24 }, + { "id": 24, "name": "İçecek", "slug": "food-beverages", "parentId": 3, "description": "Kahve, çay, su, gazlı içecek ve diğer içecek ürünleri." }, + { "id": 25, "name": "Kahve", "slug": "food-coffee", "parentId": 24, "description": "Çekirdek/öğütülmüş, kapsül ve hazır kahve çeşitleri." }, - { "id": 26, "name": "Yağlar", "slug": "auto-oils", "parentId": 4 }, - { "id": 27, "name": "Motor Yağı", "slug": "auto-engine-oil", "parentId": 26 }, + { "id": 26, "name": "Yağlar", "slug": "auto-oils", "parentId": 4, "description": "Motor yağı ve araç için kullanılan diğer yağ çeşitleri." }, + { "id": 27, "name": "Motor Yağı", "slug": "auto-engine-oil", "parentId": 26, "description": "Motoru koruyan; farklı viskozite ve onaylara sahip motor yağları." }, - { "id": 28, "name": "Oto Parçaları", "slug": "auto-parts", "parentId": 4 }, - { "id": 29, "name": "Fren Balatası", "slug": "auto-brake-pads", "parentId": 28 }, + { "id": 28, "name": "Oto Parçaları", "slug": "auto-parts", "parentId": 4, "description": "Fren, filtre, aydınlatma ve diğer araç yedek parça ürünleri." }, + { "id": 29, "name": "Fren Balatası", "slug": "auto-brake-pads", "parentId": 28, "description": "Araç fren sistemi için ön/arka fren balatası ürünleri." }, - { "id": 30, "name": "Bahçe", "slug": "home-garden-garden", "parentId": 5 }, - { "id": 31, "name": "Sulama", "slug": "garden-irrigation", "parentId": 30 }, + { "id": 30, "name": "Bahçe", "slug": "home-garden-garden", "parentId": 5, "description": "Bahçe bakımı, sulama ve dış mekân düzenleme ürünleri." }, + { "id": 31, "name": "Sulama", "slug": "garden-irrigation", "parentId": 30, "description": "Hortum, damla sulama, sprinkler ve sulama ekipmanları." }, - { "id": 32, "name": "Telefon & Aksesuarları", "slug": "phone", "parentId": 1 }, - { "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": 32, "name": "Telefon & Aksesuarları", "slug": "phone", "parentId": 1, "description": "Akıllı telefonlar ve telefonla ilgili tüm aksesuarlar." }, + { "id": 33, "name": "Akıllı Telefon", "slug": "phone-smartphone", "parentId": 32, "description": "Android/iOS akıllı telefonlar ve farklı marka/model seçenekleri." }, + { "id": 34, "name": "Telefon Kılıfı", "slug": "phone-case", "parentId": 32, "description": "Cihazı koruyan silikon, sert kapak, cüzdan tipi telefon kılıfları." }, + { "id": 35, "name": "Ekran Koruyucu", "slug": "phone-screen-protector", "parentId": 32, "description": "Cam/film ekran koruyucular; çizilme ve darbe koruması sağlar." }, + { "id": 36, "name": "Şarj & Kablo", "slug": "phone-charging", "parentId": 32, "description": "Şarj adaptörü, kablo, hızlı şarj ekipmanları ve aksesuarları." }, + { "id": 37, "name": "Powerbank", "slug": "phone-powerbank", "parentId": 32, "description": "Taşınabilir şarj cihazları; farklı kapasite ve hızlı şarj destekleri." }, - { "id": 38, "name": "Giyilebilir Teknoloji", "slug": "wearables", "parentId": 1 }, - { "id": 39, "name": "Akıllı Saat", "slug": "wearables-smartwatch", "parentId": 38 }, - { "id": 40, "name": "Akıllı Bileklik", "slug": "wearables-band", "parentId": 38 }, + { "id": 38, "name": "Giyilebilir Teknoloji", "slug": "wearables", "parentId": 1, "description": "Akıllı saat, bileklik ve sağlık/aktivite takibi yapan cihazlar." }, + { "id": 39, "name": "Akıllı Saat", "slug": "wearables-smartwatch", "parentId": 38, "description": "Bildirim, sağlık takibi ve uygulama desteği sunan akıllı saatler." }, + { "id": 40, "name": "Akıllı Bileklik", "slug": "wearables-band", "parentId": 38, "description": "Adım, uyku, nabız gibi metrikleri takip eden akıllı bileklikler." }, - { "id": 41, "name": "Ses & Audio", "slug": "audio", "parentId": 1 }, - { "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": 41, "name": "Ses & Audio", "slug": "audio", "parentId": 1, "description": "Kulaklık, hoparlör, mikrofon, soundbar ve ses ekipmanları." }, + { "id": 42, "name": "Kulaklık", "slug": "audio-headphones", "parentId": 41, "description": "Kulak üstü, kulak içi, kablolu/kablosuz kulaklık modelleri." }, + { "id": 43, "name": "TWS Kulaklık", "slug": "audio-tws", "parentId": 42, "description": "Tam kablosuz (True Wireless) kulak içi kulaklıklar." }, + { "id": 44, "name": "Bluetooth Hoparlör", "slug": "audio-bt-speaker", "parentId": 41, "description": "Taşınabilir kablosuz hoparlörler; ev ve dış mekân kullanımı için." }, + { "id": 45, "name": "Soundbar", "slug": "audio-soundbar", "parentId": 41, "description": "TV için daha güçlü ve net ses sağlayan soundbar sistemleri." }, + { "id": 46, "name": "Mikrofon", "slug": "audio-microphone", "parentId": 41, "description": "Yayın, toplantı ve kayıt amaçlı masaüstü/yalaka mikrofonlar." }, + { "id": 47, "name": "Plak / Pikap", "slug": "audio-turntable", "parentId": 41, "description": "Vinyl plak ve pikap ürünleri; analog müzik ekipmanları." }, - { "id": 48, "name": "TV & Video", "slug": "tv-video", "parentId": 1 }, - { "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": 48, "name": "TV & Video", "slug": "tv-video", "parentId": 1, "description": "Televizyonlar, projeksiyonlar, medya oynatıcılar ve TV aksesuarları." }, + { "id": 49, "name": "Televizyon", "slug": "tv", "parentId": 48, "description": "LED/QLED/OLED televizyonlar; farklı boyut ve çözünürlük seçenekleri." }, + { "id": 50, "name": "Projeksiyon", "slug": "projector", "parentId": 48, "description": "Ev sineması ve sunum amaçlı projeksiyon cihazları." }, - { "id": 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": 51, "name": "Medya Oynatıcı", "slug": "tv-media-player", "parentId": 48, "description": "TV’ye bağlanıp uygulama/film/dizi oynatmayı sağlayan medya cihazları (Android TV box vb.)." }, + { "id": 52, "name": "TV Aksesuarları", "slug": "tv-accessories", "parentId": 48, "description": "TV için kumanda, askı aparatı, kablo, stand ve benzeri yardımcı aksesuarlar." }, + { "id": 53, "name": "Uydu Alıcısı / Receiver", "slug": "tv-receiver", "parentId": 48, "description": "Uydu yayını izlemek için receiver/uydu alıcısı ve ilgili cihazlar." }, - { "id": 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": 54, "name": "Konsollar", "slug": "console", "parentId": 191, "description": "PlayStation, Xbox, Nintendo konsolları; konsol oyunları ve aksesuarları." }, + { "id": 55, "name": "PlayStation", "slug": "console-playstation", "parentId": 54, "description": "PlayStation konsolları, oyunları, üyelikleri ve PlayStation aksesuarları." }, + { "id": 56, "name": "Xbox", "slug": "console-xbox", "parentId": 54, "description": "Xbox konsolları, oyunları, Game Pass/abonelik ve Xbox aksesuarları." }, + { "id": 57, "name": "Nintendo", "slug": "console-nintendo", "parentId": 54, "description": "Nintendo konsolları (Switch vb.), oyunları ve Nintendo aksesuarları." }, + { "id": 58, "name": "Oyunlar (Konsol)", "slug": "console-games", "parentId": 54, "description": "Konsollar için fiziksel/dijital oyunlar ve oyun içerikleri." }, + { "id": 59, "name": "Konsol Aksesuarları", "slug": "console-accessories", "parentId": 54, "description": "Kollar, şarj istasyonları, kulaklıklar, taşıma çantaları ve diğer konsol aksesuarları." }, - { "id": 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": 60, "name": "Kamera & Fotoğraf", "slug": "camera", "parentId": 1, "description": "Fotoğraf/video çekim ekipmanları; kamera gövdeleri, lensler ve aksesuarlar." }, + { "id": 61, "name": "Fotoğraf Makinesi", "slug": "camera-photo", "parentId": 60, "description": "DSLR, aynasız ve kompakt fotoğraf makineleri." }, + { "id": 62, "name": "Aksiyon Kamera", "slug": "camera-action", "parentId": 60, "description": "GoPro tarzı dayanıklı, suya dayanıklı ve hareketli çekime uygun aksiyon kameraları." }, + { "id": 63, "name": "Lens", "slug": "camera-lens", "parentId": 60, "description": "Kamera lensleri; prime/zoom, geniş açı, tele, portre ve benzeri seçenekler." }, + { "id": 64, "name": "Tripod", "slug": "camera-tripod", "parentId": 60, "description": "Fotoğraf/video için tripod, monopod ve stabil çekim destek ekipmanları." }, - { "id": 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": 65, "name": "Akıllı Ev", "slug": "smart-home", "parentId": 1, "description": "Ev otomasyonu ürünleri; aydınlatma, priz, sensör ve güvenlik çözümleri." }, + { "id": 66, "name": "Güvenlik Kamerası", "slug": "smart-security-camera", "parentId": 65, "description": "Ev/ofis için IP kamera, iç/dış kamera ve izleme sistemleri." }, + { "id": 67, "name": "Akıllı Priz", "slug": "smart-plug", "parentId": 65, "description": "Uygulama ile kontrol edilen, zamanlayıcı/enerji takibi sunan akıllı prizler." }, + { "id": 68, "name": "Akıllı Ampul", "slug": "smart-bulb", "parentId": 65, "description": "Renk/ışık şiddeti kontrolü yapılabilen, Wi-Fi/Zigbee akıllı ampuller." }, + { "id": 69, "name": "Akıllı Sensör", "slug": "smart-sensor", "parentId": 65, "description": "Kapı/pencere, hareket, sıcaklık/nem gibi verileri ölçen akıllı sensörler." }, - { "id": 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": 70, "name": "Ağ Ürünleri", "slug": "pc-networking", "parentId": 6, "description": "İnternet ve yerel ağ kurulum ürünleri; router, modem, switch, menzil genişletici." }, + { "id": 71, "name": "Router", "slug": "pc-router", "parentId": 70, "description": "Kablosuz ağ dağıtımı için router cihazları (Wi-Fi 5/6/6E/7 vb.)." }, + { "id": 72, "name": "Modem", "slug": "pc-modem", "parentId": 70, "description": "DSL/VDSL/FTTH uyumlu modemler ve modem-router cihazları." }, + { "id": 73, "name": "Switch", "slug": "pc-switch", "parentId": 70, "description": "Kablolu ağ için port çoğaltan network switch cihazları." }, + { "id": 74, "name": "Wi-Fi Extender", "slug": "pc-wifi-extender", "parentId": 70, "description": "Kablosuz ağ menzilini artıran repeater/extender ve mesh uyumlu cihazlar." }, - { "id": 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": 75, "name": "Yazıcı & Tarayıcı", "slug": "pc-printing", "parentId": 6, "description": "Ev/ofis baskı ve tarama ürünleri; yazıcı, tarayıcı ve sarf malzemeleri." }, + { "id": 76, "name": "Yazıcı", "slug": "pc-printer", "parentId": 75, "description": "Lazer/mürekkep püskürtmeli yazıcılar ve çok fonksiyonlu cihazlar." }, + { "id": 77, "name": "Toner & Kartuş", "slug": "pc-ink-toner", "parentId": 75, "description": "Yazıcılar için toner, kartuş, mürekkep ve ilgili sarf malzemeleri." }, + { "id": 78, "name": "Tarayıcı", "slug": "pc-scanner", "parentId": 75, "description": "Belge ve fotoğraf taraması için flatbed/ADF tarayıcı cihazları." }, - { "id": 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": 79, "name": "Dizüstü Bilgisayar", "slug": "pc-laptop", "parentId": 6, "description": "Taşınabilir dizüstü bilgisayarlar; günlük, oyun ve iş amaçlı modeller." }, + { "id": 80, "name": "Masaüstü Bilgisayar", "slug": "pc-desktop", "parentId": 6, "description": "Hazır masaüstü bilgisayarlar ve iş/oyun odaklı sistemler." }, + { "id": 81, "name": "Tablet", "slug": "pc-tablet", "parentId": 6, "description": "Android/iPadOS/Windows tabletler ve tablet benzeri cihazlar." }, - { "id": 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": 82, "name": "Depolama", "slug": "pc-storage", "parentId": 6, "description": "Harici disk, USB bellek, NAS ve diğer depolama çözümleri." }, + { "id": 83, "name": "Harici Disk", "slug": "pc-external-drive", "parentId": 82, "description": "Taşınabilir harici HDD/SSD diskler ve yedekleme çözümleri." }, + { "id": 84, "name": "USB Flash", "slug": "pc-usb-drive", "parentId": 82, "description": "USB bellekler; farklı kapasite ve hız seçenekleri." }, + { "id": 85, "name": "NAS", "slug": "pc-nas", "parentId": 82, "description": "Ağ üzerinden depolama ve yedekleme için NAS cihazları ve disk kutuları." }, - { "id": 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": 86, "name": "Webcam", "slug": "pc-webcam", "parentId": 12, "description": "Görüntülü görüşme ve yayın için web kameraları (1080p/2K/4K vb.)." }, + { "id": 87, "name": "Hoparlör (PC)", "slug": "pc-speaker", "parentId": 12, "description": "Bilgisayar için masaüstü hoparlör sistemleri ve ses çözümleri." }, + { "id": 88, "name": "Mikrofon (PC)", "slug": "pc-mic", "parentId": 12, "description": "Oyun, yayın, toplantı ve kayıt için PC uyumlu mikrofonlar." }, + { "id": 89, "name": "Mousepad", "slug": "pc-mousepad", "parentId": 12, "description": "Mouse kullanımını iyileştiren, farklı boyut ve yüzey tiplerinde mousepadler." }, + { "id": 90, "name": "Dock / USB Hub", "slug": "pc-dock-hub", "parentId": 12, "description": "Port çoğaltma için USB hub ve laptop dock istasyonları." }, + { "id": 91, "name": "Laptop Çantası", "slug": "pc-laptop-bag", "parentId": 12, "description": "Dizüstü bilgisayar taşıma çantaları, kılıflar ve koruyucu çantalar." }, + { "id": 92, "name": "Gamepad / Controller", "slug": "pc-controller", "parentId": 12, "description": "PC ile uyumlu oyun kolları ve kontrolcü aksesuarları." }, - { "id": 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": 93, "name": "Anakart", "slug": "pc-motherboard", "parentId": 7, "description": "İşlemci soketi ve chipset’e göre PC anakartları (ATX/mATX/ITX)." }, + { "id": 94, "name": "Güç Kaynağı (PSU)", "slug": "pc-psu", "parentId": 7, "description": "Bilgisayar bileşenlerini besleyen PSU güç kaynakları (80+ sertifikalı vb.)." }, + { "id": 95, "name": "Kasa", "slug": "pc-case", "parentId": 7, "description": "Bilgisayar kasaları; hava akışı, boyut ve tasarıma göre seçenekler." }, - { "id": 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": 96, "name": "Soğutma", "slug": "pc-cooling", "parentId": 7, "description": "CPU/GPU ve kasa soğutma çözümleri; fanlar, sıvı soğutma ve aksesuarlar." }, + { "id": 97, "name": "Kasa Fanı", "slug": "pc-fan", "parentId": 96, "description": "Kasa içi hava akışı için fanlar (RGB/PWM vb. seçenekler)." }, + { "id": 98, "name": "Sıvı Soğutma", "slug": "pc-liquid-cooling", "parentId": 96, "description": "AIO ve özel loop sıvı soğutma çözümleri ve bileşenleri." }, - { "id": 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": 99, "name": "Parfüm", "slug": "beauty-fragrance", "parentId": 2, "description": "Kadın/erkek parfümleri, deodorantlar ve koku ürünleri." }, + { "id": 100, "name": "Kadın Parfüm", "slug": "beauty-fragrance-women", "parentId": 99, "description": "Kadınlara yönelik parfümler; EDT/EDP ve farklı koku profilleri." }, + { "id": 101, "name": "Erkek Parfüm", "slug": "beauty-fragrance-men", "parentId": 99, "description": "Erkeklere yönelik parfümler; EDT/EDP, fresh/odunsu/baharatlı koku seçenekleri." }, - { "id": 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": 102, "name": "Saç Bakımı", "slug": "beauty-haircare", "parentId": 2, "description": "Saç temizliği, onarımı ve şekillendirme için saç bakım ürünleri." }, + { "id": 103, "name": "Şampuan", "slug": "beauty-shampoo", "parentId": 102, "description": "Kepek, yağlı/kuru saç, onarıcı ve renk koruyucu şampuan çeşitleri." }, + { "id": 104, "name": "Saç Kremi", "slug": "beauty-conditioner", "parentId": 102, "description": "Saçı yumuşatan, kolay tarama sağlayan ve bakım yapan saç kremleri." }, + { "id": 105, "name": "Saç Şekillendirici", "slug": "beauty-hair-styling", "parentId": 102, "description": "Wax, jel, köpük, sprey ve ısı koruyucu gibi şekillendirici ürünler." }, - { "id": 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": 106, "name": "Kişisel Bakım", "slug": "beauty-personal-care", "parentId": 2, "description": "Günlük hijyen ve bakım ürünleri; deodorant, tıraş ve epilasyon gibi." }, + { "id": 107, "name": "Deodorant", "slug": "beauty-deodorant", "parentId": 106, "description": "Ter kokusunu önlemeye yardımcı roll-on, sprey ve stick deodorantlar." }, + { "id": 108, "name": "Tıraş Ürünleri", "slug": "beauty-shaving", "parentId": 106, "description": "Tıraş köpüğü/jeli, losyon, aftershave ve tıraş bıçağı ürünleri." }, + { "id": 109, "name": "Ağda / Epilasyon", "slug": "beauty-hair-removal", "parentId": 106, "description": "Ağda bantları, ağda ürünleri, epilatör ve tüy alma yardımcıları." }, - { "id": 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": 110, "name": "Serum", "slug": "beauty-skincare-serum", "parentId": 20, "description": "Leke, nem, anti-aging ve aydınlatma için yoğun içerikli cilt serumları." }, + { "id": 111, "name": "Güneş Kremi", "slug": "beauty-sunscreen", "parentId": 20, "description": "UVA/UVB koruması sağlayan yüz ve vücut güneş koruyucuları (SPF)." }, + { "id": 112, "name": "Temizleyici", "slug": "beauty-cleanser", "parentId": 20, "description": "Jel, köpük, yağ bazlı ve micellar gibi yüz temizleme ürünleri." }, + { "id": 113, "name": "Yüz Maskesi", "slug": "beauty-mask", "parentId": 20, "description": "Kil, kağıt ve gece maskeleri; nem, arındırma ve bakım amaçlı." }, + { "id": 114, "name": "Tonik", "slug": "beauty-toner", "parentId": 20, "description": "Cildi dengeleyen, gözenek görünümünü destekleyen tonik ürünleri." }, - { "id": 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": 115, "name": "Temel Gıda", "slug": "food-staples", "parentId": 3, "description": "Günlük mutfak ihtiyaçları; makarna, bakliyat, yağ ve benzeri temel ürünler." }, + { "id": 116, "name": "Makarna", "slug": "food-pasta", "parentId": 115, "description": "Spagetti, penne, erişte ve farklı çeşitlerde makarna ürünleri." }, + { "id": 117, "name": "Pirinç & Bakliyat", "slug": "food-legumes", "parentId": 115, "description": "Pirinç, bulgur, mercimek, nohut, fasulye ve diğer bakliyatlar." }, + { "id": 118, "name": "Yağ & Sirke (Gıda)", "slug": "food-oil-vinegar", "parentId": 115, "description": "Zeytinyağı, ayçiçek yağı ve çeşitli sirke türleri gibi ürünler." }, - { "id": 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": 119, "name": "Kahvaltılık", "slug": "food-breakfast", "parentId": 3, "description": "Peynir, zeytin, reçel, bal ve diğer kahvaltılık ürünler." }, + { "id": 120, "name": "Peynir", "slug": "food-cheese", "parentId": 119, "description": "Beyaz peynir, kaşar, tulum ve farklı peynir çeşitleri." }, + { "id": 121, "name": "Zeytin", "slug": "food-olive", "parentId": 119, "description": "Siyah/yeşil, çekirdekli/çekirdeksiz ve salamura zeytin çeşitleri." }, + { "id": 122, "name": "Reçel & Bal", "slug": "food-jam-honey", "parentId": 119, "description": "Kahvaltılık reçeller, marmelatlar, bal ve benzeri tatlandırıcı ürünler." }, - { "id": 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": 123, "name": "Gazlı İçecek", "slug": "food-soda", "parentId": 24, "description": "Kola, gazoz, aromalı soda ve benzeri gazlı içecekler." }, + { "id": 124, "name": "Su", "slug": "food-water", "parentId": 24, "description": "Pet şişe, damacana ve aromalı su seçenekleri." }, + { "id": 125, "name": "Enerji İçeceği", "slug": "food-energy", "parentId": 24, "description": "Enerji içecekleri; farklı hacim ve kafein/taurin içerikli seçenekler." }, + { "id": 126, "name": "Çay", "slug": "food-tea", "parentId": 24, "description": "Siyah çay, yeşil çay, bitki çayları ve aromalı çay çeşitleri." }, - { "id": 130, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 4 }, - { "id": 131, "name": "Araç İçi Elektronik", "slug": "auto-in-car-electronics", "parentId": 130 }, + { "id": 127, "name": "Dondurulmuş", "slug": "food-frozen", "parentId": 3, "description": "Dondurulmuş gıdalar; sebze, hazır ürünler ve dondurulmuş atıştırmalıklar." }, + { "id": 128, "name": "Et & Tavuk", "slug": "food-meat", "parentId": 3, "description": "Kırmızı et, tavuk ve işlenmiş et ürünleri; paketli market seçenekleri." }, + { "id": 129, "name": "Tatlı", "slug": "food-dessert", "parentId": 3, "description": "Pastane/market tatlıları, çikolata bazlı ürünler ve tatlı çeşitleri." }, - { "id": 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": 130, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 4, "description": "Araç içi/dışı kullanım için aksesuarlar; düzenleyici, tutucu, bakım setleri vb." }, + { "id": 131, "name": "Araç İçi Elektronik", "slug": "auto-in-car-electronics", "parentId": 130, "description": "Araç içi kamera, multimedya, şarj cihazı, FM transmitter gibi elektronik ürünler." }, - { "id": 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": 132, "name": "Oto Bakım", "slug": "auto-care", "parentId": 4, "description": "Araç bakım ürünleri; cila, wax, kaplama, temizlik ve koruma çözümleri." }, + { "id": 133, "name": "Oto Temizlik", "slug": "auto-cleaning", "parentId": 132, "description": "İç/dış temizlik ürünleri; şampuan, köpük, bez, fırça ve temizleyiciler." }, + { "id": 134, "name": "Lastik & Jant", "slug": "auto-tires", "parentId": 4, "description": "Lastik, jant ve ilgili aksesuarlar; mevsimlik lastikler ve bakım ürünleri." }, + { "id": 135, "name": "Akü", "slug": "auto-battery", "parentId": 4, "description": "Otomobil aküleri ve akü takviye/şarj ekipmanları." }, + { "id": 136, "name": "Oto Aydınlatma", "slug": "auto-lighting", "parentId": 130, "description": "Far ampulü, LED dönüşüm kitleri ve araç iç/dış aydınlatma ürünleri." }, + { "id": 137, "name": "Oto Ses Sistemi", "slug": "auto-audio", "parentId": 130, "description": "Teyp, hoparlör, amfi, subwoofer ve araç ses sistemi ekipmanları." }, - { "id": 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": 138, "name": "Mobilya", "slug": "home-furniture", "parentId": 5, "description": "Ev mobilyaları; masa, sandalye, koltuk, yatak ve depolama ürünleri." }, + { "id": 139, "name": "Yemek Masası", "slug": "home-dining-table", "parentId": 138, "description": "Mutfak/yemek odası için farklı boyut ve malzemelerde yemek masaları." }, + { "id": 140, "name": "Sandalye", "slug": "home-chair", "parentId": 138, "description": "Yemek odası, çalışma ve çok amaçlı kullanım için sandalyeler." }, + { "id": 141, "name": "Koltuk", "slug": "home-sofa", "parentId": 138, "description": "Oturma odası için koltuk, kanepe ve oturma grubu ürünleri." }, + { "id": 142, "name": "Yatak", "slug": "home-bed", "parentId": 138, "description": "Tek/çift kişilik yatak bazası, karyola ve yatak sistemleri." }, - { "id": 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": 143, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 5, "description": "Nevresim, battaniye, perde ve diğer ev tekstili ürünleri." }, + { "id": 144, "name": "Nevresim", "slug": "home-bedding", "parentId": 143, "description": "Nevresim takımları, çarşaflar ve yastık kılıfları." }, + { "id": 145, "name": "Yorgan & Battaniye", "slug": "home-blanket", "parentId": 143, "description": "Isı ve konfor sağlayan yorgan, battaniye ve uyku ürünleri." }, + { "id": 146, "name": "Perde", "slug": "home-curtain", "parentId": 143, "description": "Tül, fon ve stor gibi farklı perde çeşitleri ve aksesuarları." }, - { "id": 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": 147, "name": "Mutfak", "slug": "home-kitchen", "parentId": 5, "description": "Mutfak gereçleri, pişirme ekipmanları ve küçük ev aletleri." }, + { "id": 148, "name": "Tencere & Tava", "slug": "home-cookware", "parentId": 147, "description": "Tencere setleri, tava çeşitleri ve pişirme ekipmanları." }, + { "id": 149, "name": "Küçük Ev Aletleri", "slug": "home-small-appliances", "parentId": 147, "description": "Mutfakta kullanılan küçük elektrikli aletler; kahve makinesi, blender vb." }, + { "id": 150, "name": "Kahve Makinesi", "slug": "home-coffee-machine", "parentId": 149, "description": "Filtre, espresso, kapsül ve Türk kahvesi makineleri." }, + { "id": 151, "name": "Blender", "slug": "home-blender", "parentId": 149, "description": "Smoothie, çorba ve karıştırma işlemleri için blender ve el blender setleri." }, + { "id": 152, "name": "Airfryer", "slug": "home-airfryer", "parentId": 149, "description": "Az yağ ile pişirme yapmaya yarayan airfryer cihazları ve aksesuarları." }, + { "id": 153, "name": "Süpürge", "slug": "home-vacuum", "parentId": 149, "description": "Dikey, toz torbalı/torbasız ve robot süpürge dahil ev süpürgeleri." }, - { "id": 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": 154, "name": "Aydınlatma", "slug": "home-lighting", "parentId": 5, "description": "Avize, lambader, masa lambası ve LED aydınlatma çözümleri." }, + { "id": 155, "name": "Dekorasyon", "slug": "home-decor", "parentId": 5, "description": "Evi kişiselleştiren dekoratif ürünler; aksesuar, tablo, obje ve benzerleri." }, + { "id": 156, "name": "Halı", "slug": "home-rug", "parentId": 155, "description": "Salon, koridor ve oda için halılar; farklı ölçü ve materyal seçenekleri." }, + { "id": 157, "name": "Duvar Dekoru", "slug": "home-wall-decor", "parentId": 155, "description": "Tablo, raf, ayna, sticker ve benzeri duvar dekor ürünleri." }, - { "id": 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": 158, "name": "Temizlik", "slug": "home-cleaning", "parentId": 5, "description": "Ev temizliği için ürünler; deterjan, bez, sünger ve temizlik ekipmanları." }, + { "id": 159, "name": "Deterjan", "slug": "home-detergent", "parentId": 158, "description": "Çamaşır, bulaşık ve yüzey temizliği için deterjan ve temizlik kimyasalları." }, + { "id": 160, "name": "Kağıt Ürünleri", "slug": "home-paper-products", "parentId": 158, "description": "Tuvalet kağıdı, kağıt havlu, peçete ve benzeri kağıt temizlik ürünleri." }, - { "id": 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": 161, "name": "El Aletleri", "slug": "home-tools", "parentId": 5, "description": "Ev ve hobi işleri için el aletleri, tamir ve montaj ekipmanları." }, + { "id": 162, "name": "Matkap", "slug": "home-drill", "parentId": 161, "description": "Darbeli/darbesiz, şarjlı/kablolu matkap ve vidalama makineleri." }, + { "id": 163, "name": "Testere", "slug": "home-saw", "parentId": 161, "description": "Ahşap/metal kesim için el testereleri ve elektrikli testere çeşitleri." }, + { "id": 164, "name": "Vida & Dübel", "slug": "home-hardware", "parentId": 161, "description": "Montaj ve sabitleme için vida, dübel, bağlantı elemanları ve setler." }, - { "id": 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": 165, "name": "Evcil Hayvan", "slug": "pet", "parentId": 5, "description": "Kedi, köpek ve diğer evcil hayvanlar için mama, bakım ve ihtiyaç ürünleri." }, + { "id": 166, "name": "Kedi Maması", "slug": "pet-cat-food", "parentId": 165, "description": "Yavru/yetişkin kedi için kuru/yaş mama ve özel diyet mamaları." }, + { "id": 167, "name": "Köpek Maması", "slug": "pet-dog-food", "parentId": 165, "description": "Yavru/yetişkin köpek için kuru/yaş mama ve özel ihtiyaç mamaları." }, + { "id": 168, "name": "Kedi Kumu", "slug": "pet-cat-litter", "parentId": 165, "description": "Topaklanan/silikalı/bitkisel kedi kumları ve koku kontrol çözümleri." }, - { "id": 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": 169, "name": "Kırtasiye & Ofis", "slug": "office", "parentId": 0, "description": "Okul ve ofis ihtiyaçları; kağıt ürünleri, yazım gereçleri ve aksesuarlar." }, + { "id": 170, "name": "Kağıt & Defter", "slug": "office-paper-notebook", "parentId": 169, "description": "Defter, ajanda, not kağıdı ve farklı türde kağıt ürünleri." }, + { "id": 171, "name": "A4 Kağıdı", "slug": "office-a4-paper", "parentId": 170, "description": "Yazıcı ve fotokopi için A4 kağıt; farklı gramaj ve kalite seçenekleri." }, + { "id": 172, "name": "Kalem", "slug": "office-pen", "parentId": 169, "description": "Tükenmez, jel, kurşun, marker ve farklı amaçlara uygun kalemler." }, + { "id": 173, "name": "Okul Çantası", "slug": "office-school-bag", "parentId": 169, "description": "Öğrenciler için sırt çantası, beslenme çantası ve okul çantaları." }, - { "id": 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": 174, "name": "Bebek & Çocuk", "slug": "baby", "parentId": 0, "description": "Bebek ve çocuk bakım/bez, mama, ıslak mendil ve oyuncak ürünleri." }, + { "id": 175, "name": "Bebek Bezi", "slug": "baby-diaper", "parentId": 174, "description": "Yeni doğan ve farklı bedenlerde bebek bezleri, külot bez seçenekleri." }, + { "id": 176, "name": "Islak Mendil", "slug": "baby-wipes", "parentId": 174, "description": "Bebek bakımı için ıslak mendil; hassas cilt uyumlu seçenekler." }, + { "id": 177, "name": "Bebek Maması", "slug": "baby-food", "parentId": 174, "description": "Bebekler için mama, ek gıda ve püre ürünleri." }, + { "id": 178, "name": "Oyuncak", "slug": "baby-toys", "parentId": 174, "description": "Bebek ve çocuklar için eğitici, zeka ve oyun oyuncakları." }, - { "id": 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": 179, "name": "Spor & Outdoor", "slug": "sports", "parentId": 0, "description": "Spor ekipmanları ve outdoor ürünleri; kamp, fitness, bisiklet ve daha fazlası." }, + { "id": 180, "name": "Kamp", "slug": "sports-camping", "parentId": 179, "description": "Çadır, uyku tulumu, kamp sandalyesi ve kamp ekipmanları." }, + { "id": 181, "name": "Fitness", "slug": "sports-fitness", "parentId": 179, "description": "Ağırlık, dambıl, mat ve evde antrenman için fitness ekipmanları." }, + { "id": 182, "name": "Bisiklet", "slug": "sports-bicycle", "parentId": 179, "description": "Şehir/dağ/katlanır bisikletler ve bisiklet aksesuarları." }, + + { "id": 183, "name": "Moda", "slug": "fashion", "parentId": 0, "description": "Giyim, ayakkabı ve aksesuar ürünleri; kadın/erkek moda kategorileri." }, + { "id": 184, "name": "Ayakkabı", "slug": "fashion-shoes", "parentId": 183, "description": "Spor ayakkabı, günlük ayakkabı ve farklı kullanım amaçlarına uygun modeller." }, + { "id": 185, "name": "Erkek Giyim", "slug": "fashion-men", "parentId": 183, "description": "Erkek kıyafetleri; tişört, gömlek, pantolon, mont ve daha fazlası." }, + { "id": 186, "name": "Kadın Giyim", "slug": "fashion-women", "parentId": 183, "description": "Kadın kıyafetleri; elbise, bluz, pantolon, mont ve daha fazlası." }, + { "id": 187, "name": "Çanta", "slug": "fashion-bags", "parentId": 183, "description": "Sırt çantası, el çantası, valiz ve farklı kullanım amaçlı çantalar." }, + + { "id": 188, "name": "Kitap & Medya", "slug": "books-media", "parentId": 0, "description": "Kitaplar, dijital içerikler, oyun ve medya ürünleri." }, + { "id": 189, "name": "Kitap", "slug": "books", "parentId": 188, "description": "Roman, kişisel gelişim, eğitim ve diğer türlerde basılı kitaplar." }, + { "id": 190, "name": "Dijital Oyun (Genel)", "slug": "digital-games", "parentId": 191, "description": "PC/konsol platformları için dijital oyunlar, kodlar ve dijital içerikler." }, + { "id": 191, "name": "Oyun", "slug": "games", "parentId": 0, "description": "Konsol, PC ve dijital oyun fırsatları; oyun ekipmanları ve abonelikler." } - { "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/deals.json b/prisma/deals.json new file mode 100644 index 0000000..db20382 --- /dev/null +++ b/prisma/deals.json @@ -0,0 +1,230 @@ +[ + { + "title": "Samsung 990 PRO 1TB NVMe SSD", + "price": 3299.99, + "originalPrice": 3799.99, + "url": "https://example.com/samsung-990pro-1tb", + "q": "nvme ssd" + }, + { + "title": "Logitech MX Master 3S Mouse", + "price": 2499.9, + "originalPrice": 2999.9, + "url": "https://example.com/mx-master-3s", + "q": "wireless mouse" + }, + { + "title": "Sony WH-1000XM5 Kulaklık", + "price": 9999.0, + "originalPrice": 11999.0, + "shippingPrice": 0, + "url": "https://example.com/sony-xm5", + "q": "headphones" + }, + { + "title": "Apple AirPods Pro 2", + "price": 8499.0, + "originalPrice": 9999.0, + "url": "https://example.com/airpods-pro-2", + "q": "earbuds" + }, + { + "title": "Anker 65W GaN Şarj Aleti", + "price": 899.0, + "shippingPrice": 39.9, + "url": "https://example.com/anker-65w-gan", + "q": "charger" + }, + { + "title": "Kindle Paperwhite 16GB", + "price": 5199.0, + "originalPrice": 5999.0, + "shippingPrice": 0, + "url": "https://example.com/kindle-paperwhite", + "q": "ebook reader" + }, + { + "title": "Dell 27\" 144Hz Monitör", + "price": 7999.0, + "originalPrice": 9499.0, + "shippingPrice": 0, + "url": "https://example.com/dell-27-144hz", + "q": "gaming monitor" + }, + { + "title": "TP-Link Wi-Fi 6 Router", + "price": 1999.0, + "shippingPrice": 29.9, + "url": "https://example.com/tplink-wifi6", + "q": "wifi router" + }, + { + "title": "Razer Huntsman Mini Klavye", + "price": 3499.0, + "originalPrice": 3999.0, + "url": "https://example.com/huntsman-mini", + "q": "mechanical keyboard" + }, + { + "title": "WD Elements 2TB Harici Disk", + "price": 2399.0, + "shippingPrice": 49.9, + "url": "https://example.com/wd-elements-2tb", + "q": "external hard drive" + }, + { + "title": "Samsung T7 Shield 1TB SSD", + "price": 2799.0, + "originalPrice": 3299.0, + "shippingPrice": 0, + "url": "https://example.com/samsung-t7-shield", + "q": "portable ssd" + }, + { + "title": "Xiaomi Mi Band 8", + "price": 1399.0, + "originalPrice": 1699.0, + "shippingPrice": 0, + "url": "https://example.com/mi-band-8", + "q": "smart band" + }, + { + "title": "Philips Airfryer 6.2L", + "price": 5999.0, + "originalPrice": 7499.0, + "url": "https://example.com/philips-airfryer", + "q": "air fryer" + }, + { + "title": "Dyson V12 Detect Slim", + "price": 21999.0, + "originalPrice": 25999.0, + "shippingPrice": 0, + "url": "https://example.com/dyson-v12", + "q": "vacuum cleaner" + }, + { + "title": "Nespresso Vertuo Kahve Makinesi", + "price": 6999.0, + "originalPrice": 8499.0, + "shippingPrice": 0, + "url": "https://example.com/nespresso-vertuo", + "q": "coffee machine" + }, + { + "title": "Nintendo Switch OLED 64GB", + "price": 11999.0, + "originalPrice": 13999.0, + "shippingPrice": 0, + "url": "https://example.com/nintendo-switch-oled", + "q": "game console" + }, + { + "title": "PlayStation 5 DualSense Controller", + "price": 2499.0, + "originalPrice": 2999.0, + "url": "https://example.com/ps5-dualsense", + "q": "game controller" + }, + { + "title": "Xbox Game Pass 3 Aylık Üyelik", + "price": 699.0, + "originalPrice": 899.0, + "url": "https://example.com/xbox-game-pass-3m", + "q": "subscription" + }, + { + "title": "JBL Flip 6 Bluetooth Hoparlör", + "price": 3499.0, + "originalPrice": 4299.0, + "shippingPrice": 0, + "url": "https://example.com/jbl-flip-6", + "q": "bluetooth speaker" + }, + { + "title": "ASUS TUF Gaming RTX 4060 8GB", + "price": 16999.0, + "originalPrice": 19999.0, + "shippingPrice": 0, + "url": "https://example.com/rtx-4060-asus-tuf", + "q": "graphics card" + }, + { + "title": "Corsair Vengeance 32GB (2x16) DDR5 6000", + "price": 3999.0, + "originalPrice": 4999.0, + "url": "https://example.com/corsair-ddr5-32gb-6000", + "q": "ram memory" + }, + { + "title": "Samsung 55\" 4K Smart TV (Crystal UHD)", + "price": 18999.0, + "originalPrice": 22999.0, + "shippingPrice": 0, + "url": "https://example.com/samsung-55-4k-tv", + "q": "television" + }, + { + "title": "LG 27\" 4K IPS Monitör", + "price": 10999.0, + "originalPrice": 12999.0, + "shippingPrice": 0, + "url": "https://example.com/lg-27-4k-ips", + "q": "4k monitor" + }, + { + "title": "Roborock S8 Robot Süpürge", + "price": 24999.0, + "originalPrice": 29999.0, + "shippingPrice": 0, + "url": "https://example.com/roborock-s8", + "q": "robot vacuum" + }, + { + "title": "Tefal Ultragliss Buharlı Ütü", + "price": 1799.0, + "originalPrice": 2199.0, + "shippingPrice": 29.9, + "url": "https://example.com/tefal-ultragliss-iron", + "q": "steam iron" + }, + { + "title": "Brita Marella XL Su Arıtma Sürahisi", + "price": 899.0, + "originalPrice": 1099.0, + "shippingPrice": 19.9, + "url": "https://example.com/brita-marella-xl", + "q": "water filter" + }, + { + "title": "IKEA Markus Ofis Koltuğu", + "price": 4999.0, + "shippingPrice": 59.9, + "url": "https://example.com/ikea-markus", + "q": "office chair" + }, + { + "title": "Xiaomi Redmi Note 13 256GB", + "price": 9999.0, + "originalPrice": 11999.0, + "shippingPrice": 0, + "url": "https://example.com/redmi-note-13-256", + "q": "smartphone" + }, + { + "title": "Garmin Forerunner 255", + "price": 8999.0, + "originalPrice": 10499.0, + "shippingPrice": 0, + "url": "https://example.com/garmin-fr-255", + "q": "sports watch" + }, + { + "title": "Philips Hue Starter Kit (3 Ampul + Bridge)", + "price": 4499.0, + "originalPrice": 5499.0, + "shippingPrice": 0, + "url": "https://example.com/philips-hue-starter", + "q": "smart lights" + } +] diff --git a/prisma/migrations/20260126004412_add_description_category/migration.sql b/prisma/migrations/20260126004412_add_description_category/migration.sql new file mode 100644 index 0000000..2577863 --- /dev/null +++ b/prisma/migrations/20260126004412_add_description_category/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Category" ADD COLUMN "description" TEXT NOT NULL DEFAULT ''; diff --git a/prisma/migrations/20260126010310_add_shippingprice_and_originalprice_and_percent_off_to_deal/migration.sql b/prisma/migrations/20260126010310_add_shippingprice_and_originalprice_and_percent_off_to_deal/migration.sql new file mode 100644 index 0000000..3ee9b89 --- /dev/null +++ b/prisma/migrations/20260126010310_add_shippingprice_and_originalprice_and_percent_off_to_deal/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Deal" ADD COLUMN "originalPrice" DOUBLE PRECISION, +ADD COLUMN "percentOff" DOUBLE PRECISION, +ADD COLUMN "shippingPrice" DOUBLE PRECISION; diff --git a/prisma/migrations/20260126015722_add_seller_logo_to_seller/migration.sql b/prisma/migrations/20260126015722_add_seller_logo_to_seller/migration.sql new file mode 100644 index 0000000..45fc0e0 --- /dev/null +++ b/prisma/migrations/20260126015722_add_seller_logo_to_seller/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Seller" ADD COLUMN "sellerLogo" TEXT NOT NULL DEFAULT ''; diff --git a/prisma/migrations/20260127182025_add_comment_like_and_loading/migration.sql b/prisma/migrations/20260127182025_add_comment_like_and_loading/migration.sql new file mode 100644 index 0000000..45ae7cc --- /dev/null +++ b/prisma/migrations/20260127182025_add_comment_like_and_loading/migration.sql @@ -0,0 +1,27 @@ +-- AlterTable +ALTER TABLE "Comment" ADD COLUMN "likeCount" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "CommentLike" ( + "id" SERIAL NOT NULL, + "commentId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CommentLike_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "CommentLike_commentId_idx" ON "CommentLike"("commentId"); + +-- CreateIndex +CREATE INDEX "CommentLike_userId_idx" ON "CommentLike"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "CommentLike_commentId_userId_key" ON "CommentLike"("commentId", "userId"); + +-- AddForeignKey +ALTER TABLE "CommentLike" ADD CONSTRAINT "CommentLike_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommentLike" ADD CONSTRAINT "CommentLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2a87087..4a3c7a7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -36,6 +36,7 @@ model User { dealNotices DealNotice[] @relation("UserDealNotices") refreshTokens RefreshToken[] // <-- bunu ekle + commentLikes CommentLike[] } model RefreshToken { @@ -86,7 +87,6 @@ model SellerDomain { 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]) @@ -96,6 +96,7 @@ model Seller { id Int @id @default(autoincrement()) name String @unique url String @default("") + sellerLogo String @default("") isActive Boolean @default(true) createdAt DateTime @default(now()) createdById Int @@ -113,7 +114,7 @@ model Category { id Int @id @default(autoincrement()) name String slug String @unique - + description String @default("") parentId Int? parent Category? @relation("CategoryParent", fields: [parentId], references: [id]) children Category[] @relation("CategoryParent") @@ -157,14 +158,16 @@ model Deal { description String? url String? price Float? - + originalPrice Float? + shippingPrice Float? + percentOff Float? 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? @@ -267,6 +270,7 @@ model Comment { dealId Int parentId Int? + likeCount Int @default(0) deletedAt DateTime? @@ -275,6 +279,7 @@ model Comment { parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull) replies Comment[] @relation("CommentReplies") + likes CommentLike[] @@index([dealId, createdAt]) @@index([parentId, createdAt]) @@ -282,6 +287,20 @@ model Comment { @@index([deletedAt]) } +model CommentLike { + id Int @id @default(autoincrement()) + commentId Int + userId Int + createdAt DateTime @default(now()) + + comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([commentId, userId]) + @@index([commentId]) + @@index([userId]) +} + enum DealAiIssueType { NONE PROFANITY @@ -308,4 +327,4 @@ model DealAiReview { updatedAt DateTime @updatedAt @@index([needsReview, hasIssue, updatedAt]) -} \ No newline at end of file +} diff --git a/prisma/seed.js b/prisma/seed.js index 9e40d25..dc32d1b 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -1,6 +1,5 @@ // prisma/seed.js const { PrismaClient, DealStatus, SaleType, AffiliateType } = require("@prisma/client") -const bcrypt = require("bcryptjs") const fs = require("fs") const path = require("path") @@ -27,6 +26,12 @@ function normalizeSlug(s) { return String(s ?? "").trim().toLowerCase() } +function toNumberOrNull(v) { + if (v === null || v === undefined || v === "") return null + const n = Number(v) + return Number.isFinite(n) ? n : null +} + async function upsertTagBySlug(slug, name) { const s = normalizeSlug(slug) return prisma.tag.upsert({ @@ -66,6 +71,7 @@ function loadCategoriesJson(filePath) { id: Number(c.id), name: String(c.name ?? "").trim(), slug: normalizeSlug(c.slug), + description: c.description, parentId: c.parentId === null || c.parentId === undefined ? null : Number(c.parentId), })) @@ -102,11 +108,13 @@ async function seedCategoriesFromJson(categoriesFilePath) { update: { name: c.name, slug: c.slug, + description: c.description, }, create: { id: c.id, name: c.name, slug: c.slug, + description: c.description, parentId: null, }, }) @@ -135,56 +143,110 @@ async function seedCategoriesFromJson(categoriesFilePath) { 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" }, - ] +function loadDealsJson(filePath) { + const raw = fs.readFileSync(filePath, "utf-8") + const arr = JSON.parse(raw) - // 30'a tamamlamak için ikinci bir set üret (title/url benzersiz olsun) + if (!Array.isArray(arr)) throw new Error("deals.json array olmalı") + + const items = arr.map((d, idx) => { + const title = String(d.title ?? "").trim() + const url = String(d.url ?? "").trim() + const q = String(d.q ?? "").trim() + + const price = toNumberOrNull(d.price) + const originalPrice = toNumberOrNull(d.originalPrice) + const shippingPrice = toNumberOrNull(d.shippingPrice) + + if (!title) throw new Error(`deals.json: title boş (index=${idx})`) + if (!url) throw new Error(`deals.json: url boş (index=${idx})`) + if (price === null) throw new Error(`deals.json: price invalid (index=${idx})`) + + // Mantık: originalPrice varsa price'dan küçük olamaz + if (originalPrice !== null && originalPrice < price) { + throw new Error(`deals.json: originalPrice < price (index=${idx})`) + } + + return { + title, + url, + q, + price, + originalPrice, + shippingPrice, + } + }) + + // url unique olsun (seed idempotent) + const urlSet = new Set() + for (const it of items) { + if (urlSet.has(it.url)) throw new Error(`deals.json duplicate url: ${it.url}`) + urlSet.add(it.url) + } + + return items +} + +// deals.json’dan seed + her deal’a 3 foto + score 0-200 + tarih dağılımı: +// - %70: son 5 gün +// - %30: 9-11 gün önce +async function seedDealsFromJson({ userId, sellerId, categoryId, dealsFilePath }) { + const baseItems = loadDealsJson(dealsFilePath) + + // 30 adet olacak şekilde çoğalt (title/url benzersizleşsin) const items = [] for (let i = 0; i < 30; i++) { const base = baseItems[i % baseItems.length] const n = i + 1 + + // price'ı hafif oynat (base price üzerinden) + const price = Number((base.price * (0.9 + randInt(0, 30) / 100)).toFixed(2)) + + // originalPrice varsa, yeni price'a göre ölçekle (mantık korunur) + let originalPrice = null + if (base.originalPrice !== null && base.originalPrice !== undefined) { + const ratio = base.originalPrice / base.price // >= 1 olmalı + originalPrice = Number((price * ratio).toFixed(2)) + if (originalPrice < price) originalPrice = Number((price * 1.05).toFixed(2)) + } + + // shippingPrice varsa bazen aynen, bazen 0/ufak varyasyon (ama null değilse) + let shippingPrice = null + if (base.shippingPrice !== null && base.shippingPrice !== undefined) { + // 70% aynı, 30% küçük oynat + if (Math.random() < 0.7) { + shippingPrice = Number(base.shippingPrice) + } else { + const candidates = [0, 19.9, 29.9, 39.9, 49.9, 59.9] + shippingPrice = candidates[randInt(0, candidates.length - 1)] + } + } + items.push({ title: `${base.title} #${n}`, - price: Number((base.price * (0.9 + (randInt(0, 30) / 100))).toFixed(2)), - url: `${base.url}?seed=${n}`, - q: base.q, + price, + originalPrice, + shippingPrice, + url: `${base.url}${base.url.includes("?") ? "&" : "?"}seed=${n}`, + q: base.q || "product", }) } for (let i = 0; i < items.length; i++) { const it = items[i] - // %30'u 9-11 gün önce, %70'i son 5 gün const older = Math.random() < 0.3 const createdAt = older ? new Date(Date.now() - randInt(9, 11) * 24 * 60 * 60 * 1000 - randInt(0, 12) * 60 * 60 * 1000) : randomDateWithinLastDays(5) - // Not: modelinde score yoksa score satırını sil const dealData = { title: it.title, description: "Seed test deal açıklaması (otomatik üretim).", url: it.url, price: it.price, + originalPrice: it.originalPrice ?? null, + shippingPrice: it.shippingPrice ?? null, status: DealStatus.ACTIVE, saletype: SaleType.ONLINE, affiliateType: AffiliateType.NON_AFFILIATE, @@ -249,6 +311,7 @@ async function main() { create: { name: "Amazon", url: "https://www.amazon.com.tr", + sellerLogo:"https://1000logos.net/wp-content/uploads/2016/10/Amazon-logo-meaning.jpg", isActive: true, createdById: admin.id, }, @@ -269,7 +332,7 @@ async function main() { } // ---------- CATEGORIES (FROM JSON) ---------- - const categoriesFilePath = path.join(__dirname, "", "categories.json") + const categoriesFilePath = path.join(__dirname, "categories.json") const { count } = await seedCategoriesFromJson(categoriesFilePath) const catSSD = await prisma.category.findUnique({ @@ -294,6 +357,8 @@ async function main() { description: "Test deal açıklaması", url: dealUrl, price: 1299.99, + originalPrice: 1499.99, // örnek + shippingPrice: 0, // örnek status: DealStatus.ACTIVE, saletype: SaleType.ONLINE, affiliateType: AffiliateType.NON_AFFILIATE, @@ -321,11 +386,13 @@ async function main() { ], }) - // ✅ ---------- 30 DEAL ÜRET ---------- - await seedDeals30({ + // ✅ ---------- deals.json’dan 30 DEAL ÜRET ---------- + const dealsFilePath = path.join(__dirname, "deals.json") + await seedDealsFromJson({ userId: user.id, sellerId: amazon.id, categoryId: catSSD?.id ?? 0, + dealsFilePath, }) // ---------- VOTE ---------- @@ -347,7 +414,7 @@ async function main() { } console.log(`✅ Seed tamamlandı (categories.json yüklendi: ${count} kategori)`) - console.log("✅ 30 adet test deal + 3'er görsel + score(0-200) + tarih dağılımı eklendi/güncellendi") + console.log("✅ deals.json baz alınarak 30 adet test deal + 3'er görsel + score(0-200) + tarih dağılımı eklendi/güncellendi") } main() diff --git a/routes/category.routes.js b/routes/category.routes.js index aecaeef..56b6e15 100644 --- a/routes/category.routes.js +++ b/routes/category.routes.js @@ -1,6 +1,7 @@ const express = require("express"); const categoryService = require("../services/category.service"); // Kategori servisi const router = express.Router(); +const optionalAuth = require("../middleware/optionalAuth") const { mapCategoryToCategoryDetailsResponse }=require("../adapters/responses/categoryDetails.adapter") const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter") const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter") @@ -22,7 +23,10 @@ router.get("/:slug", async (req, res) => { }); -router.get("/:slug/deals", async (req, res) => { +const buildViewer = (req) => + req.auth ? { userId: req.auth.userId, role: req.auth.role } : null + +router.get("/:slug/deals", optionalAuth, async (req, res) => { const { slug } = req.params; const { page = 1, limit = 10, ...filters } = req.query; @@ -33,7 +37,12 @@ router.get("/:slug/deals", async (req, res) => { } // Kategorinin fırsatlarını alıyoruz - const deals = await categoryService.getDealsByCategoryId(category.id, page, limit, filters); + const payload = await categoryService.getDealsByCategoryId(category.id, { + page, + limit, + filters, + viewer: buildViewer(req), + }); const response = mapPaginatedDealsToDealCardResponse(payload) diff --git a/routes/comment.routes.js b/routes/comment.routes.js index 45cde2f..dc23f51 100644 --- a/routes/comment.routes.js +++ b/routes/comment.routes.js @@ -1,5 +1,6 @@ const express = require("express") const requireAuth = require("../middleware/requireAuth.js") +const optionalAuth = require("../middleware/optionalAuth") const { validate } = require("../middleware/validate.middleware") const { endpoints } = require("@shared/contracts") const { createComment, deleteComment } = require("../services/comment.service") @@ -12,13 +13,28 @@ const { comments } = endpoints router.get( "/:dealId", + optionalAuth, 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)) + const { parentId, page, limit, sort } = req.query + const payload = await commentService.getCommentsByDealId(dealId, { + parentId, + page, + limit, + sort, + viewer: req.auth ? { userId: req.auth.userId } : null, + }) + const mapped = dealCommentAdapter.mapCommentsToDealCommentResponse(payload.results) + res.json( + comments.commentListResponseSchema.parse({ + page: payload.page, + total: payload.total, + totalPages: payload.totalPages, + results: mapped, + }) + ) } catch (err) { res.status(400).json({ error: err.message }) } diff --git a/routes/commentLike.routes.js b/routes/commentLike.routes.js new file mode 100644 index 0000000..47c1789 --- /dev/null +++ b/routes/commentLike.routes.js @@ -0,0 +1,23 @@ +const express = require("express") +const requireAuth = require("../middleware/requireAuth") +const { setCommentLike } = require("../services/commentLike.service") + +const router = express.Router() + +// Body: { commentId: number, like: boolean | 0 | 1 } +router.post("/", requireAuth, async (req, res) => { + try { + const { commentId, like } = req.body || {} + const result = await setCommentLike({ commentId, userId: req.auth.userId, like }) + res.json({ + commentId: Number(commentId), + likeCount: result.likeCount, + liked: result.liked, + delta: result.delta, + }) + } catch (err) { + res.status(400).json({ error: err.message || "Like iÅŸlemi baÅŸarısız" }) + } +}) + +module.exports = router diff --git a/routes/deal.routes.js b/routes/deal.routes.js index 6856924..d4ed970 100644 --- a/routes/deal.routes.js +++ b/routes/deal.routes.js @@ -34,6 +34,7 @@ function createListHandler(preset) { page, limit, viewer, + filters: req.query, }) const response = deals.dealsListResponseSchema.parse( @@ -76,6 +77,7 @@ router.get( limit, targetUserId: targetUser.id, viewer, + filters: req.query, }) const response = deals.dealsListResponseSchema.parse( @@ -120,6 +122,7 @@ router.get( page, limit, viewer: buildViewer(req), + filters: req.query, }) const response = deals.dealsListResponseSchema.parse( @@ -153,6 +156,7 @@ router.get("/top", optionalAuth, async (req, res) => { page: 1, limit, viewer, + filters: req.query, }) const response = deals.dealsListResponseSchema.parse( @@ -173,12 +177,13 @@ router.get("/top", optionalAuth, async (req, res) => { router.get( "/:id", + optionalAuth, validate(deals.dealDetailRequestSchema, "params", "validatedDealId"), async (req, res) => { try { const { id } = req.validatedDealId - const deal = await getDealById(id) + const deal = await getDealById(id, buildViewer(req)) if (!deal) return res.status(404).json({ error: "Deal bulunamadi" }) const mapped = mapDealToDealDetailResponse(deal) diff --git a/routes/mod.routes.js b/routes/mod.routes.js new file mode 100644 index 0000000..72b79a6 --- /dev/null +++ b/routes/mod.routes.js @@ -0,0 +1,104 @@ +const express = require("express") +const router = express.Router() + +const requireAuth = require("../middleware/requireAuth") +const requireRole = require("../middleware/requireRole") +const { validate } = require("../middleware/validate.middleware") +const { endpoints } = require("@shared/contracts") +const { getPendingDeals, approveDeal, rejectDeal, expireDeal, unexpireDeal } = require("../services/mod.service") +const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter") + +const { deals } = endpoints + +const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery") + +const buildViewer = (req) => + req.auth ? { userId: req.auth.userId, role: req.auth.role } : null + +router.get("/deals/pending", requireAuth, requireRole("MOD"), listQueryValidator, async (req, res) => { + try { + const { q, page, limit } = req.validatedDealListQuery + const payload = await getPendingDeals({ + page, + limit, + filters: { ...req.query, q }, + viewer: buildViewer(req), + }) + + const response = mapPaginatedDealsToDealCardResponse(payload) + res.json(response.results) + } catch (err) { + const status = err.statusCode || 500 + res.status(status).json({ error: err.message || "Sunucu hatasi" }) + } +}) + +router.post( + "/deals/:id/approve", + requireAuth, + requireRole("MOD"), + validate(deals.dealDetailRequestSchema, "params", "validatedDealId"), + async (req, res) => { + try { + const { id } = req.validatedDealId + const updated = await approveDeal(id) + res.json({ id: updated.id, status: updated.status }) + } catch (err) { + const status = err.statusCode || 500 + res.status(status).json({ error: err.message || "Sunucu hatasi" }) + } + } +) + +router.post( + "/deals/:id/reject", + requireAuth, + requireRole("MOD"), + validate(deals.dealDetailRequestSchema, "params", "validatedDealId"), + async (req, res) => { + try { + const { id } = req.validatedDealId + const updated = await rejectDeal(id) + res.json({ id: updated.id, status: updated.status }) + } catch (err) { + const status = err.statusCode || 500 + res.status(status).json({ error: err.message || "Sunucu hatasi" }) + } + } +) + +router.post( + "/deals/:id/expire", + requireAuth, + requireRole("MOD"), + validate(deals.dealDetailRequestSchema, "params", "validatedDealId"), + async (req, res) => { + try { + const { id } = req.validatedDealId + const updated = await expireDeal(id) + res.json({ id: updated.id, status: updated.status }) + } catch (err) { + const status = err.statusCode || 500 + res.status(status).json({ error: err.message || "Sunucu hatasi" }) + } + } +) + +router.post( + "/deals/:id/unexpire", + requireAuth, + requireRole("MOD"), + validate(deals.dealDetailRequestSchema, "params", "validatedDealId"), + async (req, res) => { + try { + const { id } = req.validatedDealId + const updated = await unexpireDeal(id) + res.json({ id: updated.id, status: updated.status }) + } catch (err) { + const status = err.statusCode || 500 + res.status(status).json({ error: err.message || "Sunucu hatasi" }) + } + } +) + +module.exports = router diff --git a/routes/seller.routes.js b/routes/seller.routes.js index 856ef81..8da137b 100644 --- a/routes/seller.routes.js +++ b/routes/seller.routes.js @@ -2,10 +2,20 @@ const express = require("express") const router = express.Router() const requireAuth = require("../middleware/requireAuth") +const optionalAuth = require("../middleware/optionalAuth") +const { validate } = require("../middleware/validate.middleware") const { endpoints } = require("@shared/contracts") -const { findSellerFromLink } = require("../services/seller.service") +const { getSellerByName, getDealsBySellerName } = require("../services/seller.service") +const { findSellerFromLink } = require("../services/sellerLookup.service") +const { mapSellerToSellerDetailsResponse } = require("../adapters/responses/sellerDetails.adapter") +const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter") -const { seller } = endpoints +const { seller, deals } = endpoints + +const buildViewer = (req) => + req.auth ? { userId: req.auth.userId, role: req.auth.role } : null + +const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery") router.post("/from-link", requireAuth, async (req, res) => { try { @@ -32,4 +42,37 @@ router.post("/from-link", requireAuth, async (req, res) => { } }) +router.get("/:sellerName", async (req, res) => { + try { + const sellerName = req.params.sellerName + const sellerInfo = await getSellerByName(sellerName) + if (!sellerInfo) return res.status(404).json({ error: "Seller bulunamadi" }) + + res.json(mapSellerToSellerDetailsResponse(sellerInfo)) + } catch (e) { + const status = e.statusCode || 500 + res.status(status).json({ error: e.message || "Sunucu hatasi" }) + } +}) + +router.get("/:sellerName/deals", optionalAuth, listQueryValidator, async (req, res) => { + try { + const sellerName = req.params.sellerName + const { q, page, limit } = req.validatedDealListQuery + + const { payload } = await getDealsBySellerName(sellerName, { + page, + limit, + filters: req.query, + viewer: buildViewer(req), + }) + + const response = mapPaginatedDealsToDealCardResponse(payload) + res.json(response.results) + } catch (e) { + const status = e.statusCode || 500 + res.status(status).json({ error: e.message || "Sunucu hatasi" }) + } +}) + module.exports = router diff --git a/server.js b/server.js index 23a74c3..d3380ad 100644 --- a/server.js +++ b/server.js @@ -13,15 +13,27 @@ const accountSettingsRoutes = require("./routes/accountSettings.routes"); const userRoutes = require("./routes/user.routes"); const sellerRoutes = require("./routes/seller.routes"); const voteRoutes = require("./routes/vote.routes"); +const commentLikeRoutes = require("./routes/commentLike.routes"); const categoryRoutes =require("./routes/category.routes") +const modRoutes = require("./routes/mod.routes") const app = express(); // 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 -})); +const allowedOrigins = new Set([ + "http://192.168.1.205:3001", + "http://localhost:3001", +]); +app.use( + cors({ + origin(origin, cb) { + if (!origin) return cb(null, true); + if (allowedOrigins.has(origin)) return cb(null, true); + return cb(new Error("CORS_NOT_ALLOWED")); + }, + credentials: true, // Cookies'in paylaşıma izin verilmesi + }) +); // JSON, URL encoded ve cookies'leri parse etme app.use(express.json()); // JSON verisi almak için app.use(express.urlencoded({ extended: true })); // URL encoded veriler için @@ -37,6 +49,8 @@ app.use("/api/account", accountSettingsRoutes); // Account settings işlemleri app.use("/api/user", userRoutes); // Kullanıcı işlemleri app.use("/api/seller", sellerRoutes); // Seller işlemleri app.use("/api/vote", voteRoutes); // Vote işlemleri +app.use("/api/comment-likes", commentLikeRoutes); // Comment like işlemleri app.use("/api/category", categoryRoutes); +app.use("/api/mod", modRoutes); // Sunucuyu dinlemeye başla app.listen(3000, () => console.log("Server running on http://localhost:3000")); diff --git a/services/category.service.js b/services/category.service.js index cdb87c1..345372c 100644 --- a/services/category.service.js +++ b/services/category.service.js @@ -1,69 +1,42 @@ -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 - */ +const categoryDb = require("../db/category.db") +const dealService = require("./deal.service") + 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}`); + const normalizedSlug = String(slug || "").trim() + if (!normalizedSlug) { + throw new Error("INVALID_SLUG") } + + const category = await categoryDb.findCategoryBySlug(normalizedSlug) + if (!category) { + throw new Error("CATEGORY_NOT_FOUND") + } + + const breadcrumb = await categoryDb.getCategoryBreadcrumb(category.id) + return { category, breadcrumb } } -async function getDealsByCategoryId(categoryId, page = 1, limit = 10, filters = {}) { - 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}`); +async function getDealsByCategoryId(categoryId, { page = 1, limit = 10, filters = {}, viewer = null, scope = "USER" } = {}) { + const normalizedId = Number(categoryId) + if (!Number.isInteger(normalizedId) || normalizedId <= 0) { + throw new Error("INVALID_CATEGORY_ID") } + + const categoryIds = await categoryDb.getCategoryDescendantIds(normalizedId) + + return dealService.getDeals({ + preset: "NEW", + q: filters?.q, + page, + limit, + viewer, + scope, + baseWhere: { categoryId: { in: categoryIds } }, + filters, + }) } - - - module.exports = { findCategoryBySlug, getDealsByCategoryId, -}; +} diff --git a/services/comment.service.js b/services/comment.service.js index 2db84ae..45a5e99 100644 --- a/services/comment.service.js +++ b/services/comment.service.js @@ -8,14 +8,87 @@ function assertPositiveInt(v, name = "id") { return n } -async function getCommentsByDealId(dealId) { +const DEFAULT_LIMIT = 20 +const MAX_LIMIT = 50 +const MAX_SKIP = 5000 + +function clampPagination({ page, limit }) { + const rawPage = Number(page) + const rawLimit = Number(limit) + const normalizedPage = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1 + let normalizedLimit = Number.isFinite(rawLimit) ? Math.max(1, Math.floor(rawLimit)) : DEFAULT_LIMIT + normalizedLimit = Math.min(MAX_LIMIT, normalizedLimit) + const skip = (normalizedPage - 1) * normalizedLimit + if (skip > MAX_SKIP) throw new Error("PAGE_TOO_DEEP") + return { page: normalizedPage, limit: normalizedLimit, skip } +} + +function parseParentId(value) { + if (value === undefined) return null + if (value === null) return null + if (value === "" || value === "null") return null + const pid = Number(value) + if (!Number.isInteger(pid) || pid <= 0) throw new Error("Geçersiz parentId.") + return pid +} + +function normalizeSort(value) { + const normalized = String(value || "new").trim().toLowerCase() + if (["top", "best", "liked"].includes(normalized)) return "TOP" + return "NEW" +} + + +async function getCommentsByDealId(dealId, { parentId, page, limit, sort, viewer } = {}) { const id = Number(dealId) const deal = await dealDB.findDeal({ id }) if (!deal) throw new Error("Deal bulunamadı.") - const include = { user: { select: { id:true,username: true, avatarUrl: true } } } - return commentDB.findComments({ dealId: id }, { include }) + const include = { + user: { select: { id: true, username: true, avatarUrl: true } }, + _count: { select: { replies: true } }, + } + const pagination = clampPagination({ page, limit }) + const parsedParentId = parseParentId(parentId) + const sortMode = normalizeSort(sort) + const orderBy = + sortMode === "TOP" + ? [{ likeCount: "desc" }, { createdAt: "desc" }] + : [{ createdAt: "desc" }] + + const where = { dealId: id, parentId: parsedParentId } + const [results, total] = await Promise.all([ + commentDB.findComments(where, { + include, + orderBy, + skip: pagination.skip, + take: pagination.limit, + }), + commentDB.countComments(where), + ]) + + let likedIds = new Set() + if (viewer?.userId && results.length > 0) { + const commentLikeDb = require("../db/commentLike.db") + const likes = await commentLikeDb.findLikesByUserAndCommentIds( + viewer.userId, + results.map((c) => c.id) + ) + likedIds = new Set(likes.map((l) => l.commentId)) + } + + const enriched = results.map((comment) => ({ + ...comment, + myLike: likedIds.has(comment.id), + })) + + return { + page: pagination.page, + total, + totalPages: Math.ceil(total / pagination.limit), + results: enriched, + } } async function createComment({ dealId, userId, text, parentId = null }) { @@ -27,8 +100,11 @@ async function createComment({ dealId, userId, text, parentId = null }) { const include = { user: { select: { id: true, username: true, avatarUrl: true } } } return prisma.$transaction(async (tx) => { - const deal = await dealDB.findDeal({ id: dealId }, {}, tx) + const deal = await dealDB.findDeal({ id: dealId }, { select: { id: true, status: true } }, tx) if (!deal) throw new Error("Deal bulunamadı.") + if (deal.status !== "ACTIVE" && deal.status !== "EXPIRED") { + throw new Error("Bu deal için yorum açılamaz.") + } // ✅ Reply ise parent doğrula let parent = null @@ -66,15 +142,26 @@ async function createComment({ dealId, userId, text, parentId = null }) { async function deleteComment(commentId, userId) { - const comments = await commentDB.findComments( + const comment = await commentDB.findComment( { id: commentId }, - { select: { userId: true } } + { select: { userId: true, dealId: true, deletedAt: true } } ) - if (!comments || comments.length === 0) throw new Error("Yorum bulunamadı.") - if (comments[0].userId !== userId) throw new Error("Bu yorumu silme yetkin yok.") + if (!comment || comment.deletedAt) throw new Error("Yorum bulunamadı.") + if (comment.userId !== userId) throw new Error("Bu yorumu silme yetkin yok.") + + await prisma.$transaction(async (tx) => { + const result = await commentDB.softDeleteComment({ id: commentId, deletedAt: null }, tx) + if (result.count > 0) { + await dealDB.updateDeal( + { id: comment.dealId }, + { commentCount: { decrement: 1 } }, + {}, + tx + ) + } + }) - await commentDB.deleteComment({ id: commentId }) return { message: "Yorum silindi." } } diff --git a/services/commentLike.service.js b/services/commentLike.service.js new file mode 100644 index 0000000..00bc2fd --- /dev/null +++ b/services/commentLike.service.js @@ -0,0 +1,30 @@ +const commentLikeDb = require("../db/commentLike.db") +const commentDb = require("../db/comment.db") + +function parseLike(value) { + if (typeof value === "boolean") return value + if (typeof value === "number") return value === 1 + if (typeof value === "string") { + const normalized = value.trim().toLowerCase() + if (["true", "1", "yes"].includes(normalized)) return true + if (["false", "0", "no"].includes(normalized)) return false + } + return null +} + +async function setCommentLike({ commentId, userId, like }) { + const cid = Number(commentId) + if (!Number.isInteger(cid) || cid <= 0) throw new Error("Geçersiz commentId.") + const shouldLike = parseLike(like) + if (shouldLike === null) throw new Error("Geçersiz like.") + + // Ensure comment exists (and not deleted) + const existing = await commentDb.findComment({ id: cid }, { select: { id: true } }) + if (!existing) throw new Error("Yorum bulunamadı.") + + return commentLikeDb.setCommentLike({ commentId: cid, userId, like: shouldLike }) +} + +module.exports = { + setCommentLike, +} diff --git a/services/deal.service.js b/services/deal.service.js index 2c2edbf..cc35394 100644 --- a/services/deal.service.js +++ b/services/deal.service.js @@ -1,6 +1,6 @@ // services/deal.service.js const dealDB = require("../db/deal.db") -const { findSellerFromLink } = require("./seller.service") +const { findSellerFromLink } = require("./sellerLookup.service") const { makeDetailWebp, makeThumbWebp } = require("../utils/processImage") const { v4: uuidv4 } = require("uuid") const { uploadImage } = require("./uploadImage.service") @@ -13,7 +13,22 @@ const MAX_LIMIT = 50 const MAX_SKIP = 5000 const MS_PER_DAY = 24 * 60 * 60 * 1000 -const DEAL_LIST_INCLUDE = { +const DEAL_CARD_SELECT = { + id: true, + title: true, + description: true, + price: true, + originalPrice: true, + shippingPrice: true, + score: true, + commentCount: true, + url: true, + status: true, + saletype: true, + affiliateType: true, + createdAt: true, + updatedAt: true, + customSeller: true, user: { select: { id: true, username: true, avatarUrl: true } }, seller: { select: { id: true, name: true, url: true } }, images: { @@ -23,6 +38,59 @@ const DEAL_LIST_INCLUDE = { }, } +const DEAL_DETAIL_SELECT = { + id: true, + title: true, + description: true, + url: true, + price: true, + originalPrice: true, + shippingPrice: true, + score: true, + commentCount: true, + status: true, + saletype: true, + affiliateType: true, + createdAt: true, + updatedAt: true, + categoryId: true, + sellerId: true, + customSeller: true, + user: { select: { id: true, username: true, avatarUrl: true } }, + seller: { select: { id: true, name: true, url: true } }, + images: { orderBy: { order: "asc" }, select: { id: true, imageUrl: true, order: true } }, + notices: { + where: { isActive: true }, + orderBy: { createdAt: "desc" }, + take: 1, + select: { + id: true, + dealId: true, + title: true, + body: true, + severity: true, + isActive: true, + createdBy: true, + createdAt: true, + updatedAt: true, + }, + }, + _count: { select: { comments: true } }, +} + +const SIMILAR_DEAL_SELECT = { + id: true, + title: true, + price: true, + score: true, + createdAt: true, + categoryId: true, + sellerId: true, + customSeller: true, + seller: { select: { name: true } }, + images: { take: 1, orderBy: { order: "asc" }, select: { imageUrl: true } }, +} + function formatDateAsString(value) { return value instanceof Date ? value.toISOString() : value ?? null } @@ -54,6 +122,132 @@ function buildSearchClause(q) { } } +const DEAL_STATUSES = new Set(["PENDING", "ACTIVE", "EXPIRED", "REJECTED"]) +const SALE_TYPES = new Set(["ONLINE", "OFFLINE", "CODE"]) +const AFFILIATE_TYPES = new Set(["AFFILIATE", "NON_AFFILIATE", "USER_AFFILIATE"]) + +function normalizeListInput(value) { + if (Array.isArray(value)) { + return value.flatMap((item) => String(item).split(",")) + } + if (value === undefined || value === null) return [] + return String(value).split(",") +} + +function parseInteger(value) { + if (value === undefined || value === null || value === "") return null + const num = Number(value) + return Number.isInteger(num) ? num : null +} + +function parseNumber(value) { + if (value === undefined || value === null || value === "") return null + const num = Number(value) + return Number.isFinite(num) ? num : null +} + +function parseBoolean(value) { + if (typeof value === "boolean") return value + if (typeof value === "number") return value === 1 ? true : value === 0 ? false : null + if (typeof value === "string") { + const normalized = value.trim().toLowerCase() + if (["true", "1", "yes"].includes(normalized)) return true + if (["false", "0", "no"].includes(normalized)) return false + } + return null +} + +function parseDate(value) { + if (value === undefined || value === null || value === "") return null + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +function parseIdList(value) { + const items = normalizeListInput(value) + const ids = items + .map((item) => parseInteger(String(item).trim())) + .filter((item) => item && item > 0) + return ids.length ? Array.from(new Set(ids)) : null +} + +function parseEnumList(value, allowedSet) { + const items = normalizeListInput(value) + const filtered = items + .map((item) => String(item).trim().toUpperCase()) + .filter((item) => allowedSet.has(item)) + return filtered.length ? Array.from(new Set(filtered)) : null +} + +function buildFilterWhere(rawFilters = {}, { allowStatus = false } = {}) { + if (!rawFilters || typeof rawFilters !== "object") return null + + const clauses = [] + const categoryIds = parseIdList(rawFilters.categoryId ?? rawFilters.categoryIds) + if (categoryIds?.length) { + clauses.push({ categoryId: { in: categoryIds } }) + } + + const sellerIds = parseIdList(rawFilters.sellerId ?? rawFilters.sellerIds) + if (sellerIds?.length) { + clauses.push({ sellerId: { in: sellerIds } }) + } + + const saleTypes = parseEnumList(rawFilters.saleType, SALE_TYPES) + if (saleTypes?.length) { + clauses.push({ saletype: { in: saleTypes } }) + } + + const affiliateTypes = parseEnumList(rawFilters.affiliateType, AFFILIATE_TYPES) + if (affiliateTypes?.length) { + clauses.push({ affiliateType: { in: affiliateTypes } }) + } + + if (allowStatus) { + const statuses = parseEnumList(rawFilters.status, DEAL_STATUSES) + if (statuses?.length) { + clauses.push({ status: { in: statuses } }) + } + } + + const minPrice = parseNumber(rawFilters.minPrice ?? rawFilters.priceMin) + const maxPrice = parseNumber(rawFilters.maxPrice ?? rawFilters.priceMax) + if (minPrice !== null || maxPrice !== null) { + const price = {} + if (minPrice !== null) price.gte = minPrice + if (maxPrice !== null) price.lte = maxPrice + clauses.push({ price }) + } + + const minScore = parseNumber(rawFilters.minScore) + const maxScore = parseNumber(rawFilters.maxScore) + if (minScore !== null || maxScore !== null) { + const score = {} + if (minScore !== null) score.gte = minScore + if (maxScore !== null) score.lte = maxScore + clauses.push({ score }) + } + + const createdAfter = parseDate(rawFilters.createdAfter ?? rawFilters.from) + const createdBefore = parseDate(rawFilters.createdBefore ?? rawFilters.to) + if (createdAfter || createdBefore) { + const createdAt = {} + if (createdAfter) createdAt.gte = createdAfter + if (createdBefore) createdAt.lte = createdBefore + clauses.push({ createdAt }) + } + + const hasImage = parseBoolean(rawFilters.hasImage) + if (hasImage === true) { + clauses.push({ images: { some: {} } }) + } else if (hasImage === false) { + clauses.push({ images: { none: {} } }) + } + + if (!clauses.length) return null + return clauses.length === 1 ? clauses[0] : { AND: clauses } +} + function buildPresetCriteria(preset, { viewer, targetUserId } = {}) { const now = new Date() switch (preset) { @@ -110,6 +304,9 @@ function buildPresetCriteria(preset, { viewer, targetUserId } = {}) { orderBy: [{ score: "desc" }, { createdAt: "desc" }], } } + case "RAW": { + return { where: {}, orderBy: [{ createdAt: "desc" }] } + } default: { const err = new Error("INVALID_PRESET") err.statusCode = 400 @@ -155,14 +352,28 @@ function titleOverlapScore(aTitle, bTitle) { * - seller?: { name } * - images?: [{ imageUrl }] */ -async function buildSimilarDealsForDetail(targetDeal, { limit = 5 } = {}) { - const take = clamp(Number(limit) || 5, 1, 10) +async function buildSimilarDealsForDetail(targetDeal, { limit = 12 } = {}) { + const take = clamp(Number(limit) || 12, 1, 12) // 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 }), + dealDB.findSimilarCandidates( + { + id: { not: Number(targetDeal.id) }, + status: "ACTIVE", + categoryId: Number(targetDeal.categoryId), + }, + { take: 80, select: SIMILAR_DEAL_SELECT } + ), targetDeal.sellerId - ? dealDB.findSimilarCandidatesBySeller(targetDeal.sellerId, targetDeal.id, { take: 30 }) + ? dealDB.findSimilarCandidates( + { + id: { not: Number(targetDeal.id) }, + status: "ACTIVE", + sellerId: Number(targetDeal.sellerId), + }, + { take: 30, select: SIMILAR_DEAL_SELECT } + ) : Promise.resolve([]), ]) @@ -210,14 +421,31 @@ async function buildSimilarDealsForDetail(targetDeal, { limit = 5 } = {}) { })) } -async function getDeals({ preset = "NEW", q, page, limit, viewer = null, targetUserId = null }) { +async function getDeals({ + preset = "NEW", + q, + page, + limit, + viewer = null, + targetUserId = null, + filters = null, + baseWhere = null, + scope = "USER", +}) { const pagination = clampPagination({ page, limit }) - const { where: presetWhere, orderBy: presetOrder } = buildPresetCriteria(preset, { viewer, targetUserId }) + const { where: presetWhere, orderBy: presetOrder } = buildPresetCriteria(preset, { + viewer, + targetUserId, + }) const searchClause = buildSearchClause(q) + const allowStatus = preset === "MY" || scope === "MOD" + const filterWhere = buildFilterWhere(filters, { allowStatus }) const clauses = [] if (presetWhere && Object.keys(presetWhere).length > 0) clauses.push(presetWhere) + if (baseWhere && Object.keys(baseWhere).length > 0) clauses.push(baseWhere) if (searchClause) clauses.push(searchClause) + if (filterWhere) clauses.push(filterWhere) const finalWhere = clauses.length === 0 ? {} : clauses.length === 1 ? clauses[0] : { AND: clauses } const orderBy = presetOrder ?? [{ createdAt: "desc" }] @@ -227,7 +455,7 @@ async function getDeals({ preset = "NEW", q, page, limit, viewer = null, targetU skip: pagination.skip, take: pagination.limit, orderBy, - include: DEAL_LIST_INCLUDE, + select: DEAL_CARD_SELECT, }), dealDB.countDeals(finalWhere), ]) @@ -256,64 +484,41 @@ async function getDeals({ preset = "NEW", q, page, limit, viewer = null, targetU } } -async function getDealById(id) { +async function getDealById(id, viewer = null) { const deal = await dealDB.findDeal( { id: Number(id) }, { - include: { - seller: { select: { id: true, name: true, url: true } }, - user: { select: { id: true, username: true, avatarUrl: true } }, - images: { orderBy: { order: "asc" }, select: { id: true, imageUrl: true, order: true } }, - notices: { - where: { isActive: true }, - orderBy: { createdAt: "desc" }, - take: 1, - select: { - id: true, - dealId: true, - title: true, - body: true, - severity: true, - isActive: true, - createdBy: true, - createdAt: true, - updatedAt: true, - }, - }, - comments: { - orderBy: { createdAt: "desc" }, - select: { - id: true, - text: true, - createdAt: true, - user: { select: { id: true, username: true, avatarUrl: true } }, - }, - }, - _count: { select: { comments: true } }, - }, + select: DEAL_DETAIL_SELECT, } ) if (!deal) return null - const breadcrumb = await categoryDB.getCategoryBreadcrumb(deal.categoryId, { - includeUndefined: false, - }) + const [breadcrumb, similarDeals, userStatsAgg] = await Promise.all([ + categoryDB.getCategoryBreadcrumb(deal.categoryId, { includeUndefined: false }), + buildSimilarDealsForDetail( + { + id: deal.id, + title: deal.title, + categoryId: deal.categoryId, + sellerId: deal.sellerId ?? null, + }, + { limit: 12 } + ), + deal.user?.id ? dealDB.aggregateDeals({ userId: deal.user.id }) : Promise.resolve(null), + ]) - const similarDeals = await buildSimilarDealsForDetail( - { - id: deal.id, - title: deal.title, - categoryId: deal.categoryId, - sellerId: deal.sellerId ?? null, - }, - { limit: 5 } - ) + const userStats = { + totalLikes: userStatsAgg?._sum?.score ?? 0, + totalDeals: userStatsAgg?._count?._all ?? 0, + } return { ...deal, + comments: [], breadcrumb, similarDeals, + userStats, } } diff --git a/services/mod.service.js b/services/mod.service.js new file mode 100644 index 0000000..49eda53 --- /dev/null +++ b/services/mod.service.js @@ -0,0 +1,65 @@ +const dealService = require("./deal.service") +const dealDB = require("../db/deal.db") + +async function getPendingDeals({ page = 1, limit = 10, filters = {}, viewer = null } = {}) { + return dealService.getDeals({ + preset: "RAW", + q: filters?.q, + page, + limit, + viewer, + scope: "MOD", + baseWhere: { status: "PENDING" }, + filters, + }) +} + +async function updateDealStatus(dealId, nextStatus) { + const id = Number(dealId) + if (!Number.isInteger(id) || id <= 0) { + const err = new Error("INVALID_DEAL_ID") + err.statusCode = 400 + throw err + } + + const deal = await dealDB.findDeal({ id }, { select: { id: true, status: true } }) + if (!deal) { + const err = new Error("DEAL_NOT_FOUND") + err.statusCode = 404 + throw err + } + + if (deal.status === nextStatus) return { id: deal.id, status: deal.status } + + const updated = await dealDB.updateDeal( + { id }, + { status: nextStatus }, + { select: { id: true, status: true } } + ) + + return updated +} + +async function approveDeal(dealId) { + return updateDealStatus(dealId, "ACTIVE") +} + +async function rejectDeal(dealId) { + return updateDealStatus(dealId, "REJECTED") +} + +async function expireDeal(dealId) { + return updateDealStatus(dealId, "EXPIRED") +} + +async function unexpireDeal(dealId) { + return updateDealStatus(dealId, "ACTIVE") +} + +module.exports = { + getPendingDeals, + approveDeal, + rejectDeal, + expireDeal, + unexpireDeal, +} diff --git a/services/seller.service.js b/services/seller.service.js index 1019e8e..38f9360 100644 --- a/services/seller.service.js +++ b/services/seller.service.js @@ -1,38 +1,48 @@ // services/seller/sellerService.js -const { findSellerByDomain } = require("../db/seller.db") +const { findSeller } = require("../db/seller.db") +const dealService = require("./deal.service") -function normalizeDomain(hostname) { - return hostname.replace(/^www\./, "") +function normalizeSellerName(value) { + return String(value || "").trim() } -async function findSellerFromLink(url) { - let hostname - - try { - hostname = new URL(url).hostname - } catch { - return null +async function getSellerByName(name) { + const normalized = normalizeSellerName(name) + if (!normalized) { + const err = new Error("SELLER_NAME_REQUIRED") + err.statusCode = 400 + throw err } - const domain = normalizeDomain(hostname) + return findSeller( + { name: { equals: normalized, mode: "insensitive" } }, + { select: { id: true, name: true, url: true, sellerLogo: true } } + ) +} - const seller = await findSellerByDomain(domain) - if (seller) { - return seller +async function getDealsBySellerName(name, { page = 1, limit = 10, filters = {}, viewer = null, scope = "USER" } = {}) { + const seller = await getSellerByName(name) + if (!seller) { + const err = new Error("SELLER_NOT_FOUND") + err.statusCode = 404 + throw err } - const domainParts = domain.split(".") - for (let i = 1; i <= domainParts.length - 2; i += 1) { - const parentDomain = domainParts.slice(i).join(".") - const parentSeller = await findSellerByDomain(parentDomain) - if (parentSeller) { - return parentSeller - } - } + const payload = await dealService.getDeals({ + preset: "NEW", + q: filters?.q, + page, + limit, + viewer, + scope, + baseWhere: { sellerId: seller.id, status: "ACTIVE" }, + filters, + }) - return null + return { seller, payload } } module.exports = { - findSellerFromLink, + getSellerByName, + getDealsBySellerName, } diff --git a/services/sellerLookup.service.js b/services/sellerLookup.service.js new file mode 100644 index 0000000..e29aeac --- /dev/null +++ b/services/sellerLookup.service.js @@ -0,0 +1,37 @@ +const { findSellerByDomain } = require("../db/seller.db") + +function normalizeDomain(hostname) { + return hostname.replace(/^www\./, "") +} + +async function findSellerFromLink(url) { + let hostname + + try { + hostname = new URL(url).hostname + } catch { + return null + } + + const domain = normalizeDomain(hostname) + + const seller = await findSellerByDomain(domain) + if (seller) { + return seller + } + + const domainParts = domain.split(".") + for (let i = 1; i <= domainParts.length - 2; i += 1) { + const parentDomain = domainParts.slice(i).join(".") + const parentSeller = await findSellerByDomain(parentDomain) + if (parentSeller) { + return parentSeller + } + } + + return null +} + +module.exports = { + findSellerFromLink, +}