// services/deal.service.js const dealDB = require("../db/deal.db") const { findSellerFromLink } = require("./sellerLookup.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_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: { orderBy: { order: "asc" }, take: 1, select: { imageUrl: true }, }, } 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 } 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" } }, ], } } 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) { 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" }], } } case "RAW": { return { where: {}, orderBy: [{ 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 = 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.findSimilarCandidates( { id: { not: Number(targetDeal.id) }, status: "ACTIVE", categoryId: Number(targetDeal.categoryId), }, { take: 80, select: SIMILAR_DEAL_SELECT } ), targetDeal.sellerId ? dealDB.findSimilarCandidates( { id: { not: Number(targetDeal.id) }, status: "ACTIVE", sellerId: Number(targetDeal.sellerId), }, { take: 30, select: SIMILAR_DEAL_SELECT } ) : 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, filters = null, baseWhere = null, scope = "USER", }) { const pagination = clampPagination({ page, limit }) 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" }] const [deals, total] = await Promise.all([ dealDB.findDeals(finalWhere, { skip: pagination.skip, take: pagination.limit, orderBy, select: DEAL_CARD_SELECT, }), 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, viewer = null) { const deal = await dealDB.findDeal( { id: Number(id) }, { select: DEAL_DETAIL_SELECT, } ) if (!deal) return null 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 userStats = { totalLikes: userStatsAgg?._sum?.score ?? 0, totalDeals: userStatsAgg?._count?._all ?? 0, } return { ...deal, comments: [], breadcrumb, similarDeals, userStats, } } 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, }