const dealService = require("./deal.service") const dealAiReviewDB = require("../db/dealAiReview.db") const categoryDB = require("../db/category.db") const { findCategoryById, listCategories } = categoryDB const { findSeller } = require("../db/seller.db") const { getOrCacheDealForModeration, updateDealInRedis, } = require("./redis/dealCache.service") const { queueDealUpdate, queueNotificationCreate } = require("./redis/dbSync.service") const { publishNotification } = require("./redis/notificationPubsub.service") const { getSellerById } = require("./redis/sellerCache.service") const { attachTagsToDeal, normalizeTags } = require("./tag.service") const { toSafeRedirectUrl } = require("../utils/urlSafety") const { sanitizeDealDescriptionHtml, sanitizeOptionalPlainText, sanitizeRequiredPlainText, } = require("../utils/inputSanitizer") function normalizeDealForModResponse(deal) { if (!deal) return deal const images = Array.isArray(deal.images) ? deal.images.map((img, idx) => ({ id: img.id ?? 0, imageUrl: img.imageUrl, order: img.order ?? idx, })) : [] return { ...deal, images, myVote: deal.myVote ?? 0, _count: { comments: Number(deal.commentCount ?? 0) }, } } async function enrichDealSeller(deal) { if (!deal || deal.seller || !deal.sellerId) return deal const seller = await getSellerById(Number(deal.sellerId)) if (!seller) return deal return { ...deal, seller } } async function getPendingDeals({ page = 1, limit = 10, filters = {}, viewer = null } = {}) { return dealService.getDeals({ preset: "RAW", q: filters?.q, page, limit, viewer, scope: "MOD", baseWhere: { status: "PENDING" }, filters, useRedisSearch: true, }) } async function updateDealStatus(dealId, nextStatus) { const id = Number(dealId) if (!Number.isInteger(id) || id <= 0) { const err = new Error("INVALID_DEAL_ID") err.statusCode = 400 throw err } const { deal } = await getOrCacheDealForModeration(id) if (!deal) { const err = new Error("DEAL_NOT_FOUND") err.statusCode = 404 throw err } if (deal.status === nextStatus) return { id: deal.id, status: deal.status } const updatedAt = new Date() await updateDealInRedis(id, { status: nextStatus }, { updatedAt }) queueDealUpdate({ dealId: id, data: { status: nextStatus }, updatedAt: updatedAt.toISOString(), }).catch((err) => console.error("DB sync deal status update failed:", err?.message || err)) return { id: deal.id, status: nextStatus } } async function approveDeal(dealId) { const id = Number(dealId) if (!Number.isInteger(id) || id <= 0) { const err = new Error("INVALID_DEAL_ID") err.statusCode = 400 throw err } const { deal } = await getOrCacheDealForModeration(id) if (!deal) { const err = new Error("DEAL_NOT_FOUND") err.statusCode = 404 throw err } const aiReviewFromDb = await dealAiReviewDB.findDealAiReviewByDealId(id, { select: { bestCategoryId: true, tags: true }, }) let categoryId = Number(deal.categoryId || 0) if (!categoryId) { const aiCategoryId = Number(deal.aiReview?.bestCategoryId || 0) if (aiCategoryId > 0) { categoryId = aiCategoryId } else { const fallbackId = aiReviewFromDb?.bestCategoryId ?? 0 if (!Number.isInteger(fallbackId) || fallbackId <= 0) { const err = new Error("CATEGORY_REQUIRED") err.statusCode = 400 throw err } categoryId = fallbackId } } const aiTags = Array.isArray(deal.aiReview?.tags) ? deal.aiReview.tags : Array.isArray(aiReviewFromDb?.tags) ? aiReviewFromDb.tags : [] const normalizedTags = normalizeTags(aiTags) if (deal.status === "ACTIVE" && categoryId === Number(deal.categoryId || 0) && !normalizedTags.length) { return { id: deal.id, status: deal.status } } const tagResult = normalizedTags.length ? await attachTagsToDeal(id, normalizedTags) : { tags: [] } const updatedAt = new Date() const redisPatch = { status: "ACTIVE", categoryId } if (tagResult.tags?.length) { redisPatch.tags = tagResult.tags } await updateDealInRedis(id, redisPatch, { updatedAt }) queueDealUpdate({ dealId: id, data: { status: "ACTIVE", categoryId }, updatedAt: updatedAt.toISOString(), }).catch((err) => console.error("DB sync deal approve failed:", err?.message || err)) if (Number.isInteger(Number(deal.userId)) && Number(deal.userId) > 0) { const payload = { userId: Number(deal.userId), message: "Fırsatın onaylandı!", type: "MODERATION", extras: { dealId: Number(id), }, createdAt: updatedAt.toISOString(), } queueNotificationCreate(payload).catch((err) => console.error("DB sync approval notification failed:", err?.message || err) ) publishNotification(payload).catch((err) => console.error("Approval notification publish failed:", err?.message || err) ) } return { id: deal.id, status: "ACTIVE" } } async function rejectDeal(dealId) { return updateDealStatus(dealId, "REJECTED") } async function expireDeal(dealId) { return updateDealStatus(dealId, "EXPIRED") } async function unexpireDeal(dealId) { return updateDealStatus(dealId, "ACTIVE") } async function getDealDetailForMod(dealId, viewer = null) { const deal = await dealService.getDealById(dealId, viewer) if (!deal) { const err = new Error("DEAL_NOT_FOUND") err.statusCode = 404 throw err } const aiReview = await dealAiReviewDB.findDealAiReviewByDealId(Number(dealId), { select: { dealId: true, bestCategoryId: true, tags: true, needsReview: true, hasIssue: true, issueType: true, issueReason: true, createdAt: true, }, }) const categoryBreadcrumb = aiReview ? await categoryDB.getCategoryBreadcrumb(aiReview.bestCategoryId, { includeUndefined: false }) : [] return { deal, aiReview: aiReview ? { ...aiReview, categoryBreadcrumb } : null, } } async function updateDealForMod(dealId, input = {}, viewer = null) { const id = Number(dealId) if (!Number.isInteger(id) || id <= 0) { const err = new Error("INVALID_DEAL_ID") err.statusCode = 400 throw err } const { deal: existing } = await getOrCacheDealForModeration(id) if (!existing) { const err = new Error("DEAL_NOT_FOUND") err.statusCode = 404 throw err } if (input.sellerId !== undefined && input.customSeller !== undefined) { const err = new Error("SELLER_CONFLICT") err.statusCode = 400 throw err } const data = {} if (input.title !== undefined) { data.title = sanitizeRequiredPlainText(input.title, { fieldName: "TITLE", maxLength: 300 }) } if (input.description !== undefined) { data.description = sanitizeDealDescriptionHtml(input.description) } if (input.url !== undefined) { if (input.url === null) { data.url = null } else { const safeUrl = toSafeRedirectUrl(input.url) if (!safeUrl) { const err = new Error("INVALID_URL") err.statusCode = 400 throw err } data.url = safeUrl } } if (input.price !== undefined) data.price = input.price ?? null if (input.originalPrice !== undefined) data.originalPrice = input.originalPrice ?? null if (input.shippingPrice !== undefined) data.shippingPrice = input.shippingPrice ?? null if (input.couponCode !== undefined) { data.couponCode = sanitizeOptionalPlainText(input.couponCode, { maxLength: 120 }) } if (input.location !== undefined) { data.location = sanitizeOptionalPlainText(input.location, { maxLength: 150 }) } if (input.discountValue !== undefined) data.discountValue = input.discountValue ?? null if (input.discountType !== undefined) { const normalized = typeof input.discountType === "string" ? input.discountType.toUpperCase() : null if (normalized && !["PERCENT", "AMOUNT"].includes(normalized)) { const err = new Error("INVALID_DISCOUNT_TYPE") err.statusCode = 400 throw err } data.discountType = normalized } if (input.saleType !== undefined) { const normalized = typeof input.saleType === "string" ? input.saleType.toUpperCase() : null if (normalized && !["ONLINE", "OFFLINE", "CODE"].includes(normalized)) { const err = new Error("INVALID_SALE_TYPE") err.statusCode = 400 throw err } data.saletype = normalized } if (input.sellerId !== undefined) { const sellerId = Number(input.sellerId) if (!Number.isInteger(sellerId) || sellerId <= 0) { const err = new Error("INVALID_SELLER_ID") err.statusCode = 400 throw err } const seller = await findSeller({ id: sellerId }, { select: { id: true } }) if (!seller) { const err = new Error("SELLER_NOT_FOUND") err.statusCode = 404 throw err } data.sellerId = sellerId data.customSeller = null } if (input.customSeller !== undefined) { data.customSeller = sanitizeOptionalPlainText(input.customSeller, { maxLength: 120 }) if (data.customSeller) data.sellerId = null } if (input.categoryId !== undefined) { const categoryId = Number(input.categoryId) if (!Number.isInteger(categoryId) || categoryId < 0) { const err = new Error("INVALID_CATEGORY_ID") err.statusCode = 400 throw err } if (categoryId > 0) { const category = await findCategoryById(categoryId, { select: { id: true } }) if (!category) { const err = new Error("CATEGORY_NOT_FOUND") err.statusCode = 404 throw err } } data.categoryId = categoryId } if (!Object.keys(data).length) { const enriched = await enrichDealSeller(existing) return normalizeDealForModResponse(enriched) } const updatedAt = new Date() const updated = await updateDealInRedis(id, data, { updatedAt }) queueDealUpdate({ dealId: id, data, updatedAt: updatedAt.toISOString(), }).catch((err) => console.error("DB sync deal update failed:", err?.message || err)) let normalized = updated || existing if (!normalized?.user) { const refreshed = await getOrCacheDealForModeration(id) if (refreshed?.deal) normalized = refreshed.deal } const enriched = await enrichDealSeller(normalized) return normalizeDealForModResponse(enriched) } async function listAllCategories() { return listCategories({ select: { id: true, name: true, parentId: true }, orderBy: { id: "asc" }, }) } module.exports = { getPendingDeals, approveDeal, rejectDeal, expireDeal, unexpireDeal, getDealDetailForMod, updateDealForMod, listAllCategories, }