590 lines
18 KiB
JavaScript
590 lines
18 KiB
JavaScript
// routes/deals.js
|
|
const express = require("express")
|
|
const router = express.Router()
|
|
|
|
const requireAuth = require("../middleware/requireAuth")
|
|
const requireNotRestricted = require("../middleware/requireNotRestricted")
|
|
const optionalAuth = require("../middleware/optionalAuth")
|
|
const { upload } = require("../middleware/upload.middleware")
|
|
const { validate } = require("../middleware/validate.middleware")
|
|
const { endpoints } = require("@shared/contracts")
|
|
const requireApiKey = require("../middleware/requireApiKey")
|
|
|
|
const userDB = require("../db/user.db")
|
|
const {
|
|
getDeals,
|
|
getDealById,
|
|
createDeal,
|
|
getDealEngagement,
|
|
getDealSuggestions,
|
|
getBestWidgetDeals,
|
|
} = require("../services/deal.service")
|
|
const dealSaveService = require("../services/dealSave.service")
|
|
const dealReportService = require("../services/dealReport.service")
|
|
const personalizedFeedService = require("../services/personalizedFeed.service")
|
|
|
|
const { mapCreateDealRequestToDealCreateData } = require("../adapters/requests/dealCreate.adapter")
|
|
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter")
|
|
const { mapDealToDealCardResponse, mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
|
|
const { getClientIp } = require("../utils/requestInfo")
|
|
const {
|
|
queueDealImpressions,
|
|
queueDealView,
|
|
queueDealClick,
|
|
} = require("../services/redis/dealAnalytics.service")
|
|
const { trackUserCategoryInterest, USER_INTEREST_ACTIONS } = require("../services/userInterest.service")
|
|
const { getOrCacheDeal } = require("../services/redis/dealCache.service")
|
|
const { enqueueAuditFromRequest, buildAuditMeta } = require("../services/audit.service")
|
|
const { AUDIT_ACTIONS } = require("../services/auditActions")
|
|
const { toSafeRedirectUrl } = require("../utils/urlSafety")
|
|
|
|
const { deals, users } = endpoints
|
|
|
|
function isUserInterestDebugEnabled() {
|
|
const raw = String(process.env.USER_INTEREST_DEBUG || "0").trim().toLowerCase()
|
|
return raw === "1" || raw === "true" || raw === "yes" || raw === "on"
|
|
}
|
|
|
|
function parsePage(value) {
|
|
const num = Number(value)
|
|
if (!Number.isInteger(num) || num < 1) return 1
|
|
return num
|
|
}
|
|
|
|
function logUserInterestDebug(label, payload = {}) {
|
|
if (!isUserInterestDebugEnabled()) return
|
|
try {
|
|
console.log(`[user-interest] ${label}`, payload)
|
|
} catch {}
|
|
}
|
|
|
|
const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery")
|
|
|
|
const buildViewer = (req) =>
|
|
req.auth ? { userId: req.auth.userId, role: req.auth.role } : null
|
|
|
|
function createListHandler(preset) {
|
|
return async (req, res) => {
|
|
try {
|
|
const viewer = buildViewer(req)
|
|
const { q, page, limit, hotListId, trendingListId } = req.validatedDealListQuery
|
|
|
|
const payload = await getDeals({
|
|
preset,
|
|
q,
|
|
page,
|
|
limit,
|
|
viewer,
|
|
filters: req.query,
|
|
hotListId,
|
|
trendingListId,
|
|
})
|
|
|
|
const response = deals.dealsListResponseSchema.parse(
|
|
mapPaginatedDealsToDealCardResponse(payload)
|
|
)
|
|
const dealIds = payload?.results?.map((deal) => deal.id) || []
|
|
queueDealImpressions({
|
|
dealIds,
|
|
userId: req.auth?.userId ?? null,
|
|
ip: getClientIp(req),
|
|
}).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
|
|
res.json(response)
|
|
} catch (err) {
|
|
console.error(err)
|
|
const status = err.statusCode || 500
|
|
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// Public deals of a user (viewer optional; self profile => "MY" else "USER_PUBLIC")
|
|
router.get(
|
|
"/users/:userName/deals",
|
|
optionalAuth,
|
|
validate(users.userProfileRequestSchema, "params", "validatedUserProfile"),
|
|
listQueryValidator,
|
|
async (req, res) => {
|
|
try {
|
|
const { userName } = req.validatedUserProfile
|
|
const targetUser = await userDB.findUser(
|
|
{ username: userName },
|
|
{ select: { id: true } }
|
|
)
|
|
|
|
if (!targetUser) return res.status(404).json({ error: "Kullanici bulunamadi" })
|
|
|
|
const { q, page, limit, hotListId, trendingListId } = req.validatedDealListQuery
|
|
const viewer = buildViewer(req)
|
|
const isSelfProfile = viewer?.userId === targetUser.id
|
|
const preset = isSelfProfile ? "MY" : "USER_PUBLIC"
|
|
|
|
const payload = await getDeals({
|
|
preset,
|
|
q,
|
|
page,
|
|
limit,
|
|
targetUserId: targetUser.id,
|
|
viewer,
|
|
filters: req.query,
|
|
hotListId,
|
|
trendingListId,
|
|
})
|
|
|
|
const response = deals.dealsListResponseSchema.parse(
|
|
mapPaginatedDealsToDealCardResponse(payload)
|
|
)
|
|
const dealIds = payload?.results?.map((deal) => deal.id) || []
|
|
queueDealImpressions({
|
|
dealIds,
|
|
userId: req.auth?.userId ?? null,
|
|
ip: getClientIp(req),
|
|
}).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
|
|
res.json(response)
|
|
} catch (err) {
|
|
console.error(err)
|
|
const status = err.statusCode || 500
|
|
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
|
}
|
|
}
|
|
)
|
|
|
|
// My deals (auth required)
|
|
router.get(
|
|
"/me/deals",
|
|
requireAuth,
|
|
listQueryValidator,
|
|
createListHandler("MY")
|
|
)
|
|
|
|
router.get("/new", requireApiKey, optionalAuth, listQueryValidator, createListHandler("NEW"))
|
|
router.get("/hot", requireApiKey, optionalAuth, listQueryValidator, createListHandler("HOT"))
|
|
router.get("/trending", requireApiKey, optionalAuth, listQueryValidator, createListHandler("TRENDING"))
|
|
|
|
router.get("/for-you", requireApiKey, requireAuth, async (req, res) => {
|
|
try {
|
|
const page = parsePage(req.query.page)
|
|
const payload = await personalizedFeedService.getPersonalizedDeals({
|
|
userId: req.auth.userId,
|
|
page,
|
|
})
|
|
|
|
const response = deals.dealsListResponseSchema.parse(
|
|
mapPaginatedDealsToDealCardResponse(payload)
|
|
)
|
|
const dealIds = payload?.results?.map((deal) => deal.id) || []
|
|
queueDealImpressions({
|
|
dealIds,
|
|
userId: req.auth?.userId ?? null,
|
|
ip: getClientIp(req),
|
|
}).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
|
|
|
|
res.json({ ...response, personalizedListId: payload.personalizedListId ?? null })
|
|
} catch (err) {
|
|
console.error(err)
|
|
const status = err.statusCode || 500
|
|
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
|
}
|
|
})
|
|
|
|
router.get("/search/suggest", optionalAuth, async (req, res) => {
|
|
try {
|
|
const q = String(req.query.q || "").trim()
|
|
const limit = Number(req.query.limit || 8)
|
|
const payload = await getDealSuggestions({ q, limit, viewer: buildViewer(req) })
|
|
const response = mapPaginatedDealsToDealCardResponse({
|
|
page: 1,
|
|
total: payload.results.length,
|
|
totalPages: 1,
|
|
results: payload.results,
|
|
})
|
|
res.json(response.results)
|
|
} catch (err) {
|
|
console.error(err)
|
|
res.status(500).json({ error: "Sunucu hatasi" })
|
|
}
|
|
})
|
|
|
|
// Resolve deal URL (SSR uses api key; user token optional)
|
|
router.post(
|
|
"/url",
|
|
(req, res, next) => {
|
|
logUserInterestDebug("deal-click-request", {
|
|
hasApiKeyHeader: Boolean(req.headers?.["x-api-key"]),
|
|
hasAuthorizationHeader: Boolean(req.headers?.authorization),
|
|
hasAtCookie: Boolean(req.cookies?.at),
|
|
dealIdRaw: req.body?.dealId ?? null,
|
|
})
|
|
return next()
|
|
},
|
|
requireApiKey,
|
|
optionalAuth,
|
|
async (req, res) => {
|
|
try {
|
|
const dealId = Number(req.body?.dealId)
|
|
if (!Number.isInteger(dealId) || dealId <= 0) {
|
|
logUserInterestDebug("deal-click-skip", {
|
|
reason: "invalid_deal_id",
|
|
dealIdRaw: req.body?.dealId ?? null,
|
|
})
|
|
return res.status(400).json({ error: "dealId invalid" })
|
|
}
|
|
|
|
const deal = await getOrCacheDeal(dealId, { ttlSeconds: 15 * 60 })
|
|
if (!deal) {
|
|
logUserInterestDebug("deal-click-skip", {
|
|
reason: "deal_not_found",
|
|
dealId,
|
|
})
|
|
return res.status(404).json({ error: "Deal bulunamadi" })
|
|
}
|
|
|
|
if (deal.status === "PENDING" || deal.status === "REJECTED") {
|
|
const isOwner = req.auth?.userId && Number(deal.userId) === Number(req.auth.userId)
|
|
const isMod = req.auth?.role === "MOD" || req.auth?.role === "ADMIN"
|
|
if (!isOwner && !isMod) {
|
|
logUserInterestDebug("deal-click-skip", {
|
|
reason: "deal_not_visible_for_user",
|
|
dealId,
|
|
status: deal.status,
|
|
userId: req.auth?.userId ?? null,
|
|
})
|
|
return res.status(404).json({ error: "Deal bulunamadi" })
|
|
}
|
|
}
|
|
|
|
const userId = req.auth?.userId ?? null
|
|
const ip = getClientIp(req)
|
|
queueDealClick({ dealId, userId, ip }).catch((err) =>
|
|
console.error("Deal click queue failed:", err?.message || err)
|
|
)
|
|
if (userId) {
|
|
trackUserCategoryInterest({
|
|
userId,
|
|
categoryId: deal.categoryId,
|
|
action: USER_INTEREST_ACTIONS.DEAL_CLICK,
|
|
}).catch((err) => console.error("User interest track failed:", err?.message || err))
|
|
logUserInterestDebug("deal-click-track", {
|
|
dealId,
|
|
userId,
|
|
categoryId: deal.categoryId ?? null,
|
|
status: deal.status,
|
|
})
|
|
} else {
|
|
logUserInterestDebug("deal-click-skip", {
|
|
reason: "missing_auth_user",
|
|
dealId,
|
|
categoryId: deal.categoryId ?? null,
|
|
hasAuthorizationHeader: Boolean(req.headers?.authorization),
|
|
hasAtCookie: Boolean(req.cookies?.at),
|
|
})
|
|
}
|
|
|
|
const safeUrl = toSafeRedirectUrl(deal.url)
|
|
if (!safeUrl) {
|
|
return res.status(422).json({ error: "Deal URL gecersiz" })
|
|
}
|
|
res.json({ url: safeUrl })
|
|
} catch (err) {
|
|
console.error(err)
|
|
res.status(500).json({ error: "Sunucu hatasi" })
|
|
}
|
|
})
|
|
|
|
// Report deal (auth required)
|
|
router.post("/:id/report", requireAuth, async (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id)
|
|
const reason = req.body?.reason
|
|
const note = req.body?.note
|
|
const result = await dealReportService.createDealReport({
|
|
dealId: id,
|
|
userId: req.auth.userId,
|
|
reason,
|
|
note,
|
|
})
|
|
enqueueAuditFromRequest(
|
|
req,
|
|
AUDIT_ACTIONS.DEAL.REPORT_CREATE,
|
|
buildAuditMeta({
|
|
entityType: "DEAL",
|
|
entityId: id,
|
|
extra: { reason: reason ?? null },
|
|
})
|
|
)
|
|
res.json(result)
|
|
} catch (err) {
|
|
console.error(err)
|
|
const status = err.statusCode || 500
|
|
res.status(status).json({ error: err.message || "Report basarisiz" })
|
|
}
|
|
})
|
|
|
|
// Saved deals (auth required)
|
|
router.post("/:id/save", requireAuth, async (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id)
|
|
const result = await dealSaveService.saveDealForUser({
|
|
userId: req.auth.userId,
|
|
dealId: id,
|
|
})
|
|
enqueueAuditFromRequest(
|
|
req,
|
|
AUDIT_ACTIONS.DEAL.SAVE,
|
|
buildAuditMeta({
|
|
entityType: "DEAL",
|
|
entityId: id,
|
|
})
|
|
)
|
|
res.json(result)
|
|
} catch (err) {
|
|
console.error(err)
|
|
const status = err.statusCode || 500
|
|
res.status(status).json({ error: err.message || "Kaydetme basarisiz" })
|
|
}
|
|
})
|
|
|
|
router.delete("/:id/save", requireAuth, async (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id)
|
|
await dealSaveService.removeSavedDealForUser({
|
|
userId: req.auth.userId,
|
|
dealId: id,
|
|
})
|
|
enqueueAuditFromRequest(
|
|
req,
|
|
AUDIT_ACTIONS.DEAL.UNSAVE,
|
|
buildAuditMeta({
|
|
entityType: "DEAL",
|
|
entityId: id,
|
|
})
|
|
)
|
|
res.sendStatus(200)
|
|
} catch (err) {
|
|
console.error(err)
|
|
const status = err.statusCode || 500
|
|
res.status(status).json({ error: err.message || "Silme basarisiz" })
|
|
}
|
|
})
|
|
|
|
router.get("/saved", requireAuth, async (req, res) => {
|
|
try {
|
|
const page = parsePage(req.query.page)
|
|
const payload = await dealSaveService.listSavedDeals({
|
|
userId: req.auth.userId,
|
|
page,
|
|
})
|
|
const response = deals.dealsListResponseSchema.parse(
|
|
mapPaginatedDealsToDealCardResponse(payload)
|
|
)
|
|
res.json(response)
|
|
} catch (err) {
|
|
console.error(err)
|
|
const status = err.statusCode || 500
|
|
res.status(status).json({ error: err.message || "Kaydedilenler alinamadi" })
|
|
}
|
|
})
|
|
|
|
// Best deals widget: hot day/week/year top 5
|
|
router.get("/widgets/best", requireApiKey, optionalAuth, async (req, res) => {
|
|
try {
|
|
const viewer = buildViewer(req)
|
|
const limitRaw = Number(req.query.limit ?? 5)
|
|
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(20, limitRaw)) : 5
|
|
|
|
const payload = await getBestWidgetDeals({ viewer, limit })
|
|
const hotDay = payload.hotDay.map(mapDealToDealCardResponse)
|
|
const hotWeek = payload.hotWeek.map(mapDealToDealCardResponse)
|
|
const hotMonth = payload.hotMonth.map(mapDealToDealCardResponse)
|
|
|
|
const dealIds = [...payload.hotDay, ...payload.hotWeek, ...payload.hotMonth].map((d) => d.id)
|
|
if (dealIds.length) {
|
|
queueDealImpressions({
|
|
dealIds,
|
|
userId: req.auth?.userId ?? null,
|
|
ip: getClientIp(req),
|
|
}).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
|
|
}
|
|
|
|
res.json({ hotDay, hotWeek, hotMonth })
|
|
} catch (err) {
|
|
console.error(err)
|
|
const status = err.statusCode || 500
|
|
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
|
}
|
|
})
|
|
|
|
router.get(
|
|
"/search",
|
|
optionalAuth,
|
|
listQueryValidator,
|
|
async (req, res) => {
|
|
try {
|
|
const { q, page, limit, hotListId, trendingListId } = req.validatedDealListQuery
|
|
if (!q || !q.trim()) {
|
|
return res.json({ results: [], total: 0, totalPages: 0, page })
|
|
}
|
|
|
|
const payload = await getDeals({
|
|
preset: "NEW",
|
|
q,
|
|
page,
|
|
limit,
|
|
viewer: buildViewer(req),
|
|
filters: req.query,
|
|
baseWhere: { status: "ACTIVE" },
|
|
hotListId,
|
|
trendingListId,
|
|
useRedisSearch: true,
|
|
})
|
|
|
|
const response = deals.dealsListResponseSchema.parse(
|
|
mapPaginatedDealsToDealCardResponse(payload)
|
|
)
|
|
const dealIds = payload?.results?.map((deal) => deal.id) || []
|
|
queueDealImpressions({
|
|
dealIds,
|
|
userId: req.auth?.userId ?? null,
|
|
ip: getClientIp(req),
|
|
}).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
|
|
res.json(response)
|
|
} catch (err) {
|
|
console.error(err)
|
|
const status = err.statusCode || 500
|
|
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
|
}
|
|
}
|
|
)
|
|
// TOP deals (daily/weekly/monthly) - viewer optional
|
|
router.get("/top", optionalAuth, async (req, res) => {
|
|
try {
|
|
const viewer = buildViewer(req)
|
|
|
|
const range = String(req.query.range || "day")
|
|
const limitRaw = Number(req.query.limit ?? 6)
|
|
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(20, limitRaw)) : 6
|
|
|
|
let preset = "HOT_DAY"
|
|
if (range === "week") preset = "HOT_WEEK"
|
|
else if (range === "month") preset = "HOT_MONTH"
|
|
else if (range !== "day") return res.status(400).json({ error: "range invalid" })
|
|
|
|
const payload = await getDeals({
|
|
preset,
|
|
q: null,
|
|
page: 1,
|
|
limit,
|
|
viewer,
|
|
filters: req.query,
|
|
})
|
|
|
|
const response = deals.dealsListResponseSchema.parse(
|
|
mapPaginatedDealsToDealCardResponse(payload)
|
|
)
|
|
const dealIds = payload?.results?.map((deal) => deal.id) || []
|
|
queueDealImpressions({
|
|
dealIds,
|
|
userId: req.auth?.userId ?? null,
|
|
ip: getClientIp(req),
|
|
}).catch((err) => console.error("Deal impression queue failed:", err?.message || err))
|
|
|
|
// frontend DealCard[] bekliyor
|
|
res.json(response.results)
|
|
} catch (err) {
|
|
console.error(err)
|
|
const status = err.statusCode || 500
|
|
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
|
}
|
|
})
|
|
|
|
router.post(
|
|
"/engagement",
|
|
requireAuth,
|
|
validate(deals.dealEngagementRequestSchema, "body", "validatedEngagement"),
|
|
async (req, res) => {
|
|
try {
|
|
const { ids } = req.validatedEngagement
|
|
const viewer = buildViewer(req)
|
|
const engagement = await getDealEngagement(ids, viewer)
|
|
res.json(deals.dealEngagementResponseSchema.parse(engagement))
|
|
} catch (err) {
|
|
const status = err.statusCode || 500
|
|
res.status(status).json({ error: err.message || "Sunucu hatasi" })
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
|
|
router.get(
|
|
"/:id",
|
|
optionalAuth,
|
|
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
|
|
async (req, res) => {
|
|
try {
|
|
|
|
const { id } = req.validatedDealId
|
|
const deal = await getDealById(id, buildViewer(req))
|
|
if (!deal) return res.status(404).json({ error: "Deal bulunamadi" })
|
|
|
|
queueDealView({
|
|
dealId: deal.id,
|
|
userId: req.auth?.userId ?? null,
|
|
ip: getClientIp(req),
|
|
}).catch((err) => console.error("Deal view queue failed:", err?.message || err))
|
|
if (req.auth?.userId) {
|
|
trackUserCategoryInterest({
|
|
userId: req.auth.userId,
|
|
categoryId: deal.categoryId,
|
|
action: USER_INTEREST_ACTIONS.DEAL_VIEW,
|
|
}).catch((err) => console.error("User interest track failed:", err?.message || err))
|
|
}
|
|
|
|
const mapped = mapDealToDealDetailResponse(deal)
|
|
res.json(deals.dealDetailResponseSchema.parse(mapped))
|
|
} catch (err) {
|
|
console.error(err)
|
|
res.status(500).json({ error: "Sunucu hatasi" })
|
|
}
|
|
}
|
|
)
|
|
|
|
// Create deal (auth required)
|
|
router.post(
|
|
"/",
|
|
requireAuth,
|
|
requireNotRestricted({ checkSuspend: true }),
|
|
upload.array("images", 5),
|
|
validate(deals.dealCreateRequestSchema, "body", "validatedDealPayload"),
|
|
async (req, res) => {
|
|
try {
|
|
const userId = req.auth.userId
|
|
const dealCreateData = mapCreateDealRequestToDealCreateData(
|
|
req.validatedDealPayload,
|
|
userId
|
|
)
|
|
|
|
const deal = await createDeal(dealCreateData, req.files || [])
|
|
const mapped = mapDealToDealDetailResponse(deal)
|
|
enqueueAuditFromRequest(
|
|
req,
|
|
AUDIT_ACTIONS.DEAL.CREATE,
|
|
buildAuditMeta({
|
|
entityType: "DEAL",
|
|
entityId: deal.id,
|
|
after: { title: deal.title },
|
|
})
|
|
)
|
|
res.json(deals.dealCreateResponseSchema.parse(mapped))
|
|
} catch (err) {
|
|
console.error(err)
|
|
const status = err.statusCode || 500
|
|
res.status(status).json({ error: status >= 500 ? "Sunucu hatasi" : err.message })
|
|
}
|
|
}
|
|
)
|
|
|
|
module.exports = router
|
|
|