From dd0a9879f1a3d975198898e96a0528ee13ab9b6c Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 19 Jul 2025 21:57:11 +0900 Subject: [PATCH 01/59] =?UTF-8?q?feat:=20Youtube=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/youtube/search/dto/youtube-search.dto.ts | 4 + .../search/youtube-search.controller.ts | 102 ++++++++++++++++++ src/youtube/search/youtube-search.route.ts | 63 +++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/youtube/search/dto/youtube-search.dto.ts create mode 100644 src/youtube/search/youtube-search.controller.ts create mode 100644 src/youtube/search/youtube-search.route.ts diff --git a/src/youtube/search/dto/youtube-search.dto.ts b/src/youtube/search/dto/youtube-search.dto.ts new file mode 100644 index 0000000..9929a26 --- /dev/null +++ b/src/youtube/search/dto/youtube-search.dto.ts @@ -0,0 +1,4 @@ +export interface YoutubeSearchDto { + query: string; + limit?: number; +} diff --git a/src/youtube/search/youtube-search.controller.ts b/src/youtube/search/youtube-search.controller.ts new file mode 100644 index 0000000..fe1016a --- /dev/null +++ b/src/youtube/search/youtube-search.controller.ts @@ -0,0 +1,102 @@ +import { Request, Response } from 'express'; +import axios from 'axios'; +import { YoutubeSearchDto } from './dto/youtube-search.dto'; + +type YoutubeSearchItem = { + id: { + videoId: string; + }; + snippet: { + title: string; + thumbnails: { + medium: { + url: string; + }; + }; + channelTitle: string; + }; +}; + +type YoutubeVideoResult = { + videoId: string; + title: string; + thumbnail: string; + channelName: string; + viewCount: number; + uploadTime: string; +}; + +// 명시적 타입으로 처리 (any 사용 X) +export const searchYoutubeVideos = async ( + req: Request, + res: Response, +): Promise => { + try { + const { query, limit = 10 } = req.query; + + // Authorization 헤더 확인 + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ + success: false, + message: '인증 정보가 누락되었습니다.', + }); + return; + } + + const apiKey = process.env.YOUTUBE_API_KEY; + if (!apiKey) { + res.status(500).json({ + success: false, + message: '서버 설정 오류: YOUTUBE_API_KEY가 누락되었습니다.', + }); + return; + } + + const response = await axios.get('https://www.googleapis.com/youtube/v3/search', { + params: { + q: query, + part: 'snippet', + type: 'video', + maxResults: limit, + key: apiKey, + }, + }); + + const items = response.data.items as YoutubeSearchItem[]; + + const videoList: YoutubeVideoResult[] = await Promise.all( + items.map(async item => { + const statsRes = await axios.get('https://www.googleapis.com/youtube/v3/videos', { + params: { + part: 'statistics,snippet', + id: item.id.videoId, + key: apiKey, + }, + }); + + const videoData = statsRes.data.items[0]; + return { + videoId: item.id.videoId, + title: item.snippet.title, + thumbnail: item.snippet.thumbnails.medium.url, + channelName: item.snippet.channelTitle, + viewCount: parseInt(videoData.statistics.viewCount, 10), + uploadTime: videoData.snippet.publishedAt, + }; + }), + ); + + res.status(200).json({ + success: true, + data: videoList, + }); + } catch (error: unknown) { + console.error(error); + res.status(500).json({ + success: false, + message: 'YouTube API 요청 실패', + timestamp: new Date().toISOString(), + }); + } +}; diff --git a/src/youtube/search/youtube-search.route.ts b/src/youtube/search/youtube-search.route.ts new file mode 100644 index 0000000..18156c7 --- /dev/null +++ b/src/youtube/search/youtube-search.route.ts @@ -0,0 +1,63 @@ +import { Router } from 'express'; +import { searchYoutubeVideos } from './youtube-search.controller'; + +const router: Router = Router(); + +/** + * @swagger + * /youtube/search: + * get: + * summary: 유튜브 영상 검색 + * description: 키워드로 유튜브 영상을 검색합니다. + * tags: + * - YouTube + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: query + * required: true + * schema: + * type: string + * description: 검색할 키워드 + * - in: query + * name: limit + * schema: + * type: integer + * default: 10 + * description: 검색할 영상 수 + * responses: + * 200: + * description: 검색 결과 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: array + * items: + * type: object + * properties: + * videoId: + * type: string + * title: + * type: string + * thumbnail: + * type: string + * channelName: + * type: string + * viewCount: + * type: integer + * uploadTime: + * type: string + * 401: + * description: 인증 실패 + * 500: + * description: YouTube API 요청 실패 + */ +router.get('/search', searchYoutubeVideos); + +export default router; From 73c20f8f5c849f3400f9e203138cde2a90f83646 Mon Sep 17 00:00:00 2001 From: kdy Date: Fri, 25 Jul 2025 22:22:17 +0900 Subject: [PATCH 02/59] =?UTF-8?q?feat:=20YouTube=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20DB=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.ts | 2 + src/controllers/youtubeDetailController.ts | 80 ++++++++++++++++++++++ src/middleware/errors/errorHandler.ts | 5 +- src/routes/youtubeDetailRoute.ts | 67 ++++++++++++++++++ src/services/youtubeDetailService.ts | 52 ++++++++++++++ 5 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 src/controllers/youtubeDetailController.ts create mode 100644 src/routes/youtubeDetailRoute.ts create mode 100644 src/services/youtubeDetailService.ts diff --git a/src/app.ts b/src/app.ts index ee726e6..eecfc46 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,7 @@ import { sendSuccess } from './utils/response.js'; import { requireAuth } from './middleware/authMiddleware.js'; import youtubeRoutes from './routes/recommendationRoute.js'; import youtubeSearchRouter from './routes/youtubeSearchRoute.js'; +import youtubeDetailRouter from './routes/youtubeDetailRoute.js'; import authRoutes from './routes/authRoutes.js'; import swaggerUi from 'swagger-ui-express'; import { specs } from './swagger.js'; @@ -117,6 +118,7 @@ app.use('/api/auth', authRoutes); // app.use('/api/rooms', roomRoutes); app.use('/api/youtube', youtubeRoutes); app.use('/api/youtube', youtubeSearchRouter); +app.use('/api/youtube/videos', youtubeDetailRouter); // 404 에러 핸들링 app.use((req: Request, res: Response, next: NextFunction) => { diff --git a/src/controllers/youtubeDetailController.ts b/src/controllers/youtubeDetailController.ts new file mode 100644 index 0000000..98f293c --- /dev/null +++ b/src/controllers/youtubeDetailController.ts @@ -0,0 +1,80 @@ +import { Request, Response } from 'express'; +import axios from 'axios'; +import { sendError } from '../utils/response'; +import { saveYoutubeVideo } from '../services/youtubeDetailService'; + +export const getYoutubeVideoDetail = async ( + req: Request<{ videoId: string }>, + res: Response, +): Promise => { + try { + const { videoId } = req.params; + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + sendError(res, '인증 정보가 누락되었습니다.', 401, 'UNAUTHORIZED'); + return; + } + + const apiKey = process.env.YOUTUBE_API_KEY; + if (!apiKey) { + sendError(res, '서버 설정 오류: YOUTUBE_API_KEY가 누락되었습니다.', 500, 'SERVER_ERROR'); + return; + } + + // 1. 영상 상세 정보 + 통계 + duration + const videoRes = await axios.get('https://www.googleapis.com/youtube/v3/videos', { + params: { + part: 'snippet,statistics,contentDetails', + id: videoId, + key: apiKey, + }, + }); + + const videoData = videoRes.data.items?.[0]; + + if (!videoData) { + sendError(res, '해당 videoId에 대한 영상 정보를 찾을 수 없습니다.', 404, 'NOT_FOUND'); + return; + } + + const { id, snippet, statistics, contentDetails } = videoData; + + const channelId = snippet.channelId; + + // 2. 채널 아이콘 가져오기 + const channelRes = await axios.get('https://www.googleapis.com/youtube/v3/channels', { + params: { + part: 'snippet', + id: channelId, + key: apiKey, + }, + }); + + const channelIcon = channelRes.data.items?.[0]?.snippet?.thumbnails?.default?.url ?? null; + + // 3. 결과 객체 구성 + const result = { + videoId: id, + title: snippet.title, + description: snippet.description, + thumbnail: snippet.thumbnails.medium?.url, + channelName: snippet.channelTitle, + channelIcon: channelIcon, + viewCount: parseInt(statistics?.viewCount || '0', 10), + duration: contentDetails.duration, // ISO 8601 (e.g. "PT15M33S") + uploadedAt: snippet.publishedAt, + }; + + // 4. DB 저장 + await saveYoutubeVideo(result); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + console.error(error); + sendError(res, 'YouTube API 요청 실패', 500, 'INTERNAL_SERVER_ERROR'); + } +}; diff --git a/src/middleware/errors/errorHandler.ts b/src/middleware/errors/errorHandler.ts index cd34d8a..02106c9 100644 --- a/src/middleware/errors/errorHandler.ts +++ b/src/middleware/errors/errorHandler.ts @@ -7,8 +7,9 @@ import { Request, Response, NextFunction } from 'express'; import AppError from './AppError.js'; import { sendError } from '../../utils/response.js'; -const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => { - next(); +const errorHandler = (err: Error, req: Request, res: Response, _next: NextFunction) => { + void _next; + if (err instanceof AppError) { return sendError(res, err.message, err.statusCode); } diff --git a/src/routes/youtubeDetailRoute.ts b/src/routes/youtubeDetailRoute.ts new file mode 100644 index 0000000..85e8ab9 --- /dev/null +++ b/src/routes/youtubeDetailRoute.ts @@ -0,0 +1,67 @@ +import { Router } from 'express'; +import { getYoutubeVideoDetail } from '../controllers/youtubeDetailController'; +import { requireAuth } from '../middleware/authMiddleware'; + +const router = Router(); + +/** + * @swagger + * /api/youtube/videos/{videoId}: + * get: + * summary: 유튜브 영상 상세 조회 + * description: 특정 videoId에 대한 유튜브 영상 정보를 조회하고 DB에 저장합니다. + * tags: + * - YouTube + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: videoId + * required: true + * schema: + * type: string + * description: 유튜브 비디오 ID + * responses: + * 200: + * description: 영상 상세 정보 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * videoId: + * type: string + * title: + * type: string + * description: + * type: string + * thumbnail: + * type: string + * channelName: + * type: string + * channelIcon: + * type: string + * viewCount: + * type: integer + * duration: + * type: string + * example: PT15M33S + * uploadedAt: + * type: string + * format: date-time + * 401: + * description: 인증 실패 + * 404: + * description: 영상 없음 + * 500: + * description: 서버 에러 + */ + +router.get('/:videoId', requireAuth, getYoutubeVideoDetail); + +export default router; diff --git a/src/services/youtubeDetailService.ts b/src/services/youtubeDetailService.ts new file mode 100644 index 0000000..9977076 --- /dev/null +++ b/src/services/youtubeDetailService.ts @@ -0,0 +1,52 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +type YoutubeVideoDetail = { + videoId: string; + title: string; + description?: string; + thumbnail?: string; + channelIcon?: string; + channelName?: string; + viewCount: number; + duration?: string; + uploadedAt?: string; +}; + +// 유튜브 영상 정보를 DB에 저장/업데이트하는 함수 +export const saveYoutubeVideo = async (video: YoutubeVideoDetail) => { + try { + const existingVideo = await prisma.youtubeVideo.findUnique({ + where: { videoId: video.videoId }, + }); + + const data = { + title: video.title, + description: video.description || null, + thumbnail: video.thumbnail || null, + channelIcon: video.channelIcon || null, + channelName: video.channelName || null, + viewCount: video.viewCount, + duration: video.duration || null, + uploadedAt: video.uploadedAt ? new Date(video.uploadedAt) : null, + }; + + if (existingVideo) { + return await prisma.youtubeVideo.update({ + where: { videoId: video.videoId }, + data, + }); + } else { + return await prisma.youtubeVideo.create({ + data: { + videoId: video.videoId, + ...data, + }, + }); + } + } catch (error) { + console.error('영상 저장 중 오류 발생:', error); + throw new Error('DB 저장 실패'); + } +}; From f3e296c1d4786a8f7a7085d990cffbb99e3ae269 Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 26 Jul 2025 03:44:24 +0900 Subject: [PATCH 03/59] Fix: Add authentication middleware (requireAuth) to search route --- src/routes/youtubeSearchRoute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/youtubeSearchRoute.ts b/src/routes/youtubeSearchRoute.ts index 8924429..0cf15e8 100644 --- a/src/routes/youtubeSearchRoute.ts +++ b/src/routes/youtubeSearchRoute.ts @@ -58,6 +58,6 @@ const router: Router = Router(); * 500: * description: YouTube API 요청 실패 */ -router.get('/search', requireAuth, searchYoutubeVideos); +router.get('/search', requireAuth, searchYoutubeVideos); // 인증 미들웨어 사용 export default router; From 247790af9918040fae19fcf6f5612f771dbdccc9 Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 26 Jul 2025 15:43:50 +0900 Subject: [PATCH 04/59] Chore: remove unnecessary folder --- src/youtube/search/dto/youtube-search.dto.ts | 4 - .../search/youtube-search.controller.ts | 102 ------------------ src/youtube/search/youtube-search.route.ts | 63 ----------- 3 files changed, 169 deletions(-) delete mode 100644 src/youtube/search/dto/youtube-search.dto.ts delete mode 100644 src/youtube/search/youtube-search.controller.ts delete mode 100644 src/youtube/search/youtube-search.route.ts diff --git a/src/youtube/search/dto/youtube-search.dto.ts b/src/youtube/search/dto/youtube-search.dto.ts deleted file mode 100644 index 9929a26..0000000 --- a/src/youtube/search/dto/youtube-search.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface YoutubeSearchDto { - query: string; - limit?: number; -} diff --git a/src/youtube/search/youtube-search.controller.ts b/src/youtube/search/youtube-search.controller.ts deleted file mode 100644 index fe1016a..0000000 --- a/src/youtube/search/youtube-search.controller.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Request, Response } from 'express'; -import axios from 'axios'; -import { YoutubeSearchDto } from './dto/youtube-search.dto'; - -type YoutubeSearchItem = { - id: { - videoId: string; - }; - snippet: { - title: string; - thumbnails: { - medium: { - url: string; - }; - }; - channelTitle: string; - }; -}; - -type YoutubeVideoResult = { - videoId: string; - title: string; - thumbnail: string; - channelName: string; - viewCount: number; - uploadTime: string; -}; - -// 명시적 타입으로 처리 (any 사용 X) -export const searchYoutubeVideos = async ( - req: Request, - res: Response, -): Promise => { - try { - const { query, limit = 10 } = req.query; - - // Authorization 헤더 확인 - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - res.status(401).json({ - success: false, - message: '인증 정보가 누락되었습니다.', - }); - return; - } - - const apiKey = process.env.YOUTUBE_API_KEY; - if (!apiKey) { - res.status(500).json({ - success: false, - message: '서버 설정 오류: YOUTUBE_API_KEY가 누락되었습니다.', - }); - return; - } - - const response = await axios.get('https://www.googleapis.com/youtube/v3/search', { - params: { - q: query, - part: 'snippet', - type: 'video', - maxResults: limit, - key: apiKey, - }, - }); - - const items = response.data.items as YoutubeSearchItem[]; - - const videoList: YoutubeVideoResult[] = await Promise.all( - items.map(async item => { - const statsRes = await axios.get('https://www.googleapis.com/youtube/v3/videos', { - params: { - part: 'statistics,snippet', - id: item.id.videoId, - key: apiKey, - }, - }); - - const videoData = statsRes.data.items[0]; - return { - videoId: item.id.videoId, - title: item.snippet.title, - thumbnail: item.snippet.thumbnails.medium.url, - channelName: item.snippet.channelTitle, - viewCount: parseInt(videoData.statistics.viewCount, 10), - uploadTime: videoData.snippet.publishedAt, - }; - }), - ); - - res.status(200).json({ - success: true, - data: videoList, - }); - } catch (error: unknown) { - console.error(error); - res.status(500).json({ - success: false, - message: 'YouTube API 요청 실패', - timestamp: new Date().toISOString(), - }); - } -}; diff --git a/src/youtube/search/youtube-search.route.ts b/src/youtube/search/youtube-search.route.ts deleted file mode 100644 index 18156c7..0000000 --- a/src/youtube/search/youtube-search.route.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Router } from 'express'; -import { searchYoutubeVideos } from './youtube-search.controller'; - -const router: Router = Router(); - -/** - * @swagger - * /youtube/search: - * get: - * summary: 유튜브 영상 검색 - * description: 키워드로 유튜브 영상을 검색합니다. - * tags: - * - YouTube - * security: - * - bearerAuth: [] - * parameters: - * - in: query - * name: query - * required: true - * schema: - * type: string - * description: 검색할 키워드 - * - in: query - * name: limit - * schema: - * type: integer - * default: 10 - * description: 검색할 영상 수 - * responses: - * 200: - * description: 검색 결과 - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * data: - * type: array - * items: - * type: object - * properties: - * videoId: - * type: string - * title: - * type: string - * thumbnail: - * type: string - * channelName: - * type: string - * viewCount: - * type: integer - * uploadTime: - * type: string - * 401: - * description: 인증 실패 - * 500: - * description: YouTube API 요청 실패 - */ -router.get('/search', searchYoutubeVideos); - -export default router; From 14292ac8399a7571bb324b6a9b72e8cc14f48d00 Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 26 Jul 2025 16:29:50 +0900 Subject: [PATCH 05/59] Chore: format code using Prettier --- src/routes/youtubeSearchRoute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/youtubeSearchRoute.ts b/src/routes/youtubeSearchRoute.ts index 0cf15e8..8924429 100644 --- a/src/routes/youtubeSearchRoute.ts +++ b/src/routes/youtubeSearchRoute.ts @@ -58,6 +58,6 @@ const router: Router = Router(); * 500: * description: YouTube API 요청 실패 */ -router.get('/search', requireAuth, searchYoutubeVideos); // 인증 미들웨어 사용 +router.get('/search', requireAuth, searchYoutubeVideos); export default router; From 533f93eddfc8051f933982b45ad0aea53b738985 Mon Sep 17 00:00:00 2001 From: ekdbss <136617606+ekdbss@users.noreply.github.com> Date: Sat, 26 Jul 2025 16:53:33 +0900 Subject: [PATCH 06/59] Update src/controllers/youtubeDetailController.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/controllers/youtubeDetailController.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/controllers/youtubeDetailController.ts b/src/controllers/youtubeDetailController.ts index 98f293c..33fd12e 100644 --- a/src/controllers/youtubeDetailController.ts +++ b/src/controllers/youtubeDetailController.ts @@ -10,11 +10,6 @@ export const getYoutubeVideoDetail = async ( try { const { videoId } = req.params; - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - sendError(res, '인증 정보가 누락되었습니다.', 401, 'UNAUTHORIZED'); - return; - } const apiKey = process.env.YOUTUBE_API_KEY; if (!apiKey) { From 9ee4d5ad15dbeda1038fc65d57310c779d66008a Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 26 Jul 2025 17:02:53 +0900 Subject: [PATCH 07/59] Chore: format code using Prettier --- src/controllers/youtubeDetailController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/controllers/youtubeDetailController.ts b/src/controllers/youtubeDetailController.ts index 33fd12e..aa04f2d 100644 --- a/src/controllers/youtubeDetailController.ts +++ b/src/controllers/youtubeDetailController.ts @@ -10,7 +10,6 @@ export const getYoutubeVideoDetail = async ( try { const { videoId } = req.params; - const apiKey = process.env.YOUTUBE_API_KEY; if (!apiKey) { sendError(res, '서버 설정 오류: YOUTUBE_API_KEY가 누락되었습니다.', 500, 'SERVER_ERROR'); From 6570822744ea993b14ae1d120d778161516a2c1e Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 26 Jul 2025 17:33:28 +0900 Subject: [PATCH 08/59] fix: add .js extensions to import paths in controller and route for deployment --- src/controllers/youtubeDetailController.ts | 2 +- src/routes/youtubeDetailRoute.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/youtubeDetailController.ts b/src/controllers/youtubeDetailController.ts index aa04f2d..5a01c6a 100644 --- a/src/controllers/youtubeDetailController.ts +++ b/src/controllers/youtubeDetailController.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import axios from 'axios'; import { sendError } from '../utils/response'; -import { saveYoutubeVideo } from '../services/youtubeDetailService'; +import { saveYoutubeVideo } from '../services/youtubeDetailService.js'; export const getYoutubeVideoDetail = async ( req: Request<{ videoId: string }>, diff --git a/src/routes/youtubeDetailRoute.ts b/src/routes/youtubeDetailRoute.ts index 85e8ab9..04dcaa9 100644 --- a/src/routes/youtubeDetailRoute.ts +++ b/src/routes/youtubeDetailRoute.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { getYoutubeVideoDetail } from '../controllers/youtubeDetailController'; +import { getYoutubeVideoDetail } from '../controllers/youtubeDetailController.js'; import { requireAuth } from '../middleware/authMiddleware'; const router = Router(); From 469a2aaa667498cae861205336c0478268fbf3b3 Mon Sep 17 00:00:00 2001 From: kdy Date: Mon, 28 Jul 2025 20:02:50 +0900 Subject: [PATCH 09/59] Fix: resolved merge conflict after pulling from upstream main --- src/controllers/youtubeDetailController.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/controllers/youtubeDetailController.ts b/src/controllers/youtubeDetailController.ts index a8d41db..3048e89 100644 --- a/src/controllers/youtubeDetailController.ts +++ b/src/controllers/youtubeDetailController.ts @@ -1,10 +1,6 @@ import { Request, Response } from 'express'; import axios from 'axios'; -<<<<<<< HEAD -import { sendError } from '../utils/response'; -======= import { sendError } from '../utils/response.js'; ->>>>>>> 33773204de50beff0503de18d3814af5eacfa240 import { saveYoutubeVideo } from '../services/youtubeDetailService.js'; export const getYoutubeVideoDetail = async ( From eba55a467f941b1bfce9045630990842f613c2f6 Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 19 Jul 2025 21:57:11 +0900 Subject: [PATCH 10/59] =?UTF-8?q?feat:=20Youtube=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/youtube/search/dto/youtube-search.dto.ts | 4 + .../search/youtube-search.controller.ts | 102 ++++++++++++++++++ src/youtube/search/youtube-search.route.ts | 63 +++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/youtube/search/dto/youtube-search.dto.ts create mode 100644 src/youtube/search/youtube-search.controller.ts create mode 100644 src/youtube/search/youtube-search.route.ts diff --git a/src/youtube/search/dto/youtube-search.dto.ts b/src/youtube/search/dto/youtube-search.dto.ts new file mode 100644 index 0000000..9929a26 --- /dev/null +++ b/src/youtube/search/dto/youtube-search.dto.ts @@ -0,0 +1,4 @@ +export interface YoutubeSearchDto { + query: string; + limit?: number; +} diff --git a/src/youtube/search/youtube-search.controller.ts b/src/youtube/search/youtube-search.controller.ts new file mode 100644 index 0000000..fe1016a --- /dev/null +++ b/src/youtube/search/youtube-search.controller.ts @@ -0,0 +1,102 @@ +import { Request, Response } from 'express'; +import axios from 'axios'; +import { YoutubeSearchDto } from './dto/youtube-search.dto'; + +type YoutubeSearchItem = { + id: { + videoId: string; + }; + snippet: { + title: string; + thumbnails: { + medium: { + url: string; + }; + }; + channelTitle: string; + }; +}; + +type YoutubeVideoResult = { + videoId: string; + title: string; + thumbnail: string; + channelName: string; + viewCount: number; + uploadTime: string; +}; + +// 명시적 타입으로 처리 (any 사용 X) +export const searchYoutubeVideos = async ( + req: Request, + res: Response, +): Promise => { + try { + const { query, limit = 10 } = req.query; + + // Authorization 헤더 확인 + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ + success: false, + message: '인증 정보가 누락되었습니다.', + }); + return; + } + + const apiKey = process.env.YOUTUBE_API_KEY; + if (!apiKey) { + res.status(500).json({ + success: false, + message: '서버 설정 오류: YOUTUBE_API_KEY가 누락되었습니다.', + }); + return; + } + + const response = await axios.get('https://www.googleapis.com/youtube/v3/search', { + params: { + q: query, + part: 'snippet', + type: 'video', + maxResults: limit, + key: apiKey, + }, + }); + + const items = response.data.items as YoutubeSearchItem[]; + + const videoList: YoutubeVideoResult[] = await Promise.all( + items.map(async item => { + const statsRes = await axios.get('https://www.googleapis.com/youtube/v3/videos', { + params: { + part: 'statistics,snippet', + id: item.id.videoId, + key: apiKey, + }, + }); + + const videoData = statsRes.data.items[0]; + return { + videoId: item.id.videoId, + title: item.snippet.title, + thumbnail: item.snippet.thumbnails.medium.url, + channelName: item.snippet.channelTitle, + viewCount: parseInt(videoData.statistics.viewCount, 10), + uploadTime: videoData.snippet.publishedAt, + }; + }), + ); + + res.status(200).json({ + success: true, + data: videoList, + }); + } catch (error: unknown) { + console.error(error); + res.status(500).json({ + success: false, + message: 'YouTube API 요청 실패', + timestamp: new Date().toISOString(), + }); + } +}; diff --git a/src/youtube/search/youtube-search.route.ts b/src/youtube/search/youtube-search.route.ts new file mode 100644 index 0000000..18156c7 --- /dev/null +++ b/src/youtube/search/youtube-search.route.ts @@ -0,0 +1,63 @@ +import { Router } from 'express'; +import { searchYoutubeVideos } from './youtube-search.controller'; + +const router: Router = Router(); + +/** + * @swagger + * /youtube/search: + * get: + * summary: 유튜브 영상 검색 + * description: 키워드로 유튜브 영상을 검색합니다. + * tags: + * - YouTube + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: query + * required: true + * schema: + * type: string + * description: 검색할 키워드 + * - in: query + * name: limit + * schema: + * type: integer + * default: 10 + * description: 검색할 영상 수 + * responses: + * 200: + * description: 검색 결과 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: array + * items: + * type: object + * properties: + * videoId: + * type: string + * title: + * type: string + * thumbnail: + * type: string + * channelName: + * type: string + * viewCount: + * type: integer + * uploadTime: + * type: string + * 401: + * description: 인증 실패 + * 500: + * description: YouTube API 요청 실패 + */ +router.get('/search', searchYoutubeVideos); + +export default router; From 007165ab1f11a298c0f620c626593834fe5bd221 Mon Sep 17 00:00:00 2001 From: kdy Date: Fri, 25 Jul 2025 22:22:17 +0900 Subject: [PATCH 11/59] =?UTF-8?q?feat:=20YouTube=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20DB=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/youtubeDetailController.ts | 10 ++++++++-- src/middleware/errors/errorHandler.ts | 6 ++---- src/routes/youtubeDetailRoute.ts | 4 ++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/controllers/youtubeDetailController.ts b/src/controllers/youtubeDetailController.ts index 3048e89..98f293c 100644 --- a/src/controllers/youtubeDetailController.ts +++ b/src/controllers/youtubeDetailController.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import axios from 'axios'; -import { sendError } from '../utils/response.js'; -import { saveYoutubeVideo } from '../services/youtubeDetailService.js'; +import { sendError } from '../utils/response'; +import { saveYoutubeVideo } from '../services/youtubeDetailService'; export const getYoutubeVideoDetail = async ( req: Request<{ videoId: string }>, @@ -10,6 +10,12 @@ export const getYoutubeVideoDetail = async ( try { const { videoId } = req.params; + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + sendError(res, '인증 정보가 누락되었습니다.', 401, 'UNAUTHORIZED'); + return; + } + const apiKey = process.env.YOUTUBE_API_KEY; if (!apiKey) { sendError(res, '서버 설정 오류: YOUTUBE_API_KEY가 누락되었습니다.', 500, 'SERVER_ERROR'); diff --git a/src/middleware/errors/errorHandler.ts b/src/middleware/errors/errorHandler.ts index 9c10855..c63a467 100644 --- a/src/middleware/errors/errorHandler.ts +++ b/src/middleware/errors/errorHandler.ts @@ -6,10 +6,8 @@ import { sendError } from '../../utils/response.js'; const errorHandler = (err: Error, req: Request, res: Response, _next: NextFunction) => { void _next; - // 개발 환경에서는 전체 에러 스택 출력 - if (process.env.NODE_ENV === 'development') { - console.error('Error Stack:', err.stack); - console.error('Error Details:', err); + if (err instanceof AppError) { + return sendError(res, err.message, err.statusCode); } // AppError 인스턴스인 경우 diff --git a/src/routes/youtubeDetailRoute.ts b/src/routes/youtubeDetailRoute.ts index 1df6b02..85e8ab9 100644 --- a/src/routes/youtubeDetailRoute.ts +++ b/src/routes/youtubeDetailRoute.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; -import { getYoutubeVideoDetail } from '../controllers/youtubeDetailController.js'; -import { requireAuth } from '../middleware/authMiddleware.js'; +import { getYoutubeVideoDetail } from '../controllers/youtubeDetailController'; +import { requireAuth } from '../middleware/authMiddleware'; const router = Router(); From 36a8c72203a4ccdb444128706aa2bf3dacd15cdb Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 26 Jul 2025 03:44:24 +0900 Subject: [PATCH 12/59] Fix: Add authentication middleware (requireAuth) to search route --- src/routes/youtubeSearchRoute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/youtubeSearchRoute.ts b/src/routes/youtubeSearchRoute.ts index 8924429..0cf15e8 100644 --- a/src/routes/youtubeSearchRoute.ts +++ b/src/routes/youtubeSearchRoute.ts @@ -58,6 +58,6 @@ const router: Router = Router(); * 500: * description: YouTube API 요청 실패 */ -router.get('/search', requireAuth, searchYoutubeVideos); +router.get('/search', requireAuth, searchYoutubeVideos); // 인증 미들웨어 사용 export default router; From 47a2c5265914c09ef35d7f08189ff69ee20d94a5 Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 26 Jul 2025 15:43:50 +0900 Subject: [PATCH 13/59] Chore: remove unnecessary folder --- src/youtube/search/dto/youtube-search.dto.ts | 4 - .../search/youtube-search.controller.ts | 102 ------------------ src/youtube/search/youtube-search.route.ts | 63 ----------- 3 files changed, 169 deletions(-) delete mode 100644 src/youtube/search/dto/youtube-search.dto.ts delete mode 100644 src/youtube/search/youtube-search.controller.ts delete mode 100644 src/youtube/search/youtube-search.route.ts diff --git a/src/youtube/search/dto/youtube-search.dto.ts b/src/youtube/search/dto/youtube-search.dto.ts deleted file mode 100644 index 9929a26..0000000 --- a/src/youtube/search/dto/youtube-search.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface YoutubeSearchDto { - query: string; - limit?: number; -} diff --git a/src/youtube/search/youtube-search.controller.ts b/src/youtube/search/youtube-search.controller.ts deleted file mode 100644 index fe1016a..0000000 --- a/src/youtube/search/youtube-search.controller.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Request, Response } from 'express'; -import axios from 'axios'; -import { YoutubeSearchDto } from './dto/youtube-search.dto'; - -type YoutubeSearchItem = { - id: { - videoId: string; - }; - snippet: { - title: string; - thumbnails: { - medium: { - url: string; - }; - }; - channelTitle: string; - }; -}; - -type YoutubeVideoResult = { - videoId: string; - title: string; - thumbnail: string; - channelName: string; - viewCount: number; - uploadTime: string; -}; - -// 명시적 타입으로 처리 (any 사용 X) -export const searchYoutubeVideos = async ( - req: Request, - res: Response, -): Promise => { - try { - const { query, limit = 10 } = req.query; - - // Authorization 헤더 확인 - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - res.status(401).json({ - success: false, - message: '인증 정보가 누락되었습니다.', - }); - return; - } - - const apiKey = process.env.YOUTUBE_API_KEY; - if (!apiKey) { - res.status(500).json({ - success: false, - message: '서버 설정 오류: YOUTUBE_API_KEY가 누락되었습니다.', - }); - return; - } - - const response = await axios.get('https://www.googleapis.com/youtube/v3/search', { - params: { - q: query, - part: 'snippet', - type: 'video', - maxResults: limit, - key: apiKey, - }, - }); - - const items = response.data.items as YoutubeSearchItem[]; - - const videoList: YoutubeVideoResult[] = await Promise.all( - items.map(async item => { - const statsRes = await axios.get('https://www.googleapis.com/youtube/v3/videos', { - params: { - part: 'statistics,snippet', - id: item.id.videoId, - key: apiKey, - }, - }); - - const videoData = statsRes.data.items[0]; - return { - videoId: item.id.videoId, - title: item.snippet.title, - thumbnail: item.snippet.thumbnails.medium.url, - channelName: item.snippet.channelTitle, - viewCount: parseInt(videoData.statistics.viewCount, 10), - uploadTime: videoData.snippet.publishedAt, - }; - }), - ); - - res.status(200).json({ - success: true, - data: videoList, - }); - } catch (error: unknown) { - console.error(error); - res.status(500).json({ - success: false, - message: 'YouTube API 요청 실패', - timestamp: new Date().toISOString(), - }); - } -}; diff --git a/src/youtube/search/youtube-search.route.ts b/src/youtube/search/youtube-search.route.ts deleted file mode 100644 index 18156c7..0000000 --- a/src/youtube/search/youtube-search.route.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Router } from 'express'; -import { searchYoutubeVideos } from './youtube-search.controller'; - -const router: Router = Router(); - -/** - * @swagger - * /youtube/search: - * get: - * summary: 유튜브 영상 검색 - * description: 키워드로 유튜브 영상을 검색합니다. - * tags: - * - YouTube - * security: - * - bearerAuth: [] - * parameters: - * - in: query - * name: query - * required: true - * schema: - * type: string - * description: 검색할 키워드 - * - in: query - * name: limit - * schema: - * type: integer - * default: 10 - * description: 검색할 영상 수 - * responses: - * 200: - * description: 검색 결과 - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * data: - * type: array - * items: - * type: object - * properties: - * videoId: - * type: string - * title: - * type: string - * thumbnail: - * type: string - * channelName: - * type: string - * viewCount: - * type: integer - * uploadTime: - * type: string - * 401: - * description: 인증 실패 - * 500: - * description: YouTube API 요청 실패 - */ -router.get('/search', searchYoutubeVideos); - -export default router; From e6b86c24b3974dc4ce88190470af843b5872ac85 Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 26 Jul 2025 16:29:50 +0900 Subject: [PATCH 14/59] Chore: format code using Prettier --- src/routes/youtubeSearchRoute.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/youtubeSearchRoute.ts b/src/routes/youtubeSearchRoute.ts index 0cf15e8..8924429 100644 --- a/src/routes/youtubeSearchRoute.ts +++ b/src/routes/youtubeSearchRoute.ts @@ -58,6 +58,6 @@ const router: Router = Router(); * 500: * description: YouTube API 요청 실패 */ -router.get('/search', requireAuth, searchYoutubeVideos); // 인증 미들웨어 사용 +router.get('/search', requireAuth, searchYoutubeVideos); export default router; From e8ef12f6500775a58aef3b292013b7617c4f9ee2 Mon Sep 17 00:00:00 2001 From: ekdbss <136617606+ekdbss@users.noreply.github.com> Date: Sat, 26 Jul 2025 16:53:33 +0900 Subject: [PATCH 15/59] Update src/controllers/youtubeDetailController.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/controllers/youtubeDetailController.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/controllers/youtubeDetailController.ts b/src/controllers/youtubeDetailController.ts index 98f293c..33fd12e 100644 --- a/src/controllers/youtubeDetailController.ts +++ b/src/controllers/youtubeDetailController.ts @@ -10,11 +10,6 @@ export const getYoutubeVideoDetail = async ( try { const { videoId } = req.params; - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - sendError(res, '인증 정보가 누락되었습니다.', 401, 'UNAUTHORIZED'); - return; - } const apiKey = process.env.YOUTUBE_API_KEY; if (!apiKey) { From d270b19a25f55951c3ae2e03b0422f0c455e19d4 Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 26 Jul 2025 17:02:53 +0900 Subject: [PATCH 16/59] Chore: format code using Prettier --- src/controllers/youtubeDetailController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/controllers/youtubeDetailController.ts b/src/controllers/youtubeDetailController.ts index 33fd12e..aa04f2d 100644 --- a/src/controllers/youtubeDetailController.ts +++ b/src/controllers/youtubeDetailController.ts @@ -10,7 +10,6 @@ export const getYoutubeVideoDetail = async ( try { const { videoId } = req.params; - const apiKey = process.env.YOUTUBE_API_KEY; if (!apiKey) { sendError(res, '서버 설정 오류: YOUTUBE_API_KEY가 누락되었습니다.', 500, 'SERVER_ERROR'); From dba47d0ba05c5ac78297813f1387209af88cb3d9 Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 26 Jul 2025 17:33:28 +0900 Subject: [PATCH 17/59] fix: add .js extensions to import paths in controller and route for deployment --- src/controllers/youtubeDetailController.ts | 2 +- src/routes/youtubeDetailRoute.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/youtubeDetailController.ts b/src/controllers/youtubeDetailController.ts index aa04f2d..5a01c6a 100644 --- a/src/controllers/youtubeDetailController.ts +++ b/src/controllers/youtubeDetailController.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import axios from 'axios'; import { sendError } from '../utils/response'; -import { saveYoutubeVideo } from '../services/youtubeDetailService'; +import { saveYoutubeVideo } from '../services/youtubeDetailService.js'; export const getYoutubeVideoDetail = async ( req: Request<{ videoId: string }>, diff --git a/src/routes/youtubeDetailRoute.ts b/src/routes/youtubeDetailRoute.ts index 85e8ab9..04dcaa9 100644 --- a/src/routes/youtubeDetailRoute.ts +++ b/src/routes/youtubeDetailRoute.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { getYoutubeVideoDetail } from '../controllers/youtubeDetailController'; +import { getYoutubeVideoDetail } from '../controllers/youtubeDetailController.js'; import { requireAuth } from '../middleware/authMiddleware'; const router = Router(); From 3c38a3be6c077cf7152c9cc252b2035716c70b70 Mon Sep 17 00:00:00 2001 From: kdy Date: Mon, 28 Jul 2025 20:02:50 +0900 Subject: [PATCH 18/59] Fix: resolved merge conflict after pulling from upstream main --- src/controllers/youtubeDetailController.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/controllers/youtubeDetailController.ts b/src/controllers/youtubeDetailController.ts index 5a01c6a..dee274e 100644 --- a/src/controllers/youtubeDetailController.ts +++ b/src/controllers/youtubeDetailController.ts @@ -1,6 +1,10 @@ import { Request, Response } from 'express'; import axios from 'axios'; +<<<<<<< HEAD import { sendError } from '../utils/response'; +======= +import { sendError } from '../utils/response.js'; +>>>>>>> 469a2aa (Fix: resolved merge conflict after pulling from upstream main) import { saveYoutubeVideo } from '../services/youtubeDetailService.js'; export const getYoutubeVideoDetail = async ( From 24fe5e0a7001d7206d4505848bcd3afb3c577259 Mon Sep 17 00:00:00 2001 From: kdy Date: Wed, 30 Jul 2025 17:46:22 +0900 Subject: [PATCH 19/59] =?UTF-8?q?Feat:=20bookmark=20API=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20=EB=B0=8F=20schema=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 13 ++ package.json | 1 + .../migration.sql | 49 +++++ prisma/schema.prisma | 8 + src/app.ts | 2 + src/controllers/bookmarkController.ts | 129 ++++++++++++ src/controllers/youtubeDetailController.ts | 4 - src/dtos/bookmarkDto.ts | 14 ++ src/routes/bookmarkRoute.ts | 189 ++++++++++++++++++ src/services/bookmarkService.ts | 142 +++++++++++++ 10 files changed, 547 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20250730072811_add_timeline_and_start_fields/migration.sql create mode 100644 src/controllers/bookmarkController.ts create mode 100644 src/dtos/bookmarkDto.ts create mode 100644 src/routes/bookmarkRoute.ts create mode 100644 src/services/bookmarkService.ts diff --git a/package-lock.json b/package-lock.json index 0211f5b..845ad9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@types/cors": "^2.8.19", "@types/dotenv": "^8.2.3", "@types/express": "^5.0.3", + "@types/ioredis": "^4.28.10", "@types/node": "^24.0.10", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", @@ -560,6 +561,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/@socket.io/redis-adapter/-/redis-adapter-8.3.0.tgz", "integrity": "sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==", + "license": "MIT", "dependencies": { "debug": "~4.3.1", "notepack.io": "~3.0.1", @@ -723,6 +725,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ioredis": { + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2713,6 +2725,7 @@ "version": "5.6.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", + "license": "MIT", "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", diff --git a/package.json b/package.json index 555deb4..ae2c2be 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/cors": "^2.8.19", "@types/dotenv": "^8.2.3", "@types/express": "^5.0.3", + "@types/ioredis": "^4.28.10", "@types/node": "^24.0.10", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", diff --git a/prisma/migrations/20250730072811_add_timeline_and_start_fields/migration.sql b/prisma/migrations/20250730072811_add_timeline_and_start_fields/migration.sql new file mode 100644 index 0000000..b2f86ea --- /dev/null +++ b/prisma/migrations/20250730072811_add_timeline_and_start_fields/migration.sql @@ -0,0 +1,49 @@ +/* + Warnings: + + - You are about to alter the column `video_id` on the `rooms` table. The data in that column could be lost. The data in that column will be cast from `VarChar(191)` to `VarChar(20)`. + - Added the required column `updated_at` to the `collections` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE `rooms` DROP FOREIGN KEY `rooms_video_id_fkey`; + +-- AlterTable +ALTER TABLE `bookmarks` ADD COLUMN `collection_id` INTEGER NULL, + ADD COLUMN `timeline` INTEGER NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE `collections` ADD COLUMN `cover_image` VARCHAR(500) NULL, + ADD COLUMN `updated_at` DATETIME(3) NOT NULL; + +-- AlterTable +ALTER TABLE `room_participants` ADD COLUMN `last_joined_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + ADD COLUMN `total_stay_time` INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE `rooms` ADD COLUMN `startTime` INTEGER NOT NULL DEFAULT 0, + ADD COLUMN `startType` ENUM('BOOKMARK', 'BEGINNING') NOT NULL DEFAULT 'BEGINNING', + MODIFY `video_id` VARCHAR(20) NULL; + +-- CreateTable +CREATE TABLE `user_feedbacks` ( + `feedback_id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `content` TEXT NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `user_feedbacks_user_id_fkey`(`user_id`), + PRIMARY KEY (`feedback_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateIndex +CREATE INDEX `bookmarks_collection_id_fkey` ON `bookmarks`(`collection_id`); + +-- AddForeignKey +ALTER TABLE `rooms` ADD CONSTRAINT `rooms_video_id_fkey` FOREIGN KEY (`video_id`) REFERENCES `youtube_videos`(`video_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `bookmarks` ADD CONSTRAINT `bookmarks_collection_id_fkey` FOREIGN KEY (`collection_id`) REFERENCES `collections`(`collection_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_feedbacks` ADD CONSTRAINT `user_feedbacks_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd79635..3c88af7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -102,6 +102,8 @@ model Room { autoArchive Boolean @default(true) @map("auto_archive") inviteAuth InviteAuth @default(all) @map("invite_auth") watched30s Boolean @default(false) @map("watched_30s") + startType VideoStartType @default(BEGINNING) + startTime Int @default(0) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") bookmarks Bookmark[] @@ -184,6 +186,7 @@ model Bookmark { collectionId Int? @map("collection_id") title String? @db.VarChar(50) content String? @db.Text + timeline Int? @default(0) originalBookmarkId Int? @map("original_bookmark_id") createdAt DateTime @default(now()) @map("created_at") originalBookmark Bookmark? @relation("BookmarkCopy", fields: [originalBookmarkId], references: [bookmarkId]) @@ -343,6 +346,11 @@ enum ChatMessageType { @@map("chat_message_type") } +enum VideoStartType { + BOOKMARK + BEGINNING +} + enum CollectionVisibility { private friends diff --git a/src/app.ts b/src/app.ts index 680ce69..7026a9e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,7 @@ import youtubeDetailRouter from './routes/youtubeDetailRoute.js'; import authRoutes from './routes/authRoutes.js'; import userRoutes from './routes/userRoutes.js'; import friendRoutes from './routes/friendRoutes.js'; +import bookmarkRoutes from './routes/bookmarkRoute.js'; import swaggerUi from 'swagger-ui-express'; import { specs } from './swagger.js'; import { createServer } from 'http'; @@ -148,6 +149,7 @@ app.use('/api/chat/direct', chatDirectRoutes); app.use('/api/youtube', youtubeRoutes); app.use('/api/youtube', youtubeSearchRouter); app.use('/api/youtube/videos', youtubeDetailRouter); +app.use('/api/bookmarks', bookmarkRoutes); // 404 에러 핸들링 app.use((req: Request, res: Response, next: NextFunction) => { diff --git a/src/controllers/bookmarkController.ts b/src/controllers/bookmarkController.ts new file mode 100644 index 0000000..0540917 --- /dev/null +++ b/src/controllers/bookmarkController.ts @@ -0,0 +1,129 @@ +import { Request, Response, NextFunction } from 'express'; +import { sendError, sendSuccess } from '../utils/response'; +import AppError from '../middleware/errors/AppError.js'; +import * as bookmarkService from '../services/bookmarkService'; + +// 1. 북마크 생성 API (POST /bookmarks) +export const createBookmark = async (req: Request, res: Response, next: NextFunction) => { + try { + const { roomId, message } = req.body; + const userId = req.user?.userId; + + if (userId === undefined) { + return sendError(res, '유저 정보가 없습니다.', 401); + } + + const bookmark = await bookmarkService.createBookmark(userId, roomId, message); + + sendSuccess(res, { + bookmarkId: bookmark.bookmarkId, + message: '북마크가 생성되었습니다.', + }); + } catch (err) { + if (err instanceof Error) { + return next(new AppError(err.message)); + } + next(new AppError('북마크 생성 중 알 수 없는 오류가 발생했습니다.')); + } +}; + +// 2. 북마크 목록 조회 API (GET /bookmarks) +export const getBookmarks = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.userId; + const { collectionId, uncategorized } = req.query; + + if (userId === undefined) { + return sendError(res, '유저 정보가 없습니다.', 401); + } + + const result = await bookmarkService.getBookmarks(userId, { + collectionId: collectionId ? Number(collectionId) : undefined, + uncategorized: uncategorized === 'true', + }); + + sendSuccess(res, result); + } catch (err) { + if (err instanceof Error) { + return next(new AppError(err.message)); + } + next(new AppError('북마크 목록 조회 중 알 수 없는 오류가 발생했습니다.')); + } +}; + +// 3. 북마크 삭제 API (DELETE /bookmarks/:bookmarkId) +export const deleteBookmark = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.userId; + const bookmarkId = Number(req.params.bookmarkId); + + if (userId === undefined) { + return sendError(res, '유저 정보가 없습니다.', 401); + } + + await bookmarkService.deleteBookmark(userId, bookmarkId); + + sendSuccess(res, { + message: '북마크가 삭제되었습니다.', + }); + } catch (err) { + if (err instanceof Error) { + return next(new AppError(err.message)); + } + next(new AppError('북마크 삭제 중 알 수 없는 오류가 발생했습니다.')); + } +}; + +// 4. 북마크 컬렉션 이동 API (PUT /bookmarks/:bookmarkId/collection) +export const moveBookmarkToCollection = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.userId; + const bookmarkId = Number(req.params.bookmarkId); + const { collectionId } = req.body; + + if (userId === undefined) { + return sendError(res, '유저 정보가 없습니다.', 401); + } + + await bookmarkService.moveBookmarkToCollection(userId, bookmarkId, collectionId); + + sendSuccess(res, { message: '북마크가 이동되었습니다.' }); + } catch (err) { + if (err instanceof Error) { + return next(new AppError(err.message)); + } + next(new AppError('북마크 컬렉션 이동 중 알 수 없는 오류가 발생했습니다.')); + } +}; + +// 5. 북마크로 방 생성 API (POST /bookmarks/:bookmarkId/create-room) +export const createRoomFromBookmark = async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.userId; + const bookmarkId = Number(req.params.bookmarkId); + const { videoId, roomTitle, maxParticipants, isPrivate } = req.body; + + if (userId === undefined) { + return sendError(res, '유저 정보가 없습니다.', 401); + } + + const newRoom = await bookmarkService.createRoomFromBookmark( + userId, + bookmarkId, + videoId, + roomTitle, + maxParticipants, + isPrivate, + ); + + sendSuccess(res, { + roomId: newRoom.roomId, + message: '방이 생성되었습니다.', + }); + } catch (err) { + if (err instanceof Error) { + return next(new AppError(err.message)); + } + next(new AppError('북마크로 방 생성 중 알 수 없는 오류가 발생했습니다.')); + } +}; diff --git a/src/controllers/youtubeDetailController.ts b/src/controllers/youtubeDetailController.ts index dee274e..3048e89 100644 --- a/src/controllers/youtubeDetailController.ts +++ b/src/controllers/youtubeDetailController.ts @@ -1,10 +1,6 @@ import { Request, Response } from 'express'; import axios from 'axios'; -<<<<<<< HEAD -import { sendError } from '../utils/response'; -======= import { sendError } from '../utils/response.js'; ->>>>>>> 469a2aa (Fix: resolved merge conflict after pulling from upstream main) import { saveYoutubeVideo } from '../services/youtubeDetailService.js'; export const getYoutubeVideoDetail = async ( diff --git a/src/dtos/bookmarkDto.ts b/src/dtos/bookmarkDto.ts new file mode 100644 index 0000000..02ddc2e --- /dev/null +++ b/src/dtos/bookmarkDto.ts @@ -0,0 +1,14 @@ +export interface CreateBookmarkDto { + roomId: number; + message: string; +} + +export interface MoveBookmarkDto { + collectionId: number; +} + +export interface CreateRoomFromBookmarkDto { + roomTitle: string; + maxParticipants: number; + isPrivate: boolean; +} diff --git a/src/routes/bookmarkRoute.ts b/src/routes/bookmarkRoute.ts new file mode 100644 index 0000000..38a6df9 --- /dev/null +++ b/src/routes/bookmarkRoute.ts @@ -0,0 +1,189 @@ +import express from 'express'; +import { + createBookmark, + getBookmarks, + deleteBookmark, + moveBookmarkToCollection, + createRoomFromBookmark, +} from '../controllers/bookmarkController'; +import { requireAuth } from '../middleware/authMiddleware'; + +const router = express.Router(); + +/** + * @swagger + * tags: + * name: Bookmarks + * description: 북마크 관련 API + */ + +/** + * @swagger + * /api/bookmarks: + * post: + * summary: 북마크 생성 + * tags: [Bookmarks] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * roomId: + * type: number + * message: + * type: string + * required: + * - roomId + * - message + * responses: + * 200: + * description: 북마크 생성 성공 + * 401: + * description: 인증 실패 + */ +// 1. 북마크 생성 +router.post('/', requireAuth, createBookmark); + +/** + * @swagger + * /api/bookmarks: + * get: + * summary: 북마크 목록 조회 + * tags: [Bookmarks] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: collectionId + * schema: + * type: number + * required: false + * description: 특정 컬렉션의 북마크만 조회 + * - in: query + * name: uncategorized + * schema: + * type: boolean + * required: false + * description: 컬렉션 미지정 북마크만 조회 + * responses: + * 200: + * description: 북마크 목록 조회 성공 + * 401: + * description: 인증 실패 + */ +// 2. 북마크 목록 조회 +router.get('/', requireAuth, getBookmarks); + +/** + * @swagger + * /api/bookmarks/{bookmarkId}: + * delete: + * summary: 북마크 삭제 + * tags: [Bookmarks] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: bookmarkId + * required: true + * schema: + * type: number + * description: 삭제할 북마크 ID + * responses: + * 200: + * description: 북마크 삭제 성공 + * 401: + * description: 인증 실패 + * 404: + * description: 북마크를 찾을 수 없음 + */ +// 3. 북마크 삭제 +router.delete('/:bookmarkId', requireAuth, deleteBookmark); + +/** + * @swagger + * /api/bookmarks/{bookmarkId}/collection: + * put: + * summary: 북마크 컬렉션 이동 + * tags: [Bookmarks] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: bookmarkId + * required: true + * schema: + * type: number + * description: 이동할 북마크 ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * collectionId: + * type: number + * required: + * - collectionId + * responses: + * 200: + * description: 컬렉션 이동 성공 + * 401: + * description: 인증 실패 + */ +// 4. 북마크 컬렉션 이동 +router.put('/:bookmarkId/collection', requireAuth, moveBookmarkToCollection); + +/** + * @swagger + * /api/bookmarks/{bookmarkId}/create-room: + * post: + * summary: 북마크를 기반으로 방 생성 + * tags: [Bookmarks] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: bookmarkId + * required: true + * schema: + * type: number + * description: 기준이 되는 북마크 ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * roomTitle: + * type: string + * maxParticipants: + * type: number + * isPublic: + * type: boolean + * startFrom: + * type: string + * enum: [BOOKMARK, BEGINNING] + * required: + * - roomTitle + * - maxParticipants + * - isPublic + * - startFrom + * responses: + * 200: + * description: 방 생성 성공 + * 401: + * description: 인증 실패 + * 403: + * description: 권한 없음 + */ +// 5. 북마크로 방 생성 +router.post('/:bookmarkId/create-room', requireAuth, createRoomFromBookmark); + +export default router; diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts new file mode 100644 index 0000000..4bf560c --- /dev/null +++ b/src/services/bookmarkService.ts @@ -0,0 +1,142 @@ +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); + +// 1. 북마크 생성 서비스 +export const createBookmark = async (userId: number, roomId: number, message: string) => { + return await prisma.bookmark.create({ + data: { + userId, + roomId, + content: message, + }, + }); +}; + +// 2. 북마크 목록 조회 서비스 +export const getBookmarks = async ( + userId: number, + options: { collectionId?: number; uncategorized?: boolean }, +) => { + const { collectionId, uncategorized } = options; + + const bookmarks = await prisma.bookmark.findMany({ + where: { + userId, + ...(collectionId !== undefined && { collectionId }), + ...(uncategorized && { collectionId: null }), + }, + orderBy: { createdAt: 'desc' }, + include: { + room: { + select: { + roomName: true, + video: { + select: { + title: true, + thumbnail: true, + }, + }, + }, + }, + collection: { + select: { + title: true, // 북마크 컬렉션 제목 + }, + }, + }, + }); + + const formatted = bookmarks.map(bookmark => ({ + bookmarkId: bookmark.bookmarkId, + videoTitle: bookmark.room?.video?.title, + videoThumbnail: bookmark.room?.video?.thumbnail, + message: bookmark.content, + createdAt: bookmark.createdAt, + collectionTitle: bookmark.collection?.title ?? null, + })); + + if (uncategorized) { + return { uncategorized: formatted, all: [] }; + } else { + return { uncategorized: [], all: formatted }; + } +}; + +// 3. 북마크 삭제 서비스 +export const deleteBookmark = async (userId: number, bookmarkId: number) => { + const bookmark = await prisma.bookmark.findUnique({ + where: { bookmarkId }, + }); + + if (!bookmark || bookmark.userId !== userId) { + throw new Error('권한이 없습니다.'); + } + + await prisma.bookmark.delete({ where: { bookmarkId } }); +}; + +// 4. 북마크 컬렉션 이동 서비스 +export const moveBookmarkToCollection = async ( + userId: number, + bookmarkId: number, + collectionId: number, +) => { + const bookmark = await prisma.bookmark.findUnique({ + where: { bookmarkId }, + }); + + if (!bookmark || bookmark.userId !== userId) { + throw new Error('권한이 없습니다.'); + } + + return await prisma.bookmark.update({ + where: { bookmarkId }, + data: { collectionId }, + }); +}; + +// 5. 북마크로 방 생성 서비스 +export const createRoomFromBookmark = async ( + userId: number, + bookmarkId: number, + roomTitle: string, + maxParticipants: number, + isPublic: boolean, + startFrom: 'BOOKMARK' | 'BEGINNING', +) => { + const bookmark = await prisma.bookmark.findUnique({ + where: { bookmarkId }, + include: { + room: { + include: { + video: true, + }, + }, + }, + }); + + if (!bookmark || bookmark.userId !== userId) { + throw new Error('해당 북마크에 대한 권한이 없습니다.'); + } + + const videoThumbnail = bookmark.room?.video?.thumbnail ?? ''; + const startTime = startFrom === 'BOOKMARK' ? (bookmark?.timeline ?? 0) : 0; + + const newRoom = await prisma.room.create({ + data: { + roomName: roomTitle, + maxParticipants, + isPublic, + videoId: bookmark.room.videoId, + hostId: userId, + startType: startFrom, + startTime: startTime, + }, + }); + + return { + roomId: newRoom.roomId, + thumbnail: videoThumbnail, + message: '방이 생성되었습니다.', + }; +}; From cfcff4591412488de24863db01ccfa3dc9dc1d5e Mon Sep 17 00:00:00 2001 From: kdy Date: Wed, 30 Jul 2025 22:19:01 +0900 Subject: [PATCH 20/59] =?UTF-8?q?Feat:=20Bookmark=20message,=20timeline=20?= =?UTF-8?q?parsing=EC=9D=84=20=EC=9C=84=ED=95=9C=20util=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/parseBookmark.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/utils/parseBookmark.ts diff --git a/src/utils/parseBookmark.ts b/src/utils/parseBookmark.ts new file mode 100644 index 0000000..9c20f6e --- /dev/null +++ b/src/utils/parseBookmark.ts @@ -0,0 +1,25 @@ +export const tryParseBookmarkMessage = ( + raw: string, +): { timeline: number; content: string } | null => { + const trimmed = raw.trim(); + + // HH:MM:SS 또는 MM:SS 정규표현식 + const regex = /^(\d{1,2}:)?(\d{1,2}):(\d{2})\s*(.*)$/; + const match = trimmed.match(regex); + + if (!match) return null; + + const [, hourStr, minStr, secStr, content] = match; + const hours = hourStr ? parseInt(hourStr.replace(':', ''), 10) : 0; + const minutes = parseInt(minStr, 10); + const seconds = parseInt(secStr, 10); + + if (minutes >= 60 || seconds >= 60) return null; + + const timeline = hours * 3600 + minutes * 60 + seconds; + + return { + timeline, + content: content.trim(), + }; +}; From 7692c40c6253e98f309e42b7092c7291e847759f Mon Sep 17 00:00:00 2001 From: kdy Date: Wed, 30 Jul 2025 22:21:07 +0900 Subject: [PATCH 21/59] =?UTF-8?q?Fix:=20=EB=B6=81=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=20parsing=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20timeline=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/bookmarkController.ts | 13 ++++++++++--- src/routes/bookmarkRoute.ts | 13 +++++++++++-- src/services/bookmarkService.ts | 9 ++++++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/controllers/bookmarkController.ts b/src/controllers/bookmarkController.ts index 0540917..4066818 100644 --- a/src/controllers/bookmarkController.ts +++ b/src/controllers/bookmarkController.ts @@ -1,7 +1,8 @@ import { Request, Response, NextFunction } from 'express'; -import { sendError, sendSuccess } from '../utils/response'; +import { sendError, sendSuccess } from '../utils/response.js'; import AppError from '../middleware/errors/AppError.js'; -import * as bookmarkService from '../services/bookmarkService'; +import * as bookmarkService from '../services/bookmarkService.js'; +import { tryParseBookmarkMessage } from '../utils/parseBookmark.js'; // 1. 북마크 생성 API (POST /bookmarks) export const createBookmark = async (req: Request, res: Response, next: NextFunction) => { @@ -13,7 +14,13 @@ export const createBookmark = async (req: Request, res: Response, next: NextFunc return sendError(res, '유저 정보가 없습니다.', 401); } - const bookmark = await bookmarkService.createBookmark(userId, roomId, message); + const result = tryParseBookmarkMessage(message); + if (!result) { + return sendError(res, '메시지에서 유효한 타임라인을 찾을 수 없습니다.', 400); + } + const { timeline, content } = result; + + const bookmark = await bookmarkService.createBookmark(userId, roomId, content, timeline); sendSuccess(res, { bookmarkId: bookmark.bookmarkId, diff --git a/src/routes/bookmarkRoute.ts b/src/routes/bookmarkRoute.ts index 38a6df9..da3aad0 100644 --- a/src/routes/bookmarkRoute.ts +++ b/src/routes/bookmarkRoute.ts @@ -5,8 +5,8 @@ import { deleteBookmark, moveBookmarkToCollection, createRoomFromBookmark, -} from '../controllers/bookmarkController'; -import { requireAuth } from '../middleware/authMiddleware'; +} from '../controllers/bookmarkController.js'; +import { requireAuth } from '../middleware/authMiddleware.js'; const router = express.Router(); @@ -34,17 +34,26 @@ const router = express.Router(); * properties: * roomId: * type: number + * example: 1 * message: * type: string + * example: "01:23:45 문문문보경이 나왔다" + * description: | + * 북마크 메시지는 `MM:SS 내용` 또는 `HH:MM:SS 내용` 형식으로 작성해야 합니다. + * - 예: "12:34 삼구삼진", "01:02:30 병살 가자" + * - 앞의 시간은 북마크 타임라인으로, 뒤는 내용으로 저장됩니다. * required: * - roomId * - message * responses: * 200: * description: 북마크 생성 성공 + * 400: + * description: 잘못된 형식의 메시지 * 401: * description: 인증 실패 */ + // 1. 북마크 생성 router.post('/', requireAuth, createBookmark); diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index 4bf560c..31065ee 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -2,12 +2,18 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); // 1. 북마크 생성 서비스 -export const createBookmark = async (userId: number, roomId: number, message: string) => { +export const createBookmark = async ( + userId: number, + roomId: number, + message: string, + timeline: number, +) => { return await prisma.bookmark.create({ data: { userId, roomId, content: message, + timeline, }, }); }; @@ -51,6 +57,7 @@ export const getBookmarks = async ( videoTitle: bookmark.room?.video?.title, videoThumbnail: bookmark.room?.video?.thumbnail, message: bookmark.content, + timeline: bookmark.timeline, createdAt: bookmark.createdAt, collectionTitle: bookmark.collection?.title ?? null, })); From d09a695870cdaa3377b30351299bd6bf2bab01e1 Mon Sep 17 00:00:00 2001 From: ekdbss <136617606+ekdbss@users.noreply.github.com> Date: Thu, 31 Jul 2025 00:59:33 +0900 Subject: [PATCH 22/59] Update src/dtos/bookmarkDto.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/dtos/bookmarkDto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dtos/bookmarkDto.ts b/src/dtos/bookmarkDto.ts index 02ddc2e..af74639 100644 --- a/src/dtos/bookmarkDto.ts +++ b/src/dtos/bookmarkDto.ts @@ -10,5 +10,5 @@ export interface MoveBookmarkDto { export interface CreateRoomFromBookmarkDto { roomTitle: string; maxParticipants: number; - isPrivate: boolean; + isPublic: boolean; } From 8ee9f1114d8155c783e57692164c0041904571e5 Mon Sep 17 00:00:00 2001 From: ekdbss <136617606+ekdbss@users.noreply.github.com> Date: Thu, 31 Jul 2025 01:03:49 +0900 Subject: [PATCH 23/59] Update src/controllers/bookmarkController.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/controllers/bookmarkController.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/controllers/bookmarkController.ts b/src/controllers/bookmarkController.ts index 4066818..744041f 100644 --- a/src/controllers/bookmarkController.ts +++ b/src/controllers/bookmarkController.ts @@ -14,6 +14,14 @@ export const createBookmark = async (req: Request, res: Response, next: NextFunc return sendError(res, '유저 정보가 없습니다.', 401); } + if (!roomId || typeof roomId !== 'number') { + return sendError(res, '유효한 방 ID가 필요합니다.', 400); + } + + if (!message || typeof message !== 'string' || message.trim().length === 0) { + return sendError(res, '메시지가 필요합니다.', 400); + } + const result = tryParseBookmarkMessage(message); if (!result) { return sendError(res, '메시지에서 유효한 타임라인을 찾을 수 없습니다.', 400); From 92833dc1a27c5bf8d9fbf914697765a7b9f2e9ab Mon Sep 17 00:00:00 2001 From: kdy Date: Thu, 31 Jul 2025 15:03:54 +0900 Subject: [PATCH 24/59] =?UTF-8?q?Refactor:=20=EB=B6=81=EB=A7=88=ED=81=AC?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=EB=B0=A9=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/bookmarkController.ts | 50 ++++++++++++++++++++++++--- src/dtos/bookmarkDto.ts | 1 + src/services/bookmarkService.ts | 4 +++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/controllers/bookmarkController.ts b/src/controllers/bookmarkController.ts index 744041f..97ea0bd 100644 --- a/src/controllers/bookmarkController.ts +++ b/src/controllers/bookmarkController.ts @@ -52,8 +52,17 @@ export const getBookmarks = async (req: Request, res: Response, next: NextFuncti return sendError(res, '유저 정보가 없습니다.', 401); } + let parsedCollectionId: number | undefined; + if (collectionId) { + const num = Number(collectionId); + if (isNaN(num)) { + return sendError(res, '유효하지 않은 컬렉션 ID입니다.', 400); + } + parsedCollectionId = num; + } + const result = await bookmarkService.getBookmarks(userId, { - collectionId: collectionId ? Number(collectionId) : undefined, + collectionId: parsedCollectionId, uncategorized: uncategorized === 'true', }); @@ -72,6 +81,10 @@ export const deleteBookmark = async (req: Request, res: Response, next: NextFunc const userId = req.user?.userId; const bookmarkId = Number(req.params.bookmarkId); + if (isNaN(bookmarkId)) { + return sendError(res, '유효하지 않은 북마크 ID입니다.', 400); + } + if (userId === undefined) { return sendError(res, '유저 정보가 없습니다.', 401); } @@ -100,6 +113,18 @@ export const moveBookmarkToCollection = async (req: Request, res: Response, next return sendError(res, '유저 정보가 없습니다.', 401); } + if (isNaN(bookmarkId)) { + return sendError(res, '유효하지 않은 북마크 ID입니다.', 400); + } + + if ( + collectionId !== null && + collectionId !== undefined && + (typeof collectionId !== 'number' || isNaN(collectionId)) + ) { + return sendError(res, '유효하지 않은 컬렉션 ID입니다.', 400); + } + await bookmarkService.moveBookmarkToCollection(userId, bookmarkId, collectionId); sendSuccess(res, { message: '북마크가 이동되었습니다.' }); @@ -116,23 +141,40 @@ export const createRoomFromBookmark = async (req: Request, res: Response, next: try { const userId = req.user?.userId; const bookmarkId = Number(req.params.bookmarkId); - const { videoId, roomTitle, maxParticipants, isPrivate } = req.body; + const { roomTitle, maxParticipants, isPublic, startFrom } = req.body; if (userId === undefined) { return sendError(res, '유저 정보가 없습니다.', 401); } + if (isNaN(bookmarkId)) { + return sendError(res, '유효하지 않은 북마크 ID입니다.', 400); + } + + if (!roomTitle || typeof roomTitle !== 'string' || roomTitle.trim().length === 0) { + return sendError(res, '방 제목이 필요합니다.', 400); + } + + if (typeof maxParticipants !== 'number' || ![8, 15, 30].includes(maxParticipants)) { + return sendError(res, '최대 참가자 수는 8, 15, 30 중 하나여야 합니다.', 400); + } + + if (typeof isPublic !== 'boolean') { + return sendError(res, '방 공개 여부는 불린 타입이어야 합니다.', 400); + } + const newRoom = await bookmarkService.createRoomFromBookmark( userId, bookmarkId, - videoId, roomTitle, maxParticipants, - isPrivate, + isPublic, + startFrom ?? 'BOOKMARK', ); sendSuccess(res, { roomId: newRoom.roomId, + thumbnail: newRoom.thumbnail, message: '방이 생성되었습니다.', }); } catch (err) { diff --git a/src/dtos/bookmarkDto.ts b/src/dtos/bookmarkDto.ts index af74639..797a0b7 100644 --- a/src/dtos/bookmarkDto.ts +++ b/src/dtos/bookmarkDto.ts @@ -11,4 +11,5 @@ export interface CreateRoomFromBookmarkDto { roomTitle: string; maxParticipants: number; isPublic: boolean; + startFrom: 'BOOKMARK' | 'BEGINNING'; } diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index 31065ee..ee7076d 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -129,6 +129,10 @@ export const createRoomFromBookmark = async ( const videoThumbnail = bookmark.room?.video?.thumbnail ?? ''; const startTime = startFrom === 'BOOKMARK' ? (bookmark?.timeline ?? 0) : 0; + if (!bookmark.room?.videoId) { + throw new Error('북마크에 연결된 비디오를 찾을 수 없습니다.'); + } + const newRoom = await prisma.room.create({ data: { roomName: roomTitle, From 2d39a0a707bd755bd892746a8d05bc2bc8d20a1c Mon Sep 17 00:00:00 2001 From: kdy Date: Thu, 31 Jul 2025 15:10:22 +0900 Subject: [PATCH 25/59] feat: Add validation for maxParticipants in createRoomFromBookmark --- src/services/bookmarkService.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index ee7076d..4ea8f2c 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -111,6 +111,15 @@ export const createRoomFromBookmark = async ( isPublic: boolean, startFrom: 'BOOKMARK' | 'BEGINNING', ) => { + // 입력 값 검증 + if (!roomTitle?.trim()) { + throw new Error('방 제목은 필수입니다.'); + } + + if (maxParticipants !== 8 && maxParticipants !== 15 && maxParticipants !== 30) { + throw new Error('최대 참가자 수는 8, 15, 30 중 하나여야 합니다.'); + } + const bookmark = await prisma.bookmark.findUnique({ where: { bookmarkId }, include: { From 487a5ffaba7c017cecd845db11d9bbc3f3d8781c Mon Sep 17 00:00:00 2001 From: kdy Date: Thu, 31 Jul 2025 15:47:45 +0900 Subject: [PATCH 26/59] Add /api prefix to bookmark-related endpoints --- src/controllers/bookmarkController.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/controllers/bookmarkController.ts b/src/controllers/bookmarkController.ts index 97ea0bd..938a9ae 100644 --- a/src/controllers/bookmarkController.ts +++ b/src/controllers/bookmarkController.ts @@ -4,7 +4,7 @@ import AppError from '../middleware/errors/AppError.js'; import * as bookmarkService from '../services/bookmarkService.js'; import { tryParseBookmarkMessage } from '../utils/parseBookmark.js'; -// 1. 북마크 생성 API (POST /bookmarks) +// 1. 북마크 생성 API (POST /api/bookmarks) export const createBookmark = async (req: Request, res: Response, next: NextFunction) => { try { const { roomId, message } = req.body; @@ -42,7 +42,7 @@ export const createBookmark = async (req: Request, res: Response, next: NextFunc } }; -// 2. 북마크 목록 조회 API (GET /bookmarks) +// 2. 북마크 목록 조회 API (GET /api/bookmarks) export const getBookmarks = async (req: Request, res: Response, next: NextFunction) => { try { const userId = req.user?.userId; @@ -75,7 +75,7 @@ export const getBookmarks = async (req: Request, res: Response, next: NextFuncti } }; -// 3. 북마크 삭제 API (DELETE /bookmarks/:bookmarkId) +// 3. 북마크 삭제 API (DELETE /api/bookmarks/:bookmarkId) export const deleteBookmark = async (req: Request, res: Response, next: NextFunction) => { try { const userId = req.user?.userId; @@ -102,7 +102,7 @@ export const deleteBookmark = async (req: Request, res: Response, next: NextFunc } }; -// 4. 북마크 컬렉션 이동 API (PUT /bookmarks/:bookmarkId/collection) +// 4. 북마크 컬렉션 이동 API (PUT /api/bookmarks/:bookmarkId/collection) export const moveBookmarkToCollection = async (req: Request, res: Response, next: NextFunction) => { try { const userId = req.user?.userId; @@ -136,7 +136,7 @@ export const moveBookmarkToCollection = async (req: Request, res: Response, next } }; -// 5. 북마크로 방 생성 API (POST /bookmarks/:bookmarkId/create-room) +// 5. 북마크로 방 생성 API (POST /api/bookmarks/:bookmarkId/create-room) export const createRoomFromBookmark = async (req: Request, res: Response, next: NextFunction) => { try { const userId = req.user?.userId; From c34ab21724f5b1d43939309de2c7ca062300129a Mon Sep 17 00:00:00 2001 From: kdy Date: Thu, 31 Jul 2025 16:45:02 +0900 Subject: [PATCH 27/59] Refactor: Move Prisma client import to lib/prisma.js --- src/services/bookmarkService.ts | 3 +-- src/services/youtubeDetailService.ts | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index 4ea8f2c..664443a 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -1,5 +1,4 @@ -import { PrismaClient } from '@prisma/client'; -const prisma = new PrismaClient(); +import { prisma } from '../lib/prisma.js'; // 1. 북마크 생성 서비스 export const createBookmark = async ( diff --git a/src/services/youtubeDetailService.ts b/src/services/youtubeDetailService.ts index 9977076..676266c 100644 --- a/src/services/youtubeDetailService.ts +++ b/src/services/youtubeDetailService.ts @@ -1,6 +1,4 @@ -import { PrismaClient } from '@prisma/client'; - -const prisma = new PrismaClient(); +import { prisma } from '../lib/prisma.js'; type YoutubeVideoDetail = { videoId: string; From 1d3353e9f2989059bfd77125484947c404827d15 Mon Sep 17 00:00:00 2001 From: kdy Date: Thu, 31 Jul 2025 17:01:06 +0900 Subject: [PATCH 28/59] =?UTF-8?q?Feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EA=B4=80=EB=A0=A8=20api=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.ts | 2 + .../userRecommendationController.ts | 58 +++++++++++++ src/dtos/userRecommendationDto.ts | 3 + src/routes/userRecommendationRoute.ts | 86 +++++++++++++++++++ src/services/userRecommendationService.ts | 61 +++++++++++++ 5 files changed, 210 insertions(+) create mode 100644 src/controllers/userRecommendationController.ts create mode 100644 src/dtos/userRecommendationDto.ts create mode 100644 src/routes/userRecommendationRoute.ts create mode 100644 src/services/userRecommendationService.ts diff --git a/src/app.ts b/src/app.ts index 7026a9e..92b4133 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,6 +12,7 @@ import authRoutes from './routes/authRoutes.js'; import userRoutes from './routes/userRoutes.js'; import friendRoutes from './routes/friendRoutes.js'; import bookmarkRoutes from './routes/bookmarkRoute.js'; +import userRecommendationRoute from './routes/userRecommendationRoute.js'; import swaggerUi from 'swagger-ui-express'; import { specs } from './swagger.js'; import { createServer } from 'http'; @@ -150,6 +151,7 @@ app.use('/api/youtube', youtubeRoutes); app.use('/api/youtube', youtubeSearchRouter); app.use('/api/youtube/videos', youtubeDetailRouter); app.use('/api/bookmarks', bookmarkRoutes); +app.use('/api/recommendations', userRecommendationRoute); // 404 에러 핸들링 app.use((req: Request, res: Response, next: NextFunction) => { diff --git a/src/controllers/userRecommendationController.ts b/src/controllers/userRecommendationController.ts new file mode 100644 index 0000000..cac316f --- /dev/null +++ b/src/controllers/userRecommendationController.ts @@ -0,0 +1,58 @@ +import { Request, Response } from 'express'; +import { RecommendationService } from '../services/userRecommendationService.js'; + +const recommendationService = new RecommendationService(); + +// 1. 일일 추천하기 API (POST /api/recommendations/daily) +export const createDailyRecommendation = async (req: Request, res: Response): Promise => { + const userId = req.user?.userId; + if (!userId) { + res.status(401).json({ success: false, message: '로그인이 필요합니다.' }); + return; + } + + const { targetUserId } = req.body; + + try { + const result = await recommendationService.createDailyRecommendation(userId, { targetUserId }); + res.status(200).json(result); + } catch (err: unknown) { + if (err instanceof Error) { + res.status(400).json({ success: false, message: err.message }); + } else { + res.status(400).json({ + success: false, + message: '사용자 추천 API 호출 중 알 수 없는 오류가 발생했습니다.', + }); + } + } +}; + +// 2. 추천 가능 여부 확인 API (GET /api/recommendations/daily/:userId) +export const checkDailyRecommendation = async (req: Request, res: Response): Promise => { + const userId = req.user?.userId; + if (!userId) { + res.status(401).json({ success: false, message: '로그인이 필요합니다.' }); + return; + } + + const targetUserId = parseInt(req.params.userId, 10); + if (isNaN(targetUserId)) { + res.status(400).json({ success: false, message: '잘못된 사용자 ID입니다.' }); + return; + } + + try { + const result = await recommendationService.checkDailyRecommendation(userId, targetUserId); + res.status(200).json(result); + } catch (err: unknown) { + if (err instanceof Error) { + res.status(400).json({ success: false, message: err.message }); + } else { + res.status(400).json({ + success: false, + message: '추천 가능 여부 확인 중 알 수 없는 오류가 발생했습니다.', + }); + } + } +}; diff --git a/src/dtos/userRecommendationDto.ts b/src/dtos/userRecommendationDto.ts new file mode 100644 index 0000000..509cba5 --- /dev/null +++ b/src/dtos/userRecommendationDto.ts @@ -0,0 +1,3 @@ +export interface UserRecommendationRequestDto { + targetUserId: number; +} diff --git a/src/routes/userRecommendationRoute.ts b/src/routes/userRecommendationRoute.ts new file mode 100644 index 0000000..855ab65 --- /dev/null +++ b/src/routes/userRecommendationRoute.ts @@ -0,0 +1,86 @@ +import { Router } from 'express'; +import { + createDailyRecommendation, + checkDailyRecommendation, +} from '../controllers/userRecommendationController.js'; +import { requireAuth } from '../middleware/authMiddleware.js'; + +const router = Router(); + +/** + * @swagger + * /api/recommendations/daily: + * post: + * summary: 하루에 한 번 다른 사용자 추천하기 + * tags: [Recommendation] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * targetUserId: + * type: integer + * example: 456 + * responses: + * 200: + * description: 추천 성공 메시지 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * example: 추천을 보냈습니다. + * 400: + * description: 에러 메시지 + */ +// 1. 일일 추천하기 API (POST /api/recommendations/daily) +router.post('/daily', requireAuth, createDailyRecommendation); + +/** + * @swagger + * /api/recommendations/daily/{userId}: + * get: + * summary: 특정 사용자에 대해 오늘 추천 가능한지 확인 + * tags: [Recommendation] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: integer + * description: 추천 대상 사용자 ID + * responses: + * 200: + * description: 추천 가능 여부 정보 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * canRecommend: + * type: boolean + * lastRecommendedAt: + * type: string + * format: date-time + * 400: + * description: 에러 메시지 + */ +// 2. 추천 가능 여부 확인 API (GET /api/recommendations/daily/:userId) +router.get('/daily/:userId', requireAuth, checkDailyRecommendation); + +export default router; diff --git a/src/services/userRecommendationService.ts b/src/services/userRecommendationService.ts new file mode 100644 index 0000000..6a496cd --- /dev/null +++ b/src/services/userRecommendationService.ts @@ -0,0 +1,61 @@ +import { UserRecommendationRequestDto } from '../dtos/userRecommendationDto.js'; + +import { prisma } from '../lib/prisma.js'; + +export class RecommendationService { + // 1. 일일 추천하기 로직 + async createDailyRecommendation(userId: number, dto: UserRecommendationRequestDto) { + // 오늘 자정 기준 날짜 + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // 이미 오늘 추천했는지 DB에서 확인 + const existing = await prisma.dailyRecommendation.findFirst({ + where: { + recommenderId: userId, + recommendedUserId: dto.targetUserId, + recommendationDate: today, + }, + }); + + if (existing) { + throw new Error('이미 오늘 추천을 보냈습니다.'); + } + + // 추천 생성 + await prisma.dailyRecommendation.create({ + data: { + recommenderId: userId, + recommendedUserId: dto.targetUserId, + recommendationDate: today, + }, + }); + + return { + success: true, + message: '추천을 보냈습니다.', + }; + } + + // 2. 추천 가능 여부 확인 + async checkDailyRecommendation(userId: number, targetUserId: number) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const existing = await prisma.dailyRecommendation.findFirst({ + where: { + recommenderId: userId, + recommendedUserId: targetUserId, + recommendationDate: today, + }, + }); + + return { + success: true, + data: { + canRecommend: !existing, + lastRecommendedAt: existing?.createdAt ?? null, + }, + }; + } +} From 7f82c069a4d58fbe4e78bc82242ab438dcf80283 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Thu, 31 Jul 2025 15:55:21 +0900 Subject: [PATCH 29/59] Feat: add search user status interface (#65) * Fix: Remove CORS Error * Feat: Add RequestStatus In SearchUser Interface * Formatting --- src/controllers/friendController.ts | 2 +- src/services/friendServices.ts | 42 +++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/controllers/friendController.ts b/src/controllers/friendController.ts index c87b03c..ac66dc1 100644 --- a/src/controllers/friendController.ts +++ b/src/controllers/friendController.ts @@ -130,7 +130,7 @@ export const searchFriends = async (req: Request, res: Response, next: NextFunct throw new AppError('GENERAL_001', '검색할 닉네임을 입력해주세요.'); } - const users = await friendService.searchUserByNickname(nickname); + const users = await friendService.searchUserByNickname(nickname, userId); sendSuccess(res, users); } catch (error) { next(error); diff --git a/src/services/friendServices.ts b/src/services/friendServices.ts index 6652a38..76db93d 100644 --- a/src/services/friendServices.ts +++ b/src/services/friendServices.ts @@ -26,6 +26,7 @@ interface SearchUser { nickname: string; profileImage: string | null; popularity: number; + requestStatus: 'none' | 'pending' | 'accepted' | 'rejected'; } interface FriendLounge { @@ -267,7 +268,10 @@ export const deleteFriend = async (userId: number, friendId: number): Promise => { +export const searchUserByNickname = async ( + nickname: string, + currentUserId: number, +): Promise => { const users = await prisma.user.findMany({ where: { nickname: nickname, // 완전 일치 @@ -280,7 +284,41 @@ export const searchUserByNickname = async (nickname: string): Promise user.userId); + const friendships = await prisma.friendship.findMany({ + where: { + OR: [ + { requestedBy: currentUserId, requestedTo: { in: userIds } }, + { requestedBy: { in: userIds }, requestedTo: currentUserId }, + ], + }, + }); + + // friendship 맵 생성 (userId -> status) + const friendshipMap = new Map(); + friendships.forEach(friendship => { + const otherUserId = + friendship.requestedBy === currentUserId ? friendship.requestedTo : friendship.requestedBy; + friendshipMap.set(otherUserId, friendship.status as 'pending' | 'accepted' | 'rejected'); + }); + + // 사용자 정보와 친구 관계 상태 결합 + const usersWithRequestStatus: SearchUser[] = users.map(user => ({ + ...user, + requestStatus: (friendshipMap.get(user.userId) || 'none') as + | 'none' + | 'pending' + | 'accepted' + | 'rejected', + })); + + return usersWithRequestStatus; }; /** From cbe2453a20b232f3b2f68bdb0ad1d377e1c5479b Mon Sep 17 00:00:00 2001 From: DongilMin Date: Thu, 31 Jul 2025 20:10:37 +0900 Subject: [PATCH 30/59] =?UTF-8?q?DOCS:=20README.md=EC=97=90=20Swagger=20?= =?UTF-8?q?=EC=A0=91=EC=86=8D=20=EA=B2=BD=EB=A1=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5405a0e..cb523a5 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ ON-AIR-mate Backend(node.js) 레포지토리입니다. ### **🌐 프로덕션 서버** - **서버 URL**: http://54.180.254.48:3000 +- **Swagger URL**: http://54.180.254.48:3000/api-docs - **헬스체크**: http://54.180.254.48:3000/health - **상태**: 🟢 **ONLINE** (24시간 운영) From 1f69f8e3b750f77bfb7950bf84a41607b74e555d Mon Sep 17 00:00:00 2001 From: DongilMin Date: Thu, 31 Jul 2025 21:54:26 +0900 Subject: [PATCH 31/59] Feat/search user interface (#69) * Fix: Remove CORS Error * Feat: Add RequestStatus In SearchUser Interface * Formatting * Refactor: Performance Optimization * feat: Add Dummy Data For Test --- .../20250731114836_new_schema/migration.sql | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 prisma/migrations/20250731114836_new_schema/migration.sql diff --git a/prisma/migrations/20250731114836_new_schema/migration.sql b/prisma/migrations/20250731114836_new_schema/migration.sql new file mode 100644 index 0000000..9f794c6 --- /dev/null +++ b/prisma/migrations/20250731114836_new_schema/migration.sql @@ -0,0 +1,46 @@ +/* + Warnings: + + - You are about to alter the column `video_id` on the `rooms` table. The data in that column could be lost. The data in that column will be cast from `VarChar(191)` to `VarChar(20)`. + - Added the required column `updated_at` to the `collections` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE `rooms` DROP FOREIGN KEY `rooms_video_id_fkey`; + +-- AlterTable +ALTER TABLE `bookmarks` ADD COLUMN `collection_id` INTEGER NULL; + +-- AlterTable +ALTER TABLE `collections` ADD COLUMN `cover_image` VARCHAR(500) NULL, + ADD COLUMN `updated_at` DATETIME(3) NOT NULL; + +-- AlterTable +ALTER TABLE `room_participants` ADD COLUMN `last_joined_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + ADD COLUMN `total_stay_time` INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE `rooms` MODIFY `video_id` VARCHAR(20) NULL; + +-- CreateTable +CREATE TABLE `user_feedbacks` ( + `feedback_id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `content` TEXT NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `user_feedbacks_user_id_fkey`(`user_id`), + PRIMARY KEY (`feedback_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateIndex +CREATE INDEX `bookmarks_collection_id_fkey` ON `bookmarks`(`collection_id`); + +-- AddForeignKey +ALTER TABLE `rooms` ADD CONSTRAINT `rooms_video_id_fkey` FOREIGN KEY (`video_id`) REFERENCES `youtube_videos`(`video_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `bookmarks` ADD CONSTRAINT `bookmarks_collection_id_fkey` FOREIGN KEY (`collection_id`) REFERENCES `collections`(`collection_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_feedbacks` ADD CONSTRAINT `user_feedbacks_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; From 66df72b095f291f821e6ccdf22589ab50ab43938 Mon Sep 17 00:00:00 2001 From: GaHee Im <163043607+gaaahee@users.noreply.github.com> Date: Fri, 1 Aug 2025 01:09:02 +0900 Subject: [PATCH 32/59] =?UTF-8?q?feat:=20=ED=8A=B9=EC=A0=95=20=EB=B0=A9?= =?UTF-8?q?=EC=9D=98=20=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: main 브랜치 동기화 * feat: 특정 방 상세정보 조회 api 파일 추가 * feat: 특정 방 상세정보 조회 api를 위한 파일 추가 * chore: merge 충돌로 인한 코드 리팩토링 * feat: room api - 특정 방 상세정보 조회 구현 * chore: prisma schema 임시 수정(room 테이블) * feat: swagger.ts에 RoomInfoResponse(/api/room/roomId 응답 구조) 추가 * refactor: youtube api - 검색어 관련 추천영상 파일명 변경 * refactor: youtube api - 검색어 관련 추천영상 파일명 변경 * chore: schema.prisma 수정 - rooms 테이블 videoId 추가 * chore: 주석 제거 * chore: npm run format, build * chore: npm run format * chore: env 환경변수 추가 * refactor: 코드 리팩토링 중 main 브랜치 동기화를 위한 커밋 * refactor: youtube api 라우터 youtubeRoute.ts에서 호출되도록 리팩토링 * chore: 코드 포맷 * chore: prisma 업데이트 * feat: roomId가 존재하지 않을 때 에러 처리 코드 추가 * feat: 특정 방 상세정보 조회 api - room 데이터가 존재하지 않을 때 발생할 수 있는 에러 처리 코드 추가 * feat: roomInfoDto.ts에 videoId 추가 * chore: prisma 동기화 * fix: 에러 해결 --- .../migration.sql | 15 ++ .../migration.sql | 14 ++ prisma/schema.prisma | 12 +- src/app.ts | 26 +-- src/controllers/roomInfoController.ts | 27 +++ ....ts => youtubeRecommendationController.ts} | 2 +- src/dtos/roomInfoDto.ts | 18 ++ ...tionDto.ts => youtubeRecommendationDto.ts} | 0 src/routes/recommendationRoute.ts | 40 ----- src/routes/roomRoute.ts | 83 +++++++++ src/routes/youtubeDetailRoute.ts | 67 -------- src/routes/youtubeRoute.ts | 158 ++++++++++++++++++ src/routes/youtubeSearchRoute.ts | 63 ------- src/services/friendServices.ts | 6 +- src/services/roomInfoService.ts | 74 ++++++++ src/services/roomServices.ts | 4 +- src/services/userServices.ts | 8 +- ...ice.ts => youtubeRecommendationService.ts} | 2 +- src/swagger.ts | 34 ++++ 19 files changed, 449 insertions(+), 204 deletions(-) create mode 100644 prisma/migrations/20250725072024_add_optional_video_id/migration.sql create mode 100644 prisma/migrations/20250725074316_make_video_id_required/migration.sql create mode 100644 src/controllers/roomInfoController.ts rename src/controllers/{recommendationController.ts => youtubeRecommendationController.ts} (92%) create mode 100644 src/dtos/roomInfoDto.ts rename src/dtos/{recommendationDto.ts => youtubeRecommendationDto.ts} (100%) delete mode 100644 src/routes/recommendationRoute.ts delete mode 100644 src/routes/youtubeDetailRoute.ts create mode 100644 src/routes/youtubeRoute.ts delete mode 100644 src/routes/youtubeSearchRoute.ts create mode 100644 src/services/roomInfoService.ts rename src/services/{recommendationService.ts => youtubeRecommendationService.ts} (98%) diff --git a/prisma/migrations/20250725072024_add_optional_video_id/migration.sql b/prisma/migrations/20250725072024_add_optional_video_id/migration.sql new file mode 100644 index 0000000..f355cd1 --- /dev/null +++ b/prisma/migrations/20250725072024_add_optional_video_id/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `current_participants` on the `rooms` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE `rooms` DROP COLUMN `current_participants`, + ADD COLUMN `video_id` VARCHAR(20) NULL; + +-- CreateIndex +CREATE INDEX `rooms_video_id_fkey` ON `rooms`(`video_id`); + +-- AddForeignKey +ALTER TABLE `rooms` ADD CONSTRAINT `rooms_video_id_fkey` FOREIGN KEY (`video_id`) REFERENCES `youtube_videos`(`video_id`) ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/prisma/migrations/20250725074316_make_video_id_required/migration.sql b/prisma/migrations/20250725074316_make_video_id_required/migration.sql new file mode 100644 index 0000000..317ce68 --- /dev/null +++ b/prisma/migrations/20250725074316_make_video_id_required/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `video_id` on table `rooms` required. This step will fail if there are existing NULL values in that column. + +*/ +-- DropForeignKey +ALTER TABLE `rooms` DROP FOREIGN KEY `rooms_video_id_fkey`; + +-- AlterTable +ALTER TABLE `rooms` MODIFY `video_id` VARCHAR(20) NOT NULL; + +-- AddForeignKey +ALTER TABLE `rooms` ADD CONSTRAINT `rooms_video_id_fkey` FOREIGN KEY (`video_id`) REFERENCES `youtube_videos`(`video_id`) ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3c88af7..d438375 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -98,7 +98,6 @@ model Room { maxParticipants Int @default(6) @map("max_participants") currentParticipants Int @default(1) @map("current_participants") popularity Int @default(0) - videoId String? @map("video_id") @db.VarChar(20) autoArchive Boolean @default(true) @map("auto_archive") inviteAuth InviteAuth @default(all) @map("invite_auth") watched30s Boolean @default(false) @map("watched_30s") @@ -106,6 +105,7 @@ model Room { startTime Int @default(0) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + videoId String? @map("video_id") @db.VarChar(20) bookmarks Bookmark[] messages RoomMessage[] participants RoomParticipant[] @@ -122,8 +122,8 @@ model RoomParticipant { roomId Int @map("room_id") userId Int @map("user_id") role ParticipantRole @default(participant) - leftAt DateTime? @map("left_at") joinedAt DateTime @default(now()) @map("joined_at") + left_at DateTime? lastJoinedAt DateTime @default(now()) @map("last_joined_at") totalStayTime Int @default(0) @map("total_stay_time") room Room @relation(fields: [roomId], references: [roomId], onDelete: Cascade) @@ -183,17 +183,17 @@ model Bookmark { bookmarkId Int @id @default(autoincrement()) @map("bookmark_id") userId Int @map("user_id") roomId Int @map("room_id") - collectionId Int? @map("collection_id") title String? @db.VarChar(50) content String? @db.Text timeline Int? @default(0) originalBookmarkId Int? @map("original_bookmark_id") createdAt DateTime @default(now()) @map("created_at") + collectionId Int? @map("collection_id") + collection Collection? @relation(fields: [collectionId], references: [collectionId]) originalBookmark Bookmark? @relation("BookmarkCopy", fields: [originalBookmarkId], references: [bookmarkId]) copiedBookmarks Bookmark[] @relation("BookmarkCopy") room Room @relation(fields: [roomId], references: [roomId], onDelete: Cascade) user User @relation(fields: [userId], references: [userId], onDelete: Cascade) - collection Collection? @relation(fields: [collectionId], references: [collectionId], onDelete: SetNull) @@index([originalBookmarkId], map: "bookmarks_original_bookmark_id_fkey") @@index([roomId], map: "bookmarks_room_id_fkey") @@ -211,14 +211,14 @@ model Collection { bookmarkCount Int @default(0) @map("bookmark_count") isLiked Boolean @default(false) @map("is_liked") originalCollectionId Int? @map("original_collection_id") - coverImage String? @map("cover_image") @db.VarChar(500) createdAt DateTime @default(now()) @map("created_at") + coverImage String? @map("cover_image") @db.VarChar(500) updatedAt DateTime @updatedAt @map("updated_at") + bookmarks Bookmark[] originalCollection Collection? @relation("CollectionCopy", fields: [originalCollectionId], references: [collectionId]) copiedCollections Collection[] @relation("CollectionCopy") user User @relation(fields: [userId], references: [userId], onDelete: Cascade) sharedCollections SharedCollection[] - bookmarks Bookmark[] @@index([originalCollectionId], map: "collections_original_collection_id_fkey") @@index([userId], map: "collections_user_id_fkey") diff --git a/src/app.ts b/src/app.ts index 92b4133..709fea0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,14 +5,15 @@ import errorHandler from './middleware/errors/errorHandler.js'; import AppError from './middleware/errors/AppError.js'; import { sendSuccess } from './utils/response.js'; import { requireAuth } from './middleware/authMiddleware.js'; -import youtubeRoutes from './routes/recommendationRoute.js'; -import youtubeSearchRouter from './routes/youtubeSearchRoute.js'; -import youtubeDetailRouter from './routes/youtubeDetailRoute.js'; import authRoutes from './routes/authRoutes.js'; +import youtubeRoutes from './routes/youtubeRoute.js'; import userRoutes from './routes/userRoutes.js'; +<<<<<<< HEAD import friendRoutes from './routes/friendRoutes.js'; import bookmarkRoutes from './routes/bookmarkRoute.js'; import userRecommendationRoute from './routes/userRecommendationRoute.js'; +======= +>>>>>>> 7de86c8 (feat: 특정 방의 상세정보 조회 api 구현 (#68)) import swaggerUi from 'swagger-ui-express'; import { specs } from './swagger.js'; import { createServer } from 'http'; @@ -49,25 +50,20 @@ const corsOptions = { // 프로덕션 환경에서는 허용된 도메인만 const allowedOrigins = [ - //수정1 - undefined 값 필터링 + //수정1 address, - 'http://54.180.254.48:3000', // HTTP로 수정 'https://54.180.254.48:3000', //'https://your-frontend-domain.com', // 실제 프론트엔드 도메인으로 변경 //'https://onairmate.vercel.app', // 예시 도메인 'http://localhost:3000', // 로컬 개발용 'http://localhost:3001', // 로컬 개발용 - ].filter(Boolean); // undefined나 null 값 제거 - + ]; console.log('배포 주소', address); console.log('연결 origin:', origin); - // origin이 없는 경우(same-origin 요청) 또는 허용된 origin인 경우 - if (!origin || allowedOrigins.includes(origin)) { + if (!origin || allowedOrigins.indexOf(origin) !== -1) { callback(null, true); } else { - console.log('CORS 거부됨:', origin); - console.log('허용된 origins:', allowedOrigins); callback(new Error('Not allowed by CORS')); } }, @@ -144,17 +140,13 @@ app.get('/', (req: Request, res: Response) => { // API 라우트들을 여기에 추가 app.use('/api/auth', authRoutes); app.use('/api/users', userRoutes); -app.use('/api/friends', friendRoutes); app.use('/api/rooms', roomRoutes); app.use('/api/chat/direct', chatDirectRoutes); -app.use('/api/youtube', youtubeRoutes); -app.use('/api/youtube', youtubeSearchRouter); -app.use('/api/youtube/videos', youtubeDetailRouter); -app.use('/api/bookmarks', bookmarkRoutes); -app.use('/api/recommendations', userRecommendationRoute); +app.use('/api/youtube', youtubeRoutes); // youtubeRecommendationRoute와 youtubeSearchRoute 병합 // 404 에러 핸들링 app.use((req: Request, res: Response, next: NextFunction) => { + console.error('app.ts에서 404 에러 발생:', req.originalUrl); next(new AppError('GENERAL_003')); // 404 에러 코드 사용 }); // 전역 에러 핸들러 diff --git a/src/controllers/roomInfoController.ts b/src/controllers/roomInfoController.ts new file mode 100644 index 0000000..fdf7378 --- /dev/null +++ b/src/controllers/roomInfoController.ts @@ -0,0 +1,27 @@ +import { Request, Response, NextFunction } from 'express'; +import { roomInfoService } from '../services/roomInfoService.js'; +import AppError from '../middleware/errors/AppError.js'; +import { sendSuccess } from '../utils/response.js'; + +const getRoomInfo = async (req: Request, res: Response, next: NextFunction) => { + console.log('[Controller] getRoomInfo called'); + try { + const { roomId: roomIdStr } = req.params; + const roomId = parseInt(roomIdStr, 10); + + if (isNaN(roomId)) { + return next(new AppError('VALIDATION_001', '유효하지 않은 방 ID입니다.')); + } + + const roomInfo = await roomInfoService.getRoomInfoById(roomId); + + sendSuccess(res, roomInfo); + } catch (error) { + console.error('🔥 에러 발생:', error); + next(error); + } +}; + +export const roomInfoController = { + getRoomInfo, +}; diff --git a/src/controllers/recommendationController.ts b/src/controllers/youtubeRecommendationController.ts similarity index 92% rename from src/controllers/recommendationController.ts rename to src/controllers/youtubeRecommendationController.ts index 96c87b4..b0a04b0 100644 --- a/src/controllers/recommendationController.ts +++ b/src/controllers/youtubeRecommendationController.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from 'express'; import { sendSuccess } from '../utils/response.js'; import AppError from '../middleware/errors/AppError.js'; -import * as youtubeService from '../services/recommendationService.js'; +import * as youtubeService from '../services/youtubeRecommendationService.js'; export const recommendVideos = async (req: Request, res: Response, next: NextFunction) => { const keyword = req.query.keyword as string; diff --git a/src/dtos/roomInfoDto.ts b/src/dtos/roomInfoDto.ts new file mode 100644 index 0000000..dee502c --- /dev/null +++ b/src/dtos/roomInfoDto.ts @@ -0,0 +1,18 @@ +export interface RoomInfoResponseDto { + roomId: number; + roomTitle: string; + videoId: string; + videoTitle: string; + videoThumbnail: string; + hostNickname: string; + hostProfileImage: string; + hostPopularity: number; + currentParticipants: number; + maxParticipants: number; + duration: string; + isPrivate: boolean; + isActive: boolean; + autoArchiving: boolean; + invitePermission: 'HOST_ONLY' | string; + createdAt: string; +} diff --git a/src/dtos/recommendationDto.ts b/src/dtos/youtubeRecommendationDto.ts similarity index 100% rename from src/dtos/recommendationDto.ts rename to src/dtos/youtubeRecommendationDto.ts diff --git a/src/routes/recommendationRoute.ts b/src/routes/recommendationRoute.ts deleted file mode 100644 index 94cb65e..0000000 --- a/src/routes/recommendationRoute.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Router } from 'express'; -import { recommendVideos } from '../controllers/recommendationController.js'; -import { requireAuth } from '../middleware/authMiddleware.js'; - -const router = Router(); - -/** - * @swagger - * /api/youtube/recommendations: - * get: - * summary: 검색어 기반 추천 영상 조회 - * tags: [YouTube] - * security: - * - bearerAuth: [] - * parameters: - * - in: query - * name: keyword - * schema: - * type: string - * required: true - * description: 검색할 키워드 - * - in: query - * name: limit - * schema: - * type: integer - * default: 3 - * description: 가져올 영상의 최대 개수 (1-50) - * responses: - * 200: - * description: 추천 영상 목록 - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/RecommendationResponse' - * 400: - * description: 잘못된 요청 (키워드 누락 또는 limit 파라미터 오류) - */ -router.get('/recommendations', requireAuth, recommendVideos); - -export default router; diff --git a/src/routes/roomRoute.ts b/src/routes/roomRoute.ts index f053a4d..a6dcb9c 100644 --- a/src/routes/roomRoute.ts +++ b/src/routes/roomRoute.ts @@ -1,5 +1,6 @@ import express from 'express'; +import { roomInfoController } from '../controllers/roomInfoController.js'; import { createRoom, joinRoom, @@ -310,4 +311,86 @@ router.post('/:roomId/messages', requireAuth, postRoomMessage); */ router.get('/:roomId/messages', requireAuth, getRoomMessages); +/** + * @swagger + * /api/rooms/{roomId}: + * get: + * summary: 특정 방의 상세 정보 조회 + * tags: [Room] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: roomId + * required: true + * schema: + * type: integer + * description: 방의 고유 ID + * responses: + * 200: + * description: 방 정보 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * roomId: + * type: integer + * example: 1 + * roomTitle: + * type: string + * example: "테스트 방" + * videoId: + * type: string + * example: "dQw4w9WgXcQ" + * videoTitle: + * type: string + * example: "Never Gonna Give You Up" + * videoThumbnail: + * type: string + * format: url + * example: "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg" + * hostNickname: + * type: string + * example: "rick_astley" + * hostProfileImage: + * type: string + * format: url + * example: "https://example.com/profile.jpg" + * hostPopularity: + * type: integer + * example: 100 + * currentParticipants: + * type: integer + * example: 3 + * maxParticipants: + * type: integer + * example: 8 + * duration: + * type: string + * example: "00:15:30" + * isPrivate: + * type: boolean + * example: false + * isActive: + * type: boolean + * example: true + * autoArchiving: + * type: boolean + * example: false + * invitePermission: + * type: string + * enum: [HOST_ONLY, ALL] + * example: "HOST_ONLY" + * createdAt: + * type: string + * format: date-time + * example: "2023-10-27T10:00:00.000Z" + * 401: + * description: 인증 실패 + * 404: + * description: 방을 찾을 수 없음 + */ +router.get('/:roomId', requireAuth, roomInfoController.getRoomInfo); + export default router; diff --git a/src/routes/youtubeDetailRoute.ts b/src/routes/youtubeDetailRoute.ts deleted file mode 100644 index 04dcaa9..0000000 --- a/src/routes/youtubeDetailRoute.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Router } from 'express'; -import { getYoutubeVideoDetail } from '../controllers/youtubeDetailController.js'; -import { requireAuth } from '../middleware/authMiddleware'; - -const router = Router(); - -/** - * @swagger - * /api/youtube/videos/{videoId}: - * get: - * summary: 유튜브 영상 상세 조회 - * description: 특정 videoId에 대한 유튜브 영상 정보를 조회하고 DB에 저장합니다. - * tags: - * - YouTube - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: videoId - * required: true - * schema: - * type: string - * description: 유튜브 비디오 ID - * responses: - * 200: - * description: 영상 상세 정보 - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * data: - * type: object - * properties: - * videoId: - * type: string - * title: - * type: string - * description: - * type: string - * thumbnail: - * type: string - * channelName: - * type: string - * channelIcon: - * type: string - * viewCount: - * type: integer - * duration: - * type: string - * example: PT15M33S - * uploadedAt: - * type: string - * format: date-time - * 401: - * description: 인증 실패 - * 404: - * description: 영상 없음 - * 500: - * description: 서버 에러 - */ - -router.get('/:videoId', requireAuth, getYoutubeVideoDetail); - -export default router; diff --git a/src/routes/youtubeRoute.ts b/src/routes/youtubeRoute.ts new file mode 100644 index 0000000..8a35214 --- /dev/null +++ b/src/routes/youtubeRoute.ts @@ -0,0 +1,158 @@ +import { Router } from 'express'; +import { recommendVideos } from '../controllers/youtubeRecommendationController.js'; +import { searchYoutubeVideos } from '../controllers/youtubeSearchController.js'; +import { getYoutubeVideoDetail } from '../controllers/youtubeDetailController.js'; +import { requireAuth } from '../middleware/authMiddleware.js'; + +const router = Router(); + +/** + * @swagger + * /api/youtube/recommendations: + * get: + * summary: 검색어 기반 추천 영상 조회 + * tags: [YouTube] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: keyword + * schema: + * type: string + * required: true + * description: 검색할 키워드 + * - in: query + * name: limit + * schema: + * type: integer + * default: 3 + * description: 가져올 영상의 최대 개수 (1-50) + * responses: + * 200: + * description: 추천 영상 목록 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RecommendationResponse' + * 400: + * description: 잘못된 요청 (키워드 누락 또는 limit 파라미터 오류) + */ +router.get('/recommendations', requireAuth, recommendVideos); + +/** + * @swagger + * /api/youtube/search: + * get: + * summary: 유튜브 영상 검색 + * description: 키워드로 유튜브 영상을 검색합니다. + * tags: + * - YouTube + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: query + * required: true + * schema: + * type: string + * description: 검색할 키워드 + * - in: query + * name: limit + * schema: + * type: integer + * default: 10 + * description: 검색할 영상 수 + * responses: + * 200: + * description: 검색 결과 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: array + * items: + * type: object + * properties: + * videoId: + * type: string + * title: + * type: string + * thumbnail: + * type: string + * channelName: + * type: string + * viewCount: + * type: integer + * uploadTime: + * type: string + * 401: + * description: 인증 실패 + * 500: + * description: YouTube API 요청 실패 + */ +router.get('/search', requireAuth, searchYoutubeVideos); + +/** + * @swagger + * /api/youtube/videos/{videoId}: + * get: + * summary: 유튜브 영상 상세 조회 + * description: 특정 videoId에 대한 유튜브 영상 정보를 조회하고 DB에 저장합니다. + * tags: + * - YouTube + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: videoId + * required: true + * schema: + * type: string + * description: 유튜브 비디오 ID + * responses: + * 200: + * description: 영상 상세 정보 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * videoId: + * type: string + * title: + * type: string + * description: + * type: string + * thumbnail: + * type: string + * channelName: + * type: string + * channelIcon: + * type: string + * viewCount: + * type: integer + * duration: + * type: string + * example: PT15M33S + * uploadedAt: + * type: string + * format: date-time + * 401: + * description: 인증 실패 + * 404: + * description: 영상 없음 + * 500: + * description: 서버 에러 + */ +router.get('/:videoId', requireAuth, getYoutubeVideoDetail); + +export default router; diff --git a/src/routes/youtubeSearchRoute.ts b/src/routes/youtubeSearchRoute.ts deleted file mode 100644 index 8924429..0000000 --- a/src/routes/youtubeSearchRoute.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Router } from 'express'; -import { searchYoutubeVideos } from '../controllers/youtubeSearchController.js'; -import { requireAuth } from '../middleware/authMiddleware.js'; -const router: Router = Router(); - -/** - * @swagger - * /api/youtube/search: - * get: - * summary: 유튜브 영상 검색 - * description: 키워드로 유튜브 영상을 검색합니다. - * tags: - * - YouTube - * security: - * - bearerAuth: [] - * parameters: - * - in: query - * name: query - * required: true - * schema: - * type: string - * description: 검색할 키워드 - * - in: query - * name: limit - * schema: - * type: integer - * default: 10 - * description: 검색할 영상 수 - * responses: - * 200: - * description: 검색 결과 - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * data: - * type: array - * items: - * type: object - * properties: - * videoId: - * type: string - * title: - * type: string - * thumbnail: - * type: string - * channelName: - * type: string - * viewCount: - * type: integer - * uploadTime: - * type: string - * 401: - * description: 인증 실패 - * 500: - * description: YouTube API 요청 실패 - */ -router.get('/search', requireAuth, searchYoutubeVideos); - -export default router; diff --git a/src/services/friendServices.ts b/src/services/friendServices.ts index 76db93d..4ce8ac3 100644 --- a/src/services/friendServices.ts +++ b/src/services/friendServices.ts @@ -348,7 +348,7 @@ export const inviteFriendToRoom = async ( where: { roomId }, include: { participants: { - where: { userId, leftAt: null }, + where: { userId, left_at: null }, }, }, }); @@ -372,7 +372,7 @@ export const inviteFriendToRoom = async ( where: { roomId, userId: friendId, - leftAt: null, + left_at: null, }, }); @@ -384,7 +384,7 @@ export const inviteFriendToRoom = async ( const currentParticipants = await prisma.roomParticipant.count({ where: { roomId, - leftAt: null, + left_at: null, }, }); diff --git a/src/services/roomInfoService.ts b/src/services/roomInfoService.ts new file mode 100644 index 0000000..0fdb2b2 --- /dev/null +++ b/src/services/roomInfoService.ts @@ -0,0 +1,74 @@ +import { PrismaClient } from '@prisma/client'; +import { RoomInfoResponseDto } from '../dtos/roomInfoDto.js'; +import AppError from '../middleware/errors/AppError.js'; + +const prisma = new PrismaClient(); + +/** + * ID를 기반으로 특정 방의 상세 정보를 조회합니다. + * @param roomId 조회할 방의 ID + * @returns 방의 상세 정보 + * @throws {AppError} 해당 ID의 방을 찾을 수 없는 경우 + */ +const getRoomInfoById = async (roomId: number): Promise => { + console.log(`[Service] Fetching room with ID: ${roomId}`); + + const room = await prisma.room.findUnique({ + where: { roomId: roomId }, + include: { + host: true, + video: true, + _count: { + select: { participants: true }, + }, + }, + }); + + console.log('[Service] Room data:', room); + + if (!room) { + console.error(`[Service] Room with ID ${roomId} not found.`); + throw new AppError('ROOM_001', `ID가 ${roomId}인 방을 찾을 수 없습니다.`); + } + + console.log('[Service] Host data:', room.host); + + if (!room.host) { + console.error(`[Service] Host for room ID ${roomId} not found.`); + throw new AppError('GENERAL_005', `ID가 ${roomId}인 방의 호스트 정보를 찾을 수 없습니다.`); + } + + console.log('[Service] Video data:', room.video); + + if (!room.video) { + console.error(`[Service] Video for room ID ${roomId} not found.`); + throw new AppError('ROOM_007', `ID가 ${roomId}인 방의 비디오 정보를 찾을 수 없습니다.`); + } + + const roomInfo: RoomInfoResponseDto = { + roomId: room.roomId, + roomTitle: room.roomName, + + videoId: room.video.videoId, + videoTitle: room.video.title, + videoThumbnail: room.video.thumbnail ?? '', + duration: room.video.duration ?? 'PT0S', + + hostNickname: room.host.nickname, + hostProfileImage: room.host.profileImage || '', + hostPopularity: room.host.popularity, + currentParticipants: room._count.participants, + maxParticipants: room.maxParticipants, + isPrivate: !room.isPublic, + isActive: room.isActive, + autoArchiving: room.autoArchive, + invitePermission: room.inviteAuth, + createdAt: room.createdAt.toISOString(), + }; + + return roomInfo; +}; + +export const roomInfoService = { + getRoomInfoById, +}; diff --git a/src/services/roomServices.ts b/src/services/roomServices.ts index a9bee98..4ecc555 100644 --- a/src/services/roomServices.ts +++ b/src/services/roomServices.ts @@ -70,7 +70,7 @@ export const outRoom = async (roomId: number, userId: number) => { }, }, data: { - leftAt: new Date(), + left_at: new Date(), }, }); return { message: '방에서 퇴장했습니다.' }; @@ -83,7 +83,7 @@ export const outRoom = async (roomId: number, userId: number) => { //참가자 목록 조회 export const getParticipants = async (roomId: number) => { const participants = await prisma.roomParticipant.findMany({ - where: { roomId, leftAt: null }, + where: { roomId, left_at: null }, }); const result = await Promise.all( diff --git a/src/services/userServices.ts b/src/services/userServices.ts index 088237f..7490e7b 100644 --- a/src/services/userServices.ts +++ b/src/services/userServices.ts @@ -110,8 +110,8 @@ export const getParticipatedRooms = async (userId: number) => { const participations = await prisma.roomParticipant.findMany({ where: { userId, - // 퇴장한 방만 조회 (leftAt이 null이 아닌 경우) - leftAt: { + // 퇴장한 방만 조회 (left_at이 null이 아닌 경우) + left_at: { not: null, }, }, @@ -149,8 +149,8 @@ export const getParticipatedRooms = async (userId: number) => { // 30초 이상 체류한 방만 필터링 const filteredParticipations = participations.filter(p => { - if (p.leftAt && p.joinedAt) { - const durationMs = p.leftAt.getTime() - p.joinedAt.getTime(); + if (p.left_at && p.joinedAt) { + const durationMs = p.left_at.getTime() - p.joinedAt.getTime(); const durationSeconds = durationMs / 1000; return durationSeconds >= 30; } diff --git a/src/services/recommendationService.ts b/src/services/youtubeRecommendationService.ts similarity index 98% rename from src/services/recommendationService.ts rename to src/services/youtubeRecommendationService.ts index c3ae31c..34032a9 100644 --- a/src/services/recommendationService.ts +++ b/src/services/youtubeRecommendationService.ts @@ -6,7 +6,7 @@ import { YouTubeVideoDetailsResponse, YouTubeVideoDetailsResult, YouTubeSearchResult, -} from '../dtos/recommendationDto.js'; +} from '../dtos/youtubeRecommendationDto.js'; const YOUTUBE_SEARCH_API_URL = 'https://www.googleapis.com/youtube/v3/search'; const YOUTUBE_VIDEOS_API_URL = 'https://www.googleapis.com/youtube/v3/videos'; diff --git a/src/swagger.ts b/src/swagger.ts index 4b1ed44..cb29331 100644 --- a/src/swagger.ts +++ b/src/swagger.ts @@ -54,6 +54,40 @@ const options: swaggerJsdoc.Options = { durationFormatted: { type: 'string', example: '03:33' }, }, }, + RoomInfoResponse: { + type: 'object', + properties: { + roomId: { type: 'integer', example: 123 }, + roomTitle: { type: 'string', example: '방제목' }, + videoId: { type: 'string', example: 'dQw4w9WgXcQ' }, + videoTitle: { type: 'string', example: '영상제목' }, + videoThumbnail: { type: 'string', example: '썸네일URL' }, + hostNickname: { type: 'string', example: '방장닉네임' }, + hostProfileImage: { type: 'string', example: '방장프로필URL' }, + hostPopularity: { type: 'integer', example: 95 }, + currentParticipants: { type: 'integer', example: 5 }, + maxParticipants: { type: 'integer', example: 8 }, + duration: { type: 'string', example: 'PT1M23S' }, + isPrivate: { type: 'boolean', example: false }, + isActive: { type: 'boolean', example: true }, + autoArchiving: { type: 'boolean', example: true }, + invitePermission: { type: 'string', example: 'all' }, + createdAt: { type: 'string', format: 'date-time', example: '2025-01-01T12:00:00Z' }, + }, + }, + RoomInfoSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessResponse' }, + { + type: 'object', + properties: { + data: { + $ref: '#/components/schemas/RoomInfoResponse', + }, + }, + }, + ], + }, SuccessResponse: { type: 'object', properties: { From ad78593465d01dc912f3033587ec98372318deab Mon Sep 17 00:00:00 2001 From: kdy Date: Fri, 1 Aug 2025 05:02:20 +0900 Subject: [PATCH 33/59] Chore: Remove unintended sharedCollection-related files --- src/app.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/app.ts b/src/app.ts index 709fea0..1b1c0b1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,12 +8,6 @@ import { requireAuth } from './middleware/authMiddleware.js'; import authRoutes from './routes/authRoutes.js'; import youtubeRoutes from './routes/youtubeRoute.js'; import userRoutes from './routes/userRoutes.js'; -<<<<<<< HEAD -import friendRoutes from './routes/friendRoutes.js'; -import bookmarkRoutes from './routes/bookmarkRoute.js'; -import userRecommendationRoute from './routes/userRecommendationRoute.js'; -======= ->>>>>>> 7de86c8 (feat: 특정 방의 상세정보 조회 api 구현 (#68)) import swaggerUi from 'swagger-ui-express'; import { specs } from './swagger.js'; import { createServer } from 'http'; From 814179b2385b3eb6c631d512f332fdbdb8e1e83c Mon Sep 17 00:00:00 2001 From: kdy Date: Fri, 1 Aug 2025 05:10:18 +0900 Subject: [PATCH 34/59] =?UTF-8?q?Feat:=20=EA=B3=B5=EC=9C=A0=EB=B0=9B?= =?UTF-8?q?=EC=9D=80=20=EC=BB=AC=EB=A0=89=EC=85=98=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20ups?= =?UTF-8?q?tream=20main=20=EB=B3=91=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - upstream main 브랜치 최신 내용 pull 및 병합 완료 - 공유받은 컬렉션 목록 조회 기능 추가 --- src/app.ts | 2 + src/controllers/sharedCollectionController.ts | 25 +++++++ src/dtos/sharedCollectionDto.ts | 12 ++++ src/middleware/errors/errorHandler.ts | 6 +- src/routes/sharedCollectionRoute.ts | 67 +++++++++++++++++++ src/services/sharedCollectionService.ts | 35 ++++++++++ 6 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 src/controllers/sharedCollectionController.ts create mode 100644 src/dtos/sharedCollectionDto.ts create mode 100644 src/routes/sharedCollectionRoute.ts create mode 100644 src/services/sharedCollectionService.ts diff --git a/src/app.ts b/src/app.ts index 1b1c0b1..360f47d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,7 @@ import { createServer } from 'http'; import { initSocketServer } from './socket/index.js'; import roomRoutes from './routes/roomRoute.js'; import chatDirectRoutes from './routes/chatDirectRoute.js'; +import sharedCollectionRoute from './routes/sharedCollectionRoute.js'; dotenv.config(); const app: Express = express(); @@ -137,6 +138,7 @@ app.use('/api/users', userRoutes); app.use('/api/rooms', roomRoutes); app.use('/api/chat/direct', chatDirectRoutes); app.use('/api/youtube', youtubeRoutes); // youtubeRecommendationRoute와 youtubeSearchRoute 병합 +app.use('/api/shared-collections', sharedCollectionRoute); // 404 에러 핸들링 app.use((req: Request, res: Response, next: NextFunction) => { diff --git a/src/controllers/sharedCollectionController.ts b/src/controllers/sharedCollectionController.ts new file mode 100644 index 0000000..5aabda6 --- /dev/null +++ b/src/controllers/sharedCollectionController.ts @@ -0,0 +1,25 @@ +import { Request, Response } from 'express'; +import { SharedCollectionService } from '../services/sharedCollectionService.js'; +import { SharedCollectionActionDto } from '../dtos/sharedCollectionDto.js'; + +const service = new SharedCollectionService(); + +// 1. 공유받은 컬렉션 목록 조회 +export const getReceivedCollections = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; // 인증 미들웨어에서 주입된 userId + + if (!userId) { + res.status(401).json({ success: false, message: '로그인이 필요합니다.' }); + return; + } + + const result = await service.getReceivedCollections(userId); + res.status(200).json({ success: true, data: result }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Internal Server Error'; + res.status(500).json({ success: false, message: errorMessage }); + } +}; + +}; diff --git a/src/dtos/sharedCollectionDto.ts b/src/dtos/sharedCollectionDto.ts new file mode 100644 index 0000000..0ed6cd2 --- /dev/null +++ b/src/dtos/sharedCollectionDto.ts @@ -0,0 +1,12 @@ +export type SharedCollectionAction = 'ACCEPT' | 'REJECT'; + +export class SharedCollectionResponseDto { + sharedCollectionId!: number; + originalCollectionId!: number; + title!: string; + fromUserId!: number; + fromUserNickname!: string; + bookmarkCount!: number; + sharedAt!: Date; +} + diff --git a/src/middleware/errors/errorHandler.ts b/src/middleware/errors/errorHandler.ts index c63a467..9c10855 100644 --- a/src/middleware/errors/errorHandler.ts +++ b/src/middleware/errors/errorHandler.ts @@ -6,8 +6,10 @@ import { sendError } from '../../utils/response.js'; const errorHandler = (err: Error, req: Request, res: Response, _next: NextFunction) => { void _next; - if (err instanceof AppError) { - return sendError(res, err.message, err.statusCode); + // 개발 환경에서는 전체 에러 스택 출력 + if (process.env.NODE_ENV === 'development') { + console.error('Error Stack:', err.stack); + console.error('Error Details:', err); } // AppError 인스턴스인 경우 diff --git a/src/routes/sharedCollectionRoute.ts b/src/routes/sharedCollectionRoute.ts new file mode 100644 index 0000000..75cb11f --- /dev/null +++ b/src/routes/sharedCollectionRoute.ts @@ -0,0 +1,67 @@ +import { Router } from 'express'; +import { + getReceivedCollections, + respondToSharedCollection, +} from '../controllers/sharedCollectionController.js'; +import { requireAuth } from '../middleware/authMiddleware.js'; // 인증 미들웨어 경로에 맞게 수정 필요 + +const router = Router(); +/** + * @swagger + * tags: + * name: SharedCollections + * description: 공유받은 컬렉션 관련 API + */ + +/** + * @swagger + * /api/shared-collections: + * get: + * summary: 공유받은 컬렉션 목록 조회 + * description: 로그인한 사용자가 공유받은 컬렉션 목록을 조회합니다. + * tags: [SharedCollections] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: 공유받은 컬렉션 목록 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: array + * items: + * type: object + * properties: + * sharedCollectionId: + * type: integer + * example: 5 + * originalCollectionId: + * type: integer + * example: 12 + * title: + * type: string + * example: 공유받은 컬렉션 by nickname1 + * fromUserId: + * type: integer + * example: 3 + * fromUserNickname: + * type: string + * example: nickname1 + * bookmarkCount: + * type: integer + * example: 7 + * sharedAt: + * type: string + * format: date-time + * example: 2025-07-30T14:48:00.000Z + */ +// 1. 공유받은 컬렉션 목록 조회 (GET /api/shared-collections) +router.get('/', requireAuth, getReceivedCollections); + +export default router; diff --git a/src/services/sharedCollectionService.ts b/src/services/sharedCollectionService.ts new file mode 100644 index 0000000..a36ef7e --- /dev/null +++ b/src/services/sharedCollectionService.ts @@ -0,0 +1,35 @@ +import { prisma } from '../lib/prisma.js'; +import { + SharedCollectionActionDto, + SharedCollectionResponseDto, +} from '../dtos/sharedCollectionDto.js'; + +export class SharedCollectionService { + // 공유받은 컬렉션 목록 조회 + async getReceivedCollections(userId: number): Promise { + const sharedCollections = await prisma.sharedCollection.findMany({ + where: { sharedToUserId: userId }, + include: { + collection: { + include: { + user: true, // fromUser 정보 + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + return sharedCollections.map(sc => ({ + sharedCollectionId: sc.shareId, + originalCollectionId: sc.collection.collectionId, + title: `공유받은 컬렉션 by ${sc.collection.user.nickname}`, + fromUserId: sc.collection.user.userId, + fromUserNickname: sc.collection.user.nickname, + bookmarkCount: sc.collection.bookmarkCount, + sharedAt: sc.createdAt, + })); + } + +} From 70ffc5deb1a49ae7574ed195e5c0a75429cb1cd0 Mon Sep 17 00:00:00 2001 From: GaHee Im <163043607+gaaahee@users.noreply.github.com> Date: Fri, 1 Aug 2025 04:49:02 +0900 Subject: [PATCH 35/59] =?UTF-8?q?feat:=20=ED=8A=B9=EC=A0=95=20=EB=B0=A9=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20api?= =?UTF-8?q?=20-=20duration=20=ED=98=95=EC=8B=9D=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: main 브랜치 동기화 * feat: 특정 방 상세정보 조회 api 파일 추가 * feat: 특정 방 상세정보 조회 api를 위한 파일 추가 * chore: merge 충돌로 인한 코드 리팩토링 * feat: room api - 특정 방 상세정보 조회 구현 * chore: prisma schema 임시 수정(room 테이블) * feat: swagger.ts에 RoomInfoResponse(/api/room/roomId 응답 구조) 추가 * refactor: youtube api - 검색어 관련 추천영상 파일명 변경 * refactor: youtube api - 검색어 관련 추천영상 파일명 변경 * chore: schema.prisma 수정 - rooms 테이블 videoId 추가 * chore: 주석 제거 * chore: npm run format, build * chore: npm run format * chore: env 환경변수 추가 * refactor: 코드 리팩토링 중 main 브랜치 동기화를 위한 커밋 * refactor: youtube api 라우터 youtubeRoute.ts에서 호출되도록 리팩토링 * chore: 코드 포맷 * chore: prisma 업데이트 * feat: roomId가 존재하지 않을 때 에러 처리 코드 추가 * feat: 특정 방 상세정보 조회 api - room 데이터가 존재하지 않을 때 발생할 수 있는 에러 처리 코드 추가 * feat: roomInfoDto.ts에 videoId 추가 * chore: prisma 동기화 * fix: 에러 해결 * feat: roomInfoService.ts에서 duration 형식 formatter.ts를 이용하여 형식 변환 * Revert "feat: roomInfoService.ts에서 duration 형식 formatter.ts를 이용하여 형식 변환" This reverts commit 89aad353046b365cdcc07cb14d11efb6838bfd6d. * feat: roomInfoService.ts - duration에 formatter 적용하여 영상 시간 형식 변환 --- src/services/roomInfoService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/roomInfoService.ts b/src/services/roomInfoService.ts index 0fdb2b2..00f4638 100644 --- a/src/services/roomInfoService.ts +++ b/src/services/roomInfoService.ts @@ -1,5 +1,6 @@ import { PrismaClient } from '@prisma/client'; import { RoomInfoResponseDto } from '../dtos/roomInfoDto.js'; +import { formatISO8601Duration } from '../utils/formatters.js'; import AppError from '../middleware/errors/AppError.js'; const prisma = new PrismaClient(); @@ -52,7 +53,7 @@ const getRoomInfoById = async (roomId: number): Promise => videoId: room.video.videoId, videoTitle: room.video.title, videoThumbnail: room.video.thumbnail ?? '', - duration: room.video.duration ?? 'PT0S', + duration: formatISO8601Duration(room.video.duration ?? 'PT0S'), hostNickname: room.host.nickname, hostProfileImage: room.host.profileImage || '', From 268151f2ab84a2ac229f008f90041cb04ed312c5 Mon Sep 17 00:00:00 2001 From: GaHee Im <163043607+gaaahee@users.noreply.github.com> Date: Fri, 1 Aug 2025 05:41:39 +0900 Subject: [PATCH 36/59] =?UTF-8?q?fix:=20npm=20run=20build=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: main 브랜치 동기화 * feat: 특정 방 상세정보 조회 api 파일 추가 * feat: 특정 방 상세정보 조회 api를 위한 파일 추가 * chore: merge 충돌로 인한 코드 리팩토링 * feat: room api - 특정 방 상세정보 조회 구현 * chore: prisma schema 임시 수정(room 테이블) * feat: swagger.ts에 RoomInfoResponse(/api/room/roomId 응답 구조) 추가 * refactor: youtube api - 검색어 관련 추천영상 파일명 변경 * refactor: youtube api - 검색어 관련 추천영상 파일명 변경 * chore: schema.prisma 수정 - rooms 테이블 videoId 추가 * chore: 주석 제거 * chore: npm run format, build * chore: npm run format * chore: env 환경변수 추가 * refactor: 코드 리팩토링 중 main 브랜치 동기화를 위한 커밋 * refactor: youtube api 라우터 youtubeRoute.ts에서 호출되도록 리팩토링 * chore: 코드 포맷 * chore: prisma 업데이트 * feat: roomId가 존재하지 않을 때 에러 처리 코드 추가 * feat: 특정 방 상세정보 조회 api - room 데이터가 존재하지 않을 때 발생할 수 있는 에러 처리 코드 추가 * feat: roomInfoDto.ts에 videoId 추가 * chore: prisma 동기화 * fix: 에러 해결 * feat: roomInfoService.ts에서 duration 형식 formatter.ts를 이용하여 형식 변환 * Revert "feat: roomInfoService.ts에서 duration 형식 formatter.ts를 이용하여 형식 변환" This reverts commit 89aad353046b365cdcc07cb14d11efb6838bfd6d. * feat: roomInfoService.ts - duration에 formatter 적용하여 영상 시간 형식 변환 * fix: prisma migration 실패 해결 * fix: npm run build 에러 해결 * chore: 코드 포맷 --- prisma/schema.prisma | 4 +- src/services/roomInfoService.ts | 17 ++++---- src/services/roomServices.ts | 4 +- src/services/userServices.ts | 72 +++++++++++++++++---------------- 4 files changed, 50 insertions(+), 47 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d438375..6ba53b4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -105,12 +105,11 @@ model Room { startTime Int @default(0) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - videoId String? @map("video_id") @db.VarChar(20) + videoId String @map("video_id") @db.VarChar(20) bookmarks Bookmark[] messages RoomMessage[] participants RoomParticipant[] host User @relation("HostUser", fields: [hostId], references: [userId], onDelete: Cascade) - video YoutubeVideo? @relation("RoomVideo", fields: [videoId], references: [videoId]) @@index([hostId], map: "rooms_host_id_fkey") @@index([videoId], map: "rooms_video_id_fkey") @@ -293,7 +292,6 @@ model YoutubeVideo { duration String? @db.VarChar(20) uploadedAt DateTime? @map("uploaded_at") createdAt DateTime @default(now()) @map("created_at") - videoRooms Room[] @relation("RoomVideo") @@map("youtube_videos") } diff --git a/src/services/roomInfoService.ts b/src/services/roomInfoService.ts index 00f4638..5755a36 100644 --- a/src/services/roomInfoService.ts +++ b/src/services/roomInfoService.ts @@ -18,7 +18,6 @@ const getRoomInfoById = async (roomId: number): Promise => where: { roomId: roomId }, include: { host: true, - video: true, _count: { select: { participants: true }, }, @@ -39,9 +38,13 @@ const getRoomInfoById = async (roomId: number): Promise => throw new AppError('GENERAL_005', `ID가 ${roomId}인 방의 호스트 정보를 찾을 수 없습니다.`); } - console.log('[Service] Video data:', room.video); + const video = await prisma.youtubeVideo.findUnique({ + where: { videoId: room.videoId }, + }); + + console.log('[Service] Video data:', video); - if (!room.video) { + if (!video) { console.error(`[Service] Video for room ID ${roomId} not found.`); throw new AppError('ROOM_007', `ID가 ${roomId}인 방의 비디오 정보를 찾을 수 없습니다.`); } @@ -50,10 +53,10 @@ const getRoomInfoById = async (roomId: number): Promise => roomId: room.roomId, roomTitle: room.roomName, - videoId: room.video.videoId, - videoTitle: room.video.title, - videoThumbnail: room.video.thumbnail ?? '', - duration: formatISO8601Duration(room.video.duration ?? 'PT0S'), + videoId: video.videoId, + videoTitle: video.title, + videoThumbnail: video.thumbnail ?? '', + duration: formatISO8601Duration(video.duration ?? 'PT0S'), hostNickname: room.host.nickname, hostProfileImage: room.host.profileImage || '', diff --git a/src/services/roomServices.ts b/src/services/roomServices.ts index 4ecc555..607275f 100644 --- a/src/services/roomServices.ts +++ b/src/services/roomServices.ts @@ -33,9 +33,7 @@ export const createRoom = async (data: createNewRoom) => { roomName: data.roomName, isPublic: data.isPublic ?? true, maxParticipants: data.maxParticipants ?? 6, - video: { - connect: { videoId: video.videoId }, - }, + videoId: video.videoId, host: { connect: { userId: data.hostId }, // ← 이 부분 추가 }, diff --git a/src/services/userServices.ts b/src/services/userServices.ts index 7490e7b..548ba3d 100644 --- a/src/services/userServices.ts +++ b/src/services/userServices.ts @@ -118,13 +118,6 @@ export const getParticipatedRooms = async (userId: number) => { include: { room: { include: { - video: { - select: { - videoId: true, - title: true, - thumbnail: true, - }, - }, bookmarks: { where: { userId }, select: { @@ -159,33 +152,44 @@ export const getParticipatedRooms = async (userId: number) => { console.log(`[getParticipatedRooms] 30초 이상 체류한 방: ${filteredParticipations.length}`); - return filteredParticipations.map(p => { - // null 체크 - if (!p.room) { - console.error( - `[getParticipatedRooms] room이 null입니다. participantId: ${p.participantId}`, - ); - throw new AppError('ROOM_001', '참여 기록의 방 정보를 찾을 수 없습니다.'); - } - - if (!p.room.video) { - console.error(`[getParticipatedRooms] video가 null입니다. roomId: ${p.room.roomId}`); - throw new AppError('ROOM_007', '방의 비디오 정보를 찾을 수 없습니다.'); - } - - return { - roomId: p.room.roomId, - roomTitle: p.room.roomName, - videoTitle: p.room.video.title || '제목 없음', - videoThumbnail: p.room.video.thumbnail || '', - participatedAt: p.joinedAt, - bookmarks: - p.room.bookmarks?.map(b => ({ - bookmarkId: b.bookmarkId, - message: b.content || '', - })) || [], - }; - }); + const results = await Promise.all( + filteredParticipations.map(async p => { + // null 체크 + if (!p.room) { + console.error( + `[getParticipatedRooms] room이 null입니다. participantId: ${p.participantId}`, + ); + throw new AppError('ROOM_001', '참여 기록의 방 정보를 찾을 수 없습니다.'); + } + + const video = await prisma.youtubeVideo.findUnique({ + where: { videoId: p.room.videoId }, + select: { + title: true, + thumbnail: true, + }, + }); + + if (!video) { + console.error(`[getParticipatedRooms] video가 null입니다. roomId: ${p.room.roomId}`); + throw new AppError('ROOM_007', '방의 비디오 정보를 찾을 수 없습니다.'); + } + + return { + roomId: p.room.roomId, + roomTitle: p.room.roomName, + videoTitle: video.title || '제목 없음', + videoThumbnail: video.thumbnail || '', + participatedAt: p.joinedAt, + bookmarks: + p.room.bookmarks?.map(b => ({ + bookmarkId: b.bookmarkId, + message: b.content || '', + })) || [], + }; + }), + ); + return results; } catch (error) { console.error('[getParticipatedRooms] 에러 발생:', error); From 0e0856809738f79d32ff0c44e162ae1244496e24 Mon Sep 17 00:00:00 2001 From: GaHee Im <163043607+gaaahee@users.noreply.github.com> Date: Fri, 1 Aug 2025 05:56:59 +0900 Subject: [PATCH 37/59] =?UTF-8?q?fix:=20npm=20run=20build=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: main 브랜치 동기화 * feat: 특정 방 상세정보 조회 api 파일 추가 * feat: 특정 방 상세정보 조회 api를 위한 파일 추가 * chore: merge 충돌로 인한 코드 리팩토링 * feat: room api - 특정 방 상세정보 조회 구현 * chore: prisma schema 임시 수정(room 테이블) * feat: swagger.ts에 RoomInfoResponse(/api/room/roomId 응답 구조) 추가 * refactor: youtube api - 검색어 관련 추천영상 파일명 변경 * refactor: youtube api - 검색어 관련 추천영상 파일명 변경 * chore: schema.prisma 수정 - rooms 테이블 videoId 추가 * chore: 주석 제거 * chore: npm run format, build * chore: npm run format * chore: env 환경변수 추가 * refactor: 코드 리팩토링 중 main 브랜치 동기화를 위한 커밋 * refactor: youtube api 라우터 youtubeRoute.ts에서 호출되도록 리팩토링 * chore: 코드 포맷 * chore: prisma 업데이트 * feat: roomId가 존재하지 않을 때 에러 처리 코드 추가 * feat: 특정 방 상세정보 조회 api - room 데이터가 존재하지 않을 때 발생할 수 있는 에러 처리 코드 추가 * feat: roomInfoDto.ts에 videoId 추가 * chore: prisma 동기화 * fix: 에러 해결 * feat: roomInfoService.ts에서 duration 형식 formatter.ts를 이용하여 형식 변환 * Revert "feat: roomInfoService.ts에서 duration 형식 formatter.ts를 이용하여 형식 변환" This reverts commit 89aad353046b365cdcc07cb14d11efb6838bfd6d. * feat: roomInfoService.ts - duration에 formatter 적용하여 영상 시간 형식 변환 * fix: prisma migration 실패 해결 * fix: npm run build 에러 해결 * chore: 코드 포맷 * fix: 마이그레이션 불일치로 인한 에러 해결 --- src/controllers/roomInfoController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/controllers/roomInfoController.ts b/src/controllers/roomInfoController.ts index fdf7378..500534d 100644 --- a/src/controllers/roomInfoController.ts +++ b/src/controllers/roomInfoController.ts @@ -17,7 +17,6 @@ const getRoomInfo = async (req: Request, res: Response, next: NextFunction) => { sendSuccess(res, roomInfo); } catch (error) { - console.error('🔥 에러 발생:', error); next(error); } }; From 0d1e5f9f669fcd405d1f1f0d2571876cc17520a2 Mon Sep 17 00:00:00 2001 From: GaHee Im <163043607+gaaahee@users.noreply.github.com> Date: Fri, 1 Aug 2025 06:40:14 +0900 Subject: [PATCH 38/59] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20api=20=EA=B5=AC=ED=98=84=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 방 설정 수정 api 추가 * chore: npm run format * feat: 사용자 인증을 위한 requireAuth 추가 * feat: 방 최대 참여 인원 8, 15, 30명 중 하나로 설정되도록 수정 * chore: prisma 동기화 * refactor: AppError 파라미터 수정 * refactor: AppError 파라미터 수정 * chore: 필요없는 코드 잠시 주석처리 * refactor: rooms api를 호출하는 roomRoute.ts에 방 설정 api 라우터 추가 * chore: 코드 포맷 * chore: 임시 커밋 * feat: roomRoute.ts - room setting api 명세서 추가 * chore: 주석 제거 * chore: schema.prisma 파일 충돌 해결 * chore: schema.prisma 파일 충돌 해결 * chore: schema.prisma 동기화 * feat: room setting api 명세서 수정 * feat: room setting api 명세서 수정 --- src/controllers/roomSettingController.ts | 29 ++++++++++ src/dtos/roomSettingDto.ts | 6 ++ src/routes/roomRoute.ts | 63 ++++++++++++++++++++ src/services/roomSettingService.ts | 73 ++++++++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 src/controllers/roomSettingController.ts create mode 100644 src/dtos/roomSettingDto.ts create mode 100644 src/services/roomSettingService.ts diff --git a/src/controllers/roomSettingController.ts b/src/controllers/roomSettingController.ts new file mode 100644 index 0000000..4f3138b --- /dev/null +++ b/src/controllers/roomSettingController.ts @@ -0,0 +1,29 @@ +import { Request, Response, NextFunction } from 'express'; +import * as roomSettingService from '../services/roomSettingService.js'; +import { UpdateRoomSettingDto } from '../dtos/roomSettingDto.js'; +import AppError from '../middleware/errors/AppError.js'; + +export const updateRoomSettings = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const { roomId } = req.params; + const userId = req.user?.userId; + const updateDto: UpdateRoomSettingDto = req.body; + + if (!userId) { + return next(new AppError('인증이 필요합니다.')); + } + + await roomSettingService.updateRoomSettings(roomId, userId, updateDto); + + res.status(200).json({ + success: true, + message: '방 설정이 수정되었습니다.', + }); + } catch (error) { + next(error); + } +}; diff --git a/src/dtos/roomSettingDto.ts b/src/dtos/roomSettingDto.ts new file mode 100644 index 0000000..29dfc22 --- /dev/null +++ b/src/dtos/roomSettingDto.ts @@ -0,0 +1,6 @@ +export interface UpdateRoomSettingDto { + maxParticipants?: number; + isPrivate?: boolean; + autoArchiving?: boolean; + invitePermission?: string; +} diff --git a/src/routes/roomRoute.ts b/src/routes/roomRoute.ts index a6dcb9c..f263e98 100644 --- a/src/routes/roomRoute.ts +++ b/src/routes/roomRoute.ts @@ -1,6 +1,7 @@ import express from 'express'; import { roomInfoController } from '../controllers/roomInfoController.js'; +import * as roomSettingController from '../controllers/roomSettingController.js'; import { createRoom, joinRoom, @@ -393,4 +394,66 @@ router.get('/:roomId/messages', requireAuth, getRoomMessages); */ router.get('/:roomId', requireAuth, roomInfoController.getRoomInfo); +/** + * @swagger + * /api/rooms/{roomId}/settings: + * put: + * summary: 방 설정 수정 + * description: 방장이 특정 방의 설정을 수정합니다. + * tags: + * - Room + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: roomId + * required: true + * schema: + * type: integer + * description: 설정을 수정할 방의 ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * maxParticipants: + * type: integer + * description: 최대 참여자 수 + * example: 8 + * isPrivate: + * type: boolean + * description: 방 공개 여부 (true이면 비공개) + * example: true + * autoArchiving: + * type: boolean + * description: 자동 아카이빙 여부 + * example: true + * invitePermission: + * type: string + * enum: [all, host] + * description: 초대 권한 + * example: host + * responses: + * 200: + * description: 방 설정 수정 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * message: + * type: string + * example: 방 설정이 수정되었습니다. + * 401: + * description: 인증 실패 또는 호스트가 아님 + * 404: + * description: 방을 찾을 수 없음 + */ +router.put('/:roomId/settings', requireAuth, roomSettingController.updateRoomSettings); + export default router; diff --git a/src/services/roomSettingService.ts b/src/services/roomSettingService.ts new file mode 100644 index 0000000..a0012a6 --- /dev/null +++ b/src/services/roomSettingService.ts @@ -0,0 +1,73 @@ +import { PrismaClient, InviteAuth } from '@prisma/client'; +import AppError from '../middleware/errors/AppError.js'; +import { UpdateRoomSettingDto } from '../dtos/roomSettingDto.js'; +const prisma = new PrismaClient(); + +/** + * 방 설정을 수정합니다. (방장만 가능) + * @param roomId 방 ID (URL 파라미터로 받은 문자열) + * @param userId 요청을 보낸 사용자 ID + * @param updateDto 수정할 설정 데이터 + */ +export const updateRoomSettings = async ( + roomId: string, + userId: number, + updateDto: UpdateRoomSettingDto, +): Promise => { + // Prisma는 숫자 ID이므로 숫자로 변환 + const numericRoomId = parseInt(roomId, 10); + if (isNaN(numericRoomId)) { + throw new AppError('ROOM_003', '유효하지 않은 방 ID입니다.'); + } + + const room = await prisma.room.findUnique({ + where: { roomId: numericRoomId }, + }); + + if (!room) { + throw new AppError('ROOM_001', `ID가 '${roomId}'인 방을 찾을 수 없습니다.`); + } + + if (room.hostId !== userId) { + throw new AppError('ROOM_004', '방장만 설정을 수정할 수 있습니다.'); + } + + // DTO와 Prisma 모델 필드명 매핑 + const { maxParticipants, isPrivate, autoArchiving, invitePermission } = updateDto; + const dataToUpdate: Partial<{ + maxParticipants: number; + isPublic: boolean; + autoArchive: boolean; + inviteAuth: InviteAuth; + }> = {}; + + // maxParticipants 값 검증 (8, 15, 30명만 가능) + if (maxParticipants !== undefined) { + if (![8, 15, 30].includes(maxParticipants)) { + throw new AppError( + 'ROOM_008', + '최대 참여 인원은 8명, 15명, 또는 30명으로만 설정할 수 있습니다.', + ); + } + dataToUpdate.maxParticipants = maxParticipants; + } + + if (isPrivate !== undefined) dataToUpdate.isPublic = !isPrivate; + if (autoArchiving !== undefined) dataToUpdate.autoArchive = autoArchiving; + + // invitePermission 값 검증 + if (invitePermission) { + const lowercasedPermission = invitePermission.toLowerCase(); + if (lowercasedPermission !== 'all' && lowercasedPermission !== 'host') { + throw new AppError( + 'ROOM_009', + `유효하지 않은 초대 권한 값입니다. "ALL" 또는 "HOST"만 가능합니다.`, + ); + } + dataToUpdate.inviteAuth = lowercasedPermission as InviteAuth; + } + await prisma.room.update({ + where: { roomId: room.roomId }, + data: dataToUpdate, + }); +}; From f8abc5ed060ae8d10813477d88967c5538c6ca3e Mon Sep 17 00:00:00 2001 From: GaHee Im <163043607+gaaahee@users.noreply.github.com> Date: Fri, 1 Aug 2025 13:01:09 +0900 Subject: [PATCH 39/59] =?UTF-8?q?feat:=20=ED=99=9C=EC=84=B1=ED=99=94?= =?UTF-8?q?=EB=90=9C=20=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20api=20=EA=B5=AC=ED=98=84=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 활성화된 방 목록 조회 api 구현을 위한 파일 추가 * feat: 활성화된 방 목록 조회 api를 위한 파일 추가 * feat: 활성화된 방 목록 조회 api 명세서 작성 * feat: 활성화된 방 목록 조회 api 경로 추가 * feat: 활성화된 방 목록 조회 api 추가 * chore: prisma 업데이트 * feat: activeRoomsController.ts 에러 처리 * chore: 임시 커밋 * chore: 코드 포맷 * feat: 활성화된 방 목록 조회 api 구현 * chore: 활성화된 방 목록 조회 api 수정 * fix: main과 동일하게 코드 통일하여 에러 해결 * refactor: activeRoomService.ts currentParticipants 필드 리팩토링 --- src/controllers/activeRoomsController.ts | 73 ++++++++++++++ src/dtos/activeRoomsDto.ts | 66 +++++++++++++ src/routes/activeRoomsRoute.ts | 52 ++++++++++ src/routes/roomRoute.ts | 47 +++++++++ src/services/activeRoomsService.ts | 119 +++++++++++++++++++++++ src/services/roomServices.ts | 14 --- src/swagger.ts | 42 ++++++++ 7 files changed, 399 insertions(+), 14 deletions(-) create mode 100644 src/controllers/activeRoomsController.ts create mode 100644 src/dtos/activeRoomsDto.ts create mode 100644 src/routes/activeRoomsRoute.ts create mode 100644 src/services/activeRoomsService.ts diff --git a/src/controllers/activeRoomsController.ts b/src/controllers/activeRoomsController.ts new file mode 100644 index 0000000..213a7bc --- /dev/null +++ b/src/controllers/activeRoomsController.ts @@ -0,0 +1,73 @@ +import { Request, Response, NextFunction } from 'express'; +import { ActiveRoomService } from '../services/activeRoomsService.js'; +import { + GetRoomsQueryDto, + SortByOption, + isSearchTypeOption, // isSearchTypeOption 타입 가드 임포트 + VALID_SORT_BY_OPTIONS, +} from '../dtos/activeRoomsDto.js'; +import { sendSuccess } from '../utils/response.js'; +import AppError from '../middleware/errors/AppError.js'; + +export class ActiveRoomController { + constructor(private activeRoomService: ActiveRoomService) {} + + /** + * GET /rooms: 활성화된 방 목록 조회 + */ + public getRooms = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { searchType: rawSearchType, keyword, sortBy: rawSortBy } = req.query; + const sortBy = (rawSortBy as SortByOption) || 'latest'; + + // sortBy 값 유효성 검사 + if (!VALID_SORT_BY_OPTIONS.includes(sortBy)) { + throw new AppError( + 'GENERAL_001', + `'sortBy' 파라미터는 [${VALID_SORT_BY_OPTIONS.join(', ')}] 중 하나여야 합니다.`, + ); + } + + // searchType 및 keyword 조합 유효성 검사 + let searchType: GetRoomsQueryDto['searchType']; + if (rawSearchType) { + if (!isSearchTypeOption(rawSearchType)) { + throw new AppError('GENERAL_001', `'searchType' 파라미터가 유효하지 않습니다.`); + } + if (!keyword) { + throw new AppError( + 'GENERAL_001', + '검색 타입(searchType)을 지정하려면 검색어(keyword)가 필요합니다.', + ); + } + searchType = rawSearchType; + } else if (keyword) { + throw new AppError( + 'GENERAL_001', + '검색어(keyword)를 사용하려면 검색 타입(searchType)을 지정해야 합니다.', + ); + } + + const query: GetRoomsQueryDto = { + sortBy, + searchType, + keyword: keyword as string | undefined, + }; + + // 인증된 사용자인 경우 userId를 전달하여 개인화된 결과를 얻습니다. + const userId = req.user?.userId; + const roomsData = await this.activeRoomService.findAll(query, userId); + + sendSuccess(res, roomsData); + } catch (error) { + console.error('활성화된 방 목록 조회 중 오류 발생:', error); + if (!(error instanceof AppError)) { + // ROOM_007: 방 목록 조회 실패 + return next( + new AppError('ROOM_007', '방 목록을 조회하는 중 예상치 못한 오류가 발생했습니다.'), + ); + } + next(error); + } + }; +} diff --git a/src/dtos/activeRoomsDto.ts b/src/dtos/activeRoomsDto.ts new file mode 100644 index 0000000..cb29c69 --- /dev/null +++ b/src/dtos/activeRoomsDto.ts @@ -0,0 +1,66 @@ +/** + * @file 활성화된 방 조회 관련 DTO + */ + +/** + * 정렬 기준 + * - 'latest': 최신순 + * - 'popularity': 방장 인기순 + */ +export type SortByOption = 'latest' | 'popularity'; +export const VALID_SORT_BY_OPTIONS: SortByOption[] = ['latest', 'popularity']; + +/** + * 검색 기준 + * - 'videoTitle': 영상 제목 + * - 'roomTitle': 방 제목 + * - 'hostNickname': 방장 닉네임 + */ +export type SearchTypeOption = 'videoTitle' | 'roomTitle' | 'hostNickname'; +export const VALID_SEARCH_TYPE_OPTIONS: SearchTypeOption[] = [ + 'videoTitle', + 'roomTitle', + 'hostNickname', +]; + +/** + * searchType 문자열이 유효한 SearchTypeOption인지 확인하는 타입 가드 함수 + * @param value - 검사할 문자열 + */ +export function isSearchTypeOption(value: any): value is SearchTypeOption { + return VALID_SEARCH_TYPE_OPTIONS.includes(value); +} + +/** + * GET /rooms API 요청 시 사용하는 쿼리 파라미터 DTO + */ +export interface GetRoomsQueryDto { + sortBy?: SortByOption; + searchType?: SearchTypeOption; + keyword?: string; +} + +/** + * API 응답에 포함된 개별 방의 정보 DTO + */ +export interface RoomDto { + roomId: number; + roomTitle: string; + videoTitle: string; + videoThumbnail: string; + hostNickname: string; + hostProfileImage: string; + hostPopularity: number; + currentParticipants: number; + maxParticipants: number; + duration: string; // "HH:mm:ss" format + isPrivate: boolean; +} + +/** + * GET /rooms API의 `data` 필드 DTO + */ +export interface RoomsDataDto { + continueWatching: RoomDto[]; + onAirRooms: RoomDto[]; +} diff --git a/src/routes/activeRoomsRoute.ts b/src/routes/activeRoomsRoute.ts new file mode 100644 index 0000000..b255a72 --- /dev/null +++ b/src/routes/activeRoomsRoute.ts @@ -0,0 +1,52 @@ +import { Router } from 'express'; +import { ActiveRoomController } from '../controllers/activeRoomsController.js'; +import { ActiveRoomService } from '../services/activeRoomsService.js'; +import { requireAuth } from '../middleware/authMiddleware.js'; + +const router = Router(); + +const activeRoomService = new ActiveRoomService(); +const activeRoomController = new ActiveRoomController(activeRoomService); +/** + * @swagger + * /api/rooms: + * get: + * summary: 활성화된 방 목록 조회 + * description: 현재 활성화된 방 목록을 검색 및 정렬 조건에 따라 조회합니다. + * tags: + * - Room + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: sortBy + * schema: + * type: string + * enum: [latest, popularity] + * description: '정렬 기준 (latest: 최신순, popularity: 방장 인기순)' + * - in: query + * name: searchType + * schema: + * type: string + * enum: [videoTitle, roomTitle, hostNickname] + * description: '검색 기준 (videoTitle: 영상제목, roomTitle: 방제목, hostNickname: 방장 닉네임)' + * - in: query + * name: keyword + * schema: + * type: string + * description: '검색어 (searchType이 지정된 경우에만 유효)' + * responses: + * 200: + * description: 활성화된 방 목록 조회 성공 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ActiveRoomsResponse' + * 401: + * description: 인증 실패 + * 500: + * description: 서버 내부 오류 + */ +router.get('/', requireAuth, activeRoomController.getRooms); + +export default router; diff --git a/src/routes/roomRoute.ts b/src/routes/roomRoute.ts index f263e98..5858f56 100644 --- a/src/routes/roomRoute.ts +++ b/src/routes/roomRoute.ts @@ -10,6 +10,9 @@ import { postRoomMessage, leaveRoom, } from '../controllers/roomController.js'; +import { ActiveRoomController } from '../controllers/activeRoomsController.js'; +import { ActiveRoomService } from '../services/activeRoomsService.js'; + import { requireAuth } from '../middleware/authMiddleware.js'; const router = express.Router(); @@ -91,6 +94,50 @@ const router = express.Router(); */ router.post('/', requireAuth, createRoom); +const activeRoomService = new ActiveRoomService(); +const activeRoomController = new ActiveRoomController(activeRoomService); +/** + * @swagger + * /api/rooms: + * get: + * summary: 활성화된 방 목록 조회 + * description: 현재 활성화된 방 목록을 검색 및 정렬 조건에 따라 조회합니다. + * tags: + * - Room + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: sortBy + * schema: + * type: string + * enum: [latest, popularity] + * description: '정렬 기준 (latest: 최신순, popularity: 방장 인기순)' + * - in: query + * name: searchType + * schema: + * type: string + * enum: [videoTitle, roomTitle, hostNickname] + * description: '검색 기준 (videoTitle: 영상제목, roomTitle: 방제목, hostNickname: 방장 닉네임)' + * - in: query + * name: keyword + * schema: + * type: string + * description: '검색어 (searchType이 지정된 경우에만 유효)' + * responses: + * 200: + * description: 활성화된 방 목록 조회 성공 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ActiveRoomsResponse' + * 401: + * description: 인증 실패 + * 500: + * description: 서버 내부 오류 + */ +router.get('/', requireAuth, activeRoomController.getRooms); + // 소켓 통신으로 할거임 이거는 단순 db 및 라우팅 처리 /** * @swagger diff --git a/src/services/activeRoomsService.ts b/src/services/activeRoomsService.ts new file mode 100644 index 0000000..fc53a05 --- /dev/null +++ b/src/services/activeRoomsService.ts @@ -0,0 +1,119 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import { + GetRoomsQueryDto, + isSearchTypeOption, + RoomDto, + RoomsDataDto, +} from '../dtos/activeRoomsDto.js'; +import { formatISO8601Duration } from '../utils/formatters.js'; + +const prisma = new PrismaClient(); + +export class ActiveRoomService { + /** + * 활성화된 모든 방 목록을 데이터베이스에서 조회하고, 비즈니스 로직에 따라 처리합니다. + * @param query - 필터링 및 정렬을 위한 쿼리 파라미터 + * @param userId - (Optional) 요청한 사용자의 ID. 시청 기록 기반 추천에 사용됩니다. + */ + public async findAll(query: GetRoomsQueryDto, userId?: number): Promise { + console.log('Service: Finding rooms with query:', query); + + // 1. Prisma 쿼리 조건 구성 (검색, 정렬) + const where: Prisma.RoomWhereInput = { + isActive: true, // 활성화된 방만 조회 + isPublic: true, // 공개된 방만 조회 (요구사항에 따라 변경 가능) + }; + + if (query.keyword && query.searchType && isSearchTypeOption(query.searchType)) { + const { searchType, keyword } = query; + + if (searchType === 'hostNickname') { + where.host = { + nickname: { + contains: keyword, + }, + }; + } else if (searchType === 'roomTitle') { + where.roomName = { + contains: keyword, + }; + } + // 'videoTitle'은 DB에서 직접 검색하지 않고, 조회 후 애플리케이션 레벨에서 필터링합니다. + } + + const orderBy: Prisma.RoomOrderByWithRelationInput = {}; + if (query.sortBy === 'popularity') { + orderBy.host = { + popularity: 'desc', + }; + } else { + // 'latest' or default + orderBy.createdAt = 'desc'; + } + + // 2. 데이터베이스에서 방 목록 조회 + const rooms = await prisma.room.findMany({ + where, + include: { + host: true, // 호스트 정보 포함 + _count: { + select: { participants: true }, + }, + }, + orderBy, + }); + + // 3. 각 방의 비디오 정보 조회 (N+1 문제 방지를 위해 Promise.all 사용) + const videoIds = rooms.map(room => room.videoId); + const videos = await prisma.youtubeVideo.findMany({ + where: { + videoId: { in: videoIds }, + }, + }); + const videoMap = new Map(videos.map(v => [v.videoId, v])); + + // 4. DTO 형태로 데이터 매핑 및 추가 필터링 + let roomDtos: RoomDto[] = rooms + .map(room => { + const video = videoMap.get(room.videoId); + if (!video) return null; // 비디오 정보가 없으면 목록에서 제외 + + return { + roomId: room.roomId, + roomTitle: room.roomName, + videoTitle: video.title, + videoThumbnail: video.thumbnail || 'url_default', + hostNickname: room.host.nickname, + hostProfileImage: room.host.profileImage || 'url_profile_default', + hostPopularity: room.host.popularity, + currentParticipants: room._count.participants, + maxParticipants: room.maxParticipants, + duration: formatISO8601Duration(video.duration || 'PT0S'), + isPrivate: !room.isPublic, + }; + }) + .filter((room): room is RoomDto => room !== null); + + // videoTitle 검색어 필터링 (DB에서 직접 처리 불가 시) + if (query.keyword && query.searchType === 'videoTitle') { + roomDtos = roomDtos.filter(room => + room.videoTitle.toLowerCase().includes(query.keyword!.toLowerCase()), + ); + } + + // 5. 데이터 분리 (continueWatching / onAirRooms) + // TODO: 추후 사용자의 실제 시청 기록(RoomParticipant)을 기반으로 로직 구현 필요 + const continueWatching: RoomDto[] = []; + const onAirRooms = roomDtos; + + // 예시 로직 (만약 userId가 제공되면) + if (userId) { + // 사용자가 참여했던 방 목록을 조회하여 continueWatching 목록을 채우는 로직 추가 + } + + return { + continueWatching, + onAirRooms, + }; + } +} diff --git a/src/services/roomServices.ts b/src/services/roomServices.ts index 607275f..5520c5f 100644 --- a/src/services/roomServices.ts +++ b/src/services/roomServices.ts @@ -131,14 +131,6 @@ export const addParticipant = async (roomId: number, participant: Participant) = }, }); - //방에 현재 참여자 수 증가 - await prisma.room.update({ - where: { roomId }, - data: { - currentParticipants: { increment: 1 }, - }, - }); - return room; }; @@ -156,12 +148,6 @@ export const removeParticipant = async (roomId: number, userId: number) => { unique_participant: { roomId, userId }, }, }), - prisma.room.update({ - where: { roomId }, - data: { - currentParticipants: { decrement: 1 }, - }, - }), ]); return room; diff --git a/src/swagger.ts b/src/swagger.ts index cb29331..cd97f68 100644 --- a/src/swagger.ts +++ b/src/swagger.ts @@ -35,6 +35,48 @@ const options: swaggerJsdoc.Options = { }, }, schemas: { + ActiveRoom: { + type: 'object', + properties: { + roomId: { type: 'number', example: 123 }, + roomTitle: { type: 'string', example: '같이 명작 영화 봐요' }, + videoTitle: { type: 'string', example: '쇼생크 탈출' }, + videoThumbnail: { type: 'string', example: 'https://thumbnail.url/image.jpg' }, + hostNickname: { type: 'string', example: '영화광' }, + hostProfileImage: { type: 'string', example: 'https://profile.url/image.png' }, + hostPopularity: { type: 'number', example: 95 }, + currentParticipants: { type: 'number', example: 5 }, + maxParticipants: { type: 'number', example: 8 }, + duration: { type: 'string', example: '01:23:45' }, + isPrivate: { type: 'boolean', example: false }, + }, + }, + ActiveRoomsData: { + type: 'object', + properties: { + continueWatching: { + type: 'array', + items: { $ref: '#/components/schemas/ActiveRoom' }, + }, + onAirRooms: { + type: 'array', + items: { $ref: '#/components/schemas/ActiveRoom' }, + }, + }, + }, + ActiveRoomsResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessResponse' }, + { + type: 'object', + properties: { + data: { + $ref: '#/components/schemas/ActiveRoomsData', + }, + }, + }, + ], + }, RecommendedVideo: { type: 'object', properties: { From 1b90d8747dbfad05e45be811bcfac23fc40c8a7e Mon Sep 17 00:00:00 2001 From: GaHee Im <163043607+gaaahee@users.noreply.github.com> Date: Fri, 1 Aug 2025 15:54:28 +0900 Subject: [PATCH 40/59] =?UTF-8?q?feat:=20=EB=B0=A9=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B0=92=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EB=8A=94=20api=20=EA=B5=AC=ED=98=84=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 방 설정 수정 api 추가 * chore: npm run format * feat: 사용자 인증을 위한 requireAuth 추가 * feat: 방 최대 참여 인원 8, 15, 30명 중 하나로 설정되도록 수정 * chore: prisma 동기화 * refactor: AppError 파라미터 수정 * refactor: AppError 파라미터 수정 * chore: 필요없는 코드 잠시 주석처리 * refactor: rooms api를 호출하는 roomRoute.ts에 방 설정 api 라우터 추가 * chore: 코드 포맷 * chore: 임시 커밋 * feat: roomRoute.ts - room setting api 명세서 추가 * chore: 주석 제거 * chore: schema.prisma 파일 충돌 해결 * chore: schema.prisma 파일 충돌 해결 * chore: schema.prisma 동기화 * feat: room setting api 명세서 수정 * feat: room setting api 명세서 수정 * feat: 방에 설정된 기존 설정값 가져오는 api 구현 * chore: 코드 포맷 * refactor: 검색어 기반 추천영상 조회 api 긴급 수정 - 알맞은 형식으로 포맷팅된 duration 값만 응답에 포함 --- src/controllers/roomSettingController.ts | 30 ++++++-- src/dtos/youtubeRecommendationDto.ts | 1 - src/routes/roomRoute.ts | 60 +++++++++++++++- src/services/roomSettingService.ts | 74 +++++++++++++++----- src/services/youtubeRecommendationService.ts | 3 +- 5 files changed, 141 insertions(+), 27 deletions(-) diff --git a/src/controllers/roomSettingController.ts b/src/controllers/roomSettingController.ts index 4f3138b..27b8403 100644 --- a/src/controllers/roomSettingController.ts +++ b/src/controllers/roomSettingController.ts @@ -2,7 +2,30 @@ import { Request, Response, NextFunction } from 'express'; import * as roomSettingService from '../services/roomSettingService.js'; import { UpdateRoomSettingDto } from '../dtos/roomSettingDto.js'; import AppError from '../middleware/errors/AppError.js'; +import { sendSuccess } from '../utils/response.js'; +// 방 설정 조회 +export const getRoomSettings = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const { roomId } = req.params; + const userId = req.user?.userId; + + if (!userId) { + return next(new AppError('AUTH_007', '인증이 필요합니다.')); + } + + const settings = await roomSettingService.getRoomSettings(roomId, userId); + sendSuccess(res, settings); + } catch (error) { + next(error); + } +}; + +// 방 설정 수정 export const updateRoomSettings = async ( req: Request, res: Response, @@ -14,15 +37,12 @@ export const updateRoomSettings = async ( const updateDto: UpdateRoomSettingDto = req.body; if (!userId) { - return next(new AppError('인증이 필요합니다.')); + return next(new AppError('AUTH_007', '인증이 필요합니다.')); } await roomSettingService.updateRoomSettings(roomId, userId, updateDto); - res.status(200).json({ - success: true, - message: '방 설정이 수정되었습니다.', - }); + sendSuccess(res, { message: '방 설정이 수정되었습니다.' }); } catch (error) { next(error); } diff --git a/src/dtos/youtubeRecommendationDto.ts b/src/dtos/youtubeRecommendationDto.ts index bc07e0a..a076a76 100644 --- a/src/dtos/youtubeRecommendationDto.ts +++ b/src/dtos/youtubeRecommendationDto.ts @@ -41,5 +41,4 @@ export interface RecommendedVideoDto { viewCount: number; uploadTime: string; duration: string; - durationFormatted: string; } diff --git a/src/routes/roomRoute.ts b/src/routes/roomRoute.ts index 5858f56..636f773 100644 --- a/src/routes/roomRoute.ts +++ b/src/routes/roomRoute.ts @@ -1,7 +1,7 @@ import express from 'express'; import { roomInfoController } from '../controllers/roomInfoController.js'; -import * as roomSettingController from '../controllers/roomSettingController.js'; +import { getRoomSettings, updateRoomSettings } from '../controllers/roomSettingController.js'; import { createRoom, joinRoom, @@ -441,6 +441,62 @@ router.get('/:roomId/messages', requireAuth, getRoomMessages); */ router.get('/:roomId', requireAuth, roomInfoController.getRoomInfo); +/** + * @swagger + * /api/rooms/{roomId}/settings: + * get: + * summary: 방 설정 조회 + * description: 특정 방의 설정된 기존 설정 값을 가져옵니다. + * tags: + * - Room + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: roomId + * required: true + * schema: + * type: integer + * description: 설정을 조회할 방의 ID + * responses: + * 200: + * description: 방 설정 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * maxParticipants: + * type: integer + * example: 15 + * isPrivate: + * type: boolean + * example: true + * autoArchiving: + * type: boolean + * example: false + * invitePermission: + * type: string + * enum: [all, host] + * example: "all" + * error: + * type: object + * nullable: true + * 401: + * description: 인증 실패 + * 403: + * description: 방에 참여하지 않은 사용자 + * 404: + * description: 방을 찾을 수 없음 + */ +router.get('/:roomId/settings', requireAuth, getRoomSettings); + /** * @swagger * /api/rooms/{roomId}/settings: @@ -501,6 +557,6 @@ router.get('/:roomId', requireAuth, roomInfoController.getRoomInfo); * 404: * description: 방을 찾을 수 없음 */ -router.put('/:roomId/settings', requireAuth, roomSettingController.updateRoomSettings); +router.put('/:roomId/settings', requireAuth, updateRoomSettings); export default router; diff --git a/src/services/roomSettingService.ts b/src/services/roomSettingService.ts index a0012a6..c3a162b 100644 --- a/src/services/roomSettingService.ts +++ b/src/services/roomSettingService.ts @@ -1,8 +1,39 @@ import { PrismaClient, InviteAuth } from '@prisma/client'; import AppError from '../middleware/errors/AppError.js'; import { UpdateRoomSettingDto } from '../dtos/roomSettingDto.js'; + const prisma = new PrismaClient(); +// 방장이 맞는지 확인하는 헬퍼 함수 +const findRoomAndCheckHost = async (roomId: string, userId: number) => { + const numericRoomId = parseInt(roomId, 10); + if (isNaN(numericRoomId)) throw new AppError('ROOM_003', '유효하지 않은 방 ID입니다.'); + + const room = await prisma.room.findUnique({ where: { roomId: numericRoomId } }); + if (!room) throw new AppError('ROOM_001', `ID가 '${roomId}'인 방을 찾을 수 없습니다.`); + if (room.hostId !== userId) throw new AppError('ROOM_004', '방장만 접근할 수 있습니다.'); + return room; +}; + +// 방 참여자가 맞는지 확인하는 헬퍼 함수 +const findRoomAndCheckParticipant = async (roomId: string, userId: number) => { + const numericRoomId = parseInt(roomId, 10); + if (isNaN(numericRoomId)) throw new AppError('ROOM_003', '유효하지 않은 방 ID입니다.'); + + const room = await prisma.room.findUnique({ where: { roomId: numericRoomId } }); + if (!room) throw new AppError('ROOM_001', `ID가 '${roomId}'인 방을 찾을 수 없습니다.`); + + // 사용자가 해당 방에 참여하고 있는지 확인 (left_at이 null인 경우) + const participant = await prisma.roomParticipant.findFirst({ + where: { roomId: numericRoomId, userId: userId, left_at: null }, + }); + + if (!participant) { + throw new AppError('ROOM_006', '방에 참여한 사용자만 설정을 조회할 수 있습니다.'); + } + return room; +}; + /** * 방 설정을 수정합니다. (방장만 가능) * @param roomId 방 ID (URL 파라미터로 받은 문자열) @@ -14,23 +45,7 @@ export const updateRoomSettings = async ( userId: number, updateDto: UpdateRoomSettingDto, ): Promise => { - // Prisma는 숫자 ID이므로 숫자로 변환 - const numericRoomId = parseInt(roomId, 10); - if (isNaN(numericRoomId)) { - throw new AppError('ROOM_003', '유효하지 않은 방 ID입니다.'); - } - - const room = await prisma.room.findUnique({ - where: { roomId: numericRoomId }, - }); - - if (!room) { - throw new AppError('ROOM_001', `ID가 '${roomId}'인 방을 찾을 수 없습니다.`); - } - - if (room.hostId !== userId) { - throw new AppError('ROOM_004', '방장만 설정을 수정할 수 있습니다.'); - } + const room = await findRoomAndCheckHost(roomId, userId); // DTO와 Prisma 모델 필드명 매핑 const { maxParticipants, isPrivate, autoArchiving, invitePermission } = updateDto; @@ -71,3 +86,28 @@ export const updateRoomSettings = async ( data: dataToUpdate, }); }; + +/** + * 방 설정을 조회합니다. + * @param roomId 방 ID + * @param userId 요청을 보낸 사용자 ID + * @returns 방 설정 데이터 + */ +export const getRoomSettings = async ( + roomId: string, + userId: number, +): Promise<{ + maxParticipants: number; + isPrivate: boolean; + autoArchiving: boolean; + invitePermission: InviteAuth; +}> => { + const room = await findRoomAndCheckParticipant(roomId, userId); + + return { + maxParticipants: room.maxParticipants, + isPrivate: !room.isPublic, + autoArchiving: room.autoArchive, + invitePermission: room.inviteAuth, + }; +}; diff --git a/src/services/youtubeRecommendationService.ts b/src/services/youtubeRecommendationService.ts index 34032a9..71ee878 100644 --- a/src/services/youtubeRecommendationService.ts +++ b/src/services/youtubeRecommendationService.ts @@ -61,8 +61,7 @@ export const getRecommendedVideos = async ( channelName: item.snippet.channelTitle || 'Unknown Channel', viewCount: details ? parseInt(details.statistics.viewCount, 10) : 0, uploadTime: item.snippet.publishedAt, - duration: isoDuration, // 원본 ISO 8601 형식 - durationFormatted: formatISO8601Duration(isoDuration), // "mm:ss" 형식으로 변환된 값 + duration: formatISO8601Duration(isoDuration), }; }, ); From f70ce3b430057c78b68861ba364757fa0063425c Mon Sep 17 00:00:00 2001 From: GaHee Im <163043607+gaaahee@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:35:16 +0900 Subject: [PATCH 41/59] =?UTF-8?q?feat:=20=ED=99=9C=EC=84=B1=ED=99=94?= =?UTF-8?q?=EB=90=9C=20=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20api=20=EC=9D=BC=EB=B6=80=20=EC=88=98=EC=A0=95=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 활성화된 방 목록 조회 api 구현을 위한 파일 추가 * feat: 활성화된 방 목록 조회 api를 위한 파일 추가 * feat: 활성화된 방 목록 조회 api 명세서 작성 * feat: 활성화된 방 목록 조회 api 경로 추가 * feat: 활성화된 방 목록 조회 api 추가 * chore: prisma 업데이트 * feat: activeRoomsController.ts 에러 처리 * chore: 임시 커밋 * chore: 코드 포맷 * feat: 활성화된 방 목록 조회 api 구현 * chore: 활성화된 방 목록 조회 api 수정 * fix: main과 동일하게 코드 통일하여 에러 해결 * refactor: activeRoomService.ts currentParticipants 필드 리팩토링 * chore: 활성화된 방 목록 조회 api 라우터 중복 제거 * chore: merge 충돌 해결 * feat: 활성화된 방 목록 조회 api - 정렬 기준, 검색 타입 기본 값 설정 및 에러 처리 수정 --- src/controllers/activeRoomsController.ts | 23 ++++------- src/routes/activeRoomsRoute.ts | 52 ------------------------ src/routes/roomRoute.ts | 5 ++- src/services/activeRoomsService.ts | 52 ++++++++++++++++++------ 4 files changed, 51 insertions(+), 81 deletions(-) delete mode 100644 src/routes/activeRoomsRoute.ts diff --git a/src/controllers/activeRoomsController.ts b/src/controllers/activeRoomsController.ts index 213a7bc..e6e23fa 100644 --- a/src/controllers/activeRoomsController.ts +++ b/src/controllers/activeRoomsController.ts @@ -3,7 +3,7 @@ import { ActiveRoomService } from '../services/activeRoomsService.js'; import { GetRoomsQueryDto, SortByOption, - isSearchTypeOption, // isSearchTypeOption 타입 가드 임포트 + isSearchTypeOption, VALID_SORT_BY_OPTIONS, } from '../dtos/activeRoomsDto.js'; import { sendSuccess } from '../utils/response.js'; @@ -30,22 +30,17 @@ export class ActiveRoomController { // searchType 및 keyword 조합 유효성 검사 let searchType: GetRoomsQueryDto['searchType']; - if (rawSearchType) { - if (!isSearchTypeOption(rawSearchType)) { - throw new AppError('GENERAL_001', `'searchType' 파라미터가 유효하지 않습니다.`); - } - if (!keyword) { + if (keyword) { + if (!rawSearchType) { throw new AppError( 'GENERAL_001', - '검색 타입(searchType)을 지정하려면 검색어(keyword)가 필요합니다.', + '검색어(keyword)를 사용하려면 검색 타입(searchType)을 지정해야 합니다.', ); } + if (!isSearchTypeOption(rawSearchType)) { + throw new AppError('GENERAL_001', `'searchType' 파라미터가 유효하지 않습니다.`); + } searchType = rawSearchType; - } else if (keyword) { - throw new AppError( - 'GENERAL_001', - '검색어(keyword)를 사용하려면 검색 타입(searchType)을 지정해야 합니다.', - ); } const query: GetRoomsQueryDto = { @@ -54,7 +49,6 @@ export class ActiveRoomController { keyword: keyword as string | undefined, }; - // 인증된 사용자인 경우 userId를 전달하여 개인화된 결과를 얻습니다. const userId = req.user?.userId; const roomsData = await this.activeRoomService.findAll(query, userId); @@ -62,9 +56,8 @@ export class ActiveRoomController { } catch (error) { console.error('활성화된 방 목록 조회 중 오류 발생:', error); if (!(error instanceof AppError)) { - // ROOM_007: 방 목록 조회 실패 return next( - new AppError('ROOM_007', '방 목록을 조회하는 중 예상치 못한 오류가 발생했습니다.'), + new AppError('GENERAL_004', '방 목록을 조회하는 중 예상치 못한 오류가 발생했습니다.'), ); } next(error); diff --git a/src/routes/activeRoomsRoute.ts b/src/routes/activeRoomsRoute.ts deleted file mode 100644 index b255a72..0000000 --- a/src/routes/activeRoomsRoute.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Router } from 'express'; -import { ActiveRoomController } from '../controllers/activeRoomsController.js'; -import { ActiveRoomService } from '../services/activeRoomsService.js'; -import { requireAuth } from '../middleware/authMiddleware.js'; - -const router = Router(); - -const activeRoomService = new ActiveRoomService(); -const activeRoomController = new ActiveRoomController(activeRoomService); -/** - * @swagger - * /api/rooms: - * get: - * summary: 활성화된 방 목록 조회 - * description: 현재 활성화된 방 목록을 검색 및 정렬 조건에 따라 조회합니다. - * tags: - * - Room - * security: - * - bearerAuth: [] - * parameters: - * - in: query - * name: sortBy - * schema: - * type: string - * enum: [latest, popularity] - * description: '정렬 기준 (latest: 최신순, popularity: 방장 인기순)' - * - in: query - * name: searchType - * schema: - * type: string - * enum: [videoTitle, roomTitle, hostNickname] - * description: '검색 기준 (videoTitle: 영상제목, roomTitle: 방제목, hostNickname: 방장 닉네임)' - * - in: query - * name: keyword - * schema: - * type: string - * description: '검색어 (searchType이 지정된 경우에만 유효)' - * responses: - * 200: - * description: 활성화된 방 목록 조회 성공 - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ActiveRoomsResponse' - * 401: - * description: 인증 실패 - * 500: - * description: 서버 내부 오류 - */ -router.get('/', requireAuth, activeRoomController.getRooms); - -export default router; diff --git a/src/routes/roomRoute.ts b/src/routes/roomRoute.ts index 636f773..68bbdca 100644 --- a/src/routes/roomRoute.ts +++ b/src/routes/roomRoute.ts @@ -16,6 +16,9 @@ import { ActiveRoomService } from '../services/activeRoomsService.js'; import { requireAuth } from '../middleware/authMiddleware.js'; const router = express.Router(); +const activeRoomService = new ActiveRoomService(); +const activeRoomController = new ActiveRoomController(activeRoomService); + /** * @swagger * /api/rooms: @@ -94,8 +97,6 @@ const router = express.Router(); */ router.post('/', requireAuth, createRoom); -const activeRoomService = new ActiveRoomService(); -const activeRoomController = new ActiveRoomController(activeRoomService); /** * @swagger * /api/rooms: diff --git a/src/services/activeRoomsService.ts b/src/services/activeRoomsService.ts index fc53a05..620b382 100644 --- a/src/services/activeRoomsService.ts +++ b/src/services/activeRoomsService.ts @@ -11,17 +11,17 @@ const prisma = new PrismaClient(); export class ActiveRoomService { /** - * 활성화된 모든 방 목록을 데이터베이스에서 조회하고, 비즈니스 로직에 따라 처리합니다. - * @param query - 필터링 및 정렬을 위한 쿼리 파라미터 - * @param userId - (Optional) 요청한 사용자의 ID. 시청 기록 기반 추천에 사용됩니다. + * 활성화된 방 목록 조회 + * @param query + * @param userId */ public async findAll(query: GetRoomsQueryDto, userId?: number): Promise { console.log('Service: Finding rooms with query:', query); // 1. Prisma 쿼리 조건 구성 (검색, 정렬) const where: Prisma.RoomWhereInput = { - isActive: true, // 활성화된 방만 조회 - isPublic: true, // 공개된 방만 조회 (요구사항에 따라 변경 가능) + isActive: true, + isPublic: true, }; if (query.keyword && query.searchType && isSearchTypeOption(query.searchType)) { @@ -38,7 +38,6 @@ export class ActiveRoomService { contains: keyword, }; } - // 'videoTitle'은 DB에서 직접 검색하지 않고, 조회 후 애플리케이션 레벨에서 필터링합니다. } const orderBy: Prisma.RoomOrderByWithRelationInput = {}; @@ -47,7 +46,7 @@ export class ActiveRoomService { popularity: 'desc', }; } else { - // 'latest' or default + // 기본값: 최신순 orderBy.createdAt = 'desc'; } @@ -55,7 +54,7 @@ export class ActiveRoomService { const rooms = await prisma.room.findMany({ where, include: { - host: true, // 호스트 정보 포함 + host: true, _count: { select: { participants: true }, }, @@ -63,7 +62,7 @@ export class ActiveRoomService { orderBy, }); - // 3. 각 방의 비디오 정보 조회 (N+1 문제 방지를 위해 Promise.all 사용) + // 3. 각 방의 영상 정보 조회 const videoIds = rooms.map(room => room.videoId); const videos = await prisma.youtubeVideo.findMany({ where: { @@ -76,7 +75,7 @@ export class ActiveRoomService { let roomDtos: RoomDto[] = rooms .map(room => { const video = videoMap.get(room.videoId); - if (!video) return null; // 비디오 정보가 없으면 목록에서 제외 + if (!video) return null; // 영상 정보가 없으면 목록에서 제외 return { roomId: room.roomId, @@ -94,7 +93,7 @@ export class ActiveRoomService { }) .filter((room): room is RoomDto => room !== null); - // videoTitle 검색어 필터링 (DB에서 직접 처리 불가 시) + // videoTitle 검색어 필터링 if (query.keyword && query.searchType === 'videoTitle') { roomDtos = roomDtos.filter(room => room.videoTitle.toLowerCase().includes(query.keyword!.toLowerCase()), @@ -102,13 +101,42 @@ export class ActiveRoomService { } // 5. 데이터 분리 (continueWatching / onAirRooms) - // TODO: 추후 사용자의 실제 시청 기록(RoomParticipant)을 기반으로 로직 구현 필요 const continueWatching: RoomDto[] = []; const onAirRooms = roomDtos; // 예시 로직 (만약 userId가 제공되면) if (userId) { // 사용자가 참여했던 방 목록을 조회하여 continueWatching 목록을 채우는 로직 추가 + const userRooms = await prisma.roomParticipant.findMany({ + where: { userId }, + include: { + room: { + include: { + host: true, + _count: { select: { participants: true } }, + }, + }, + }, + }); + userRooms.forEach(userRoom => { + const room = userRoom.room; + const video = videoMap.get(room.videoId); + if (video) { + continueWatching.push({ + roomId: room.roomId, + roomTitle: room.roomName, + videoTitle: video.title, + videoThumbnail: video.thumbnail || 'url_default', + hostNickname: room.host.nickname, + hostProfileImage: room.host.profileImage || 'url_profile_default', + hostPopularity: room.host.popularity, + currentParticipants: room._count.participants, + maxParticipants: room.maxParticipants, + duration: formatISO8601Duration(video.duration || 'PT0S'), + isPrivate: !room.isPublic, + }); + } + }); } return { From 2c22164a7811a2f05958ce88e75e55067cecb52b Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 2 Aug 2025 02:01:07 +0900 Subject: [PATCH 42/59] Fix: Remove unrelated code for collection acceptance/rejection, keep shared collection list API --- src/controllers/sharedCollectionController.ts | 3 --- src/dtos/sharedCollectionDto.ts | 1 - src/routes/sharedCollectionRoute.ts | 5 +---- src/services/sharedCollectionService.ts | 6 +----- 4 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/controllers/sharedCollectionController.ts b/src/controllers/sharedCollectionController.ts index 5aabda6..a48e5de 100644 --- a/src/controllers/sharedCollectionController.ts +++ b/src/controllers/sharedCollectionController.ts @@ -1,6 +1,5 @@ import { Request, Response } from 'express'; import { SharedCollectionService } from '../services/sharedCollectionService.js'; -import { SharedCollectionActionDto } from '../dtos/sharedCollectionDto.js'; const service = new SharedCollectionService(); @@ -21,5 +20,3 @@ export const getReceivedCollections = async (req: Request, res: Response): Promi res.status(500).json({ success: false, message: errorMessage }); } }; - -}; diff --git a/src/dtos/sharedCollectionDto.ts b/src/dtos/sharedCollectionDto.ts index 0ed6cd2..c8844d0 100644 --- a/src/dtos/sharedCollectionDto.ts +++ b/src/dtos/sharedCollectionDto.ts @@ -9,4 +9,3 @@ export class SharedCollectionResponseDto { bookmarkCount!: number; sharedAt!: Date; } - diff --git a/src/routes/sharedCollectionRoute.ts b/src/routes/sharedCollectionRoute.ts index 75cb11f..e323814 100644 --- a/src/routes/sharedCollectionRoute.ts +++ b/src/routes/sharedCollectionRoute.ts @@ -1,8 +1,5 @@ import { Router } from 'express'; -import { - getReceivedCollections, - respondToSharedCollection, -} from '../controllers/sharedCollectionController.js'; +import { getReceivedCollections } from '../controllers/sharedCollectionController.js'; import { requireAuth } from '../middleware/authMiddleware.js'; // 인증 미들웨어 경로에 맞게 수정 필요 const router = Router(); diff --git a/src/services/sharedCollectionService.ts b/src/services/sharedCollectionService.ts index a36ef7e..d8c2a72 100644 --- a/src/services/sharedCollectionService.ts +++ b/src/services/sharedCollectionService.ts @@ -1,8 +1,5 @@ import { prisma } from '../lib/prisma.js'; -import { - SharedCollectionActionDto, - SharedCollectionResponseDto, -} from '../dtos/sharedCollectionDto.js'; +import { SharedCollectionResponseDto } from '../dtos/sharedCollectionDto.js'; export class SharedCollectionService { // 공유받은 컬렉션 목록 조회 @@ -31,5 +28,4 @@ export class SharedCollectionService { sharedAt: sc.createdAt, })); } - } From 7c3d1254e2f066d6beebed0630189997cb900862 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sat, 2 Aug 2025 02:10:38 +0900 Subject: [PATCH 43/59] Feat/s3 upload (#80) * Fix: Remove CORS Error * Feat: Add S3 Upload --- .gitignore | 6 + package-lock.json | 2532 ++++++++++++++++++++++++---- package.json | 11 +- src/config/s3Config.ts | 14 + src/constants/errorCodes.ts | 6 + src/controllers/authController.ts | 7 + src/controllers/userController.ts | 51 +- src/dtos/activeRoomsDto.ts | 4 +- src/middleware/authValidation.ts | 8 +- src/middleware/uploadMiddleware.ts | 106 ++ src/routes/userRoutes.ts | 48 + src/utils/s3utils.ts | 21 + tsconfig.json | 6 +- 13 files changed, 2500 insertions(+), 320 deletions(-) create mode 100644 src/config/s3Config.ts create mode 100644 src/middleware/uploadMiddleware.ts create mode 100644 src/utils/s3utils.ts diff --git a/.gitignore b/.gitignore index 9c65e55..1075915 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,9 @@ bower_components/ # cors-config.json on-air-mate-back@* prettier +test-upload.html +test.* +*.mjs +test-upload.html +test.* +*.mjs diff --git a/package-lock.json b/package-lock.json index 845ad9d..b8e89c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "ISC", "dependencies": { + "@aws-sdk/client-s3": "^3.857.0", "@prisma/client": "^6.12.0", "@socket.io/redis-adapter": "^8.3.0", "axios": "^1.10.0", @@ -19,6 +20,8 @@ "dotenv": "^17.1.0", "express": "^4.21.2", "ioredis": "^5.6.1", + "multer": "^2.0.2", + "multer-s3": "^3.0.1", "mysql2": "^3.14.1", "socket.io": "^4.8.1", "swagger-jsdoc": "^6.2.8", @@ -29,7 +32,12 @@ "@types/cors": "^2.8.19", "@types/dotenv": "^8.2.3", "@types/express": "^5.0.3", +<<<<<<< HEAD "@types/ioredis": "^4.28.10", +======= + "@types/multer": "^2.0.0", + "@types/multer-s3": "^3.0.3", +>>>>>>> e6ad38d (Feat/s3 upload (#80)) "@types/node": "^24.0.10", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", @@ -92,464 +100,2062 @@ "openapi-types": ">=7" } }, - "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", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", + "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": { - "@jridgewell/trace-mapping": "0.3.9" + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12" + "node": ">=16.0.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", - "dev": true, - "license": "MIT", + "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": { - "eslint-visitor-keys": "^3.4.3" + "@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": "^12.22.0 || ^14.17.0 || >=16.0.0" + "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" }, - "funding": { - "url": "https://opencollective.com/eslint" + "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" }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "license": "MIT", + "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": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=14.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", - "dev": true, + "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": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=14.0.0" } }, - "node_modules/@eslint/config-array/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", + "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": { - "ms": "^2.1.3" + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0" + "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" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@eslint/config-array/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" + "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/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", - "dev": true, + "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", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" } }, - "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", - "dev": true, + "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": { - "@types/json-schema": "^7.0.15" + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=14.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", + "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": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=14.0.0" } }, - "node_modules/@eslint/eslintrc/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", + "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": { - "ms": "^2.1.3" + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=14.0.0" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", + "node_modules/@aws-sdk/client-s3": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.857.0.tgz", + "integrity": "sha512-kdNgv0QUIDc3nBStIXa22lX7WbfFmChTDHzONa53ZPIaP2E8CkPJJeZS55VRzHE7FytF34uP+6q1jDysdSTeYA==", + "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.857.0", + "@aws-sdk/credential-provider-node": "3.857.0", + "@aws-sdk/middleware-bucket-endpoint": "3.840.0", + "@aws-sdk/middleware-expect-continue": "3.840.0", + "@aws-sdk/middleware-flexible-checksums": "3.857.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-location-constraint": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-sdk-s3": "3.857.0", + "@aws-sdk/middleware-ssec": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.857.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/signature-v4-multi-region": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.857.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.2", + "@smithy/eventstream-serde-browser": "^4.0.4", + "@smithy/eventstream-serde-config-resolver": "^4.1.2", + "@smithy/eventstream-serde-node": "^4.0.4", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-blob-browser": "^4.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/hash-stream-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/md5-js": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-retry": "^4.1.18", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.25", + "@smithy/util-defaults-mode-node": "^4.0.25", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "@smithy/util-waiter": "^4.0.6", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.857.0.tgz", + "integrity": "sha512-0jXF4YJ3mGspNsxOU1rdk1uTtm/xadSWvgU+JQb2YCnallEDeT/Kahlyg4GOzPDj0UnnYWsD2s1Hx82O08SbiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.857.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.857.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.857.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.2", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-retry": "^4.1.18", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.25", + "@smithy/util-defaults-mode-node": "^4.0.25", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.857.0.tgz", + "integrity": "sha512-mgtjKignFcCl19TS6vKbC3e9jtogg6S38a0HFFWjcqMCUAskM+ZROickVTKsYeAk7FoYa2++YkM0qz8J/yteVA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/core": "^3.7.2", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.857.0.tgz", + "integrity": "sha512-i9NjopufQc7mrJr2lVU4DU5cLGJQ1wNEucnP6XcpCozbJfGJExU9o/VY27qU/pI8V0zK428KXuABuN70Qb+xkw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, "engines": { - "node": ">= 4" + "node": ">=18.0.0" } }, - "node_modules/@eslint/eslintrc/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/js": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", - "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.857.0.tgz", + "integrity": "sha512-Ig1dwbn+vO7Fo+2uznZ6Pv0xoLIWz6ndzJygn2eR2MRi6LvZSnTZqbeovjJeoEzWO2xFdK++SyjS7aEuAMAmzw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.3", + "tslib": "^2.6.2" }, - "funding": { - "url": "https://eslint.org/donate" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "dev": true, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.857.0.tgz", + "integrity": "sha512-w24ABs913sweDFz0aX/PGEfK1jgpV21a2E8p78ueSkQ7Fb7ELVzsv1C16ESFDDF++P4KVkxNQrjRuKw/5+T7ug==", "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.857.0", + "@aws-sdk/credential-provider-env": "3.857.0", + "@aws-sdk/credential-provider-http": "3.857.0", + "@aws-sdk/credential-provider-process": "3.857.0", + "@aws-sdk/credential-provider-sso": "3.857.0", + "@aws-sdk/credential-provider-web-identity": "3.857.0", + "@aws-sdk/nested-clients": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18.0.0" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", - "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", - "dev": true, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.857.0.tgz", + "integrity": "sha512-4ulf6NmbGrE1S+8eAHZQ/krvd441KdKvpT3bFoTsg+89YlGwobW+C+vy94qQBx0iKbqkILbLeFF2F/Bf/ACnmw==", "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.1", - "levn": "^0.4.1" + "@aws-sdk/credential-provider-env": "3.857.0", + "@aws-sdk/credential-provider-http": "3.857.0", + "@aws-sdk/credential-provider-ini": "3.857.0", + "@aws-sdk/credential-provider-process": "3.857.0", + "@aws-sdk/credential-provider-sso": "3.857.0", + "@aws-sdk/credential-provider-web-identity": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18.0.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", - "dev": true, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.857.0.tgz", + "integrity": "sha512-WLSLM4+vDyrjT+aeaiUHkAxUXUSQSXIQT8ZoS7RHo2BvTlpBOJY9nxvcmKWNCQ2hW2AhVjqBeMjVz3u3fFhoJQ==", "license": "Apache-2.0", "dependencies": { - "@types/json-schema": "^7.0.15" + "@aws-sdk/core": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18.0.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.857.0.tgz", + "integrity": "sha512-OfbkZ//9+nC2HH+3cbjjQz4d4ODQsFml38mPvwq7FSiVrUR7hxgE7OQael4urqKVWLEqFt6/PCr+QZq0J4dJ1A==", "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.857.0", + "@aws-sdk/core": "3.857.0", + "@aws-sdk/token-providers": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18.18.0" + "node": ">=18.0.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.857.0.tgz", + "integrity": "sha512-aj1QbOyhu+bl+gsgIpMuvVRJa1LkgwHzyu6lzjCrPxuPO6ytHDMmii+QUyM9P5K3Xk6fT/JGposhMFB5AtI+Og==", "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@aws-sdk/core": "3.857.0", + "@aws-sdk/nested-clients": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" }, "engines": { - "node": ">=18.18.0" + "node": ">=18.0.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, + "node_modules/@aws-sdk/lib-storage": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.857.0.tgz", + "integrity": "sha512-UkJDA6g/L+piv+q9mqp+zPbXYHpMblaupXHDo7DVTz9kmse4ZwftTAnAOQJc6zZzkaPPAiJPDOxOY5Q/B5+Tfg==", "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/smithy-client": "^4.4.9", + "buffer": "5.6.0", + "events": "3.3.0", + "stream-browserify": "3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=18.18" + "node": ">=18.0.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "peerDependencies": { + "@aws-sdk/client-s3": "^3.857.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.840.0.tgz", + "integrity": "sha512-+gkQNtPwcSMmlwBHFd4saVVS11In6ID1HczNzpM3MXKXRBfSlbZJbCt6wN//AZ8HMklZEik4tcEOG0qa9UY8SQ==", "license": "Apache-2.0", - "engines": { - "node": ">=12.22" + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-arn-parser": "3.804.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "tslib": "^2.6.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.840.0.tgz", + "integrity": "sha512-iJg2r6FKsKKvdiU4oCOuCf7Ro/YE0Q2BT/QyEZN3/Rt8Nr4SAZiQOlcBXOCpGvuIKOEAhvDOUnW3aDHL01PdVw==", "license": "Apache-2.0", - "engines": { - "node": ">=18.18" + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@ioredis/commands": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", - "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.857.0.tgz", + "integrity": "sha512-6iHar8RMW1JHYHlho3AQXEwvMmFSfxZHaj6d+TR/os0YrmQFBkLqpK8OBmJ750qa0S6QB22s+8kmbH4BCpeccg==", + "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.857.0", + "@aws-sdk/types": "3.840.0", + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.840.0.tgz", + "integrity": "sha512-ub+hXJAbAje94+Ya6c6eL7sYujoE8D4Bumu1NUI8TXjUhVVn0HzVWQjpRLshdLsUp1AW7XyeJaxyajRaJQ8+Xg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.840.0.tgz", + "integrity": "sha512-KVLD0u0YMF3aQkVF8bdyHAGWSUY6N1Du89htTLgqCcIhSxxAJ9qifrosVZ9jkAzqRW99hcufyt2LylcVU2yoKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.840.0.tgz", + "integrity": "sha512-lSV8FvjpdllpGaRspywss4CtXV8M7NNNH+2/j86vMH+YCOZ6fu2T/TyFd/tHwZ92vDfHctWkRbQxg0bagqwovA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.840.0.tgz", + "integrity": "sha512-Gu7lGDyfddyhIkj1Z1JtrY5NHb5+x/CRiB87GjaSrKxkDaydtX2CU977JIABtt69l9wLbcGDIQ+W0uJ5xPof7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.857.0.tgz", + "integrity": "sha512-qKbr6I6+4kRvI9guR1xnTX3dS37JaIM042/uLYzb65/dUfOm7oxBTDW0+7Jdu92nj5bAChYloKQGEsr7dwKxeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-arn-parser": "3.804.0", + "@smithy/core": "^3.7.2", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.840.0.tgz", + "integrity": "sha512-CBZP9t1QbjDFGOrtnUEHL1oAvmnCUUm7p0aPNbIdSzNtH42TNKjPRN3TuEIJDGjkrqpL3MXyDSmNayDcw/XW7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.857.0.tgz", + "integrity": "sha512-JPqTxJGwc5QyxpCpDuOi64+z+9krpkv9FidnWjPqqNwLy25Da8espksTzptPivsMzUukdObFWJsDG89/8/I6TQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@smithy/core": "^3.7.2", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.857.0.tgz", + "integrity": "sha512-3P1GP34hu3Yb7C8bcIqIGASMt/MT/1Lxwy37UJwCn4IrccrvYM3i8y5XX4wW8sn1J5832wB4kdb4HTYbEz6+zw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.857.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.857.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.857.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.2", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-retry": "^4.1.18", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.25", + "@smithy/util-defaults-mode-node": "^4.0.25", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.840.0.tgz", + "integrity": "sha512-Qjnxd/yDv9KpIMWr90ZDPtRj0v75AqGC92Lm9+oHXZ8p1MjG5JE2CW0HL8JRgK9iKzgKBL7pPQRXI8FkvEVfrA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.857.0.tgz", + "integrity": "sha512-KVpHAtRjv4oNydwXwAEf2ur4BOAWjjZiT/QtLtTKYbEbnXW1eOFZg4kWwJwHa/T/w2zfPMVf6LhZvyFwLU9XGg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.857.0.tgz", + "integrity": "sha512-4DBZw+QHpsnpYLXzQtDYCEP9KFFQlYAmNnrCK1bsWoKqnUgjKgwr9Re0yhtNiieHhEE4Lhu+E+IAiNwDx2ClVw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.857.0", + "@aws-sdk/nested-clients": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.840.0.tgz", + "integrity": "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.804.0.tgz", + "integrity": "sha512-wmBJqn1DRXnZu3b4EkE6CWnoWMo1ZMvlfkqU5zPz67xx1GMaXlDCchFvKAXMjk4jn/L1O3tKnoFDNsoLV1kgNQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.848.0.tgz", + "integrity": "sha512-fY/NuFFCq/78liHvRyFKr+aqq1aA/uuVSANjzr5Ym8c+9Z3HRPE9OrExAHoMrZ6zC8tHerQwlsXYYH5XZ7H+ww==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-endpoints": "^3.0.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.804.0.tgz", + "integrity": "sha512-zVoRfpmBVPodYlnMjgVjfGoEZagyRF5IPn3Uo6ZvOZp24chnW/FRstH7ESDHDDRga4z3V+ElUQHKpFDXWyBW5A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.840.0.tgz", + "integrity": "sha512-JdyZM3EhhL4PqwFpttZu1afDpPJCCc3eyZOLi+srpX11LsGj6sThf47TYQN75HT1CarZ7cCdQHGzP2uy3/xHfQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.857.0.tgz", + "integrity": "sha512-xWNfAnD2t5yACGW1wM3iLoy2FvRM8N/XjkjgJE1O35gBHn00evtLC9q4nkR4x7+vXdZb8cVw4Y6GmcfMckgFQg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.857.0", + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.821.0.tgz", + "integrity": "sha512-DIIotRnefVL6DiaHtO6/21DhJ4JZnnIwdNbpwiAhdt/AVbttcE4yw925gsjur0OGv5BTYXQXU3YnANBYnZjuQA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "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", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, "engines": { - "node": ">=6.0.0" + "node": ">=12" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "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/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "ms": "^2.1.3" }, "engines": { - "node": ">= 8" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@eslint/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">= 8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": ">= 8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@pkgr/core": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz", - "integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==", + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/pkgr" + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "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/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz", + "integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@prisma/client": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.12.0.tgz", + "integrity": "sha512-wn98bJ3Cj6edlF4jjpgXwbnQIo/fQLqqQHPk2POrZPxTlhY3+n90SSIF3LMRVa8VzRFC/Gec3YKJRxRu+AIGVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.11.1.tgz", + "integrity": "sha512-z6rCTQN741wxDq82cpdzx2uVykpnQIXalLhrWQSR0jlBVOxCIkz3HZnd8ern3uYTcWKfB3IpVAF7K2FU8t/8AQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "jiti": "2.4.2" + } + }, + "node_modules/@prisma/debug": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.11.1.tgz", + "integrity": "sha512-lWRb/YSWu8l4Yum1UXfGLtqFzZkVS2ygkWYpgkbgMHn9XJlMITIgeMvJyX5GepChzhmxuSuiq/MY/kGFweOpGw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.11.1.tgz", + "integrity": "sha512-6eKEcV6V8W2eZAUwX2xTktxqPM4vnx3sxz3SDtpZwjHKpC6lhOtc4vtAtFUuf5+eEqBk+dbJ9Dcaj6uQU+FNNg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.11.1", + "@prisma/engines-version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9", + "@prisma/fetch-engine": "6.11.1", + "@prisma/get-platform": "6.11.1" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9.tgz", + "integrity": "sha512-swFJTOOg4tHyOM1zB/pHb3MeH0i6t7jFKn5l+ZsB23d9AQACuIRo9MouvuKGvnDogzkcjbWnXi/NvOZ0+n5Jfw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.11.1.tgz", + "integrity": "sha512-NBYzmkXTkj9+LxNPRSndaAeALOL1Gr3tjvgRYNqruIPlZ6/ixLeuE/5boYOewant58tnaYFZ5Ne0jFBPfGXHpQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.11.1", + "@prisma/engines-version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9", + "@prisma/get-platform": "6.11.1" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.11.1.tgz", + "integrity": "sha512-b2Z8oV2gwvdCkFemBTFd0x4lsL4O2jLSx8lB7D+XqoFALOQZPa7eAPE1NU0Mj1V8gPHRxIsHnyUNtw2i92psUw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.11.1" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@smithy/abort-controller": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", + "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.0.0.tgz", + "integrity": "sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.0.0.tgz", + "integrity": "sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.4.tgz", + "integrity": "sha512-prmU+rDddxHOH0oNcwemL+SwnzcG65sBF2yXRO7aeXIn/xTlq2pX7JLVbkBnVLowHLg4/OL4+jBmv9hVrVGS+w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.7.2.tgz", + "integrity": "sha512-JoLw59sT5Bm8SAjFCYZyuCGxK8y3vovmoVbZWLDPTH5XpPEIwpFd9m90jjVMwoypDuB/SdVgje5Y4T7w50lJaw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.0.8", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.6.tgz", + "integrity": "sha512-hKMWcANhUiNbCJouYkZ9V3+/Qf9pteR1dnwgdyzR09R4ODEYx8BbUysHwRSyex4rZ9zapddZhLFTnT4ZijR4pw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.0.4.tgz", + "integrity": "sha512-7XoWfZqWb/QoR/rAU4VSi0mWnO2vu9/ltS6JZ5ZSZv0eovLVfDfu0/AX4ub33RsJTOth3TiFWSHS5YdztvFnig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.3.1", + "@smithy/util-hex-encoding": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.0.4.tgz", + "integrity": "sha512-3fb/9SYaYqbpy/z/H3yIi0bYKyAa89y6xPmIqwr2vQiUT2St+avRt8UKwsWt9fEdEasc5d/V+QjrviRaX1JRFA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.1.2.tgz", + "integrity": "sha512-JGtambizrWP50xHgbzZI04IWU7LdI0nh/wGbqH3sJesYToMi2j/DcoElqyOcqEIG/D4tNyxgRuaqBXWE3zOFhQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.0.4.tgz", + "integrity": "sha512-RD6UwNZ5zISpOWPuhVgRz60GkSIp0dy1fuZmj4RYmqLVRtejFqQ16WmfYDdoSoAjlp1LX+FnZo+/hkdmyyGZ1w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.0.4.tgz", + "integrity": "sha512-UeJpOmLGhq1SLox79QWw/0n2PFX+oPRE1ZyRMxPIaFEfCqWaqpB7BU9C8kpPOGEhLF7AwEqfFbtwNxGy4ReENA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.0.tgz", + "integrity": "sha512-mADw7MS0bYe2OGKkHYMaqarOXuDwRbO6ArD91XhHcl2ynjGCFF+hvqf0LyQcYxkA1zaWjefSkU7Ne9mqgApSgQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.0.4.tgz", + "integrity": "sha512-WszRiACJiQV3QG6XMV44i5YWlkrlsM5Yxgz4jvsksuu7LDXA6wAtypfPajtNTadzpJy3KyJPoWehYpmZGKUFIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.0.0", + "@smithy/chunked-blob-reader-native": "^4.0.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.4.tgz", + "integrity": "sha512-qnbTPUhCVnCgBp4z4BUJUhOEkVwxiEi1cyFM+Zj6o+aY8OFGxUQleKWq8ltgp3dujuhXojIvJWdoqpm6dVO3lQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.0.4.tgz", + "integrity": "sha512-wHo0d8GXyVmpmMh/qOR0R7Y46/G1y6OR8U+bSTB4ppEzRxd1xVAQ9xOE9hOc0bSjhz0ujCPAbfNLkLrpa6cevg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.4.tgz", + "integrity": "sha512-bNYMi7WKTJHu0gn26wg8OscncTt1t2b8KcsZxvOv56XA6cyXtOAAAaNP7+m45xfppXfOatXF3Sb1MNsLUgVLTw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", + "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.0.4.tgz", + "integrity": "sha512-uGLBVqcOwrLvGh/v/jw423yWHq/ofUGK1W31M2TNspLQbUV1Va0F5kTxtirkoHawODAZcjXTSGi7JwbnPcDPJg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.4.tgz", + "integrity": "sha512-F7gDyfI2BB1Kc+4M6rpuOLne5LOcEknH1n6UQB69qv+HucXBR1rkzXBnQTB2q46sFy1PM/zuSJOB532yc8bg3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.17.tgz", + "integrity": "sha512-S3hSGLKmHG1m35p/MObQCBCdRsrpbPU8B129BVzRqRfDvQqPMQ14iO4LyRw+7LNizYc605COYAcjqgawqi+6jA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.7.2", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.18.tgz", + "integrity": "sha512-bYLZ4DkoxSsPxpdmeapvAKy7rM5+25gR7PGxq2iMiecmbrRGBHj9s75N74Ylg+aBiw9i5jIowC/cLU2NR0qH8w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/service-error-classification": "^4.0.6", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.8.tgz", + "integrity": "sha512-iSSl7HJoJaGyMIoNn2B7czghOVwJ9nD7TMvLhMWeSB5vt0TnEYyRRqPJu/TqW76WScaNvYYB8nRoiBHR9S1Ddw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.4.tgz", + "integrity": "sha512-kagK5ggDrBUCCzI93ft6DjteNSfY8Ulr83UtySog/h09lTIOAJ/xUSObutanlPT0nhoHAkpmW9V5K8oPyLh+QA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.3.tgz", + "integrity": "sha512-HGHQr2s59qaU1lrVH6MbLlmOBxadtzTsoO4c+bF5asdgVik3I8o7JIOzoeqWc5MjVa+vD36/LWE0iXKpNqooRw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.0.tgz", + "integrity": "sha512-vqfSiHz2v8b3TTTrdXi03vNz1KLYYS3bhHCDv36FYDqxT7jvTll1mMnCrkD+gOvgwybuunh/2VmvOMqwBegxEg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.4.tgz", + "integrity": "sha512-qHJ2sSgu4FqF4U/5UUp4DhXNmdTrgmoAai6oQiM+c5RZ/sbDwJ12qxB1M6FnP+Tn/ggkPZf9ccn4jqKSINaquw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.2.tgz", + "integrity": "sha512-rOG5cNLBXovxIrICSBm95dLqzfvxjEmuZx4KK3hWwPFHGdW3lxY0fZNXfv2zebfRO7sJZ5pKJYHScsqopeIWtQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.4.tgz", + "integrity": "sha512-SwREZcDnEYoh9tLNgMbpop+UTGq44Hl9tdj3rf+yeLcfH7+J8OXEBaMc2kDxtyRHu8BhSg9ADEx0gFHvpJgU8w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-uri-escape": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.4.tgz", + "integrity": "sha512-6yZf53i/qB8gRHH/l2ZwUG5xgkPgQF15/KxH0DdXMDHjesA9MeZje/853ifkSY0x4m5S+dfDZ+c4x439PF0M2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.6.tgz", + "integrity": "sha512-RRoTDL//7xi4tn5FrN2NzH17jbgmnKidUqd4KvquT0954/i6CXXkh1884jBiunq24g9cGtPBEXlU40W6EpNOOg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.4.tgz", + "integrity": "sha512-63X0260LoFBjrHifPDs+nM9tV0VMkOTl4JRMYNuKh/f5PauSjowTfvF3LogfkWdcPoxsA9UjqEOgjeYIbhb7Nw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.2.tgz", + "integrity": "sha512-d3+U/VpX7a60seHziWnVZOHuEgJlclufjkS6zhXvxcJgkJq4UWdH5eOBLzHRMx6gXjsdT9h6lfpmLzbrdupHgQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-uri-escape": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.9.tgz", + "integrity": "sha512-mbMg8mIUAWwMmb74LoYiArP04zWElPzDoA1jVOp3or0cjlDMgoS6WTC3QXK0Vxoc9I4zdrX0tq6qsOmaIoTWEQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.7.2", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", + "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.4.tgz", + "integrity": "sha512-eMkc144MuN7B0TDA4U2fKs+BqczVbk3W+qIvcoCY6D1JY3hnAdCuhCZODC+GAeaxj0p6Jroz4+XMUn3PCxQQeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", + "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", + "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", + "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", + "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", + "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.25.tgz", + "integrity": "sha512-pxEWsxIsOPLfKNXvpgFHBGFC3pKYKUFhrud1kyooO9CJai6aaKDHfT10Mi5iiipPXN/JhKAu3qX9o75+X85OdQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.25.tgz", + "integrity": "sha512-+w4n4hKFayeCyELZLfsSQG5mCC3TwSkmRHv4+el5CzFU8ToQpYGhpV7mrRzqlwKkntlPilT1HJy1TVeEvEjWOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.1.4", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.6.tgz", + "integrity": "sha512-YARl3tFL3WgPuLzljRUnrS2ngLiUtkwhQtj8PAL13XZSyUiNLQxwG3fBBq3QXFqGFUXepIN73pINp3y8c2nBmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", + "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@prisma/client": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.12.0.tgz", - "integrity": "sha512-wn98bJ3Cj6edlF4jjpgXwbnQIo/fQLqqQHPk2POrZPxTlhY3+n90SSIF3LMRVa8VzRFC/Gec3YKJRxRu+AIGVA==", - "hasInstallScript": true, + "node_modules/@smithy/util-middleware": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.4.tgz", + "integrity": "sha512-9MLKmkBmf4PRb0ONJikCbCwORACcil6gUWojwARCClT7RmLzF04hUR4WdRprIXal7XVyrddadYNfp2eF3nrvtQ==", "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "peerDependencies": { - "prisma": "*", - "typescript": ">=5.1.0" + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" }, - "peerDependenciesMeta": { - "prisma": { - "optional": true - }, - "typescript": { - "optional": true - } + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@prisma/config": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.11.1.tgz", - "integrity": "sha512-z6rCTQN741wxDq82cpdzx2uVykpnQIXalLhrWQSR0jlBVOxCIkz3HZnd8ern3uYTcWKfB3IpVAF7K2FU8t/8AQ==", - "devOptional": true, + "node_modules/@smithy/util-retry": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.6.tgz", + "integrity": "sha512-+YekoF2CaSMv6zKrA6iI/N9yva3Gzn4L6n35Luydweu5MMPYpiGZlWqehPHDHyNbnyaYlz/WJyYAZnC+loBDZg==", "license": "Apache-2.0", "dependencies": { - "jiti": "2.4.2" + "@smithy/service-error-classification": "^4.0.6", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@prisma/debug": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.11.1.tgz", - "integrity": "sha512-lWRb/YSWu8l4Yum1UXfGLtqFzZkVS2ygkWYpgkbgMHn9XJlMITIgeMvJyX5GepChzhmxuSuiq/MY/kGFweOpGw==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/engines": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.11.1.tgz", - "integrity": "sha512-6eKEcV6V8W2eZAUwX2xTktxqPM4vnx3sxz3SDtpZwjHKpC6lhOtc4vtAtFUuf5+eEqBk+dbJ9Dcaj6uQU+FNNg==", - "devOptional": true, - "hasInstallScript": true, + "node_modules/@smithy/util-stream": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.3.tgz", + "integrity": "sha512-cQn412DWHHFNKrQfbHY8vSFI3nTROY1aIKji9N0tpp8gUABRilr7wdf8fqBbSlXresobM+tQFNk6I+0LXK/YZg==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.11.1", - "@prisma/engines-version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9", - "@prisma/fetch-engine": "6.11.1", - "@prisma/get-platform": "6.11.1" + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@prisma/engines-version": { - "version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9.tgz", - "integrity": "sha512-swFJTOOg4tHyOM1zB/pHb3MeH0i6t7jFKn5l+ZsB23d9AQACuIRo9MouvuKGvnDogzkcjbWnXi/NvOZ0+n5Jfw==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/fetch-engine": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.11.1.tgz", - "integrity": "sha512-NBYzmkXTkj9+LxNPRSndaAeALOL1Gr3tjvgRYNqruIPlZ6/ixLeuE/5boYOewant58tnaYFZ5Ne0jFBPfGXHpQ==", - "devOptional": true, + "node_modules/@smithy/util-uri-escape": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", + "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.11.1", - "@prisma/engines-version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9", - "@prisma/get-platform": "6.11.1" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@prisma/get-platform": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.11.1.tgz", - "integrity": "sha512-b2Z8oV2gwvdCkFemBTFd0x4lsL4O2jLSx8lB7D+XqoFALOQZPa7eAPE1NU0Mj1V8gPHRxIsHnyUNtw2i92psUw==", - "devOptional": true, + "node_modules/@smithy/util-utf8": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", + "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.11.1" + "@smithy/util-buffer-from": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@scarf/scarf": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", - "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", - "hasInstallScript": true, - "license": "Apache-2.0" + "node_modules/@smithy/util-waiter": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.6.tgz", + "integrity": "sha512-slcr1wdRbX7NFphXZOxtxRNA7hXAAtJAXJDE/wdoMAos27SIquVCKiSqfB6/28YzQ8FCsB5NKkhdM5gMADbqxg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", @@ -748,6 +2354,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/multer-s3": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/multer-s3/-/multer-s3-3.0.3.tgz", + "integrity": "sha512-VgWygI9UwyS7loLithUUi0qAMIDWdNrERS2Sb06UuPYiLzKuIFn2NgL7satyl4v8sh/LLoU7DiPanvbQaRg9Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@aws-sdk/client-s3": "^3.0.0", + "@types/multer": "*", + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "24.0.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz", @@ -812,6 +2440,12 @@ "@types/serve-static": "*" } }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.35.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", @@ -1268,6 +2902,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -1318,6 +2958,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -1387,6 +3047,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1410,6 +3076,33 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1584,6 +3277,21 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2219,6 +3927,15 @@ "node": ">= 0.6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -2310,6 +4027,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -2333,6 +4068,15 @@ "node": ">=16.0.0" } }, + "node_modules/file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2632,6 +4376,12 @@ "node": ">= 0.4" } }, + "node_modules/html-comment-regex": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.2.tgz", + "integrity": "sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==", + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -2660,6 +4410,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -3113,18 +4883,65 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer-s3": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/multer-s3/-/multer-s3-3.0.1.tgz", + "integrity": "sha512-BFwSO80a5EW4GJRBdUuSHblz2jhVSAze33ZbnGpcfEicoT0iRolx4kWR+AJV07THFRCQ78g+kelKFdjkCCaXeQ==", + "license": "MIT", + "dependencies": { + "@aws-sdk/lib-storage": "^3.46.0", + "file-type": "^3.3.0", + "html-comment-regex": "^1.1.2", + "run-parallel": "^1.1.6" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-s3": "^3.0.0" + } + }, "node_modules/mysql2": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.1.tgz", @@ -3563,7 +5380,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -3604,6 +5420,20 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -3661,7 +5491,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -4026,6 +5855,33 @@ "node": ">= 0.8" } }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -4049,6 +5905,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -4238,6 +6106,12 @@ "node": ">=6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4264,6 +6138,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -4341,6 +6221,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -4350,6 +6236,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -4428,6 +6327,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yaml": { "version": "2.0.0-1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", diff --git a/package.json b/package.json index ae2c2be..ea52822 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,14 @@ "postinstall": "prisma generate" }, "prisma": { - "schema": "prisma/schema.prisma" + "schema": "prisma/schema.prisma", + "seed": "node prisma/seed.js" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { + "@aws-sdk/client-s3": "^3.857.0", "@prisma/client": "^6.12.0", "@socket.io/redis-adapter": "^8.3.0", "axios": "^1.10.0", @@ -31,6 +33,8 @@ "dotenv": "^17.1.0", "express": "^4.21.2", "ioredis": "^5.6.1", + "multer": "^2.0.2", + "multer-s3": "^3.0.1", "mysql2": "^3.14.1", "socket.io": "^4.8.1", "swagger-jsdoc": "^6.2.8", @@ -41,7 +45,12 @@ "@types/cors": "^2.8.19", "@types/dotenv": "^8.2.3", "@types/express": "^5.0.3", +<<<<<<< HEAD "@types/ioredis": "^4.28.10", +======= + "@types/multer": "^2.0.0", + "@types/multer-s3": "^3.0.3", +>>>>>>> e6ad38d (Feat/s3 upload (#80)) "@types/node": "^24.0.10", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", diff --git a/src/config/s3Config.ts b/src/config/s3Config.ts new file mode 100644 index 0000000..5e72282 --- /dev/null +++ b/src/config/s3Config.ts @@ -0,0 +1,14 @@ +import { S3Client } from '@aws-sdk/client-s3'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const s3Client = new S3Client({ + region: process.env.AWS_REGION!, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, +}); + +export const S3_BUCKET_NAME = process.env.S3_BUCKET_NAME!; diff --git a/src/constants/errorCodes.ts b/src/constants/errorCodes.ts index 57d8e30..bf39de7 100644 --- a/src/constants/errorCodes.ts +++ b/src/constants/errorCodes.ts @@ -56,6 +56,12 @@ export const ERROR_CODES = { GENERAL_003: { message: '리소스를 찾을 수 없습니다.', statusCode: 404 }, GENERAL_004: { message: '서버 내부 오류가 발생했습니다.', statusCode: 500 }, GENERAL_005: { message: '데이터베이스 오류가 발생했습니다.', statusCode: 500 }, + + // 파일 관련 에러 (FILE_XXX) + FILE_001: { message: '파일 크기가 너무 큽니다.', statusCode: 400 }, + FILE_002: { message: '파일 업로드에 실패했습니다.', statusCode: 400 }, + FILE_003: { message: '이미지 파일이 필요합니다.', statusCode: 400 }, + FILE_004: { message: '지원하지 않는 파일 형식입니다.', statusCode: 400 }, } as const; export type ErrorCode = keyof typeof ERROR_CODES; diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts index 706f9f2..7876ff5 100644 --- a/src/controllers/authController.ts +++ b/src/controllers/authController.ts @@ -75,6 +75,13 @@ export const register = async (req: Request, res: Response, next: NextFunction) // 닉네임 중복 확인 const checkNickname = await findUserByNickname(nickname); if (checkNickname) { + const nicknameRegex = /^[가-힣a-zA-Z0-9_-]{3,10}$/; + if (!nicknameRegex.test(nickname)) { + throw new AppError( + 'GENERAL_001', + '닉네임은 3~10자의 한글, 영문, 숫자, -, _만 사용 가능합니다.', + ); + } throw new AppError('AUTH_002'); // '사용할 수 없는 닉네임입니다.' } diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index 0d6c73c..305bc76 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -2,6 +2,8 @@ import { Request, Response, NextFunction } from 'express'; import { sendSuccess } from '../utils/response.js'; import * as userService from '../services/userServices.js'; import AppError from '../middleware/errors/AppError.js'; +import { uploadProfileImage, handleUploadError } from '../middleware/uploadMiddleware.js'; +import { S3_BUCKET_NAME } from '../config/s3Config.js'; // 프로필 정보 조회 export const getProfile = async (req: Request, res: Response, next: NextFunction) => { @@ -29,11 +31,21 @@ export const updateProfile = async (req: Request, res: Response, next: NextFunct throw new AppError('AUTH_007'); } - // 원본: if (!nickname && profileImage === undefined) { if (!nickname && !profileImage) { throw new AppError('USER_004'); } + // 이미지 URL이 제공된 경우 검증 (선택사항) + if (profileImage) { + // S3 URL 형식 검증 + const validS3Pattern = new RegExp( + `https://${S3_BUCKET_NAME}\\.s3\\.[a-z0-9-]+\\.amazonaws\\.com/.+`, + ); + if (!validS3Pattern.test(profileImage)) { + throw new AppError('GENERAL_001', '유효하지 않은 이미지 URL입니다.'); + } + } + await userService.updateUserProfile(userId, { nickname, profileImage }); sendSuccess(res, { message: '프로필이 수정되었습니다.' }); } catch (error) { @@ -41,6 +53,43 @@ export const updateProfile = async (req: Request, res: Response, next: NextFunct } }; +// 프로필 이미지 업로드 핸들러 - 배열로 export +export const uploadProfileImageHandler = [ + uploadProfileImage, + handleUploadError, + async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.user?.userId; + + if (!userId) { + throw new AppError('AUTH_007'); + } + + // multer-s3를 사용하면 req.file에 location과 key가 추가됩니다 + const file = req.file as Express.MulterS3.File; + + if (!file) { + throw new AppError('FILE_003'); + } + + // S3에 업로드된 파일의 URL + const profileImageUrl = file.location; + + // DB에 URL 저장 + await userService.updateUserProfile(userId, { + profileImage: profileImageUrl, + }); + + sendSuccess(res, { + message: '프로필 이미지가 업로드되었습니다.', + profileImage: profileImageUrl, + }); + } catch (error) { + next(error); + } + }, +]; + // 알림 설정 조회 export const getNotificationSettings = async (req: Request, res: Response, next: NextFunction) => { try { diff --git a/src/dtos/activeRoomsDto.ts b/src/dtos/activeRoomsDto.ts index cb29c69..3c95c5e 100644 --- a/src/dtos/activeRoomsDto.ts +++ b/src/dtos/activeRoomsDto.ts @@ -27,8 +27,8 @@ export const VALID_SEARCH_TYPE_OPTIONS: SearchTypeOption[] = [ * searchType 문자열이 유효한 SearchTypeOption인지 확인하는 타입 가드 함수 * @param value - 검사할 문자열 */ -export function isSearchTypeOption(value: any): value is SearchTypeOption { - return VALID_SEARCH_TYPE_OPTIONS.includes(value); +export function isSearchTypeOption(value: unknown): value is SearchTypeOption { + return VALID_SEARCH_TYPE_OPTIONS.includes(value as SearchTypeOption); } /** diff --git a/src/middleware/authValidation.ts b/src/middleware/authValidation.ts index ad56870..adf05d3 100644 --- a/src/middleware/authValidation.ts +++ b/src/middleware/authValidation.ts @@ -41,8 +41,12 @@ export const validateRegister = (req: Request, res: Response, next: NextFunction } // 닉네임 검증 (3~10자) - if (nickname.length < 3 || nickname.length > 10) { - throw new AppError('GENERAL_001', '닉네임은 3~10자 이내로 입력해주세요.'); + const nicknameRegex = /^[가-힣a-zA-Z0-9_-]{3,10}$/; + if (!nicknameRegex.test(nickname)) { + throw new AppError( + 'GENERAL_001', + '닉네임은 3~10자의 한글, 영문, 숫자, -, _만 사용 가능합니다.', + ); } // 필수 약관 동의 검증 diff --git a/src/middleware/uploadMiddleware.ts b/src/middleware/uploadMiddleware.ts new file mode 100644 index 0000000..ea35bdf --- /dev/null +++ b/src/middleware/uploadMiddleware.ts @@ -0,0 +1,106 @@ +import multer from 'multer'; +import multerS3 from 'multer-s3'; +import { Request, Response, NextFunction } from 'express'; +import path from 'path'; +import crypto from 'crypto'; +import { s3Client, S3_BUCKET_NAME } from '../config/s3Config.js'; +import AppError from './errors/AppError.js'; + +// Express Request 타입 확장 +interface AuthRequest extends Request { + user?: { + id: string; + nickname: string; + userId: number; + }; +} + +// 파일 필터 (이미지만 허용) +const fileFilter = (req: AuthRequest, file: Express.Multer.File, cb: multer.FileFilterCallback) => { + // 디버깅용 로그 + console.log('>>> 업로드 파일 정보:', { + mimetype: file.mimetype, + originalname: file.originalname, + fieldname: file.fieldname, + encoding: file.encoding, + }); + + const allowedMimeTypes = [ + 'image/jpeg', + 'image/jpg', // 추가 + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', // SVG 추가 (선택사항) + ]; + + if (allowedMimeTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb( + new Error( + `허용되지 않는 파일 형식: ${file.mimetype}. 허용된 형식: ${allowedMimeTypes.join(', ')}`, + ), + ); + } +}; + +// S3 업로드 설정 +const s3Storage = multerS3({ + s3: s3Client, + bucket: S3_BUCKET_NAME, + contentType: multerS3.AUTO_CONTENT_TYPE, + key: (req: AuthRequest, file: Express.Multer.File, cb) => { + const userId = req.user?.userId || 'anonymous'; + const uniqueSuffix = crypto.randomBytes(16).toString('hex'); + const fileExtension = path.extname(file.originalname); + + // S3 키 형식: profile-images/{userId}/{timestamp}-{random}.{ext} + const s3Key = `profile-images/${userId}/${Date.now()}-${uniqueSuffix}${fileExtension}`; + cb(null, s3Key); + }, + metadata: (req: AuthRequest, file, cb) => { + cb(null, { + fieldName: file.fieldname, + originalName: file.originalname, + uploadedBy: req.user?.userId?.toString() || 'anonymous', + }); + }, +}); + +// Multer 인스턴스 생성 +export const uploadProfileImage = multer({ + storage: s3Storage, + fileFilter: fileFilter, + limits: { + fileSize: 50 * 1024 * 1024, // 5MB 제한 + }, +}).single('profileImage'); + +// 에러 핸들링 미들웨어 +export const handleUploadError = ( + error: Error | multer.MulterError | null, + req: Request, + res: Response, + next: NextFunction, +): void => { + if (error instanceof multer.MulterError) { + console.log('Multer 에러:', error.code, error.message); + + if (error.code === 'LIMIT_FILE_SIZE') { + return next(new AppError('FILE_001', '파일 크기가 50MB를 초과했습니다.')); + } + return next(new AppError('FILE_002', `파일 업로드 실패: ${error.message}`)); + } else if (error) { + console.log('업로드 에러:', error.message); + + // fileFilter에서 던진 구체적인 에러 메시지 전달 + if (error.message.includes('허용되지 않는 파일 형식')) { + return next(new AppError('FILE_004', error.message)); + } + + // 기타 에러 + return next(new AppError('FILE_002', error.message)); + } + next(); +}; diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index 238c8ec..c1e6795 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -3,6 +3,7 @@ import { getProfile, updateProfile, getNotificationSettings, + uploadProfileImageHandler, updateNotificationSettings, getParticipatedRooms, getSearchHistory, @@ -64,6 +65,53 @@ router.get('/test', (req, res) => { * format: date-time * example: 2025-01-27T12:00:00Z */ + +/** + * @swagger + * /api/users/profile/image: + * post: + * summary: 프로필 이미지 업로드 + * tags: [User] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * profileImage: + * type: string + * format: binary + * description: 업로드할 이미지 파일 (최대 5MB, jpeg/png/gif/webp) + * responses: + * 200: + * description: 이미지 업로드 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * message: + * type: string + * example: 프로필 이미지가 업로드되었습니다. + * profileImage: + * type: string + * example: https://onairmate-profile-images-seoul.s3.ap-northeast-2.amazonaws.com/profile-images/123/... + * 400: + * description: 잘못된 요청 + * 401: + * description: 인증 실패 + */ +router.post('/profile/image', requireAuth, uploadProfileImageHandler); + router.get('/profile', requireAuth, getProfile); /** diff --git a/src/utils/s3utils.ts b/src/utils/s3utils.ts new file mode 100644 index 0000000..7eb4618 --- /dev/null +++ b/src/utils/s3utils.ts @@ -0,0 +1,21 @@ +import { DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { s3Client, S3_BUCKET_NAME } from '../config/s3Config.js'; + +export const deleteS3Object = async (imageUrl: string): Promise => { + try { + // URL에서 S3 키 추출 + const url = new URL(imageUrl); + const key = url.pathname.substring(1); // 맨 앞의 '/' 제거 + + const command = new DeleteObjectCommand({ + Bucket: S3_BUCKET_NAME, + Key: key, + }); + + await s3Client.send(command); + console.log(`S3 객체 삭제 성공: ${key}`); + } catch (error) { + console.error('S3 객체 삭제 실패:', error); + // 실패해도 계속 진행 (로그만 남김) + } +}; diff --git a/tsconfig.json b/tsconfig.json index 3a8bf5e..ae45a49 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,13 +15,15 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "noEmit": false + "noEmit": false, + "typeRoots": ["./node_modules/@types", "./src"], + "types": ["node", "express"] }, "ts-node": { "esm": true, "experimentalSpecifierResolution": "node", "transpileOnly": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "src/global.d.ts"], "exclude": ["node_modules", "dist"] } \ No newline at end of file From 16b3c2e56d21331403490530ce85473df6da6757 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sun, 3 Aug 2025 17:58:42 +0900 Subject: [PATCH 44/59] Feat/ai summary (#81) * Fix: Remove CORS Error * Feat: Add S3 Upload * Feat: Add AI Summary && Feedback with AWS Bedrock Calude 3.5 Sonnet * Formatting * Refact: Refactoring potential issues && Improve Performance * Refact: Refactoring Throwing Error Again --- package-lock.json | 457 ++++++++++++++++++++++++- package.json | 4 +- src/app.ts | 2 + src/controllers/aiSummaryController.ts | 74 ++++ src/controllers/authController.ts | 16 +- src/controllers/userController.ts | 59 ++-- src/dtos/aiSummaryDto.ts | 67 ++++ src/middleware/uploadMiddleware.ts | 6 +- src/routes/aiSummaryRoutes.ts | 123 +++++++ src/routes/userRoutes.ts | 2 +- src/services/aiSummaryService.ts | 251 ++++++++++++++ src/utils/s3utils.ts | 25 +- tsconfig.json | 2 +- 13 files changed, 1042 insertions(+), 46 deletions(-) create mode 100644 src/controllers/aiSummaryController.ts create mode 100644 src/dtos/aiSummaryDto.ts create mode 100644 src/routes/aiSummaryRoutes.ts create mode 100644 src/services/aiSummaryService.ts diff --git a/package-lock.json b/package-lock.json index b8e89c9..1944414 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "ISC", "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.859.0", "@aws-sdk/client-s3": "^3.857.0", "@prisma/client": "^6.12.0", "@socket.io/redis-adapter": "^8.3.0", @@ -37,8 +38,7 @@ ======= "@types/multer": "^2.0.0", "@types/multer-s3": "^3.0.3", ->>>>>>> e6ad38d (Feat/s3 upload (#80)) - "@types/node": "^24.0.10", + "@types/node": "^24.1.0", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^8.35.1", @@ -302,6 +302,387 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.859.0.tgz", + "integrity": "sha512-L2Mf4ubfTrtOydxP5GDRYK0oNz55fGNnevH2aJZ9be8Dd0XGi2Irh1JMqmQnbqoRwmGZrNhV1nl9FTUzgWt3Vg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/credential-provider-node": "3.859.0", + "@aws-sdk/eventstream-handler-node": "3.840.0", + "@aws-sdk/middleware-eventstream": "3.840.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.858.0", + "@aws-sdk/middleware-websocket": "3.844.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/token-providers": "3.859.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.858.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.2", + "@smithy/eventstream-serde-browser": "^4.0.4", + "@smithy/eventstream-serde-config-resolver": "^4.1.2", + "@smithy/eventstream-serde-node": "^4.0.4", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-retry": "^4.1.18", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.25", + "@smithy/util-defaults-mode-node": "^4.0.25", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/client-sso": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.858.0.tgz", + "integrity": "sha512-iXuZQs4KH6a3Pwnt0uORalzAZ5EXRPr3lBYAsdNwkP8OYyoUz5/TE3BLyw7ceEh0rj4QKGNnNALYo1cDm0EV8w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.858.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.858.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.2", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-retry": "^4.1.18", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.25", + "@smithy/util-defaults-mode-node": "^4.0.25", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/core": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.858.0.tgz", + "integrity": "sha512-iWm4QLAS+/XMlnecIU1Y33qbBr1Ju+pmWam3xVCPlY4CSptKpVY+2hXOnmg9SbHAX9C005fWhrIn51oDd00c9A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/core": "^3.7.2", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.858.0.tgz", + "integrity": "sha512-kZsGyh2BoSRguzlcGtzdLhw/l/n3KYAC+/l/H0SlsOq3RLHF6tO/cRdsLnwoix2bObChHUp03cex63o1gzdx/Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.858.0.tgz", + "integrity": "sha512-GDnfYl3+NPJQ7WQQYOXEA489B212NinpcIDD7rpsB6IWUPo8yDjT5NceK4uUkIR3MFpNCGt9zd/z6NNLdB2fuQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.859.0.tgz", + "integrity": "sha512-KsccE1T88ZDNhsABnqbQj014n5JMDilAroUErFbGqu5/B3sXqUsYmG54C/BjvGTRUFfzyttK9lB9P9h6ddQ8Cw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/credential-provider-env": "3.858.0", + "@aws-sdk/credential-provider-http": "3.858.0", + "@aws-sdk/credential-provider-process": "3.858.0", + "@aws-sdk/credential-provider-sso": "3.859.0", + "@aws-sdk/credential-provider-web-identity": "3.858.0", + "@aws-sdk/nested-clients": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.859.0.tgz", + "integrity": "sha512-ZRDB2xU5aSyTR/jDcli30tlycu6RFvQngkZhBs9Zoh2BiYXrfh2MMuoYuZk+7uD6D53Q2RIEldDHR9A/TPlRuA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.858.0", + "@aws-sdk/credential-provider-http": "3.858.0", + "@aws-sdk/credential-provider-ini": "3.859.0", + "@aws-sdk/credential-provider-process": "3.858.0", + "@aws-sdk/credential-provider-sso": "3.859.0", + "@aws-sdk/credential-provider-web-identity": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.858.0.tgz", + "integrity": "sha512-l5LJWZJMRaZ+LhDjtupFUKEC5hAjgvCRrOvV5T60NCUBOy0Ozxa7Sgx3x+EOwiruuoh3Cn9O+RlbQlJX6IfZIw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.859.0.tgz", + "integrity": "sha512-BwAqmWIivhox5YlFRjManFF8GoTvEySPk6vsJNxDsmGsabY+OQovYxFIYxRCYiHzH7SFjd4Lcd+riJOiXNsvRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.858.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/token-providers": "3.859.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.858.0.tgz", + "integrity": "sha512-8iULWsH83iZDdUuiDsRb83M0NqIlXjlDbJUIddVsIrfWp4NmanKw77SV6yOZ66nuJjPsn9j7RDb9bfEPCy5SWA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/nested-clients": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.858.0.tgz", + "integrity": "sha512-pC3FT/sRZ6n5NyXiTVu9dpf1D9j3YbJz3XmeOOwJqO/Mib2PZyIQktvNMPgwaC5KMVB1zWqS5bmCwxpMOnq0UQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@smithy/core": "^3.7.2", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/nested-clients": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.858.0.tgz", + "integrity": "sha512-ChdIj80T2whoWbovmO7o8ICmhEB2S9q4Jes9MBnKAPm69PexcJAK2dQC8yI4/iUP8b3+BHZoUPrYLWjBxIProQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.858.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.858.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.2", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-retry": "^4.1.18", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.25", + "@smithy/util-defaults-mode-node": "^4.0.25", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.859.0.tgz", + "integrity": "sha512-6P2wlvm9KBWOvRNn0Pt8RntnXg8fzOb5kEShvWsOsAocZeqKNaYbihum5/Onq1ZPoVtkdb++8eWDocDnM4k85Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/nested-clients": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.858.0.tgz", + "integrity": "sha512-T1m05QlN8hFpx5/5duMjS8uFSK5e6EXP45HQRkZULVkL3DK+jMaxsnh3KLl5LjUoHn/19M4HM0wNUBhYp4Y2Yw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, "node_modules/@aws-sdk/client-s3": { "version": "3.857.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.857.0.tgz", @@ -583,6 +964,21 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.840.0.tgz", + "integrity": "sha512-m/zVrSSAEHq+6h4sy0JUEBScB1pGgs/1+iRVhfzfbnf+/gTr4ut2jRq4tDiNEX9pQ1oFVvw+ntPua5qfquQeRQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/eventstream-codec": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/lib-storage": { "version": "3.857.0", "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.857.0.tgz", @@ -622,6 +1018,21 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.840.0.tgz", + "integrity": "sha512-4khgf7AjJ4llh3aiNmZ+x4PGl4vkKNxRHn0xTgi6Iw1J3SChsF2mnNaLXK8hoXeydx756rw+JhqOuZH91i5l4w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/middleware-expect-continue": { "version": "3.840.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.840.0.tgz", @@ -776,6 +1187,27 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.844.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.844.0.tgz", + "integrity": "sha512-5ZtntUZ9ZMdUbQZ3kI5e5tpiZPN/O57h6fnGZ+GHB+wpSVSOQS78TBt0qYZW+CoZr8iyRsVkJheGETajFCMaUg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-format-url": "3.840.0", + "@smithy/eventstream-codec": "^4.0.4", + "@smithy/eventstream-serde-browser": "^4.0.4", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-hex-encoding": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/@aws-sdk/nested-clients": { "version": "3.857.0", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.857.0.tgz", @@ -918,6 +1350,21 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.840.0.tgz", + "integrity": "sha512-VB1PWyI1TQPiPvg4w7tgUGGQER1xxXPNUqfh3baxUSFi1Oh8wHrDnFywkxLm3NMmgDmnLnSZ5Q326qAoyqKLSg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.804.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.804.0.tgz", @@ -2377,9 +2824,9 @@ } }, "node_modules/@types/node": { - "version": "24.0.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz", - "integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==", + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", + "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", "license": "MIT", "dependencies": { "undici-types": "~7.8.0" diff --git a/package.json b/package.json index ea52822..95c1b65 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "author": "", "license": "ISC", "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.859.0", "@aws-sdk/client-s3": "^3.857.0", "@prisma/client": "^6.12.0", "@socket.io/redis-adapter": "^8.3.0", @@ -50,8 +51,7 @@ ======= "@types/multer": "^2.0.0", "@types/multer-s3": "^3.0.3", ->>>>>>> e6ad38d (Feat/s3 upload (#80)) - "@types/node": "^24.0.10", + "@types/node": "^24.1.0", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^8.35.1", diff --git a/src/app.ts b/src/app.ts index 360f47d..e3ab1d8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,6 +12,7 @@ import swaggerUi from 'swagger-ui-express'; import { specs } from './swagger.js'; import { createServer } from 'http'; import { initSocketServer } from './socket/index.js'; +import aiSummaryRoutes from './routes/aiSummaryRoutes.js'; import roomRoutes from './routes/roomRoute.js'; import chatDirectRoutes from './routes/chatDirectRoute.js'; import sharedCollectionRoute from './routes/sharedCollectionRoute.js'; @@ -139,6 +140,7 @@ app.use('/api/rooms', roomRoutes); app.use('/api/chat/direct', chatDirectRoutes); app.use('/api/youtube', youtubeRoutes); // youtubeRecommendationRoute와 youtubeSearchRoute 병합 app.use('/api/shared-collections', sharedCollectionRoute); +app.use('/api/ai', aiSummaryRoutes); // 404 에러 핸들링 app.use((req: Request, res: Response, next: NextFunction) => { diff --git a/src/controllers/aiSummaryController.ts b/src/controllers/aiSummaryController.ts new file mode 100644 index 0000000..d967d84 --- /dev/null +++ b/src/controllers/aiSummaryController.ts @@ -0,0 +1,74 @@ +import { Request, Response, NextFunction } from 'express'; +import { aiSummaryService } from '../services/aiSummaryService.js'; +import { sendSuccess } from '../utils/response.js'; +import AppError from '../middleware/errors/AppError.js'; + +// 타입 가드 함수 +function isFeedbackType(value: unknown): value is 'LIKE' | 'DISLIKE' { + return value === 'LIKE' || value === 'DISLIKE'; +} + +/** + * 방 종료 시 채팅 요약 생성 + * POST /ai/summary + */ +export const generateSummary = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const { roomId } = req.body as { roomId: number }; + const userId = req.user?.userId; + + if (!userId) { + throw new AppError('AUTH_007', '인증이 필요합니다.'); + } + + if (!roomId || typeof roomId !== 'number') { + throw new AppError('GENERAL_001', 'roomId는 필수이며 숫자여야 합니다.'); + } + + const summary = await aiSummaryService.generateChatSummary({ roomId }, userId); + + sendSuccess(res, summary, 201); + } catch (error) { + next(error); + } +}; + +/** + * AI 요약에 대한 피드백 제출 + * POST /ai/summary/:summaryId/feedback + */ +export const submitSummaryFeedback = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const { summaryId } = req.params; + const { feedback, comment } = req.body as { feedback: unknown; comment?: string }; + const userId = req.user?.userId; + + if (!userId) { + throw new AppError('AUTH_007', '인증이 필요합니다.'); + } + + if (!summaryId) { + throw new AppError('GENERAL_001', 'summaryId는 필수입니다.'); + } + + if (!feedback || !isFeedbackType(feedback)) { + throw new AppError('GENERAL_001', 'feedback은 LIKE 또는 DISLIKE여야 합니다.'); + } + + await aiSummaryService.submitFeedback(summaryId, userId, feedback, comment); + + sendSuccess(res, { + message: '피드백이 제출되었습니다.', + }); + } catch (error) { + next(error); + } +}; diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts index 7876ff5..c347139 100644 --- a/src/controllers/authController.ts +++ b/src/controllers/authController.ts @@ -72,16 +72,18 @@ export const register = async (req: Request, res: Response, next: NextFunction) try { const { username, password, nickname, profileImage, agreements } = req.body; + // 닉네임 형식 검증 (중복 확인보다 먼저!) + const nicknameRegex = /^[가-힣a-zA-Z0-9_-]{3,10}$/; + if (!nicknameRegex.test(nickname)) { + throw new AppError( + 'GENERAL_001', + '닉네임은 3~10자의 한글, 영문, 숫자, -, _만 사용 가능합니다.', + ); + } + // 닉네임 중복 확인 const checkNickname = await findUserByNickname(nickname); if (checkNickname) { - const nicknameRegex = /^[가-힣a-zA-Z0-9_-]{3,10}$/; - if (!nicknameRegex.test(nickname)) { - throw new AppError( - 'GENERAL_001', - '닉네임은 3~10자의 한글, 영문, 숫자, -, _만 사용 가능합니다.', - ); - } throw new AppError('AUTH_002'); // '사용할 수 없는 닉네임입니다.' } diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index 305bc76..b4da560 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -4,6 +4,7 @@ import * as userService from '../services/userServices.js'; import AppError from '../middleware/errors/AppError.js'; import { uploadProfileImage, handleUploadError } from '../middleware/uploadMiddleware.js'; import { S3_BUCKET_NAME } from '../config/s3Config.js'; +import { deleteS3Object } from '../utils/s3utils.js'; // 프로필 정보 조회 export const getProfile = async (req: Request, res: Response, next: NextFunction) => { @@ -37,11 +38,20 @@ export const updateProfile = async (req: Request, res: Response, next: NextFunct // 이미지 URL이 제공된 경우 검증 (선택사항) if (profileImage) { - // S3 URL 형식 검증 - const validS3Pattern = new RegExp( - `https://${S3_BUCKET_NAME}\\.s3\\.[a-z0-9-]+\\.amazonaws\\.com/.+`, + // S3 URL 형식 검증 - 버킷 이름의 특수문자를 이스케이프 + const escapedBucketName = S3_BUCKET_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + // Virtual-hosted-style URL 검증 + const virtualHostedStyle = new RegExp( + `^https://${escapedBucketName}\\.s3\\.[a-z0-9-]+\\.amazonaws\\.com/.+$`, + ); + + // Path-style URL 검증 + const pathStyle = new RegExp( + `^https://s3\\.[a-z0-9-]+\\.amazonaws\\.com/${escapedBucketName}/.+$`, ); - if (!validS3Pattern.test(profileImage)) { + + if (!virtualHostedStyle.test(profileImage) && !pathStyle.test(profileImage)) { throw new AppError('GENERAL_001', '유효하지 않은 이미지 URL입니다.'); } } @@ -75,10 +85,22 @@ export const uploadProfileImageHandler = [ // S3에 업로드된 파일의 URL const profileImageUrl = file.location; - // DB에 URL 저장 - await userService.updateUserProfile(userId, { - profileImage: profileImageUrl, - }); + // DB에 URL 저장 - 실패시 S3 파일 롤백 + try { + await userService.updateUserProfile(userId, { + profileImage: profileImageUrl, + }); + } catch (dbError) { + // S3에서 파일 삭제 시도 + try { + await deleteS3Object(file.location); + console.log(`DB 업데이트 실패로 S3 파일 삭제: ${file.key}`); + } catch (s3Error) { + console.error('S3 파일 삭제 실패:', s3Error); + // 삭제 실패해도 원래 에러를 전달 + } + throw dbError; // DB 에러를 다시 던짐 + } sendSuccess(res, { message: '프로필 이미지가 업로드되었습니다.', @@ -114,26 +136,13 @@ export const updateNotificationSettings = async ( ) => { try { const userId = req.user?.userId; - const { serviceNotification, advertisementNotification, nightNotification } = req.body; + const settings = req.body; if (!userId) { throw new AppError('AUTH_007'); } - if ( - serviceNotification === undefined && - advertisementNotification === undefined && - nightNotification === undefined - ) { - throw new AppError('USER_004'); - } - - await userService.updateNotificationSettings(userId, { - serviceNotification, - advertisementNotification, - nightNotification, - }); - + await userService.updateNotificationSettings(userId, settings); sendSuccess(res, { message: '알림 설정이 수정되었습니다.' }); } catch (error) { next(error); @@ -182,8 +191,8 @@ export const sendFeedback = async (req: Request, res: Response, next: NextFuncti throw new AppError('AUTH_007'); } - if (!content || content.trim().length === 0) { - throw new AppError('USER_005'); + if (!content) { + throw new AppError('GENERAL_001', '의견 내용을 입력해주세요.'); } await userService.sendUserFeedback(userId, content); diff --git a/src/dtos/aiSummaryDto.ts b/src/dtos/aiSummaryDto.ts new file mode 100644 index 0000000..f4b2b05 --- /dev/null +++ b/src/dtos/aiSummaryDto.ts @@ -0,0 +1,67 @@ +/** + * AI 채팅 요약 생성 요청 DTO + */ +export interface GenerateSummaryRequestDto { + roomId: number; +} + +/** + * AI 채팅 요약 응답 DTO + */ +export interface GenerateSummaryResponseDto { + summaryId: string; + roomTitle: string; + videoTitle: string; + topicSummary: string; + emotionAnalysis: string; // "기쁨", "슬픔", "분노", "혐오", "공포", "놀람" 중 하나 + timestamp: string; +} + +/** + * AI 요약 피드백 요청 DTO + */ +export interface SummaryFeedbackRequestDto { + feedback: 'LIKE' | 'DISLIKE'; + comment?: string; +} + +/** + * AI 요약 피드백 응답 DTO + */ +export interface SummaryFeedbackResponseDto { + message: string; +} + +/** + * 채팅 메시지 포맷 DTO (내부 사용) + */ +export interface ChatMessageFormatDto { + nickname: string; + content: string; + createdAt: Date; +} + +/** + * Claude 모델 응답 DTO + */ +export interface ClaudeResponseDto { + topicSummary: string; + emotionAnalysis: string; // EmotionType + 설명 +} + +/** + * 감정 분석 타입 + */ +export type EmotionType = '기쁨' | '슬픔' | '분노' | '혐오' | '공포' | '놀람'; + +/** + * UserFeedback에 저장될 AI 피드백 데이터 구조 + */ +export interface AISummaryFeedbackData { + type: 'AI_SUMMARY_FEEDBACK'; + summaryId: string; + roomId: number; + feedback: 'LIKE' | 'DISLIKE'; + comment?: string; + timestamp: string; +} diff --git a/src/middleware/uploadMiddleware.ts b/src/middleware/uploadMiddleware.ts index ea35bdf..1f40da6 100644 --- a/src/middleware/uploadMiddleware.ts +++ b/src/middleware/uploadMiddleware.ts @@ -27,11 +27,11 @@ const fileFilter = (req: AuthRequest, file: Express.Multer.File, cb: multer.File const allowedMimeTypes = [ 'image/jpeg', - 'image/jpg', // 추가 + 'image/jpg', 'image/png', 'image/gif', 'image/webp', - 'image/svg+xml', // SVG 추가 (선택사항) + 'image/svg+xml', ]; if (allowedMimeTypes.includes(file.mimetype)) { @@ -73,7 +73,7 @@ export const uploadProfileImage = multer({ storage: s3Storage, fileFilter: fileFilter, limits: { - fileSize: 50 * 1024 * 1024, // 5MB 제한 + fileSize: 50 * 1024 * 1024, }, }).single('profileImage'); diff --git a/src/routes/aiSummaryRoutes.ts b/src/routes/aiSummaryRoutes.ts new file mode 100644 index 0000000..15e8888 --- /dev/null +++ b/src/routes/aiSummaryRoutes.ts @@ -0,0 +1,123 @@ +import express from 'express'; +import { generateSummary, submitSummaryFeedback } from '../controllers/aiSummaryController.js'; +import { requireAuth } from '../middleware/authMiddleware.js'; + +const router = express.Router(); + +/** + * @swagger + * /api/ai/summary: + * post: + * summary: 방 종료 시 채팅 요약 생성 + * tags: [AI Summary] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - roomId + * properties: + * roomId: + * type: number + * description: 요약할 방의 ID + * example: 123 + * responses: + * 201: + * description: 채팅 요약 생성 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * summaryId: + * type: string + * example: summary_123 + * roomTitle: + * type: string + * example: 방제목 + * videoTitle: + * type: string + * example: 영상제목 + * topicSummary: + * type: string + * example: 전체 대화 주제 요약 + * emotionAnalysis: + * type: string + * description: 6가지 기본 감정 중 하나와 설명 (기쁨/슬픔/분노/혐오/공포/놀람) + * example: 기쁨 - 영상을 보며 즐거워하는 반응이 많았습니다 + * timestamp: + * type: string + * format: date-time + * example: 2025-01-27T12:00:00Z + * 404: + * description: 방을 찾을 수 없음 + * 403: + * description: 방에 참여하지 않은 사용자 + */ +router.post('/summary', requireAuth, generateSummary); + +/** + * @swagger + * /api/ai/summary/{summaryId}/feedback: + * post: + * summary: AI 요약에 대한 피드백 제출 + * tags: [AI Summary] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: summaryId + * required: true + * schema: + * type: string + * description: 요약 ID + * example: summary_123 + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - feedback + * properties: + * feedback: + * type: string + * enum: [LIKE, DISLIKE] + * description: 피드백 타입 + * example: LIKE + * comment: + * type: string + * description: 피드백 코멘트 + * example: 피드백 코멘트 + * responses: + * 200: + * description: 피드백 제출 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * message: + * type: string + * example: 피드백이 제출되었습니다. + */ +router.post('/summary/:summaryId/feedback', requireAuth, submitSummaryFeedback); + +export default router; diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index c1e6795..9a259ee 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -84,7 +84,7 @@ router.get('/test', (req, res) => { * profileImage: * type: string * format: binary - * description: 업로드할 이미지 파일 (최대 5MB, jpeg/png/gif/webp) + * description: 업로드할 이미지 파일 (최대 50MB, jpeg/png/gif/webp..) * responses: * 200: * description: 이미지 업로드 성공 diff --git a/src/services/aiSummaryService.ts b/src/services/aiSummaryService.ts new file mode 100644 index 0000000..63ea646 --- /dev/null +++ b/src/services/aiSummaryService.ts @@ -0,0 +1,251 @@ +// src/services/aiSummaryService.ts +import prisma from '../lib/prisma.js'; +import AppError from '../middleware/errors/AppError.js'; +import { randomUUID } from 'crypto'; +import { + BedrockRuntimeClient, + InvokeModelCommand, + InvokeModelCommandInput, + InvokeModelCommandOutput, +} from '@aws-sdk/client-bedrock-runtime'; +import { + GenerateSummaryRequestDto, + GenerateSummaryResponseDto, + ClaudeResponseDto, + AISummaryFeedbackData, +} from '../dtos/aiSummaryDto.js'; + +if (!process.env.BEDROCK_MODEL_ID) { + throw new Error('BEDROCK_MODEL_ID 환경변수가 필요합니다'); +} + +const bedrockClient = new BedrockRuntimeClient({ + region: process.env.AWS_REGION || 'ap-northeast-2', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, +}); + +interface ClaudeResponse { + content: Array<{ + type: string; + text: string; + }>; +} + +export class AiSummaryService { + /** + * 방의 채팅 내역을 가져와서 AI 요약을 생성합니다. + * DB에 저장하지 않고 바로 응답을 반환합니다. + */ + async generateChatSummary( + data: GenerateSummaryRequestDto, + userId: number, + ): Promise { + // 1. 방 정보 확인 + const room = await prisma.room.findUnique({ + where: { roomId: data.roomId }, + include: { + host: true, + }, + }); + + if (!room) { + throw new AppError('ROOM_001', '방이 존재하지 않습니다.'); + } + + // 2. 사용자가 방에 참여했는지 확인 + const participant = await prisma.roomParticipant.findFirst({ + where: { + roomId: data.roomId, + userId: userId, + }, + }); + + if (!participant) { + throw new AppError('ROOM_006', '방에 참여하지 않았습니다.'); + } + + // 3. 채팅 메시지 가져오기 + const messages = await prisma.roomMessage.findMany({ + where: { + roomId: data.roomId, + type: 'general', // 시스템 메시지 제외 + }, + include: { + user: { + select: { + nickname: true, + }, + }, + }, + orderBy: { + createdAt: 'asc', + }, + }); + + if (messages.length === 0) { + throw new AppError('GENERAL_003', '요약할 채팅 내역이 없습니다.'); + } + + // 4. 비디오 정보 가져오기 + const video = await prisma.youtubeVideo.findUnique({ + where: { videoId: room.videoId }, + }); + + if (!video) { + throw new AppError('ROOM_007', '유튜브 영상을 찾을 수 없습니다.'); + } + + // 5. 채팅 내용 포맷팅 + const MAX_MESSAGES = 1000; + const MAX_CONTENT_LENGTH = 50000; + const limitedMessages = messages.slice(-MAX_MESSAGES); + const chatContent = limitedMessages + .map(msg => `${msg.user.nickname}: ${msg.content}`) + .join('\n') + .slice(0, MAX_CONTENT_LENGTH); + + // 6. Claude 3.5 Sonnet 모델 호출 + const summary = await this.callClaudeModel(chatContent, video.title); + + // 7. 임시 summaryId 생성 + const summaryId = `summary_${data.roomId}_${randomUUID()}`; + + return { + summaryId, + roomTitle: room.roomName, + videoTitle: video.title, + topicSummary: summary.topicSummary, + emotionAnalysis: summary.emotionAnalysis, + timestamp: new Date().toISOString(), + }; + } + + /** + * Claude 3.5 Sonnet 모델을 호출하여 채팅 요약을 생성합니다. + */ + private async callClaudeModel( + chatContent: string, + videoTitle: string, + ): Promise { + const systemPrompt = `당신은 채팅 내용을 분석하고 요약하는 전문가입니다. +응답은 반드시 유효한 JSON 형식으로만 해주세요.`; + + const userPrompt = `다음은 "${videoTitle}" 영상을 함께 시청하며 나눈 채팅 내용입니다. + +채팅 내용: +${chatContent} + +위 채팅 내용을 분석하여 다음 두 가지를 한국어로 작성해주세요: + +1. 전체 대화 주제 요약 (3-5문장으로 핵심 내용 정리) +2. 대화의 전반적인 감정 분석 + - 반드시 다음 6가지 중 하나를 선택: 기쁨, 슬픔, 분노, 혐오, 공포, 놀람 + - 기쁨: 즐겁고 유쾌한 분위기 + - 슬픔: 우울하거나 아쉬운 분위기 + - 분노: 화나거나 짜증나는 분위기 + - 혐오: 불쾌하거나 거부감이 드는 분위기 + - 공포: 무섭거나 불안한 분위기 + - 놀람: 놀랍거나 신기하거나 충격적인 분위기 + - 형식: "감정이름 - 이유 설명" + +응답은 반드시 다음 JSON 형식으로만 해주세요: +{ + "topicSummary": "요약 내용", + "emotionAnalysis": "기쁨 - 대화 전반적으로 즐거운 분위기가 지배적이었습니다" +}`; + + try { + const input: InvokeModelCommandInput = { + modelId: process.env.BEDROCK_MODEL_ID!, // Claude 3.5 Sonnet + contentType: 'application/json', + accept: 'application/json', + body: JSON.stringify({ + anthropic_version: 'bedrock-2023-05-31', + max_tokens: 1000, + temperature: 0.7, + system: systemPrompt, + messages: [ + { + role: 'user', + content: userPrompt, + }, + ], + }), + }; + + const command = new InvokeModelCommand(input); + const response: InvokeModelCommandOutput = await bedrockClient.send(command); + + try { + const responseBody = JSON.parse(new TextDecoder().decode(response.body)) as ClaudeResponse; + const responseText = responseBody.content[0]?.text || '{}'; + const result = JSON.parse(responseText); + + if (!result.topicSummary || !result.emotionAnalysis) { + throw new Error('응답 형식 오류'); + } + + return { + topicSummary: result.topicSummary, + emotionAnalysis: result.emotionAnalysis, + }; + } catch (parseError) { + console.error('Claude 응답 파싱 실패:', parseError); + throw new AppError('GENERAL_004', 'AI 응답 파싱에 실패했습니다.'); + } + } catch (error) { + console.error('Claude 모델 호출 실패:', error); + throw new AppError('GENERAL_004', 'AI 요약 생성에 실패했습니다.'); + } + } + + /** + * AI 요약에 대한 피드백을 저장합니다. + * 기존 UserFeedback 테이블을 활용하여 JSON 형태로 저장합니다. + */ + async submitFeedback( + summaryId: string, + userId: number, + feedback: 'LIKE' | 'DISLIKE', + comment?: string, + ): Promise { + // summaryId에서 roomId 추출 (summary_123_timestamp 형식) + const roomIdMatch = summaryId.match(/summary_(\d+)_/); + if (!roomIdMatch) { + throw new AppError('GENERAL_001', '유효하지 않은 summaryId입니다.'); + } + + const roomId = parseInt(roomIdMatch[1]); + + // 실제로 존재하는 방인지 확인 + const room = await prisma.room.findUnique({ + where: { roomId }, + }); + + if (!room) { + throw new AppError('ROOM_001', '방이 존재하지 않습니다.'); + } + + // UserFeedback 테이블에 AI 요약 피드백 저장 + const feedbackData: AISummaryFeedbackData = { + type: 'AI_SUMMARY_FEEDBACK', + summaryId, + roomId, + feedback, + comment, + timestamp: new Date().toISOString(), + }; + + await prisma.userFeedback.create({ + data: { + userId, + content: JSON.stringify(feedbackData), + }, + }); + } +} + +export const aiSummaryService = new AiSummaryService(); diff --git a/src/utils/s3utils.ts b/src/utils/s3utils.ts index 7eb4618..8a92f01 100644 --- a/src/utils/s3utils.ts +++ b/src/utils/s3utils.ts @@ -3,10 +3,30 @@ import { s3Client, S3_BUCKET_NAME } from '../config/s3Config.js'; export const deleteS3Object = async (imageUrl: string): Promise => { try { + // 입력 매개변수 검증 + if (!imageUrl || typeof imageUrl !== 'string') { + throw new Error('Invalid image URL provided'); + } + // URL에서 S3 키 추출 - const url = new URL(imageUrl); + let url: URL; + try { + url = new URL(imageUrl); + } catch { + throw new Error('Invalid URL format provided'); + } + + // S3 URL인지 검증 + if (!url.hostname.includes('amazonaws.com') || !url.hostname.includes('s3')) { + throw new Error('URL is not from S3'); + } + const key = url.pathname.substring(1); // 맨 앞의 '/' 제거 + if (!key) { + throw new Error('Unable to extract S3 key from URL'); + } + const command = new DeleteObjectCommand({ Bucket: S3_BUCKET_NAME, Key: key, @@ -16,6 +36,7 @@ export const deleteS3Object = async (imageUrl: string): Promise => { console.log(`S3 객체 삭제 성공: ${key}`); } catch (error) { console.error('S3 객체 삭제 실패:', error); - // 실패해도 계속 진행 (로그만 남김) + // 호출자가 실패를 처리할 수 있도록 에러를 다시 던집니다 + throw error; } }; diff --git a/tsconfig.json b/tsconfig.json index ae45a49..4005f8d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "sourceMap": true, "noEmit": false, "typeRoots": ["./node_modules/@types", "./src"], - "types": ["node", "express"] + "types": ["node", "express", "multer", "multer-s3"] }, "ts-node": { "esm": true, From 94b3173aaf347b2ddcda4446b680296df51503e5 Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sun, 3 Aug 2025 18:06:23 +0900 Subject: [PATCH 45/59] Feat: Add Bedrock Claude Sonnet v1 --- src/services/aiSummaryService.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/services/aiSummaryService.ts b/src/services/aiSummaryService.ts index 63ea646..cb08127 100644 --- a/src/services/aiSummaryService.ts +++ b/src/services/aiSummaryService.ts @@ -15,9 +15,7 @@ import { AISummaryFeedbackData, } from '../dtos/aiSummaryDto.js'; -if (!process.env.BEDROCK_MODEL_ID) { - throw new Error('BEDROCK_MODEL_ID 환경변수가 필요합니다'); -} +const BEDROCK_MODEL_ID = 'anthropic.claude-3-5-sonnet-20240620-v1:0'; const bedrockClient = new BedrockRuntimeClient({ region: process.env.AWS_REGION || 'ap-northeast-2', From b6177f6a42dc30b49676970974a5eb952d0dd6cb Mon Sep 17 00:00:00 2001 From: DongilMin Date: Sun, 3 Aug 2025 18:12:34 +0900 Subject: [PATCH 46/59] Feat/ai summary (#82) * Fix: Remove CORS Error * Feat: Add S3 Upload * Feat: Add AI Summary && Feedback with AWS Bedrock Calude 3.5 Sonnet * Formatting * Refact: Refactoring potential issues && Improve Performance * Refact: Refactoring Throwing Error Again --- src/services/aiSummaryService.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/services/aiSummaryService.ts b/src/services/aiSummaryService.ts index cb08127..7155fd3 100644 --- a/src/services/aiSummaryService.ts +++ b/src/services/aiSummaryService.ts @@ -1,4 +1,3 @@ -// src/services/aiSummaryService.ts import prisma from '../lib/prisma.js'; import AppError from '../middleware/errors/AppError.js'; import { randomUUID } from 'crypto'; @@ -157,7 +156,7 @@ ${chatContent} try { const input: InvokeModelCommandInput = { - modelId: process.env.BEDROCK_MODEL_ID!, // Claude 3.5 Sonnet + modelId: BEDROCK_MODEL_ID, // Claude 3.5 Sonnet contentType: 'application/json', accept: 'application/json', body: JSON.stringify({ From d7387cfaa9fc8cbbec5467c7183391afcb04aa8a Mon Sep 17 00:00:00 2001 From: ekdbss <136617606+ekdbss@users.noreply.github.com> Date: Tue, 5 Aug 2025 23:44:38 +0900 Subject: [PATCH 47/59] =?UTF-8?q?Feature:=20bookmark=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20api=20=EA=B0=9C=EB=B0=9C=20=EB=B0=8F=20prisma=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Youtube 영상 검색 api 구현 * feat: YouTube 영상 상세 조회 API 구현 및 DB 저장 기능 추가 * Fix: Add authentication middleware (requireAuth) to search route * Chore: remove unnecessary folder * Chore: format code using Prettier * feat: Youtube 영상 검색 api 구현 * feat: YouTube 영상 상세 조회 API 구현 및 DB 저장 기능 추가 * Fix: Add authentication middleware (requireAuth) to search route * Chore: remove unnecessary folder * Chore: format code using Prettier * Update src/controllers/youtubeDetailController.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Chore: format code using Prettier * fix: add .js extensions to import paths in controller and route for deployment * Fix: resolved merge conflict after pulling from upstream main * Feat: bookmark API 개발 및 schema 수정 * Feat: Bookmark message, timeline parsing을 위한 util 함수 추가 * Fix: 북마크 생성 시 parsing 함수 적용 및 timeline 분리 처리 * Update src/dtos/bookmarkDto.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/controllers/bookmarkController.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Refactor: 북마크 기반 방 생성 로직 정리 및 유효성 검사 강화 * feat: Add validation for maxParticipants in createRoomFromBookmark * Add /api prefix to bookmark-related endpoints * Refactor: Move Prisma client import to lib/prisma.js * chore: schema.prisma 및 마이그레이션 병합 파일 추가 * Fix: host 관계 타입 에러로 room 생성 시 hostId 직접 설정 Prisma create 시 'host' 관계 필드 사용 시 타입 오류 발생하여, 직접 hostId 값을 설정하는 방식으로 수정 --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: DongilMin --- package-lock.json | 184 ++++----- .../migrations/20250805_merge/migration.sql | 352 ++++++++++++++++++ prisma/schema.prisma | 2 + src/middleware/errors/errorHandler.ts | 6 +- src/routes/youtubeDetailRoute.ts | 67 ++++ src/services/roomServices.ts | 4 +- 6 files changed, 516 insertions(+), 99 deletions(-) create mode 100644 prisma/migrations/20250805_merge/migration.sql create mode 100644 src/routes/youtubeDetailRoute.ts diff --git a/package-lock.json b/package-lock.json index 1944414..c25bc3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -684,32 +684,32 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.857.0.tgz", - "integrity": "sha512-kdNgv0QUIDc3nBStIXa22lX7WbfFmChTDHzONa53ZPIaP2E8CkPJJeZS55VRzHE7FytF34uP+6q1jDysdSTeYA==", + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.859.0.tgz", + "integrity": "sha512-oFLHZX1X6o54ZlweubtSVvQDz15JiNrgDD7KeMZT2MwxiI3axPcHzTo2uizjj5mgNapmYjRmQS5c1c63dvruVA==", "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.857.0", - "@aws-sdk/credential-provider-node": "3.857.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/credential-provider-node": "3.859.0", "@aws-sdk/middleware-bucket-endpoint": "3.840.0", "@aws-sdk/middleware-expect-continue": "3.840.0", - "@aws-sdk/middleware-flexible-checksums": "3.857.0", + "@aws-sdk/middleware-flexible-checksums": "3.858.0", "@aws-sdk/middleware-host-header": "3.840.0", "@aws-sdk/middleware-location-constraint": "3.840.0", "@aws-sdk/middleware-logger": "3.840.0", "@aws-sdk/middleware-recursion-detection": "3.840.0", - "@aws-sdk/middleware-sdk-s3": "3.857.0", + "@aws-sdk/middleware-sdk-s3": "3.858.0", "@aws-sdk/middleware-ssec": "3.840.0", - "@aws-sdk/middleware-user-agent": "3.857.0", + "@aws-sdk/middleware-user-agent": "3.858.0", "@aws-sdk/region-config-resolver": "3.840.0", - "@aws-sdk/signature-v4-multi-region": "3.857.0", + "@aws-sdk/signature-v4-multi-region": "3.858.0", "@aws-sdk/types": "3.840.0", "@aws-sdk/util-endpoints": "3.848.0", "@aws-sdk/util-user-agent-browser": "3.840.0", - "@aws-sdk/util-user-agent-node": "3.857.0", + "@aws-sdk/util-user-agent-node": "3.858.0", "@aws-sdk/xml-builder": "3.821.0", "@smithy/config-resolver": "^4.1.4", "@smithy/core": "^3.7.2", @@ -753,23 +753,23 @@ } }, "node_modules/@aws-sdk/client-sso": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.857.0.tgz", - "integrity": "sha512-0jXF4YJ3mGspNsxOU1rdk1uTtm/xadSWvgU+JQb2YCnallEDeT/Kahlyg4GOzPDj0UnnYWsD2s1Hx82O08SbiQ==", + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.858.0.tgz", + "integrity": "sha512-iXuZQs4KH6a3Pwnt0uORalzAZ5EXRPr3lBYAsdNwkP8OYyoUz5/TE3BLyw7ceEh0rj4QKGNnNALYo1cDm0EV8w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.857.0", + "@aws-sdk/core": "3.858.0", "@aws-sdk/middleware-host-header": "3.840.0", "@aws-sdk/middleware-logger": "3.840.0", "@aws-sdk/middleware-recursion-detection": "3.840.0", - "@aws-sdk/middleware-user-agent": "3.857.0", + "@aws-sdk/middleware-user-agent": "3.858.0", "@aws-sdk/region-config-resolver": "3.840.0", "@aws-sdk/types": "3.840.0", "@aws-sdk/util-endpoints": "3.848.0", "@aws-sdk/util-user-agent-browser": "3.840.0", - "@aws-sdk/util-user-agent-node": "3.857.0", + "@aws-sdk/util-user-agent-node": "3.858.0", "@smithy/config-resolver": "^4.1.4", "@smithy/core": "^3.7.2", "@smithy/fetch-http-handler": "^5.1.0", @@ -802,9 +802,9 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.857.0.tgz", - "integrity": "sha512-mgtjKignFcCl19TS6vKbC3e9jtogg6S38a0HFFWjcqMCUAskM+ZROickVTKsYeAk7FoYa2++YkM0qz8J/yteVA==", + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.858.0.tgz", + "integrity": "sha512-iWm4QLAS+/XMlnecIU1Y33qbBr1Ju+pmWam3xVCPlY4CSptKpVY+2hXOnmg9SbHAX9C005fWhrIn51oDd00c9A==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.840.0", @@ -828,12 +828,12 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.857.0.tgz", - "integrity": "sha512-i9NjopufQc7mrJr2lVU4DU5cLGJQ1wNEucnP6XcpCozbJfGJExU9o/VY27qU/pI8V0zK428KXuABuN70Qb+xkw==", + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.858.0.tgz", + "integrity": "sha512-kZsGyh2BoSRguzlcGtzdLhw/l/n3KYAC+/l/H0SlsOq3RLHF6tO/cRdsLnwoix2bObChHUp03cex63o1gzdx/Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.857.0", + "@aws-sdk/core": "3.858.0", "@aws-sdk/types": "3.840.0", "@smithy/property-provider": "^4.0.4", "@smithy/types": "^4.3.1", @@ -844,12 +844,12 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.857.0.tgz", - "integrity": "sha512-Ig1dwbn+vO7Fo+2uznZ6Pv0xoLIWz6ndzJygn2eR2MRi6LvZSnTZqbeovjJeoEzWO2xFdK++SyjS7aEuAMAmzw==", + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.858.0.tgz", + "integrity": "sha512-GDnfYl3+NPJQ7WQQYOXEA489B212NinpcIDD7rpsB6IWUPo8yDjT5NceK4uUkIR3MFpNCGt9zd/z6NNLdB2fuQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.857.0", + "@aws-sdk/core": "3.858.0", "@aws-sdk/types": "3.840.0", "@smithy/fetch-http-handler": "^5.1.0", "@smithy/node-http-handler": "^4.1.0", @@ -865,18 +865,18 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.857.0.tgz", - "integrity": "sha512-w24ABs913sweDFz0aX/PGEfK1jgpV21a2E8p78ueSkQ7Fb7ELVzsv1C16ESFDDF++P4KVkxNQrjRuKw/5+T7ug==", + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.859.0.tgz", + "integrity": "sha512-KsccE1T88ZDNhsABnqbQj014n5JMDilAroUErFbGqu5/B3sXqUsYmG54C/BjvGTRUFfzyttK9lB9P9h6ddQ8Cw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.857.0", - "@aws-sdk/credential-provider-env": "3.857.0", - "@aws-sdk/credential-provider-http": "3.857.0", - "@aws-sdk/credential-provider-process": "3.857.0", - "@aws-sdk/credential-provider-sso": "3.857.0", - "@aws-sdk/credential-provider-web-identity": "3.857.0", - "@aws-sdk/nested-clients": "3.857.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/credential-provider-env": "3.858.0", + "@aws-sdk/credential-provider-http": "3.858.0", + "@aws-sdk/credential-provider-process": "3.858.0", + "@aws-sdk/credential-provider-sso": "3.859.0", + "@aws-sdk/credential-provider-web-identity": "3.858.0", + "@aws-sdk/nested-clients": "3.858.0", "@aws-sdk/types": "3.840.0", "@smithy/credential-provider-imds": "^4.0.6", "@smithy/property-provider": "^4.0.4", @@ -889,17 +889,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.857.0.tgz", - "integrity": "sha512-4ulf6NmbGrE1S+8eAHZQ/krvd441KdKvpT3bFoTsg+89YlGwobW+C+vy94qQBx0iKbqkILbLeFF2F/Bf/ACnmw==", + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.859.0.tgz", + "integrity": "sha512-ZRDB2xU5aSyTR/jDcli30tlycu6RFvQngkZhBs9Zoh2BiYXrfh2MMuoYuZk+7uD6D53Q2RIEldDHR9A/TPlRuA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "3.857.0", - "@aws-sdk/credential-provider-http": "3.857.0", - "@aws-sdk/credential-provider-ini": "3.857.0", - "@aws-sdk/credential-provider-process": "3.857.0", - "@aws-sdk/credential-provider-sso": "3.857.0", - "@aws-sdk/credential-provider-web-identity": "3.857.0", + "@aws-sdk/credential-provider-env": "3.858.0", + "@aws-sdk/credential-provider-http": "3.858.0", + "@aws-sdk/credential-provider-ini": "3.859.0", + "@aws-sdk/credential-provider-process": "3.858.0", + "@aws-sdk/credential-provider-sso": "3.859.0", + "@aws-sdk/credential-provider-web-identity": "3.858.0", "@aws-sdk/types": "3.840.0", "@smithy/credential-provider-imds": "^4.0.6", "@smithy/property-provider": "^4.0.4", @@ -912,12 +912,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.857.0.tgz", - "integrity": "sha512-WLSLM4+vDyrjT+aeaiUHkAxUXUSQSXIQT8ZoS7RHo2BvTlpBOJY9nxvcmKWNCQ2hW2AhVjqBeMjVz3u3fFhoJQ==", + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.858.0.tgz", + "integrity": "sha512-l5LJWZJMRaZ+LhDjtupFUKEC5hAjgvCRrOvV5T60NCUBOy0Ozxa7Sgx3x+EOwiruuoh3Cn9O+RlbQlJX6IfZIw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.857.0", + "@aws-sdk/core": "3.858.0", "@aws-sdk/types": "3.840.0", "@smithy/property-provider": "^4.0.4", "@smithy/shared-ini-file-loader": "^4.0.4", @@ -929,14 +929,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.857.0.tgz", - "integrity": "sha512-OfbkZ//9+nC2HH+3cbjjQz4d4ODQsFml38mPvwq7FSiVrUR7hxgE7OQael4urqKVWLEqFt6/PCr+QZq0J4dJ1A==", + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.859.0.tgz", + "integrity": "sha512-BwAqmWIivhox5YlFRjManFF8GoTvEySPk6vsJNxDsmGsabY+OQovYxFIYxRCYiHzH7SFjd4Lcd+riJOiXNsvRw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.857.0", - "@aws-sdk/core": "3.857.0", - "@aws-sdk/token-providers": "3.857.0", + "@aws-sdk/client-sso": "3.858.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/token-providers": "3.859.0", "@aws-sdk/types": "3.840.0", "@smithy/property-provider": "^4.0.4", "@smithy/shared-ini-file-loader": "^4.0.4", @@ -948,13 +948,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.857.0.tgz", - "integrity": "sha512-aj1QbOyhu+bl+gsgIpMuvVRJa1LkgwHzyu6lzjCrPxuPO6ytHDMmii+QUyM9P5K3Xk6fT/JGposhMFB5AtI+Og==", + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.858.0.tgz", + "integrity": "sha512-8iULWsH83iZDdUuiDsRb83M0NqIlXjlDbJUIddVsIrfWp4NmanKw77SV6yOZ66nuJjPsn9j7RDb9bfEPCy5SWA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.857.0", - "@aws-sdk/nested-clients": "3.857.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/nested-clients": "3.858.0", "@aws-sdk/types": "3.840.0", "@smithy/property-provider": "^4.0.4", "@smithy/types": "^4.3.1", @@ -1049,15 +1049,15 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.857.0.tgz", - "integrity": "sha512-6iHar8RMW1JHYHlho3AQXEwvMmFSfxZHaj6d+TR/os0YrmQFBkLqpK8OBmJ750qa0S6QB22s+8kmbH4BCpeccg==", + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.858.0.tgz", + "integrity": "sha512-/GBerFXab3Mk5zkkTaOR1drR1IWMShiUbcEocCPig068/HnpjVSd9SP4+ro/ivG+zLOtxJdpjBcBKxCwQmefMA==", "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.857.0", + "@aws-sdk/core": "3.858.0", "@aws-sdk/types": "3.840.0", "@smithy/is-array-buffer": "^4.0.0", "@smithy/node-config-provider": "^4.1.3", @@ -1131,12 +1131,12 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.857.0.tgz", - "integrity": "sha512-qKbr6I6+4kRvI9guR1xnTX3dS37JaIM042/uLYzb65/dUfOm7oxBTDW0+7Jdu92nj5bAChYloKQGEsr7dwKxeg==", + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.858.0.tgz", + "integrity": "sha512-g1LBHK9iAAMnh4rRX4/cGBuICH5R9boHUw4X9FkMC+ROAH9z1A2uy6bE55sg5guheAmVTQ5sOsVZb8QPEQbIUA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.857.0", + "@aws-sdk/core": "3.858.0", "@aws-sdk/types": "3.840.0", "@aws-sdk/util-arn-parser": "3.804.0", "@smithy/core": "^3.7.2", @@ -1170,12 +1170,12 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.857.0.tgz", - "integrity": "sha512-JPqTxJGwc5QyxpCpDuOi64+z+9krpkv9FidnWjPqqNwLy25Da8espksTzptPivsMzUukdObFWJsDG89/8/I6TQ==", + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.858.0.tgz", + "integrity": "sha512-pC3FT/sRZ6n5NyXiTVu9dpf1D9j3YbJz3XmeOOwJqO/Mib2PZyIQktvNMPgwaC5KMVB1zWqS5bmCwxpMOnq0UQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.857.0", + "@aws-sdk/core": "3.858.0", "@aws-sdk/types": "3.840.0", "@aws-sdk/util-endpoints": "3.848.0", "@smithy/core": "^3.7.2", @@ -1209,23 +1209,23 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.857.0.tgz", - "integrity": "sha512-3P1GP34hu3Yb7C8bcIqIGASMt/MT/1Lxwy37UJwCn4IrccrvYM3i8y5XX4wW8sn1J5832wB4kdb4HTYbEz6+zw==", + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.858.0.tgz", + "integrity": "sha512-ChdIj80T2whoWbovmO7o8ICmhEB2S9q4Jes9MBnKAPm69PexcJAK2dQC8yI4/iUP8b3+BHZoUPrYLWjBxIProQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.857.0", + "@aws-sdk/core": "3.858.0", "@aws-sdk/middleware-host-header": "3.840.0", "@aws-sdk/middleware-logger": "3.840.0", "@aws-sdk/middleware-recursion-detection": "3.840.0", - "@aws-sdk/middleware-user-agent": "3.857.0", + "@aws-sdk/middleware-user-agent": "3.858.0", "@aws-sdk/region-config-resolver": "3.840.0", "@aws-sdk/types": "3.840.0", "@aws-sdk/util-endpoints": "3.848.0", "@aws-sdk/util-user-agent-browser": "3.840.0", - "@aws-sdk/util-user-agent-node": "3.857.0", + "@aws-sdk/util-user-agent-node": "3.858.0", "@smithy/config-resolver": "^4.1.4", "@smithy/core": "^3.7.2", "@smithy/fetch-http-handler": "^5.1.0", @@ -1275,12 +1275,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.857.0.tgz", - "integrity": "sha512-KVpHAtRjv4oNydwXwAEf2ur4BOAWjjZiT/QtLtTKYbEbnXW1eOFZg4kWwJwHa/T/w2zfPMVf6LhZvyFwLU9XGg==", + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.858.0.tgz", + "integrity": "sha512-WtQvCtIz8KzTqd/OhjziWb5nAFDEZ0pE1KJsWBZ0j6Ngvp17ORSY37U96buU0SlNNflloGT7ZIlDkdFh73YktA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.857.0", + "@aws-sdk/middleware-sdk-s3": "3.858.0", "@aws-sdk/types": "3.840.0", "@smithy/protocol-http": "^5.1.2", "@smithy/signature-v4": "^5.1.2", @@ -1292,13 +1292,13 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.857.0.tgz", - "integrity": "sha512-4DBZw+QHpsnpYLXzQtDYCEP9KFFQlYAmNnrCK1bsWoKqnUgjKgwr9Re0yhtNiieHhEE4Lhu+E+IAiNwDx2ClVw==", + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.859.0.tgz", + "integrity": "sha512-6P2wlvm9KBWOvRNn0Pt8RntnXg8fzOb5kEShvWsOsAocZeqKNaYbihum5/Onq1ZPoVtkdb++8eWDocDnM4k85Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.857.0", - "@aws-sdk/nested-clients": "3.857.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/nested-clients": "3.858.0", "@aws-sdk/types": "3.840.0", "@smithy/property-provider": "^4.0.4", "@smithy/shared-ini-file-loader": "^4.0.4", @@ -1390,12 +1390,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.857.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.857.0.tgz", - "integrity": "sha512-xWNfAnD2t5yACGW1wM3iLoy2FvRM8N/XjkjgJE1O35gBHn00evtLC9q4nkR4x7+vXdZb8cVw4Y6GmcfMckgFQg==", + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.858.0.tgz", + "integrity": "sha512-T1m05QlN8hFpx5/5duMjS8uFSK5e6EXP45HQRkZULVkL3DK+jMaxsnh3KLl5LjUoHn/19M4HM0wNUBhYp4Y2Yw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.857.0", + "@aws-sdk/middleware-user-agent": "3.858.0", "@aws-sdk/types": "3.840.0", "@smithy/node-config-provider": "^4.1.3", "@smithy/types": "^4.3.1", diff --git a/prisma/migrations/20250805_merge/migration.sql b/prisma/migrations/20250805_merge/migration.sql new file mode 100644 index 0000000..0cf2e37 --- /dev/null +++ b/prisma/migrations/20250805_merge/migration.sql @@ -0,0 +1,352 @@ +-- CreateTable +CREATE TABLE `users` ( + `user_id` INTEGER NOT NULL AUTO_INCREMENT, + `loginId` VARCHAR(50) NOT NULL, + `password` VARCHAR(255) NOT NULL, + `nickname` VARCHAR(30) NOT NULL, + `profile_image` VARCHAR(500) NULL, + `popularity` INTEGER NOT NULL DEFAULT 0, + `is_verified` BOOLEAN NOT NULL DEFAULT false, + `refresh_token` VARCHAR(500) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `users_loginId_key`(`loginId`), + UNIQUE INDEX `users_nickname_key`(`nickname`), + PRIMARY KEY (`user_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `user_agreements` ( + `agreement_id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `service_terms` BOOLEAN NOT NULL DEFAULT false, + `privacy_collection` BOOLEAN NOT NULL DEFAULT false, + `privacy_policy` BOOLEAN NOT NULL DEFAULT false, + `marketing_consent` BOOLEAN NOT NULL DEFAULT false, + `event_promotion` BOOLEAN NOT NULL DEFAULT false, + `service_notification` BOOLEAN NOT NULL DEFAULT true, + `advertising_notification` BOOLEAN NOT NULL DEFAULT false, + `night_notification` BOOLEAN NOT NULL DEFAULT false, + + UNIQUE INDEX `user_agreements_user_id_key`(`user_id`), + PRIMARY KEY (`agreement_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `friendship` ( + `friendship_id` INTEGER NOT NULL AUTO_INCREMENT, + `requested_by` INTEGER NOT NULL, + `requested_to` INTEGER NOT NULL, + `is_accepted` BOOLEAN NOT NULL DEFAULT false, + `status` ENUM('pending', 'accepted', 'rejected') NOT NULL DEFAULT 'pending', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `accepted_at` DATETIME(3) NULL, + + INDEX `idx_requested_by_status`(`requested_by`, `status`), + INDEX `friendship_requested_to_fkey`(`requested_to`), + UNIQUE INDEX `friendship_requested_by_requested_to_key`(`requested_by`, `requested_to`), + PRIMARY KEY (`friendship_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `user_blocks` ( + `block_id` INTEGER NOT NULL AUTO_INCREMENT, + `blocker_user_id` INTEGER NOT NULL, + `blocked_user_id` INTEGER NOT NULL, + `blocked_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `is_active` BOOLEAN NOT NULL DEFAULT true, + + INDEX `user_blocks_blocked_user_id_fkey`(`blocked_user_id`), + UNIQUE INDEX `user_blocks_blocker_user_id_blocked_user_id_key`(`blocker_user_id`, `blocked_user_id`), + PRIMARY KEY (`block_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `rooms` ( + `room_id` INTEGER NOT NULL AUTO_INCREMENT, + `host_id` INTEGER NOT NULL, + `room_name` VARCHAR(100) NOT NULL, + `is_public` BOOLEAN NOT NULL DEFAULT true, + `is_active` BOOLEAN NOT NULL DEFAULT true, + `max_participants` INTEGER NOT NULL DEFAULT 6, + `current_participants` INTEGER NOT NULL DEFAULT 1, + `popularity` INTEGER NOT NULL DEFAULT 0, + `auto_archive` BOOLEAN NOT NULL DEFAULT true, + `invite_auth` ENUM('all', 'host') NOT NULL DEFAULT 'all', + `watched_30s` BOOLEAN NOT NULL DEFAULT false, + `startType` ENUM('BOOKMARK', 'BEGINNING') NOT NULL DEFAULT 'BEGINNING', + `startTime` INTEGER NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + `video_id` VARCHAR(20) NOT NULL, + + INDEX `rooms_host_id_fkey`(`host_id`), + INDEX `rooms_video_id_fkey`(`video_id`), + PRIMARY KEY (`room_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `room_participants` ( + `participant_id` INTEGER NOT NULL AUTO_INCREMENT, + `room_id` INTEGER NOT NULL, + `user_id` INTEGER NOT NULL, + `role` ENUM('host', 'participant') NOT NULL DEFAULT 'participant', + `joined_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `left_at` DATETIME(3) NULL, + `last_joined_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `total_stay_time` INTEGER NOT NULL DEFAULT 0, + + INDEX `room_participants_user_id_fkey`(`user_id`), + UNIQUE INDEX `room_participants_room_id_user_id_key`(`room_id`, `user_id`), + PRIMARY KEY (`participant_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `room_messages` ( + `message_id` INTEGER NOT NULL AUTO_INCREMENT, + `room_id` INTEGER NOT NULL, + `user_id` INTEGER NOT NULL, + `content` TEXT NOT NULL, + `type` ENUM('general', 'system') NOT NULL DEFAULT 'general', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `room_messages_room_id_fkey`(`room_id`), + INDEX `room_messages_user_id_fkey`(`user_id`), + PRIMARY KEY (`message_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `user_chats` ( + `chat_id` INTEGER NOT NULL AUTO_INCREMENT, + `user1_id` INTEGER NOT NULL, + `user2_id` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `user_chats_user2_id_fkey`(`user2_id`), + UNIQUE INDEX `user_chats_user1_id_user2_id_key`(`user1_id`, `user2_id`), + PRIMARY KEY (`chat_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `user_chat_messages` ( + `message_id` INTEGER NOT NULL AUTO_INCREMENT, + `chat_id` INTEGER NOT NULL, + `user_id` INTEGER NOT NULL, + `content` TEXT NULL, + `type` ENUM('general', 'collectionShare', 'roomInvite') NOT NULL DEFAULT 'general', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `idx_chat_type`(`chat_id`, `type`), + INDEX `user_chat_messages_user_id_fkey`(`user_id`), + PRIMARY KEY (`message_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `bookmarks` ( + `bookmark_id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `room_id` INTEGER NOT NULL, + `title` VARCHAR(50) NULL, + `content` TEXT NULL, + `timeline` INTEGER NULL DEFAULT 0, + `original_bookmark_id` INTEGER NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `collection_id` INTEGER NULL, + + INDEX `bookmarks_original_bookmark_id_fkey`(`original_bookmark_id`), + INDEX `bookmarks_room_id_fkey`(`room_id`), + INDEX `bookmarks_user_id_fkey`(`user_id`), + INDEX `bookmarks_collection_id_fkey`(`collection_id`), + PRIMARY KEY (`bookmark_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `collections` ( + `collection_id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `title` VARCHAR(100) NOT NULL, + `description` VARCHAR(100) NULL, + `visibility` ENUM('private', 'friends', 'public') NOT NULL DEFAULT 'public', + `bookmark_count` INTEGER NOT NULL DEFAULT 0, + `is_liked` BOOLEAN NOT NULL DEFAULT false, + `original_collection_id` INTEGER NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `cover_image` VARCHAR(500) NULL, + `updated_at` DATETIME(3) NOT NULL, + + INDEX `collections_original_collection_id_fkey`(`original_collection_id`), + INDEX `collections_user_id_fkey`(`user_id`), + PRIMARY KEY (`collection_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `shared_collections` ( + `share_id` INTEGER NOT NULL AUTO_INCREMENT, + `shared_to_user_id` INTEGER NOT NULL, + `collection_id` INTEGER NOT NULL, + `shared_in_chat_id` INTEGER NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `shared_collections_collection_id_fkey`(`collection_id`), + INDEX `shared_collections_shared_in_chat_id_fkey`(`shared_in_chat_id`), + INDEX `shared_collections_shared_to_user_id_fkey`(`shared_to_user_id`), + PRIMARY KEY (`share_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `notifications` ( + `notification_id` INTEGER NOT NULL AUTO_INCREMENT, + `from_user_id` INTEGER NULL, + `to_user_id` INTEGER NOT NULL, + `title` VARCHAR(150) NULL, + `type` ENUM('roomInvite', 'collectionShare', 'friendRequest', 'popularityUp') NOT NULL, + `status` ENUM('unread', 'read') NOT NULL DEFAULT 'unread', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `idx_to_user_created`(`to_user_id`, `created_at`), + INDEX `notifications_from_user_id_fkey`(`from_user_id`), + PRIMARY KEY (`notification_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `daily_recommendations` ( + `recommendation_id` INTEGER NOT NULL AUTO_INCREMENT, + `recommender_id` INTEGER NOT NULL, + `recommended_user_id` INTEGER NOT NULL, + `recommendation_date` DATE NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `daily_recommendations_recommended_user_id_fkey`(`recommended_user_id`), + UNIQUE INDEX `daily_recommendations_recommender_id_recommended_user_id_rec_key`(`recommender_id`, `recommended_user_id`, `recommendation_date`), + PRIMARY KEY (`recommendation_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `search_history` ( + `history_id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `search_keyword` VARCHAR(100) NOT NULL, + `searched_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `idx_user_searched`(`user_id`, `searched_at`), + PRIMARY KEY (`history_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `youtube_videos` ( + `video_id` VARCHAR(20) NOT NULL, + `title` VARCHAR(200) NOT NULL, + `description` TEXT NULL, + `thumbnail` VARCHAR(500) NULL, + `channel_icon` VARCHAR(500) NULL, + `channel_name` VARCHAR(100) NULL, + `view_count` INTEGER NOT NULL DEFAULT 0, + `duration` VARCHAR(20) NULL, + `uploaded_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`video_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `user_feedbacks` ( + `feedback_id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `content` TEXT NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `user_feedbacks_user_id_fkey`(`user_id`), + PRIMARY KEY (`feedback_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `user_agreements` ADD CONSTRAINT `user_agreements_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `friendship` ADD CONSTRAINT `friendship_requested_by_fkey` FOREIGN KEY (`requested_by`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `friendship` ADD CONSTRAINT `friendship_requested_to_fkey` FOREIGN KEY (`requested_to`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_blocks` ADD CONSTRAINT `user_blocks_blocked_user_id_fkey` FOREIGN KEY (`blocked_user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_blocks` ADD CONSTRAINT `user_blocks_blocker_user_id_fkey` FOREIGN KEY (`blocker_user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `rooms` ADD CONSTRAINT `rooms_host_id_fkey` FOREIGN KEY (`host_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `rooms` ADD CONSTRAINT `rooms_video_id_fkey` FOREIGN KEY (`video_id`) REFERENCES `youtube_videos`(`video_id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `room_participants` ADD CONSTRAINT `room_participants_room_id_fkey` FOREIGN KEY (`room_id`) REFERENCES `rooms`(`room_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `room_participants` ADD CONSTRAINT `room_participants_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `room_messages` ADD CONSTRAINT `room_messages_room_id_fkey` FOREIGN KEY (`room_id`) REFERENCES `rooms`(`room_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `room_messages` ADD CONSTRAINT `room_messages_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_chats` ADD CONSTRAINT `user_chats_user1_id_fkey` FOREIGN KEY (`user1_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_chats` ADD CONSTRAINT `user_chats_user2_id_fkey` FOREIGN KEY (`user2_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_chat_messages` ADD CONSTRAINT `user_chat_messages_chat_id_fkey` FOREIGN KEY (`chat_id`) REFERENCES `user_chats`(`chat_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_chat_messages` ADD CONSTRAINT `user_chat_messages_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `bookmarks` ADD CONSTRAINT `bookmarks_collection_id_fkey` FOREIGN KEY (`collection_id`) REFERENCES `collections`(`collection_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `bookmarks` ADD CONSTRAINT `bookmarks_original_bookmark_id_fkey` FOREIGN KEY (`original_bookmark_id`) REFERENCES `bookmarks`(`bookmark_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `bookmarks` ADD CONSTRAINT `bookmarks_room_id_fkey` FOREIGN KEY (`room_id`) REFERENCES `rooms`(`room_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `bookmarks` ADD CONSTRAINT `bookmarks_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `collections` ADD CONSTRAINT `collections_original_collection_id_fkey` FOREIGN KEY (`original_collection_id`) REFERENCES `collections`(`collection_id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `collections` ADD CONSTRAINT `collections_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `shared_collections` ADD CONSTRAINT `shared_collections_collection_id_fkey` FOREIGN KEY (`collection_id`) REFERENCES `collections`(`collection_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `shared_collections` ADD CONSTRAINT `shared_collections_shared_in_chat_id_fkey` FOREIGN KEY (`shared_in_chat_id`) REFERENCES `user_chats`(`chat_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `shared_collections` ADD CONSTRAINT `shared_collections_shared_to_user_id_fkey` FOREIGN KEY (`shared_to_user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `notifications` ADD CONSTRAINT `notifications_from_user_id_fkey` FOREIGN KEY (`from_user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `notifications` ADD CONSTRAINT `notifications_to_user_id_fkey` FOREIGN KEY (`to_user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `daily_recommendations` ADD CONSTRAINT `daily_recommendations_recommended_user_id_fkey` FOREIGN KEY (`recommended_user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `daily_recommendations` ADD CONSTRAINT `daily_recommendations_recommender_id_fkey` FOREIGN KEY (`recommender_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `search_history` ADD CONSTRAINT `search_history_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_feedbacks` ADD CONSTRAINT `user_feedbacks_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6ba53b4..3d1b6af 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,6 +110,7 @@ model Room { messages RoomMessage[] participants RoomParticipant[] host User @relation("HostUser", fields: [hostId], references: [userId], onDelete: Cascade) + video YoutubeVideo? @relation("VideoOnRoom", fields: [videoId], references: [videoId]) @@index([hostId], map: "rooms_host_id_fkey") @@index([videoId], map: "rooms_video_id_fkey") @@ -292,6 +293,7 @@ model YoutubeVideo { duration String? @db.VarChar(20) uploadedAt DateTime? @map("uploaded_at") createdAt DateTime @default(now()) @map("created_at") + rooms Room[] @relation("VideoOnRoom") @@map("youtube_videos") } diff --git a/src/middleware/errors/errorHandler.ts b/src/middleware/errors/errorHandler.ts index 9c10855..c63a467 100644 --- a/src/middleware/errors/errorHandler.ts +++ b/src/middleware/errors/errorHandler.ts @@ -6,10 +6,8 @@ import { sendError } from '../../utils/response.js'; const errorHandler = (err: Error, req: Request, res: Response, _next: NextFunction) => { void _next; - // 개발 환경에서는 전체 에러 스택 출력 - if (process.env.NODE_ENV === 'development') { - console.error('Error Stack:', err.stack); - console.error('Error Details:', err); + if (err instanceof AppError) { + return sendError(res, err.message, err.statusCode); } // AppError 인스턴스인 경우 diff --git a/src/routes/youtubeDetailRoute.ts b/src/routes/youtubeDetailRoute.ts new file mode 100644 index 0000000..04dcaa9 --- /dev/null +++ b/src/routes/youtubeDetailRoute.ts @@ -0,0 +1,67 @@ +import { Router } from 'express'; +import { getYoutubeVideoDetail } from '../controllers/youtubeDetailController.js'; +import { requireAuth } from '../middleware/authMiddleware'; + +const router = Router(); + +/** + * @swagger + * /api/youtube/videos/{videoId}: + * get: + * summary: 유튜브 영상 상세 조회 + * description: 특정 videoId에 대한 유튜브 영상 정보를 조회하고 DB에 저장합니다. + * tags: + * - YouTube + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: videoId + * required: true + * schema: + * type: string + * description: 유튜브 비디오 ID + * responses: + * 200: + * description: 영상 상세 정보 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * videoId: + * type: string + * title: + * type: string + * description: + * type: string + * thumbnail: + * type: string + * channelName: + * type: string + * channelIcon: + * type: string + * viewCount: + * type: integer + * duration: + * type: string + * example: PT15M33S + * uploadedAt: + * type: string + * format: date-time + * 401: + * description: 인증 실패 + * 404: + * description: 영상 없음 + * 500: + * description: 서버 에러 + */ + +router.get('/:videoId', requireAuth, getYoutubeVideoDetail); + +export default router; diff --git a/src/services/roomServices.ts b/src/services/roomServices.ts index 5520c5f..058d2a2 100644 --- a/src/services/roomServices.ts +++ b/src/services/roomServices.ts @@ -34,9 +34,7 @@ export const createRoom = async (data: createNewRoom) => { isPublic: data.isPublic ?? true, maxParticipants: data.maxParticipants ?? 6, videoId: video.videoId, - host: { - connect: { userId: data.hostId }, // ← 이 부분 추가 - }, + hostId: data.hostId, }, }); From d6ab9fc48bfdb18a0fc12e462391bb54b52943d6 Mon Sep 17 00:00:00 2001 From: ekdbss <136617606+ekdbss@users.noreply.github.com> Date: Wed, 6 Aug 2025 17:40:31 +0900 Subject: [PATCH 48/59] =?UTF-8?q?Feature:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?recommendation=20=EA=B4=80=EB=A0=A8=20api=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Youtube 영상 검색 api 구현 * feat: YouTube 영상 상세 조회 API 구현 및 DB 저장 기능 추가 * Fix: Add authentication middleware (requireAuth) to search route * Chore: remove unnecessary folder * Chore: format code using Prettier * Update src/controllers/youtubeDetailController.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Chore: format code using Prettier * fix: add .js extensions to import paths in controller and route for deployment * Fix: resolved merge conflict after pulling from upstream main * feat: Youtube 영상 검색 api 구현 * feat: YouTube 영상 상세 조회 API 구현 및 DB 저장 기능 추가 * Fix: Add authentication middleware (requireAuth) to search route * Chore: remove unnecessary folder * Chore: format code using Prettier * Update src/controllers/youtubeDetailController.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Chore: format code using Prettier * fix: add .js extensions to import paths in controller and route for deployment * Fix: resolved merge conflict after pulling from upstream main * Feat: bookmark API 개발 및 schema 수정 * Feat: Bookmark message, timeline parsing을 위한 util 함수 추가 * Fix: 북마크 생성 시 parsing 함수 적용 및 timeline 분리 처리 * Update src/dtos/bookmarkDto.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/controllers/bookmarkController.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Refactor: 북마크 기반 방 생성 로직 정리 및 유효성 검사 강화 * feat: Add validation for maxParticipants in createRoomFromBookmark * Add /api prefix to bookmark-related endpoints * Refactor: Move Prisma client import to lib/prisma.js * Feat: 사용자 추천 관련 api 구현 * Feat: 일일 추천 시 인기도(popularity) 증가 로직 추가 * chore: upstream main 병합 --------- Co-authored-by: DongilMin Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- package-lock.json | 337 +----------------- package.json | 5 +- .../migration.sql | 15 - .../migration.sql | 14 - .../migration.sql | 49 --- .../20250731114836_new_schema/migration.sql | 46 --- src/services/userRecommendationService.ts | 26 +- 7 files changed, 20 insertions(+), 472 deletions(-) delete mode 100644 prisma/migrations/20250725072024_add_optional_video_id/migration.sql delete mode 100644 prisma/migrations/20250725074316_make_video_id_required/migration.sql delete mode 100644 prisma/migrations/20250730072811_add_timeline_and_start_fields/migration.sql delete mode 100644 prisma/migrations/20250731114836_new_schema/migration.sql diff --git a/package-lock.json b/package-lock.json index c25bc3a..418e7c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,9 +33,6 @@ "@types/cors": "^2.8.19", "@types/dotenv": "^8.2.3", "@types/express": "^5.0.3", -<<<<<<< HEAD - "@types/ioredis": "^4.28.10", -======= "@types/multer": "^2.0.0", "@types/multer-s3": "^3.0.3", "@types/node": "^24.1.0", @@ -362,327 +359,6 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/client-sso": { - "version": "3.858.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.858.0.tgz", - "integrity": "sha512-iXuZQs4KH6a3Pwnt0uORalzAZ5EXRPr3lBYAsdNwkP8OYyoUz5/TE3BLyw7ceEh0rj4QKGNnNALYo1cDm0EV8w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.858.0", - "@aws-sdk/middleware-host-header": "3.840.0", - "@aws-sdk/middleware-logger": "3.840.0", - "@aws-sdk/middleware-recursion-detection": "3.840.0", - "@aws-sdk/middleware-user-agent": "3.858.0", - "@aws-sdk/region-config-resolver": "3.840.0", - "@aws-sdk/types": "3.840.0", - "@aws-sdk/util-endpoints": "3.848.0", - "@aws-sdk/util-user-agent-browser": "3.840.0", - "@aws-sdk/util-user-agent-node": "3.858.0", - "@smithy/config-resolver": "^4.1.4", - "@smithy/core": "^3.7.2", - "@smithy/fetch-http-handler": "^5.1.0", - "@smithy/hash-node": "^4.0.4", - "@smithy/invalid-dependency": "^4.0.4", - "@smithy/middleware-content-length": "^4.0.4", - "@smithy/middleware-endpoint": "^4.1.17", - "@smithy/middleware-retry": "^4.1.18", - "@smithy/middleware-serde": "^4.0.8", - "@smithy/middleware-stack": "^4.0.4", - "@smithy/node-config-provider": "^4.1.3", - "@smithy/node-http-handler": "^4.1.0", - "@smithy/protocol-http": "^5.1.2", - "@smithy/smithy-client": "^4.4.9", - "@smithy/types": "^4.3.1", - "@smithy/url-parser": "^4.0.4", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.25", - "@smithy/util-defaults-mode-node": "^4.0.25", - "@smithy/util-endpoints": "^3.0.6", - "@smithy/util-middleware": "^4.0.4", - "@smithy/util-retry": "^4.0.6", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/core": { - "version": "3.858.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.858.0.tgz", - "integrity": "sha512-iWm4QLAS+/XMlnecIU1Y33qbBr1Ju+pmWam3xVCPlY4CSptKpVY+2hXOnmg9SbHAX9C005fWhrIn51oDd00c9A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.840.0", - "@aws-sdk/xml-builder": "3.821.0", - "@smithy/core": "^3.7.2", - "@smithy/node-config-provider": "^4.1.3", - "@smithy/property-provider": "^4.0.4", - "@smithy/protocol-http": "^5.1.2", - "@smithy/signature-v4": "^5.1.2", - "@smithy/smithy-client": "^4.4.9", - "@smithy/types": "^4.3.1", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.4", - "@smithy/util-utf8": "^4.0.0", - "fast-xml-parser": "5.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-env": { - "version": "3.858.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.858.0.tgz", - "integrity": "sha512-kZsGyh2BoSRguzlcGtzdLhw/l/n3KYAC+/l/H0SlsOq3RLHF6tO/cRdsLnwoix2bObChHUp03cex63o1gzdx/Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.858.0", - "@aws-sdk/types": "3.840.0", - "@smithy/property-provider": "^4.0.4", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.858.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.858.0.tgz", - "integrity": "sha512-GDnfYl3+NPJQ7WQQYOXEA489B212NinpcIDD7rpsB6IWUPo8yDjT5NceK4uUkIR3MFpNCGt9zd/z6NNLdB2fuQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.858.0", - "@aws-sdk/types": "3.840.0", - "@smithy/fetch-http-handler": "^5.1.0", - "@smithy/node-http-handler": "^4.1.0", - "@smithy/property-provider": "^4.0.4", - "@smithy/protocol-http": "^5.1.2", - "@smithy/smithy-client": "^4.4.9", - "@smithy/types": "^4.3.1", - "@smithy/util-stream": "^4.2.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.859.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.859.0.tgz", - "integrity": "sha512-KsccE1T88ZDNhsABnqbQj014n5JMDilAroUErFbGqu5/B3sXqUsYmG54C/BjvGTRUFfzyttK9lB9P9h6ddQ8Cw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.858.0", - "@aws-sdk/credential-provider-env": "3.858.0", - "@aws-sdk/credential-provider-http": "3.858.0", - "@aws-sdk/credential-provider-process": "3.858.0", - "@aws-sdk/credential-provider-sso": "3.859.0", - "@aws-sdk/credential-provider-web-identity": "3.858.0", - "@aws-sdk/nested-clients": "3.858.0", - "@aws-sdk/types": "3.840.0", - "@smithy/credential-provider-imds": "^4.0.6", - "@smithy/property-provider": "^4.0.4", - "@smithy/shared-ini-file-loader": "^4.0.4", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.859.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.859.0.tgz", - "integrity": "sha512-ZRDB2xU5aSyTR/jDcli30tlycu6RFvQngkZhBs9Zoh2BiYXrfh2MMuoYuZk+7uD6D53Q2RIEldDHR9A/TPlRuA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.858.0", - "@aws-sdk/credential-provider-http": "3.858.0", - "@aws-sdk/credential-provider-ini": "3.859.0", - "@aws-sdk/credential-provider-process": "3.858.0", - "@aws-sdk/credential-provider-sso": "3.859.0", - "@aws-sdk/credential-provider-web-identity": "3.858.0", - "@aws-sdk/types": "3.840.0", - "@smithy/credential-provider-imds": "^4.0.6", - "@smithy/property-provider": "^4.0.4", - "@smithy/shared-ini-file-loader": "^4.0.4", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-process": { - "version": "3.858.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.858.0.tgz", - "integrity": "sha512-l5LJWZJMRaZ+LhDjtupFUKEC5hAjgvCRrOvV5T60NCUBOy0Ozxa7Sgx3x+EOwiruuoh3Cn9O+RlbQlJX6IfZIw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.858.0", - "@aws-sdk/types": "3.840.0", - "@smithy/property-provider": "^4.0.4", - "@smithy/shared-ini-file-loader": "^4.0.4", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.859.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.859.0.tgz", - "integrity": "sha512-BwAqmWIivhox5YlFRjManFF8GoTvEySPk6vsJNxDsmGsabY+OQovYxFIYxRCYiHzH7SFjd4Lcd+riJOiXNsvRw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-sso": "3.858.0", - "@aws-sdk/core": "3.858.0", - "@aws-sdk/token-providers": "3.859.0", - "@aws-sdk/types": "3.840.0", - "@smithy/property-provider": "^4.0.4", - "@smithy/shared-ini-file-loader": "^4.0.4", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.858.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.858.0.tgz", - "integrity": "sha512-8iULWsH83iZDdUuiDsRb83M0NqIlXjlDbJUIddVsIrfWp4NmanKw77SV6yOZ66nuJjPsn9j7RDb9bfEPCy5SWA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.858.0", - "@aws-sdk/nested-clients": "3.858.0", - "@aws-sdk/types": "3.840.0", - "@smithy/property-provider": "^4.0.4", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.858.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.858.0.tgz", - "integrity": "sha512-pC3FT/sRZ6n5NyXiTVu9dpf1D9j3YbJz3XmeOOwJqO/Mib2PZyIQktvNMPgwaC5KMVB1zWqS5bmCwxpMOnq0UQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.858.0", - "@aws-sdk/types": "3.840.0", - "@aws-sdk/util-endpoints": "3.848.0", - "@smithy/core": "^3.7.2", - "@smithy/protocol-http": "^5.1.2", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/nested-clients": { - "version": "3.858.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.858.0.tgz", - "integrity": "sha512-ChdIj80T2whoWbovmO7o8ICmhEB2S9q4Jes9MBnKAPm69PexcJAK2dQC8yI4/iUP8b3+BHZoUPrYLWjBxIProQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.858.0", - "@aws-sdk/middleware-host-header": "3.840.0", - "@aws-sdk/middleware-logger": "3.840.0", - "@aws-sdk/middleware-recursion-detection": "3.840.0", - "@aws-sdk/middleware-user-agent": "3.858.0", - "@aws-sdk/region-config-resolver": "3.840.0", - "@aws-sdk/types": "3.840.0", - "@aws-sdk/util-endpoints": "3.848.0", - "@aws-sdk/util-user-agent-browser": "3.840.0", - "@aws-sdk/util-user-agent-node": "3.858.0", - "@smithy/config-resolver": "^4.1.4", - "@smithy/core": "^3.7.2", - "@smithy/fetch-http-handler": "^5.1.0", - "@smithy/hash-node": "^4.0.4", - "@smithy/invalid-dependency": "^4.0.4", - "@smithy/middleware-content-length": "^4.0.4", - "@smithy/middleware-endpoint": "^4.1.17", - "@smithy/middleware-retry": "^4.1.18", - "@smithy/middleware-serde": "^4.0.8", - "@smithy/middleware-stack": "^4.0.4", - "@smithy/node-config-provider": "^4.1.3", - "@smithy/node-http-handler": "^4.1.0", - "@smithy/protocol-http": "^5.1.2", - "@smithy/smithy-client": "^4.4.9", - "@smithy/types": "^4.3.1", - "@smithy/url-parser": "^4.0.4", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.25", - "@smithy/util-defaults-mode-node": "^4.0.25", - "@smithy/util-endpoints": "^3.0.6", - "@smithy/util-middleware": "^4.0.4", - "@smithy/util-retry": "^4.0.6", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": { - "version": "3.859.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.859.0.tgz", - "integrity": "sha512-6P2wlvm9KBWOvRNn0Pt8RntnXg8fzOb5kEShvWsOsAocZeqKNaYbihum5/Onq1ZPoVtkdb++8eWDocDnM4k85Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.858.0", - "@aws-sdk/nested-clients": "3.858.0", - "@aws-sdk/types": "3.840.0", - "@smithy/property-provider": "^4.0.4", - "@smithy/shared-ini-file-loader": "^4.0.4", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.858.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.858.0.tgz", - "integrity": "sha512-T1m05QlN8hFpx5/5duMjS8uFSK5e6EXP45HQRkZULVkL3DK+jMaxsnh3KLl5LjUoHn/19M4HM0wNUBhYp4Y2Yw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "3.858.0", - "@aws-sdk/types": "3.840.0", - "@smithy/node-config-provider": "^4.1.3", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, "node_modules/@aws-sdk/client-s3": { "version": "3.859.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.859.0.tgz", @@ -2778,16 +2454,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ioredis": { - "version": "4.28.10", - "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", - "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2806,7 +2472,6 @@ "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", "dev": true, - "license": "MIT", "dependencies": { "@types/express": "*" } @@ -6846,4 +6511,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 95c1b65..5ef5627 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,6 @@ "@types/cors": "^2.8.19", "@types/dotenv": "^8.2.3", "@types/express": "^5.0.3", -<<<<<<< HEAD - "@types/ioredis": "^4.28.10", -======= "@types/multer": "^2.0.0", "@types/multer-s3": "^3.0.3", "@types/node": "^24.1.0", @@ -68,4 +65,4 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.35.1" } -} +} \ No newline at end of file diff --git a/prisma/migrations/20250725072024_add_optional_video_id/migration.sql b/prisma/migrations/20250725072024_add_optional_video_id/migration.sql deleted file mode 100644 index f355cd1..0000000 --- a/prisma/migrations/20250725072024_add_optional_video_id/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `current_participants` on the `rooms` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE `rooms` DROP COLUMN `current_participants`, - ADD COLUMN `video_id` VARCHAR(20) NULL; - --- CreateIndex -CREATE INDEX `rooms_video_id_fkey` ON `rooms`(`video_id`); - --- AddForeignKey -ALTER TABLE `rooms` ADD CONSTRAINT `rooms_video_id_fkey` FOREIGN KEY (`video_id`) REFERENCES `youtube_videos`(`video_id`) ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/prisma/migrations/20250725074316_make_video_id_required/migration.sql b/prisma/migrations/20250725074316_make_video_id_required/migration.sql deleted file mode 100644 index 317ce68..0000000 --- a/prisma/migrations/20250725074316_make_video_id_required/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ -/* - Warnings: - - - Made the column `video_id` on table `rooms` required. This step will fail if there are existing NULL values in that column. - -*/ --- DropForeignKey -ALTER TABLE `rooms` DROP FOREIGN KEY `rooms_video_id_fkey`; - --- AlterTable -ALTER TABLE `rooms` MODIFY `video_id` VARCHAR(20) NOT NULL; - --- AddForeignKey -ALTER TABLE `rooms` ADD CONSTRAINT `rooms_video_id_fkey` FOREIGN KEY (`video_id`) REFERENCES `youtube_videos`(`video_id`) ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/prisma/migrations/20250730072811_add_timeline_and_start_fields/migration.sql b/prisma/migrations/20250730072811_add_timeline_and_start_fields/migration.sql deleted file mode 100644 index b2f86ea..0000000 --- a/prisma/migrations/20250730072811_add_timeline_and_start_fields/migration.sql +++ /dev/null @@ -1,49 +0,0 @@ -/* - Warnings: - - - You are about to alter the column `video_id` on the `rooms` table. The data in that column could be lost. The data in that column will be cast from `VarChar(191)` to `VarChar(20)`. - - Added the required column `updated_at` to the `collections` table without a default value. This is not possible if the table is not empty. - -*/ --- DropForeignKey -ALTER TABLE `rooms` DROP FOREIGN KEY `rooms_video_id_fkey`; - --- AlterTable -ALTER TABLE `bookmarks` ADD COLUMN `collection_id` INTEGER NULL, - ADD COLUMN `timeline` INTEGER NULL DEFAULT 0; - --- AlterTable -ALTER TABLE `collections` ADD COLUMN `cover_image` VARCHAR(500) NULL, - ADD COLUMN `updated_at` DATETIME(3) NOT NULL; - --- AlterTable -ALTER TABLE `room_participants` ADD COLUMN `last_joined_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - ADD COLUMN `total_stay_time` INTEGER NOT NULL DEFAULT 0; - --- AlterTable -ALTER TABLE `rooms` ADD COLUMN `startTime` INTEGER NOT NULL DEFAULT 0, - ADD COLUMN `startType` ENUM('BOOKMARK', 'BEGINNING') NOT NULL DEFAULT 'BEGINNING', - MODIFY `video_id` VARCHAR(20) NULL; - --- CreateTable -CREATE TABLE `user_feedbacks` ( - `feedback_id` INTEGER NOT NULL AUTO_INCREMENT, - `user_id` INTEGER NOT NULL, - `content` TEXT NOT NULL, - `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - - INDEX `user_feedbacks_user_id_fkey`(`user_id`), - PRIMARY KEY (`feedback_id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - --- CreateIndex -CREATE INDEX `bookmarks_collection_id_fkey` ON `bookmarks`(`collection_id`); - --- AddForeignKey -ALTER TABLE `rooms` ADD CONSTRAINT `rooms_video_id_fkey` FOREIGN KEY (`video_id`) REFERENCES `youtube_videos`(`video_id`) ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE `bookmarks` ADD CONSTRAINT `bookmarks_collection_id_fkey` FOREIGN KEY (`collection_id`) REFERENCES `collections`(`collection_id`) ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE `user_feedbacks` ADD CONSTRAINT `user_feedbacks_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250731114836_new_schema/migration.sql b/prisma/migrations/20250731114836_new_schema/migration.sql deleted file mode 100644 index 9f794c6..0000000 --- a/prisma/migrations/20250731114836_new_schema/migration.sql +++ /dev/null @@ -1,46 +0,0 @@ -/* - Warnings: - - - You are about to alter the column `video_id` on the `rooms` table. The data in that column could be lost. The data in that column will be cast from `VarChar(191)` to `VarChar(20)`. - - Added the required column `updated_at` to the `collections` table without a default value. This is not possible if the table is not empty. - -*/ --- DropForeignKey -ALTER TABLE `rooms` DROP FOREIGN KEY `rooms_video_id_fkey`; - --- AlterTable -ALTER TABLE `bookmarks` ADD COLUMN `collection_id` INTEGER NULL; - --- AlterTable -ALTER TABLE `collections` ADD COLUMN `cover_image` VARCHAR(500) NULL, - ADD COLUMN `updated_at` DATETIME(3) NOT NULL; - --- AlterTable -ALTER TABLE `room_participants` ADD COLUMN `last_joined_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - ADD COLUMN `total_stay_time` INTEGER NOT NULL DEFAULT 0; - --- AlterTable -ALTER TABLE `rooms` MODIFY `video_id` VARCHAR(20) NULL; - --- CreateTable -CREATE TABLE `user_feedbacks` ( - `feedback_id` INTEGER NOT NULL AUTO_INCREMENT, - `user_id` INTEGER NOT NULL, - `content` TEXT NOT NULL, - `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - - INDEX `user_feedbacks_user_id_fkey`(`user_id`), - PRIMARY KEY (`feedback_id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - --- CreateIndex -CREATE INDEX `bookmarks_collection_id_fkey` ON `bookmarks`(`collection_id`); - --- AddForeignKey -ALTER TABLE `rooms` ADD CONSTRAINT `rooms_video_id_fkey` FOREIGN KEY (`video_id`) REFERENCES `youtube_videos`(`video_id`) ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE `bookmarks` ADD CONSTRAINT `bookmarks_collection_id_fkey` FOREIGN KEY (`collection_id`) REFERENCES `collections`(`collection_id`) ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE `user_feedbacks` ADD CONSTRAINT `user_feedbacks_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/services/userRecommendationService.ts b/src/services/userRecommendationService.ts index 6a496cd..7e62614 100644 --- a/src/services/userRecommendationService.ts +++ b/src/services/userRecommendationService.ts @@ -22,14 +22,24 @@ export class RecommendationService { throw new Error('이미 오늘 추천을 보냈습니다.'); } - // 추천 생성 - await prisma.dailyRecommendation.create({ - data: { - recommenderId: userId, - recommendedUserId: dto.targetUserId, - recommendationDate: today, - }, - }); + // 추천 생성 + 인기도 증가 (트랜잭션 처리) + await prisma.$transaction([ + prisma.dailyRecommendation.create({ + data: { + recommenderId: userId, + recommendedUserId: dto.targetUserId, + recommendationDate: today, + }, + }), + prisma.user.update({ + where: { userId: dto.targetUserId }, + data: { + popularity: { + increment: 1, + }, + }, + }), + ]); return { success: true, From e7c88242d9c01c6acc5de5fbffba885eaa1626c3 Mon Sep 17 00:00:00 2001 From: kdy Date: Wed, 6 Aug 2025 18:51:51 +0900 Subject: [PATCH 49/59] =?UTF-8?q?Feat:=20=EA=B3=B5=EC=9C=A0=20=EC=BB=AC?= =?UTF-8?q?=EB=A0=89=EC=85=98=20=EC=88=98=EB=9D=BD/=EA=B1=B0=EC=A0=88=20AP?= =?UTF-8?q?I=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/sharedCollectionController.ts | 30 +++++++ src/dtos/sharedCollectionDto.ts | 4 + src/routes/sharedCollectionRoute.ts | 50 +++++++++++- src/services/sharedCollectionService.ts | 78 ++++++++++++++++++- 4 files changed, 160 insertions(+), 2 deletions(-) diff --git a/src/controllers/sharedCollectionController.ts b/src/controllers/sharedCollectionController.ts index a48e5de..3d2c5a7 100644 --- a/src/controllers/sharedCollectionController.ts +++ b/src/controllers/sharedCollectionController.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { SharedCollectionService } from '../services/sharedCollectionService.js'; +import { SharedCollectionActionDto } from '../dtos/sharedCollectionDto.js'; const service = new SharedCollectionService(); @@ -20,3 +21,32 @@ export const getReceivedCollections = async (req: Request, res: Response): Promi res.status(500).json({ success: false, message: errorMessage }); } }; + +// 2. 공유 컬렉션 수락/거절 처리 +export const respondToSharedCollection = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.userId; + const sharedCollectionId = Number(req.params.sharedCollectionId); + const { action }: SharedCollectionActionDto = req.body; + + if (!userId) { + res.status(401).json({ success: false, message: '로그인이 필요합니다.' }); + return; + } + + if (!['ACCEPT', 'REJECT'].includes(action)) { + res.status(400).json({ success: false, message: '유효하지 않은 action입니다.' }); + return; + } + + const message = await service.respondToSharedCollection( + userId, + Number(sharedCollectionId), + action, + ); + res.status(200).json({ success: true, message }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Internal Server Error'; + res.status(500).json({ success: false, message: errorMessage }); + } +}; diff --git a/src/dtos/sharedCollectionDto.ts b/src/dtos/sharedCollectionDto.ts index c8844d0..87f84d2 100644 --- a/src/dtos/sharedCollectionDto.ts +++ b/src/dtos/sharedCollectionDto.ts @@ -9,3 +9,7 @@ export class SharedCollectionResponseDto { bookmarkCount!: number; sharedAt!: Date; } + +export class SharedCollectionActionDto { + action!: SharedCollectionAction; +} diff --git a/src/routes/sharedCollectionRoute.ts b/src/routes/sharedCollectionRoute.ts index e323814..14421c5 100644 --- a/src/routes/sharedCollectionRoute.ts +++ b/src/routes/sharedCollectionRoute.ts @@ -1,5 +1,8 @@ import { Router } from 'express'; -import { getReceivedCollections } from '../controllers/sharedCollectionController.js'; +import { + getReceivedCollections, + respondToSharedCollection, +} from '../controllers/sharedCollectionController.js'; import { requireAuth } from '../middleware/authMiddleware.js'; // 인증 미들웨어 경로에 맞게 수정 필요 const router = Router(); @@ -61,4 +64,49 @@ const router = Router(); // 1. 공유받은 컬렉션 목록 조회 (GET /api/shared-collections) router.get('/', requireAuth, getReceivedCollections); +/** + * @swagger + * /api/shared-collections/{sharedCollectionId}: + * put: + * summary: 공유받은 컬렉션 수락 또는 거절 + * description: 공유받은 컬렉션을 수락하거나 거절합니다. + * tags: [SharedCollections] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: sharedCollectionId + * required: true + * schema: + * type: integer + * description: 공유받은 컬렉션의 ID (share_id 필드) + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * action: + * type: string + * enum: [ACCEPT, REJECT] + * example: ACCEPT + * responses: + * 200: + * description: 공유 컬렉션 수락 또는 거절 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * message: + * type: string + * example: 컬렉션을 수락했습니다. + */ +// 2. 공유 컬렉션 수락/거절 (PUT /api/shared-collections/:sharedCollectionId) +router.put('/:sharedCollectionId', requireAuth, respondToSharedCollection); + export default router; diff --git a/src/services/sharedCollectionService.ts b/src/services/sharedCollectionService.ts index d8c2a72..e82693e 100644 --- a/src/services/sharedCollectionService.ts +++ b/src/services/sharedCollectionService.ts @@ -1,8 +1,9 @@ import { prisma } from '../lib/prisma.js'; import { SharedCollectionResponseDto } from '../dtos/sharedCollectionDto.js'; +import AppError from '../middleware/errors/AppError.js'; export class SharedCollectionService { - // 공유받은 컬렉션 목록 조회 + // 1. 공유받은 컬렉션 목록 조회 async getReceivedCollections(userId: number): Promise { const sharedCollections = await prisma.sharedCollection.findMany({ where: { sharedToUserId: userId }, @@ -28,4 +29,79 @@ export class SharedCollectionService { sharedAt: sc.createdAt, })); } + + // 2. 공유 컬렉션 수락/거절 처리 + async respondToSharedCollection( + userId: number, + sharedCollectionId: number, + action: 'ACCEPT' | 'REJECT', + ): Promise { + const shared = await prisma.sharedCollection.findUnique({ + where: { shareId: sharedCollectionId }, + include: { collection: true }, + }); + + if (!shared || shared.sharedToUserId !== userId) { + throw new AppError('해당 공유 정보를 찾을 수 없거나 권한이 없습니다.'); + } + + if (action === 'REJECT') { + await prisma.sharedCollection.delete({ + where: { shareId: sharedCollectionId }, + }); + return '컬렉션을 거절했습니다.'; + } + + if (action === 'ACCEPT') { + // 1. 복사된 컬렉션 생성 + const copiedCollection = await prisma.collection.create({ + data: { + userId, + title: shared.collection.title, + description: shared.collection.description, + visibility: 'private', + originalCollectionId: shared.collection.collectionId, + coverImage: shared.collection.coverImage, + }, + }); + + // 2. 원본 컬렉션 ID 저장 + const originalCollectionId = shared.collection.collectionId; + + // 3. 북마크 복사 + const bookmarks = await prisma.bookmark.findMany({ + where: { collectionId: originalCollectionId }, + include: { + room: { + select: { + videoId: true, + }, + }, + }, + }); + + // 4. 북마크 생성할 데이터 생성 + const copiedBookmarks = bookmarks.map(b => ({ + userId: userId, + roomId: b.roomId, + collectionId: copiedCollection.collectionId, + timeline: b.timeline, + content: b.content, + originalBookmarkId: b.bookmarkId, + })); + + await prisma.bookmark.createMany({ + data: copiedBookmarks, + }); + + // 5. 공유 기록 삭제 + await prisma.sharedCollection.delete({ + where: { shareId: sharedCollectionId }, + }); + + return '컬렉션을 수락했습니다.'; + } + + throw new AppError('잘못된 요청입니다.'); + } } From 66270d44ef07c716fd0331d4cf97320b23cee867 Mon Sep 17 00:00:00 2001 From: kdy Date: Wed, 6 Aug 2025 23:45:59 +0900 Subject: [PATCH 50/59] chore: resolve merge conflict with upstream main --- src/controllers/sharedCollectionController.ts | 3 +++ src/dtos/sharedCollectionDto.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/controllers/sharedCollectionController.ts b/src/controllers/sharedCollectionController.ts index 3d2c5a7..814480f 100644 --- a/src/controllers/sharedCollectionController.ts +++ b/src/controllers/sharedCollectionController.ts @@ -21,6 +21,7 @@ export const getReceivedCollections = async (req: Request, res: Response): Promi res.status(500).json({ success: false, message: errorMessage }); } }; +<<<<<<< HEAD // 2. 공유 컬렉션 수락/거절 처리 export const respondToSharedCollection = async (req: Request, res: Response): Promise => { @@ -50,3 +51,5 @@ export const respondToSharedCollection = async (req: Request, res: Response): Pr res.status(500).json({ success: false, message: errorMessage }); } }; +======= +>>>>>>> e144817243cd7f05bcb35bd1c486c7cdfae22c0f diff --git a/src/dtos/sharedCollectionDto.ts b/src/dtos/sharedCollectionDto.ts index 87f84d2..f6d8447 100644 --- a/src/dtos/sharedCollectionDto.ts +++ b/src/dtos/sharedCollectionDto.ts @@ -9,7 +9,10 @@ export class SharedCollectionResponseDto { bookmarkCount!: number; sharedAt!: Date; } +<<<<<<< HEAD export class SharedCollectionActionDto { action!: SharedCollectionAction; } +======= +>>>>>>> e144817243cd7f05bcb35bd1c486c7cdfae22c0f From 00f80fb6d5ca4b596e0b61603e88fa7af1556025 Mon Sep 17 00:00:00 2001 From: ekdbss <136617606+ekdbss@users.noreply.github.com> Date: Wed, 6 Aug 2025 17:46:47 +0900 Subject: [PATCH 51/59] =?UTF-8?q?Feature:=20sharedCollection=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84=20(#7?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Youtube 영상 검색 api 구현 * feat: YouTube 영상 상세 조회 API 구현 및 DB 저장 기능 추가 * Fix: Add authentication middleware (requireAuth) to search route * Chore: remove unnecessary folder * Chore: format code using Prettier * Update src/controllers/youtubeDetailController.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Chore: format code using Prettier * fix: add .js extensions to import paths in controller and route for deployment * Fix: resolved merge conflict after pulling from upstream main * feat: Youtube 영상 검색 api 구현 * feat: YouTube 영상 상세 조회 API 구현 및 DB 저장 기능 추가 * Fix: Add authentication middleware (requireAuth) to search route * Chore: remove unnecessary folder * Chore: format code using Prettier * Update src/controllers/youtubeDetailController.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Chore: format code using Prettier * fix: add .js extensions to import paths in controller and route for deployment * Fix: resolved merge conflict after pulling from upstream main * Feat: bookmark API 개발 및 schema 수정 * Feat: Bookmark message, timeline parsing을 위한 util 함수 추가 * Fix: 북마크 생성 시 parsing 함수 적용 및 timeline 분리 처리 * Update src/dtos/bookmarkDto.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/controllers/bookmarkController.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Refactor: 북마크 기반 방 생성 로직 정리 및 유효성 검사 강화 * feat: Add validation for maxParticipants in createRoomFromBookmark * Add /api prefix to bookmark-related endpoints * Refactor: Move Prisma client import to lib/prisma.js * Feat: 사용자 추천 관련 api 구현 * Chore: Remove unintended sharedCollection-related files * Feat: 공유받은 컬렉션 목록 조회 API 구현 및 upstream main 병합 - upstream main 브랜치 최신 내용 pull 및 병합 완료 - 공유받은 컬렉션 목록 조회 기능 추가 * Fix: Remove unrelated code for collection acceptance/rejection, keep shared collection list API --------- Co-authored-by: DongilMin Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- package-lock.json | 10 ++++++++++ src/controllers/sharedCollectionController.ts | 3 --- src/dtos/sharedCollectionDto.ts | 5 +---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 418e7c8..fa411ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2454,6 +2454,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ioredis": { + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/src/controllers/sharedCollectionController.ts b/src/controllers/sharedCollectionController.ts index 814480f..3d2c5a7 100644 --- a/src/controllers/sharedCollectionController.ts +++ b/src/controllers/sharedCollectionController.ts @@ -21,7 +21,6 @@ export const getReceivedCollections = async (req: Request, res: Response): Promi res.status(500).json({ success: false, message: errorMessage }); } }; -<<<<<<< HEAD // 2. 공유 컬렉션 수락/거절 처리 export const respondToSharedCollection = async (req: Request, res: Response): Promise => { @@ -51,5 +50,3 @@ export const respondToSharedCollection = async (req: Request, res: Response): Pr res.status(500).json({ success: false, message: errorMessage }); } }; -======= ->>>>>>> e144817243cd7f05bcb35bd1c486c7cdfae22c0f diff --git a/src/dtos/sharedCollectionDto.ts b/src/dtos/sharedCollectionDto.ts index f6d8447..65b1198 100644 --- a/src/dtos/sharedCollectionDto.ts +++ b/src/dtos/sharedCollectionDto.ts @@ -9,10 +9,7 @@ export class SharedCollectionResponseDto { bookmarkCount!: number; sharedAt!: Date; } -<<<<<<< HEAD export class SharedCollectionActionDto { action!: SharedCollectionAction; -} -======= ->>>>>>> e144817243cd7f05bcb35bd1c486c7cdfae22c0f +} \ No newline at end of file From a7127001a80f0563c4477ac9fc378a1ade762aa5 Mon Sep 17 00:00:00 2001 From: kdy Date: Wed, 6 Aug 2025 23:51:00 +0900 Subject: [PATCH 52/59] chore: resolve merge conflict with upstream main --- src/dtos/sharedCollectionDto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dtos/sharedCollectionDto.ts b/src/dtos/sharedCollectionDto.ts index 65b1198..87f84d2 100644 --- a/src/dtos/sharedCollectionDto.ts +++ b/src/dtos/sharedCollectionDto.ts @@ -12,4 +12,4 @@ export class SharedCollectionResponseDto { export class SharedCollectionActionDto { action!: SharedCollectionAction; -} \ No newline at end of file +} From ab4b022661d32e6c14ed20fb60f441bb00f240ff Mon Sep 17 00:00:00 2001 From: kdy Date: Sat, 9 Aug 2025 15:06:07 +0900 Subject: [PATCH 53/59] chore: resolve merge conflicts after pulling upstream main --- package-lock.json | 2 +- prisma/schema.prisma | 31 +++++++++++++++---------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 898ad0a..1fd8c00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6943,4 +6943,4 @@ } } } -} \ No newline at end of file +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2fcf15a..5852e58 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -76,15 +76,15 @@ model Friendship { } model UserBlock { - blockId Int @id @default(autoincrement()) @map("block_id") - blockerUserId Int @map("blocker_user_id") - blockedUserId Int @map("blocked_user_id") - blockedAt DateTime @default(now()) @map("blocked_at") - isActive Boolean @default(true) @map("is_active") - blocked User @relation("BlockedUser", fields: [blockedUserId], references: [userId], onDelete: Cascade) - blocker User @relation("BlockerUser", fields: [blockerUserId], references: [userId], onDelete: Cascade) - customReason String? - reportReasons UserBlockReason[] + blockId Int @id @default(autoincrement()) @map("block_id") + blockerUserId Int @map("blocker_user_id") + blockedUserId Int @map("blocked_user_id") + blockedAt DateTime @default(now()) @map("blocked_at") + isActive Boolean @default(true) @map("is_active") + customReason String? + reportReasons UserBlockReason[] + blocked User @relation("BlockedUser", fields: [blockedUserId], references: [userId], onDelete: Cascade) + blocker User @relation("BlockerUser", fields: [blockerUserId], references: [userId], onDelete: Cascade) @@unique([blockerUserId, blockedUserId], name: "unique_block") @@index([blockedUserId], map: "user_blocks_blocked_user_id_fkey") @@ -92,11 +92,12 @@ model UserBlock { } model UserBlockReason { - userBlockReasonId Int @id @default(autoincrement())@map("user_block_reason_id") - block UserBlock @relation(fields: [blockId], references: [blockId], onDelete: Cascade) - blockId Int @map("block_id") - reason reportReason + userBlockReasonId Int @id @default(autoincrement()) @map("user_block_reason_id") + blockId Int @map("block_id") + reason reportReason + block UserBlock @relation(fields: [blockId], references: [blockId], onDelete: Cascade) + @@index([blockId], map: "user_block_reasons_block_id_fkey") @@map("user_block_reasons") } @@ -121,7 +122,6 @@ model Room { messages RoomMessage[] participants RoomParticipant[] host User @relation("HostUser", fields: [hostId], references: [userId], onDelete: Cascade) - video YoutubeVideo @relation("VideoOnRoom", fields: [videoId], references: [videoId]) @@index([hostId], map: "rooms_host_id_fkey") @@index([videoId], map: "rooms_video_id_fkey") @@ -304,7 +304,6 @@ model YoutubeVideo { duration String? @db.VarChar(20) uploadedAt DateTime? @map("uploaded_at") createdAt DateTime @default(now()) @map("created_at") - rooms Room[] @relation("VideoOnRoom") @@map("youtube_videos") } @@ -392,4 +391,4 @@ enum reportReason { PROFANITY HATE ETC -} \ No newline at end of file +} From 0908b6536aa118efaa492a3dc7437e4a7e182c86 Mon Sep 17 00:00:00 2001 From: kdy Date: Thu, 14 Aug 2025 19:35:44 +0900 Subject: [PATCH 54/59] =?UTF-8?q?fix:=20bookmarkRoute.ts=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/bookmarkRoute.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/routes/bookmarkRoute.ts b/src/routes/bookmarkRoute.ts index 1f10b8e..450d7d4 100644 --- a/src/routes/bookmarkRoute.ts +++ b/src/routes/bookmarkRoute.ts @@ -143,12 +143,9 @@ router.post('/', requireAuth, createBookmark); * roomId: * type: number * example: 393 -<<<<<<< HEAD * roomName: * type: string * example: "방이름2" -======= ->>>>>>> f77acd4bd44d2a4333910a342248dad3de6b9657 * videoTitle: * type: string * example: "영상제목" From 13ecaa3e6c78b1fa93480e611106186fd6ae6b9a Mon Sep 17 00:00:00 2001 From: kdy Date: Tue, 19 Aug 2025 17:55:29 +0900 Subject: [PATCH 55/59] =?UTF-8?q?fix:=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EB=AC=B8=EC=A0=9C=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=EB=B6=81=EB=A7=88=ED=81=AC=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=9D=91=EB=8B=B5=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/collectionRoutes.ts | 71 ++++++++++++++++++++----------- src/services/collectionService.ts | 2 +- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/routes/collectionRoutes.ts b/src/routes/collectionRoutes.ts index 0dc437c..a1e705e 100644 --- a/src/routes/collectionRoutes.ts +++ b/src/routes/collectionRoutes.ts @@ -104,6 +104,51 @@ router.get('/', requireAuth, collectionController.getCollections); */ router.get('/:collectionId', requireAuth, collectionController.getCollectionDetail); +/** + * @swagger + * /api/collections/order: + * put: + * summary: 컬렉션 순서 변경 + * tags: [Collections] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ReorderCollectionsDto' + * example: + * collectionOrders: + * - collectionId: 123 + * order: 1 + * - collectionId: 124 + * order: 2 + * responses: + * 200: + * description: 컬렉션 순서 변경 성공 + * + * components: + * schemas: + * ReorderCollectionsDto: + * type: object + * properties: + * collectionOrders: + * type: array + * items: + * type: object + * properties: + * collectionId: + * type: integer + * example: 123 + * order: + * type: integer + * example: 1 + * required: + * - collectionOrders + */ +router.put('/order', requireAuth, collectionController.updateCollectionOrder); + /** * @swagger * /api/collections/{collectionId}: @@ -154,32 +199,6 @@ router.put('/:collectionId', requireAuth, collectionController.updateCollection) */ router.delete('/:collectionId', requireAuth, collectionController.deleteCollection); -/** - * @swagger - * /api/collections/order: - * put: - * summary: 컬렉션 순서 변경 - * tags: [Collections] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ReorderCollectionsDto' - * example: - * collectionOrders: - * - collectionId: 123 - * order: 1 - * - collectionId: 124 - * order: 2 - * responses: - * 200: - * description: 컬렉션 순서 변경 성공 - */ -router.put('/order', requireAuth, collectionController.updateCollectionOrder); - /** * 컬렉션 공유하기 */ diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index 1ceb7a9..bba5328 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -208,4 +208,4 @@ export const updateCollectionOrder = async ( }), ), ); -}; +}; \ No newline at end of file From 4765ac5bdefb758a20895b5ce4887ab1ba322c7b Mon Sep 17 00:00:00 2001 From: kdy Date: Tue, 19 Aug 2025 17:55:29 +0900 Subject: [PATCH 56/59] =?UTF-8?q?fix:=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EC=B6=A9=EB=8F=8C=EB=A1=9C=20=EC=BB=AC?= =?UTF-8?q?=EB=A0=89=EC=85=98=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?API=20=EB=8F=99=EC=9E=91=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/collectionRoutes.ts | 71 ++++++++++++++++++++----------- src/services/collectionService.ts | 2 +- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/routes/collectionRoutes.ts b/src/routes/collectionRoutes.ts index 0dc437c..a1e705e 100644 --- a/src/routes/collectionRoutes.ts +++ b/src/routes/collectionRoutes.ts @@ -104,6 +104,51 @@ router.get('/', requireAuth, collectionController.getCollections); */ router.get('/:collectionId', requireAuth, collectionController.getCollectionDetail); +/** + * @swagger + * /api/collections/order: + * put: + * summary: 컬렉션 순서 변경 + * tags: [Collections] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ReorderCollectionsDto' + * example: + * collectionOrders: + * - collectionId: 123 + * order: 1 + * - collectionId: 124 + * order: 2 + * responses: + * 200: + * description: 컬렉션 순서 변경 성공 + * + * components: + * schemas: + * ReorderCollectionsDto: + * type: object + * properties: + * collectionOrders: + * type: array + * items: + * type: object + * properties: + * collectionId: + * type: integer + * example: 123 + * order: + * type: integer + * example: 1 + * required: + * - collectionOrders + */ +router.put('/order', requireAuth, collectionController.updateCollectionOrder); + /** * @swagger * /api/collections/{collectionId}: @@ -154,32 +199,6 @@ router.put('/:collectionId', requireAuth, collectionController.updateCollection) */ router.delete('/:collectionId', requireAuth, collectionController.deleteCollection); -/** - * @swagger - * /api/collections/order: - * put: - * summary: 컬렉션 순서 변경 - * tags: [Collections] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ReorderCollectionsDto' - * example: - * collectionOrders: - * - collectionId: 123 - * order: 1 - * - collectionId: 124 - * order: 2 - * responses: - * 200: - * description: 컬렉션 순서 변경 성공 - */ -router.put('/order', requireAuth, collectionController.updateCollectionOrder); - /** * 컬렉션 공유하기 */ diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index 1ceb7a9..bba5328 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -208,4 +208,4 @@ export const updateCollectionOrder = async ( }), ), ); -}; +}; \ No newline at end of file From fb5fa8520a57af427f42f12a6484d34038d092ae Mon Sep 17 00:00:00 2001 From: kdy Date: Wed, 20 Aug 2025 00:43:37 +0900 Subject: [PATCH 57/59] chore: format code using project formatter --- src/services/collectionService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index bba5328..1ceb7a9 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -208,4 +208,4 @@ export const updateCollectionOrder = async ( }), ), ); -}; \ No newline at end of file +}; From 3ca87fa685c75637df3816bc0ad003348d132aeb Mon Sep 17 00:00:00 2001 From: kdy Date: Wed, 20 Aug 2025 00:49:21 +0900 Subject: [PATCH 58/59] chore: sync Prisma schema and regenerate client --- prisma/schema.prisma | 2 ++ 1 file changed, 2 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c80db7d..0eca298 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -122,6 +122,7 @@ model Room { messages RoomMessage[] participants RoomParticipant[] host User @relation("HostUser", fields: [hostId], references: [userId], onDelete: Cascade) + youtube_videos YoutubeVideo @relation(fields: [videoId], references: [videoId]) @@index([hostId], map: "rooms_host_id_fkey") @@index([videoId], map: "rooms_video_id_fkey") @@ -305,6 +306,7 @@ model YoutubeVideo { duration String? @db.VarChar(20) uploadedAt DateTime? @map("uploaded_at") createdAt DateTime @default(now()) @map("created_at") + rooms Room[] @@map("youtube_videos") } From 7d88be84fb5c8da415e6de7fcb074d1cbe94fb68 Mon Sep 17 00:00:00 2001 From: kdy Date: Wed, 20 Aug 2025 15:13:41 +0900 Subject: [PATCH 59/59] =?UTF-8?q?fix:=20Prisma=20relation=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EB=B3=80=EA=B2=BD(video=20=E2=86=92=20you?= =?UTF-8?q?tube=5Fvideos)=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/bookmarkService.ts | 12 ++++++------ src/services/collectionService.ts | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index e30a964..a8b91ea 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -43,7 +43,7 @@ type BookmarkWithRelations = Prisma.BookmarkGetPayload<{ include: { room: { include: { - video: true; // Room.video (YoutubeVideo) + youtube_videos: true; // Room.video (YoutubeVideo) }; }; collection: { @@ -71,7 +71,7 @@ export const getBookmarks = async (userId: number, options: GetBookmarksOptions) const rows: BookmarkWithRelations[] = await prisma.bookmark.findMany({ where: baseWhere, include: { - room: { include: { video: true } }, + room: { include: { youtube_videos: true } }, collection: { select: { title: true } }, }, orderBy: { createdAt: 'desc' }, @@ -108,8 +108,8 @@ export const getBookmarks = async (userId: number, options: GetBookmarksOptions) for (const b of list) { const roomId = b.roomId; const roomName = b.room?.roomName ?? null; - const videoTitle = b.room?.video?.title ?? null; - const videoThumbnail = b.room?.video?.thumbnail ?? null; + const videoTitle = b.room?.youtube_videos?.title ?? null; + const videoThumbnail = b.room?.youtube_videos?.thumbnail ?? null; const bookmarkCollectionTitle = b.collection?.title ?? null; let idx = map.get(roomId); @@ -246,7 +246,7 @@ export const createRoomFromBookmark = async ( include: { room: { include: { - video: true, + youtube_videos: true, }, }, }, @@ -256,7 +256,7 @@ export const createRoomFromBookmark = async ( throw new Error('해당 북마크에 대한 권한이 없습니다.'); } - const videoThumbnail = bookmark.room?.video?.thumbnail ?? ''; + const videoThumbnail = bookmark.room?.youtube_videos?.thumbnail ?? ''; const startTime = startFrom === 'BOOKMARK' ? (bookmark?.timeline ?? 0) : 0; if (!bookmark.room?.videoId) { diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index b0b3b53..938ee6f 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -63,7 +63,7 @@ export const getCollectionDetailById = async ( include: { room: { include: { - video: true, + youtube_videos: true, }, }, }, @@ -107,8 +107,8 @@ export const getCollectionDetailById = async ( acc[room.roomId] = { roomId: room.roomId, roomTitle: room.roomName, - videoTitle: room.video.title, - videoThumbnail: room.video.thumbnail || '', + videoTitle: room.youtube_videos.title, + videoThumbnail: room.youtube_videos.thumbnail || '', bookmarks: [], }; }