376 lines
11 KiB
JavaScript
376 lines
11 KiB
JavaScript
// 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,
|
||
}
|