diff --git a/example.env b/example.env deleted file mode 100644 index 5e9ba1e..0000000 --- a/example.env +++ /dev/null @@ -1,3 +0,0 @@ -DB_URL=mongodb 주소 - -PORT=포트번호 diff --git a/get-s3.js b/get-s3.js new file mode 100644 index 0000000..b3b809c --- /dev/null +++ b/get-s3.js @@ -0,0 +1 @@ +const AWS = require('aws-sdk') \ No newline at end of file diff --git a/src/controller/meme.controller.ts b/src/controller/meme.controller.ts index 8d365fc..7f37703 100644 --- a/src/controller/meme.controller.ts +++ b/src/controller/meme.controller.ts @@ -40,25 +40,18 @@ const getMeme = async (req: Request, res: Response, next: NextFunction) => { } }; -const getMemeWithKeywords = async (req: Request, res: Response, next: NextFunction) => { - const memeId = req.params?.memeId || null; - - if (_.isNull(memeId)) { - return next(new CustomError(`'memeId' field should be provided`, HttpCode.BAD_REQUEST)); - } - - if (!mongoose.Types.ObjectId.isValid(memeId)) { - return next(new CustomError(`'memeId' is not a valid ObjectId`, HttpCode.BAD_REQUEST)); - } +const getMemeWithKeywords = async (req: CustomRequest, res: Response, next: NextFunction) => { + const user = req.requestedUser; + const meme = req.requestedMeme; try { - const meme = await MemeService.getMemeWithKeywords(memeId); - if (_.isNull(meme)) { - return next(new CustomError(`Meme(${memeId}) not found.`, HttpCode.NOT_FOUND)); + const ret = await MemeService.getMemeWithKeywords(user, meme); + if (_.isNull(ret)) { + return next(new CustomError(`Meme(${meme._id}) not found.`, HttpCode.NOT_FOUND)); } - logger.info(`Get meme with keywords - ${memeId})`); - return res.json(createSuccessResponse(HttpCode.OK, 'Get Meme', meme)); + logger.info(`Get meme with keywords - ${meme._id})`); + return res.json(createSuccessResponse(HttpCode.OK, 'Get Meme', ret)); } catch (err) { return next(new CustomError(err.message, err.status)); } @@ -120,7 +113,8 @@ const deleteMeme = async (req: CustomRequest, res: Response, next: NextFunction) } }; -const getAllMemeList = async (req: Request, res: Response, next: NextFunction) => { +const getAllMemeList = async (req: CustomRequest, res: Response, next: NextFunction) => { + const user = req.requestedUser; const page = parseInt(req.query.page as string) || 1; if (page < 1) { return next(new CustomError(`Invalid 'page' parameter`, HttpCode.BAD_REQUEST)); @@ -132,7 +126,7 @@ const getAllMemeList = async (req: Request, res: Response, next: NextFunction) = } try { - const memeList = await MemeService.getAllMemeList(page, size); + const memeList = await MemeService.getAllMemeList(page, size, user); const data = { pagination: { @@ -151,7 +145,8 @@ const getAllMemeList = async (req: Request, res: Response, next: NextFunction) = } }; -const getTodayMemeList = async (req: Request, res: Response, next: NextFunction) => { +const getTodayMemeList = async (req: CustomRequest, res: Response, next: NextFunction) => { + const user = req.requestedUser; const size = parseInt(req.query.size as string) || 5; if (size > 5) { @@ -164,7 +159,7 @@ const getTodayMemeList = async (req: Request, res: Response, next: NextFunction) } try { - const todayMemeList = await MemeService.getTodayMemeList(size); + const todayMemeList = await MemeService.getTodayMemeList(size, user); return res.json(createSuccessResponse(HttpCode.OK, 'Get today meme list', todayMemeList)); } catch (err) { return next(new CustomError(err.message, err.status)); @@ -172,6 +167,7 @@ const getTodayMemeList = async (req: Request, res: Response, next: NextFunction) }; const searchMemeListByKeyword = async (req: CustomRequest, res: Response, next: NextFunction) => { + const user = req.requestedUser; const keyword = req.requestedKeyword; const page = parseInt(req.query.page as string) || 1; @@ -185,7 +181,7 @@ const searchMemeListByKeyword = async (req: CustomRequest, res: Response, next: } try { - const memeList = await MemeService.searchMemeByKeyword(page, size, keyword); + const memeList = await MemeService.searchMemeByKeyword(page, size, keyword, user); const data = { pagination: { total: memeList.total, diff --git a/src/controller/user.controller.ts b/src/controller/user.controller.ts index 69ad3f7..33400ed 100644 --- a/src/controller/user.controller.ts +++ b/src/controller/user.controller.ts @@ -4,7 +4,6 @@ import _ from 'lodash'; import CustomError from '../errors/CustomError'; import { HttpCode } from '../errors/HttpCode'; import { CustomRequest } from '../middleware/requestedInfo'; -import { InteractionType, MemeInteractionModel } from '../model/memeInteraction'; import * as UserService from '../service/user.service'; import { createSuccessResponse } from '../util/response'; @@ -26,48 +25,26 @@ const getUser = async (req: CustomRequest, res: Response, next: NextFunction) => const user = req.requestedUser; try { - const countInteractionType = (type: InteractionType) => - MemeInteractionModel.countDocuments({ - deviceId: user.deviceId, - interactionType: type, - }); - - const [watch, reaction, share, save] = await Promise.all([ - countInteractionType(InteractionType.WATCH), - countInteractionType(InteractionType.REACTION), - countInteractionType(InteractionType.SHARE), - countInteractionType(InteractionType.SAVE), - ]); - - const level = getLevel(watch, reaction, share); - - return res.json( - createSuccessResponse(HttpCode.OK, 'Get User', { - ...user, - watch, - reaction, - share, - save, - level, - }), - ); + const userInfos = await UserService.makeUserInfos(user.deviceId); + const level = getLevel(userInfos.watch, userInfos.reaction, userInfos.share); + return res.json(createSuccessResponse(HttpCode.OK, 'Get User', { ...userInfos, level })); } catch (err) { return next(new CustomError(err.message, err.status)); } }; -const getLastSeenMemes = async (req: CustomRequest, res: Response, next: NextFunction) => { +const getLastSeenMemeList = async (req: CustomRequest, res: Response, next: NextFunction) => { const user = req.requestedUser; try { - const memeList = await UserService.getLastSeenMemes(user); + const memeList = await UserService.getLastSeenMemeList(user); return res.json(createSuccessResponse(HttpCode.OK, 'Get Last Seen Meme', memeList)); } catch (err) { return next(new CustomError(err.message, err.status)); } }; -const getSavedMemes = async (req: CustomRequest, res: Response, next: NextFunction) => { +const getSavedMemeList = async (req: CustomRequest, res: Response, next: NextFunction) => { const user = req.requestedUser; const page = parseInt(req.query.page as string) || 1; @@ -81,7 +58,7 @@ const getSavedMemes = async (req: CustomRequest, res: Response, next: NextFuncti } try { - const memeList = await UserService.getSavedMemes(page, size, user); + const memeList = await UserService.getSavedMemeList(page, size, user); const data = { pagination: { @@ -99,7 +76,7 @@ const getSavedMemes = async (req: CustomRequest, res: Response, next: NextFuncti } }; -export { getUser, createUser, getLastSeenMemes, getSavedMemes }; +export { getUser, createUser, getLastSeenMemeList, getSavedMemeList }; function getLevel(watch: number, reaction: number, share: number): number { let level = 1; diff --git a/src/middleware/requestedInfo.ts b/src/middleware/requestedInfo.ts index 0527baa..ea6841e 100644 --- a/src/middleware/requestedInfo.ts +++ b/src/middleware/requestedInfo.ts @@ -56,8 +56,7 @@ export const getKeywordInfoByName = async ( } const keyword = await getKeywordByName(keywordName); - - if (!keyword) { + if (_.isNull(keyword)) { return next( new CustomError(`Keyword with name ${keywordName} does not exist`, HttpCode.NOT_FOUND), ); @@ -100,7 +99,7 @@ export const getRequestedUserInfo = async ( const user = await getUser(deviceId); - if (!user) { + if (_.isNull(user)) { return next(new CustomError(`user(${deviceId}) does not exist`, HttpCode.NOT_FOUND)); } diff --git a/src/model/keyword.ts b/src/model/keyword.ts index f7a9658..b646dc0 100644 --- a/src/model/keyword.ts +++ b/src/model/keyword.ts @@ -14,6 +14,11 @@ export interface IKeywordWithImage extends IKeyword { topReactionImage: string; } +export interface IKeywordGetResponse { + _id: Types.ObjectId; + name: string; +} + export interface IKeyword { name: string; category: string; diff --git a/src/model/meme.ts b/src/model/meme.ts index f2a51b3..e350606 100644 --- a/src/model/meme.ts +++ b/src/model/meme.ts @@ -1,5 +1,7 @@ import mongoose, { Schema, Document, Types } from 'mongoose'; +import { IKeywordGetResponse } from './keyword'; + export interface IMemeCreatePayload { title: string; keywordIds: Types.ObjectId[]; @@ -23,6 +25,11 @@ export interface IMeme { isTodayMeme: boolean; } +export interface IMemeGetResponse extends Omit { + keywords: IKeywordGetResponse[]; + isSaved: boolean; // 나의 파밈함 저장 여부 +} + export interface IMemeDocument extends Document { _id: Types.ObjectId; title: string; @@ -36,11 +43,6 @@ export interface IMemeDocument extends Document { isDeleted: boolean; } -// keywordIds로 조회한 keywords로 대체된 Meme 정보 -export interface IMemeWithKeywords extends Omit { - keywords: string[]; -} - const MemeSchema: Schema = new Schema( { title: { type: String, required: true }, diff --git a/src/model/memeInteraction.ts b/src/model/memeInteraction.ts index c8a312f..d644d64 100644 --- a/src/model/memeInteraction.ts +++ b/src/model/memeInteraction.ts @@ -8,14 +8,14 @@ export enum InteractionType { } export interface IMemeInteraction { - deviceId: String; + deviceId: string; memeId: Types.ObjectId; interactionType: InteractionType; } -export interface IMemeInteraction extends Document { +export interface IMemeInteractionDocument extends Document { _id: Types.ObjectId; - deviceId: String; + deviceId: string; memeId: Types.ObjectId; interactionType: InteractionType; isDeleted: boolean; @@ -37,7 +37,7 @@ const MemeInteractionSchema: Schema = new Schema( }, ); -export const MemeInteractionModel = mongoose.model( +export const MemeInteractionModel = mongoose.model( 'memeInteraction', MemeInteractionSchema, ); diff --git a/src/routes/keyword.ts b/src/routes/keyword.ts index 2e9c2ad..bca66ba 100644 --- a/src/routes/keyword.ts +++ b/src/routes/keyword.ts @@ -267,7 +267,7 @@ router.delete('/:keywordId', getKeywordInfoById, deleteKeyword); * example: 100 * topReactionImage: * type: string - * example: "https://example.com/top-reaction-image.jpg" + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" * createdAt: * type: string * example: "2024-07-05T15:21:34.012Z" @@ -446,13 +446,17 @@ router.patch('/count', getKeywordInfoByName, increaseSearchCount); * type: string * example: "상황" * keywords: - * type: array - * items: - * type: string - * example: - * - "키워드1" - * - "키워드2" - * - "키워드3" + * type: object + * properties: + * _id: + * type: string + * example: "667ff3d1239eeaf78630a283" + * name: + * type: string + * example: "웃긴" + * name: + * type: string + * example: "웃긴" * 500: * description: Internal server error * content: diff --git a/src/routes/meme.ts b/src/routes/meme.ts index 383706c..dc324a9 100644 --- a/src/routes/meme.ts +++ b/src/routes/meme.ts @@ -12,6 +12,7 @@ import { createMemeReaction, createMemeWatch, searchMemeListByKeyword, + deleteMemeSave, } from '../controller/meme.controller'; import { getRequestedMemeInfo, @@ -30,6 +31,11 @@ const router = express.Router(); * summary: 밈 전체 목록 조회 (페이지네이션 적용) * description: 밈 전체 목록 조회 * parameters: + * - name: x-device-id + * in: header + * description: 유저의 고유한 deviceId + * required: true + * type: string * - in: query * name: page * schema: @@ -90,14 +96,24 @@ const router = express.Router(); * example: "66805b1a72ef94c9c0ba134c" * image: * type: string - * example: "https://example.com/meme.jpg" + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" * isTodayMeme: * type: boolean * example: false - * keywordIds: + * isSaved: + * type: boolean + * example: true + * keywords: * type: array * items: - * example: "667fee7ac58681a42d57dc3b" + * type: object + * properties: + * _id: + * type: string + * example: "667fee6dc58681a42d57dc37" + * name: + * type: string + * example: "무한도전" * title: * type: string * example: "무한상사 정총무" @@ -155,7 +171,7 @@ const router = express.Router(); * type: null * example: null */ -router.get('/list', getAllMemeList); // meme 목록 전체 조회 (페이지네이션) +router.get('/list', getRequestedUserInfo, getAllMemeList); // meme 목록 전체 조회 (페이지네이션) /** * @swagger @@ -165,6 +181,11 @@ router.get('/list', getAllMemeList); // meme 목록 전체 조회 (페이지네 * summary: 추천 밈 정보 조회 * description: 추천 밈 목록을 조회한다. (현재는 주 단위, 추후 일 단위로 변경될 수 있음) * parameters: + * - name: x-device-id + * in: header + * description: 유저의 고유한 deviceId + * required: true + * type: string * - in: query * name: size * schema: @@ -201,7 +222,7 @@ router.get('/list', getAllMemeList); // meme 목록 전체 조회 (페이지네 * example: "title1" * image: * type: string - * example: "image1" + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" * reaction: * type: integer * example: 0 @@ -219,11 +240,20 @@ router.get('/list', getAllMemeList); // meme 목록 전체 조회 (페이지네 * type: string * format: date-time * example: "2024-06-29T19:05:55.638Z" + * isSaved: + * type: boolean + * example: true * keywords: * type: array * items: - * type: string - * example: "angry" + * type: object + * properties: + * _id: + * type: string + * example: "66805b1372ef94c9c0ba1349" + * name: + * type: string + * example: "무한도전" * 400: * description: Invalid request parameters * content: @@ -263,7 +293,7 @@ router.get('/list', getAllMemeList); // meme 목록 전체 조회 (페이지네 * type: null * example: null */ -router.get('/recommend-memes', getTodayMemeList); // 오늘의 추천 밈 (5개) +router.get('/recommend-memes', getRequestedUserInfo, getTodayMemeList); // 오늘의 추천 밈 (5개) /** * @swagger @@ -285,7 +315,7 @@ router.get('/recommend-memes', getTodayMemeList); // 오늘의 추천 밈 (5개) * description: 밈 제목 * image: * type: string - * example: "https://example.com/meme.jpg" + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" * description: 밈 이미지 주소 * source: * type: string @@ -327,7 +357,7 @@ router.get('/recommend-memes', getTodayMemeList); // 오늘의 추천 밈 (5개) * description: 밈 제목 * image: * type: string - * example: "https://example.com/meme.jpg" + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" * description: 밈 이미지 주소 * source: * type: string @@ -406,12 +436,17 @@ router.post('/', createMeme); // meme 생성 * summary: 밈 정보 조회(키워드 포함) * description: 밈 정보를 조회한다. 밈의 키워드 정보도 함께 포함한다. 이때 키워드는 키워드명만 제공된다 (키워드의 개별 정보 X) * parameters: + * - name: x-device-id + * in: header + * description: 유저의 고유한 deviceId + * required: true + * type: string * - in: path * name: memeId * required: true * schema: * type: string - * description: 밈 ID + * description: 밈 ID * responses: * 200: * description: The meme @@ -440,7 +475,7 @@ router.post('/', createMeme); // meme 생성 * example: "무한도전 정총무" * image: * type: string - * example: "https://example.com/meme.jpg" + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" * reaction: * type: integer * example: 0 @@ -461,14 +496,20 @@ router.post('/', createMeme); // meme 생성 * type: string * format: date-time * example: "2024-06-29T19:05:55.638Z" + * isSaved: + * type: boolean + * example: true * keywords: * type: array * items: - * type: string - * example: - * - "무한상사" - * - "정총무" - * - "전자두뇌" + * type: object + * properties: + * _id: + * type: string + * example: "66805b1372ef94c9c0ba1349" + * name: + * type: string + * example: "무한도전" * 400: * description: Bad Request * content: @@ -527,7 +568,7 @@ router.post('/', createMeme); // meme 생성 * type: null * example: null */ -router.get('/:memeId', getMemeWithKeywords); // meme 조회 +router.get('/:memeId', getRequestedUserInfo, getRequestedMemeInfo, getMemeWithKeywords); // meme 조회 /** * @swagger @@ -556,7 +597,7 @@ router.get('/:memeId', getMemeWithKeywords); // meme 조회 * description: 밈 제목 * image: * type: string - * example: "https://example.com/meme.jpg" + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" * description: 밈 이미지 주소 * source: * type: string @@ -598,7 +639,7 @@ router.get('/:memeId', getMemeWithKeywords); // meme 조회 * description: 밈 제목 * image: * type: string - * example: "https://example.com/meme.jpg" + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" * description: 밈 이미지 주소 * source: * type: string @@ -800,7 +841,7 @@ router.delete('/:memeId', getRequestedMemeInfo, deleteMeme); // meme 삭제 * name: memeId * schema: * type: string - * description: 저장할 밈 id + * description: 저장할 밈 id * responses: * 201: * description: Meme successfully saved @@ -881,6 +922,105 @@ router.delete('/:memeId', getRequestedMemeInfo, deleteMeme); // meme 삭제 */ router.post('/:memeId/save', getRequestedUserInfo, getRequestedMemeInfo, createMemeSave); // meme 저장하기 +/** + * @swagger + * /api/meme/{memeId}/save: + * delete: + * tags: [Meme] + * summary: 밈 저장 + * description: 밈 저장할 취소할 때 사용되는 api + * parameters: + * - name: x-device-id + * in: header + * description: 유저의 고유한 deviceId + * required: true + * type: string + * - in: path + * name: memeId + * schema: + * type: string + * description: 저장할 밈 id + * responses: + * 200: + * description: Meme successfully saved + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * code: + * type: integer + * example: 200 + * message: + * type: string + * example: Deleted Meme Save + * data: + * type: boolean + * example: true + * 400: + * description: Invalid parameters + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: error + * code: + * type: integer + * example: 400 + * message: + * type: string + * example: 'deviceId should be provided' + * data: + * type: null + * example: null + * 404: + * description: Meme or user not found + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: error + * code: + * type: integer + * example: 404 + * message: + * type: string + * example: Meme(66805b1372ef94c9c0ba1349) does not exist + * data: + * type: null + * example: null + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: error + * code: + * type: integer + * example: 500 + * message: + * type: string + * example: Internal server error + * data: + * type: null + * example: null + * + * */ +router.delete('/:memeId/save', getRequestedUserInfo, getRequestedMemeInfo, deleteMemeSave); + /** * @swagger * /api/meme/{memeId}/share: @@ -898,7 +1038,7 @@ router.post('/:memeId/save', getRequestedUserInfo, getRequestedMemeInfo, createM * name: memeId * schema: * type: string - * description: 공유할 밈 id + * description: 공유할 밈 id * responses: * 201: * description: Meme successfully shared @@ -997,7 +1137,7 @@ router.post('/:memeId/share', getRequestedUserInfo, getRequestedMemeInfo, create * required: true * schema: * type: string - * description: 밈 id + * description: 밈 id * - in: path * name: type * required: true @@ -1107,8 +1247,8 @@ router.post('/:memeId/watch/:type', getRequestedUserInfo, getRequestedMemeInfo, * name: memeId * schema: * type: string - * required: true - * description: 리액션할 밈 id + * required: true + * description: 리액션할 밈 id * responses: * 201: * description: Created Meme Reaction @@ -1194,25 +1334,30 @@ router.post('/:memeId/reaction', getRequestedUserInfo, getRequestedMemeInfo, cre * summary: 키워드가 포함된 밈 검색 (페이지네이션 적용) * description: 키워드 클릭 시 해당 키워드를 포함한 밈을 조회하고 목록을 반환한다. * parameters: - * - in: query - * name: page - * schema: - * type: number - * example: 1 - * description: 현재 페이지 번호 (기본값 1) - * - in: query - * name: size - * schema: - * type: number - * example: 10 - * description: 한 번에 조회할 밈 개수 (기본값 10) - * - in: path - * name: name - * schema: - * type: string - * example: "행복" - * required: true - * description: 키워드명 + * - name: x-device-id + * in: header + * description: 유저의 고유한 deviceId + * required: true + * type: string + * - in: query + * name: page + * schema: + * type: number + * example: 1 + * description: 현재 페이지 번호 (기본값 1) + * - in: query + * name: size + * schema: + * type: number + * example: 10 + * description: 한 번에 조회할 밈 개수 (기본값 10) + * - in: path + * name: name + * schema: + * type: string + * example: "행복" + * required: true + * description: 키워드명 * responses: * 200: * description: 키워드를 포함한 밈 목록 @@ -1261,26 +1406,24 @@ router.post('/:memeId/reaction', getRequestedUserInfo, getRequestedMemeInfo, cre * example: "66805b1a72ef94c9c0ba134c" * image: * type: string - * example: "https://example.com/meme.jpg" + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" * isTodayMeme: * type: boolean * example: false - * keywordIds: + * isSaved: + * type: boolean + * example: true + * keywords: * type: array * items: * type: object * properties: * _id: * type: string - * example: "667fee7ac58681a42d57dc3b" + * example: "66805b1a72ef94c9c0ba134c" * name: * type: string * example: "행복" - * example: - * - _id: "667fee7ac58681a42d57dc3b" - * name: "행복" - * - _id: "667fee7ac58681a42d57dc3d" - * name: "장원영" * title: * type: string * example: "무한상사 정총무" @@ -1339,6 +1482,6 @@ router.post('/:memeId/reaction', getRequestedUserInfo, getRequestedMemeInfo, cre * type: null * example: null */ -router.get('/search/:name', getKeywordInfoByName, searchMemeListByKeyword); // 키워드에 해당하는 밈 검색하기 (페이지네이션) +router.get('/search/:name', getRequestedUserInfo, getKeywordInfoByName, searchMemeListByKeyword); // 키워드에 해당하는 밈 검색하기 (페이지네이션) export default router; diff --git a/src/routes/user.ts b/src/routes/user.ts index dc02639..19247a5 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -282,14 +282,24 @@ router.get('/', getRequestedUserInfo, UserController.getUser); // user 조회 * example: "66805b1a72ef94c9c0ba134c" * image: * type: string - * example: "https://example.com/meme.jpg" + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" * isTodayMeme: * type: boolean * example: false - * keywordIds: + * isSaved: + * type: boolean + * example: true + * keywords: * type: array * items: - * example: "667fee7ac58681a42d57dc3b" + * type: object + * properties: + * _id: + * type: string + * example: "5f6f6b1d6ab9c8f7d9a4b5c6" + * title: + * type: string + * example: "무한도전" * title: * type: string * example: "무한상사 정총무" @@ -300,6 +310,10 @@ router.get('/', getRequestedUserInfo, UserController.getUser); // user 조회 * type: integer * example: 99 * description: 밈 리액션 수 + * watch: + * type: integer + * example: 999 + * description: 조회 수 * createdAt: * type: string * format: date-time @@ -348,7 +362,7 @@ router.get('/', getRequestedUserInfo, UserController.getUser); // user 조회 * type: null * example: null */ -router.get('/saved-memes', getRequestedUserInfo, UserController.getSavedMemes); // user가 저장한 meme 조회 (페이지네이션 적용) +router.get('/saved-memes', getRequestedUserInfo, UserController.getSavedMemeList); // user가 저장한 meme 조회 (페이지네이션 적용) /** * @swagger @@ -390,14 +404,24 @@ router.get('/saved-memes', getRequestedUserInfo, UserController.getSavedMemes); * example: "66805b1a72ef94c9c0ba134c" * image: * type: string - * example: "https://example.com/meme.jpg" + * example: "https://ppac-meme.s3.ap-northeast-2.amazonaws.com/17207029441190.png" * isTodayMeme: * type: boolean * example: false - * keywordIds: + * isSaved: + * type: boolean + * example: false + * keywords: * type: array * items: - * example: "667fee7ac58681a42d57dc3b" + * type: object + * properties: + * _id: + * type: string + * example: "667fee7ac58681a42d57dc3b" + * name: + * type: string + * example: "무한도전" * title: * type: string * example: "무한상사 정총무" @@ -408,6 +432,10 @@ router.get('/saved-memes', getRequestedUserInfo, UserController.getSavedMemes); * type: integer * example: 99 * description: 밈 리액션 수 + * watch: + * type: integer + * example: 999 + * description: 밈 조회수 * createdAt: * type: string * format: date-time @@ -456,6 +484,6 @@ router.get('/saved-memes', getRequestedUserInfo, UserController.getSavedMemes); * type: null * example: null */ -router.get('/recent-memes', getRequestedUserInfo, UserController.getLastSeenMemes); // user가 최근에 본 밈 정보 조회 (10개 제한) +router.get('/recent-memes', getRequestedUserInfo, UserController.getLastSeenMemeList); // user가 최근에 본 밈 정보 조회 (10개 제한) export default router; diff --git a/src/service/keyword.service.ts b/src/service/keyword.service.ts index 97620f9..6bb7704 100644 --- a/src/service/keyword.service.ts +++ b/src/service/keyword.service.ts @@ -3,7 +3,12 @@ import { Types } from 'mongoose'; import CustomError from '../errors/CustomError'; import { HttpCode } from '../errors/HttpCode'; -import { IKeywordCreatePayload, KeywordModel, IKeywordDocument } from '../model/keyword'; +import { + IKeywordCreatePayload, + KeywordModel, + IKeywordDocument, + IKeywordGetResponse, +} from '../model/keyword'; import { KeywordCategoryModel } from '../model/keywordCategory'; import { logger } from '../util/logger'; @@ -44,7 +49,7 @@ async function updateKeyword( } async function deleteKeyword(keywordId: Types.ObjectId): Promise { const deletedKeyword = await KeywordModel.findOneAndDelete({ _id: keywordId }).lean(); - if (!deletedKeyword) { + if (_.isNull(deletedKeyword)) { throw new CustomError(`Keyword with ID ${keywordId} not found`, HttpCode.NOT_FOUND); } return true; @@ -70,7 +75,7 @@ async function increaseSearchCount(keywordId: Types.ObjectId): Promise { +async function getKeywordByName(keywordName: string): Promise { try { const keyword = await KeywordModel.findOne({ name: keywordName, isDeleted: false }).lean(); - return keyword; + return keyword || null; } catch (err) { - logger.info(`Failed to get a Keyword Info By Name(${keywordName})`); + logger.error(`Failed to get a Keyword Info By Name(${keywordName})`); + throw new CustomError( + `Failed to get a Keyword Info By Name(${keywordName}) (${err.message})`, + HttpCode.INTERNAL_SERVER_ERROR, + ); } } -async function getKeywordById(keywordId: Types.ObjectId): Promise { +async function getKeywordById(keywordId: Types.ObjectId): Promise { try { const keyword = await KeywordModel.findOne({ _id: keywordId, isDeleted: false }).lean(); - return keyword; + return keyword || null; } catch (err) { logger.info(`Failed to get a Keyword Info By id (${keywordId})`); + throw new CustomError( + `Failed to get a Keyword Info By id(${keywordId}) (${err.message})`, + HttpCode.INTERNAL_SERVER_ERROR, + ); } } -async function getRecommendedKeywords(): Promise<{ title: string; keywords: string[] }[]> { +async function getKeywordInfoByKeywordIds( + keywordIds: Types.ObjectId[], +): Promise { + try { + const keyword = await KeywordModel.find( + { _id: { $in: keywordIds }, isDeleted: false }, + { + _id: 1, + name: 1, + }, + ).lean(); + return keyword; + } catch (err) { + logger.error(`Failed to get a Keyword Info By keywordIds(${JSON.stringify(keywordIds)})`); + throw new CustomError( + `Failed to get a Keyword Info By keywordIds(${JSON.stringify(keywordIds)})`, + HttpCode.INTERNAL_SERVER_ERROR, + ); + } +} + +async function getRecommendedKeywords(): Promise< + { title: string; keywords: IKeywordGetResponse[] }[] +> { try { const result = await KeywordCategoryModel.aggregate([ { @@ -119,23 +155,16 @@ async function getRecommendedKeywords(): Promise<{ title: string; keywords: stri $project: { _id: 0, category: '$name', - keywords: '$keywords.name', - }, - }, - { - $unwind: '$keywords', - }, - { - $group: { - _id: '$category', - keywords: { $push: '$keywords' }, - }, - }, - { - $project: { - _id: 0, - category: '$_id', - keywords: 1, + keywords: { + $map: { + input: '$keywords', + as: 'keyword', + in: { + name: '$$keyword.name', + _id: '$$keyword._id', + }, + }, + }, }, }, ]); @@ -157,4 +186,5 @@ export { getKeywordByName, getKeywordById, getRecommendedKeywords, + getKeywordInfoByKeywordIds, }; diff --git a/src/service/keywordCategory.service.ts b/src/service/keywordCategory.service.ts index a85a7b2..ad80d0c 100644 --- a/src/service/keywordCategory.service.ts +++ b/src/service/keywordCategory.service.ts @@ -1,3 +1,5 @@ +import _ from 'lodash'; + import CustomError from '../errors/CustomError'; import { HttpCode } from '../errors/HttpCode'; import { @@ -35,11 +37,11 @@ async function updateKeywordCategory( { new: true }, ); - if (!updatedCategory) { + if (_.isNull(updatedCategory)) { throw new CustomError(`Category with ID ${updatedCategory} not found`, HttpCode.NOT_FOUND); } - logger.info(`Update keyword category - category(${categoryName})`); + logger.info(`Update keyword category - category(${categoryName})`); return updatedCategory.toObject(); } @@ -50,19 +52,19 @@ async function deleteKeywordCategory(categoryName: string): Promise { }, { isDeleted: true }, ); - if (!deletedCategory) { + if (_.isNull(deletedCategory)) { throw new CustomError(`Category with Name ${categoryName} not found`, HttpCode.NOT_FOUND); } return true; } -async function getKeywordCategory(categoryName: string): Promise { +async function getKeywordCategory(categoryName: string): Promise { const keywordCategory = await KeywordCategoryModel.findOne({ name: categoryName, isDeleted: false, }); - if (!keywordCategory) { + if (_.isNull(keywordCategory)) { throw new CustomError(`Category with Name ${categoryName} not found`, HttpCode.NOT_FOUND); } diff --git a/src/service/meme.service.ts b/src/service/meme.service.ts index 3f4f461..7440f55 100644 --- a/src/service/meme.service.ts +++ b/src/service/meme.service.ts @@ -1,11 +1,13 @@ import _ from 'lodash'; import { Types } from 'mongoose'; +import * as KeywordService from './keyword.service'; +import * as MemeInteractionService from './memeInteraction.service'; import CustomError from '../errors/CustomError'; import { HttpCode } from '../errors/HttpCode'; import { IKeywordDocument } from '../model/keyword'; -import { IMemeCreatePayload, IMemeDocument, MemeModel, IMemeWithKeywords } from '../model/meme'; -import { InteractionType, MemeInteractionModel } from '../model/memeInteraction'; +import { IMemeCreatePayload, IMemeDocument, MemeModel, IMemeGetResponse } from '../model/meme'; +import { InteractionType } from '../model/memeInteraction'; import { IUserDocument } from '../model/user'; import { logger } from '../util/logger'; @@ -15,97 +17,120 @@ async function getMeme(memeId: string): Promise { .and([{ isDeleted: false }]) .lean(); - if (!meme) { - logger.info(`Meme(${memeId}) not found.`); - return null; - } - - return meme; + return meme || null; } catch (err) { logger.error(`Failed to get a meme(${memeId}): ${err.message}`); throw new CustomError(`Failed to get a meme(${memeId})`, HttpCode.INTERNAL_SERVER_ERROR); } } -async function getMemeWithKeywords(memeId: string): Promise { +async function getMemeWithKeywords( + user: IUserDocument, + meme: IMemeDocument, +): Promise { try { - const meme = await MemeModel.aggregate([ - { $match: { _id: new Types.ObjectId(memeId), isDeleted: false } }, - { - $lookup: { - from: 'keyword', - localField: 'keywordIds', - foreignField: '_id', - as: 'keywords', - }, - }, - { - $addFields: { - keywords: '$keywords.name', - }, - }, - { $project: { keywordIds: 0, isDeleted: 0 } }, - ]); - - if (!meme) { - logger.info(`Meme(${memeId}) not found.`); - return null; - } + const keywords = await KeywordService.getKeywordInfoByKeywordIds(meme.keywordIds); + const isSaved = await MemeInteractionService.getMemeInteractionInfo( + user, + meme, + InteractionType.SAVE, + ); - return meme[0] || null; + return { + ..._.omit(meme, 'keywordIds'), + keywords, + isSaved: !_.isNil(isSaved), + }; } catch (err) { - logger.error(`Failed to get a meme(${memeId}): ${err.message}`); - throw new CustomError(`Failed to get a meme(${memeId})`, HttpCode.INTERNAL_SERVER_ERROR); + logger.error(`Failed to get a meme(${meme._id}) with keywords: ${err.message}`); + throw new CustomError( + `Failed to get a meme(${meme._id}) with keywords`, + HttpCode.INTERNAL_SERVER_ERROR, + ); } } -async function getTodayMemeList(limit: number = 5): Promise { - const todayMemeList = await MemeModel.aggregate([ - { $match: { isTodayMeme: true, isDeleted: false } }, - { $limit: limit }, - { - $lookup: { - from: 'keyword', - localField: 'keywordIds', - foreignField: '_id', - as: 'keywords', - }, - }, - { - $addFields: { - keywords: '$keywords.name', - }, - }, - { $project: { keywordIds: 0, isDeleted: 0 } }, - ]); - - const memeIds = todayMemeList.map((meme) => meme._id); - logger.info( - `Get all today meme list(${todayMemeList.length}) - memeIds(${memeIds}), limit(${limit})`, - ); - return todayMemeList; +async function getTodayMemeList( + limit: number = 5, + user: IUserDocument, +): Promise { + try { + const todayMemeList = await MemeModel.find( + { isDeleted: false, isTodayMeme: true }, + { isDeleted: 0 }, + ); + + const memeList = await getMemeListWithKeywordsAndisSaved(user, todayMemeList); + + const memeIds = todayMemeList.map((meme) => meme._id); + logger.info( + `Get all today meme list(${todayMemeList.length}) - memeIds(${memeIds}), limit(${limit})`, + ); + + return memeList; + } catch (err) { + logger.error(`Failed to get today meme list: ${err.message}`); + throw new CustomError( + `Failed to get today meme list ${err.message}`, + HttpCode.INTERNAL_SERVER_ERROR, + ); + } } async function getAllMemeList( page: number, size: number, -): Promise<{ total: number; page: number; totalPages: number; data: IMemeDocument[] }> { + user: IUserDocument, +): Promise<{ total: number; page: number; totalPages: number; data: IMemeGetResponse[] }> { const totalMemes = await MemeModel.countDocuments(); const memeList = await MemeModel.find({ isDeleted: false }, { isDeleted: 0 }) .skip((page - 1) * size) .limit(size) .sort({ createdAt: -1 }); + + const allMemeList = await getMemeListWithKeywordsAndisSaved(user, memeList); + logger.info(`Get all meme list - page(${page}), size(${size}), total(${totalMemes})`); return { total: totalMemes, page, totalPages: Math.ceil(totalMemes / size), - data: memeList, + data: allMemeList, }; } +// MemeList에서 keywords와 isSaved 정보를 확인하여 추가 반환 +async function getMemeListWithKeywordsAndisSaved( + user: IUserDocument, + memeList: IMemeDocument[], +): Promise { + try { + return await Promise.all( + memeList.map(async (meme: IMemeDocument) => { + const keywords = await KeywordService.getKeywordInfoByKeywordIds(meme.keywordIds); + const isSaved = await MemeInteractionService.getMemeInteractionInfo( + user, + meme, + InteractionType.SAVE, + ); + return { + ..._.omit(meme, 'keywordIds'), + keywords, + isSaved: !_.isNil(isSaved), + } as IMemeGetResponse; + }), + ); + } catch (err) { + logger.error('Failed to get keywords and isSaved info from meme list', err.message); + throw new CustomError( + `Failed to get keywords and isSaved info from meme list ${err.message}`, + HttpCode.INTERNAL_SERVER_ERROR, + ); + } +} + async function createMeme(info: IMemeCreatePayload): Promise { const meme = await MemeModel.create({ ...info, @@ -158,23 +183,25 @@ async function searchMemeByKeyword( page: number, size: number, keyword: IKeywordDocument, -): Promise<{ total: number; page: number; totalPages: number; data: IMemeDocument[] }> { + user: IUserDocument, +): Promise<{ total: number; page: number; totalPages: number; data: IMemeGetResponse[] }> { try { const totalMemes = await MemeModel.countDocuments({ keywordIds: { $in: keyword._id }, isDeleted: false, }); - const memeList = await MemeModel.find( + const searchedMemeList = await MemeModel.find( { isDeleted: false, keywordIds: { $in: keyword._id } }, { isDeleted: 0 }, ) .skip((page - 1) * size) .limit(size) .sort({ reaction: -1 }) - .populate('keywordIds', 'name') .lean(); + const memeList = await getMemeListWithKeywordsAndisSaved(user, searchedMemeList); + logger.info( `Get all meme list with keyword(${keyword.name}) - page(${page}), size(${size}), total(${totalMemes})`, ); @@ -200,44 +227,30 @@ async function createMemeInteraction( interactionType: InteractionType, ): Promise { try { - const memeInteraction = await MemeInteractionModel.findOne({ - memeId: meme._id, - deviceId: user.deviceId, + // interaction 조회 + const memeInteraction = await MemeInteractionService.getMemeInteractionInfo( + user, + meme, interactionType, - isDeleted: false, - }); + ); - // 밈당 interaction은 1회 - if (!_.isNull(memeInteraction)) { + if (_.isNull(memeInteraction)) { + // 신규 생성 + await MemeInteractionService.createMemeInteraction(user, meme, interactionType); + } else { logger.info( `Already ${interactionType} meme - deviceId(${user.deviceId}), memeId(${meme._id}`, ); - } else { - const newMemeInteraction = await MemeInteractionModel.create({ - memeId: meme._id, - deviceId: user.deviceId, - interactionType, - }); - await newMemeInteraction.save(); - } - // 'reaction'인 경우에만 Meme의 reaction 수를 업데이트한다. - if (interactionType === InteractionType.REACTION) { - await MemeModel.findOneAndUpdate( - { memeId: meme._id, isDeleted: false }, - { $inc: { reaction: 1 } }, - { - projection: { _id: 0, createdAt: 0, updatedAt: 0 }, - returnDocument: 'after', - }, - ).lean(); + // interactionType에 따른 동작 처리 (MemeInteracionService에서 진행) + await MemeInteractionService.updateMemeInteraction(user, meme, interactionType); } - return true; } catch (err) { - logger.error(`Failed to create memeInteraction`, err.message); + logger.error(`Failed to create memeInteraction(${interactionType})`, err.message); + throw new CustomError( - `Failed to create memeInteraction(${err.message})`, + `Failed to create memeInteraction(${interactionType}) (${err.message})`, HttpCode.INTERNAL_SERVER_ERROR, ); } @@ -245,29 +258,26 @@ async function createMemeInteraction( async function deleteMemeSave(user: IUserDocument, meme: IMemeDocument): Promise { try { - const meemSaveInteraction = await MemeInteractionModel.findOne({ - memeId: meme._id, - deviceId: user.deviceId, - interactionType: InteractionType.SAVE, - isDeleted: false, - }); + const memeSaveInteraction = await MemeInteractionService.getMemeInteractionInfoWithCondition( + user, + meme, + InteractionType.SAVE, + { isDeleted: true }, + ); - if (_.isNull(meemSaveInteraction)) { + if (!_.isNull(memeSaveInteraction)) { logger.info(`Already delete memeSave - deviceId(${user.deviceId}), memeId(${meme._id}`); return false; } - await MemeInteractionModel.findOneAndUpdate( - { deviceId: user.deviceId, memeId: meme._id, interactionType: InteractionType.SAVE }, - { - isDeleted: true, - }, - ).lean(); - + await MemeInteractionService.deleteMemeInteraction(user, meme, InteractionType.SAVE); return true; } catch (err) { - logger.error(`Failed delete memeSave`, err.message); - throw new CustomError(`Failed delete memeSave(${err.message})`, HttpCode.INTERNAL_SERVER_ERROR); + logger.error(`Failed to delete meme save`, err.message); + throw new CustomError( + `Failed to delete meme save(${err.message})`, + HttpCode.INTERNAL_SERVER_ERROR, + ); } } async function getTopReactionImage(keyword: IKeywordDocument): Promise { @@ -299,6 +309,7 @@ export { deleteMemeSave, getTodayMemeList, getAllMemeList, + getMemeListWithKeywordsAndisSaved, deleteKeywordOfMeme, getMemeWithKeywords, searchMemeByKeyword, diff --git a/src/service/memeInteraction.service.ts b/src/service/memeInteraction.service.ts new file mode 100644 index 0000000..433157a --- /dev/null +++ b/src/service/memeInteraction.service.ts @@ -0,0 +1,211 @@ +import CustomError from '../errors/CustomError'; +import { HttpCode } from '../errors/HttpCode'; +import { IMemeDocument, MemeModel } from '../model/meme'; +import { + IMemeInteractionDocument, + InteractionType, + MemeInteractionModel, +} from '../model/memeInteraction'; +import { IUserDocument } from '../model/user'; +import { logger } from '../util/logger'; + +async function getMemeInteractionInfo( + user: IUserDocument, + meme: IMemeDocument, + interactionType: InteractionType, +): Promise { + try { + const condition = { + deviceId: user.deviceId, + memeId: meme._id, + interactionType, + }; + + // 'save' interaction은 isDeleted 조건 검색 필요없음 + const isDeletedCondition = interactionType !== InteractionType.SAVE ? { isDeleted: false } : {}; + + const memeInteraction = await MemeInteractionModel.findOne({ + ...condition, + ...isDeletedCondition, + }); + + return memeInteraction || null; + } catch (error) { + logger.error(`Failed to get a MemeInteraction Info(${meme._id} - ${interactionType})`, { + error, + }); + throw new CustomError( + `Failed to get a MemeInteraction Info(${meme._id} - ${interactionType})`, + HttpCode.INTERNAL_SERVER_ERROR, + ); + } +} + +async function getMemeInteractionInfoWithCondition( + user: IUserDocument, + meme: IMemeDocument, + interactionType: InteractionType, + findCondition: Partial = {}, +): Promise { + try { + const condition: Partial = { + deviceId: user.deviceId, + memeId: meme._id, + interactionType, + ...findCondition, + }; + + const memeInteraction = await MemeInteractionModel.findOne(condition); + return memeInteraction || null; + } catch (err) { + logger.error(`Failed to get a MemeInteraction Info(${meme._id} - ${interactionType})`); + throw new CustomError( + `Failed to get a MemeInteraction Info(${meme._id} - ${interactionType})`, + HttpCode.INTERNAL_SERVER_ERROR, + ); + } +} + +async function getMemeInteractionCount( + user: IUserDocument, + interactionType: InteractionType, +): Promise { + try { + const count = await MemeInteractionModel.countDocuments({ + deviceId: user.deviceId, + interactionType, + isDeleted: false, + }); + return count; + } catch (err) { + logger.error(`Failed to count MemeInteraction(${interactionType})`); + throw new CustomError( + `Failed to count MemeInteraction(${interactionType}) (${err.message})`, + HttpCode.INTERNAL_SERVER_ERROR, + ); + } +} + +async function getMemeInteractionList( + page: number, + size: number, + user: IUserDocument, + interactionType: InteractionType, +): Promise { + try { + const memeInteractionList = await MemeInteractionModel.find( + { + deviceId: user.deviceId, + interactionType: InteractionType.SAVE, + isDeleted: false, + }, + { isDeleted: 0 }, + ) + .skip((page - 1) * size) + .limit(size) + .sort({ createdAt: -1 }); + + return memeInteractionList; + } catch (err) { + logger.error(`Failed to count MemeInteraction(${interactionType})`); + throw new CustomError( + `Failed to count MemeInteraction(${interactionType}) (${err.message})`, + HttpCode.INTERNAL_SERVER_ERROR, + ); + } +} + +async function createMemeInteraction( + user: IUserDocument, + meme: IMemeDocument, + interactionType: InteractionType, +): Promise { + try { + const newMemeInteraction = new MemeInteractionModel({ + deviceId: user.deviceId, + memeId: meme._id, + interactionType, + }); + await newMemeInteraction.save(); + return newMemeInteraction; + } catch (err) { + logger.error(`Failed to create a MemeInteraction(${meme._id} - ${interactionType})`); + throw new CustomError( + `Failed to create a MemeInteraction(${meme._id} - ${interactionType})`, + HttpCode.INTERNAL_SERVER_ERROR, + ); + } +} + +async function updateMemeInteraction( + user: IUserDocument, + meme: IMemeDocument, + interactionType: InteractionType, +): Promise { + switch (interactionType) { + case InteractionType.SAVE: + await MemeInteractionModel.findOneAndUpdate( + { memeId: meme._id, deviceId: user.deviceId, interactionType }, + { $set: { isDeleted: false } }, + ); + logger.debug(`[${interactionType}] interaction - updated isDeleted to 'false'`); + break; + + case InteractionType.REACTION: + await MemeModel.findOneAndUpdate( + { memeId: meme._id, isDeleted: false }, + { $inc: { reaction: 1 } }, + { + projection: { _id: 0, createdAt: 0, updatedAt: 0 }, + returnDocument: 'after', + }, + ).lean(); + logger.debug(`[${interactionType}] interaction - increased Meme reaction count`); + break; + + case InteractionType.SHARE: + case InteractionType.WATCH: + logger.debug(`${interactionType} interaction don't need to be updated. `); + break; + + default: + logger.error(`Unsupported interactionType(${interactionType})`); + throw new CustomError( + `Unsupported interactionType(${interactionType})`, + HttpCode.BAD_REQUEST, + ); + } +} + +async function deleteMemeInteraction( + user: IUserDocument, + meme: IMemeDocument, + interactionType: InteractionType, +): Promise { + try { + const memeInteraction = await MemeInteractionModel.findOneAndUpdate( + { deviceId: user.deviceId, memeId: meme._id, interactionType: InteractionType.SAVE }, + { + isDeleted: true, + }, + ); + + return memeInteraction; + } catch (err) { + logger.error(`Failed to delete a MemeInteraction(${meme._id} - ${interactionType})`); + throw new CustomError( + `Failed to delete a MemeInteraction(${meme._id} - ${interactionType})`, + HttpCode.INTERNAL_SERVER_ERROR, + ); + } +} + +export { + getMemeInteractionInfo, + getMemeInteractionInfoWithCondition, + getMemeInteractionCount, + getMemeInteractionList, + createMemeInteraction, + updateMemeInteraction, + deleteMemeInteraction, +}; diff --git a/src/service/user.service.ts b/src/service/user.service.ts index 9de751e..0d29bee 100644 --- a/src/service/user.service.ts +++ b/src/service/user.service.ts @@ -2,9 +2,11 @@ import { startOfWeek } from 'date-fns'; import _ from 'lodash'; import { Types } from 'mongoose'; +import * as MemeService from './meme.service'; +import * as MemeInteractionService from './memeInteraction.service'; import CustomError from '../errors/CustomError'; import { HttpCode } from '../errors/HttpCode'; -import { IMemeDocument, MemeModel } from '../model/meme'; +import { IMemeDocument, IMemeGetResponse, MemeModel } from '../model/meme'; import { InteractionType, MemeInteractionModel } from '../model/memeInteraction'; import { MemeRecommendWatchModel, @@ -33,6 +35,38 @@ async function getUser(deviceId: string): Promise { } } +async function makeUserInfos(deviceId: string): Promise { + const user = await UserModel.findOne({ deviceId, isDeleted: false }); + const countInteractionType = (type: InteractionType) => + MemeInteractionModel.countDocuments({ + deviceId: user.deviceId, + interactionType: type, + }); + + const [watch, reaction, share, save] = await Promise.all([ + countInteractionType(InteractionType.WATCH), + countInteractionType(InteractionType.REACTION), + countInteractionType(InteractionType.SHARE), + countInteractionType(InteractionType.SAVE), + ]); + + const todayWeekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); + const memeRecommendWatchCount = await MemeRecommendWatchModel.countDocuments({ + startDate: todayWeekStart, + deviceId: user.deviceId, + isDeleted: false, + }); + + return { + ...user.toObject(), + watch, + reaction, + save, + share, + memeRecommendWatchCount, + }; +} + async function createUser(deviceId: string): Promise { try { const foundUser = await UserModel.findOne( @@ -41,33 +75,9 @@ async function createUser(deviceId: string): Promise { ); if (foundUser) { - const countInteractionType = (type: InteractionType) => - MemeInteractionModel.countDocuments({ - deviceId: foundUser.deviceId, - interactionType: type, - }); - - const [watch, reaction, share, save] = await Promise.all([ - countInteractionType(InteractionType.WATCH), - countInteractionType(InteractionType.REACTION), - countInteractionType(InteractionType.SHARE), - countInteractionType(InteractionType.SAVE), - ]); - - const todayWeekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); - const memeRecommendWatchCount = await MemeRecommendWatchModel.countDocuments({ - startDate: todayWeekStart, - deviceId: foundUser.deviceId, - isDeleted: false, - }); - + const foundUserInfos = await makeUserInfos(deviceId); return { - ...foundUser.toObject(), - watch, - reaction, - save, - share, - memeRecommendWatchCount, + ...foundUserInfos, }; } @@ -125,7 +135,7 @@ async function updateLastSeenMeme(user: IUserDocument, meme: IMemeDocument): Pro } } -async function getLastSeenMemes(user: IUserDocument): Promise { +async function getLastSeenMemeList(user: IUserDocument): Promise { try { const lastSeenMeme = user.lastSeenMeme; const memeList = await MemeModel.find( @@ -136,52 +146,53 @@ async function getLastSeenMemes(user: IUserDocument): Promise { { isDeleted: 0 }, ).lean(); - return memeList; + const getLastSeenMemeList = await MemeService.getMemeListWithKeywordsAndisSaved(user, memeList); + logger.info( + `Get lastSeenMemeList - deviceId(${user.deviceId}), memeList(${getLastSeenMemeList})`, + ); + + return getLastSeenMemeList; } catch (err) { - logger.error(`Failed get lastSeenMeme`, err.message); + logger.error(`Failed get lastSeenMemeList`, err.message); throw new CustomError( - `Failed get lastSeenMeme(${err.message})`, + `Failed get lastSeenMemeList(${err.message})`, HttpCode.INTERNAL_SERVER_ERROR, ); } } -async function getSavedMemes( +async function getSavedMemeList( page: number, size: number, user: IUserDocument, -): Promise<{ total: number; page: number; totalPages: number; data: IMemeDocument[] }> { +): Promise<{ total: number; page: number; totalPages: number; data: IMemeGetResponse[] }> { try { - const totalSavedMemes = await MemeInteractionModel.countDocuments({ - deviceId: user.deviceId, - interactionType: InteractionType.SAVE, - isDeleted: false, - }); + const totalSavedMemes = await MemeInteractionService.getMemeInteractionCount( + user, + InteractionType.SAVE, + ); - const savedMemes = await MemeInteractionModel.find( - { - deviceId: user.deviceId, - interactionType: InteractionType.SAVE, - isDeleted: false, - }, - { isDeleted: 0 }, - ) - .skip((page - 1) * size) - .limit(size) - .sort({ createdAt: -1 }) - .lean(); + const savedMemeInteractionList = await MemeInteractionService.getMemeInteractionList( + page, + size, + user, + InteractionType.SAVE, + ); - const memeIds = savedMemes.map(({ memeId }) => new Types.ObjectId(memeId)); + const memeIds = savedMemeInteractionList.map(({ memeId }) => memeId); const memeList = await MemeModel.find( { _id: { $in: memeIds }, isDeleted: false }, { isDeleted: 0 }, ).lean(); + const savedMemeList = await MemeService.getMemeListWithKeywordsAndisSaved(user, memeList); + logger.info(`Get savedMemeList - deviceId(${user.deviceId}), memeList(${savedMemeList})`); + return { total: totalSavedMemes, page, totalPages: Math.ceil(totalSavedMemes / size), - data: memeList, + data: savedMemeList, }; } catch (error) { throw new CustomError(`Failed to get saved memes`, HttpCode.INTERNAL_SERVER_ERROR, error); @@ -238,7 +249,8 @@ export { getUser, createUser, updateLastSeenMeme, - getLastSeenMemes, - getSavedMemes, + getLastSeenMemeList, + getSavedMemeList, + makeUserInfos, createMemeRecommendWatch, }; diff --git a/test/meme/delete-meme.test.ts b/test/meme/delete-meme.test.ts index 7644d36..dbf29f1 100644 --- a/test/meme/delete-meme.test.ts +++ b/test/meme/delete-meme.test.ts @@ -3,8 +3,10 @@ import request from 'supertest'; import app from '../../src/app'; import { KeywordModel } from '../../src/model/keyword'; import { MemeModel } from '../../src/model/meme'; +import { UserModel } from '../../src/model/user'; import { createMockData as createKeywordMockData } from '../util/keyword.mock'; import { createMockData } from '../util/meme.mock'; +import { mockUser } from '../util/user.mock'; let testMemeId = ''; let keywordIds = []; @@ -20,10 +22,13 @@ describe("[DELETE] '/api/meme/:memeId' ", () => { await MemeModel.insertMany(mockDatas); memeList = await MemeModel.find({}); testMemeId = memeList[0]._id.toString(); + + await UserModel.insertMany(mockUser); }); afterAll(async () => { await MemeModel.deleteMany({}); + await UserModel.deleteMany({}); }); it('should delete a meme', async () => { @@ -31,7 +36,7 @@ describe("[DELETE] '/api/meme/:memeId' ", () => { expect(response.statusCode).toBe(200); expect(response.body.data).toBeTruthy(); - response = await request(app).get(`/api/meme/list`); + response = await request(app).get(`/api/meme/list`).set('x-device-id', 'deviceId'); expect(response.statusCode).toBe(200); expect(response.body.data.memeList.length).toBe(1); }); diff --git a/test/meme/get-meme-list.test.ts b/test/meme/get-meme-list.test.ts index 8947025..d7abada 100644 --- a/test/meme/get-meme-list.test.ts +++ b/test/meme/get-meme-list.test.ts @@ -3,8 +3,10 @@ import request from 'supertest'; import app from '../../src/app'; import { KeywordModel } from '../../src/model/keyword'; import { MemeModel } from '../../src/model/meme'; +import { UserModel } from '../../src/model/user'; import { createMockData as createKeywordMockData } from '../util/keyword.mock'; import { createMockData } from '../util/meme.mock'; +import { mockUser } from '../util/user.mock'; const totalCount = 15; let keywordIds = []; @@ -19,14 +21,17 @@ describe("[GET] '/api/meme/list' ", () => { const memeMockDatas = createMockData(totalCount, 1, keywordIds); await MemeModel.insertMany(memeMockDatas); + + await UserModel.insertMany(mockUser); }); afterAll(async () => { await MemeModel.deleteMany({}); + await UserModel.deleteMany({}); }); it('should return the default paginated list of memes', async () => { - const response = await request(app).get('/api/meme/list'); + const response = await request(app).get('/api/meme/list').set('x-device-id', 'deviceId'); expect(response.statusCode).toBe(200); expect(response.body.data.pagination.total).toBe(totalCount); expect(response.body.data.pagination.page).toBe(1); @@ -42,7 +47,9 @@ describe("[GET] '/api/meme/list' ", () => { it('should return paginated list of memes for specific page and size', async () => { const size = 5; const page = 1; - const response = await request(app).get(`/api/meme/list?page=${page}&size=${size}`); + const response = await request(app) + .get(`/api/meme/list?page=${page}&size=${size}`) + .set('x-device-id', 'deviceId'); expect(response.statusCode).toBe(200); expect(response.body.data.pagination.total).toBe(totalCount); @@ -54,7 +61,9 @@ describe("[GET] '/api/meme/list' ", () => { it('should return an error for invalid page', async () => { const size = 5; const page = -1; - const response = await request(app).get(`/api/meme/list?page=${page}&size=${size}`); + const response = await request(app) + .get(`/api/meme/list?page=${page}&size=${size}`) + .set('x-device-id', 'deviceId'); expect(response.statusCode).toBe(400); }); @@ -62,7 +71,9 @@ describe("[GET] '/api/meme/list' ", () => { it('should return an error for invalid size', async () => { const size = -1; const page = 3; - const response = await request(app).get(`/api/meme/list?page=${page}&size=${size}`); + const response = await request(app) + .get(`/api/meme/list?page=${page}&size=${size}`) + .set('x-device-id', 'deviceId'); expect(response.statusCode).toBe(400); }); diff --git a/test/meme/get-meme.test.ts b/test/meme/get-meme.test.ts index d21b6c0..0205704 100644 --- a/test/meme/get-meme.test.ts +++ b/test/meme/get-meme.test.ts @@ -4,8 +4,10 @@ import request from 'supertest'; import app from '../../src/app'; import { KeywordModel } from '../../src/model/keyword'; import { MemeModel } from '../../src/model/meme'; +import { UserModel } from '../../src/model/user'; import { createMockData as createKeywordMockData } from '../util/keyword.mock'; import { createMockData as createMemeMockData } from '../util/meme.mock'; +import { mockUser } from '../util/user.mock'; let testMemeId = ''; let keywordIds = []; @@ -23,21 +25,28 @@ describe("[GET] '/api/meme/:memeId' ", () => { memeList = await MemeModel.find({}); testMemeId = memeList[0]._id.toString(); + + await UserModel.insertMany(mockUser); }); afterAll(async () => { await MemeModel.deleteMany({}); + await UserModel.deleteMany({}); }); it('should get a meme', async () => { - const response = await request(app).get(`/api/meme/${testMemeId}`); + const response = await request(app) + .get(`/api/meme/${testMemeId}`) + .set('x-device-id', 'deviceId'); expect(response.body.data._id).toBe(testMemeId); expect(response.body.data).toHaveProperty('keywords'); expect(response.body.data.isTodayMeme).toBeFalsy(); }); it('should not get a meme with nonexisting id', async () => { - const response = await request(app).get(`/api/meme/nonexistingId`); + const response = await request(app) + .get(`/api/meme/nonexistingId`) + .set('x-device-id', 'deviceId'); expect(response.statusCode).toBe(400); }); }); diff --git a/test/meme/get-recommend-meme-list.test.ts b/test/meme/get-recommend-meme-list.test.ts index 1c0fc2b..e5f35a9 100644 --- a/test/meme/get-recommend-meme-list.test.ts +++ b/test/meme/get-recommend-meme-list.test.ts @@ -3,31 +3,35 @@ import request from 'supertest'; import app from '../../src/app'; import { KeywordModel } from '../../src/model/keyword'; import { MemeModel } from '../../src/model/meme'; +import { UserModel } from '../../src/model/user'; import { createMockData as createKeywordMockData } from '../util/keyword.mock'; import { createMockData } from '../util/meme.mock'; +import { mockUser } from '../util/user.mock'; const totalCount = 10; let keywordIds = []; -let keywords = []; describe("[GET] '/api/meme/recommend-memes' ", () => { beforeEach(async () => { const keywordMockDatas = createKeywordMockData(5); const createdKeywords = await KeywordModel.insertMany(keywordMockDatas); keywordIds = createdKeywords.map((k) => k._id); - keywords = createdKeywords.map((k) => k.name); + await UserModel.insertMany(mockUser); }); afterEach(async () => { await MemeModel.deleteMany({}); await KeywordModel.deleteMany({}); + await UserModel.deleteMany({}); }); it('should return list of recommend-memes - default size: 5', async () => { const mockDatas = createMockData(totalCount, 5, keywordIds); await MemeModel.insertMany(mockDatas); - const response = await request(app).get('/api/meme/recommend-memes'); + const response = await request(app) + .get('/api/meme/recommend-memes') + .set('x-device-id', 'deviceId'); expect(response.statusCode).toBe(200); expect(response.body.data.length).toBe(5); }); @@ -37,9 +41,9 @@ describe("[GET] '/api/meme/recommend-memes' ", () => { const mockDatas = createMockData(totalCount, customizedTodayMemeCount, keywordIds); await MemeModel.insertMany(mockDatas); - const response = await request(app).get( - `/api/meme/recommend-memes?size=${customizedTodayMemeCount}`, - ); + const response = await request(app) + .get(`/api/meme/recommend-memes?size=${customizedTodayMemeCount}`) + .set('x-device-id', 'deviceId'); expect(response.statusCode).toBe(200); expect(response.body.data.length).toBe(customizedTodayMemeCount); @@ -50,9 +54,9 @@ describe("[GET] '/api/meme/recommend-memes' ", () => { const mockDatas = createMockData(totalCount, customizedTodayMemeCount, keywordIds); await MemeModel.insertMany(mockDatas); - const response = await request(app).get( - `/api/meme/recommend-memes?size=${customizedTodayMemeCount}`, - ); + const response = await request(app) + .get(`/api/meme/recommend-memes?size=${customizedTodayMemeCount}`) + .set('x-device-id', 'deviceId'); expect(response.statusCode).toBe(400); }); diff --git a/test/meme/patch-meme.test.ts b/test/meme/patch-meme.test.ts index 4714195..6bee178 100644 --- a/test/meme/patch-meme.test.ts +++ b/test/meme/patch-meme.test.ts @@ -3,8 +3,10 @@ import request from 'supertest'; import app from '../../src/app'; import { KeywordModel } from '../../src/model/keyword'; import { IMemeUpdatePayload, MemeModel } from '../../src/model/meme'; +import { UserModel } from '../../src/model/user'; import { createMockData as createKeywordMockData } from '../util/keyword.mock'; import { createMockData } from '../util/meme.mock'; +import { mockUser } from '../util/user.mock'; let memeList = []; let keywordIds = []; @@ -22,10 +24,13 @@ describe("[PATCH] '/api/meme/:memeId' ", () => { await MemeModel.insertMany(mockDatas); memeList = await MemeModel.find({}); testMemeId = memeList[0]._id.toString(); + + await UserModel.insertMany(mockUser); }); afterAll(async () => { await MemeModel.deleteMany({}); + await UserModel.deleteMany({}); }); it('should patch a meme', async () => { @@ -37,9 +42,10 @@ describe("[PATCH] '/api/meme/:memeId' ", () => { expect(response.statusCode).toBe(200); expect(response.body.data._id).toBe(memeList[0]._id.toString()); - response = await request(app).get(`/api/meme/${testMemeId}`); + response = await request(app).get(`/api/meme/${testMemeId}`).set('x-device-id', 'deviceId'); expect(response.body.data._id).toBe(memeList[0]._id.toString()); - expect(response.body.data.keywords).toEqual([keywords[1]]); + expect(response.body.data.keywords[0]).toHaveProperty('_id'); + expect(response.body.data.keywords[0]).toHaveProperty('name'); expect(response.body.data.isTodayMeme).toBeTruthy(); }); });