HotTRDealsBackend/services/deal.service.js
2026-01-25 17:50:56 +00:00

376 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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,
}