HotTRDealsBackend/services/deal.service.js
2026-01-29 00:45:52 +00:00

581 lines
16 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("./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,
}