From 5ac0a9e47960d20e4de621cbfaf697bb95a30601 Mon Sep 17 00:00:00 2001 From: cureb Date: Mon, 9 Feb 2026 21:47:55 +0000 Subject: [PATCH] latest-before-monorepo --- adapters/requests/dealCreate.adapter.js | 31 +- adapters/responses/comment.adapter.js | 3 +- adapters/responses/dealCard.adapter.js | 5 +- adapters/responses/dealDetail.adapter.js | 9 +- adapters/responses/me.adapter.js | 4 +- adapters/responses/publicUser.adapter.js | 5 +- adapters/responses/userProfile.adapter.js | 3 +- db/userInterestProfile.db.js | 161 ++ jobs/dbSync.queue.js | 3 +- package-lock.json | 1776 ++++++++++++++++- package.json | 2 +- .../migration.sql | 16 + .../20260207231213_add_interest/migration.sql | 2 + .../migration.sql | 2 + prisma/schema.prisma | 14 + prisma/seed.js | 22 +- routes/accountSettings.routes.js | 22 +- routes/category.routes.js | 9 + routes/deal.routes.js | 115 +- routes/upload.routes.js | 3 +- services/admin.service.js | 36 +- services/auth.service.js | 11 +- services/avatar.service.js | 3 +- services/badge.service.js | 19 +- services/category.service.js | 125 +- services/comment.service.js | 54 +- services/deal.service.js | 9 +- services/dealReport.service.js | 3 +- services/dealSave.service.js | 7 + services/linkPreviewImage.service.js | 21 +- services/mod.service.js | 43 +- services/moderation.service.js | 7 +- services/personalizedFeed.service.js | 327 +++ services/productPreview.service.js | 9 + services/profile.service.js | 56 +- services/redis/categoryDealIndex.service.js | 149 ++ services/redis/dbSync.service.js | 14 +- services/redis/dealCache.service.js | 19 +- services/redis/dealIndexing.service.js | 25 +- .../redis/linkPreviewImageCache.service.js | 15 +- services/redis/scraperRpc.service.js | 97 + services/redis/sellerDealIndex.service.js | 157 ++ services/seller.service.js | 177 +- services/supabaseUpload.service.js | 38 +- services/uploadImage.service.js | 64 +- services/userInterest.service.js | 204 ++ services/vote.service.js | 18 + utils/inputSanitizer.js | 158 ++ utils/mediaPath.js | 39 + utils/urlSafety.js | 17 + workers/dbSync.worker.js | 74 +- 51 files changed, 3917 insertions(+), 285 deletions(-) create mode 100644 db/userInterestProfile.db.js create mode 100644 prisma/migrations/20260207101500_add_user_interest_profile/migration.sql create mode 100644 prisma/migrations/20260207231213_add_interest/migration.sql create mode 100644 prisma/migrations/20260208142000_add_notification_extras/migration.sql create mode 100644 services/personalizedFeed.service.js create mode 100644 services/redis/categoryDealIndex.service.js create mode 100644 services/redis/scraperRpc.service.js create mode 100644 services/redis/sellerDealIndex.service.js create mode 100644 services/userInterest.service.js create mode 100644 utils/inputSanitizer.js create mode 100644 utils/mediaPath.js create mode 100644 utils/urlSafety.js diff --git a/adapters/requests/dealCreate.adapter.js b/adapters/requests/dealCreate.adapter.js index 0bcbaf7..175e269 100644 --- a/adapters/requests/dealCreate.adapter.js +++ b/adapters/requests/dealCreate.adapter.js @@ -1,3 +1,10 @@ +const { toSafeRedirectUrl } = require("../../utils/urlSafety") +const { + sanitizeDealDescriptionHtml, + sanitizeOptionalPlainText, + sanitizeRequiredPlainText, +} = require("../../utils/inputSanitizer") + function mapCreateDealRequestToDealCreateData(payload, userId) { const { title, @@ -13,26 +20,28 @@ function mapCreateDealRequestToDealCreateData(payload, userId) { discountValue, } = payload - const normalizedCouponCode = - couponCode === undefined || couponCode === null - ? null - : String(couponCode).trim() || null - const hasUrl = Boolean(url) + const normalizedTitle = sanitizeRequiredPlainText(title, { fieldName: "TITLE", maxLength: 300 }) + const normalizedDescription = sanitizeDealDescriptionHtml(description) + const normalizedCouponCode = sanitizeOptionalPlainText(couponCode, { maxLength: 120 }) + const normalizedLocation = sanitizeOptionalPlainText(location, { maxLength: 150 }) + const normalizedSellerName = sanitizeOptionalPlainText(sellerName ?? customSeller, { + maxLength: 120, + }) + const normalizedUrl = toSafeRedirectUrl(url) + const hasUrl = Boolean(normalizedUrl) const saleType = !hasUrl ? "OFFLINE" : normalizedCouponCode ? "CODE" : "ONLINE" const hasPrice = price != null const normalizedDiscountType = hasPrice ? null : discountType ?? null const normalizedDiscountValue = hasPrice ? null : discountValue ?? null - const normalizedSellerName = sellerName ?? customSeller ?? null - return { - title, - description: description ?? null, - url: url ?? null, + title: normalizedTitle, + description: normalizedDescription, + url: normalizedUrl, price: price ?? null, originalPrice: originalPrice ?? null, couponCode: normalizedCouponCode, - location: location ?? null, + location: normalizedLocation, discountType: normalizedDiscountType, discountValue: normalizedDiscountValue, saletype: saleType, diff --git a/adapters/responses/comment.adapter.js b/adapters/responses/comment.adapter.js index ff78337..b9f7c00 100644 --- a/adapters/responses/comment.adapter.js +++ b/adapters/responses/comment.adapter.js @@ -1,5 +1,6 @@ const formatDateAsString = (value) => value instanceof Date ? value.toISOString() : value ?? null +const { normalizeMediaPath } = require("../../utils/mediaPath") function mapCommentToDealCommentResponse(comment) { return { @@ -18,7 +19,7 @@ function mapCommentToDealCommentResponse(comment) { user: { id: comment.user.id, username: comment.user.username, - avatarUrl: comment.user.avatarUrl ?? null, + avatarUrl: normalizeMediaPath(comment.user.avatarUrl) ?? null, }, } } diff --git a/adapters/responses/dealCard.adapter.js b/adapters/responses/dealCard.adapter.js index 32913b3..e59d477 100644 --- a/adapters/responses/dealCard.adapter.js +++ b/adapters/responses/dealCard.adapter.js @@ -1,4 +1,5 @@ const formatDateAsString = (value) => (value instanceof Date ? value.toISOString() : value ?? null) +const { normalizeMediaPath } = require("../../utils/mediaPath") function mapDealToDealCardResponse(deal) { return { @@ -30,7 +31,7 @@ function mapDealToDealCardResponse(deal) { user: { id: deal.user.id, username: deal.user.username, - avatarUrl: deal.user.avatarUrl ?? null, + avatarUrl: normalizeMediaPath(deal.user.avatarUrl) ?? null, }, seller: deal.seller @@ -43,7 +44,7 @@ function mapDealToDealCardResponse(deal) { url: null, }, - imageUrl: deal.images?.[0]?.imageUrl || "", + imageUrl: normalizeMediaPath(deal.images?.[0]?.imageUrl) || "", } } diff --git a/adapters/responses/dealDetail.adapter.js b/adapters/responses/dealDetail.adapter.js index d8e2b66..0bc34d1 100644 --- a/adapters/responses/dealDetail.adapter.js +++ b/adapters/responses/dealDetail.adapter.js @@ -1,5 +1,6 @@ // adapters/responses/dealDetail.adapter.js const {mapBreadcrumbToResponse} =require( "./breadCrumb.adapter") +const { normalizeMediaPath } = require("../../utils/mediaPath") const formatDateAsString = (value) => value instanceof Date ? value.toISOString() : value ?? null @@ -35,7 +36,7 @@ function mapSimilarDealItem(d) { title: d.title, price: d.price ?? null, score: Number.isFinite(d.score) ? d.score : 0, - imageUrl: d.imageUrl || "", + imageUrl: normalizeMediaPath(d.imageUrl) || "", sellerName: d.sellerName || "Bilinmiyor", createdAt: formatDateAsString(d.createdAt), // SimilarDealSchema: nullable OK // url: d.url ?? null, @@ -78,7 +79,7 @@ function mapDealToDealDetailResponse(deal) { user: { id: deal.user.id, username: deal.user.username, - avatarUrl: deal.user.avatarUrl ?? null, + avatarUrl: normalizeMediaPath(deal.user.avatarUrl) ?? null, }, userStats: { totalLikes: deal.userStats?.totalLikes ?? 0, @@ -100,7 +101,7 @@ function mapDealToDealDetailResponse(deal) { images: (deal.images || []).map((img) => ({ id: img.id, - imageUrl: img.imageUrl, + imageUrl: normalizeMediaPath(img.imageUrl) || "", order: img.order, })), @@ -124,7 +125,7 @@ function mapDealToDealDetailResponse(deal) { user: { id: comment.user.id, username: comment.user.username, - avatarUrl: comment.user.avatarUrl ?? null, + avatarUrl: normalizeMediaPath(comment.user.avatarUrl) ?? null, }, } }), diff --git a/adapters/responses/me.adapter.js b/adapters/responses/me.adapter.js index e896722..e0ac6b5 100644 --- a/adapters/responses/me.adapter.js +++ b/adapters/responses/me.adapter.js @@ -1,3 +1,5 @@ +const { normalizeMediaPath } = require("../../utils/mediaPath") + function mapMeRequestToUserId(req) { // authMiddleware -> req.user.userId return req.user.userId; @@ -8,7 +10,7 @@ function mapMeResultToResponse(user) { id: user.id, username: user.username, email: user.email, - avatarUrl: user.avatarUrl ?? null, + avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null, role: user.role, }; } diff --git a/adapters/responses/publicUser.adapter.js b/adapters/responses/publicUser.adapter.js index 817a40e..f49cafd 100644 --- a/adapters/responses/publicUser.adapter.js +++ b/adapters/responses/publicUser.adapter.js @@ -1,12 +1,13 @@ const formatDateAsString = (value) => value instanceof Date ? value.toISOString() : value ?? null +const { normalizeMediaPath } = require("../../utils/mediaPath") // adapters/responses/publicUser.adapter.js function mapUserToPublicUserSummaryResponse(user) { return { id: user.id, username: user.username, - avatarUrl: user.avatarUrl ?? null, + avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null, } } @@ -14,7 +15,7 @@ function mapUserToPublicUserDetailsResponse(user) { return { id: user.id, username: user.username, - avatarUrl: user.avatarUrl ?? null, + avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null, email: user.email, createdAt: formatDateAsString(user.createdAt), // ISO string } diff --git a/adapters/responses/userProfile.adapter.js b/adapters/responses/userProfile.adapter.js index ddbcd5f..440ab4c 100644 --- a/adapters/responses/userProfile.adapter.js +++ b/adapters/responses/userProfile.adapter.js @@ -3,6 +3,7 @@ const dealCardAdapter = require("./dealCard.adapter") const dealCommentAdapter = require("./comment.adapter") const publicUserAdapter = require("./publicUser.adapter") // yoksa yaz const userProfileStatsAdapter = require("./userProfileStats.adapter") +const { normalizeMediaPath } = require("../../utils/mediaPath") const formatDateAsString = (value) => value instanceof Date ? value.toISOString() : value ?? null @@ -14,7 +15,7 @@ function mapUserBadgeToResponse(item) { ? { id: item.badge.id, name: item.badge.name, - iconUrl: item.badge.iconUrl ?? null, + iconUrl: normalizeMediaPath(item.badge.iconUrl) ?? null, description: item.badge.description ?? null, } : null, diff --git a/db/userInterestProfile.db.js b/db/userInterestProfile.db.js new file mode 100644 index 0000000..e1ce2e2 --- /dev/null +++ b/db/userInterestProfile.db.js @@ -0,0 +1,161 @@ +const prisma = require("./client") + +const DEFAULT_SATURATION_RATIO = 0.3 +const DEFAULT_TX_USER_CHUNK_SIZE = Math.max( + 1, + Number(process.env.USER_INTEREST_DB_TX_USER_CHUNK_SIZE) || 200 +) + +function getDb(db) { + return db || prisma +} + +function normalizePositiveInt(value) { + const num = Number(value) + if (!Number.isInteger(num) || num <= 0) return null + return num +} + +function normalizePoints(value) { + const num = Number(value) + if (!Number.isFinite(num) || num <= 0) return null + return Math.floor(num) +} + +function normalizeScores(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {} + return { ...raw } +} + +function normalizeSaturationRatio(value) { + const num = Number(value) + if (!Number.isFinite(num)) return DEFAULT_SATURATION_RATIO + if (num <= 0 || num >= 1) return DEFAULT_SATURATION_RATIO + return num +} + +function getMaxAllowedBySaturation({ currentCategoryScore, totalScore, ratio }) { + const current = Number(currentCategoryScore) || 0 + const total = Number(totalScore) || 0 + const otherTotal = Math.max(0, total - current) + if (otherTotal <= 0) return Number.POSITIVE_INFINITY + return Math.floor((otherTotal * ratio) / (1 - ratio)) +} + +function aggregateIncrements(increments = []) { + const map = new Map() + for (const item of Array.isArray(increments) ? increments : []) { + const userId = normalizePositiveInt(item?.userId) + const categoryId = normalizePositiveInt(item?.categoryId) + const points = normalizePoints(item?.points) + if (!userId || !categoryId || !points) continue + const key = `${userId}:${categoryId}` + map.set(key, (map.get(key) || 0) + points) + } + + const groupedByUser = new Map() + for (const [key, points] of map.entries()) { + const [userIdRaw, categoryIdRaw] = key.split(":") + const userId = Number(userIdRaw) + const categoryId = Number(categoryIdRaw) + if (!groupedByUser.has(userId)) groupedByUser.set(userId, []) + groupedByUser.get(userId).push({ categoryId, points }) + } + + return groupedByUser +} + +function chunkEntries(entries = [], size = DEFAULT_TX_USER_CHUNK_SIZE) { + const normalizedSize = Math.max(1, Number(size) || DEFAULT_TX_USER_CHUNK_SIZE) + const chunks = [] + for (let i = 0; i < entries.length; i += normalizedSize) { + chunks.push(entries.slice(i, i + normalizedSize)) + } + return chunks +} + +async function getUserInterestProfile(userId, db) { + const uid = normalizePositiveInt(userId) + if (!uid) return null + const p = getDb(db) + const rows = await p.$queryRawUnsafe( + 'SELECT "userId", "categoryScores", "totalScore", "createdAt", "updatedAt" FROM "UserInterestProfile" WHERE "userId" = $1 LIMIT 1', + uid + ) + return Array.isArray(rows) && rows.length ? rows[0] : null +} + +async function applyInterestIncrementsBatch(increments = [], options = {}, db) { + const groupedByUser = aggregateIncrements(increments) + if (!groupedByUser.size) { + return { updated: 0, appliedPoints: 0 } + } + + const saturationRatio = normalizeSaturationRatio(options?.saturationRatio) + let updated = 0 + let appliedPoints = 0 + + const userEntries = Array.from(groupedByUser.entries()) + const chunks = chunkEntries(userEntries) + + for (const chunk of chunks) { + await getDb(db).$transaction(async (tx) => { + for (const [userId, entries] of chunk) { + await tx.$executeRawUnsafe( + 'INSERT INTO "UserInterestProfile" ("userId", "categoryScores", "totalScore", "createdAt", "updatedAt") VALUES ($1, \'{}\'::jsonb, 0, NOW(), NOW()) ON CONFLICT ("userId") DO NOTHING', + userId + ) + const rows = await tx.$queryRawUnsafe( + 'SELECT "userId", "categoryScores", "totalScore" FROM "UserInterestProfile" WHERE "userId" = $1 FOR UPDATE', + userId + ) + const profile = Array.isArray(rows) && rows.length ? rows[0] : null + if (!profile) continue + + const scores = normalizeScores(profile.categoryScores) + let totalScore = Number(profile.totalScore || 0) + let changed = false + + for (const entry of entries) { + const categoryKey = String(entry.categoryId) + const currentCategoryScore = Number(scores[categoryKey] || 0) + const maxAllowedBySaturation = getMaxAllowedBySaturation({ + currentCategoryScore, + totalScore, + ratio: saturationRatio, + }) + + let nextCategoryScore = currentCategoryScore + entry.points + if (Number.isFinite(maxAllowedBySaturation)) { + nextCategoryScore = Math.min(nextCategoryScore, maxAllowedBySaturation) + } + + const applied = Math.max(0, Math.floor(nextCategoryScore - currentCategoryScore)) + if (applied <= 0) continue + + scores[categoryKey] = currentCategoryScore + applied + totalScore += applied + appliedPoints += applied + changed = true + } + + if (!changed) continue + + await tx.$executeRawUnsafe( + 'UPDATE "UserInterestProfile" SET "categoryScores" = $1::jsonb, "totalScore" = $2, "updatedAt" = NOW() WHERE "userId" = $3', + JSON.stringify(scores), + totalScore, + userId + ) + updated += 1 + } + }) + } + + return { updated, appliedPoints } +} + +module.exports = { + getUserInterestProfile, + applyInterestIncrementsBatch, +} diff --git a/jobs/dbSync.queue.js b/jobs/dbSync.queue.js index 1666a3d..2d1cda2 100644 --- a/jobs/dbSync.queue.js +++ b/jobs/dbSync.queue.js @@ -3,6 +3,7 @@ const { getRedisConnectionOptions } = require("../services/redis/connection") const connection = getRedisConnectionOptions() const queue = new Queue("db-sync", { connection }) +const DB_SYNC_REPEAT_MS = Math.max(2000, Number(process.env.DB_SYNC_REPEAT_MS) || 10000) async function ensureDbSyncRepeatable() { return queue.add( @@ -10,7 +11,7 @@ async function ensureDbSyncRepeatable() { {}, { jobId: "db-sync-batch", - repeat: { every: 30000 }, + repeat: { every: DB_SYNC_REPEAT_MS }, removeOnComplete: true, removeOnFail: 200, } diff --git a/package-lock.json b/package-lock.json index 4238367..6d8493e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@aws-sdk/client-s3": "^3.985.0", "@prisma/client": "^6.18.0", "@shared/contracts": "file:../Contracts", - "@supabase/supabase-js": "^2.78.0", "axios": "^1.11.0", "bcryptjs": "^3.0.2", "bullmq": "^5.67.0", @@ -48,6 +48,904 @@ "typescript": "^5.0.0" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.985.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.985.0.tgz", + "integrity": "sha512-S9TqjzzZEEIKBnC7yFpvqM7CG9ALpY5qhQ5BnDBJtdG20NoGpjKLGUUfD2wmZItuhbrcM4Z8c6m6Fg0XYIOVvw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/credential-provider-node": "^3.972.6", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.3", + "@aws-sdk/middleware-expect-continue": "^3.972.3", + "@aws-sdk/middleware-flexible-checksums": "^3.972.5", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-location-constraint": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-sdk-s3": "^3.972.7", + "@aws-sdk/middleware-ssec": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.7", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/signature-v4-multi-region": "3.985.0", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.985.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.5", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.22.1", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-blob-browser": "^4.2.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/hash-stream-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/md5-js": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.13", + "@smithy/middleware-retry": "^4.4.30", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.29", + "@smithy/util-defaults-mode-node": "^4.2.32", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-stream": "^4.5.11", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.985.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.985.0.tgz", + "integrity": "sha512-81J8iE8MuXhdbMfIz4sWFj64Pe41bFi/uqqmqOC5SlGv+kwoyLsyKS/rH2tW2t5buih4vTUxskRjxlqikTD4oQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.7", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.985.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.5", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.22.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.13", + "@smithy/middleware-retry": "^4.4.30", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.29", + "@smithy/util-defaults-mode-node": "^4.2.32", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.7.tgz", + "integrity": "sha512-wNZZQQNlJ+hzD49cKdo+PY6rsTDElO8yDImnrI69p2PLBa7QomeUKAJWYp9xnaR38nlHqWhMHZuYLCQ3oSX+xg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/xml-builder": "^3.972.4", + "@smithy/core": "^3.22.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz", + "integrity": "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.5.tgz", + "integrity": "sha512-LxJ9PEO4gKPXzkufvIESUysykPIdrV7+Ocb9yAhbhJLE4TiAYqbCVUE+VuKP1leGR1bBfjWjYgSV5MxprlX3mQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.7.tgz", + "integrity": "sha512-L2uOGtvp2x3bTcxFTpSM+GkwFIPd8pHfGWO1764icMbo7e5xJh0nfhx1UwkXLnwvocTNEf8A7jISZLYjUSNaTg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/types": "^3.973.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.9", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.5.tgz", + "integrity": "sha512-SdDTYE6jkARzOeL7+kudMIM4DaFnP5dZVeatzw849k4bSXDdErDS188bgeNzc/RA2WGrlEpsqHUKP6G7sVXhZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/credential-provider-env": "^3.972.5", + "@aws-sdk/credential-provider-http": "^3.972.7", + "@aws-sdk/credential-provider-login": "^3.972.5", + "@aws-sdk/credential-provider-process": "^3.972.5", + "@aws-sdk/credential-provider-sso": "^3.972.5", + "@aws-sdk/credential-provider-web-identity": "^3.972.5", + "@aws-sdk/nested-clients": "3.985.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.5.tgz", + "integrity": "sha512-uYq1ILyTSI6ZDCMY5+vUsRM0SOCVI7kaW4wBrehVVkhAxC6y+e9rvGtnoZqCOWL1gKjTMouvsf4Ilhc5NCg1Aw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/nested-clients": "3.985.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.6.tgz", + "integrity": "sha512-DZ3CnAAtSVtVz+G+ogqecaErMLgzph4JH5nYbHoBMgBkwTUV+SUcjsjOJwdBJTHu3Dm6l5LBYekZoU2nDqQk2A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.5", + "@aws-sdk/credential-provider-http": "^3.972.7", + "@aws-sdk/credential-provider-ini": "^3.972.5", + "@aws-sdk/credential-provider-process": "^3.972.5", + "@aws-sdk/credential-provider-sso": "^3.972.5", + "@aws-sdk/credential-provider-web-identity": "^3.972.5", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.5.tgz", + "integrity": "sha512-HDKF3mVbLnuqGg6dMnzBf1VUOywE12/N286msI9YaK9mEIzdsGCtLTvrDhe3Up0R9/hGFbB+9l21/TwF5L1C6g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.5.tgz", + "integrity": "sha512-8urj3AoeNeQisjMmMBhFeiY2gxt6/7wQQbEGun0YV/OaOOiXrIudTIEYF8ZfD+NQI6X1FY5AkRsx6O/CaGiybA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.985.0", + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/token-providers": "3.985.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.5.tgz", + "integrity": "sha512-OK3cULuJl6c+RcDZfPpaK5o3deTOnKZbxm7pzhFNGA3fI2hF9yDih17fGRazJzGGWaDVlR9ejZrpDef4DJCEsw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/nested-clients": "3.985.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.3.tgz", + "integrity": "sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-arn-parser": "^3.972.2", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.3.tgz", + "integrity": "sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.5.tgz", + "integrity": "sha512-SF/1MYWx67OyCrLA4icIpWUfCkdlOi8Y1KecQ9xYxkL10GMjVdPTGPnYhAg0dw5U43Y9PVUWhAV2ezOaG+0BLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/crc64-nvme": "3.972.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.11", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz", + "integrity": "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.3.tgz", + "integrity": "sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz", + "integrity": "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz", + "integrity": "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.7.tgz", + "integrity": "sha512-VtZ7tMIw18VzjG+I6D6rh2eLkJfTtByiFoCIauGDtTTPBEUMQUiGaJ/zZrPlCY6BsvLLeFKz3+E5mntgiOWmIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-arn-parser": "^3.972.2", + "@smithy/core": "^3.22.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.11", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.3.tgz", + "integrity": "sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.7.tgz", + "integrity": "sha512-HUD+geASjXSCyL/DHPQc/Ua7JhldTcIglVAoCV8kiVm99IaFSlAbTvEnyhZwdE6bdFyTL+uIaWLaCFSRsglZBQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.985.0", + "@smithy/core": "^3.22.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.985.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.985.0.tgz", + "integrity": "sha512-TsWwKzb/2WHafAY0CE7uXgLj0FmnkBTgfioG9HO+7z/zCPcl1+YU+i7dW4o0y+aFxFgxTMG+ExBQpqT/k2ao8g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.7", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.985.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.5", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.22.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.13", + "@smithy/middleware-retry": "^4.4.30", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.29", + "@smithy/util-defaults-mode-node": "^4.2.32", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz", + "integrity": "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.985.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.985.0.tgz", + "integrity": "sha512-W6hTSOPiSbh4IdTYVxN7xHjpCh0qvfQU1GKGBzGQm0ZEIOaMmWqiDEvFfyGYKmfBvumT8vHKxQRTX0av9omtIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.7", + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.985.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.985.0.tgz", + "integrity": "sha512-+hwpHZyEq8k+9JL2PkE60V93v2kNhUIv7STFt+EAez1UJsJOQDhc5LpzEX66pNjclI5OTwBROs/DhJjC/BtMjQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.7", + "@aws-sdk/nested-clients": "3.985.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", + "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.2.tgz", + "integrity": "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.985.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.985.0.tgz", + "integrity": "sha512-vth7UfGSUR3ljvaq8V4Rc62FsM7GUTH/myxPWkaEgOrprz1/Pc72EgTXxj+cPPPDAfHFIpjhkB7T7Td0RJx+BA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz", + "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz", + "integrity": "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.5.tgz", + "integrity": "sha512-GsUDF+rXyxDZkkJxUsDxnA67FG+kc5W1dnloCFLl6fWzceevsCYzJpASBzT+BPjwUgREE6FngfJYYYMQUY5fZQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.7", + "@aws-sdk/types": "^3.973.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz", + "integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -737,6 +1635,738 @@ "resolved": "../Contracts", "link": true }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", + "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", + "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", + "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", + "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.22.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.22.1.tgz", + "integrity": "sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.11", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", + "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz", + "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz", + "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz", + "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz", + "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz", + "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", + "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz", + "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.0", + "@smithy/chunked-blob-reader-native": "^4.2.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", + "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz", + "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", + "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz", + "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", + "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.13.tgz", + "integrity": "sha512-x6vn0PjYmGdNuKh/juUJJewZh7MoQ46jYaJ2mvekF4EesMuFfrl4LaW/k97Zjf8PTCPQmPgMvwewg7eNoH9n5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.22.1", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.30", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.30.tgz", + "integrity": "sha512-CBGyFvN0f8hlnqKH/jckRDz78Snrp345+PVk8Ux7pnkUCW97Iinse59lY78hBt04h1GZ6hjBN94BRwZy1xC8Bg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/service-error-classification": "^4.2.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", + "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", + "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", + "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.9.tgz", + "integrity": "sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", + "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", + "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", + "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", + "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", + "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", + "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", + "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.2.tgz", + "integrity": "sha512-SCkGmFak/xC1n7hKRsUr6wOnBTJ3L22Qd4e8H1fQIuKTAjntwgU8lrdMe7uHdiT2mJAOWA/60qaW9tiMu69n1A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.22.1", + "@smithy/middleware-endpoint": "^4.4.13", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", + "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", + "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.29", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.29.tgz", + "integrity": "sha512-nIGy3DNRmOjaYaaKcQDzmWsro9uxlaqUOhZDHQed9MW/GmkBZPtnU70Pu1+GT9IBmUXwRdDuiyaeiy9Xtpn3+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.32", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.32.tgz", + "integrity": "sha512-7dtFff6pu5fsjqrVve0YMhrnzJtccCWDacNKOkiZjJ++fmjGExmmSu341x+WU6Oc1IccL7lDuaUj7SfrHpWc5Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.6", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", + "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", + "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", + "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.11", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.11.tgz", + "integrity": "sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.9", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz", + "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -744,86 +2374,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@supabase/auth-js": { - "version": "2.90.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz", - "integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/functions-js": { - "version": "2.90.1", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz", - "integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/postgrest-js": { - "version": "2.90.1", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz", - "integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.90.1", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz", - "integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==", - "license": "MIT", - "dependencies": { - "@types/phoenix": "^1.6.6", - "@types/ws": "^8.18.1", - "tslib": "2.8.1", - "ws": "^8.18.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.90.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz", - "integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==", - "license": "MIT", - "dependencies": { - "iceberg-js": "^0.8.1", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.90.1", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz", - "integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==", - "license": "MIT", - "dependencies": { - "@supabase/auth-js": "2.90.1", - "@supabase/functions-js": "2.90.1", - "@supabase/postgrest-js": "2.90.1", - "@supabase/realtime-js": "2.90.1", - "@supabase/storage-js": "2.90.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -919,17 +2469,12 @@ "version": "24.10.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, - "node_modules/@types/phoenix": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", - "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", - "license": "MIT" - }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -965,15 +2510,6 @@ "@types/node": "*" } }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1122,6 +2658,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1813,6 +3355,24 @@ "node": ">=8.0.0" } }, + "node_modules/fast-xml-parser": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", + "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2077,15 +3637,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/iceberg-js": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", - "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -3242,6 +4793,18 @@ "node": ">=4" } }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3420,6 +4983,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -3498,6 +5062,8 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 235effa..c812835 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "@aws-sdk/client-s3": "^3.985.0", "@prisma/client": "^6.18.0", "@shared/contracts": "file:../Contracts", - "@supabase/supabase-js": "^2.78.0", "axios": "^1.11.0", "bcryptjs": "^3.0.2", "bullmq": "^5.67.0", diff --git a/prisma/migrations/20260207101500_add_user_interest_profile/migration.sql b/prisma/migrations/20260207101500_add_user_interest_profile/migration.sql new file mode 100644 index 0000000..83825f9 --- /dev/null +++ b/prisma/migrations/20260207101500_add_user_interest_profile/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "UserInterestProfile" ( + "userId" INTEGER NOT NULL, + "categoryScores" JSONB NOT NULL DEFAULT '{}', + "totalScore" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserInterestProfile_pkey" PRIMARY KEY ("userId") +); + +-- CreateIndex +CREATE INDEX "UserInterestProfile_updatedAt_idx" ON "UserInterestProfile"("updatedAt"); + +-- AddForeignKey +ALTER TABLE "UserInterestProfile" ADD CONSTRAINT "UserInterestProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260207231213_add_interest/migration.sql b/prisma/migrations/20260207231213_add_interest/migration.sql new file mode 100644 index 0000000..a30ef3d --- /dev/null +++ b/prisma/migrations/20260207231213_add_interest/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "UserInterestProfile" ALTER COLUMN "updatedAt" DROP DEFAULT; diff --git a/prisma/migrations/20260208142000_add_notification_extras/migration.sql b/prisma/migrations/20260208142000_add_notification_extras/migration.sql new file mode 100644 index 0000000..2607f97 --- /dev/null +++ b/prisma/migrations/20260208142000_add_notification_extras/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Notification" +ADD COLUMN "extras" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b9961a3..8d02bdf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,7 @@ model User { auditEvents AuditEvent[] userNotes UserNote[] @relation("UserNotes") notesAuthored UserNote[] @relation("UserNotesAuthor") + interestProfile UserInterestProfile? } model UserNote { @@ -466,6 +467,18 @@ model AuditEvent { @@index([action, createdAt]) } +model UserInterestProfile { + userId Int @id + categoryScores Json @default("{}") + totalScore Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([updatedAt]) +} + model DealAnalyticsTotal { dealId Int @id impressions Int @default(0) @@ -499,6 +512,7 @@ model Notification { userId Int message String type String @default("INFO") + extras Json? createdAt DateTime @default(now()) readAt DateTime? diff --git a/prisma/seed.js b/prisma/seed.js index ba169ff..5432459 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -9,6 +9,13 @@ function randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min } +function pickRandomCategoryId(categoryIds = [], fallbackCategoryId = 0) { + if (Array.isArray(categoryIds) && categoryIds.length) { + return categoryIds[randInt(0, categoryIds.length - 1)] + } + return fallbackCategoryId +} + // Stabil gerçek foto linkleri (redirect yok, hotlink derdi az) function realImage(seed, w = 1200, h = 900) { return `https://picsum.photos/seed/${encodeURIComponent(seed)}/${w}/${h}` @@ -246,7 +253,7 @@ function loadDealsJson(filePath) { // deals.json’dan seed + her deal’a 3 foto + score 0-200 + tarih dağılımı: // - %70: son 5 gün // - %30: 9-11 gün önce -async function seedDealsFromJson({ userId, sellerId, categoryId, dealsFilePath }) { +async function seedDealsFromJson({ userId, sellerId, categoryIds = [], defaultCategoryId = 0, dealsFilePath }) { const baseItems = loadDealsJson(dealsFilePath) // 30 adet olacak şekilde çoğalt (title/url benzersizleşsin) @@ -309,7 +316,7 @@ async function seedDealsFromJson({ userId, sellerId, categoryId, dealsFilePath } commentCount: randInt(0, 25), userId, sellerId, - categoryId, + categoryId: pickRandomCategoryId(categoryIds, defaultCategoryId), score: randInt(0, 200), createdAt, } @@ -388,6 +395,12 @@ async function main() { where: { slug: "pc-ssd" }, select: { id: true }, }) + const availableCategoryIds = ( + await prisma.category.findMany({ + where: { isActive: true, id: { gt: 0 } }, + select: { id: true }, + }) + ).map((cat) => cat.id) // ---------- TAGS ---------- await upsertTagBySlug("ssd", "SSD") @@ -414,7 +427,7 @@ async function main() { commentCount: 1, userId: user.id, sellerId: amazon.id, - categoryId: catSSD?.id ?? 0, + categoryId: pickRandomCategoryId(availableCategoryIds, catSSD?.id ?? 0), // score: randInt(0, 200), // modelinde varsa aç } @@ -440,7 +453,8 @@ async function main() { await seedDealsFromJson({ userId: user.id, sellerId: amazon.id, - categoryId: catSSD?.id ?? 0, + categoryIds: availableCategoryIds, + defaultCategoryId: catSSD?.id ?? 0, dealsFilePath, }) diff --git a/routes/accountSettings.routes.js b/routes/accountSettings.routes.js index ff598e5..80b049b 100644 --- a/routes/accountSettings.routes.js +++ b/routes/accountSettings.routes.js @@ -18,6 +18,20 @@ const { AUDIT_ACTIONS } = require("../services/auditActions") const { account } = endpoints +function attachNotificationExtras(validatedList = [], sourceList = []) { + const extrasById = new Map( + (Array.isArray(sourceList) ? sourceList : []).map((item) => [ + Number(item?.id), + item?.extras ?? null, + ]) + ) + + return (Array.isArray(validatedList) ? validatedList : []).map((item) => ({ + ...item, + extras: extrasById.get(Number(item?.id)) ?? null, + })) +} + router.post( "/avatar", requireAuth, @@ -51,7 +65,9 @@ router.post( router.get("/me", requireAuth, async (req, res) => { try { const user = await getUserProfile(req.auth.userId) - res.json(account.accountMeResponseSchema.parse(user)) + const payload = account.accountMeResponseSchema.parse(user) + payload.notifications = attachNotificationExtras(payload.notifications, user?.notifications) + res.json(payload) } catch (err) { res.status(400).json({ error: err.message }) } @@ -79,7 +95,9 @@ router.get("/notifications", requireAuth, async (req, res) => { try { const input = account.accountNotificationsListRequestSchema.parse(req.query) const payload = await getUserNotificationsPage(req.auth.userId, input.page, 10) - res.json(account.accountNotificationsListResponseSchema.parse(payload)) + const validated = account.accountNotificationsListResponseSchema.parse(payload) + validated.results = attachNotificationExtras(validated.results, payload?.results) + res.json(validated) } catch (err) { res.status(400).json({ error: err.message }) } diff --git a/routes/category.routes.js b/routes/category.routes.js index 8940de1..5e58f55 100644 --- a/routes/category.routes.js +++ b/routes/category.routes.js @@ -7,6 +7,7 @@ const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetai const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter") const { getClientIp } = require("../utils/requestInfo") const { queueDealImpressions } = require("../services/redis/dealAnalytics.service") +const { trackUserCategoryInterest, USER_INTEREST_ACTIONS } = require("../services/userInterest.service") router.get("/:slug", async (req, res) => { @@ -53,6 +54,14 @@ router.get("/:slug/deals", optionalAuth, async (req, res) => { userId: req.auth?.userId ?? null, ip: getClientIp(req), }).catch((err) => console.error("Deal impression queue failed:", err?.message || err)) + + if (req.auth?.userId) { + trackUserCategoryInterest({ + userId: req.auth.userId, + categoryId: category.id, + action: USER_INTEREST_ACTIONS.CATEGORY_VISIT, + }).catch((err) => console.error("User interest track failed:", err?.message || err)) + } res.json({ diff --git a/routes/deal.routes.js b/routes/deal.routes.js index 2379a27..120f456 100644 --- a/routes/deal.routes.js +++ b/routes/deal.routes.js @@ -21,6 +21,7 @@ const { } = 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") @@ -31,18 +32,32 @@ const { 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) => @@ -147,6 +162,32 @@ router.get("/new", requireApiKey, optionalAuth, listQueryValidator, createListHa 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() @@ -166,20 +207,51 @@ router.get("/search/suggest", optionalAuth, async (req, res) => { }) // Resolve deal URL (SSR uses api key; user token optional) -router.post("/url", requireApiKey, optionalAuth, async (req, res) => { +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) return res.status(404).json({ error: "Deal bulunamadi" }) + 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) return res.status(404).json({ error: "Deal bulunamadi" }) + 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 @@ -187,8 +259,33 @@ router.post("/url", requireApiKey, optionalAuth, async (req, res) => { 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), + }) + } - res.json({ url: deal.url ?? null }) + 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" }) @@ -436,6 +533,13 @@ router.get( 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)) @@ -475,7 +579,8 @@ router.post( res.json(deals.dealCreateResponseSchema.parse(mapped)) } catch (err) { console.error(err) - res.status(500).json({ error: "Sunucu hatasi" }) + const status = err.statusCode || 500 + res.status(status).json({ error: status >= 500 ? "Sunucu hatasi" : err.message }) } } ) diff --git a/routes/upload.routes.js b/routes/upload.routes.js index f2483ae..8559d94 100644 --- a/routes/upload.routes.js +++ b/routes/upload.routes.js @@ -25,10 +25,9 @@ router.post( const key = uuidv4() const webpBuffer = await makeWebp(req.file.buffer, { quality: 40 }) - const path = `misc/${req.auth.userId}/${key}.webp` + const path = `images/dealDescription/${key}.webp` const url = await uploadImage({ - bucket: "deal", path, fileBuffer: webpBuffer, contentType: "image/webp", diff --git a/services/admin.service.js b/services/admin.service.js index c87ed40..554e6de 100644 --- a/services/admin.service.js +++ b/services/admin.service.js @@ -21,6 +21,8 @@ const { } = require("./redis/dbSync.service") const { ensureCategoryIdCounter, generateCategoryId } = require("./redis/categoryId.service") const { ensureSellerIdCounter, generateSellerId } = require("./redis/sellerId.service") +const { sanitizeOptionalPlainText } = require("../utils/inputSanitizer") +const { normalizeMediaPath } = require("../utils/mediaPath") function httpError(statusCode, message) { const err = new Error(message) @@ -29,11 +31,19 @@ function httpError(statusCode, message) { } function normalizeCategoryPayload(input = {}, fallback = {}) { - const name = input.name !== undefined ? String(input.name || "").trim() : fallback.name - const rawSlug = input.slug !== undefined ? String(input.slug || "").trim() : fallback.slug + const name = + input.name !== undefined + ? sanitizeOptionalPlainText(input.name, { maxLength: 120 }) || "" + : sanitizeOptionalPlainText(fallback.name, { maxLength: 120 }) || "" + const rawSlug = + input.slug !== undefined + ? sanitizeOptionalPlainText(input.slug, { maxLength: 160 }) || "" + : sanitizeOptionalPlainText(fallback.slug, { maxLength: 160 }) || "" const slug = rawSlug ? slugify(rawSlug) : name ? slugify(name) : fallback.slug const description = - input.description !== undefined ? String(input.description || "").trim() : fallback.description + input.description !== undefined + ? sanitizeOptionalPlainText(input.description, { maxLength: 300 }) || "" + : sanitizeOptionalPlainText(fallback.description, { maxLength: 300 }) || "" const parentId = input.parentId !== undefined && input.parentId !== null ? Number(input.parentId) @@ -169,10 +179,18 @@ async function updateCategory(categoryId, input = {}) { } function normalizeSellerPayload(input = {}, fallback = {}) { - const name = input.name !== undefined ? String(input.name || "").trim() : fallback.name - const url = input.url !== undefined ? String(input.url || "").trim() : fallback.url + const name = + input.name !== undefined + ? sanitizeOptionalPlainText(input.name, { maxLength: 120 }) || "" + : sanitizeOptionalPlainText(fallback.name, { maxLength: 120 }) || "" + const url = + input.url !== undefined + ? sanitizeOptionalPlainText(input.url, { maxLength: 500 }) || "" + : sanitizeOptionalPlainText(fallback.url, { maxLength: 500 }) || "" const sellerLogo = - input.sellerLogo !== undefined ? String(input.sellerLogo || "").trim() : fallback.sellerLogo + input.sellerLogo !== undefined + ? normalizeMediaPath(sanitizeOptionalPlainText(input.sellerLogo, { maxLength: 500 }) || "") || "" + : normalizeMediaPath(sanitizeOptionalPlainText(fallback.sellerLogo, { maxLength: 500 }) || "") || "" const isActive = input.isActive !== undefined ? Boolean(input.isActive) : Boolean(fallback.isActive ?? true) return { name, url: url ?? "", sellerLogo: sellerLogo ?? "", isActive } @@ -191,7 +209,7 @@ async function listSellersCached() { id: seller.id, name: seller.name, url: seller.url ?? "", - sellerLogo: seller.sellerLogo ?? "", + sellerLogo: normalizeMediaPath(seller.sellerLogo) ?? "", isActive: seller.isActive !== undefined ? Boolean(seller.isActive) : true, })) } @@ -230,7 +248,7 @@ async function createSeller(input = {}, { createdById } = {}) { }).catch((err) => console.error("DB sync seller create failed:", err?.message || err)) if (input.domain) { - const domain = String(input.domain || "").trim().toLowerCase() + const domain = (sanitizeOptionalPlainText(input.domain, { maxLength: 255 }) || "").toLowerCase() if (domain) { await setSellerDomainInRedis(domain, id) queueSellerDomainUpsert({ sellerId: id, domain, createdById: creatorId }).catch((err) => @@ -284,7 +302,7 @@ async function updateSeller(sellerId, input = {}, { createdById } = {}) { }).catch((err) => console.error("DB sync seller update failed:", err?.message || err)) if (input.domain) { - const domain = String(input.domain || "").trim().toLowerCase() + const domain = (sanitizeOptionalPlainText(input.domain, { maxLength: 255 }) || "").toLowerCase() if (domain) { await setSellerDomainInRedis(domain, id) queueSellerDomainUpsert({ sellerId: id, domain, createdById: creatorId }).catch((err) => diff --git a/services/auth.service.js b/services/auth.service.js index 1e162c4..3f25187 100644 --- a/services/auth.service.js +++ b/services/auth.service.js @@ -8,6 +8,8 @@ const refreshTokenDb = require("../db/refreshToken.db") const { queueAuditEvent } = require("./redis/dbSync.service") const { AUDIT_ACTIONS } = require("./auditActions") const { buildAuditMeta } = require("./audit.service") +const { sanitizeOptionalPlainText } = require("../utils/inputSanitizer") +const { normalizeMediaPath } = require("../utils/mediaPath") const REUSE_GRACE_MS = Number(process.env.REFRESH_REUSE_GRACE_MS || 10000) @@ -51,7 +53,7 @@ function mapUserPublic(user) { id: user.id, username: user.username, email: user.email, - avatarUrl: user.avatarUrl ?? null, + avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null, role: user.role, } } @@ -100,8 +102,13 @@ async function register({ username, email, password, meta = {} }) { const existingUser = await authDb.findUserByEmail(email) if (existingUser) throw httpError(400, "Bu e-posta zaten kayitli.") + const normalizedUsername = sanitizeOptionalPlainText(username, { maxLength: 18 }) + if (!normalizedUsername || normalizedUsername.length < 5) { + throw httpError(400, "Kullanici adi gecersiz.") + } + const passwordHash = await bcrypt.hash(password, 10) - const user = await authDb.createUser({ username, email, passwordHash }) + const user = await authDb.createUser({ username: normalizedUsername, email, passwordHash }) const { token: accessToken } = signAccessToken(user) diff --git a/services/avatar.service.js b/services/avatar.service.js index f2283c2..5a084ad 100644 --- a/services/avatar.service.js +++ b/services/avatar.service.js @@ -20,8 +20,7 @@ async function updateUserAvatar(userId, file) { const webpBuffer = await makeWebp(buffer, { quality: 80 }) const imageUrl = await uploadImage({ - bucket: "avatars", - path: `${userId}_${Date.now()}.webp`, + path: `avatars/${userId}_${Date.now()}.webp`, fileBuffer: webpBuffer, contentType: "image/webp", }) diff --git a/services/badge.service.js b/services/badge.service.js index ea2a639..62cec8d 100644 --- a/services/badge.service.js +++ b/services/badge.service.js @@ -1,6 +1,8 @@ const badgeDb = require("../db/badge.db") const userBadgeDb = require("../db/userBadge.db") const userDb = require("../db/user.db") +const { sanitizeOptionalPlainText } = require("../utils/inputSanitizer") +const { normalizeMediaPath } = require("../utils/mediaPath") function assertPositiveInt(value, name) { const num = Number(value) @@ -11,8 +13,13 @@ function assertPositiveInt(value, name) { function normalizeOptionalString(value) { if (value === undefined) return undefined if (value === null) return null - const trimmed = String(value).trim() - return trimmed ? trimmed : null + return sanitizeOptionalPlainText(value, { maxLength: 500 }) +} + +function normalizeOptionalImagePath(value) { + if (value === undefined) return undefined + const normalized = normalizeMediaPath(value) + return normalized ?? null } async function listBadges() { @@ -20,12 +27,12 @@ async function listBadges() { } async function createBadge({ name, iconUrl, description }) { - const normalizedName = String(name || "").trim() + const normalizedName = sanitizeOptionalPlainText(name, { maxLength: 120 }) if (!normalizedName) throw new Error("Badge adı zorunlu.") return badgeDb.createBadge({ name: normalizedName, - iconUrl: normalizeOptionalString(iconUrl), + iconUrl: normalizeOptionalImagePath(iconUrl), description: normalizeOptionalString(description), }) } @@ -35,11 +42,11 @@ async function updateBadge(badgeId, { name, iconUrl, description }) { const data = {} if (name !== undefined) { - const normalizedName = String(name || "").trim() + const normalizedName = sanitizeOptionalPlainText(name, { maxLength: 120 }) if (!normalizedName) throw new Error("Badge adı zorunlu.") data.name = normalizedName } - if (iconUrl !== undefined) data.iconUrl = normalizeOptionalString(iconUrl) + if (iconUrl !== undefined) data.iconUrl = normalizeOptionalImagePath(iconUrl) if (description !== undefined) data.description = normalizeOptionalString(description) if (!Object.keys(data).length) throw new Error("Güncellenecek alan yok.") diff --git a/services/category.service.js b/services/category.service.js index 817c009..f7d1154 100644 --- a/services/category.service.js +++ b/services/category.service.js @@ -1,5 +1,107 @@ const categoryDb = require("../db/category.db") const dealService = require("./deal.service") +const { listCategoriesFromRedis, setCategoriesInRedis, setCategoryInRedis } = require("./redis/categoryCache.service") + +function normalizeCategory(category = {}) { + const id = Number(category.id) + if (!Number.isInteger(id) || id < 0) return null + const parentIdRaw = category.parentId + const parentId = + parentIdRaw === null || parentIdRaw === undefined ? null : Number(parentIdRaw) + return { + id, + name: category.name, + slug: String(category.slug || "").trim().toLowerCase(), + parentId: Number.isInteger(parentId) ? parentId : null, + isActive: category.isActive !== undefined ? Boolean(category.isActive) : true, + description: category.description ?? "", + } +} + +function buildCategoryMaps(categories = []) { + const byId = new Map() + const bySlug = new Map() + + categories.forEach((item) => { + const category = normalizeCategory(item) + if (!category) return + byId.set(category.id, category) + if (category.slug) bySlug.set(category.slug, category) + }) + + return { byId, bySlug } +} + +function getCategoryBreadcrumbFromMap(categoryId, byId, { includeUndefined = false } = {}) { + const currentId = Number(categoryId) + if (!Number.isInteger(currentId)) return [] + + const path = [] + const visited = new Set() + let nextId = currentId + + while (true) { + if (visited.has(nextId)) break + visited.add(nextId) + + const category = byId.get(nextId) + if (!category) break + + if (includeUndefined || category.id !== 0) { + path.push({ id: category.id, name: category.name, slug: category.slug }) + } + + if (category.parentId === null || category.parentId === undefined) break + nextId = Number(category.parentId) + } + + return path.reverse() +} + +function getCategoryDescendantIdsFromMap(categoryId, categories = []) { + const rootId = Number(categoryId) + if (!Number.isInteger(rootId) || rootId <= 0) return [] + + const childrenByParent = new Map() + categories.forEach((item) => { + const category = normalizeCategory(item) + if (!category || category.parentId === null) return + const parentId = Number(category.parentId) + if (!Number.isInteger(parentId)) return + if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, []) + childrenByParent.get(parentId).push(category.id) + }) + + const seen = new Set([rootId]) + const queue = [rootId] + while (queue.length) { + const current = queue.shift() + const children = childrenByParent.get(current) || [] + children.forEach((childId) => { + if (seen.has(childId)) return + seen.add(childId) + queue.push(childId) + }) + } + + return Array.from(seen) +} + +async function listCategoriesCached() { + let categories = await listCategoriesFromRedis() + if (categories.length) return categories + + categories = await categoryDb.listCategories({ + select: { id: true, name: true, slug: true, parentId: true, isActive: true, description: true }, + orderBy: { id: "asc" }, + }) + + if (categories.length) { + await setCategoriesInRedis(categories) + } + + return categories +} async function findCategoryBySlug(slug) { const normalizedSlug = String(slug || "").trim() @@ -7,13 +109,25 @@ async function findCategoryBySlug(slug) { throw new Error("INVALID_SLUG") } + const categories = await listCategoriesCached() + if (categories.length) { + const { byId, bySlug } = buildCategoryMaps(categories) + const cachedCategory = bySlug.get(normalizedSlug.toLowerCase()) + if (cachedCategory) { + const breadcrumb = getCategoryBreadcrumbFromMap(cachedCategory.id, byId) + return { category: cachedCategory, breadcrumb } + } + } + const category = await categoryDb.findCategoryBySlug(normalizedSlug) if (!category) { throw new Error("CATEGORY_NOT_FOUND") } + const normalizedCategory = normalizeCategory(category) || category + await setCategoryInRedis(normalizedCategory) const breadcrumb = await categoryDb.getCategoryBreadcrumb(category.id) - return { category, breadcrumb } + return { category: normalizedCategory, breadcrumb } } async function getDealsByCategoryId(categoryId, { page = 1, limit = 10, filters = {}, viewer = null, scope = "USER" } = {}) { @@ -22,7 +136,14 @@ async function getDealsByCategoryId(categoryId, { page = 1, limit = 10, filters throw new Error("INVALID_CATEGORY_ID") } - const categoryIds = await categoryDb.getCategoryDescendantIds(normalizedId) + let categoryIds = [] + const categories = await listCategoriesCached() + if (categories.length) { + categoryIds = getCategoryDescendantIdsFromMap(normalizedId, categories) + } + if (!categoryIds.length) { + categoryIds = await categoryDb.getCategoryDescendantIds(normalizedId) + } return dealService.getDeals({ preset: "NEW", diff --git a/services/comment.service.js b/services/comment.service.js index 1f96950..9e1f2e7 100644 --- a/services/comment.service.js +++ b/services/comment.service.js @@ -7,7 +7,14 @@ const { } = require("./redis/commentCache.service") const { getOrCacheDeal, getDealIdByCommentId } = require("./redis/dealCache.service") const { generateCommentId } = require("./redis/commentId.service") -const { queueCommentCreate, queueCommentDelete } = require("./redis/dbSync.service") +const { + queueCommentCreate, + queueCommentDelete, + queueNotificationCreate, +} = require("./redis/dbSync.service") +const { publishNotification } = require("./redis/notificationPubsub.service") +const { trackUserCategoryInterest, USER_INTEREST_ACTIONS } = require("./userInterest.service") +const { sanitizeOptionalPlainText } = require("../utils/inputSanitizer") function parseParentId(value) { if (value === undefined || value === null || value === "" || value === "null") return null @@ -43,7 +50,8 @@ async function getCommentsByDealId(dealId, { parentId, page, limit, sort, viewer } async function createComment({ dealId, userId, text, parentId = null }) { - if (!text || typeof text !== "string" || !text.trim()) { + const normalizedText = sanitizeOptionalPlainText(text, { maxLength: 2000 }) + if (!normalizedText) { throw new Error("Yorum bos olamaz.") } @@ -62,7 +70,11 @@ async function createComment({ dealId, userId, text, parentId = null }) { if (Number(cachedParent.dealId) !== Number(dealId)) { throw new Error("Yanıtlanan yorum bu deal'a ait degil.") } - parent = { id: cachedParent.id, dealId: cachedParent.dealId } + parent = { + id: cachedParent.id, + dealId: cachedParent.dealId, + userId: Number(cachedParent.userId) || null, + } } const user = await userDB.findUser( @@ -75,7 +87,7 @@ async function createComment({ dealId, userId, text, parentId = null }) { const commentId = await generateCommentId() const comment = { id: commentId, - text: text.trim(), + text: normalizedText, userId, dealId, parentId: parent ? parent.id : null, @@ -94,11 +106,43 @@ async function createComment({ dealId, userId, text, parentId = null }) { commentId, dealId, userId, - text: text.trim(), + text: normalizedText, parentId: parent ? parent.id : null, createdAt: createdAt.toISOString(), }).catch((err) => console.error("DB sync comment create failed:", err?.message || err)) + trackUserCategoryInterest({ + userId, + categoryId: deal.categoryId, + action: USER_INTEREST_ACTIONS.COMMENT_CREATE, + }).catch((err) => console.error("User interest track failed:", err?.message || err)) + + const parentUserId = Number(parent?.userId) + if ( + parent && + Number.isInteger(parentUserId) && + parentUserId > 0 && + parentUserId !== Number(userId) + ) { + const notificationPayload = { + userId: parentUserId, + message: "Yorumuna cevap geldi.", + type: "COMMENT_REPLY", + extras: { + dealId: Number(dealId), + commentId: Number(commentId), + parentCommentId: Number(parent.id), + }, + createdAt: createdAt.toISOString(), + } + queueNotificationCreate(notificationPayload).catch((err) => + console.error("DB sync comment reply notification failed:", err?.message || err) + ) + publishNotification(notificationPayload).catch((err) => + console.error("Comment reply notification publish failed:", err?.message || err) + ) + } + return comment } diff --git a/services/deal.service.js b/services/deal.service.js index 5925952..b112ce2 100644 --- a/services/deal.service.js +++ b/services/deal.service.js @@ -1138,14 +1138,10 @@ async function createDeal(dealCreateData, files = []) { const file = files[i] const order = i const key = uuidv4() - const basePath = `deals/${dealId}/${key}` - const detailPath = `${basePath}_detail.webp` - const thumbPath = `${basePath}_thumb.webp` - const BUCKET = "deal" - + const detailPath = `images/details/${key}.webp` + const thumbPath = `images/thumbnail/${key}.webp` const detailBuffer = await makeDetailWebp(file.buffer) const detailUrl = await uploadImage({ - bucket: BUCKET, path: detailPath, fileBuffer: detailBuffer, contentType: "image/webp", @@ -1154,7 +1150,6 @@ async function createDeal(dealCreateData, files = []) { if (order === 0) { const thumbBuffer = await makeThumbWebp(file.buffer) await uploadImage({ - bucket: BUCKET, path: thumbPath, fileBuffer: thumbBuffer, contentType: "image/webp", diff --git a/services/dealReport.service.js b/services/dealReport.service.js index d194230..1c40a4e 100644 --- a/services/dealReport.service.js +++ b/services/dealReport.service.js @@ -1,6 +1,7 @@ const dealDB = require("../db/deal.db") const dealReportDB = require("../db/dealReport.db") const { queueDealReportStatusUpdate } = require("./redis/dbSync.service") +const { sanitizeOptionalPlainText } = require("../utils/inputSanitizer") const PAGE_LIMIT = 20 const ALLOWED_REASONS = new Set([ @@ -56,7 +57,7 @@ async function createDealReport({ dealId, userId, reason, note }) { dealId: did, userId: uid, reason: normalizedReason, - note: note ? String(note).trim().slice(0, 500) : null, + note: sanitizeOptionalPlainText(note, { maxLength: 500 }), }) return { reported: true } diff --git a/services/dealSave.service.js b/services/dealSave.service.js index 63a1aa6..164c9a6 100644 --- a/services/dealSave.service.js +++ b/services/dealSave.service.js @@ -11,6 +11,7 @@ const { const { mapDealToRedisJson } = require("./redis/dealIndexing.service") const { getOrCacheDeal, updateDealSavesInRedis, setDealInRedis } = require("./redis/dealCache.service") const { queueDealSaveUpdate } = require("./redis/dbSync.service") +const { trackUserCategoryInterest, USER_INTEREST_ACTIONS } = require("./userInterest.service") const PAGE_LIMIT = 20 const ALLOWED_STATUSES = new Set(["ACTIVE", "EXPIRED"]) @@ -80,6 +81,12 @@ async function saveDealForUser({ userId, dealId }) { action: "SAVE", createdAt: new Date().toISOString(), }).catch((err) => console.error("DB sync dealSave queue failed:", err?.message || err)) + + trackUserCategoryInterest({ + userId: uid, + categoryId: deal.categoryId, + action: USER_INTEREST_ACTIONS.DEAL_SAVE, + }).catch((err) => console.error("User interest track failed:", err?.message || err)) return { saved: true } } diff --git a/services/linkPreviewImage.service.js b/services/linkPreviewImage.service.js index 39a1b90..10042eb 100644 --- a/services/linkPreviewImage.service.js +++ b/services/linkPreviewImage.service.js @@ -2,11 +2,12 @@ const { cacheImageFromUrl } = require("./redis/linkPreviewImageCache.service") function extractImageUrlsFromDescription(description, { max = 5 } = {}) { if (!description || typeof description !== "string") return [] - const regex = /]+src=["']([^"']+)["'][^>]*>/gi + const regex = /]+src\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))[^>]*>/gi const urls = [] let match while ((match = regex.exec(description)) !== null) { - if (match[1]) urls.push(match[1]) + const src = match[1] || match[2] || match[3] || "" + if (src) urls.push(src) if (urls.length >= max) break } return urls @@ -14,15 +15,19 @@ function extractImageUrlsFromDescription(description, { max = 5 } = {}) { function replaceDescriptionImageUrls(description, urlMap, { maxImages = 5 } = {}) { if (!description || typeof description !== "string") return description - if (!urlMap || urlMap.size === 0) return description let seen = 0 - return description.replace(/]+src=["']([^"']+)["'][^>]*>/gi, (full, src) => { + const safeMap = urlMap instanceof Map ? urlMap : new Map() + return description.replace( + /]+src\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))[^>]*>/gi, + (full, srcDq, srcSq, srcUnq) => { seen += 1 if (seen > maxImages) return "" - const next = urlMap.get(src) - if (!next) return full - return full.replace(src, next) - }) + const src = srcDq || srcSq || srcUnq || "" + const next = safeMap.get(src) + if (!next) return full + return full.replace(src, next) + } + ) } async function cacheLinkPreviewImages({ product, baseUrl } = {}) { diff --git a/services/mod.service.js b/services/mod.service.js index ad20aa2..865eb04 100644 --- a/services/mod.service.js +++ b/services/mod.service.js @@ -11,6 +11,12 @@ const { queueDealUpdate, queueNotificationCreate } = require("./redis/dbSync.ser 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 @@ -146,6 +152,9 @@ async function approveDeal(dealId) { userId: Number(deal.userId), message: "Fırsatın onaylandı!", type: "MODERATION", + extras: { + dealId: Number(id), + }, createdAt: updatedAt.toISOString(), } queueNotificationCreate(payload).catch((err) => @@ -225,14 +234,34 @@ async function updateDealForMod(dealId, input = {}, viewer = null) { const data = {} - if (input.title !== undefined) data.title = input.title - if (input.description !== undefined) data.description = input.description ?? null - if (input.url !== undefined) data.url = input.url ?? null + 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 = input.couponCode ?? null - if (input.location !== undefined) data.location = input.location ?? 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 = @@ -272,9 +301,7 @@ async function updateDealForMod(dealId, input = {}, viewer = null) { } if (input.customSeller !== undefined) { - const normalized = - typeof input.customSeller === "string" ? input.customSeller.trim() : null - data.customSeller = normalized || null + data.customSeller = sanitizeOptionalPlainText(input.customSeller, { maxLength: 120 }) if (data.customSeller) data.sellerId = null } diff --git a/services/moderation.service.js b/services/moderation.service.js index b5f1975..89eb7f5 100644 --- a/services/moderation.service.js +++ b/services/moderation.service.js @@ -6,6 +6,7 @@ const { getOrCacheUserModeration, setUserModerationInRedis, } = require("./redis/userModerationCache.service") +const { sanitizeOptionalPlainText } = require("../utils/inputSanitizer") function assertUserId(userId) { const id = Number(userId) @@ -94,7 +95,7 @@ async function updateUserRole(userId, role) { async function addUserNote({ userId, createdById, note }) { const uid = assertUserId(userId) const cid = assertUserId(createdById) - const text = String(note || "").trim() + const text = sanitizeOptionalPlainText(note, { maxLength: 1000 }) if (!text) { const err = new Error("NOTE_REQUIRED") err.statusCode = 400 @@ -104,11 +105,11 @@ async function addUserNote({ userId, createdById, note }) { queueUserNoteCreate({ userId: uid, createdById: cid, - note: text.slice(0, 1000), + note: text, createdAt: new Date().toISOString(), }).catch((err) => console.error("DB sync user note failed:", err?.message || err)) - return { userId: uid, createdById: cid, note: text.slice(0, 1000) } + return { userId: uid, createdById: cid, note: text } } async function listUserNotes({ userId, page = 1, limit = 20 }) { diff --git a/services/personalizedFeed.service.js b/services/personalizedFeed.service.js new file mode 100644 index 0000000..a8c32ed --- /dev/null +++ b/services/personalizedFeed.service.js @@ -0,0 +1,327 @@ +const { getRedisClient } = require("./redis/client") +const userInterestProfileDb = require("../db/userInterestProfile.db") +const { getCategoryDealIndexKey } = require("./redis/categoryDealIndex.service") +const { getDealsByIdsFromRedis } = require("./redis/hotDealList.service") +const { getNewDealIds } = require("./redis/newDealList.service") + +const FEED_KEY_PREFIX = "deals:lists:personalized:user:" +const FEED_TTL_SECONDS = Math.max(60, Number(process.env.PERSONAL_FEED_TTL_SECONDS) || 2 * 60 * 60) +const FEED_REBUILD_THRESHOLD_SECONDS = Math.max( + 60, + Number(process.env.PERSONAL_FEED_REBUILD_THRESHOLD_SECONDS) || 60 * 60 +) +const FEED_CANDIDATE_LIMIT = Math.max(20, Number(process.env.PERSONAL_FEED_CANDIDATE_LIMIT) || 120) +const FEED_MAX_CATEGORIES = Math.max(1, Number(process.env.PERSONAL_FEED_MAX_CATEGORIES) || 8) +const FEED_PER_CATEGORY_LIMIT = Math.max(5, Number(process.env.PERSONAL_FEED_PER_CATEGORY_LIMIT) || 40) +const FEED_LOOKBACK_DAYS = Math.max(1, Number(process.env.PERSONAL_FEED_LOOKBACK_DAYS) || 30) +const FEED_NOISE_MAX = Math.max(0, Number(process.env.PERSONAL_FEED_NOISE_MAX) || 50) +const FEED_PAGE_LIMIT = 20 + +function normalizePositiveInt(value) { + const num = Number(value) + if (!Number.isInteger(num) || num <= 0) return null + return num +} + +function normalizePagination({ page, limit }) { + const rawPage = Number(page) + const rawLimit = Number(limit) + const safePage = Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1 + const safeLimit = + Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 50) : 20 + return { page: safePage, limit: safeLimit, skip: (safePage - 1) * safeLimit } +} + +function getLatestKey(userId) { + return `${FEED_KEY_PREFIX}${userId}:latest` +} + +function getFeedKey(userId, feedId) { + return `${FEED_KEY_PREFIX}${userId}:${feedId}` +} + +function getFeedKeyMatchPattern(userId) { + return `${FEED_KEY_PREFIX}${userId}:*` +} + +function parseCategoryScores(rawScores) { + if (!rawScores || typeof rawScores !== "object" || Array.isArray(rawScores)) return [] + const entries = [] + for (const [categoryIdRaw, scoreRaw] of Object.entries(rawScores)) { + const categoryId = normalizePositiveInt(categoryIdRaw) + const score = Number(scoreRaw) + if (!categoryId || !Number.isFinite(score) || score <= 0) continue + entries.push({ categoryId, score }) + } + return entries.sort((a, b) => b.score - a.score) +} + +function buildFallbackFeedIds(dealIds = []) { + return Array.from( + new Set( + (Array.isArray(dealIds) ? dealIds : []) + .map((id) => Number(id)) + .filter((id) => Number.isInteger(id) && id > 0) + ) + ).slice(0, FEED_CANDIDATE_LIMIT) +} + +function computePersonalScore({ categoryScore, dealScore }) { + const safeCategory = Math.max(0, Number(categoryScore) || 0) + const safeDealScore = Math.max(1, Number(dealScore) || 0) + const noise = FEED_NOISE_MAX > 0 ? Math.floor(Math.random() * (FEED_NOISE_MAX + 1)) : 0 + return safeCategory * safeDealScore + noise +} + +async function getFeedFromRedis(redis, userId) { + const latestId = await redis.get(getLatestKey(userId)) + if (!latestId) return null + const key = getFeedKey(userId, latestId) + const raw = await redis.call("JSON.GET", key) + const ttl = Number(await redis.ttl(key)) + if (!raw || ttl <= 0) return null + try { + const parsed = JSON.parse(raw) + return { + id: String(parsed.id || latestId), + dealIds: buildFallbackFeedIds(parsed.dealIds || []), + ttl, + } + } catch { + return null + } +} + +async function listUserFeedKeys(redis, userId) { + const pattern = getFeedKeyMatchPattern(userId) + const keys = [] + let cursor = "0" + + do { + const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100) + cursor = String(nextCursor) + if (Array.isArray(batch) && batch.length) { + batch.forEach((key) => { + if (String(key).endsWith(":latest")) return + keys.push(String(key)) + }) + } + } while (cursor !== "0") + + return Array.from(new Set(keys)) +} + +function extractFeedIdFromKey(userId, key) { + const prefix = `${FEED_KEY_PREFIX}${userId}:` + if (!String(key).startsWith(prefix)) return null + const feedId = String(key).slice(prefix.length) + return feedId || null +} + +async function getBestFeedFromRedis(redis, userId) { + const keys = await listUserFeedKeys(redis, userId) + if (!keys.length) return null + + const pipeline = redis.pipeline() + keys.forEach((key) => pipeline.ttl(key)) + keys.forEach((key) => pipeline.call("JSON.GET", key)) + const results = await pipeline.exec() + if (!Array.isArray(results) || !results.length) return null + + const ttlResults = results.slice(0, keys.length) + const jsonResults = results.slice(keys.length) + + let best = null + keys.forEach((key, idx) => { + try { + const ttl = Number(ttlResults[idx]?.[1] ?? -1) + if (!Number.isFinite(ttl) || ttl <= 0) return + const raw = jsonResults[idx]?.[1] + if (!raw) return + const parsed = JSON.parse(raw) + const dealIds = buildFallbackFeedIds(parsed?.dealIds || []) + const feedId = extractFeedIdFromKey(userId, key) || String(parsed?.id || "") + if (!feedId) return + + if (!best || ttl > best.ttl) { + best = { + id: feedId, + dealIds, + ttl, + } + } + } catch {} + }) + + return best +} + +async function setLatestPointer(redis, userId, feedId, ttlSeconds) { + const ttl = Math.max(1, Number(ttlSeconds) || FEED_TTL_SECONDS) + await redis.set(getLatestKey(userId), String(feedId), "EX", ttl) +} + +async function collectCandidateIdsFromIndexes(redis, topCategories = []) { + if (!topCategories.length) return new Map() + const cutoffTs = Date.now() - FEED_LOOKBACK_DAYS * 24 * 60 * 60 * 1000 + const pipeline = redis.pipeline() + const refs = [] + + topCategories.forEach((entry) => { + const key = getCategoryDealIndexKey(entry.categoryId) + if (!key) return + pipeline.zrevrangebyscore(key, "+inf", String(cutoffTs), "LIMIT", 0, FEED_PER_CATEGORY_LIMIT) + refs.push(entry) + }) + + if (!refs.length) return new Map() + const results = await pipeline.exec() + const categoryByDealId = new Map() + + results.forEach((result, idx) => { + const [, rawIds] = result || [] + const categoryEntry = refs[idx] + const ids = Array.isArray(rawIds) ? rawIds : [] + ids.forEach((id) => { + const dealId = Number(id) + if (!Number.isInteger(dealId) || dealId <= 0) return + if (!categoryByDealId.has(dealId)) { + categoryByDealId.set(dealId, categoryEntry) + } + }) + }) + + return categoryByDealId +} + +async function buildPersonalizedFeedForUser(redis, userId) { + const profile = await userInterestProfileDb.getUserInterestProfile(userId) + const categories = parseCategoryScores(profile?.categoryScores).slice(0, FEED_MAX_CATEGORIES) + + if (!categories.length) { + const fallback = await getNewDealIds({}) + return { + id: String(Date.now()), + dealIds: buildFallbackFeedIds(fallback?.dealIds || []), + } + } + + const categoryByDealId = await collectCandidateIdsFromIndexes(redis, categories) + const candidateIds = Array.from(categoryByDealId.keys()).slice(0, FEED_CANDIDATE_LIMIT * 3) + + if (!candidateIds.length) { + const fallback = await getNewDealIds({}) + return { + id: String(Date.now()), + dealIds: buildFallbackFeedIds(fallback?.dealIds || []), + } + } + + const deals = await getDealsByIdsFromRedis(candidateIds, userId) + const scored = deals + .filter((deal) => String(deal?.status || "").toUpperCase() === "ACTIVE") + .map((deal) => { + const entry = categoryByDealId.get(Number(deal.id)) + const categoryScore = Number(entry?.score || 0) + return { + id: Number(deal.id), + score: computePersonalScore({ + categoryScore, + dealScore: Number(deal.score || 0), + }), + } + }) + .filter((item) => Number.isInteger(item.id) && item.id > 0) + + scored.sort((a, b) => b.score - a.score) + + const rankedIds = Array.from(new Set(scored.map((item) => item.id))).slice(0, FEED_CANDIDATE_LIMIT) + if (!rankedIds.length) { + const fallback = await getNewDealIds({}) + return { + id: String(Date.now()), + dealIds: buildFallbackFeedIds(fallback?.dealIds || []), + } + } + + return { + id: String(Date.now()), + dealIds: rankedIds, + } +} + +async function cacheFeed(redis, userId, feed) { + const feedId = String(feed?.id || Date.now()) + const dealIds = buildFallbackFeedIds(feed?.dealIds || []) + const key = getFeedKey(userId, feedId) + const payload = { + id: feedId, + createdAt: new Date().toISOString(), + total: dealIds.length, + dealIds, + } + await redis.call("JSON.SET", key, "$", JSON.stringify(payload)) + await redis.expire(key, FEED_TTL_SECONDS) + await setLatestPointer(redis, userId, feedId, FEED_TTL_SECONDS) + return { id: feedId, dealIds, ttl: FEED_TTL_SECONDS } +} + +async function getOrBuildFeedIds(userId) { + const uid = normalizePositiveInt(userId) + if (!uid) return { id: null, dealIds: [] } + const redis = getRedisClient() + + try { + const best = (await getBestFeedFromRedis(redis, uid)) || (await getFeedFromRedis(redis, uid)) + if (best && best.ttl >= FEED_REBUILD_THRESHOLD_SECONDS) { + await setLatestPointer(redis, uid, best.id, best.ttl) + return best + } + if (best && best.ttl > 0) { + // Keep current feed as fallback while creating a fresh one. + const built = await buildPersonalizedFeedForUser(redis, uid) + const cached = await cacheFeed(redis, uid, built) + return cached?.dealIds?.length ? cached : best + } + } catch {} + + try { + const built = await buildPersonalizedFeedForUser(redis, uid) + return cacheFeed(redis, uid, built) + } catch { + const fallback = await getNewDealIds({}) + const dealIds = buildFallbackFeedIds(fallback?.dealIds || []) + const payload = { id: String(Date.now()), dealIds, ttl: 0 } + try { + return cacheFeed(redis, uid, payload) + } catch { + return payload + } + } +} + +async function getPersonalizedDeals({ + userId, + page = 1, +} = {}) { + const uid = normalizePositiveInt(userId) + if (!uid) return { page: 1, total: 0, totalPages: 0, results: [], personalizedListId: null } + + const pagination = normalizePagination({ page, limit: FEED_PAGE_LIMIT }) + const feed = await getOrBuildFeedIds(uid) + const ids = Array.isArray(feed?.dealIds) ? feed.dealIds : [] + const pageIds = ids.slice(pagination.skip, pagination.skip + pagination.limit) + const deals = await getDealsByIdsFromRedis(pageIds, uid) + + return { + page: pagination.page, + total: ids.length, + totalPages: ids.length ? Math.ceil(ids.length / pagination.limit) : 0, + results: deals, + personalizedListId: feed?.id || null, + } +} + +module.exports = { + getPersonalizedDeals, +} diff --git a/services/productPreview.service.js b/services/productPreview.service.js index afca955..b880520 100644 --- a/services/productPreview.service.js +++ b/services/productPreview.service.js @@ -1,4 +1,5 @@ const axios = require("axios") +const { requestProductPreviewOverRedis } = require("./redis/scraperRpc.service") function buildScraperUrl(baseUrl, targetUrl) { if (!baseUrl) throw new Error("SCRAPER_API_URL missing") @@ -21,6 +22,14 @@ function buildScraperUrl(baseUrl, targetUrl) { } async function getProductPreviewFromUrl(url) { + const transport = String(process.env.SCRAPER_TRANSPORT || "http") + .trim() + .toLowerCase() + + if (transport === "redis") { + return requestProductPreviewOverRedis(url, { timeoutMs: 20000 }) + } + const baseUrl = process.env.SCRAPER_API_URL const scraperUrl = buildScraperUrl(baseUrl, url) diff --git a/services/profile.service.js b/services/profile.service.js index db83d4f..9ca6057 100644 --- a/services/profile.service.js +++ b/services/profile.service.js @@ -1,22 +1,27 @@ -const bcrypt = require("bcryptjs") +const bcrypt = require("bcryptjs") const userDb = require("../db/user.db") const notificationDb = require("../db/notification.db") const refreshTokenDb = require("../db/refreshToken.db") const { queueNotificationReadAll } = require("./redis/dbSync.service") +const { normalizeMediaPath } = require("../utils/mediaPath") function assertPositiveInt(v, name = "id") { const n = Number(v) - if (!Number.isInteger(n) || n <= 0) throw new Error(`Geçersiz ${name}.`) + if (!Number.isInteger(n) || n <= 0) throw new Error(`Gecersiz ${name}.`) return n } async function updateAvatarUrl(userId, url) { const id = assertPositiveInt(userId, "userId") - if (!url || typeof url !== "string" || !url.trim()) - throw new Error("Geçersiz URL.") + if (!url || typeof url !== "string" || !url.trim()) { + throw new Error("Gecersiz URL.") + } + + const normalizedAvatarUrl = normalizeMediaPath(url) + if (!normalizedAvatarUrl) throw new Error("Gecersiz URL.") const select = { id: true, username: true, avatarUrl: true } - return userDb.updateUser({ id }, { avatarUrl: url.trim() }, { select }) + return userDb.updateUser({ id }, { avatarUrl: normalizedAvatarUrl }, { select }) } async function getUserProfile(userId) { @@ -40,11 +45,13 @@ async function getUserProfile(userId) { id: true, message: true, type: true, + extras: true, createdAt: true, readAt: true, }, }, } + const user = await userDb.findUser({ id }, { select }) if (!user) return user @@ -53,6 +60,7 @@ async function getUserProfile(userId) { const notifications = Array.isArray(user.notifications) ? user.notifications.map((n) => ({ ...n, + extras: n.extras ?? null, createdAt: formatDate(n.createdAt), readAt: formatDate(n.readAt), unread: n.readAt == null, @@ -65,7 +73,7 @@ async function getUserProfile(userId) { ? { id: item.badge.id, name: item.badge.name, - iconUrl: item.badge.iconUrl ?? null, + iconUrl: normalizeMediaPath(item.badge.iconUrl) ?? null, description: item.badge.description ?? null, } : null, @@ -76,21 +84,13 @@ async function getUserProfile(userId) { return { id: user.id, username: user.username, - avatarUrl: user.avatarUrl ?? null, + avatarUrl: normalizeMediaPath(user.avatarUrl) ?? null, createdAt: formatDate(user.createdAt), notifications, badges, } } -module.exports = { - updateAvatarUrl, - getUserProfile, - markAllNotificationsRead, - getUserNotificationsPage, - changePassword, -} - async function markAllNotificationsRead(userId) { const id = assertPositiveInt(userId, "userId") const readAt = new Date().toISOString() @@ -116,6 +116,7 @@ async function getUserNotificationsPage(userId, page = 1, limit = 10) { id: true, message: true, type: true, + extras: true, createdAt: true, readAt: true, }, @@ -127,6 +128,7 @@ async function getUserNotificationsPage(userId, page = 1, limit = 10) { const results = Array.isArray(notifications) ? notifications.map((n) => ({ ...n, + extras: n.extras ?? null, createdAt: formatDate(n.createdAt), readAt: formatDate(n.readAt), unread: n.readAt == null, @@ -145,23 +147,33 @@ async function getUserNotificationsPage(userId, page = 1, limit = 10) { async function changePassword(userId, { currentPassword, newPassword }) { const id = assertPositiveInt(userId, "userId") - if (!currentPassword || typeof currentPassword !== "string") - throw new Error("Mevcut şifre gerekli.") - if (!newPassword || typeof newPassword !== "string") - throw new Error("Yeni şifre gerekli.") + if (!currentPassword || typeof currentPassword !== "string") { + throw new Error("Mevcut sifre gerekli.") + } + if (!newPassword || typeof newPassword !== "string") { + throw new Error("Yeni sifre gerekli.") + } const user = await userDb.findUser( { id }, { select: { id: true, passwordHash: true } } ) - if (!user) throw new Error("Kullanıcı bulunamadı.") + if (!user) throw new Error("Kullanici bulunamadi.") const isMatch = await bcrypt.compare(currentPassword, user.passwordHash) - if (!isMatch) throw new Error("Mevcut şifre hatalı.") + if (!isMatch) throw new Error("Mevcut sifre hatali.") const passwordHash = await bcrypt.hash(newPassword, 10) await userDb.updateUser({ id }, { passwordHash }) await refreshTokenDb.revokeAllUserRefreshTokens(id) - return { message: "Şifre güncellendi." } + return { message: "Sifre guncellendi." } +} + +module.exports = { + updateAvatarUrl, + getUserProfile, + markAllNotificationsRead, + getUserNotificationsPage, + changePassword, } diff --git a/services/redis/categoryDealIndex.service.js b/services/redis/categoryDealIndex.service.js new file mode 100644 index 0000000..5b0b229 --- /dev/null +++ b/services/redis/categoryDealIndex.service.js @@ -0,0 +1,149 @@ +const { getRedisClient } = require("./client") + +const CATEGORY_DEAL_INDEX_KEY_PREFIX = "index:category:" +const CATEGORY_DEAL_INDEX_KEY_SUFFIX = ":deals" + +function normalizePositiveInt(value) { + const num = Number(value) + if (!Number.isInteger(num) || num <= 0) return null + return num +} + +function normalizeEpochMs(value) { + const num = Number(value) + if (!Number.isFinite(num) || num <= 0) return null + return Math.floor(num) +} + +function toEpochMs(value) { + if (value instanceof Date) return value.getTime() + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + return date.getTime() +} + +function isActiveStatus(status) { + return String(status || "").toUpperCase() === "ACTIVE" +} + +function getCategoryDealIndexKey(categoryId) { + const cid = normalizePositiveInt(categoryId) + if (!cid) return null + return `${CATEGORY_DEAL_INDEX_KEY_PREFIX}${cid}${CATEGORY_DEAL_INDEX_KEY_SUFFIX}` +} + +function normalizeDealIndexPayload(payload = {}) { + const dealId = normalizePositiveInt(payload.dealId ?? payload.id) + const categoryId = normalizePositiveInt(payload.categoryId) + const createdAtTs = + normalizeEpochMs(payload.createdAtTs) ?? normalizeEpochMs(toEpochMs(payload.createdAt)) + const status = String(payload.status || "").toUpperCase() + return { dealId, categoryId, createdAtTs, status } +} + +function isIndexableDeal(payload = {}) { + const normalized = normalizeDealIndexPayload(payload) + return Boolean( + normalized.dealId && + normalized.categoryId && + normalized.createdAtTs && + isActiveStatus(normalized.status) + ) +} + +function addDealToCategoryIndexInPipeline(pipeline, payload = {}) { + const normalized = normalizeDealIndexPayload(payload) + if (!normalized.dealId || !normalized.categoryId || !normalized.createdAtTs) return false + if (!isActiveStatus(normalized.status)) return false + const key = getCategoryDealIndexKey(normalized.categoryId) + if (!key) return false + pipeline.zadd(key, String(normalized.createdAtTs), String(normalized.dealId)) + return true +} + +function removeDealFromCategoryIndexInPipeline(pipeline, payload = {}) { + const normalized = normalizeDealIndexPayload(payload) + if (!normalized.dealId || !normalized.categoryId) return false + const key = getCategoryDealIndexKey(normalized.categoryId) + if (!key) return false + pipeline.zrem(key, String(normalized.dealId)) + return true +} + +async function reconcileDealCategoryIndex({ before = null, after = null } = {}) { + const prev = normalizeDealIndexPayload(before || {}) + const next = normalizeDealIndexPayload(after || {}) + + const prevIndexable = isIndexableDeal(prev) + const nextIndexable = isIndexableDeal(next) + + const redis = getRedisClient() + const pipeline = redis.pipeline() + let commandCount = 0 + + if (prevIndexable) { + const removedForStatus = !nextIndexable + const movedCategory = nextIndexable && prev.categoryId !== next.categoryId + if (removedForStatus || movedCategory) { + if (removeDealFromCategoryIndexInPipeline(pipeline, prev)) commandCount += 1 + } + } + + if (nextIndexable) { + const isNew = !prevIndexable + const movedCategory = prevIndexable && prev.categoryId !== next.categoryId + const scoreChanged = + prevIndexable && + prev.categoryId === next.categoryId && + Number(prev.createdAtTs) !== Number(next.createdAtTs) + if (isNew || movedCategory || scoreChanged) { + if (addDealToCategoryIndexInPipeline(pipeline, next)) commandCount += 1 + } + } + + if (!commandCount) return 0 + try { + await pipeline.exec() + return commandCount + } catch { + return 0 + } +} + +async function getRecentDealIdsByCategory({ + categoryId, + sinceTs, + limit = 30, +} = {}) { + const cid = normalizePositiveInt(categoryId) + if (!cid) return [] + const key = getCategoryDealIndexKey(cid) + if (!key) return [] + const minTs = normalizeEpochMs(sinceTs) || 0 + const safeLimit = Math.max(1, Math.min(Number(limit) || 30, 300)) + const redis = getRedisClient() + + try { + const ids = await redis.zrevrangebyscore( + key, + "+inf", + String(minTs), + "LIMIT", + 0, + safeLimit + ) + return Array.isArray(ids) + ? ids.map((id) => Number(id)).filter((id) => Number.isInteger(id) && id > 0) + : [] + } catch { + return [] + } +} + +module.exports = { + getCategoryDealIndexKey, + addDealToCategoryIndexInPipeline, + removeDealFromCategoryIndexInPipeline, + reconcileDealCategoryIndex, + getRecentDealIdsByCategory, +} diff --git a/services/redis/dbSync.service.js b/services/redis/dbSync.service.js index cbb6d81..4ad837f 100644 --- a/services/redis/dbSync.service.js +++ b/services/redis/dbSync.service.js @@ -28,6 +28,15 @@ function createRedisClient() { return getRedisClient() } +function normalizeJsonValue(value) { + if (value === undefined || value === null) return null + try { + return JSON.parse(JSON.stringify(value)) + } catch { + return null + } +} + async function tryQueue({ redisAction, fallbackAction, label }) { try { await redisAction() @@ -323,15 +332,17 @@ async function queueDealAiReviewUpdate({ dealId, data, updatedAt }) { }) } -async function queueNotificationCreate({ userId, message, type = "INFO", createdAt }) { +async function queueNotificationCreate({ userId, message, type = "INFO", extras = null, createdAt }) { if (!userId || !message) return const redis = createRedisClient() + const normalizedExtras = normalizeJsonValue(extras) const field = `notification:${userId}:${Date.now()}` const payload = JSON.stringify({ userId: Number(userId), message: String(message), type: String(type || "INFO"), + extras: normalizedExtras, createdAt, }) @@ -345,6 +356,7 @@ async function queueNotificationCreate({ userId, message, type = "INFO", created userId: Number(userId), message: String(message), type: String(type || "INFO"), + extras: normalizedExtras, createdAt: createdAt ? new Date(createdAt) : new Date(), }, }) diff --git a/services/redis/dealCache.service.js b/services/redis/dealCache.service.js index 77a35f1..8a3b802 100644 --- a/services/redis/dealCache.service.js +++ b/services/redis/dealCache.service.js @@ -9,6 +9,8 @@ const { setUserPublicInRedis, ensureUserMinTtl, } = require("./userPublicCache.service") +const { reconcileDealCategoryIndex } = require("./categoryDealIndex.service") +const { reconcileDealSellerIndex } = require("./sellerDealIndex.service") const DEAL_KEY_PREFIX = "deals:cache:" const DEAL_ANALYTICS_TOTAL_PREFIX = "deals:analytics:total:" @@ -226,6 +228,8 @@ async function cacheDealFromDb(dealId, { ttlSeconds = 1800 } = {}) { } await pipeline.exec() await cacheVotesAndAnalytics(redis, deal.id, payload, { ttlSeconds }) + await reconcileDealCategoryIndex({ before: null, after: payload }) + await reconcileDealSellerIndex({ before: null, after: payload }) } catch { // ignore cache failures } finally {} @@ -271,8 +275,12 @@ async function updateDealInRedis(dealId, patch = {}, { updatedAt = new Date() } const iso = toIso(updatedAt) try { - const exists = await redis.call("JSON.GET", key) - if (!exists) return null + const beforeRaw = await redis.call("JSON.GET", key) + if (!beforeRaw) return null + let beforeDeal = null + try { + beforeDeal = JSON.parse(beforeRaw) + } catch {} const pipeline = redis.pipeline() Object.entries(patch || {}).forEach(([field, value]) => { @@ -284,7 +292,10 @@ async function updateDealInRedis(dealId, patch = {}, { updatedAt = new Date() } await pipeline.exec() const raw = await redis.call("JSON.GET", key) - return raw ? JSON.parse(raw) : null + const updated = raw ? JSON.parse(raw) : null + await reconcileDealCategoryIndex({ before: beforeDeal, after: updated }) + await reconcileDealSellerIndex({ before: beforeDeal, after: updated }) + return updated } catch { return null } finally {} @@ -315,6 +326,8 @@ async function setDealInRedis( ttlSeconds, skipDbEnsure: skipAnalyticsInit, }) + await reconcileDealCategoryIndex({ before: null, after: payload }) + await reconcileDealSellerIndex({ before: null, after: payload }) return payload } catch { return payload diff --git a/services/redis/dealIndexing.service.js b/services/redis/dealIndexing.service.js index 25299c5..99ea764 100644 --- a/services/redis/dealIndexing.service.js +++ b/services/redis/dealIndexing.service.js @@ -6,6 +6,8 @@ const { getRedisClient } = require("./client") const { setUsersPublicInRedis } = require("./userPublicCache.service") const { setBadgesInRedis } = require("./badgeCache.service") const badgeDb = require("../../db/badge.db") +const { addDealToCategoryIndexInPipeline } = require("./categoryDealIndex.service") +const { addDealToSellerIndexInPipeline } = require("./sellerDealIndex.service") const DEAL_KEY_PREFIX = "deals:cache:" const DEAL_ANALYTICS_TOTAL_PREFIX = "deals:analytics:total:" @@ -219,11 +221,32 @@ async function seedRecentDealsToRedis({ days = 30, ttlDays = 31, batchSize = 200 for (const deal of chunk) { try { + const mapped = mapDealToRedisJson(deal) const key = `${DEAL_KEY_PREFIX}${deal.id}` - const payload = JSON.stringify(mapDealToRedisJson(deal)) + const payload = JSON.stringify(mapped) pipeline.call("JSON.SET", key, "$", payload, "NX") setCommands.push({ deal, index: cmdIndex }) cmdIndex += 1 + if ( + addDealToCategoryIndexInPipeline(pipeline, { + dealId: deal.id, + categoryId: mapped.categoryId, + createdAtTs: mapped.createdAtTs, + status: mapped.status, + }) + ) { + cmdIndex += 1 + } + if ( + addDealToSellerIndexInPipeline(pipeline, { + dealId: deal.id, + sellerId: mapped.sellerId, + createdAtTs: mapped.createdAtTs, + status: mapped.status, + }) + ) { + cmdIndex += 1 + } const totals = totalsById.get(deal.id) || { impressions: 0, views: 0, clicks: 0 } pipeline.hset( `${DEAL_ANALYTICS_TOTAL_PREFIX}${deal.id}`, diff --git a/services/redis/linkPreviewImageCache.service.js b/services/redis/linkPreviewImageCache.service.js index 9bee397..4da48c5 100644 --- a/services/redis/linkPreviewImageCache.service.js +++ b/services/redis/linkPreviewImageCache.service.js @@ -4,7 +4,13 @@ const { getRedisClient } = require("./client") const IMAGE_KEY_PREFIX = "cache:deal_create:image:" const DEFAULT_TTL_SECONDS = 5 * 60 -const MAX_IMAGE_BYTES = 2 * 1024 * 1024 +const DEFAULT_MAX_IMAGE_BYTES = 8 * 1024 * 1024 + +function getMaxImageBytes() { + const raw = Number(process.env.LINK_PREVIEW_MAX_IMAGE_BYTES) + if (!Number.isFinite(raw) || raw < 256 * 1024) return DEFAULT_MAX_IMAGE_BYTES + return Math.floor(raw) +} function createRedisClient() { return getRedisClient() @@ -32,6 +38,7 @@ function buildKey(normalizedUrl) { async function cacheImageFromUrl(rawUrl, { ttlSeconds = DEFAULT_TTL_SECONDS } = {}) { const normalized = normalizeUrl(rawUrl) if (!normalized) return null + const maxImageBytes = getMaxImageBytes() const key = buildKey(normalized) const redis = createRedisClient() @@ -43,8 +50,8 @@ async function cacheImageFromUrl(rawUrl, { ttlSeconds = DEFAULT_TTL_SECONDS } = const response = await axios.get(normalized, { responseType: "arraybuffer", timeout: 15000, - maxContentLength: MAX_IMAGE_BYTES, - maxBodyLength: MAX_IMAGE_BYTES, + maxContentLength: maxImageBytes, + maxBodyLength: maxImageBytes, validateStatus: (status) => status >= 200 && status < 300, }) @@ -52,7 +59,7 @@ async function cacheImageFromUrl(rawUrl, { ttlSeconds = DEFAULT_TTL_SECONDS } = if (!contentType.startsWith("image/")) return null const buffer = Buffer.from(response.data || []) - if (!buffer.length || buffer.length > MAX_IMAGE_BYTES) return null + if (!buffer.length || buffer.length > maxImageBytes) return null const payload = JSON.stringify({ ct: contentType, diff --git a/services/redis/scraperRpc.service.js b/services/redis/scraperRpc.service.js new file mode 100644 index 0000000..a58436f --- /dev/null +++ b/services/redis/scraperRpc.service.js @@ -0,0 +1,97 @@ +const Redis = require("ioredis") +const { randomUUID } = require("crypto") + +const { getRedisClient } = require("./client") +const { getRedisConnectionOptions } = require("./connection") + +const DEFAULT_QUEUE_KEY = "scraper:requests" +const DEFAULT_RESPONSE_CHANNEL_PREFIX = "scraper:response:" +const DEFAULT_TIMEOUT_MS = 20000 + +function normalizeTimeoutMs(value) { + const num = Number(value) + if (!Number.isFinite(num) || num < 1000) return DEFAULT_TIMEOUT_MS + return Math.floor(num) +} + +async function requestProductPreviewOverRedis(url, options = {}) { + if (!url) throw new Error("url parametresi zorunlu") + + const queueKey = process.env.SCRAPER_QUEUE_KEY || DEFAULT_QUEUE_KEY + const responsePrefix = + process.env.SCRAPER_RESPONSE_CHANNEL_PREFIX || DEFAULT_RESPONSE_CHANNEL_PREFIX + const timeoutMs = normalizeTimeoutMs( + options.timeoutMs ?? process.env.SCRAPER_RPC_TIMEOUT_MS + ) + + const requestId = randomUUID() + const responseChannel = `${responsePrefix}${requestId}` + const redis = getRedisClient() + const subscriber = new Redis(getRedisConnectionOptions()) + + const requestPayload = { + requestId, + type: "PRODUCT_PREVIEW", + url: String(url), + createdAt: new Date().toISOString(), + } + + let timeoutHandle = null + let settled = false + + function finish() { + if (timeoutHandle) { + clearTimeout(timeoutHandle) + timeoutHandle = null + } + subscriber.removeAllListeners("message") + subscriber + .unsubscribe(responseChannel) + .catch(() => {}) + .finally(() => subscriber.disconnect()) + } + + return new Promise(async (resolve, reject) => { + const safeResolve = (data) => { + if (settled) return + settled = true + finish() + resolve(data) + } + const safeReject = (err) => { + if (settled) return + settled = true + finish() + reject(err) + } + + try { + subscriber.on("message", (channel, rawMessage) => { + if (channel !== responseChannel) return + try { + const parsed = JSON.parse(String(rawMessage || "{}")) + if (parsed?.error) { + return safeReject(new Error(String(parsed.error))) + } + if (parsed && typeof parsed === "object" && parsed.product) { + return safeResolve(parsed.product) + } + return safeResolve(parsed) + } catch { + return safeReject(new Error("Scraper yaniti parse edilemedi")) + } + }) + + await subscriber.subscribe(responseChannel) + await redis.rpush(queueKey, JSON.stringify(requestPayload)) + + timeoutHandle = setTimeout(() => { + safeReject(new Error("Scraper yaniti zaman asimina ugradi")) + }, timeoutMs) + } catch (err) { + safeReject(err instanceof Error ? err : new Error("Scraper RPC hatasi")) + } + }) +} + +module.exports = { requestProductPreviewOverRedis } diff --git a/services/redis/sellerDealIndex.service.js b/services/redis/sellerDealIndex.service.js new file mode 100644 index 0000000..e238771 --- /dev/null +++ b/services/redis/sellerDealIndex.service.js @@ -0,0 +1,157 @@ +const { getRedisClient } = require("./client") + +const SELLER_DEAL_INDEX_KEY_PREFIX = "index:seller:" +const SELLER_DEAL_INDEX_KEY_SUFFIX = ":deals" + +function normalizePositiveInt(value) { + const num = Number(value) + if (!Number.isInteger(num) || num <= 0) return null + return num +} + +function normalizeEpochMs(value) { + const num = Number(value) + if (!Number.isFinite(num) || num <= 0) return null + return Math.floor(num) +} + +function toEpochMs(value) { + if (value instanceof Date) return value.getTime() + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + return date.getTime() +} + +function isActiveStatus(status) { + return String(status || "").toUpperCase() === "ACTIVE" +} + +function getSellerDealIndexKey(sellerId) { + const sid = normalizePositiveInt(sellerId) + if (!sid) return null + return `${SELLER_DEAL_INDEX_KEY_PREFIX}${sid}${SELLER_DEAL_INDEX_KEY_SUFFIX}` +} + +function normalizeDealIndexPayload(payload = {}) { + const dealId = normalizePositiveInt(payload.dealId ?? payload.id) + const sellerId = normalizePositiveInt(payload.sellerId) + const createdAtTs = + normalizeEpochMs(payload.createdAtTs) ?? normalizeEpochMs(toEpochMs(payload.createdAt)) + const status = String(payload.status || "").toUpperCase() + return { dealId, sellerId, createdAtTs, status } +} + +function isIndexableDeal(payload = {}) { + const normalized = normalizeDealIndexPayload(payload) + return Boolean( + normalized.dealId && + normalized.sellerId && + normalized.createdAtTs && + isActiveStatus(normalized.status) + ) +} + +function addDealToSellerIndexInPipeline(pipeline, payload = {}) { + const normalized = normalizeDealIndexPayload(payload) + if (!normalized.dealId || !normalized.sellerId || !normalized.createdAtTs) return false + if (!isActiveStatus(normalized.status)) return false + const key = getSellerDealIndexKey(normalized.sellerId) + if (!key) return false + pipeline.zadd(key, String(normalized.createdAtTs), String(normalized.dealId)) + return true +} + +function removeDealFromSellerIndexInPipeline(pipeline, payload = {}) { + const normalized = normalizeDealIndexPayload(payload) + if (!normalized.dealId || !normalized.sellerId) return false + const key = getSellerDealIndexKey(normalized.sellerId) + if (!key) return false + pipeline.zrem(key, String(normalized.dealId)) + return true +} + +async function reconcileDealSellerIndex({ before = null, after = null } = {}) { + const prev = normalizeDealIndexPayload(before || {}) + const next = normalizeDealIndexPayload(after || {}) + + const prevIndexable = isIndexableDeal(prev) + const nextIndexable = isIndexableDeal(next) + + const redis = getRedisClient() + const pipeline = redis.pipeline() + let commandCount = 0 + + if (prevIndexable) { + const removedForStatus = !nextIndexable + const movedSeller = nextIndexable && prev.sellerId !== next.sellerId + if (removedForStatus || movedSeller) { + if (removeDealFromSellerIndexInPipeline(pipeline, prev)) commandCount += 1 + } + } + + if (nextIndexable) { + const isNew = !prevIndexable + const movedSeller = prevIndexable && prev.sellerId !== next.sellerId + const scoreChanged = + prevIndexable && + prev.sellerId === next.sellerId && + Number(prev.createdAtTs) !== Number(next.createdAtTs) + if (isNew || movedSeller || scoreChanged) { + if (addDealToSellerIndexInPipeline(pipeline, next)) commandCount += 1 + } + } + + if (!commandCount) return 0 + try { + await pipeline.exec() + return commandCount + } catch { + return 0 + } +} + +async function getRecentDealIdsBySeller({ + sellerId, + offset = 0, + limit = 30, +} = {}) { + const sid = normalizePositiveInt(sellerId) + if (!sid) return [] + const key = getSellerDealIndexKey(sid) + if (!key) return [] + const safeOffset = Math.max(0, Number(offset) || 0) + const safeLimit = Math.max(1, Math.min(Number(limit) || 30, 300)) + const redis = getRedisClient() + + try { + const ids = await redis.zrevrange(key, safeOffset, safeOffset + safeLimit - 1) + return Array.isArray(ids) + ? ids.map((id) => Number(id)).filter((id) => Number.isInteger(id) && id > 0) + : [] + } catch { + return [] + } +} + +async function getSellerDealIndexCount(sellerId) { + const sid = normalizePositiveInt(sellerId) + if (!sid) return 0 + const key = getSellerDealIndexKey(sid) + if (!key) return 0 + const redis = getRedisClient() + try { + const count = await redis.zcard(key) + return Number.isFinite(Number(count)) ? Number(count) : 0 + } catch { + return 0 + } +} + +module.exports = { + getSellerDealIndexKey, + addDealToSellerIndexInPipeline, + removeDealFromSellerIndexInPipeline, + reconcileDealSellerIndex, + getRecentDealIdsBySeller, + getSellerDealIndexCount, +} diff --git a/services/seller.service.js b/services/seller.service.js index df03df7..cec0448 100644 --- a/services/seller.service.js +++ b/services/seller.service.js @@ -1,11 +1,95 @@ // services/seller/sellerService.js const { findSeller, findSellers } = require("../db/seller.db") const dealService = require("./deal.service") +const { listSellersFromRedis, setSellerInRedis, setSellersInRedis } = require("./redis/sellerCache.service") +const { getRecentDealIdsBySeller, getSellerDealIndexCount } = require("./redis/sellerDealIndex.service") +const { getDealsByIdsFromRedis } = require("./redis/hotDealList.service") + +const DEFAULT_LIMIT = 10 +const MAX_LIMIT = 50 function normalizeSellerName(value) { return String(value || "").trim() } +function normalizeSeller(seller = {}) { + const id = Number(seller.id) + if (!Number.isInteger(id) || id <= 0) return null + return { + id, + name: String(seller.name || "").trim(), + url: seller.url ?? null, + sellerLogo: seller.sellerLogo ?? null, + isActive: seller.isActive !== undefined ? Boolean(seller.isActive) : true, + } +} + +async function listSellersCached() { + let sellers = await listSellersFromRedis() + if (sellers.length) return sellers + + sellers = await findSellers( + {}, + { select: { id: true, name: true, url: true, sellerLogo: true, isActive: true }, orderBy: { name: "asc" } } + ) + if (sellers.length) { + await setSellersInRedis(sellers) + } + return sellers +} + +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 + return { page: normalizedPage, limit: normalizedLimit, skip } +} + +function normalizeDealCardFromRedis(deal = {}) { + return { + ...deal, + id: Number(deal.id), + score: Number.isFinite(deal.score) ? deal.score : 0, + commentCount: Number.isFinite(deal.commentCount) ? deal.commentCount : 0, + price: deal.price ?? null, + originalPrice: deal.originalPrice ?? null, + shippingPrice: deal.shippingPrice ?? null, + discountValue: deal.discountValue ?? null, + } +} + +function hasSellerFilters(filters = {}) { + if (!filters || typeof filters !== "object") return false + const keys = [ + "status", + "categoryId", + "categoryIds", + "saleType", + "affiliateType", + "minPrice", + "maxPrice", + "priceMin", + "priceMax", + "minScore", + "maxScore", + "sortBy", + "sortDir", + "createdAfter", + "createdBefore", + "from", + "to", + "hasImage", + ] + + return keys.some((key) => { + const value = filters[key] + return value !== undefined && value !== null && String(value).trim() !== "" + }) +} + async function getSellerByName(name) { const normalized = normalizeSellerName(name) if (!normalized) { @@ -14,10 +98,28 @@ async function getSellerByName(name) { throw err } - return findSeller( + const sellers = await listSellersCached() + const lower = normalized.toLowerCase() + const cached = sellers + .map(normalizeSeller) + .filter(Boolean) + .find((seller) => seller.name.toLowerCase() === lower) + + if (cached) { + return { id: cached.id, name: cached.name, url: cached.url, sellerLogo: cached.sellerLogo } + } + + const seller = await findSeller( { name: { equals: normalized, mode: "insensitive" } }, - { select: { id: true, name: true, url: true, sellerLogo: true } } + { select: { id: true, name: true, url: true, sellerLogo: true, isActive: true } } ) + + if (seller) { + await setSellerInRedis(seller) + return { id: seller.id, name: seller.name, url: seller.url, sellerLogo: seller.sellerLogo } + } + + return null } async function getDealsBySellerName(name, { page = 1, limit = 10, filters = {}, viewer = null, scope = "USER" } = {}) { @@ -28,9 +130,65 @@ async function getDealsBySellerName(name, { page = 1, limit = 10, filters = {}, throw err } + const searchTerm = String(filters?.q || "").trim() + const useSellerIndex = !searchTerm && !hasSellerFilters(filters) + + if (useSellerIndex) { + const pagination = clampPagination({ page, limit }) + const [total, ids] = await Promise.all([ + getSellerDealIndexCount(seller.id), + getRecentDealIdsBySeller({ + sellerId: seller.id, + offset: pagination.skip, + limit: pagination.limit, + }), + ]) + + if (!total) { + return { + seller, + payload: { + page: pagination.page, + total: 0, + totalPages: 0, + results: [], + }, + } + } + + if (!ids.length) { + return { + seller, + payload: { + page: pagination.page, + total, + totalPages: Math.ceil(total / pagination.limit), + results: [], + }, + } + } + + const viewerId = viewer?.userId ? Number(viewer.userId) : null + const deals = await getDealsByIdsFromRedis(ids, viewerId) + if (deals.length === ids.length) { + const activeDeals = deals.filter((deal) => String(deal?.status || "").toUpperCase() === "ACTIVE") + if (activeDeals.length === ids.length) { + return { + seller, + payload: { + page: pagination.page, + total, + totalPages: Math.ceil(total / pagination.limit), + results: activeDeals.map(normalizeDealCardFromRedis), + }, + } + } + } + } + const payload = await dealService.getDeals({ preset: "NEW", - q: filters?.q, + q: searchTerm || undefined, page, limit, viewer, @@ -44,10 +202,15 @@ async function getDealsBySellerName(name, { page = 1, limit = 10, filters = {}, } async function getActiveSellers() { - return findSellers( - { isActive: true }, - { select: { name: true, sellerLogo: true }, orderBy: { name: "asc" } } - ) + const sellers = await listSellersCached() + return sellers + .map(normalizeSeller) + .filter((seller) => seller && seller.isActive) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((seller) => ({ + name: seller.name, + sellerLogo: seller.sellerLogo, + })) } module.exports = { diff --git a/services/supabaseUpload.service.js b/services/supabaseUpload.service.js index 998ce88..2cb904a 100644 --- a/services/supabaseUpload.service.js +++ b/services/supabaseUpload.service.js @@ -1,39 +1,3 @@ -const { createClient } = require("@supabase/supabase-js") - -const supabase = createClient( - process.env.SUPABASE_URL, - process.env.SUPABASE_KEY -) - -/** - * @param {Object} options - * @param {string} options.bucket - supabase bucket adı - * @param {string} options.path - storage içi path - * @param {Buffer} options.fileBuffer - file buffer - * @param {string} options.contentType - */ -async function uploadImage({ - bucket, - path, - fileBuffer, - contentType, -}) { - const { error } = await supabase.storage - .from(bucket) - .upload(path, fileBuffer, { - contentType, - upsert: true, - }) - - if (error) { - throw new Error(error.message) - } - - const { data } = supabase.storage - .from(bucket) - .getPublicUrl(path) - - return data.publicUrl -} +const { uploadImage } = require("./uploadImage.service") module.exports = { uploadImage } diff --git a/services/uploadImage.service.js b/services/uploadImage.service.js index a3077fa..2c94ec6 100644 --- a/services/uploadImage.service.js +++ b/services/uploadImage.service.js @@ -1,22 +1,58 @@ -const { createClient } = require("@supabase/supabase-js") +const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3") -const supabase = createClient( - process.env.SUPABASE_URL, - process.env.SUPABASE_KEY -) +function createR2Client() { + const endpoint = process.env.R2_ENDPOINT + const accessKeyId = process.env.R2_ACCESS_KEY_ID + const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY + + if (!endpoint || !accessKeyId || !secretAccessKey) { + throw new Error("R2 config missing (R2_ENDPOINT/R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY)") + } + + return new S3Client({ + region: process.env.R2_REGION || "auto", + endpoint, + credentials: { accessKeyId, secretAccessKey }, + }) +} + +let r2Client = null + +function getR2Client() { + if (!r2Client) r2Client = createR2Client() + return r2Client +} + +function encodeObjectKey(key) { + return String(key) + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/") +} + +function toStoredPath(key) { + const normalized = String(key || "").replace(/^\/+/, "") + return `/${encodeObjectKey(normalized)}` +} async function uploadImage({ bucket, path, fileBuffer, contentType }) { - const { error } = await supabase.storage - .from(bucket) - .upload(path, fileBuffer, { - contentType, - upsert: true, + if (!path || !fileBuffer) throw new Error("uploadImage params missing") + + const targetBucket = bucket || process.env.R2_BUCKET_NAME + if (!targetBucket) throw new Error("R2 bucket missing (R2_BUCKET_NAME)") + + const r2 = getR2Client() + + await r2.send( + new PutObjectCommand({ + Bucket: targetBucket, + Key: path, + Body: fileBuffer, + ContentType: contentType || "image/webp", }) + ) - if (error) throw new Error(error.message) - - const { data } = supabase.storage.from(bucket).getPublicUrl(path) - return data.publicUrl + return toStoredPath(path) } module.exports = { uploadImage } diff --git a/services/userInterest.service.js b/services/userInterest.service.js new file mode 100644 index 0000000..18404bd --- /dev/null +++ b/services/userInterest.service.js @@ -0,0 +1,204 @@ +const { getRedisClient } = require("./redis/client") +const userInterestProfileDb = require("../db/userInterestProfile.db") + +const USER_INTEREST_INCREMENT_HASH_KEY = "bull:dbsync:userInterestIncrements" +const USER_INTEREST_INCREMENT_HASH_KEY_PREFIX = `${USER_INTEREST_INCREMENT_HASH_KEY}:` +const DAILY_USER_KEY_PREFIX = "users:interest:daily:" + +const USER_INTEREST_ACTIONS = Object.freeze({ + CATEGORY_VISIT: "CATEGORY_VISIT", + DEAL_VIEW: "DEAL_VIEW", + DEAL_CLICK: "DEAL_CLICK", + DEAL_HOT_VOTE: "DEAL_HOT_VOTE", + DEAL_SAVE: "DEAL_SAVE", + COMMENT_CREATE: "COMMENT_CREATE", +}) + +const ACTION_POINTS = Object.freeze({ + [USER_INTEREST_ACTIONS.CATEGORY_VISIT]: 1, + [USER_INTEREST_ACTIONS.DEAL_VIEW]: 2, + [USER_INTEREST_ACTIONS.DEAL_CLICK]: 12, + [USER_INTEREST_ACTIONS.DEAL_HOT_VOTE]: 5, + [USER_INTEREST_ACTIONS.DEAL_SAVE]: 8, + [USER_INTEREST_ACTIONS.COMMENT_CREATE]: 4, +}) + +const DEFAULT_DAILY_CAP = Number(process.env.USER_INTEREST_DAILY_CATEGORY_CAP) || 50 +const DEFAULT_TTL_SECONDS = Number(process.env.USER_INTEREST_DAILY_TTL_SECONDS) || 24 * 60 * 60 +const DEFAULT_FULL_LIMIT = Number(process.env.USER_INTEREST_ACTION_FULL_LIMIT) || 5 +const DEFAULT_HALF_LIMIT = Number(process.env.USER_INTEREST_ACTION_HALF_LIMIT) || 10 +const DEFAULT_SATURATION_RATIO = Number(process.env.USER_INTEREST_SATURATION_RATIO) || 0.3 +const DEFAULT_INCREMENT_SHARDS = Math.max( + 1, + Math.min(128, Number(process.env.USER_INTEREST_INCREMENT_SHARDS) || 32) +) + +const APPLY_CAPS_SCRIPT = ` +local actionCount = redis.call("HINCRBY", KEYS[1], ARGV[1], 1) + +local basePoints = tonumber(ARGV[3]) or 0 +local fullLimit = tonumber(ARGV[4]) or 5 +local halfLimit = tonumber(ARGV[5]) or 10 +local ttlSeconds = tonumber(ARGV[6]) or 86400 +local dailyCap = tonumber(ARGV[7]) or 50 + +local awarded = 0 +if actionCount <= fullLimit then + awarded = basePoints +elseif actionCount <= halfLimit then + awarded = math.floor(basePoints / 2) +else + awarded = 0 +end + +if awarded <= 0 then + local ttlNow = redis.call("TTL", KEYS[1]) + if ttlNow < 0 then + redis.call("EXPIRE", KEYS[1], ttlSeconds) + end + return {0, actionCount} +end + +local usedToday = tonumber(redis.call("HGET", KEYS[1], ARGV[2]) or "0") +local remaining = dailyCap - usedToday +if remaining <= 0 then + local ttlNow = redis.call("TTL", KEYS[1]) + if ttlNow < 0 then + redis.call("EXPIRE", KEYS[1], ttlSeconds) + end + return {0, actionCount} +end + +if awarded > remaining then + awarded = remaining +end + +if awarded > 0 then + redis.call("HINCRBY", KEYS[1], ARGV[2], awarded) +end + +local ttlNow = redis.call("TTL", KEYS[1]) +if ttlNow < 0 then + redis.call("EXPIRE", KEYS[1], ttlSeconds) +end + +return {awarded, actionCount} +` + +function normalizePositiveInt(value) { + const num = Number(value) + if (!Number.isInteger(num) || num <= 0) return null + return num +} + +function normalizeAction(action) { + const normalized = String(action || "").trim().toUpperCase() + if (!ACTION_POINTS[normalized]) return null + return normalized +} + +function buildDailyUserKey(userId) { + return `${DAILY_USER_KEY_PREFIX}${userId}` +} + +function buildDailyCategoryScoreField(categoryId) { + return `cat:${categoryId}:score` +} + +function buildDailyActionField(categoryId, action) { + return `cat:${categoryId}:act:${action}` +} + +async function applyRedisCaps({ redis, userId, categoryId, action, basePoints }) { + const userKey = buildDailyUserKey(userId) + const actionField = buildDailyActionField(categoryId, action) + const categoryScoreField = buildDailyCategoryScoreField(categoryId) + const result = await redis.eval( + APPLY_CAPS_SCRIPT, + 1, + userKey, + actionField, + categoryScoreField, + String(basePoints), + String(DEFAULT_FULL_LIMIT), + String(DEFAULT_HALF_LIMIT), + String(DEFAULT_TTL_SECONDS), + String(DEFAULT_DAILY_CAP) + ) + + const awarded = Number(Array.isArray(result) ? result[0] : 0) + return Number.isFinite(awarded) && awarded > 0 ? Math.floor(awarded) : 0 +} + +async function queueIncrement({ redis, userId, categoryId, points }) { + const field = `${userId}:${categoryId}` + const key = getUserInterestIncrementHashKeyByUserId(userId) + await redis.hincrby(key, field, points) +} + +async function persistFallback({ userId, categoryId, points }) { + return userInterestProfileDb.applyInterestIncrementsBatch( + [{ userId, categoryId, points }], + { saturationRatio: DEFAULT_SATURATION_RATIO } + ) +} + +async function trackUserCategoryInterest({ userId, categoryId, action }) { + const uid = normalizePositiveInt(userId) + const cid = normalizePositiveInt(categoryId) + const normalizedAction = normalizeAction(action) + if (!uid || !cid || !normalizedAction) return { awarded: 0, queued: false } + + const basePoints = Number(ACTION_POINTS[normalizedAction] || 0) + if (!Number.isInteger(basePoints) || basePoints <= 0) return { awarded: 0, queued: false } + + const redis = getRedisClient() + try { + const awarded = await applyRedisCaps({ + redis, + userId: uid, + categoryId: cid, + action: normalizedAction, + basePoints, + }) + if (!awarded) return { awarded: 0, queued: false } + + await queueIncrement({ + redis, + userId: uid, + categoryId: cid, + points: awarded, + }) + return { awarded, queued: true } + } catch (err) { + try { + await persistFallback({ userId: uid, categoryId: cid, points: basePoints }) + return { awarded: basePoints, queued: false, fallback: true } + } catch { + return { awarded: 0, queued: false, fallback: false } + } + } +} + +function getUserInterestIncrementHashKeyByUserId(userId) { + const uid = normalizePositiveInt(userId) + if (!uid || DEFAULT_INCREMENT_SHARDS <= 1) return USER_INTEREST_INCREMENT_HASH_KEY + const shard = uid % DEFAULT_INCREMENT_SHARDS + return `${USER_INTEREST_INCREMENT_HASH_KEY_PREFIX}${shard}` +} + +function getUserInterestIncrementHashKeys() { + if (DEFAULT_INCREMENT_SHARDS <= 1) return [USER_INTEREST_INCREMENT_HASH_KEY] + const keys = [USER_INTEREST_INCREMENT_HASH_KEY] + for (let shard = 0; shard < DEFAULT_INCREMENT_SHARDS; shard += 1) { + keys.push(`${USER_INTEREST_INCREMENT_HASH_KEY_PREFIX}${shard}`) + } + return keys +} + +module.exports = { + USER_INTEREST_ACTIONS, + USER_INTEREST_INCREMENT_HASH_KEY, + getUserInterestIncrementHashKeys, + trackUserCategoryInterest, +} diff --git a/services/vote.service.js b/services/vote.service.js index 379b977..f95a0fd 100644 --- a/services/vote.service.js +++ b/services/vote.service.js @@ -2,6 +2,8 @@ const { updateDealVoteInRedis } = require("./redis/dealVote.service"); const { queueVoteUpdate, queueDealUpdate, queueNotificationCreate } = require("./redis/dbSync.service"); const { updateDealInRedis, getDealFromRedis } = require("./redis/dealCache.service"); const { publishNotification } = require("./redis/notificationPubsub.service"); +const { trackUserCategoryInterest, USER_INTEREST_ACTIONS } = require("./userInterest.service"); +const voteDb = require("../db/vote.db") async function voteDeal({ dealId, userId, voteType }) { if (!dealId || !userId || voteType === undefined) { @@ -60,12 +62,20 @@ async function voteDeal({ dealId, userId, voteType }) { userId: ownerId, message: "Fırsatın 100 beğeniyi geçti!", type: "MILESTONE", + extras: { + dealId: Number(dealId), + milestone, + }, createdAt: updatedAt.toISOString(), }).catch((err) => console.error("DB sync notification queue failed:", err?.message || err)) publishNotification({ userId: ownerId, message: "Fırsatın 100 beğeniyi geçti!", type: "MILESTONE", + extras: { + dealId: Number(dealId), + milestone, + }, createdAt: updatedAt.toISOString(), }).catch((err) => console.error("Notification publish failed:", err?.message || err)) } @@ -80,6 +90,14 @@ async function voteDeal({ dealId, userId, voteType }) { delta = 0; } + if (Number(voteType) === 1) { + trackUserCategoryInterest({ + userId, + categoryId: deal.categoryId, + action: USER_INTEREST_ACTIONS.DEAL_HOT_VOTE, + }).catch((err) => console.error("User interest track failed:", err?.message || err)) + } + return { dealId, voteType, diff --git a/utils/inputSanitizer.js b/utils/inputSanitizer.js new file mode 100644 index 0000000..759dfa9 --- /dev/null +++ b/utils/inputSanitizer.js @@ -0,0 +1,158 @@ +const { toSafeRedirectUrl } = require("./urlSafety") + +const ALLOWED_DESCRIPTION_TAGS = new Set(["p", "strong", "ul", "ol", "li", "img", "br"]) +const SELF_CLOSING_DESCRIPTION_TAGS = new Set(["img", "br"]) + +function stripControlChars(value) { + return String(value || "").replace(/[\u0000-\u001F\u007F]/g, "") +} + +function stripHtmlTags(value) { + return String(value || "") + .replace(//g, "") + .replace(/<\/?[^>]+>/g, "") +} + +function escapeHtml(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") +} + +function escapeHtmlAttribute(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">") +} + +function sanitizeOptionalPlainText(value, { maxLength } = {}) { + if (value === undefined || value === null) return null + + let normalized = stripControlChars(value) + normalized = stripHtmlTags(normalized).trim() + if (!normalized) return null + if (Number.isInteger(maxLength) && maxLength > 0 && normalized.length > maxLength) { + normalized = normalized.slice(0, maxLength) + } + return normalized +} + +function sanitizeRequiredPlainText(value, { fieldName = "field", maxLength } = {}) { + const normalized = sanitizeOptionalPlainText(value, { maxLength }) + if (!normalized) { + const err = new Error(`${fieldName}_REQUIRED`) + err.statusCode = 400 + throw err + } + return normalized +} + +function sanitizeImageSrc(value) { + const trimmed = stripControlChars(value).trim() + if (!trimmed) return null + + const lower = trimmed.toLowerCase() + if ( + lower.startsWith("javascript:") || + lower.startsWith("data:") || + lower.startsWith("vbscript:") + ) { + return null + } + + const isHttp = lower.startsWith("http://") || lower.startsWith("https://") + const isProtocolRelative = lower.startsWith("//") + const isRootRelative = trimmed.startsWith("/") + if (!isHttp && !isProtocolRelative && !isRootRelative) return null + + return toSafeRedirectUrl(trimmed) +} + +function sanitizeImgTagAttributes(rawAttrs = "") { + const attrs = {} + const attrRegex = /([a-zA-Z0-9:-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g + let match + + while ((match = attrRegex.exec(rawAttrs)) !== null) { + const name = String(match[1] || "").toLowerCase() + const rawValue = match[3] ?? match[4] ?? match[5] ?? "" + if (name === "src") { + const safeSrc = sanitizeImageSrc(rawValue) + if (safeSrc) attrs.src = safeSrc + continue + } + if (name === "alt" || name === "title") { + const safeText = sanitizeOptionalPlainText(rawValue, { maxLength: 300 }) + if (safeText) attrs[name] = safeText + } + } + + return attrs +} + +function sanitizeDealDescriptionHtml(value) { + if (value === undefined || value === null) return null + + const raw = stripControlChars(value).trim() + if (!raw) return null + + const tagRegex = + /<\/?([a-zA-Z0-9]+)((?:\s+[a-zA-Z0-9:-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/g + const stack = [] + let output = "" + let cursor = 0 + let match + + while ((match = tagRegex.exec(raw)) !== null) { + const [fullTag, rawTagName, rawAttrs = ""] = match + const tagName = String(rawTagName || "").toLowerCase() + const isClosing = fullTag.startsWith("= idx; i -= 1) { + output += `` + } + } + } + } else if (tagName === "br") { + output += "
" + } else if (tagName === "img") { + const attrs = sanitizeImgTagAttributes(rawAttrs) + if (attrs.src) { + const altPart = attrs.alt ? ` alt="${escapeHtmlAttribute(attrs.alt)}"` : "" + const titlePart = attrs.title ? ` title="${escapeHtmlAttribute(attrs.title)}"` : "" + output += `` + } + } else { + output += `<${tagName}>` + stack.push(tagName) + } + } + + cursor = tagRegex.lastIndex + } + + output += escapeHtml(raw.slice(cursor)) + + while (stack.length) { + output += `` + } + + const normalized = output.trim() + return normalized || null +} + +module.exports = { + sanitizeOptionalPlainText, + sanitizeRequiredPlainText, + sanitizeDealDescriptionHtml, +} diff --git a/utils/mediaPath.js b/utils/mediaPath.js new file mode 100644 index 0000000..ffe511f --- /dev/null +++ b/utils/mediaPath.js @@ -0,0 +1,39 @@ +function normalizeMediaPath(value) { + if (value === undefined) return undefined + if (value === null) return null + + const raw = String(value).trim() + if (!raw) return null + + const lower = raw.toLowerCase() + if ( + lower.startsWith("javascript:") || + lower.startsWith("data:") || + lower.startsWith("vbscript:") + ) { + return null + } + + try { + if (raw.startsWith("http://") || raw.startsWith("https://")) { + const parsed = new URL(raw) + return parsed.pathname || "/" + } + + if (raw.startsWith("//")) { + const parsed = new URL(`https:${raw}`) + return parsed.pathname || "/" + } + + const domainLike = /^[a-z0-9.-]+\.[a-z]{2,}(?::\d+)?(\/|$)/i.test(raw) + if (domainLike) { + const parsed = new URL(`https://${raw}`) + return parsed.pathname || "/" + } + } catch {} + + if (raw.startsWith("/")) return raw + return `/${raw.replace(/^\/+/, "")}` +} + +module.exports = { normalizeMediaPath } diff --git a/utils/urlSafety.js b/utils/urlSafety.js new file mode 100644 index 0000000..750d21c --- /dev/null +++ b/utils/urlSafety.js @@ -0,0 +1,17 @@ +function toSafeRedirectUrl(rawUrl) { + if (rawUrl === undefined || rawUrl === null) return null + const trimmed = String(rawUrl).trim() + if (!trimmed) return null + + // Header icin tehlikeli kontrol karakterlerini temizle. + const cleaned = trimmed.replace(/[\u0000-\u001F\u007F]/g, "") + if (!cleaned) return null + + try { + return encodeURI(cleaned) + } catch { + return null + } +} + +module.exports = { toSafeRedirectUrl } diff --git a/workers/dbSync.worker.js b/workers/dbSync.worker.js index aa1e6fe..a1d4cb9 100644 --- a/workers/dbSync.worker.js +++ b/workers/dbSync.worker.js @@ -24,12 +24,28 @@ const { const { DEAL_ANALYTICS_TOTAL_HASH_KEY } = require("../services/redis/dealAnalytics.service") const commentLikeDb = require("../db/commentLike.db") const dealAnalyticsDb = require("../db/dealAnalytics.db") +const userInterestProfileDb = require("../db/userInterestProfile.db") const prisma = require("../db/client") +const { getUserInterestIncrementHashKeys } = require("../services/userInterest.service") + +const USER_INTEREST_DB_APPLY_BATCH_SIZE = Math.max( + 100, + Number(process.env.USER_INTEREST_DB_APPLY_BATCH_SIZE) || 2000 +) function createRedisClient() { return new Redis(getRedisConnectionOptions()) } +function normalizeJsonValue(value) { + if (value === undefined || value === null) return null + try { + return JSON.parse(JSON.stringify(value)) + } catch { + return null + } +} + async function consumeUserUpdates(redis) { const data = await redis.eval( "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;", @@ -339,6 +355,57 @@ async function consumeVoteUpdates(redis) { return result?.count ?? batch.length } +async function consumeUserInterestIncrements(redis) { + const hashKeys = getUserInterestIncrementHashKeys() + if (!hashKeys.length) return 0 + + const increments = [] + + for (const hashKey of hashKeys) { + const data = await redis.eval( + "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;", + 1, + hashKey + ) + if (!data || data.length === 0) continue + + for (let i = 0; i < data.length; i += 2) { + try { + const field = String(data[i] || "") + const points = Number(data[i + 1] || 0) + if (!field || !Number.isFinite(points) || points <= 0) continue + const [userIdRaw, categoryIdRaw] = field.split(":") + const userId = Number(userIdRaw) + const categoryId = Number(categoryIdRaw) + if (!Number.isInteger(userId) || userId <= 0) continue + if (!Number.isInteger(categoryId) || categoryId <= 0) continue + increments.push({ + userId, + categoryId, + points: Math.floor(points), + }) + } catch (err) { + console.error("db-sync userInterest parse failed:", err?.message || err) + } + } + } + + if (!increments.length) return 0 + + let updated = 0 + for (let i = 0; i < increments.length; i += USER_INTEREST_DB_APPLY_BATCH_SIZE) { + const chunk = increments.slice(i, i + USER_INTEREST_DB_APPLY_BATCH_SIZE) + try { + const result = await userInterestProfileDb.applyInterestIncrementsBatch(chunk) + updated += Number(result?.updated || 0) + } catch (err) { + console.error("db-sync userInterest batch failed:", err?.message || err) + } + } + + return updated +} + async function consumeCommentLikeUpdates(redis) { const data = await redis.eval( "local data = redis.call('HGETALL', KEYS[1]); redis.call('DEL', KEYS[1]); return data;", @@ -791,6 +858,7 @@ async function consumeNotifications(redis) { userId: Number(parsed.userId), message: String(parsed.message), type: String(parsed.type || "INFO"), + extras: normalizeJsonValue(parsed.extras), createdAt: parsed.createdAt ? new Date(parsed.createdAt) : new Date(), }) } catch (err) { @@ -809,6 +877,7 @@ async function consumeNotifications(redis) { userId: item.userId, message: item.message, type: item.type, + extras: item.extras, createdAt: item.createdAt, }, }) @@ -1012,6 +1081,7 @@ async function handler() { const categoryUpsertCount = await consumeCategoryUpserts(redis) const sellerUpsertCount = await consumeSellerUpserts(redis) const sellerDomainUpsertCount = await consumeSellerDomainUpserts(redis) + const userInterestCount = await consumeUserInterestIncrements(redis) return { votes: voteCount, commentLikes: commentLikeCount, @@ -1031,6 +1101,7 @@ async function handler() { categoryUpserts: categoryUpsertCount, sellerUpserts: sellerUpsertCount, sellerDomainUpserts: sellerDomainUpsertCount, + userInterests: userInterestCount, } } finally { redis.disconnect() @@ -1045,7 +1116,7 @@ function startDbSyncWorker() { worker.on("completed", (job) => { console.log( - `✅ DB sync batch done. Votes: ${job.returnvalue?.votes ?? 0} CommentLikes: ${job.returnvalue?.commentLikes ?? 0} CommentsCreated: ${job.returnvalue?.commentsCreated ?? 0} CommentsDeleted: ${job.returnvalue?.commentsDeleted ?? 0} DealSaves: ${job.returnvalue?.dealSaves ?? 0} DealEvents: ${job.returnvalue?.dealEvents ?? 0} DealCreates: ${job.returnvalue?.dealCreates ?? 0} DealAiReviews: ${job.returnvalue?.dealAiReviews ?? 0} NotificationsRead: ${job.returnvalue?.notificationsRead ?? 0} Notifications: ${job.returnvalue?.notifications ?? 0} DealUpdates: ${job.returnvalue?.dealUpdates ?? 0} Audits: ${job.returnvalue?.audits ?? 0} UserUpdates: ${job.returnvalue?.userUpdates ?? 0} UserNotes: ${job.returnvalue?.userNotes ?? 0} DealReportUpdates: ${job.returnvalue?.dealReportUpdates ?? 0} CategoryUpserts: ${job.returnvalue?.categoryUpserts ?? 0} SellerUpserts: ${job.returnvalue?.sellerUpserts ?? 0} SellerDomainUpserts: ${job.returnvalue?.sellerDomainUpserts ?? 0}` + `DB sync batch done. Votes: ${job.returnvalue?.votes ?? 0} CommentLikes: ${job.returnvalue?.commentLikes ?? 0} CommentsCreated: ${job.returnvalue?.commentsCreated ?? 0} CommentsDeleted: ${job.returnvalue?.commentsDeleted ?? 0} DealSaves: ${job.returnvalue?.dealSaves ?? 0} DealEvents: ${job.returnvalue?.dealEvents ?? 0} DealCreates: ${job.returnvalue?.dealCreates ?? 0} DealAiReviews: ${job.returnvalue?.dealAiReviews ?? 0} NotificationsRead: ${job.returnvalue?.notificationsRead ?? 0} Notifications: ${job.returnvalue?.notifications ?? 0} DealUpdates: ${job.returnvalue?.dealUpdates ?? 0} Audits: ${job.returnvalue?.audits ?? 0} UserUpdates: ${job.returnvalue?.userUpdates ?? 0} UserNotes: ${job.returnvalue?.userNotes ?? 0} DealReportUpdates: ${job.returnvalue?.dealReportUpdates ?? 0} CategoryUpserts: ${job.returnvalue?.categoryUpserts ?? 0} SellerUpserts: ${job.returnvalue?.sellerUpserts ?? 0} SellerDomainUpserts: ${job.returnvalue?.sellerDomainUpserts ?? 0} UserInterests: ${job.returnvalue?.userInterests ?? 0}` ) }) @@ -1057,3 +1128,4 @@ function startDbSyncWorker() { } module.exports = { startDbSyncWorker } +