// services/deal.service.js const dealDB = require("../db/deal.db") const { findSellerFromLink } = require("./seller.service") const { makeDetailWebp, makeThumbWebp } = require("../utils/processImage") const { v4: uuidv4 } = require("uuid") const { uploadImage } = require("./uploadImage.service") const categoryDB = require("../db/category.db") const dealImageDB = require("../db/dealImage.db") const { enqueueDealClassification } = require("../jobs/dealClassification.queue") const DEFAULT_LIMIT = 20 const MAX_LIMIT = 50 const MAX_SKIP = 5000 const MS_PER_DAY = 24 * 60 * 60 * 1000 const DEAL_LIST_INCLUDE = { user: { select: { id: true, username: true, avatarUrl: true } }, seller: { select: { id: true, name: true, url: true } }, images: { orderBy: { order: "asc" }, take: 1, select: { imageUrl: true }, }, } function formatDateAsString(value) { return value instanceof Date ? value.toISOString() : value ?? null } function clampPagination({ page, limit }) { const rawPage = Number(page) const rawLimit = Number(limit) const normalizedPage = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1 let normalizedLimit = Number.isFinite(rawLimit) ? Math.max(1, Math.floor(rawLimit)) : DEFAULT_LIMIT normalizedLimit = Math.min(MAX_LIMIT, normalizedLimit) const skip = (normalizedPage - 1) * normalizedLimit if (skip > MAX_SKIP) { const err = new Error("PAGE_TOO_DEEP") err.statusCode = 400 throw err } return { page: normalizedPage, limit: normalizedLimit, skip } } function buildSearchClause(q) { if (q === undefined || q === null) return null const normalized = String(q).trim() if (!normalized) return null return { OR: [ { title: { contains: normalized, mode: "insensitive" } }, { description: { contains: normalized, mode: "insensitive" } }, ], } } function buildPresetCriteria(preset, { viewer, targetUserId } = {}) { const now = new Date() switch (preset) { case "NEW": return { where: { status: "ACTIVE" }, orderBy: [{ createdAt: "desc" }] } case "HOT": { const cutoff = new Date(now.getTime() - 3 * MS_PER_DAY) return { where: { status: "ACTIVE", createdAt: { gte: cutoff } }, orderBy: [{ score: "desc" }, { createdAt: "desc" }], } } case "TRENDING": { const cutoff = new Date(now.getTime() - 2 * MS_PER_DAY) return { where: { status: "ACTIVE", createdAt: { gte: cutoff } }, orderBy: [{ score: "desc" }, { createdAt: "desc" }], } } case "MY": { if (!viewer?.userId) { const err = new Error("AUTH_REQUIRED") err.statusCode = 401 throw err } return { where: { userId: viewer.userId }, orderBy: [{ createdAt: "desc" }] } } case "USER_PUBLIC": { if (!targetUserId) { const err = new Error("TARGET_USER_REQUIRED") err.statusCode = 400 throw err } return { where: { userId: targetUserId, status: "ACTIVE" }, orderBy: [{ createdAt: "desc" }] } } case "HOT_DAY": { const cutoff = new Date(now.getTime() - 1 * MS_PER_DAY) return { where: { status: "ACTIVE", createdAt: { gte: cutoff } }, orderBy: [{ score: "desc" }, { createdAt: "desc" }], } } case "HOT_WEEK": { const cutoff = new Date(now.getTime() - 7 * MS_PER_DAY) return { where: { status: "ACTIVE", createdAt: { gte: cutoff } }, orderBy: [{ score: "desc" }, { createdAt: "desc" }], } } case "HOT_MONTH": { const cutoff = new Date(now.getTime() - 30 * MS_PER_DAY) return { where: { status: "ACTIVE", createdAt: { gte: cutoff } }, orderBy: [{ score: "desc" }, { createdAt: "desc" }], } } default: { const err = new Error("INVALID_PRESET") err.statusCode = 400 throw err } } } // -------------------- // Similar deals helpers (tagsiz, lightweight) // -------------------- function clamp(n, min, max) { return Math.max(min, Math.min(max, n)) } function tokenizeTitle(title = "") { return String(title) .toLowerCase() .replace(/[^a-z0-9çğıöşü\s]/gi, " ") .split(/\s+/) .filter(Boolean) .filter((w) => w.length >= 3) } function titleOverlapScore(aTitle, bTitle) { const a = tokenizeTitle(aTitle) const b = tokenizeTitle(bTitle) if (!a.length || !b.length) return 0 const aset = new Set(a) const bset = new Set(b) let hit = 0 for (const w of bset) if (aset.has(w)) hit++ const denom = Math.min(aset.size, bset.size) || 1 return hit / denom // 0..1 } /** * SimilarDeals: DealCard değil, minimal summary döndürür. * Beklenen candidate shape: * - id, title, price, score, createdAt, categoryId, sellerId, customSeller * - seller?: { name } * - images?: [{ imageUrl }] */ async function buildSimilarDealsForDetail(targetDeal, { limit = 5 } = {}) { const take = clamp(Number(limit) || 5, 1, 10) // Bu 2 DB fonksiyonu: ACTIVE filter + images(take:1) + seller(name) getirmeli const [byCategory, bySeller] = await Promise.all([ dealDB.findSimilarCandidatesByCategory(targetDeal.categoryId, targetDeal.id, { take: 80 }), targetDeal.sellerId ? dealDB.findSimilarCandidatesBySeller(targetDeal.sellerId, targetDeal.id, { take: 30 }) : Promise.resolve([]), ]) const dedup = new Map() for (const d of [...byCategory, ...bySeller]) dedup.set(d.id, d) const candidates = Array.from(dedup.values()) const now = Date.now() const scored = candidates.map((d) => { const sameCategory = d.categoryId === targetDeal.categoryId const sameSeller = Boolean(targetDeal.sellerId && d.sellerId === targetDeal.sellerId) const titleSim = titleOverlapScore(targetDeal.title, d.title) // 0..1 const titlePoints = Math.round(titleSim * 25) const scoreVal = Number.isFinite(d.score) ? d.score : 0 const scorePoints = clamp(Math.round(scoreVal / 10), 0, 25) const ageDays = Math.floor((now - new Date(d.createdAt).getTime()) / (1000 * 60 * 60 * 24)) const recencyPoints = ageDays <= 3 ? 10 : ageDays <= 10 ? 6 : ageDays <= 30 ? 3 : 0 const rank = (sameCategory ? 60 : 0) + (sameSeller ? 25 : 0) + titlePoints + scorePoints + recencyPoints return { d, rank } }) scored.sort((a, b) => b.rank - a.rank) return scored.slice(0, take).map(({ d }) => ({ id: d.id, title: d.title, price: d.price ?? null, score: Number.isFinite(d.score) ? d.score : 0, imageUrl: d.images?.[0]?.imageUrl || "", sellerName: d.seller?.name || d.customSeller || "Bilinmiyor", createdAt: formatDateAsString(d.createdAt), // url istersen: // url: d.url ?? null, })) } async function getDeals({ preset = "NEW", q, page, limit, viewer = null, targetUserId = null }) { const pagination = clampPagination({ page, limit }) const { where: presetWhere, orderBy: presetOrder } = buildPresetCriteria(preset, { viewer, targetUserId }) const searchClause = buildSearchClause(q) const clauses = [] if (presetWhere && Object.keys(presetWhere).length > 0) clauses.push(presetWhere) if (searchClause) clauses.push(searchClause) const finalWhere = clauses.length === 0 ? {} : clauses.length === 1 ? clauses[0] : { AND: clauses } const orderBy = presetOrder ?? [{ createdAt: "desc" }] const [deals, total] = await Promise.all([ dealDB.findDeals(finalWhere, { skip: pagination.skip, take: pagination.limit, orderBy, include: DEAL_LIST_INCLUDE, }), dealDB.countDeals(finalWhere), ]) const dealIds = deals.map((d) => d.id) const voteByDealId = new Map() if (viewer?.userId && dealIds.length > 0) { const votes = await dealDB.findVotes( { userId: viewer.userId, dealId: { in: dealIds } }, { select: { dealId: true, voteType: true } } ) votes.forEach((vote) => voteByDealId.set(vote.dealId, vote.voteType)) } const enriched = deals.map((deal) => ({ ...deal, myVote: voteByDealId.get(deal.id) ?? 0, })) return { page: pagination.page, total, totalPages: Math.ceil(total / pagination.limit), results: enriched, } } async function getDealById(id) { const deal = await dealDB.findDeal( { 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 } }, }, } ) if (!deal) return null const breadcrumb = await categoryDB.getCategoryBreadcrumb(deal.categoryId, { includeUndefined: false, }) const similarDeals = await buildSimilarDealsForDetail( { id: deal.id, title: deal.title, categoryId: deal.categoryId, sellerId: deal.sellerId ?? null, }, { limit: 5 } ) return { ...deal, breadcrumb, similarDeals, } } async function createDeal(dealCreateData, files = []) { if (dealCreateData.url) { const seller = await findSellerFromLink(dealCreateData.url) if (seller) { dealCreateData.seller = { connect: { id: seller.id } } dealCreateData.customSeller = null } } const deal = await dealDB.createDeal(dealCreateData) const rows = [] for (let i = 0; i < files.length && i < 5; i++) { const file = files[i] const order = i const key = uuidv4() const basePath = `deals/${deal.id}/${key}` const detailPath = `${basePath}_detail.webp` const thumbPath = `${basePath}_thumb.webp` const BUCKET = "deal" const detailBuffer = await makeDetailWebp(file.buffer) const detailUrl = await uploadImage({ bucket: BUCKET, path: detailPath, fileBuffer: detailBuffer, contentType: "image/webp", }) if (order === 0) { const thumbBuffer = await makeThumbWebp(file.buffer) await uploadImage({ bucket: BUCKET, path: thumbPath, fileBuffer: thumbBuffer, contentType: "image/webp", }) } rows.push({ dealId: deal.id, order, imageUrl: detailUrl }) } if (rows.length > 0) { await dealImageDB.createManyDealImages(rows) } await enqueueDealClassification({ dealId: deal.id }) return getDealById(deal.id) } module.exports = { getDeals, getDealById, createDeal, }