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("")
+
+ output += escapeHtml(raw.slice(cursor, match.index))
+
+ if (ALLOWED_DESCRIPTION_TAGS.has(tagName)) {
+ if (isClosing) {
+ if (!SELF_CLOSING_DESCRIPTION_TAGS.has(tagName)) {
+ const idx = stack.lastIndexOf(tagName)
+ if (idx !== -1) {
+ for (let i = stack.length - 1; i >= idx; i -= 1) {
+ output += `${stack.pop()}>`
+ }
+ }
+ }
+ } 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 += `${stack.pop()}>`
+ }
+
+ 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 }
+