diff --git a/.env.example b/.env.example index 817f8c2..27f1947 100644 --- a/.env.example +++ b/.env.example @@ -73,4 +73,8 @@ GROQ_RETRY_BASE_DELAY_MS=400 GROQ_RETRY_MAX_DELAY_MS=30000 #------------------------------------------------- WEEKLY_REPORT_CONCURRENCY=2 -WEEKLY_REPORT_BATCH_DELAY_MS=800 \ No newline at end of file +WEEKLY_REPORT_BATCH_DELAY_MS=800 +#------------------------------------------------- +VAPID_PUBLIC=여기에_출력된_Public_Key +VAPID_PRIVATE=여기에_출력된_Private_Key +#------------------------------------------------- \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1dcef2d..e14d388 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules -.env \ No newline at end of file +.env +logs \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0720dc0..ba06b73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "axios": "^1.13.2", "badwords-ko": "^1.0.4", "bcrypt": "^6.0.0", + "bullmq": "^5.67.2", "cors": "^2.8.5", "dayjs": "^1.11.19", "dotenv": "^16.6.1", @@ -20,6 +21,7 @@ "express-session": "^1.18.2", "groq-sdk": "^0.37.0", "http-status-codes": "^2.3.0", + "ioredis": "^5.9.2", "jsonwebtoken": "^9.0.3", "leo-profanity": "^1.8.0", "morgan": "^1.10.0", @@ -36,6 +38,8 @@ "swagger-ui-express": "^5.0.1", "uuid": "^13.0.0", "web-push": "^3.6.7", + "winston": "^3.19.0", + "winston-daily-rotate-file": "^5.0.0", "zod": "^4.3.5" }, "devDependencies": { @@ -988,6 +992,26 @@ "node": ">=18.0.0" } }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1092,12 +1116,96 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2027,6 +2135,16 @@ "node": ">=18.0.0" } }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2105,6 +2223,12 @@ "@types/node": "*" } }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2408,6 +2532,12 @@ "node": ">=0.8" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2618,6 +2748,46 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bullmq": { + "version": "5.67.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.67.2.tgz", + "integrity": "sha512-3KYqNqQptKcgksACO1li4YW9/jxEh6XWa1lUg4OFrHa80Pf0C7H9zeb6ssbQQDfQab/K3QCXopbZ40vrvcyrLw==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.9.2", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.3", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, + "node_modules/bullmq/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/bullmq/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -2835,6 +3005,19 @@ "node": ">=0.10.0" } }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2855,6 +3038,48 @@ "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2973,6 +3198,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3181,6 +3418,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3265,6 +3512,12 @@ "node": ">=14" } }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -3916,6 +4169,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3929,6 +4188,15 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4014,6 +4282,12 @@ "dev": true, "license": "ISC" }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -4624,6 +4898,30 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", + "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4957,6 +5255,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -5236,6 +5546,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, "node_modules/leo-profanity": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/leo-profanity/-/leo-profanity-1.9.0.tgz", @@ -5285,6 +5601,12 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -5298,6 +5620,12 @@ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -5354,6 +5682,23 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -5375,6 +5720,15 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5483,6 +5837,15 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/morgan": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", @@ -5532,6 +5895,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/multer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", @@ -5614,6 +6008,12 @@ "node": ">= 0.6" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", @@ -5690,6 +6090,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/nodemailer": { "version": "7.0.12", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", @@ -5808,6 +6223,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -6042,6 +6466,15 @@ "wrappy": "1" } }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, "node_modules/openapi-types": { "version": "12.1.3", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", @@ -6492,6 +6925,27 @@ "node": ">= 18" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6704,6 +7158,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6989,6 +7452,21 @@ "node": ">=0.10.0" } }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7239,6 +7717,12 @@ "express": ">=4.0.0 || >=5.0.0-beta" } }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -7294,6 +7778,15 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -7720,6 +8213,60 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-daily-rotate-file": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", + "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", + "license": "MIT", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^3.0.0", + "triple-beam": "^1.4.1", + "winston-transport": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index e212c81..7930360 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "axios": "^1.13.2", "badwords-ko": "^1.0.4", "bcrypt": "^6.0.0", + "bullmq": "^5.67.2", "cors": "^2.8.5", "dayjs": "^1.11.19", "dotenv": "^16.6.1", @@ -32,6 +33,7 @@ "express-session": "^1.18.2", "groq-sdk": "^0.37.0", "http-status-codes": "^2.3.0", + "ioredis": "^5.9.2", "jsonwebtoken": "^9.0.3", "leo-profanity": "^1.8.0", "morgan": "^1.10.0", @@ -48,6 +50,8 @@ "swagger-ui-express": "^5.0.1", "uuid": "^13.0.0", "web-push": "^3.6.7", + "winston": "^3.19.0", + "winston-daily-rotate-file": "^5.0.0", "zod": "^4.3.5" }, "devDependencies": { diff --git a/src/configs/db.config.js b/src/configs/db.config.js index 40d17ef..26f57ff 100644 --- a/src/configs/db.config.js +++ b/src/configs/db.config.js @@ -1,5 +1,6 @@ import { PrismaClient } from "@prisma/client"; import { createClient } from "redis"; +import IORedis from "ioredis"; export const prisma = new PrismaClient({ log: ["query", "info", "warn", "error"], @@ -16,4 +17,11 @@ const redisClient = createClient({ redisClient.on('error', err => console.log('Redis Client Error', err)); await redisClient.connect(); -export const redis = redisClient; \ No newline at end of file +export const redis = redisClient; + +export const ioredisConnection = new IORedis({ + host: process.env.REDIS_HOST, + port: process.env.REDIS_PORT, + password: process.env.REDIS_PASSWORD, + maxRetriesPerRequest: null +}) \ No newline at end of file diff --git a/src/configs/logger.config.js b/src/configs/logger.config.js new file mode 100644 index 0000000..4e13392 --- /dev/null +++ b/src/configs/logger.config.js @@ -0,0 +1,53 @@ +import winston from "winston"; +import DailyRotateFile from "winston-daily-rotate-file"; +import path from "path"; + +const logDir = "logs"; + +const logFormat = winston.format.combine( + winston.format.timestamp({format: "YYYY-MM-DD HH:mm:ss"}), + winston.format.printf((info => `${info.timestamp} [${info.level.toUpperCase()}]: ${info.message}`)) +) + +const logLevels = { + error: 0, + warn: 1, + info: 2, + debug: 3, +}; + +const logger = winston.createLogger({ + level: "debug", + levels: logLevels, + format: logFormat, + transports: [ + new DailyRotateFile({ + level: 'error', + datePattern: 'YYYY-MM-DD', + dirname: path.join(logDir, 'error'), + filename: '%DATE%.error.log', + maxFiles: 14, + zippedArchive: true, + handleExceptions: true + }), + new DailyRotateFile({ + level: 'debug', + datePattern: 'YYYY-MM-DD', + dirname: path.join(logDir, 'combined'), + filename: '%DATE%.combined.log', + maxFiles: 7, + zippedArchive: true + }) + ], +}); + +if (process.env.NODE_ENV !== 'production') { + logger.add(new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ), + })); +} + +export default logger; \ No newline at end of file diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index 929ed92..fa0456f 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -176,9 +176,9 @@ export const handleGetAccountInfo = async (req, res, next) => { export const handleResetPassword = async (req, res, next) => { const userId = req.user.id; - const {password} = req.body; + const {oldPassword, newPassword} = req.body; try{ - const result = await resetPassword({userId, password}); + const result = await resetPassword({userId, oldPassword, newPassword}); res.status(200).success(result); } catch(err) { diff --git a/src/controllers/friend.controller.js b/src/controllers/friend.controller.js index 8f54cd1..5303266 100644 --- a/src/controllers/friend.controller.js +++ b/src/controllers/friend.controller.js @@ -65,6 +65,7 @@ export const handleGetIncomingFriendRequests = async (req, res, next) => { export const handleGetOutgoingFriendRequests = async (req, res, next) => { // 보낸 친구 신청 목록 조회 로직 구현 + const userId = req.user.id; userIsNull(userId); try { @@ -78,7 +79,7 @@ export const handleGetOutgoingFriendRequests = async (req, res, next) => { export const handleAcceptFriendRequest = async (req, res, next) => { // 친구 신청 수락 로직 구현 const receiverUserId = req.user.id; - const { requesterUserId } = req.params; + const requesterUserId = Number(req.params.requesterUserId); userIsNull(receiverUserId, requesterUserId); try { const result = await acceptFriendRequest(receiverUserId, requesterUserId); diff --git a/src/controllers/letter.controller.js b/src/controllers/letter.controller.js index 1a7836c..7076ba2 100644 --- a/src/controllers/letter.controller.js +++ b/src/controllers/letter.controller.js @@ -1,4 +1,16 @@ -import { addLetterLike, getLetter, getLetterAssets, getPublicLetterFromFriend, getPublicLetterFromOther, getUserLetterStats, removeLetterLike, sendLetterToMe, sendLetterToOther } from "../services/letter.service.js"; +import { addLetterLike, getLetter, getLetterAssets, getPublicLetterFromFriend, getPublicLetterFromOther, getUserLetterStats, removeLetterLike, sendLetterToMe, sendLetterToOther, getLetterByAiKeyword } from "../services/letter.service.js"; + +export const handleGetLetterByAiKeyword = async (req, res, next) => { + const userId = req.user.id; + const aiKeyword = req.params.aiKeyword; + try { + const result = await getLetterByAiKeyword({userId, aiKeyword}); + + res.status(200).success( result ); + } catch(err) { + next(err); + } +} export const handleGetLetterDetail = async (req, res, next) => { const userId = req.user.id; diff --git a/src/errors/auth.error.js b/src/errors/auth.error.js index 8e5495c..3086964 100644 --- a/src/errors/auth.error.js +++ b/src/errors/auth.error.js @@ -58,4 +58,10 @@ export class InvalidGrantCodeError extends BadRequestError { constructor(code, message, data = null) { super(code, message, data); } +} + +export class PasswordNotFoundError extends NotFoundError { + constructor(code, message, data = null) { + super(code, message, data); + } } \ No newline at end of file diff --git a/src/index.js b/src/index.js index a4c25ed..c99f26b 100644 --- a/src/index.js +++ b/src/index.js @@ -2,15 +2,15 @@ import cors from "cors"; import "dotenv/config"; import express from "express"; -import morgan from "morgan"; import swaggerUi from "swagger-ui-express"; import session from "express-session"; import passport from "passport"; import multer from "multer"; +import httpLogger from "./middlewares/logger.middleware.js"; import { specs } from "./configs/swagger.config.js"; import { jwtStrategy } from "./Auths/strategies/jwt.strategy.js"; import { handleGetFriendsList, handlePostFriendsRequest, handleGetIncomingFriendRequests, handleGetOutgoingFriendRequests, handleAcceptFriendRequest, handleRejectFriendRequest, handleDeleteFriendRequest } from "./controllers/friend.controller.js"; -import { handleSendMyLetter, handleSendOtherLetter, handleGetLetterDetail, handleRemoveLetterLike, handleAddLetterLike, handleGetPublicLetterFromOther, handleGetPublicLetterFromFriend, handleGetUserLetterStats, handleGetLetterAssets } from "./controllers/letter.controller.js"; +import { handleSendMyLetter, handleSendOtherLetter, handleGetLetterDetail, handleRemoveLetterLike, handleAddLetterLike, handleGetPublicLetterFromOther, handleGetPublicLetterFromFriend, handleGetUserLetterStats, handleGetLetterAssets, handleGetLetterByAiKeyword } from "./controllers/letter.controller.js"; import { handleCheckDuplicatedEmail, handleLogin, handleRefreshToken, handleSignUp, handleSendVerifyEmailCode, handleCheckEmailCode, handleGetAccountInfo, handleResetPassword, handleLogout, handleWithdrawUser, handleCheckDuplicatedUsername, handleSocialLogin, handleSocialLoginCertification, handleSocialLoginCallback } from "./controllers/auth.controller.js"; import { handlePostMatchingSession, handlePatchMatchingSessionStatusDiscarded, handlePatchMatchingSessionStatusFriends, handlePostSessionReview } from "./controllers/session.controller.js"; import { handleCreateUserAgreements, handlePatchOnboardingStep1, handleGetAllInterests, handleGetMyInterests, handleUpdateMyOnboardingInterests, handleGetMyNotificationSettings, handleUpdateMyNotificationSettings, handleGetMyProfile, handlePatchMyProfile, handlePostMyProfileImage, handlePutMyPushSubscription, handleGetMyConsents, handlePatchMyConsents, handleUpdateActivity, } from "./controllers/user.controller.js"; @@ -24,7 +24,7 @@ import { emailSchema, loginSchema, passwordSchema, SignUpSchema, usernameSchema, import { handleInsertInquiryAsUser, handleInsertInquiryAsAdmin, handleGetInquiry, handleGetInquiryDetail } from "./controllers/inquiry.controller.js"; import { isLogin } from "./middlewares/auth.middleware.js"; import { isRestricted } from "./middlewares/restriction.middleware.js"; -import { letterToMeSchema, letterToOtherSchema, publicCarouselSchema } from "./schemas/letter.schema.js"; +import { letterByAiKeywordSchema, letterToMeSchema, letterToOtherSchema, publicCarouselSchema } from "./schemas/letter.schema.js"; import { idParamSchema, ISOTimeSchema } from "./schemas/common.schema.js"; import { pushSubscriptionSchema, onboardingStep1Schema, updateInterestsSchema, updateProfileSchema, updateNotificationSettingsSchema, updateConsentsSchema, updateActivitySchema, createUserAgreementsSchema } from "./schemas/user.schema.js"; import { threadIdParamSchema } from "./schemas/mailbox.schema.js"; @@ -40,6 +40,7 @@ import { postBlockUserSchema } from "./schemas/block.schema.js"; import { handleGetBlock, handlePostBlock } from "./controllers/block.controller.js"; import { handleGetRestrict } from "./controllers/restrict.controller.js"; import { configurePush } from "./configs/push.config.js"; +import logger from "./configs/logger.config.js"; const app = express(); const port = process.env.PORT || 3000; @@ -52,9 +53,6 @@ app.use((req, res, next) => { next(); }); -// 미들웨어 설정 -app.use(morgan("dev")); - // app.use(cors({ origin: ["http://localhost:3000"], credentials: true })); app.use( @@ -71,6 +69,9 @@ app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(passport.initialize()); +// 미들웨어 설정 +app.use(httpLogger); + // 로그인 전략 passport.use(jwtStrategy); @@ -169,6 +170,7 @@ app.get("/letter-assets", isLogin, isRestricted, handleGetLetterAssets); app.post("/letter/me", isLogin, isRestricted, validate(letterToMeSchema), handleSendMyLetter); // 나에게 편지 전송 app.post("/letter/other", isLogin, isRestricted, validate(letterToOtherSchema),handleSendOtherLetter); // 타인/친구에게 편지 전송 app.get("/letters/:letterId", isLogin, isRestricted, validate(idParamSchema("letterId")),handleGetLetterDetail); // 편지 상세 조회 +app.get("/letters/keywords/:aiKeyword", isLogin, isRestricted, validate(letterByAiKeywordSchema), asyncHandler(handleGetLetterByAiKeyword)); // AI 키워드로 편지 조회 app.post("/letters/:letterId/like", isLogin, isRestricted, validate(idParamSchema("letterId")), handleAddLetterLike); // 편지 좋아요 추가 app.delete("/letters/:letterId/like", isLogin, isRestricted, validate(idParamSchema("letterId")), handleRemoveLetterLike); // 편지 좋아요 삭제 @@ -224,6 +226,9 @@ app.use((err, req, res, next) => { if (res.headersSent) return next(err); const status = err.status || err.statusCode || 500; + res.locals.errorMessage = err.reason || err.message || "Internal Server Error"; + + if (status >= 500) logger.error(`[Server Error]\n${err.stack}`); return res.status(status).json({ resultType: "FAIL", error: { errorCode: err.errorCode || "COMMON_001", reason: err.reason || err.message || "Internal Server Error", data: err.data || null }, success: null }); }); @@ -232,6 +237,4 @@ app.use((err, req, res, next) => { app.listen(port, async () => { console.log(`Server is running on port ${port}`); startBatch(); -}); - -startBatch(); \ No newline at end of file +}); \ No newline at end of file diff --git a/src/jobs/bootstraps/letter.bootstrap.js b/src/jobs/bootstraps/letter.bootstrap.js index 96408fd..f4517f3 100644 --- a/src/jobs/bootstraps/letter.bootstrap.js +++ b/src/jobs/bootstraps/letter.bootstrap.js @@ -1,12 +1,44 @@ +import { Queue, Worker } from "bullmq"; import { sendReservedLetter } from "../../repositories/letter.repository.js"; import { getDayStartAndEnd } from "../../utils/date.util.js" +import { ioredisConnection } from "../../configs/db.config.js"; +import { sendQueuedLettersByLetterId } from "../../services/letter.service.js"; export const sendScheduledLetters = async () => { const today = new Date(); const { startTime, endTime } = getDayStartAndEnd(today); - const isUpdated = await sendReservedLetter({startTime, endTime}); - if(isUpdated != 0){ - console.log("편지가 발송되었습니다."); + const {count} = await sendReservedLetter({startTime, endTime}); + if(count != 0){ + console.log("[Cron Success] 편지 전송에 성공했습니다."); } +} + +export const letterQueue = new Queue("letter-matching", { connection: ioredisConnection }); + +export const sendQueuedLettersWorker = () => { + const worker = new Worker("letter-matching", async (job) => { + if(job.name === "MATCH_BY_LETTER") { + console.log(`[Job Start] ${job.data.letterId}번 편지 매칭중`); + await sendQueuedLettersByLetterId(job.data); + + console.log(`[Job Success] 편지 전송에 성공했습니다.`); + } + + if(job.name === "MATCH_BY_USER") { + console.log(`[Job Start] ${job.data.userId}번 유저풀에 맞는 편지 검색 중`); + await sendQueuedLettersByLetterId(job.data); + + console.log(`[Job Success] 편지 전송에 성공했습니다.`); + } + + }, {connection: ioredisConnection}); + + worker.on('failed', (job, err) => { + console.error(`[Job Failed] ID: ${job.id} | 에러: ${err.message}`); + }); + + worker.on('error', (err) => { + console.error(`[Worker Error] 시스템 오류: ${err.message}`); + }); } \ No newline at end of file diff --git a/src/jobs/bootstraps/mail.bootstrap.js b/src/jobs/bootstraps/mail.bootstrap.js new file mode 100644 index 0000000..49544ec --- /dev/null +++ b/src/jobs/bootstraps/mail.bootstrap.js @@ -0,0 +1,25 @@ +import { Queue, Worker } from "bullmq"; +import { ioredisConnection } from "../../configs/db.config.js"; +import { transporter } from "../../configs/mailer.config.js"; + +export const mailQueue = new Queue("send-mail", {connection: ioredisConnection}); + +export const sendMailWorker = () => { + const worker = new Worker("send-mail", async (job) => { + if(job.name === "SEND_MAIL_FOR_CODE") { + console.log(`[Job Start] 인증코드 전송 중.`); + + await transporter.sendMail(job.data); + + console.log(`[Job Success] 인증코드 전송에 성공했습니다.`); + } + }, {connection: ioredisConnection}); + + worker.on('failed', (job, err) => { + console.error(`[Job Failed] ID: ${job.id} | 에러: ${err.message}`); + }); + + worker.on('error', (err) => { + console.error(`[Worker Error] 시스템 오류: ${err.message}`); + }); +} \ No newline at end of file diff --git a/src/jobs/bootstraps/push.bootstrap.js b/src/jobs/bootstraps/push.bootstrap.js new file mode 100644 index 0000000..5b60dd7 --- /dev/null +++ b/src/jobs/bootstraps/push.bootstrap.js @@ -0,0 +1,24 @@ +import { Queue, Worker } from "bullmq"; +import { ioredisConnection } from "../../configs/db.config.js"; +import { sendPushNotification } from "../../services/push.service.js"; + +export const pushQueue = new Queue("send-push", { connection: ioredisConnection }); + +export const sendPushNotificationWorker = () => { + const worker = new Worker("send-push", async (job) => { + if(job.name === "PUSH_BY_LETTER") { + console.log(`[Job Start] 푸시알람 전송 중.`); + await sendPushNotification(job.data); + + console.log(`[Job Success] 푸시 알람 전송에 성공했습니다.`); + } + }, {connection: ioredisConnection}); + + worker.on('failed', (job, err) => { + console.error(`[Job Failed] ID: ${job.id} | 에러: ${err.message}`); + }); + + worker.on('error', (err) => { + console.error(`[Worker Error] 시스템 오류: ${err.message}`); + }); +} \ No newline at end of file diff --git a/src/jobs/crons/letter.cron.js b/src/jobs/crons/letter.cron.js index c3cbd51..0f6bb14 100644 --- a/src/jobs/crons/letter.cron.js +++ b/src/jobs/crons/letter.cron.js @@ -6,7 +6,7 @@ export const sendScheduledLettersCron = () => { '0 0 * * *', async () => { console.log("편지 전송을 시작합니다."); - sendScheduledLetters(); + await sendScheduledLetters(); }, { scheduled: false, diff --git a/src/jobs/index.job.js b/src/jobs/index.job.js index 05a5c85..548daa5 100644 --- a/src/jobs/index.job.js +++ b/src/jobs/index.job.js @@ -1,7 +1,14 @@ import { sendScheduledLettersCron } from "./crons/letter.cron.js"; import { startWeeklyReportCron } from "./crons/weeklyReport.cron.js" +import { sendQueuedLettersWorker } from "./bootstraps/letter.bootstrap.js" +import { sendPushNotificationWorker } from "./bootstraps/push.bootstrap.js"; +import { sendMailWorker } from "./bootstraps/mail.bootstrap.js"; export const startBatch = async () => { startWeeklyReportCron(); sendScheduledLettersCron(); + + sendQueuedLettersWorker(); + sendPushNotificationWorker(); + sendMailWorker(); } \ No newline at end of file diff --git a/src/middlewares/logger.middleware.js b/src/middlewares/logger.middleware.js new file mode 100644 index 0000000..d1782a2 --- /dev/null +++ b/src/middlewares/logger.middleware.js @@ -0,0 +1,44 @@ +import morgan from "morgan"; +import logger from "../configs/logger.config.js"; +import { maskSensitiveData } from "../utils/mask.util.js"; + +const logFormat = (tokens, req, res) => { + const query = maskSensitiveData(req.query || {}); + const body = maskSensitiveData(req.body || {}); + const errorPart = res.locals.errorMessage ? `\n > Error: ${res.locals.errorMessage}` : ''; + + return [ + `[${tokens.method(req, res)}]`, + tokens.url(req, res), + '| Status:', tokens.status(req, res), + '| Time:', tokens['response-time'](req, res), 'ms', + errorPart, + '\n > Query:', Object.keys(query).length ? JSON.stringify(query) : '{}', + '\n > Body:', Object.keys(body).length ? JSON.stringify(body) : '{}', + '\n > Headers:', JSON.stringify({ + 'content-type': req.headers['content-type'], + 'authorization': req.headers['authorization'] ? 'Present' : 'None' + }) + ].join(' '); +} + +const stream = { + write: (message) => { + const msg = message.trim(); + + const statusMatch = msg.match(/Status: (\d+)/); + const status = statusMatch ? parseInt(statusMatch[1]) : 0; + + if (status >= 500) { + logger.error(msg); + } else if (status >= 400) { + logger.warn(msg); + } else { + logger.info(msg); + } + } +} + +const httpLogger = morgan(logFormat, { stream }); + +export default httpLogger; \ No newline at end of file diff --git a/src/repositories/auth.repository.js b/src/repositories/auth.repository.js index c46d0da..d1b643b 100644 --- a/src/repositories/auth.repository.js +++ b/src/repositories/auth.repository.js @@ -28,12 +28,18 @@ export const getBlackListToken = async (token) => { return value; } -export const getHashedPassword = async (username) => { +export const getHashedPasswordByUsername = async (username) => { const {passwordHash} = await prisma.auth.findFirst({ select: { passwordHash: true }, where: { username } }); return passwordHash; } +export const getHashedPasswordByUserId = async (userId) => { + const {passwordHash} = await prisma.auth.findFirst({ select: { passwordHash: true }, where: { userId } }); + + return passwordHash; +} + export const saveEmailVerifyCode = async ({email, authCode, type}) => { await redis.set(`emailVerifyNumber:${type}:${email}`, authCode, { EX: 60 * 10 // 10분 diff --git a/src/repositories/friend.repository.js b/src/repositories/friend.repository.js index 700b270..21b806c 100644 --- a/src/repositories/friend.repository.js +++ b/src/repositories/friend.repository.js @@ -28,21 +28,61 @@ export async function insertFriendRequest(userId, targetUserId, sessionId) { } // 친구 신청 추가 export async function selectFriendsRequestByUserId(userId) { - return await prisma.FriendRequest.findMany({ + const result = await prisma.FriendRequest.findMany({ where: { requesterUserId: userId, status: "PENDING", }, + select: { + id: true, + requesterUserId: true, + receiverUserId: true, + sessionId: true, + status: true, + createdAt: true, + updatedAt: true, + receiver: { select: { nickname: true }}, + } }); + return result.map((r) => ({ + id: r.id, + requesterUserId: r.requesterUserId, + receiverUserId: r.receiverUserId, + receiverNickname: r.receiver?.nickname ?? null, + sessionId: r.sessionId, + status: r.status, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); } // 내가 보낸 친구 신청 목록 조회 export async function selectFriendsRequestByTargetUserId(userId) { - return await prisma.FriendRequest.findMany({ + const result = await prisma.FriendRequest.findMany({ where: { receiverUserId: userId, status: "PENDING", }, + select: { + id: true, + requesterUserId: true, + receiverUserId: true, + sessionId: true, + status: true, + createdAt: true, + updatedAt: true, + requester: { select: { nickname: true }}, + } }); + return result.map((r) => ({ + id: r.id, + requesterUserId: r.requesterUserId, + requesterNickname: r.requester?.nickname ?? null, + receiverUserId: r.receiverUserId, + sessionId: r.sessionId, + status: r.status, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); } // 나에게 온 친구 신청 목록 조회 export async function updateFriendRequestAccept(userId, targetUserId) { diff --git a/src/repositories/letter.repository.js b/src/repositories/letter.repository.js index 725fbc8..292a3a3 100644 --- a/src/repositories/letter.repository.js +++ b/src/repositories/letter.repository.js @@ -1,6 +1,45 @@ import { prisma } from "../configs/db.config.js" import { ReferenceNotFoundError } from "../errors/base.error.js"; +export const getLetterByUserIdAndAiKeyword = async (senderUserId, keyword) => { + const letter = await prisma.letter.findMany({ + where: { + senderUserId, + aiKeywords: { + some: { + keyword: { + name: keyword + } + } + } + }, + select: { + id: true, + senderUserId: true, + receiverUserId: true, + sessionId: true, + letterType: true, + questionId: true, + title: true, + content: true, + isPublic: true, + status: true, + scheduledAt: true, + deliveredAt: true, + readAt: true, + createdAt: true, + aiKeywords: { + select: { + keyword: { + select: { name: true } + } + } + } + } + }) + return letter; +} + export const getLetterDetail = async (id) => { const letter = await prisma.letter.findFirst({ select: { @@ -390,9 +429,8 @@ export const sendReservedLetter = async ({startTime, endTime}) => { return updatedLetters; } -export const updateLetter = async ({id, data}) => { - console.log(data); - await prisma.letter.update({ +export const updateLetter = async ({id, data}, tx = prisma) => { + await tx.letter.update({ where: { id }, @@ -400,4 +438,15 @@ export const updateLetter = async ({id, data}) => { ...data } }) +} + +export const selectQueuedLetter = async () => { + const letter = await prisma.letter.findFirst({ + where: { + status: "QUEUED", + orderBy: { createdAt: "asc"}, + } + }) + + return letter; } \ No newline at end of file diff --git a/src/repositories/session.repository.js b/src/repositories/session.repository.js index 25c7ce5..86af207 100644 --- a/src/repositories/session.repository.js +++ b/src/repositories/session.repository.js @@ -170,6 +170,19 @@ export const findMatchingSessionBySessionId = async (sessionId) => { }); }; +export const findMatchingSessionByParticipantUserId = async (userId, otherUserId) => { + const session = await prisma.matchingSession.findFirst({ + where: { + participants: { + every: { + userId: { in: [userId, otherUserId] }, + }, + }, + }, + }); + return session; +}; + export const countMatchingSessionByUserId = async (userId) => { const participants = await prisma.sessionParticipant.findMany({ where: { userId }, diff --git a/src/schemas/auth.schema.js b/src/schemas/auth.schema.js index 5d4dbb3..8461f12 100644 --- a/src/schemas/auth.schema.js +++ b/src/schemas/auth.schema.js @@ -41,7 +41,8 @@ export const usernameSchema = z.object({ export const passwordSchema = z.object({ body: z.object({ - password: passwordPart + oldPassword: passwordPart, + newPassword: passwordPart }) }) diff --git a/src/schemas/letter.schema.js b/src/schemas/letter.schema.js index 4891396..294d78e 100644 --- a/src/schemas/letter.schema.js +++ b/src/schemas/letter.schema.js @@ -43,4 +43,12 @@ export const publicCarouselSchema = z.object({ detail: detailpart, date: ISOTimePart }) -}) \ No newline at end of file +}) + +export const letterByAiKeywordSchema = z.object({ + params: z.object({ + aiKeyword: z.string("AI 키워드는 필수 항목입니다.").min(1, "AI 키워드는 최소 1자 이상입니다.") + }), + query: z.object({}).strict().optional().default({}), + body: z.object({}).strict().optional().default({}), +}); \ No newline at end of file diff --git a/src/services/auth.service.js b/src/services/auth.service.js index efa01fc..4782c01 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -2,14 +2,15 @@ import bcrypt from "bcrypt" import { prisma } from "../configs/db.config.js"; import { findUserByEmail, createUserAndAuth, createUserAgreement, findUserByUsername, softDeleteUser } from "../repositories/user.repository.js"; import { generateAccessToken, generateRefreshToken, verifyToken } from "../Auths/token.js"; -import { checkEmailRateLimit, createEmailVerifiedKey, getEmailVerifiedKey, getEmailVerifyCode, getHashedPassword, getRefreshToken, revokeToken, saveEmailVerifyCode, saveRefreshToken, updatePassword } from "../repositories/auth.repository.js"; +import { checkEmailRateLimit, createEmailVerifiedKey, getEmailVerifiedKey, getEmailVerifyCode, getHashedPasswordByUserId, getHashedPasswordByUsername, getRefreshToken, revokeToken, saveEmailVerifyCode, saveRefreshToken, updatePassword } from "../repositories/auth.repository.js"; import { createRandomNumber } from "../utils/random.util.js"; -import { transporter } from "../configs/mailer.config.js"; import { UserNotFoundError } from "../errors/user.error.js"; import { DuplicatedValueError, InternalServerError } from "../errors/base.error.js"; -import { AuthError, InvalidGrantCodeError, InvalidVerificationCodeError, NotRefreshTokenError, RequiredTermAgreementError, UnprocessableProviderError, VerificationRateLimitError } from "../errors/auth.error.js"; +import { AuthError, InvalidGrantCodeError, InvalidVerificationCodeError, NotRefreshTokenError, PasswordNotFoundError, RequiredTermAgreementError, UnprocessableProviderError, VerificationRateLimitError } from "../errors/auth.error.js"; import { ALLOWED_PROVIDERS, authConfigs } from "../constants/auth.constant.js"; import axios from "axios"; +import { enqueueJob } from "../utils/queue.util.js"; +import { mailQueue } from "../jobs/bootstraps/mail.bootstrap.js"; /** * 유저가 서비스에 가입했는지 확인하고 JWT를 반환하는 함수 @@ -175,7 +176,7 @@ export const loginUser = async ({username, password}) => { const user = await findUserByUsername(username); if(!user) throw new AuthError("AUTH_BAD_REQUEST", "아이디 또는 비밀번호가 일치하지 않습니다."); - const passwordHash = await getHashedPassword(username); + const passwordHash = await getHashedPasswordByUsername(username); if(!passwordHash) throw new AuthError("AUTH_BAD_REQUEST", "아이디 또는 비밀번호가 일치하지 않습니다."); const isValidPassword = await bcrypt.compare(password, passwordHash); @@ -238,7 +239,7 @@ export const SendVerifyEmailCode = async ({email, type}) => { const expiredAt = new Date(Date.now() + 10 * 60 * 1000); - const info = await transporter.sendMail({ + await enqueueJob(mailQueue, "SEND_MAIL_FOR_CODE", { from: `"속삭편지" <${process.env.MAILER_USER}>`, to: email, subject: "[속삭] 회원가입 인증번호", @@ -289,9 +290,15 @@ export const getAccountInfo = async (email) => { } } -export const resetPassword = async ({userId, password}) => { - const passwordHash = await bcrypt.hash(password, 10); - await updatePassword({userId, newPassword: passwordHash}); +export const resetPassword = async ({userId, oldPassword, newPassword}) => { + const oldPasswordHash = await getHashedPasswordByUserId(userId); + if(!oldPasswordHash) throw new PasswordNotFoundError("PASSWORD_NOT_FOUND", "기존 비밀번호를 찾을 수 없습니다."); + + const isValidPassword = await bcrypt.compare(oldPassword, oldPasswordHash); + if(!isValidPassword) throw new AuthError("AUTH_BAD_REQUEST", "비밀번호가 일치하지 않습니다."); + + const newPasswordHash = await bcrypt.hash(newPassword, 10); + await updatePassword({userId, newPassword: newPasswordHash}); return { message: "비밀번호 재설정이 완료되었습니다." }; } \ No newline at end of file diff --git a/src/services/friend.service.js b/src/services/friend.service.js index 0cc176e..e3a08d6 100644 --- a/src/services/friend.service.js +++ b/src/services/friend.service.js @@ -25,6 +25,8 @@ import { ConflictError, InternalServerError, } from "../errors/base.error.js"; +import { findMatchingSessionByParticipantUserId, updateMatchingSessionToFriends, updateMatchingSessionToDiscard } from "../repositories/session.repository.js"; +import { SessionInternalError, SessionNotFoundError } from "../errors/session.error.js"; async function userExistsOrThrow(userId) { const userById = await findUserById(userId); @@ -42,12 +44,12 @@ async function assertUsersExistOrThrow(userId, targetUserId) { if (!userById) throw new InvalidUserError(undefined, "잘못된 유저 정보 입력입니다.", { - userId, + userId }); if (!targetUserById) throw new InvalidUserError(undefined, "잘못된 유저 정보 입력입니다.", { - targetUserId, + targetUserId }); } @@ -178,10 +180,17 @@ export const getOutgoingFriendRequests = async (userId) => { } }; -// 5) 친구 신청 수락 +// 5) 친구 신청 수락 (receiverUserId: userId, requesterUserId: targetUserId) export const acceptFriendRequest = async (receiverUserId, requesterUserId) => { await assertUsersExistOrThrow(receiverUserId, requesterUserId); - + const session = await findMatchingSessionByParticipantUserId(receiverUserId, requesterUserId); + if (!session) { + throw new SessionNotFoundError(undefined, undefined, {receiverUserId, requesterUserId}); + } + const friendsSession = await updateMatchingSessionToFriends(session.id); + if(friendsSession.status != "FRIENDS") { + throw new SessionInternalError(undefined, "세션 상태 변경에 실패했습니다.", {sessionId: session.id}); + } const req = await userExistsFriendRequest(receiverUserId, requesterUserId); if (!req) throw new FriendRequestNotFoundError(undefined, undefined, { @@ -221,7 +230,6 @@ export const acceptFriendRequest = async (receiverUserId, requesterUserId) => { // 6) 친구 신청 거절 export const rejectFriendRequest = async (requesterUserId, receiverUserId) => { await assertUsersExistOrThrow(requesterUserId, receiverUserId); - try { const rejectedFriendRequest = await updateFriendRequestRejectTx( requesterUserId, receiverUserId diff --git a/src/services/letter.service.js b/src/services/letter.service.js index fa5b729..9e3188b 100644 --- a/src/services/letter.service.js +++ b/src/services/letter.service.js @@ -1,9 +1,9 @@ import { UserNotFoundError } from "../errors/user.error.js"; import { DuplicatedValueError } from "../errors/base.error.js"; import { findFriendById, selectAllFriendsByUserId } from "../repositories/friend.repository.js"; -import { countLetterStatsForWeek, countTotalSentLetter, createLetter, getLetterDetail, getPublicLetters, updateLetter } from "../repositories/letter.repository.js" +import { countLetterStatsForWeek, countTotalSentLetter, createLetter, getLetterDetail, getPublicLetters, selectQueuedLetter, updateLetter, getLetterByUserIdAndAiKeyword } from "../repositories/letter.repository.js" import { createLetterLike, deleteLetterLike, findLetterLike } from "../repositories/like.repository.js"; -import { findRandomUserByPool, findUserByIdForProfile } from "../repositories/user.repository.js"; +import { findRandomUserByPool, findUserByIdForProfile, findUserById } from "../repositories/user.repository.js"; import { getDayStartAndEnd, getMonthAndWeek, getToday, getWeekStartAndEnd } from "../utils/date.util.js"; import { getLevelInfo } from "../constants/planet.constant.js"; import { blockBadWordsInText } from "../utils/profanity.util.js"; @@ -16,6 +16,46 @@ import { findQuestionByQuestionId } from "../repositories/question.repository.js import { prisma } from "../configs/db.config.js"; import { SessionCountOverError, SessionNotFoundError } from "../errors/session.error.js"; import { sendPushNotification } from "./push.service.js"; +import { letterQueue } from "../jobs/bootstraps/letter.bootstrap.js"; +import { enqueueJob } from "../utils/queue.util.js"; +import { pushQueue } from "../jobs/bootstraps/push.bootstrap.js"; + +const dispatchSessionLogicByStatus = async (userId, receiverUserId, session, questionId, tx) => { + if (!session) { + const count = await countMatchingSessionWhichChating(userId); + if (count >= 10) throw new SessionCountOverError("SESSION_COUNTOVER_ERROR", "세션이 10개 이상입니다."); + + const newSession = await createMatchingSession(userId, receiverUserId, questionId, tx); + await decrementSessionTurn(newSession.id, tx); + return newSession; + } + + switch (session.status) { + case "PENDING": + await updateMatchingSessionToChating(session.id, tx); + await decrementSessionTurn(session.id, tx); + session.status = "CHATING"; + + break; + case "CHATING": + await decrementSessionTurn(session.id, tx); + + break; + default: + break; + } + + return session; +} + +export const getLetterByAiKeyword = async ({userId, aiKeyword}) => { + const user = await findUserById(userId); + if(!user) throw new UserNotFoundError("USER_NOT_FOUND", "해당 정보로 가입된 계정을 찾을 수 없습니다.", "id"); + try {const letter = await getLetterByUserIdAndAiKeyword(userId, aiKeyword); + return letter;} catch(err) { + throw new LetterNotFound("LETTER_NOT_FOUND", "해당 키워드로 작성된 편지가 없습니다."); + } +} export const getLetter = async ({userId, letterId}) => { const {letter, receiverUserId, senderUserId, readAt} = await getLetterDetail(letterId); @@ -24,19 +64,19 @@ export const getLetter = async ({userId, letterId}) => { if(userId == receiverUserId && !readAt) { await updateLetter({id: letter.id, data: { readAt: new Date() }}); - const friend = await findFriendById(receiverUserId); - - await sendPushNotification({ - userId: senderUserId, - type: "READ_CONFIRMATION", - data: { - isFriend: !!friend, - nickname: friend?.nickname - } - }); - + if(receiverUserId != senderUserId) { + const friend = await findFriendById(senderUserId, receiverUserId); + + await enqueueJob(pushQueue, "PUSH_BY_LETTER", { + userId: senderUserId, + type: "READ_CONFIRMATION", + data: { + isFriend: !!friend, + nickname: friend?.nickname + } + }) + } } - return letter; } @@ -70,10 +110,6 @@ export const sendLetterToMe = async (userId, data) => { } export const sendLetterToOther = async (userId, data) => { - if(!data?.receiverUserId) { - data.receiverUserId = await findRandomUserByPool(userId); - } - const question = await findQuestionByQuestionId(data.questionId); if(question == null) throw new QuestionNotFoundError("QUESTION_NOT_FOUND", "해당 질문을 찾을 수 없습니다."); @@ -82,6 +118,7 @@ export const sendLetterToOther = async (userId, data) => { let session = null; let receiver = null; + if(data.receiverUserId) { receiver = await findUserByIdForProfile(data.receiverUserId); if(!receiver) throw new UserNotFoundError("USER_NOT_FOUND", "해당 정보로 가입된 계정을 찾을 수 없습니다.", "id"); @@ -92,25 +129,7 @@ export const sendLetterToOther = async (userId, data) => { const letterId = await prisma.$transaction(async (tx) => { if(data.receiverUserId) { - // 친구는 아닌데 비활성화 세션일 때 - if(session?.status === "PENDING") { - await updateMatchingSessionToChating(session.id, tx) - session.status = "CHATING"; - } - - // 친구는 아닌데 채팅중일 때 - if(session?.status === "CHATING") { - await decrementSessionTurn(session.id, tx); - } - - // 친구도 아니고 채팅중도 아닐 때 - if(!session){ - const count = await countMatchingSessionWhichChating(userId); - if(count >= 10) throw new SessionCountOverError("SESSION_COUNTOVER_ERROR", "세션이 10개 이상입니다."); - - session = await createMatchingSession(userId, data.receiverUserId, data.questionId, tx); - await decrementSessionTurn(session.id, tx); - } + session = await dispatchSessionLogicByStatus(userId, data.receiverUserId, session, data.questionId, tx); } const letterId = await createLetter({ @@ -135,17 +154,23 @@ export const sendLetterToOther = async (userId, data) => { return letterId }) - console.log(session); - // 매칭잡혔을 때 푸시알림 전송 if (data.receiverUserId) { - await sendPushNotification({ + // 매칭잡혔을 때 푸시알림 큐에 작업 추가 + await enqueueJob(pushQueue, "PUSH_BY_LETTER", { userId: data.receiverUserId, type: "NEW_LETTER", data: { nickname: receiver.nickname, status: session.status } + }) + } else { + // 매칭이 잡히지 않았을 때 큐에 작업 추가 + await enqueueJob(letterQueue, "MATCH_BY_LETTER", { + letterId, + userId, + questionId: data.questionId }); } @@ -226,4 +251,44 @@ export const getLetterAssets = async () => { const assets = await findLetterAssets(); return assets; +} + +export const sendQueuedLettersByLetterId = async (data) => { + const receiverUserId = await findRandomUserByPool(data.userId); + if(!receiverUserId) throw new Error("매칭 상대 없음"); + + const count = await countMatchingSessionWhichChating(data.userId); + if(count >= 10) throw new SessionCountOverError("SESSION_COUNTOVER_ERROR", "세션이 10개 이상입니다."); + + await prisma.$transaction(async (tx) => { + const session = await createMatchingSession(data.userId, receiverUserId, data.questionId, tx); + await decrementSessionTurn(session.id, tx); + await updateLetter({id: data.letterId, data: { + status: "DELIVERED", + receiverUserId, + sessionId: session.id, + deliveredAt: new Date() + }}, tx); + }) + + await enqueueJob(pushQueue, "PUSH_BY_LETTER", { + userId: data.receiverUserId, + type: "NEW_LETTER" + }) +} + +export const sendQueuedLettersByUserId = async (data) => { + const letter = await selectQueuedLetter(); + if(!letter) throw new Error("매칭되지 않은 편지가 없습니다."); + + await prisma.$transaction(async (tx) => { + const session = await createMatchingSession(letter.senderUserId, data.userId, letter.questionId, tx); + await decrementSessionTurn(session.id, tx); + await updateLetter({id: data.letterId, data: { + status: "DELIVERED", + receiverUserId: data.userId, + sessionId: session.id, + deliveredAt: new Date() + }}, tx); + }) } \ No newline at end of file diff --git a/src/services/push.service.js b/src/services/push.service.js index a801573..60bfee7 100644 --- a/src/services/push.service.js +++ b/src/services/push.service.js @@ -33,7 +33,6 @@ export const sendPushNotification = async ({userId, type, data = {}}) => { if (err.statusCode === 410 || err.statusCode === 404) { await deletePushSubscription(sub.id); } - throw err; }); }) ) diff --git a/src/services/user.service.js b/src/services/user.service.js index b454eb9..6f7ccd5 100644 --- a/src/services/user.service.js +++ b/src/services/user.service.js @@ -16,6 +16,7 @@ import { InterestIdsMinCountError, InterestIdsInvalidError, } from "../errors/user.error.js"; +import { letterQueue } from "../jobs/bootstraps/letter.bootstrap.js"; import { upsertPushSubscription, @@ -44,6 +45,7 @@ import { updateUserPoolById, uploadProfileImageToStorage, } from "../repositories/user.repository.js"; +import { enqueueJob } from "../utils/queue.util.js"; import { mimeToExt, requiredEnv, @@ -236,6 +238,9 @@ export const updateMyOnboardingInterests = async ({ userId, interestIds }) => { await replaceUserInterests({ userId, interestIds: uniqueIds }); await updateUserPoolById({id: userId, pool: userPoolId}); + await enqueueJob(letterQueue, "MATCH_BY_USER", {userId}, { + delay: 1000 * 60 + }); return { updated: true }; }; diff --git a/src/swagger/auth.swagger.js b/src/swagger/auth.swagger.js index 03b73a7..0794977 100644 --- a/src/swagger/auth.swagger.js +++ b/src/swagger/auth.swagger.js @@ -575,7 +575,7 @@ * example: "123456" * responses: * 200: - * description: 인증 성공 + * description: 인증 성공. type이 'reset-password'인 경우, 임시 액세스 토큰이 발급됩니다. * content: * application/json: * schema: @@ -590,7 +590,7 @@ * example: true * jwtAccessToken: * type: string - * description: "reset-password 타입일 경우에만 반환됨" + * description: "type이 'reset-password'일 경우에만 반환됨" * 400: * description: 인증번호 불일치 (EMAIL_INVALID_CODE) * content: @@ -606,7 +606,7 @@ * reason: * example: "인증번호가 일치하지 않습니다." * 404: - * description: 가입된 계정 없음 (USER_NOT_FOUND) + * description: 가입된 계정 없음 (USER_NOT_FOUND). type이 'reset-password'일 때 발생 가능. * content: * application/json: * schema: @@ -703,14 +703,18 @@ * schema: * type: object * required: - * - password + * - oldPassword + * - newPassword * properties: - * password: + * oldPassword: * type: string - * example: "newPass123!" + * example: "currentPassword123" + * newPassword: + * type: string + * example: "newStrongPassword123" * responses: * 200: - * description: 재설정 성공 + * description: 비밀번호 변경 성공 * content: * application/json: * schema: @@ -790,6 +794,20 @@ * example: "AUTH_NOT_FOUND" * reason: * example: "인증 토큰이 없습니다." + * 404: + * description: 기존 비밀번호를 찾을 수 없음 (PASSWORD_NOT_FOUND). 주로 소셜 로그인 유저에게 발생합니다. + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/ErrorResponse' + * - properties: + * error: + * properties: + * errorCode: + * example: "PASSWORD_NOT_FOUND" + * reason: + * example: "기존 비밀번호를 찾을 수 없습니다." */ /** @@ -1022,6 +1040,4 @@ * example: "INTERNAL_SERVER_ERROR" * reason: * example: "로그아웃에 실패했습니다. 다시 시도해주세요." - */ - - + */ \ No newline at end of file diff --git a/src/swagger/friend.swagger.js b/src/swagger/friend.swagger.js index 43f9d70..49980f6 100644 --- a/src/swagger/friend.swagger.js +++ b/src/swagger/friend.swagger.js @@ -233,7 +233,7 @@ * security: * - bearerAuth: [] * responses: - * 200: + * '200': * description: 조회 성공 * content: * application/json: @@ -255,17 +255,64 @@ * type: array * items: * type: object - * example: - * [ - * { "id": 55, "requesterUserId": 2, "receiverUserId": 1, "status": "PENDING" } - * ] + * properties: + * id: + * type: integer + * example: 1 + * requesterNickname: + * type: string + * example: "웃는참외" + * requesterUserId: + * type: integer + * example: 30 + * receiverUserId: + * type: integer + * example: 2 + * sessionId: + * type: integer + * nullable: true + * example: null + * status: + * type: string + * example: "PENDING" + * createdAt: + * type: string + * format: date-time + * example: "2026-02-01T18:55:14.000Z" + * updatedAt: + * type: string + * format: date-time + * example: "2026-02-01T18:55:14.000Z" + * examples: + * success: + * value: + * success: + * message: "들어온 친구 신청 목록 조회가 성공하였습니다." + * result: + * data: + * - id: 1 + * requesterNickname: "웃는참외" + * requesterUserId: 30 + * receiverUserId: 2 + * sessionId: null + * status: "PENDING" + * createdAt: "2026-02-01T18:55:14.000Z" + * updatedAt: "2026-02-01T18:55:14.000Z" * 401: * description: 인증 필요 (UNAUTHORIZED) * content: * application/json: * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 404: + * allOf: + * - $ref: '#/components/schemas/ErrorResponse' + * - properties: + * error: + * properties: + * errorCode: + * example: "UNAUTHORIZED" + * reason: + * example: "인증이 필요합니다" + * '404': * description: 요청 없음 (FRIEND_REQUESTNOTFOUND_ERROR) * content: * application/json: @@ -279,7 +326,7 @@ * example: "FRIEND_REQUESTNOTFOUND_ERROR" * reason: * example: "처리할 수 있는 친구 요청이 없습니다." - * 500: + * '500': * description: 친구 처리 중 서버 오류 (FRIEND_INTERNALSERVER_ERROR) * content: * application/json: @@ -296,7 +343,7 @@ * security: * - bearerAuth: [] * responses: - * 200: + * '200': * description: 조회 성공 * content: * application/json: @@ -318,17 +365,64 @@ * type: array * items: * type: object - * example: - * [ - * { "id": 77, "requesterUserId": 1, "receiverUserId": 3, "status": "PENDING" } - * ] + * properties: + * id: + * type: integer + * example: 77 + * receiverNickname: + * type: string + * example: "닉네임3" + * requesterUserId: + * type: integer + * example: 1 + * receiverUserId: + * type: integer + * example: 3 + * sessionId: + * type: integer + * nullable: true + * example: null + * status: + * type: string + * example: "PENDING" + * createdAt: + * type: string + * format: date-time + * example: "2026-02-01T18:55:14.000Z" + * updatedAt: + * type: string + * format: date-time + * example: "2026-02-01T18:55:14.000Z" + * examples: + * success: + * value: + * success: + * message: "보낸 친구 신청 목록 조회가 성공하였습니다." + * result: + * data: + * - id: 77 + * receiverNickname: "닉네임3" + * requesterUserId: 1 + * receiverUserId: 3 + * sessionId: null + * status: "PENDING" + * createdAt: "2026-02-01T18:55:14.000Z" + * updatedAt: "2026-02-01T18:55:14.000Z" * 401: * description: 인증 필요 (UNAUTHORIZED) * content: * application/json: * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 404: + * allOf: + * - $ref: '#/components/schemas/ErrorResponse' + * - properties: + * error: + * properties: + * errorCode: + * example: "UNAUTHORIZED" + * reason: + * example: "인증이 필요합니다" + * '404': * description: 요청 없음 (FRIEND_REQUESTNOTFOUND_ERROR) * content: * application/json: @@ -342,7 +436,7 @@ * example: "FRIEND_REQUESTNOTFOUND_ERROR" * reason: * example: "처리할 수 있는 친구 요청이 없습니다." - * 500: + * '500': * description: 친구 처리 중 서버 오류 (FRIEND_INTERNALSERVER_ERROR) * content: * application/json: diff --git a/src/swagger/letter.swagger.js b/src/swagger/letter.swagger.js index b83869a..223ffa3 100644 --- a/src/swagger/letter.swagger.js +++ b/src/swagger/letter.swagger.js @@ -116,6 +116,143 @@ * example: "인증 토큰이 없습니다." */ +/** + * @swagger + * /letters/keywords/{aiKeyword}: + * get: + * summary: AI 키워드로 내가 보낸 편지 조회 + * tags: [편지] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: aiKeyword + * required: true + * schema: + * type: string + * description: 조회할 AI 키워드 이름 (AiKeyword.name) + * responses: + * 200: + * description: 조회 성공 + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessResponse' + * - properties: + * success: + * type: object + * properties: + * message: + * type: string + * result: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * title: + * type: string + * content: + * type: string + * senderUserId: + * type: integer + * receiverUserId: + * type: integer + * nullable: true + * deliveredAt: + * type: string + * format: date-time + * nullable: true + * createdAt: + * type: string + * format: date-time + * aiKeywords: + * type: array + * items: + * type: object + * properties: + * keyword: + * type: object + * properties: + * name: + * type: string + * examples: + * SUCCESS: + * value: + * resultType: "SUCCESS" + * error: null + * success: + * message: "AI 키워드 편지 조회가 성공하였습니다." + * result: + * - id: 1 + * title: "dummy title 01" + * content: "dummy content 01" + * senderUserId: 1 + * receiverUserId: 1 + * deliveredAt: "2026-02-02T12:00:00.000Z" + * createdAt: "2026-02-02T12:00:00.000Z" + * aiKeywords: + * - keyword: + * name: "DUMMY_KEYWORD_A" + * 400: + * description: | + * 잘못된 요청: + * - `REQ_BAD_REQUEST`: 요청 유효성 검사 실패 + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/ErrorResponse' + * - properties: + * error: + * properties: + * errorCode: + * example: "REQ_BAD_REQUEST" + * reason: + * example: "입력값이 잘못되었습니다." + * 401: + * description: | + * 인증 실패: + * - `AUTH_TOKEN_EXPIRED` + * - `AUTH_INVALID_TOKEN` + * - `AUTH_NOT_ACCESS_TOKEN` + * - `AUTH_EXPIRED_TOKEN` + * - `AUTH_UNAUTHORIZED` + * - `AUTH_NOT_FOUND` + * content: + * application/json: + * schema: + * oneOf: + * - allOf: + * - $ref: '#/components/schemas/ErrorResponse' + * - properties: + * error: + * properties: + * errorCode: + * example: "AUTH_UNAUTHORIZED" + * reason: + * example: "액세스 토큰이 유효하지 않습니다." + * 404: + * description: | + * 조회 실패: + * - `USER_NOT_FOUND`: 해당 정보로 가입된 계정을 찾을 수 없습니다. + * - `LETTER_NOT_FOUND`: 해당 키워드로 작성된 편지가 없습니다. + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/ErrorResponse' + * - properties: + * error: + * properties: + * errorCode: + * example: "LETTER_NOT_FOUND" + * reason: + * example: "해당 키워드로 작성된 편지가 없습니다." + */ + /** * @swagger * /letter/me: diff --git a/src/utils/date.util.js b/src/utils/date.util.js index 6783760..dc5aca1 100644 --- a/src/utils/date.util.js +++ b/src/utils/date.util.js @@ -110,11 +110,11 @@ export function getISOWeek(date = new Date()) { } export const getToday = (date) => { - return dayjs(date).utc().toDate(); + return dayjs(date).tz("Asia/Seoul").utc().toDate(); } export const getWeekStartAndEnd = (date) => { - const userDate = dayjs(date); + const userDate = dayjs(date).tz("Asia/Seoul"); const weekStart = userDate.startOf("isoWeek"); const weekEnd = userDate.endOf("isoWeek"); @@ -126,7 +126,7 @@ export const getWeekStartAndEnd = (date) => { } export const getDayStartAndEnd = (date) => { - const userDate = dayjs(date); + const userDate = dayjs(date).tz("Asia/Seoul"); const startTime = userDate.startOf("day"); const endTime = userDate.endOf("day"); @@ -138,7 +138,7 @@ export const getDayStartAndEnd = (date) => { } export const getMonthAndWeek = (date) => { - const userDate = dayjs(date); + const userDate = dayjs(date).tz("Asia/Seoul"); const thursday = userDate.isoWeekday(4); diff --git a/src/utils/mask.util.js b/src/utils/mask.util.js new file mode 100644 index 0000000..f7df6aa --- /dev/null +++ b/src/utils/mask.util.js @@ -0,0 +1,34 @@ +export const maskSensitiveData = (obj) => { + if (!obj || typeof obj !== "object") return obj; + + const masked = { ...obj }; + + const rules = { + partial: ["username", "email", "name", "nickname"], + full: ["password", "token", "accesstoken", "refreshToken", "secret", "code"] + }; + + Object.keys(masked).forEach(key => { + const value = masked[key]; + if (typeof value !== 'string') return; + + if (rules.full.includes(key)) { + masked[key] = "*******"; + } + + else if (rules.partial.includes(key)) { + if (key === "email" && value.includes("@")) { + const [id, domain] = value.split("@"); + masked[key] = id.length > 3 + ? id.substring(0, 3) + "*".repeat(id.length - 3) + "@" + domain + : id.substring(0, 1) + "**@" + domain; + } else { + masked[key] = value.length > 3 + ? value.substring(0, 3) + "*".repeat(value.length - 3) + : value.substring(0, 1) + "**"; + } + } + }); + + return masked; +}; \ No newline at end of file diff --git a/src/utils/queue.util.js b/src/utils/queue.util.js new file mode 100644 index 0000000..a607f0e --- /dev/null +++ b/src/utils/queue.util.js @@ -0,0 +1,22 @@ +import { InternalServerError } from "../errors/base.error.js"; + +export const enqueueJob = async (queue, jobName, data, customOptions = {}) => { + const defaultOptions = { + attempts: 5, + backoff: { + type: 'fixed', + delay: 1000 * 30 // 30초 + }, + removeOnComplete: true, + removeOnFail: false, + } + + const finalOptions = { ...defaultOptions, ...customOptions }; + + try { + const job = await queue.add(jobName, data, finalOptions); + return job; + } catch (error) { + throw InternalServerError("INTERNAL_SERVER_ERROR", "작업 추가중 에러가 발생했습니다"); + } +} \ No newline at end of file diff --git a/src/utils/s3key.js b/src/utils/s3key.js deleted file mode 100644 index aba27b1..0000000 --- a/src/utils/s3key.js +++ /dev/null @@ -1 +0,0 @@ -// src/utils/s3key.js