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 a97fb79..7f37703 100644 --- a/src/controller/meme.controller.ts +++ b/src/controller/meme.controller.ts @@ -51,7 +51,7 @@ const getMemeWithKeywords = async (req: CustomRequest, res: Response, next: Next } logger.info(`Get meme with keywords - ${meme._id})`); - return res.json(createSuccessResponse(HttpCode.OK, 'Get Meme', meme)); + return res.json(createSuccessResponse(HttpCode.OK, 'Get Meme', ret)); } catch (err) { return next(new CustomError(err.message, err.status)); } @@ -167,6 +167,7 @@ const getTodayMemeList = async (req: CustomRequest, res: Response, next: NextFun }; 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; @@ -180,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 aa207af..33400ed 100644 --- a/src/controller/user.controller.ts +++ b/src/controller/user.controller.ts @@ -33,18 +33,18 @@ const getUser = async (req: CustomRequest, res: Response, next: NextFunction) => } }; -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; @@ -58,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: { @@ -76,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/meme.ts b/src/model/meme.ts index 8a2f47a..e350606 100644 --- a/src/model/meme.ts +++ b/src/model/meme.ts @@ -1,4 +1,5 @@ import mongoose, { Schema, Document, Types } from 'mongoose'; + import { IKeywordGetResponse } from './keyword'; export interface IMemeCreatePayload { @@ -24,18 +25,9 @@ export interface IMeme { isTodayMeme: boolean; } -export interface IMemeGetResponse { - _id: Types.ObjectId; - title: string; +export interface IMemeGetResponse extends Omit { keywords: IKeywordGetResponse[]; - image: string; - reaction: number; - source: string; - isTodayMeme: boolean; isSaved: boolean; // 나의 파밈함 저장 여부 - createdAt: Date; - updatedAt: Date; - isDeleted: boolean; } export interface IMemeDocument extends Document { 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/meme.ts b/src/routes/meme.ts index de1147b..dc324a9 100644 --- a/src/routes/meme.ts +++ b/src/routes/meme.ts @@ -31,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: @@ -176,6 +181,11 @@ router.get('/list', getRequestedUserInfo, getAllMemeList); // meme 목록 전체 * summary: 추천 밈 정보 조회 * description: 추천 밈 목록을 조회한다. (현재는 주 단위, 추후 일 단위로 변경될 수 있음) * parameters: + * - name: x-device-id + * in: header + * description: 유저의 고유한 deviceId + * required: true + * type: string * - in: query * name: size * schema: @@ -426,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 @@ -826,7 +841,7 @@ router.delete('/:memeId', getRequestedMemeInfo, deleteMeme); // meme 삭제 * name: memeId * schema: * type: string - * description: 저장할 밈 id + * description: 저장할 밈 id * responses: * 201: * description: Meme successfully saved @@ -924,7 +939,7 @@ router.post('/:memeId/save', getRequestedUserInfo, getRequestedMemeInfo, createM * name: memeId * schema: * type: string - * description: 저장할 밈 id + * description: 저장할 밈 id * responses: * 200: * description: Meme successfully saved @@ -1023,7 +1038,7 @@ router.delete('/:memeId/save', getRequestedUserInfo, getRequestedMemeInfo, delet * name: memeId * schema: * type: string - * description: 공유할 밈 id + * description: 공유할 밈 id * responses: * 201: * description: Meme successfully shared @@ -1122,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 @@ -1232,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 @@ -1319,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: 키워드를 포함한 밈 목록 @@ -1462,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 adc1352..19247a5 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -288,7 +288,7 @@ router.get('/', getRequestedUserInfo, UserController.getUser); // user 조회 * example: false * isSaved: * type: boolean - * example: false + * example: true * keywords: * type: array * items: @@ -362,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 @@ -484,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 2246579..6bb7704 100644 --- a/src/service/keyword.service.ts +++ b/src/service/keyword.service.ts @@ -49,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; @@ -75,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, + ); } } @@ -116,7 +124,11 @@ async function getKeywordInfoByKeywordIds( ).lean(); return keyword; } catch (err) { - logger.info(`Failed to get a Keyword Info By id (${keywordIds})`); + 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, + ); } } 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 49612b6..7440f55 100644 --- a/src/service/meme.service.ts +++ b/src/service/meme.service.ts @@ -1,14 +1,14 @@ 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, KeywordModel } from '../model/keyword'; +import { IKeywordDocument } from '../model/keyword'; import { IMemeCreatePayload, IMemeDocument, MemeModel, IMemeGetResponse } from '../model/meme'; -import { InteractionType, MemeInteractionModel } from '../model/memeInteraction'; +import { InteractionType } from '../model/memeInteraction'; import { IUserDocument } from '../model/user'; - -import * as KeywordService from './keyword.service'; import { logger } from '../util/logger'; async function getMeme(memeId: string): Promise { @@ -17,12 +17,7 @@ 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); @@ -35,12 +30,11 @@ async function getMemeWithKeywords( ): Promise { try { const keywords = await KeywordService.getKeywordInfoByKeywordIds(meme.keywordIds); - const isSaved = await MemeInteractionModel.findOne({ - deviceId: user.deviceId, - memeId: meme._id, - type: InteractionType.SAVE, - isDeleted: false, - }); + const isSaved = await MemeInteractionService.getMemeInteractionInfo( + user, + meme, + InteractionType.SAVE, + ); return { ..._.omit(meme, 'keywordIds'), @@ -48,8 +42,11 @@ async function getMemeWithKeywords( isSaved: !_.isNil(isSaved), }; } catch (err) { - logger.error(`Failed to get a meme(${meme._id}): ${err.message}`); - throw new CustomError(`Failed to get a meme(${meme._id})`, 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, + ); } } @@ -57,30 +54,27 @@ async function getTodayMemeList( limit: number = 5, user: IUserDocument, ): Promise { - const todayMemeList = await MemeModel.find( - { isDeleted: false, isTodayMeme: true }, - { isDeleted: 0 }, - ); + try { + const todayMemeList = await MemeModel.find( + { isDeleted: false, isTodayMeme: true }, + { isDeleted: 0 }, + ); - const ret = await Promise.all( - todayMemeList.map(async (meme) => { - const keywords = await KeywordService.getKeywordInfoByKeywordIds(meme.keywordIds); - const isSaved = await MemeInteractionModel.findOne({ - deviceId: user.deviceId, - memeId: meme._id, - type: InteractionType.SAVE, - isDeleted: false, - }); - return { ..._.omit(meme, 'keywordIds'), keywords, isSaved: !_.isNil(isSaved) }; - }), - ); + 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})`, - ); + const memeIds = todayMemeList.map((meme) => meme._id); + logger.info( + `Get all today meme list(${todayMemeList.length}) - memeIds(${memeIds}), limit(${limit})`, + ); - return ret; + 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( @@ -95,18 +89,7 @@ async function getAllMemeList( .limit(size) .sort({ createdAt: -1 }); - const ret: IMemeGetResponse[] = await Promise.all( - memeList.map(async (meme) => { - const keywords = await KeywordService.getKeywordInfoByKeywordIds(meme.keywordIds); - const isSaved = await MemeInteractionModel.findOne({ - deviceId: user.deviceId, - memeId: meme._id, - type: InteractionType.SAVE, - isDeleted: false, - }); - return { ..._.omit(meme.toObject(), 'keywordIds'), keywords, isSaved: !_.isNil(isSaved) }; - }), - ); + const allMemeList = await getMemeListWithKeywordsAndisSaved(user, memeList); logger.info(`Get all meme list - page(${page}), size(${size}), total(${totalMemes})`); @@ -114,10 +97,40 @@ async function getAllMemeList( total: totalMemes, page, totalPages: Math.ceil(totalMemes / size), - data: ret, + 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, @@ -170,6 +183,7 @@ async function searchMemeByKeyword( page: number, size: number, keyword: IKeywordDocument, + user: IUserDocument, ): Promise<{ total: number; page: number; totalPages: number; data: IMemeGetResponse[] }> { try { const totalMemes = await MemeModel.countDocuments({ @@ -177,7 +191,7 @@ async function searchMemeByKeyword( isDeleted: false, }); - const memeList = await MemeModel.find( + const searchedMemeList = await MemeModel.find( { isDeleted: false, keywordIds: { $in: keyword._id } }, { isDeleted: 0 }, ) @@ -186,17 +200,7 @@ async function searchMemeByKeyword( .sort({ reaction: -1 }) .lean(); - const ret = await Promise.all( - memeList.map(async (meme) => { - const keywords = await KeywordService.getKeywordInfoByKeywordIds(meme.keywordIds); - const isSaved = await MemeInteractionModel.findOne({ - memeId: meme._id, - type: InteractionType.SAVE, - isDeleted: false, - }); - return { ..._.omit(meme, 'keywordIds'), keywords, isSaved: !_.isNil(isSaved) }; - }), - ); + const memeList = await getMemeListWithKeywordsAndisSaved(user, searchedMemeList); logger.info( `Get all meme list with keyword(${keyword.name}) - page(${page}), size(${size}), total(${totalMemes})`, @@ -206,7 +210,7 @@ async function searchMemeByKeyword( total: totalMemes, page, totalPages: Math.ceil(totalMemes / size), - data: ret, + data: memeList, }; } catch (err) { logger.error(`Failed to search meme list with keyword(${keyword})`, err.message); @@ -223,54 +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, - }); + ); - // 밈당 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}`, ); - if (interactionType === InteractionType.SAVE && memeInteraction.isDeleted) { - // 'save'인 경우 isDeleted를 false로 업데이트한다. - await MemeInteractionModel.findOneAndUpdate( - { memeId: meme._id, deviceId: user.deviceId, interactionType }, - { $set: { isDeleted: false } }, - ); - } else if (interactionType === InteractionType.REACTION) { - // 'reaction'인 경우에만 Meme의 reaction 수를 업데이트한다. - await MemeModel.findOneAndUpdate( - { memeId: meme._id, isDeleted: false }, - { $inc: { reaction: 1 } }, - { - projection: { _id: 0, createdAt: 0, updatedAt: 0 }, - returnDocument: 'after', - }, - ).lean(); - } else { - // 'watch', 'share'인 경우 isDeleted 여부 로그를 남긴다. - logger.debug( - `${memeInteraction.interactionType} document exist - isDeleted(${memeInteraction.isDeleted})`, - ); - } - } else { - const newMemeInteraction = await MemeInteractionModel.create({ - memeId: meme._id, - deviceId: user.deviceId, - interactionType, - }); - await newMemeInteraction.save(); + // 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, ); } @@ -278,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 { @@ -332,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 c7c2a84..0d29bee 100644 --- a/src/service/user.service.ts +++ b/src/service/user.service.ts @@ -2,6 +2,8 @@ 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, IMemeGetResponse, MemeModel } from '../model/meme'; @@ -12,8 +14,6 @@ import { IMemeRecommendWatchCreatePayload, } from '../model/memeRecommendWatch'; import { IUser, IUserDocument, IUserInfos, UserModel } from '../model/user'; - -import * as KeywordService from './keyword.service'; import { logger } from '../util/logger'; async function getUser(deviceId: string): Promise { @@ -135,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( @@ -146,79 +146,53 @@ async function getLastSeenMemes(user: IUserDocument): Promise { - const keywords = await KeywordService.getKeywordInfoByKeywordIds(meme.keywordIds); - const isSaved = await MemeInteractionModel.findOne({ - deviceId: user.deviceId, - memeId: meme._id, - type: InteractionType.SAVE, - isDeleted: false, - }); - return { ..._.omit(meme, 'keywordIds'), keywords, isSaved: !_.isNil(isSaved) }; - }), + const getLastSeenMemeList = await MemeService.getMemeListWithKeywordsAndisSaved(user, memeList); + logger.info( + `Get lastSeenMemeList - deviceId(${user.deviceId}), memeList(${getLastSeenMemeList})`, ); - logger.info(`Get lastSeenMeme - deviceId(${user.deviceId}), memeList(${ret})`); - return ret; + 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: 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 ret = await Promise.all( - memeList.map(async (meme) => { - const keywords = await KeywordService.getKeywordInfoByKeywordIds(meme.keywordIds); - const isSaved = await MemeInteractionModel.findOne({ - deviceId: user.deviceId, - memeId: meme._id, - type: InteractionType.SAVE, - isDeleted: false, - }); - return { ..._.omit(meme, 'keywordIds'), keywords, isSaved: !_.isNil(isSaved) }; - }), - ); + 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: ret, + data: savedMemeList, }; } catch (error) { throw new CustomError(`Failed to get saved memes`, HttpCode.INTERNAL_SERVER_ERROR, error); @@ -275,8 +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(); }); });