diff --git a/loadenv.js b/loadenv.js index cbf47d88..4e589543 100644 --- a/loadenv.js +++ b/loadenv.js @@ -43,7 +43,18 @@ module.exports = { slackWebhookUrl: { report: process.env.SLACK_REPORT_WEBHOOK_URL || "", // optional }, - eventConfig: process.env.EVENT_CONFIG && JSON.parse(process.env.EVENT_CONFIG), // optional + eventConfig: (process.env.EVENT_CONFIG && + JSON.parse(process.env.EVENT_CONFIG)) || { + mode: "2024fall", + credit: { + name: "송편코인", + initialAmount: 0, + }, + period: { + startAt: new Date("2024-09-07T00:00:00+09:00"), + endAt: new Date("2024-09-24T00:00:00+09:00"), + }, + }, // optional naverMap: { apiId: process.env.NAVER_MAP_API_ID, // optional apiKey: process.env.NAVER_MAP_API_KEY, //optional diff --git a/src/lottery/index.js b/src/lottery/index.js index d485dfe1..a00e3598 100644 --- a/src/lottery/index.js +++ b/src/lottery/index.js @@ -28,10 +28,10 @@ lotteryRouter.use(require("../middlewares/originValidator")); // [Router] APIs lotteryRouter.use("/globalState", require("./routes/globalState")); -lotteryRouter.use("/invite", require("./routes/invite")); +lotteryRouter.use("/invites", require("./routes/invites")); lotteryRouter.use("/transactions", require("./routes/transactions")); lotteryRouter.use("/items", require("./routes/items")); -lotteryRouter.use("/publicNotice", require("./routes/publicNotice")); +// lotteryRouter.use("/publicNotice", require("./routes/publicNotice")); lotteryRouter.use("/quests", require("./routes/quests")); // [AdminJS] AdminJS에 표시할 Resource 생성 diff --git a/src/lottery/modules/contracts.js b/src/lottery/modules/contracts.js index 72a62deb..a415b0af 100644 --- a/src/lottery/modules/contracts.js +++ b/src/lottery/modules/contracts.js @@ -13,19 +13,10 @@ const quests = buildQuests({ firstLogin: { name: "첫 발걸음", description: - "로그인만 해도 넙죽코인을 얻을 수 있다고?? 이벤트 기간에 처음으로 SPARCS Taxi 서비스에 로그인하여 넙죽코인을 받아보세요.", + "이벤트 참여만 해도 송편코인을 얻을 수 있다고?? 이벤트 참여에 동의하고 송편코인을 받아 보세요.", imageUrl: "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_firstLogin.png", - reward: 50, - }, - payingAndSending: { - name: "함께하는 택시의 여정", - description: - "2명 이상과 함께 택시를 타고 정산/송금까지 완료해보세요. 최대 3번까지 넙죽코인을 받을 수 있어요. 정산/송금 버튼은 채팅 페이지 좌측 하단의 +버튼을 눌러 확인할 수 있어요.", - imageUrl: - "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_payingAndSending.png", - reward: 150, - maxCount: 0, + reward: 200, }, firstRoomCreation: { name: "첫 방 개설", @@ -33,33 +24,33 @@ const quests = buildQuests({ "원하는 택시팟을 찾을 수 없다면? 원하는 조건으로 방 개설 페이지에서 방을 직접 개설해보세요.", imageUrl: "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_firstRoomCreation.png", - reward: 50, + reward: 500, }, roomSharing: { - name: "너 T야? Taxi", + name: "이 택시팟은 진짜 유명한 택시팟임", description: - "방을 공유해 친구들을 택시에 초대해보세요. 채팅창 상단의 햄버거(☰) 버튼을 누르면 공유하기 버튼을 찾을 수 있어요.", + "방을 공유해 친구들을 택시팟에 초대해 보세요. 채팅창 상단의 햄버거(☰) 버튼을 누르면 공유하기 버튼을 찾을 수 있어요.", imageUrl: "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_roomSharing.png", - reward: 50, + reward: 500, isApiRequired: true, }, - paying: { - name: "정산해요 택시의 숲", + fareSettlement: { + name: "정산의 신, 신팍스", description: - "2명 이상과 함께 택시를 타고 택시비를 결제한 후 정산하기를 요청해보세요. 정산하기 버튼은 채팅 페이지 좌측 하단의 +버튼을 눌러 확인할 수 있어요.", + "2명 이상과 함께 택시를 타고 택시비를 결제한 후 정산을 요청해 보세요. 정산하기 버튼은 채팅 페이지 좌측 하단의 + 버튼을 눌러 찾을 수 있어요.", imageUrl: "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_paying.png", - reward: 100, + reward: 2000, maxCount: 0, }, - sending: { + farePayment: { name: "송금 완료면 I am 신뢰에요", description: "2명 이상과 함께 택시를 타고 택시비를 결제한 분께 송금해주세요. 송금하기 버튼은 채팅 페이지 좌측 하단의 +버튼을 눌러 확인할 수 있어요.", imageUrl: "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_sending.png", - reward: 50, + reward: 2000, maxCount: 0, }, nicknameChanging: { @@ -68,7 +59,7 @@ const quests = buildQuests({ "닉네임을 변경하여 자신을 표현하세요. 마이페이지수정하기 버튼을 눌러 닉네임을 수정할 수 있어요.", imageUrl: "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_nicknameChanging.png", - reward: 50, + reward: 500, }, accountChanging: { name: "계좌 등록을 해야 능률이 올라갑니다", @@ -76,7 +67,7 @@ const quests = buildQuests({ "정산하기 기능을 더욱 빠르고 이용할 수 있다고? 계좌번호를 등록하면 정산하기를 할 때 계좌가 자동으로 입력돼요. 마이페이지수정하기 버튼을 눌러 계좌번호를 등록 또는 수정할 수 있어요.", imageUrl: "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_accountChanging.png", - reward: 50, + reward: 500, }, adPushAgreement: { name: "Taxi의 소울메이트", @@ -84,25 +75,30 @@ const quests = buildQuests({ "Taxi 서비스를 잊지 않도록 가끔 찾아갈게요! 광고성 푸시 알림 수신 동의를 해주시면 방이 많이 모이는 시즌, 주변에 택시앱 사용자가 있을 때 알려드릴 수 있어요.", imageUrl: "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_adPushAgreement.png", - reward: 50, + reward: 500, }, eventSharing: { - name: "너 나랑 ㅌ태태택 (1명)", - description: - "내가 초대한 사람이 Taxi에 가입하여 이벤트에 참여하면 넙죽코인을 드려요. 내가 초대한 사람도 넙죽코인을 받아요. 이벤트 안내 페이지의 이벤트 공유하기 버튼을 통해 카카오톡으로 초대 문자를 보낼 수 있어요!", + name: "Taxi를 아십니까", + description: "내가 초대한 사람이 이벤트에 참여하면 송편코인을 드려요.", imageUrl: "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_eventSharing.png", - reward: 50, + reward: 700, maxCount: 0, }, - eventSharing5: { - name: "너 나랑 ㅌ태태택 (5명)", + dailyAttendance: { + name: "하루 한 번 Taxi!", description: - "내가 초대한 사람이 5명이 Taxi에 가입하여 이벤트에 참여하면 넙죽코인을 드려요. 내가 초대한 사람도 넙죽코인을 받아요. 이벤트 안내 페이지의 이벤트 공유하기 버튼을 통해 카카오톡으로 초대 문자를 보낼 수 있어요!", - imageUrl: - "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_eventSharing.png", - reward: 250, - maxCount: 0, + "매일 Taxi에 접속하여 출석 체크를 하면 송편코인을 드려요! 하루에 한 번, 택시팟도 둘러보고 송편코인도 받아 가세요. 송편코인을 얻으려면 출석 체크 페이지에서 출석 버튼을 눌러야 해요.", + imageUrl: "", + reward: 700, + maxCount: 17, + isApiRequired: true, + }, + itemPurchase: { + name: "itemPurchase", + description: "itemPurchase", + imageUrl: "", + reward: 500, }, }); @@ -111,40 +107,12 @@ const quests = buildQuests({ * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @returns {Promise} - * @usage lottery/globalState/createUserGlobalStateHandler + * @usage lottery/globalState - createUserGlobalStateHandler */ const completeFirstLoginQuest = async (userId, timestamp) => { return await completeQuest(userId, timestamp, quests.firstLogin); }; -/** - * payingAndSending 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. - * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. - * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. - * @param {Object} roomObject - 방의 정보입니다. - * @param {mongoose.Types.ObjectId} roomObject._id - 방의 ObjectId입니다. - * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. - * @param {Date} roomObject.time - 출발 시각입니다. - * @returns {Promise} - * @description 정산 요청 또는 송금이 이루어질 때마다 호출해 주세요. - * @usage rooms - commitSettlementHandler, rooms - commitPaymentHandler - */ -const completePayingAndSendingQuest = async (userId, timestamp, roomObject) => { - logger.info( - `User ${userId} requested to complete payingAndSendingQuest in Room ${roomObject._id}` - ); - - if (roomObject.part.length < 2) return null; - if ( - !eventPeriod || - roomObject.time >= eventPeriod.endAt || - roomObject.time < eventPeriod.startAt - ) - return null; // 택시 출발 시각이 이벤트 기간 내에 포함되지 않는 경우 퀘스트 완료 요청을 하지 않습니다. - - return await completeQuest(userId, timestamp, quests.payingAndSending); -}; - /** * firstRoomCreation 퀘스트의 완료를 요청합니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. @@ -158,7 +126,7 @@ const completeFirstRoomCreationQuest = async (userId, timestamp) => { }; /** - * paying 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. + * fareSettlement 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @param {Object} roomObject - 방의 정보입니다. @@ -169,9 +137,9 @@ const completeFirstRoomCreationQuest = async (userId, timestamp) => { * @description 정산 요청이 이루어질 때마다 호출해 주세요. * @usage rooms - commitSettlementHandler */ -const completePayingQuest = async (userId, timestamp, roomObject) => { +const completeFareSettlementQuest = async (userId, timestamp, roomObject) => { logger.info( - `User ${userId} requested to complete payingQuest in Room ${roomObject._id}` + `User ${userId} requested to complete fareSettlementQuest in Room ${roomObject._id}` ); if (roomObject.part.length < 2) return null; @@ -182,11 +150,11 @@ const completePayingQuest = async (userId, timestamp, roomObject) => { ) return null; // 택시 출발 시각이 이벤트 기간 내에 포함되지 않는 경우 퀘스트 완료 요청을 하지 않습니다. - return await completeQuest(userId, timestamp, quests.paying); + return await completeQuest(userId, timestamp, quests.fareSettlement); }; /** - * sending 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. + * farePayment 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @param {Object} roomObject - 방의 정보입니다. @@ -197,9 +165,9 @@ const completePayingQuest = async (userId, timestamp, roomObject) => { * @description 송금이 이루어질 때마다 호출해 주세요. * @usage rooms - commitPaymentHandler */ -const completeSendingQuest = async (userId, timestamp, roomObject) => { +const completeFarePaymentQuest = async (userId, timestamp, roomObject) => { logger.info( - `User ${userId} requested to complete sendingQuest in Room ${roomObject._id}` + `User ${userId} requested to complete farePaymentQuest in Room ${roomObject._id}` ); if (roomObject.part.length < 2) return null; @@ -210,7 +178,7 @@ const completeSendingQuest = async (userId, timestamp, roomObject) => { ) return null; // 택시 출발 시각이 이벤트 기간 내에 포함되지 않는 경우 퀘스트 완료 요청을 하지 않습니다. - return await completeQuest(userId, timestamp, quests.sending); + return await completeQuest(userId, timestamp, quests.farePayment); }; /** @@ -241,13 +209,13 @@ const completeAccountChangingQuest = async (userId, timestamp, newAccount) => { }; /** - * adPushAgreementQuest 퀘스트의 완료를 요청합니다. + * adPushAgreement 퀘스트의 완료를 요청합니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @param {boolean} advertisement - 변경된 광고성 알림 수신 동의 여부입니다. * @returns {Promise} * @description 알림 옵션을 변경할 때마다 호출해 주세요. - * @usage notifications/editOptionsHandler + * @usage notifications - editOptionsHandler */ const completeAdPushAgreementQuest = async ( userId, @@ -260,38 +228,36 @@ const completeAdPushAgreementQuest = async ( }; /** - * eventSharing, eventSharing5 퀘스트의 완료를 요청합니다. + * eventSharing 퀘스트의 완료를 요청합니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @returns {Promise} - * @description 초대 링크를 통해 사용자가 이벤트에 참여할 때마다, 초대한 사용자 및 초대받은 사용자에 대해 각각 호출해 주세요. + * @usage lottery/globalState - createUserGlobalStateHandler */ const completeEventSharingQuest = async (userId, timestamp) => { - const eventSharingResult = await completeQuest( - userId, - timestamp, - quests.eventSharing - ); - if (!eventSharingResult || eventSharingResult.questCount % 5 !== 0) - return [eventSharingResult, null]; + return await completeQuest(userId, timestamp, quests.eventSharing); +}; - const eventSharing5Result = await completeQuest( - userId, - timestamp, - quests.eventSharing5 - ); - return [eventSharingResult, eventSharing5Result]; +/** + * itemPurchase 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @returns {Promise} + * @description 상품을 구입할 때마다 호출해 주세요. + */ +const completeItemPurchaseQuest = async (userId, timestamp) => { + return await completeQuest(userId, timestamp, quests.itemPurchase); }; module.exports = { quests, completeFirstLoginQuest, - completePayingAndSendingQuest, completeFirstRoomCreationQuest, - completePayingQuest, - completeSendingQuest, + completeFareSettlementQuest, + completeFarePaymentQuest, completeNicknameChangingQuest, completeAccountChangingQuest, completeAdPushAgreementQuest, completeEventSharingQuest, + completeItemPurchaseQuest, }; diff --git a/src/lottery/modules/populates/transactions.js b/src/lottery/modules/populates/transactions.js index 6d965258..a09428c5 100644 --- a/src/lottery/modules/populates/transactions.js +++ b/src/lottery/modules/populates/transactions.js @@ -1,8 +1,7 @@ const transactionPopulateOption = [ { - path: "item", - select: - "name imageUrl instagramStoryStickerImageUrl price description isDisabled stock itemType", + path: "itemId", + select: "name imageUrl", }, ]; diff --git a/src/lottery/modules/quests.js b/src/lottery/modules/quests.js index 04c6cd4c..0d79ac81 100644 --- a/src/lottery/modules/quests.js +++ b/src/lottery/modules/quests.js @@ -14,6 +14,7 @@ const eventPeriod = eventConfig && { }; const requiredQuestFields = ["name", "description", "imageUrl", "reward"]; + const buildQuests = (quests) => { for (const [id, quest] of Object.entries(quests)) { // quest에 필수 필드가 모두 포함되어 있는지 확인합니다. @@ -61,7 +62,7 @@ const buildQuests = (quests) => { * @param {number} quest.reward.credit - 퀘스트의 완료 보상 중 재화의 양입니다. * @param {number} quest.reward.ticket1 - 퀘스트의 완료 보상 중 일반 티켓의 개수입니다. * @param {number} quest.maxCount - 퀘스트의 최대 완료 가능 횟수입니다. - * @returns {Object|null} 성공한 경우 Object를, 실패한 경우 null을 반환합니다. 이미 최대 완료 횟수에 도달했거나, 퀘스트가 원격으로 비활성화 된 경우에도 실패로 처리됩니다. + * @returns {Object|null} 성공한 경우 Object를, 실패한 경우 null을 반환합니다. 이미 최대 완료 횟수에 도달했거나, 퀘스트가 원격으로 비활성화된 경우에도 실패로 처리됩니다. */ const completeQuest = async (userId, timestamp, quest) => { try { @@ -118,7 +119,10 @@ const completeQuest = async (userId, timestamp, quest) => { ticket1Amount: quest.reward.ticket1, }, $push: { - completedQuests: quest.id, + completedQuests: { + questId: quest.id, + completedAt: timestamp, + }, }, } ); @@ -143,7 +147,7 @@ const completeQuest = async (userId, timestamp, quest) => { amount: 0, userId, questId: quest.id, - item: ticket1._id, + itemId: ticket1._id, comment: `"${quest.name}" 퀘스트를 완료해 "${ticket1.name}" ${quest.reward.ticket1}개를 획득했습니다.`, }); await transaction.save(); diff --git a/src/lottery/modules/stores/mongo.js b/src/lottery/modules/stores/mongo.js index 600c99ec..09b6c80e 100644 --- a/src/lottery/modules/stores/mongo.js +++ b/src/lottery/modules/stores/mongo.js @@ -10,6 +10,17 @@ const integerValidator = { message: "{VALUE} is not an integer value", }; +const completedQuestSchema = Schema({ + questId: { + type: String, + required: true, + }, + completedAt: { + type: Date, + required: true, + }, +}); + const eventStatusSchema = Schema({ userId: { type: Schema.Types.ObjectId, @@ -17,7 +28,7 @@ const eventStatusSchema = Schema({ required: true, }, completedQuests: { - type: [String], + type: [completedQuestSchema], default: [], }, creditAmount: { @@ -42,17 +53,11 @@ const eventStatusSchema = Schema({ type: Boolean, default: false, }, - group: { - type: Number, - required: true, - min: 1, - validate: integerValidator, - }, // 소속된 새터반 inviter: { type: Schema.Types.ObjectId, ref: "User", }, // 이 사용자를 초대한 사용자 - isEnabledInviteUrl: { + isInviteUrlEnabled: { type: Boolean, default: false, }, // 초대 링크 활성화 여부 @@ -101,7 +106,13 @@ const itemSchema = Schema({ required: true, min: 0, validate: integerValidator, - }, + }, // 의미 없는 값, 기존 코드와의 호환성을 위해 남겨둡니다. + realStock: { + type: Number, + required: true, + min: 1, + validate: integerValidator, + }, // 상품의 실제 재고 itemType: { type: Number, enum: [0, 1, 2, 3], @@ -124,13 +135,13 @@ const transactionSchema = Schema({ type: String, enum: ["get", "use"], required: true, - }, + }, // get: 재화 획득, use: 재화 사용 amount: { type: Number, required: true, min: 0, validate: integerValidator, - }, + }, // 재화의 변화량의 절댓값 userId: { type: Schema.Types.ObjectId, ref: "User", @@ -138,22 +149,23 @@ const transactionSchema = Schema({ }, questId: { type: String, - }, - item: { + }, // 완료한 퀘스트의 ID + itemId: { type: Schema.Types.ObjectId, ref: `${modelNamePrefix}Item`, - }, - itemType: { + }, // 획득한 상품의 ID + itemAmount: { type: Number, - enum: [0, 1, 2, 3], - }, + min: 1, + validate: integerValidator, + }, // 획득한 상품의 개수 comment: { type: String, required: true, }, }); transactionSchema.set("timestamps", { - createdAt: "createAt", + createdAt: "createdAt", updatedAt: false, }); diff --git a/src/lottery/routes/docs/globalState.js b/src/lottery/routes/docs/globalState.js index 4af3493e..1bbf23f4 100644 --- a/src/lottery/routes/docs/globalState.js +++ b/src/lottery/routes/docs/globalState.js @@ -5,24 +5,21 @@ const globalStateDocs = {}; globalStateDocs[`${apiPrefix}/`] = { get: { tags: [`${apiPrefix}`], - summary: "Frontend에서 Global state로 관리하는 정보 반환", + summary: "Frontend에서 Global State로 관리하는 정보 반환", description: - "유저의 재화 개수, 퀘스트 완료 상태 등 Frontend에서 Global state로 관리할 정보를 가져옵니다.", + "유저의 재화 개수, 퀘스트 완료 상태 등 Frontend에서 Global State로 관리하는 정보를 가져옵니다.", responses: { 200: { - description: "", content: { "application/json": { schema: { type: "object", required: [ "isAgreeOnTermsOfEvent", - "isEligible", + "isBanned", "creditAmount", - "groupCreditAmount", - "completedQuests", - "group", "quests", + "completedQuests", ], properties: { isAgreeOnTermsOfEvent: { @@ -30,44 +27,19 @@ globalStateDocs[`${apiPrefix}/`] = { description: "유저의 이벤트 참여 동의 여부", example: true, }, - isEligible: { - type: "boolean", - description: "유저의 이벤트 참여 가능 여부", - example: true, - }, - creditAmount: { - type: "number", - description: "재화 개수. 0 이상입니다.", - example: 1000, - }, - groupCreditAmount: { - type: "number", - description: "소속 새터반에 소속된 유저의 전체 재화 개수", - example: 35000, - }, - completedQuests: { - type: "array", - description: - "유저가 완료한 퀘스트의 배열. 여러 번 완료할 수 있는 퀘스트의 경우 배열 내에 같은 퀘스트가 여러 번 포함됩니다.", - items: { - type: "string", - description: "Quest의 Id", - example: "QUEST ID", - }, - }, isBanned: { type: "boolean", - description: "해당 유저 제재 대상 여부", + description: "유저의 이벤트 참여 제한 여부", example: false, }, - group: { + creditAmount: { type: "number", - description: "유저의 소속 새터반", - example: 16, + description: "유저의 재화 개수. 0 이상의 정수입니다.", + example: 1000, }, quests: { type: "array", - description: "Quest의 배열", + description: "전체 퀘스트의 배열", items: { type: "object", required: [ @@ -82,7 +54,7 @@ globalStateDocs[`${apiPrefix}/`] = { properties: { id: { type: "string", - description: "Quest의 Id", + description: "퀘스트의 Id", example: "QUEST ID", }, name: { @@ -98,34 +70,54 @@ globalStateDocs[`${apiPrefix}/`] = { }, imageUrl: { type: "string", - description: "이미지 썸네일 URL", + description: "퀘스트의 썸네일 이미지 URL", example: "THUMBNAIL URL", }, reward: { type: "object", - description: "완료 보상", required: ["credit"], properties: { credit: { type: "number", - description: "완료 보상 중 재화의 개수입니다.", + description: "퀘스트의 완료 보상 중 재화의 개수", example: 100, }, }, }, maxCount: { type: "number", - description: "최대 완료 가능 횟수", + description: "퀘스트의 최대 완료 가능 횟수", example: 1, }, isApiRequired: { type: "boolean", - description: `/events/${eventConfig?.mode}/quests/complete/:questId API를 통해 퀘스트 완료를 요청할 수 있는지 여부`, + description: `/events/${eventConfig?.mode}/quests/complete/:questId API를 통해 퀘스트 완료를 요청해야 하는지의 여부`, example: false, }, }, }, }, + completedQuests: { + type: "array", + description: + "유저가 완료한 퀘스트의 배열. 여러 번 완료한 퀘스트의 경우 배열 내에 같은 퀘스트가 여러 번 포함됩니다.", + items: { + type: "object", + required: ["id", "completedAt"], + properties: { + id: { + type: "string", + description: "퀘스트의 Id", + example: "QUEST ID", + }, + completedAt: { + type: "string", + description: "퀘스트의 완료 시각", + example: "2023-01-01 00:00:00", + }, + }, + }, + }, }, }, }, @@ -137,11 +129,10 @@ globalStateDocs[`${apiPrefix}/`] = { globalStateDocs[`${apiPrefix}/create`] = { post: { tags: [`${apiPrefix}`], - summary: "Frontend에서 Global state로 관리하는 정보 생성", + summary: "Frontend에서 Global State로 관리할 정보 생성", description: - "유저의 재화 개수, 퀘스트 완료 상태 등 Frontend에서 Global state로 관리할 정보를 생성합니다.", + "유저의 재화 개수, 퀘스트 완료 상태 등 Frontend에서 Global State로 관리할 정보를 생성합니다.", requestBody: { - description: "", content: { "application/json": { schema: { @@ -152,7 +143,6 @@ globalStateDocs[`${apiPrefix}/create`] = { }, responses: { 200: { - description: "", content: { "application/json": { schema: { diff --git a/src/lottery/routes/docs/invite.js b/src/lottery/routes/docs/invites.js similarity index 60% rename from src/lottery/routes/docs/invite.js rename to src/lottery/routes/docs/invites.js index 3a3972da..cfe37214 100644 --- a/src/lottery/routes/docs/invite.js +++ b/src/lottery/routes/docs/invites.js @@ -1,25 +1,23 @@ const { eventConfig } = require("../../../../loadenv"); -const apiPrefix = `/events/${eventConfig?.mode}/invite`; +const apiPrefix = `/events/${eventConfig?.mode}/invites`; -const inviteDocs = {}; -inviteDocs[`${apiPrefix}/search/:inviter`] = { +const invitesDocs = {}; +invitesDocs[`${apiPrefix}/search/{inviter}`] = { get: { tags: [`${apiPrefix}`], - summary: "초대자 정보 조회", - description: "초대자의 정보를 조회합니다.", - requestBody: { - description: "", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/searchInviterHandler", - }, - }, + summary: "초대한 유저의 정보 반환", + description: "초대한 유저의 정보를 가져옵니다.", + parameters: [ + { + in: "path", + name: "inviter", + required: true, + description: "초대한 유저의 eventStatus ObjectId", + example: "INVITER ID", }, - }, + ], responses: { 200: { - description: "", content: { "application/json": { schema: { @@ -28,13 +26,13 @@ inviteDocs[`${apiPrefix}/search/:inviter`] = { properties: { nickname: { type: "string", - description: "초대자의 닉네임", - example: "asdf", + description: "초대한 유저의 닉네임", + example: "static", }, profileImageUrl: { type: "string", - description: "초대자의 프로필 이미지 URL", - example: "IMAGE URL", + description: "초대한 유저의 프로필 이미지 URL", + example: "PROFILE URL", }, }, }, @@ -44,14 +42,13 @@ inviteDocs[`${apiPrefix}/search/:inviter`] = { }, }, }; -inviteDocs[`${apiPrefix}/create`] = { +invitesDocs[`${apiPrefix}/create`] = { post: { tags: [`${apiPrefix}`], summary: "초대 링크 생성", description: "초대 링크를 생성합니다.", responses: { 200: { - description: "", content: { "application/json": { schema: { @@ -72,4 +69,4 @@ inviteDocs[`${apiPrefix}/create`] = { }, }; -module.exports = inviteDocs; +module.exports = invitesDocs; diff --git a/src/lottery/routes/docs/items.js b/src/lottery/routes/docs/items.js index b08aeaf7..063e5f45 100644 --- a/src/lottery/routes/docs/items.js +++ b/src/lottery/routes/docs/items.js @@ -2,15 +2,13 @@ const { eventConfig } = require("../../../../loadenv"); const apiPrefix = `/events/${eventConfig?.mode}/items`; const itemsDocs = {}; -itemsDocs[`${apiPrefix}/list`] = { +itemsDocs[`${apiPrefix}/`] = { get: { tags: [`${apiPrefix}`], - summary: "상점에서 판매하는 모든 상품의 목록 반환", - description: - "상점에서 판매하는 모든 상품의 목록을 가져옵니다. 매진된 상품도 가져옵니다.", + summary: "상점에서 판매하는 상품의 목록 반환", + description: "상점에서 판매하는 상품의 목록을 가져옵니다.", responses: { 200: { - description: "", content: { "application/json": { schema: { @@ -19,9 +17,61 @@ itemsDocs[`${apiPrefix}/list`] = { properties: { items: { type: "array", - description: "Item의 배열", + description: "상품의 배열", items: { - $ref: "#/components/schemas/item", + type: "object", + required: [ + "_id", + "name", + "description", + "imageUrl", + "price", + "isDisabled", + "itemType", + ], + properties: { + _id: { + type: "string", + description: "상품의 ObjectId", + example: "ITEM ID", + }, + name: { + type: "string", + description: "상품의 이름", + example: "진짜 송편", + }, + description: { + type: "string", + description: "상품의 설명", + example: "먹을 수 있는 송편입니다.", + }, + imageUrl: { + type: "string", + description: "상품의 썸네일 이미지 URL", + example: "THUMBNAIL URL", + }, + instagramStoryStickerImageUrl: { + type: "string", + description: "인스타그램 스토리 스티커 이미지 URL", + example: "STICKER URL", + }, + price: { + type: "number", + description: "상품의 가격. 0 이상의 정수입니다.", + example: 400, + }, + isDisabled: { + type: "boolean", + description: "상품의 판매 중지 여부", + example: false, + }, + itemType: { + type: "number", + description: + "상품의 유형. 0: 일반 상품, 1: 일반 티켓, 2: 고급 티켓, 3: 랜덤박스입니다.", + example: 0, + }, + }, }, }, }, @@ -32,56 +82,229 @@ itemsDocs[`${apiPrefix}/list`] = { }, }, }; -itemsDocs[`${apiPrefix}/purchase/:itemId`] = { - post: { +itemsDocs[`${apiPrefix}/{itemId}`] = { + get: { tags: [`${apiPrefix}`], - summary: "상품 구매", - description: "상품을 구매합니다.", - requestBody: { - description: "", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/purchaseHandler", + summary: "상점에서 판매하는 특정 상품의 정보 반환", + description: "상점에서 판매하는 특정 상품의 정보를 가져옵니다.", + parameters: [ + { + in: "path", + name: "itemId", + required: true, + description: "상품 정보를 조회할 ObjectId", + example: "ITEM ID", + }, + ], + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + required: ["item"], + properties: { + item: { + type: "object", + required: [ + "_id", + "name", + "description", + "imageUrl", + "price", + "isDisabled", + "itemType", + ], + description: "상품의 정보", + properties: { + _id: { + type: "string", + description: "상품의 ObjectId", + example: "ITEM ID", + }, + name: { + type: "string", + description: "상품의 이름", + example: "진짜 송편", + }, + description: { + type: "string", + description: "상품의 설명", + example: "먹을 수 있는 송편입니다.", + }, + imageUrl: { + type: "string", + description: "상품의 썸네일 이미지 URL", + example: "THUMBNAIL URL", + }, + instagramStoryStickerImageUrl: { + type: "string", + description: "인스타그램 스토리 스티커 이미지 URL", + example: "STICKER URL", + }, + price: { + type: "number", + description: "상품의 가격. 0 이상의 정수입니다.", + example: 400, + }, + isDisabled: { + type: "boolean", + description: "상품의 판매 중지 여부", + example: false, + }, + itemType: { + type: "number", + description: + "상품의 유형. 0: 일반 상품, 1: 일반 티켓, 2: 고급 티켓, 3: 랜덤박스입니다.", + example: 0, + }, + }, + }, + }, + }, }, }, }, }, + }, +}; +itemsDocs[`${apiPrefix}/leaderboard/{itemId}`] = { + get: { + tags: [`${apiPrefix}`], + summary: "상품 리더보드 반환", + description: "상품 리더보드를 가져옵니다. 일반 상품만 리더보드를 갖습니다.", + parameters: [ + { + in: "path", + name: "itemId", + required: true, + description: "리더보드를 조회할 상품의 ObjectId", + example: "ITEM ID", + }, + ], responses: { 200: { - description: "", content: { "application/json": { schema: { type: "object", - required: ["result"], + required: ["leaderboard", "totalAmount", "totalUser"], properties: { - result: { - type: "boolean", - description: "성공 여부. 항상 true입니다.", - example: true, + leaderboard: { + type: "array", + description: "상품 리더보드. 상위 20등까지만 반환됩니다.", + items: { + type: "object", + required: [ + "nickname", + "profileImageUrl", + "amount", + "probability", + "rank", + ], + properties: { + nickname: { + type: "string", + description: "유저의 닉네임", + example: "static", + }, + profileImageUrl: { + type: "string", + description: "유저의 프로필 이미지 URL", + example: "PROFILE URL", + }, + amount: { + type: "number", + description: "유저가 상품을 구입한 횟수", + example: 3, + }, + probability: { + type: "number", + description: "유저가 상품에 당첨될 확률", + example: 0.1, + }, + rank: { + type: "number", + description: "순위", + example: 1, + }, + }, + }, + }, + totalAmount: { + type: "number", + description: "상품의 총 판매량", + example: 100, + }, + totalUser: { + type: "number", + description: "상품을 구입한 유저의 수", + example: 50, + }, + rank: { + type: "number", + description: "현재 유저의 리더보드 순위. 1부터 시작합니다.", + example: 1, }, - reward: { - $ref: "#/components/schemas/rewardItem", + amount: { + type: "number", + description: "현재 유저가 상품을 구입한 횟수", + example: 3, + }, + probability: { + type: "number", + description: "현재 유저가 상품에 당첨될 확률", + example: 0.1, }, }, }, }, }, }, - 400: { - description: - "checkBanned에서 이벤트에 동의하지 않은 사람과 제재 대상을 선별합니다.", + }, + }, +}; +itemsDocs[`${apiPrefix}/purchase/{itemId}`] = { + post: { + tags: [`${apiPrefix}`], + summary: "상품 구입", + description: "상품을 구입합니다.", + parameters: [ + { + in: "path", + name: "itemId", + required: true, + description: "리더보드를 조회할 상품의 ObjectId", + example: "ITEM ID", + }, + ], + requestBody: { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/purchaseItemHandlerBody", + }, + }, + }, + }, + responses: { + 200: { content: { "application/json": { schema: { type: "object", - required: ["error"], + required: ["result"], properties: { - error: { - type: "string", - description: "", - example: "checkBanned: banned user", + result: { + type: "boolean", + description: "성공 여부. 항상 true입니다.", + example: true, + }, + isJackpot: { + type: "boolean", + description: + "대박 여부. 랜덤박스를 구입한 경우에만 포함됩니다.", + example: true, }, }, }, diff --git a/src/lottery/routes/docs/publicNotice.js b/src/lottery/routes/docs/publicNotice.js index 23a410b2..bcf2cc78 100644 --- a/src/lottery/routes/docs/publicNotice.js +++ b/src/lottery/routes/docs/publicNotice.js @@ -2,7 +2,7 @@ const { eventConfig } = require("../../../../loadenv"); const apiPrefix = `/events/${eventConfig?.mode}/publicNotice`; const publicNoticeDocs = {}; -// 다음 Endpoint는 2024 봄학기 이벤트에서 사용되지 않습니다. +// 다음 Endpoint들은 2024 추석 이벤트에서 사용되지 않습니다. // // publicNoticeDocs[`${apiPrefix}/recentTransactions`] = { // get: { @@ -35,75 +35,75 @@ const publicNoticeDocs = {}; // }, // }, // }; -publicNoticeDocs[`${apiPrefix}/leaderboard`] = { - get: { - tags: [`${apiPrefix}`], - summary: "리더보드 반환", - description: - "새터반 별 재화 개수 기준의 리더보드와 관련된 정보를 가져옵니다.", - responses: { - 200: { - description: "", - content: { - "application/json": { - schema: { - type: "object", - required: ["leaderboard"], - properties: { - leaderboard: { - type: "array", - description: "이벤트에 참여한 새터반 전체가 포함된 리더보드", - items: { - type: "object", - required: [ - "group", - "creditAmount", - "mvpNickname", - "mvpProfileImageUrl", - ], - properties: { - group: { - type: "number", - description: "새터반", - example: 16, - }, - creditAmount: { - type: "number", - description: "새터반에 소속된 유저의 전체 재화 개수", - example: 3000, - }, - mvpNickname: { - type: "string", - description: - "MVP(새터반 내에서 가장 많은 재화를 가진 유저)의 닉네임", - example: "asdf", - }, - mvpProfileImageUrl: { - type: "string", - description: "MVP의 프로필 이미지 URL", - example: "IMAGE URL", - }, - }, - }, - }, - group: { - type: "number", - description: "유저의 소속 새터반", - example: 16, - }, - rank: { - type: "number", - description: - "유저의 소속 새터반의 리더보드 순위. 1부터 시작합니다.", - example: 1, - }, - }, - }, - }, - }, - }, - }, - }, -}; +// publicNoticeDocs[`${apiPrefix}/leaderboard`] = { +// get: { +// tags: [`${apiPrefix}`], +// summary: "리더보드 반환", +// description: +// "새터반 별 재화 개수 기준의 리더보드와 관련된 정보를 가져옵니다.", +// responses: { +// 200: { +// description: "", +// content: { +// "application/json": { +// schema: { +// type: "object", +// required: ["leaderboard"], +// properties: { +// leaderboard: { +// type: "array", +// description: "이벤트에 참여한 새터반 전체가 포함된 리더보드", +// items: { +// type: "object", +// required: [ +// "group", +// "creditAmount", +// "mvpNickname", +// "mvpProfileImageUrl", +// ], +// properties: { +// group: { +// type: "number", +// description: "새터반", +// example: 16, +// }, +// creditAmount: { +// type: "number", +// description: "새터반에 소속된 유저의 전체 재화 개수", +// example: 3000, +// }, +// mvpNickname: { +// type: "string", +// description: +// "MVP(새터반 내에서 가장 많은 재화를 가진 유저)의 닉네임", +// example: "asdf", +// }, +// mvpProfileImageUrl: { +// type: "string", +// description: "MVP의 프로필 이미지 URL", +// example: "IMAGE URL", +// }, +// }, +// }, +// }, +// group: { +// type: "number", +// description: "유저의 소속 새터반", +// example: 16, +// }, +// rank: { +// type: "number", +// description: +// "유저의 소속 새터반의 리더보드 순위. 1부터 시작합니다.", +// example: 1, +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// }; module.exports = publicNoticeDocs; diff --git a/src/lottery/routes/docs/quests.js b/src/lottery/routes/docs/quests.js index 14694f3e..42a9c022 100644 --- a/src/lottery/routes/docs/quests.js +++ b/src/lottery/routes/docs/quests.js @@ -2,24 +2,22 @@ const { eventConfig } = require("../../../../loadenv"); const apiPrefix = `/events/${eventConfig?.mode}/quests`; const questsDocs = {}; -questsDocs[`${apiPrefix}/complete/:questId`] = { +questsDocs[`${apiPrefix}/complete/{questId}`] = { post: { tags: [`${apiPrefix}`], summary: "퀘스트 완료 요청", description: "퀘스트의 완료를 요청합니다.", - requestBody: { - description: "", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/completeHandler", - }, - }, + parameters: [ + { + in: "path", + name: "questId", + required: true, + description: "완료를 요청할 퀘스트의 ID", + example: "QUEST ID", }, - }, + ], responses: { 200: { - description: "", content: { "application/json": { schema: { @@ -36,25 +34,6 @@ questsDocs[`${apiPrefix}/complete/:questId`] = { }, }, }, - 400: { - description: - "checkBanned에서 이벤트에 동의하지 않은 사람과 제재 대상을 선별합니다.", - content: { - "application/json": { - schema: { - type: "object", - required: ["error"], - properties: { - error: { - type: "string", - description: "", - example: "checkBanned: banned user", - }, - }, - }, - }, - }, - }, }, }, }; diff --git a/src/lottery/routes/docs/schemas/globalStateSchema.js b/src/lottery/routes/docs/schemas/globalStateSchema.js index 0c3c55e6..15055525 100644 --- a/src/lottery/routes/docs/schemas/globalStateSchema.js +++ b/src/lottery/routes/docs/schemas/globalStateSchema.js @@ -6,7 +6,6 @@ const globalStateZod = { createUserGlobalStateHandler: z .object({ phoneNumber: z.string().regex(user.phoneNumber), - group: z.number().gte(1).lte(26), inviter: z.string().regex(objectId), }) .partial({ inviter: true }), diff --git a/src/lottery/routes/docs/schemas/inviteSchema.js b/src/lottery/routes/docs/schemas/invitesSchema.js similarity index 67% rename from src/lottery/routes/docs/schemas/inviteSchema.js rename to src/lottery/routes/docs/schemas/invitesSchema.js index e3016557..dfc33c5c 100644 --- a/src/lottery/routes/docs/schemas/inviteSchema.js +++ b/src/lottery/routes/docs/schemas/invitesSchema.js @@ -2,12 +2,12 @@ const { z } = require("zod"); const { zodToSchemaObject } = require("../../../../routes/docs/utils"); const { objectId } = require("../../../../modules/patterns"); -const inviteZod = { +const invitesZod = { searchInviterHandler: z.object({ inviter: z.string().regex(objectId), }), }; -const inviteSchema = zodToSchemaObject(inviteZod); +const invitesSchema = zodToSchemaObject(invitesZod); -module.exports = { inviteSchema, inviteZod }; +module.exports = { invitesZod, invitesSchema }; diff --git a/src/lottery/routes/docs/schemas/itemsSchema.js b/src/lottery/routes/docs/schemas/itemsSchema.js index 80912cfb..d224ba70 100644 --- a/src/lottery/routes/docs/schemas/itemsSchema.js +++ b/src/lottery/routes/docs/schemas/itemsSchema.js @@ -1,98 +1,22 @@ -/* Item에 대한 기본적인 프로퍼티를 갖고 있는 스키마입니다. - * TODO: 추후 코드 재사용시 상황에 맞춰 zod로 이전이 필요합니다. - */ -const itemBase = { - type: "object", - required: [ - "_id", - "name", - "imageUrl", - "price", - "description", - "isDisabled", - "stock", - ], - properties: { - _id: { - type: "string", - description: "Item의 ObjectId", - example: "OBJECT ID", - }, - name: { - type: "string", - description: "상품의 이름", - example: "진짜송편", - }, - imageUrl: { - type: "string", - description: "이미지 썸네일 URL", - example: "THUMBNAIL URL", - }, - instagramStoryStickerImageUrl: { - type: "string", - description: "인스타그램 스토리 스티커 이미지 URL", - example: "STICKER URL", - }, - price: { - type: "number", - description: "상품의 가격. 0 이상입니다.", - example: 400, - }, - description: { - type: "string", - description: "상품의 설명", - example: "맛있는 송편입니다.", - }, - isDisabled: { - type: "boolean", - description: "판매 중지 여부", - example: false, - }, - stock: { - type: "number", - description: "남은 상품 재고. 재고가 있는 경우 1, 없는 경우 0입니다.", - example: 1, - }, - }, -}; +const { z } = require("zod"); +const { zodToSchemaObject } = require("../../../../routes/docs/utils"); +const { objectId } = require("../../../../modules/patterns"); -/** itemBase에 itemType(상품 유형) 프로퍼티가 추가된 스키마입니다. */ -const itemWithType = { - type: itemBase.type, - required: itemBase.required.concat(["itemType"]), - properties: { - ...itemBase.properties, - itemType: { - type: "number", - description: - "상품 유형. 0: 일반 상품, 1: 일반 티켓, 2: 고급 티켓, 3: 랜덤박스입니다.", - example: 0, - }, - }, +const itemsZod = { + getItemHandler: z.object({ + itemId: z.string().regex(objectId), + }), + getItemLeaderboardHandler: z.object({ + itemId: z.string().regex(objectId), + }), + purchaseItemHandlerParams: z.object({ + itemId: z.string().regex(objectId), + }), + purchaseItemHandlerBody: z.object({ + amount: z.number().int().positive(), + }), }; -const itemsSchema = { - item: itemWithType, - relatedItem: { - ...itemWithType, - description: - "Transaction과 관련된 아이템의 Object. 아이템과 관련된 Transaction인 경우에만 포함됩니다.", - }, - rewardItem: { - ...itemBase, - description: "랜덤박스를 구입한 경우에만 포함됩니다.", - }, - purchaseHandler: { - type: "object", - required: ["itemId"], - properties: { - itemId: { - type: "string", - pattern: "^[a-fA-F\\d]{24}$", - }, - }, - errorMessage: "validation: bad request", - }, -}; +const itemsSchema = zodToSchemaObject(itemsZod); -module.exports = itemsSchema; +module.exports = { itemsZod, itemsSchema }; diff --git a/src/lottery/routes/docs/schemas/questsSchema.js b/src/lottery/routes/docs/schemas/questsSchema.js index 2efd11cd..8daf560d 100644 --- a/src/lottery/routes/docs/schemas/questsSchema.js +++ b/src/lottery/routes/docs/schemas/questsSchema.js @@ -2,7 +2,9 @@ const { z } = require("zod"); const { zodToSchemaObject } = require("../../../../routes/docs/utils"); const questsZod = { - completeHandler: z.object({ questId: z.enum(["roomSharing"]) }), + completeQuestHandler: z.object({ + questId: z.enum(["roomSharing", "dailyAttendance"]), + }), }; const questsSchema = zodToSchemaObject(questsZod); diff --git a/src/lottery/routes/docs/swaggerDocs.js b/src/lottery/routes/docs/swaggerDocs.js index 38ca8298..0b6702da 100644 --- a/src/lottery/routes/docs/swaggerDocs.js +++ b/src/lottery/routes/docs/swaggerDocs.js @@ -1,13 +1,13 @@ const globalStateDocs = require("./globalState"); -const inviteDocs = require("./invite"); +const invitesDocs = require("./invites"); const itemsDocs = require("./items"); const publicNoticeDocs = require("./publicNotice"); const questsDocs = require("./quests"); const transactionsDocs = require("./transactions"); const { globalStateSchema } = require("./schemas/globalStateSchema"); -const { inviteSchema } = require("./schemas/inviteSchema"); -const itemsSchema = require("./schemas/itemsSchema"); +const { invitesSchema } = require("./schemas/invitesSchema"); +const { itemsSchema } = require("./schemas/itemsSchema"); const { questsSchema } = require("./schemas/questsSchema"); const { eventConfig } = require("../../../../loadenv"); @@ -20,41 +20,39 @@ const eventSwaggerDocs = { description: "이벤트 - Global State 관련 API", }, { - name: `${apiPrefix}/invite`, + name: `${apiPrefix}/invites`, description: "이벤트 - 초대 링크 관련 API", }, - // 이 태그는 2024 봄학기 이벤트에서 사용되지 않습니다. - // - // { - // name: `${apiPrefix}/items`, - // description: "이벤트 - 아이템 관련 API", - // }, { - name: `${apiPrefix}/publicNotice`, - description: "이벤트 - 아이템 구매, 뽑기, 획득 공지 관련 API", + name: `${apiPrefix}/items`, + description: "이벤트 - 상품 관련 API", }, + // { + // name: `${apiPrefix}/publicNotice`, + // description: "이벤트 - 상품 구매, 뽑기, 획득 공지 관련 API", + // }, { name: `${apiPrefix}/quests`, description: "이벤트 - 퀘스트 관련 API", }, { name: `${apiPrefix}/transactions`, - description: "이벤트 - 입출금 내역 관련 API", + description: "이벤트 - 재화 입출금 내역 관련 API", }, ], paths: { ...globalStateDocs, - ...inviteDocs, - //...itemsDocs, - ...publicNoticeDocs, + ...invitesDocs, + ...itemsDocs, + // ...publicNoticeDocs, ...questsDocs, ...transactionsDocs, }, components: { schemas: { ...globalStateSchema, - ...inviteSchema, - //...itemsSchema, + ...invitesSchema, + ...itemsSchema, ...questsSchema, }, }, diff --git a/src/lottery/routes/docs/transactions.js b/src/lottery/routes/docs/transactions.js index fa78238b..a041b949 100644 --- a/src/lottery/routes/docs/transactions.js +++ b/src/lottery/routes/docs/transactions.js @@ -6,10 +6,9 @@ transactionsDocs[`${apiPrefix}/`] = { get: { tags: [`${apiPrefix}`], summary: "재화 입출금 내역 반환", - description: "유저의 재화 입출금 내역을 가져옵니다.", + description: "재화 입출금 내역을 가져옵니다.", responses: { 200: { - description: "", content: { "application/json": { schema: { @@ -18,16 +17,11 @@ transactionsDocs[`${apiPrefix}/`] = { properties: { transactions: { type: "array", - description: "유저의 재화 입출금 기록의 배열", + description: "유저의 재화 입출금 내역의 배열", items: { type: "object", - required: ["_id", "type", "amount", "comment", "createAt"], + required: ["type", "amount", "comment", "createdAt"], properties: { - _id: { - type: "string", - description: "Transaction의 ObjectId", - example: "OBJECT ID", - }, type: { type: "string", description: @@ -41,18 +35,33 @@ transactionsDocs[`${apiPrefix}/`] = { }, questId: { type: "string", - description: - "Transaction과 관련된 퀘스트의 Id. 퀘스트와 관련된 Transaction인 경우에만 포함됩니다.", + description: "입출금 내역과 관련된 퀘스트의 Id", example: "QUEST ID", }, + item: { + type: "object", + required: ["name", "imageUrl"], + properties: { + name: { + type: "string", + description: "상품의 이름", + example: "랜덤 상자", + }, + imageUrl: { + type: "string", + description: "상품의 썸네일 이미지 URL", + example: "IMAGE URL", + }, + }, + }, comment: { type: "string", description: "입출금 내역에 대한 설명", example: "랜덤 상자 구입 - 50개 차감", }, - createAt: { + createdAt: { type: "string", - description: "입출금이 일어난 시각", + description: "입출금 내역이 생성된 시각", example: "2023-01-01 00:00:00", }, }, diff --git a/src/lottery/routes/globalState.js b/src/lottery/routes/globalState.js index 1f2b4327..c4f37d39 100644 --- a/src/lottery/routes/globalState.js +++ b/src/lottery/routes/globalState.js @@ -1,7 +1,8 @@ const express = require("express"); +const router = express.Router(); + const { validateBody } = require("../../middlewares/zod"); const { globalStateZod } = require("./docs/schemas/globalStateSchema"); -const router = express.Router(); const globalStateHandlers = require("../services/globalState"); router.get("/", globalStateHandlers.getUserGlobalStateHandler); diff --git a/src/lottery/routes/invite.js b/src/lottery/routes/invite.js deleted file mode 100644 index eafa09cb..00000000 --- a/src/lottery/routes/invite.js +++ /dev/null @@ -1,20 +0,0 @@ -const express = require("express"); -const { validateParams } = require("../../middlewares/zod"); -const { inviteZod } = require("./docs/schemas/inviteSchema"); -const router = express.Router(); -const inviteHandlers = require("../services/invite"); - -router.get( - "/search/:inviter", - validateParams(inviteZod.searchInviterHandler), - inviteHandlers.searchInviterHandler -); - -// 아래의 Endpoint 접근 시 로그인, 차단 여부 체크 및 시각 체크 필요 -router.use(require("../../middlewares/auth")); -router.use(require("../middlewares/checkBanned")); -router.use(require("../middlewares/timestampValidator")); - -router.post("/create", inviteHandlers.createInviteUrlHandler); - -module.exports = router; diff --git a/src/lottery/routes/invites.js b/src/lottery/routes/invites.js new file mode 100644 index 00000000..65e4271e --- /dev/null +++ b/src/lottery/routes/invites.js @@ -0,0 +1,21 @@ +const express = require("express"); +const router = express.Router(); + +const { validateParams } = require("../../middlewares/zod"); +const { invitesZod } = require("./docs/schemas/invitesSchema"); +const invitesHandlers = require("../services/invites"); + +router.get( + "/search/:inviter", + validateParams(invitesZod.searchInviterHandler), + invitesHandlers.searchInviterHandler +); + +// 아래의 Endpoint 접근 시 로그인, 차단 여부 및 시각 체크 필요 +router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/checkBanned")); +router.use(require("../middlewares/timestampValidator")); + +router.post("/create", invitesHandlers.createInviteUrlHandler); + +module.exports = router; diff --git a/src/lottery/routes/items.js b/src/lottery/routes/items.js index 5cdf98a8..0de8be18 100644 --- a/src/lottery/routes/items.js +++ b/src/lottery/routes/items.js @@ -1,23 +1,32 @@ const express = require("express"); - const router = express.Router(); -// TODO: 추후 코드 재사용시 상황에 맞춰 zod로 이전이 필요합니다. + +const { validateBody, validateParams } = require("../../middlewares/zod"); +const { itemsZod } = require("./docs/schemas/itemsSchema"); const itemsHandlers = require("../services/items"); -const itemsSchema = require("./docs/schemas/itemsSchema"); -// 아래의 Endpoint는 2024 봄학기 이벤트에서 사용되지 않습니다. -// -// router.get("/list", itemsHandlers.listHandler); +router.get("/", itemsHandlers.getItemsHandler); +router.get( + "/:itemId", + validateParams(itemsZod.getItemHandler), + itemsHandlers.getItemHandler +); +router.get( + "/leaderboard/:itemId", + validateParams(itemsZod.getItemLeaderboardHandler), + itemsHandlers.getItemLeaderboardHandler +); -// // 아래의 Endpoint 접근 시 로그인, 차단 여부 체크 및 시각 체크 필요 -// router.use(require("../../middlewares/auth")); -// router.use(require("../middlewares/checkBanned")); -// router.use(require("../middlewares/timestampValidator")); +// 아래의 Endpoint 접근 시 로그인, 차단 여부 및 시각 체크 필요 +router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/checkBanned")); +router.use(require("../middlewares/timestampValidator")); -// router.post( -// "/purchase/:itemId", -// validateParams(itemsSchema.purchaseHandler), -// itemsHandlers.purchaseHandler -// ); +router.post( + "/purchase/:itemId", + validateParams(itemsZod.purchaseItemHandlerParams), + validateBody(itemsZod.purchaseItemHandlerBody), + itemsHandlers.purchaseItemHandler +); module.exports = router; diff --git a/src/lottery/routes/publicNotice.js b/src/lottery/routes/publicNotice.js index 4698a193..f6646061 100644 --- a/src/lottery/routes/publicNotice.js +++ b/src/lottery/routes/publicNotice.js @@ -3,10 +3,10 @@ const express = require("express"); const router = express.Router(); const publicNoticeHandlers = require("../services/publicNotice"); -router.get("/leaderboard", publicNoticeHandlers.getGroupLeaderboardHandler); - -// 아래의 Endpoint는 2024 봄학기 이벤트에서 사용되지 않습니다. +// 아래의 Endpoint들은 2024 추석 이벤트에서 사용되지 않습니다. // +// router.get("/leaderboard", publicNoticeHandlers.getGroupLeaderboardHandler); + // router.get( // "/recentTransactions", // publicNoticeHandlers.getRecentPurchaceItemListHandler diff --git a/src/lottery/routes/quests.js b/src/lottery/routes/quests.js index 4941c8d2..e9845434 100644 --- a/src/lottery/routes/quests.js +++ b/src/lottery/routes/quests.js @@ -1,18 +1,19 @@ const express = require("express"); +const router = express.Router(); + const { validateParams } = require("../../middlewares/zod"); const { questsZod } = require("./docs/schemas/questsSchema"); -const router = express.Router(); const questsHandlers = require("../services/quests"); -// 아래의 Endpoint 접근 시 로그인, 차단 여부 체크 및 시각 체크 필요 +// 아래의 Endpoint 접근 시 로그인, 차단 여부 및 시각 체크 필요 router.use(require("../../middlewares/auth")); router.use(require("../middlewares/checkBanned")); router.use(require("../middlewares/timestampValidator")); router.post( "/complete/:questId", - validateParams(questsZod.completeHandler), - questsHandlers.completeHandler + validateParams(questsZod.completeQuestHandler), + questsHandlers.completeQuestHandler ); module.exports = router; diff --git a/src/lottery/routes/transactions.js b/src/lottery/routes/transactions.js index aee05d90..f9e375ca 100644 --- a/src/lottery/routes/transactions.js +++ b/src/lottery/routes/transactions.js @@ -1,6 +1,6 @@ const express = require("express"); - const router = express.Router(); + const transactionsHandlers = require("../services/transactions"); // 아래의 Endpoint 접근 시 로그인 필요 diff --git a/src/lottery/services/globalState.js b/src/lottery/services/globalState.js index 5459f851..ee01f47e 100644 --- a/src/lottery/services/globalState.js +++ b/src/lottery/services/globalState.js @@ -8,61 +8,57 @@ const { eventConfig } = require("../../../loadenv"); const contracts = require("../modules/contracts"); const quests = Object.values(contracts.quests); -// 유저가 이벤트에 참여할 수 있는지 확인하는 함수입니다. -const checkIsUserEligible = (user) => { - // production 환경이 아닌 경우 테스트를 위해 참여 조건을 확인하지 않습니다. - if (nodeEnv !== "production") return true; +// 아래의 함수는 2024 추석 이벤트에서 사용되지 않습니다. +// +// // 유저가 이벤트에 참여할 수 있는지 확인하는 함수입니다. +// const checkIsUserEligible = (user) => { +// // production 환경이 아닌 경우 테스트를 위해 참여 조건을 확인하지 않습니다. +// if (nodeEnv !== "production") return true; - const kaistId = parseInt(user?.subinfo?.kaist || "0"); - return 20240001 <= kaistId && kaistId <= 20241500; -}; +// const kaistId = parseInt(user?.subinfo?.kaist || "0"); +// return 20240001 <= kaistId && kaistId <= 20241500; +// }; const getUserGlobalStateHandler = async (req, res) => { try { const userId = isLogin(req) ? getLoginInfo(req).oid : null; - const user = userId && (await userModel.findOne({ _id: userId }).lean()); - const eventStatus = userId && (await eventStatusModel - .findOne({ userId }, "completedQuests creditAmount isBanned group") + .findOne({ userId }, "completedQuests creditAmount isBanned") .lean()); if (!eventStatus) return res.json({ isAgreeOnTermsOfEvent: false, - isEligible: checkIsUserEligible(user) || !!user?.isAdmin, // 테스트를 위해 관리자인 경우 true로 설정합니다. 하지만 관리자이더라도 이벤트에 참여할 수 없습니다. - completedQuests: [], + isBanned: false, creditAmount: 0, - group: 0, - groupCreditAmount: 0, quests, + completedQuests: [], }); // group이 eventStatus.group과 같은 사용자들의 creditAmount를 합산합니다. - const groupCreditAmount = await eventStatusModel.aggregate([ - { - $match: { - group: eventStatus.group, - }, - }, - { - $group: { - _id: null, - creditAmount: { $sum: "$creditAmount" }, - }, - }, - ]); - const groupCreditAmountReal = groupCreditAmount?.[0].creditAmount; - if (!groupCreditAmountReal && groupCreditAmountReal !== 0) - return res - .status(500) - .json({ error: "GlobalState/ : internal server error" }); + // const groupCreditAmount = await eventStatusModel.aggregate([ + // { + // $match: { + // group: eventStatus.group, + // }, + // }, + // { + // $group: { + // _id: null, + // creditAmount: { $sum: "$creditAmount" }, + // }, + // }, + // ]); + // const groupCreditAmountReal = groupCreditAmount?.[0].creditAmount; + // if (!groupCreditAmountReal && groupCreditAmountReal !== 0) + // return res + // .status(500) + // .json({ error: "GlobalState/ : internal server error" }); return res.json({ - isAgreeOnTermsOfEvent: true, - isEligible: true, ...eventStatus, - groupCreditAmount: groupCreditAmountReal, + isAgreeOnTermsOfEvent: true, quests, }); } catch (err) { @@ -79,7 +75,7 @@ const createUserGlobalStateHandler = async (req, res) => { if (eventStatus) return res .status(400) - .json({ error: "GlobalState/Create : already created" }); + .json({ error: "GlobalState/create : already created" }); /* Request의 inviter 필드가 설정되어 있는데, 1. 해당되는 유저가 이벤트에 참여하지 않았거나, @@ -88,47 +84,53 @@ const createUserGlobalStateHandler = async (req, res) => { 에러를 발생시킵니다. 개인정보 보호를 위해 오류 메세지는 하나로 통일하였습니다. */ const inviterStatus = req.body.inviter && - (await eventStatusModel.findOne({ _id: req.body.inviter }).lean()); + (await eventStatusModel.findById(req.body.inviter).lean()); if ( req.body.inviter && (!inviterStatus || inviterStatus.isBanned || - !inviterStatus.isEnabledInviteUrl) + !inviterStatus.isInviteUrlEnabled) ) return res.status(400).json({ - error: "GlobalState/Create : inviter did not participate in the event", + error: "GlobalState/create : invalid inviter", }); - const user = await userModel.findOne({ _id: req.userOid }); + const user = await userModel.findById(req.userOid); if (!user) return res .status(500) - .json({ error: "GlobalState/Create : internal server error" }); + .json({ error: "GlobalState/create : internal server error" }); // 유저가 이벤트에 참여할 수 있는지 확인합니다. - const isEligible = checkIsUserEligible(user); - if (!isEligible) - return res.status(400).json({ - error: "GlobalState/Create : not eligible to participate in the event", - }); - - // 수집한 전화번호를 User Document에 저장합니다. - // 다른 이벤트 참여 과정에서 문제가 생길 수 있으므로, 이벤트 참여 자격이 있는 경우에만 저장합니다. - user.phoneNumber = req.body.phoneNumber; - await user.save(); + // const isEligible = checkIsUserEligible(user); + // if (!isEligible) + // return res.status(400).json({ + // error: "GlobalState/create : not eligible to participate in the event", + // }); + + // 필요한 경우 유저의 전화번호를 업데이트합니다. + if (user.phoneNumber !== req.body.phoneNumber) { + if (user.phoneNumber) { + logger.info(`Past user phone number: ${user.phoneNumber}`); + logger.info(`Update user phone number: ${req.body.phoneNumber}`); + } + + user.phoneNumber = req.body.phoneNumber; + await user.save(); + } // EventStatus Document를 생성합니다. eventStatus = new eventStatusModel({ userId: req.userOid, creditAmount: eventConfig?.credit.initialAmount ?? 0, - group: req.body.group, - inviter: req.body.inviter, + inviter: inviterStatus?.userId ?? undefined, }); await eventStatus.save(); + // 퀘스트를 완료 처리합니다. await contracts.completeFirstLoginQuest(req.userOid, req.timestamp); - if (req.body.inviter) { + if (inviterStatus) { await contracts.completeEventSharingQuest(req.userOid, req.timestamp); await contracts.completeEventSharingQuest( inviterStatus.userId, @@ -141,7 +143,7 @@ const createUserGlobalStateHandler = async (req, res) => { logger.error(err); res .status(500) - .json({ error: "GlobalState/Create : internal server error" }); + .json({ error: "GlobalState/create : internal server error" }); } }; diff --git a/src/lottery/services/invite.js b/src/lottery/services/invite.js deleted file mode 100644 index c7871273..00000000 --- a/src/lottery/services/invite.js +++ /dev/null @@ -1,66 +0,0 @@ -const { eventStatusModel } = require("../modules/stores/mongo"); -const { userModel } = require("../../modules/stores/mongo"); -const logger = require("../../modules/logger"); - -const { eventConfig } = require("../../../loadenv"); - -const searchInviterHandler = async (req, res) => { - try { - const { inviter } = req.params; - const inviterStatus = await eventStatusModel.findOne({ _id: inviter }); - if ( - !inviterStatus || - !inviterStatus.isEnabledInviteUrl || - inviterStatus.isBanned - ) - return res.status(400).json({ error: "Invite/Search : invalid inviter" }); - - const inviterInfo = await userModel.findOne({ _id: inviterStatus.userId }); - if (!inviterInfo) - return res - .status(500) - .json({ error: "Invite/Search : internal server error" }); - - return res.json({ - nickname: inviterInfo.nickname, - profileImageUrl: inviterInfo.profileImageUrl, - }); - } catch (err) { - logger.error(err); - res.status(500).json({ error: "Invite/Search : internal server error" }); - } -}; - -const createInviteUrlHandler = async (req, res) => { - try { - const inviteUrl = `${req.origin}/event/${eventConfig?.mode}-invite/${req.eventStatus._id}`; - - if (req.eventStatus.isEnabledInviteUrl) return res.json({ inviteUrl }); - - const eventStatus = await eventStatusModel - .findOneAndUpdate( - { - _id: req.eventStatus._id, - isEnabledInviteUrl: false, - }, - { - isEnabledInviteUrl: true, - } - ) - .lean(); - if (!eventStatus) - return res - .status(500) - .json({ error: "Invite/Create : internal server error" }); - - return res.json({ inviteUrl }); - } catch (err) { - logger.error(err); - res.status(500).json({ error: "Invite/Create : internal server error" }); - } -}; - -module.exports = { - searchInviterHandler, - createInviteUrlHandler, -}; diff --git a/src/lottery/services/invites.js b/src/lottery/services/invites.js new file mode 100644 index 00000000..6479bce8 --- /dev/null +++ b/src/lottery/services/invites.js @@ -0,0 +1,73 @@ +const { eventStatusModel } = require("../modules/stores/mongo"); +const { userModel } = require("../../modules/stores/mongo"); +const logger = require("../../modules/logger"); + +const { eventConfig } = require("../../../loadenv"); + +const searchInviterHandler = async (req, res) => { + try { + /* 1. 해당되는 유저가 이벤트에 참여하지 않았거나, + 2. 해당되는 유저의 이벤트 참여가 제한된 상태이거나, + 3. 해당되는 유저의 초대 링크가 활성화되지 않았으면, + 에러를 발생시킵니다. 개인정보 보호를 위해 오류 메세지는 하나로 통일하였습니다. */ + const inviterStatus = await eventStatusModel + .findById(req.params.inviter) + .lean(); + if ( + !inviterStatus || + inviterStatus.isBanned || + !inviterStatus.isInviteUrlEnabled + ) + return res + .status(400) + .json({ error: "Invites/search : invalid inviter" }); + + // 해당되는 유저의 닉네임과 프로필 이미지를 가져옵니다. + const inviter = await userModel + .findById(inviterStatus.userId, "nickname profileImageUrl") + .lean(); + if (!inviter) + return res + .status(500) + .json({ error: "Invites/search : internal server error" }); + + return res.json(inviter); + } catch (err) { + logger.error(err); + res.status(500).json({ error: "Invites/search : internal server error" }); + } +}; + +const createInviteUrlHandler = async (req, res) => { + try { + const inviteUrl = `${req.origin}/event/${eventConfig?.mode}-invite/${req.eventStatus._id}`; + + // 이미 초대 링크가 활성화된 경우 링크를 즉시 반환합니다. + if (req.eventStatus.isInviteUrlEnabled) return res.json({ inviteUrl }); + + // 초대 링크를 활성화합니다. + const { modifiedCount } = await eventStatusModel.updateOne( + { + _id: req.eventStatus._id, + isInviteUrlEnabled: false, + }, + { + isInviteUrlEnabled: true, + } + ); + if (modifiedCount !== 1) + return res + .status(500) + .json({ error: "Invites/create : internal server error" }); + + return res.json({ inviteUrl }); + } catch (err) { + logger.error(err); + res.status(500).json({ error: "Invites/create : internal server error" }); + } +}; + +module.exports = { + searchInviterHandler, + createInviteUrlHandler, +}; diff --git a/src/lottery/services/items.js b/src/lottery/services/items.js index 9189bae4..eb00535d 100644 --- a/src/lottery/services/items.js +++ b/src/lottery/services/items.js @@ -3,9 +3,159 @@ const { itemModel, transactionModel, } = require("../modules/stores/mongo"); +const { userModel } = require("../../modules/stores/mongo"); +const { isLogin, getLoginInfo } = require("../../modules/auths/login"); const logger = require("../../modules/logger"); const { eventConfig } = require("../../../loadenv"); +const contracts = require("../modules/contracts"); + +const getItemsHandler = async (req, res) => { + try { + const items = await itemModel + .find( + {}, + "_id name description imageUrl instagramStoryStickerImageUrl price isDisabled itemType" + ) + .lean(); + res.json({ items }); + } catch (err) { + logger.error(err); + res.status(500).json({ error: "Items/ : internal server error" }); + } +}; + +const getItemHandler = async (req, res) => { + try { + const { itemId } = req.params; + const item = await itemModel + .findById( + itemId, + "_id name description imageUrl instagramStoryStickerImageUrl price isDisabled itemType" + ) + .lean(); + if (!item) return res.status(400).json({ error: "Items/ : invalid item" }); + + res.json({ item }); + } catch (err) { + logger.error(err); + res.status(500).json({ error: "Items/ : internal server error" }); + } +}; + +// 유도 과정은 services/publicNotice.js 파일에 정의된 calculateProbabilityV2 함수의 주석 참조 +const calculateWinProbability = (realStock, users, amount, totalAmount) => { + if (users.length <= realStock) return 1; + + const base = Math.pow( + 1 - realStock / users.length, + users.length / totalAmount + ); + return 1 - Math.pow(base, amount); +}; + +const getItemLeaderboardHandler = async (req, res) => { + try { + // 상품 정보를 가져옵니다. + const { itemId } = req.params; + const item = await itemModel.findOne({ _id: itemId, itemType: 0 }).lean(); + if (!item) + return res + .status(400) + .json({ error: "Items/leaderboard : invalid item" }); + + // 해당 상품을 구매한 유저들의 목록을 가져옵니다. + const users = await transactionModel.aggregate([ + { + $match: { + type: "use", + itemId: item._id, + }, + }, + { + $group: { + _id: "$userId", + amount: { $sum: "$itemAmount" }, + }, + }, + { + $lookup: { + from: eventStatusModel.collection.name, + localField: "_id", + foreignField: "userId", + as: "eventStatus", + }, + }, + { + $match: { + "eventStatus.0.isBanned": false, + }, + }, + { + $sort: { amount: -1 }, + }, + ]); + + // 리더보드 생성을 위해 필요한 정보를 계산합니다. + const totalAmount = users.reduce((acc, user) => acc + user.amount, 0); + const rankMap = new Map( + users + .map((user) => user.amount) + .reduce((acc, amount, index) => { + if (acc.length === 0 || acc[acc.length - 1][0] !== amount) { + acc.push([amount, index + 1]); + } + return acc; + }, []) + ); + + // 리더보드를 생성합니다. + const leaderboardBase = users.map((user) => ({ + userId: user._id, + amount: user.amount, + probability: calculateWinProbability( + item.realStock, + users, + user.amount, + totalAmount + ), + rank: rankMap.get(user.amount), + })); + const leaderboard = await Promise.all( + leaderboardBase + .filter((user) => user.rank <= 20) + .map(async (user) => { + const userInfo = await userModel.findById(user.userId).lean(); + return { + nickname: userInfo.nickname, + profileImageUrl: userInfo.profileImageUrl, + amount: user.amount, + probability: user.probability, + rank: user.rank, + }; + }) + ); + + const userId = isLogin(req) ? getLoginInfo(req).oid : null; + const user = leaderboardBase.find( + (user) => user.userId.toString() === userId + ); + + return res.json({ + leaderboard, + totalAmount, + totalUser: users.length, + amount: user?.amount, + probability: user?.probability, + rank: user?.rank, + }); + } catch (err) { + logger.error(err); + res + .status(500) + .json({ error: "Items/leaderboard : internal server error" }); + } +}; const updateEventStatus = async ( userId, @@ -22,199 +172,217 @@ const updateEventStatus = async ( } ); -const hideItemStock = (item) => { - item.stock = item.stock > 0 ? 1 : 0; - return item; -}; +// 아래의 함수는 2024 추석 이벤트에서 사용되지 않습니다. +// +// const getRandomItem = async (req, depth) => { +// if (depth >= 10) { +// logger.error(`User ${req.userOid} failed to open random box`); +// return null; +// } -const getRandomItem = async (req, depth) => { - if (depth >= 10) { - logger.error(`User ${req.userOid} failed to open random box`); - return null; - } - - const items = await itemModel - .find({ - isRandomItem: true, - stock: { $gt: 0 }, - isDisabled: false, - }) - .lean(); - const randomItems = items - .map((item) => Array(item.randomWeight).fill(item)) - .reduce((a, b) => a.concat(b), []); - const dumpRandomItems = randomItems - .map((item) => item._id.toString()) - .join(","); - - logger.info( - `User ${req.userOid}'s ${ - depth + 1 - }th random box probability is: [${dumpRandomItems}]` - ); +// const items = await itemModel +// .find({ +// isRandomItem: true, +// stock: { $gt: 0 }, +// isDisabled: false, +// }) +// .lean(); +// const randomItems = items +// .map((item) => Array(item.randomWeight).fill(item)) +// .reduce((a, b) => a.concat(b), []); +// const dumpRandomItems = randomItems +// .map((item) => item._id.toString()) +// .join(","); - if (randomItems.length === 0) return null; +// logger.info( +// `User ${req.userOid}'s ${ +// depth + 1 +// }th random box probability is: [${dumpRandomItems}]` +// ); - const randomItem = - randomItems[Math.floor(Math.random() * randomItems.length)]; - try { - // 1단계: 재고를 차감합니다. - const newRandomItem = await itemModel - .findOneAndUpdate( - { _id: randomItem._id, stock: { $gt: 0 } }, - { - $inc: { - stock: -1, - }, - }, - { - new: true, - fields: { - itemType: 0, - isRandomItem: 0, - randomWeight: 0, - }, - } - ) - .lean(); - if (!newRandomItem) { - throw new Error(`Item ${randomItem._id.toString()} was already sold out`); - } +// if (randomItems.length === 0) return null; - // 2단계: 유저 정보를 업데이트합니다. - await updateEventStatus(req.userOid, { - ticket1Delta: randomItem.itemType === 1 ? 1 : 0, - ticket2Delta: randomItem.itemType === 2 ? 1 : 0, - }); +// const randomItem = +// randomItems[Math.floor(Math.random() * randomItems.length)]; +// try { +// // 1단계: 재고를 차감합니다. +// const newRandomItem = await itemModel +// .findOneAndUpdate( +// { _id: randomItem._id, stock: { $gt: 0 } }, +// { +// $inc: { +// stock: -1, +// }, +// }, +// { +// new: true, +// fields: { +// itemType: 0, +// isRandomItem: 0, +// randomWeight: 0, +// }, +// } +// ) +// .lean(); +// if (!newRandomItem) { +// throw new Error(`Item ${randomItem._id.toString()} was already sold out`); +// } - // 3단계: Transaction을 추가합니다. - const transaction = new transactionModel({ - type: "use", - amount: 0, - userId: req.userOid, - item: randomItem._id, - itemType: randomItem.itemType, - comment: `랜덤박스에서 "${randomItem.name}" 1개를 획득했습니다.`, - }); - await transaction.save(); +// // 2단계: 유저 정보를 업데이트합니다. +// await updateEventStatus(req.userOid, { +// ticket1Delta: randomItem.itemType === 1 ? 1 : 0, +// ticket2Delta: randomItem.itemType === 2 ? 1 : 0, +// }); - return newRandomItem; - } catch (err) { - logger.error(err); - logger.warn( - `User ${req.userOid}'s ${depth + 1}th random box failed due to exception` - ); +// // 3단계: Transaction을 추가합니다. +// const transaction = new transactionModel({ +// type: "use", +// amount: 0, +// userId: req.userOid, +// itemId: randomItem._id, +// comment: `랜덤박스에서 "${randomItem.name}" 1개를 획득했습니다.`, +// }); +// await transaction.save(); - return await getRandomItem(req, depth + 1); - } -}; +// return newRandomItem; +// } catch (err) { +// logger.error(err); +// logger.warn( +// `User ${req.userOid}'s ${depth + 1}th random box failed due to exception` +// ); -const listHandler = async (_, res) => { - try { - const items = await itemModel - .find( - {}, - "name imageUrl instagramStoryStickerImageUrl price description isDisabled stock itemType" - ) - .lean(); - res.json({ items: items.map(hideItemStock) }); - } catch (err) { - logger.error(err); - res.status(500).json({ error: "Items/List : internal server error" }); - } -}; +// return await getRandomItem(req, depth + 1); +// } +// }; -const purchaseHandler = async (req, res) => { +const purchaseItemHandler = async (req, res) => { try { const { itemId } = req.params; - const item = await itemModel.findOne({ _id: itemId }).lean(); + const item = await itemModel.findById(itemId).lean(); if (!item) - return res.status(400).json({ error: "Items/Purchase : invalid Item" }); + return res.status(400).json({ error: "Items/purchase : invalid Item" }); - // 구매 가능 조건: 크레딧이 충분하며, 재고가 남아있으며, 판매 중인 아이템이어야 합니다. + const { amount } = req.body; + const totalPrice = item.price * amount; + + // 구매 가능 조건: 재화가 충분하며, 재고가 남아있으며, 판매 중인 상품이어야 합니다. if (item.isDisabled) - return res.status(400).json({ error: "Items/Purchase : disabled item" }); - if (req.eventStatus.creditAmount < item.price) + return res.status(400).json({ error: "Items/purchase : disabled item" }); + if (req.eventStatus.creditAmount < totalPrice) return res .status(400) - .json({ error: "Items/Purchase : not enough credit" }); - if (item.stock <= 0) + .json({ error: "Items/purchase : not enough credit" }); + if (item.stock < amount) return res .status(400) - .json({ error: "Items/Purchase : item out of stock" }); + .json({ error: "Items/purchase : item out of stock" }); // 1단계: 재고를 차감합니다. const { modifiedCount } = await itemModel.updateOne( - { _id: item._id, stock: { $gt: 0 } }, - { - $inc: { - stock: -1, - }, - } + { _id: item._id, stock: { $gte: amount } }, + { $inc: { stock: -amount } } ); if (modifiedCount === 0) return res .status(400) - .json({ error: "Items/Purchase : item out of stock" }); + .json({ error: "Items/purchase : item out of stock" }); - // 2단계: 유저 정보를 업데이트합니다. - await updateEventStatus(req.userOid, { - creditDelta: -item.price, - ticket1Delta: item.itemType === 1 ? 1 : 0, - ticket2Delta: item.itemType === 2 ? 1 : 0, - }); + if (item.itemType !== 3) { + // 랜덤박스가 아닌 상품을 구입한 경우 + // 2단계: 유저 정보를 업데이트합니다. + await updateEventStatus(req.userOid, { + creditDelta: -totalPrice, + ticket1Delta: item.itemType === 1 ? amount : 0, + ticket2Delta: item.itemType === 2 ? amount : 0, + }); - // 3단계: Transaction을 추가합니다. - const transaction = new transactionModel({ - type: "use", - amount: item.price, - userId: req.userOid, - item: item._id, - itemType: item.itemType, - comment: `${eventConfig?.credit.name} ${item.price}개를 사용해 "${item.name}" 1개를 획득했습니다.`, - }); - await transaction.save(); + // 3단계: 출금 내역을 추가합니다. + const transaction = new transactionModel({ + type: "use", + amount: totalPrice, + userId: req.userOid, + itemId: item._id, + itemAmount: amount, + comment: `${eventConfig?.credit.name} ${totalPrice}개를 사용해 "${item.name}" ${amount}개를 획득했습니다.`, + }); + await transaction.save(); - // 4단계: 랜덤박스인 경우 아이템을 추첨합니다. - if (item.itemType !== 3) return res.json({ result: true }); + // 4단계: 퀘스트를 완료 처리합니다. + await contracts.completeItemPurchaseQuest( + req.userOid, + transaction.createdAt + ); - const randomItem = await getRandomItem(req, 0); - if (!randomItem) { - // 랜덤박스가 실패한 경우, 상태를 구매 이전으로 되돌립니다. - // TODO: Transactions 도입 후 이 코드는 삭제합니다. - logger.info(`User ${req.userOid}'s status will be restored`); + return res.json({ result: true }); + } else { + // 랜덤박스를 구입한 경우 + // 2단계: 대박(40%)인지 쪽박(60%)인지 결정합니다. + const isJackpot = Math.random() < 0.4; + const creditDelta = isJackpot ? totalPrice : -totalPrice; - await transactionModel.deleteOne({ _id: transaction._id }); - await updateEventStatus(req.userOid, { - creditDelta: item.price, - }); - await itemModel.updateOne( - { _id: item._id }, - { - $inc: { - stock: 1, - }, - } - ); + // 3단계: 유저 정보를 업데이트합니다. + await updateEventStatus(req.userOid, { creditDelta }); - logger.info(`User ${req.userOid}'s status was successfully restored`); + // 4단계: 입출금 내역을 추가합니다. + if (isJackpot) { + const transaction = new transactionModel({ + type: "get", + amount: creditDelta, + userId: req.userOid, + itemId: item._id, + itemAmount: amount, + comment: `${eventConfig?.credit.name} ${totalPrice}개를 "${item.name}"에 사용해 대박을 터뜨렸습니다.`, + }); + await transaction.save(); + } else { + const transaction = new transactionModel({ + type: "use", + amount: creditDelta, + userId: req.userOid, + itemId: item._id, + itemAmount: amount, + comment: `${eventConfig?.credit.name} ${totalPrice}개를 "${item.name}"에 사용했지만 쪽박을 맞았습니다.`, + }); + await transaction.save(); + } - return res - .status(500) - .json({ error: "Items/Purchase : random box error" }); + return res.json({ result: true, isJackpot }); } - res.json({ - result: true, - reward: hideItemStock(randomItem), - }); + // const randomItem = await getRandomItem(req, 0); + // if (!randomItem) { + // // 랜덤박스가 실패한 경우, 상태를 구매 이전으로 되돌립니다. + // // TODO: Transactions 도입 후 이 코드는 삭제합니다. + // logger.info(`User ${req.userOid}'s status will be restored`); + + // await transactionModel.deleteOne({ _id: transaction._id }); + // await updateEventStatus(req.userOid, { + // creditDelta: item.price, + // }); + // await itemModel.updateOne( + // { _id: item._id }, + // { + // $inc: { + // stock: 1, + // }, + // } + // ); + + // logger.info(`User ${req.userOid}'s status was successfully restored`); + + // return res + // .status(500) + // .json({ error: "Items/purchase : random box error" }); + // } } catch (err) { logger.error(err); - res.status(500).json({ error: "Items/Purchase : internal server error" }); + res.status(500).json({ error: "Items/purchase : internal server error" }); } }; module.exports = { - listHandler, - purchaseHandler, + getItemsHandler, + getItemHandler, + getItemLeaderboardHandler, + purchaseItemHandler, }; diff --git a/src/lottery/services/quests.js b/src/lottery/services/quests.js index ae1472bc..5a0c6ae6 100644 --- a/src/lottery/services/quests.js +++ b/src/lottery/services/quests.js @@ -3,20 +3,38 @@ const logger = require("../../modules/logger"); const contracts = require("../modules/contracts"); -const completeHandler = async (req, res) => { +const completeQuestHandler = async (req, res) => { try { const quest = contracts.quests[req.params.questId]; if (!quest || !quest.isApiRequired) - return res.status(400).json({ error: "Quests/Complete: invalid Quest" }); + return res.status(400).json({ error: "Quests/complete: invalid quest" }); + + // 출석 체크 퀘스트는 하루에 1번만 완료하도록 제한합니다. + if (quest.id === "dailyAttendance") { + const todayMidnight = new Date(req.timestamp); + todayMidnight.setHours(0, 0, 0, 0); + + const tomorrowMidnight = new Date(todayMidnight); + tomorrowMidnight.setDate(tomorrowMidnight.getDate() + 1); + + // 오늘 완료된 dailyAttendance 퀘스트가 있는지 확인합니다. + const completedQuest = req.eventStatus.completedQuests.find( + ({ questId, completedAt }) => + questId === quest.id && + completedAt >= todayMidnight && + completedAt < tomorrowMidnight + ); + if (completedQuest) return res.json({ result: false }); + } const result = await completeQuest(req.userOid, req.timestamp, quest); res.json({ result: !!result }); // boolean으로 변환하기 위해 !!를 사용합니다. } catch (err) { logger.error(err); - res.status(500).json({ error: "Quests/Complete: internal server error" }); + res.status(500).json({ error: "Quests/complete: internal server error" }); } }; module.exports = { - completeHandler, + completeQuestHandler, }; diff --git a/src/lottery/services/transactions.js b/src/lottery/services/transactions.js index 1d920870..fe976e28 100644 --- a/src/lottery/services/transactions.js +++ b/src/lottery/services/transactions.js @@ -1,25 +1,34 @@ const { transactionModel } = require("../modules/stores/mongo"); +const { + transactionPopulateOption, +} = require("../modules/populates/transactions"); const logger = require("../../modules/logger"); -const hideItemStock = (transaction) => { - if (transaction.item) { - transaction.item.stock = transaction.item.stock > 0 ? 1 : 0; +const formatTransaction = (transaction) => { + if (transaction.itemId) { + transaction.item = transaction.itemId; + delete transaction.itemId; } return transaction; }; const getUserTransactionsHandler = async (req, res) => { try { - // userId는 이미 Frontend에서 알고 있고, 중복되는 값이므로 제외합니다. const transactions = await transactionModel - .find({ userId: req.userOid }, "_id type amount questId comment createAt") + .find( + { userId: req.userOid }, + "type amount questId itemId comment createdAt" + ) + .populate(transactionPopulateOption) .lean(); - if (transactions) - res.json({ - transactions, - }); - else - res.status(500).json({ error: "Transactions/ : internal server error" }); + if (!transactions) + return res + .status(500) + .json({ error: "Transactions/ : internal server error" }); + + res.json({ + transactions: transactions.map(formatTransaction), + }); } catch (err) { logger.error(err); res.status(500).json({ error: "Transactions/ : internal server error" }); diff --git a/src/middlewares/ban.js b/src/middlewares/ban.js new file mode 100644 index 00000000..f70b9550 --- /dev/null +++ b/src/middlewares/ban.js @@ -0,0 +1,19 @@ +const { validateServiceBanRecord } = require("../modules/ban"); + +const serviceMapper = new Map([ + ["/rooms/create", "service"], + ["/rooms/join", "service"], +]); + +const banMiddleware = async (req, res, next) => { + const banErrorMessage = await validateServiceBanRecord( + req, + serviceMapper.get(req.originalUrl) + ); + if (banErrorMessage !== undefined) { + return res.status(400).json({ error: banErrorMessage }); + } + next(); +}; + +module.exports = banMiddleware; diff --git a/src/modules/ban.js b/src/modules/ban.js new file mode 100644 index 00000000..4068db78 --- /dev/null +++ b/src/modules/ban.js @@ -0,0 +1,45 @@ +const logger = require("./logger"); +const { banModel } = require("./stores/mongo"); + +/** + * @param {*} req + * @param {String} service + */ +const validateServiceBanRecord = async (req, service) => { + let banRecord = undefined; + + try { + // 현재 시각이 expireAt 보다 작고, 본인인 경우(ban의 userId가 userId랑 같은 경우) 중 serviceName이 "service"인 record를 모두 가져옴 + const bans = await banModel + .find({ + userSid: req.session.loginInfo.sid, + expireAt: { + $gte: req.timestamp, + }, + serviceName: service, + }) + .sort({ expireAt: -1 }); + if (bans.length > 0) { + // 가장 expireAt이 큰 정지 기록만 반환함. + banRecord = bans[0]; + } + } catch (err) { + logger.error( + "Error occured while validateServiceBanRecord: " + err.message + ); + return; + } + if (banRecord !== undefined) { + const formattedExpireAt = banRecord.expireAt + .toISOString() + .replace("T", " ") + .split(".")[0]; + const banErrorMessage = `${req.originalUrl} : user ${req.userId} (${req.session.loginInfo.sid}) is temporarily restricted from service until ${formattedExpireAt}.`; + return banErrorMessage; + } + return; +}; + +module.exports = { + validateServiceBanRecord, +}; diff --git a/src/modules/stores/mongo.js b/src/modules/stores/mongo.js index 8f837775..f236b829 100755 --- a/src/modules/stores/mongo.js +++ b/src/modules/stores/mongo.js @@ -28,35 +28,21 @@ const userSchema = Schema({ const banSchema = Schema({ // 정지 시킬 사용자를 기제함. - userId: { type: mongoose.Types.ObjectId, ref: "User", required: true }, + userSid: { type: String, required: true }, // 정지 사유 - reason: { + reason: { type: String, required: true }, + bannedAt: { type: Date, required: true }, // 정지 당한 시각 + expireAt: { type: Date, required: true }, // 정지 만료 시각 + // 정지를 당한 서비스를 기제함 + serviceName: { type: String, required: true, + // 필요시 이곳에 정지를 시킬 서비스를 추가함. + enum: [ + "service", // service: 방 생성/참여 제한 + "2023-fall-event", // xxxx-xxxx-event: 특정 이벤트 참여 제한 + ], }, - bannedAt: { - type: Date, // 정지 당한 시각 - required: true, - }, - expireAt: { - type: Date, // 정지 만료 시각 - required: true, - }, - services: [ - { - // 정지를 당한 서비스를 기제함 - serviceName: { - type: String, - required: true, - // 필요시 이곳에 정지를 시킬 서비스를 추가함. - enum: [ - "all", // all -> 과거/미래 모든 서비스 및 이벤트 이용 제한 - "service", // service -> 방 생성/참여 제한 - "2023-fall-event", // event -> 특정 이벤트 참여 제한 - ], - }, - }, - ], }); const participantSchema = Schema({ diff --git a/src/routes/docs/rooms.js b/src/routes/docs/rooms.js index 3ac66e6b..0c9303a2 100644 --- a/src/routes/docs/rooms.js +++ b/src/routes/docs/rooms.js @@ -72,6 +72,12 @@ roomsDocs[`${apiPrefix}/create`] = { }, }, examples: { + "방 생성 기능이 정지당한 경우": { + value: { + error: + "Rooms/join : user monday is temporarily restricted from creating rooms until 2024-08-23 15:00:00.", + }, + }, "출발지와 도착지가 같음": { value: { error: "Rooms/create : locations are same", @@ -309,6 +315,12 @@ roomsDocs[`${apiPrefix}/join`] = { }, }, examples: { + "방 참여 기능이 정지당한 경우": { + value: { + error: + "Rooms/join : user monday is temporarily restricted from joining rooms until 2024-08-23 15:00:00.", + }, + }, "사용자가 참여하는 진행 중 방이 5개 이상": { value: { error: "Rooms/join : participating in too many rooms", diff --git a/src/routes/docs/users.js b/src/routes/docs/users.js index deaeb113..0f19ecde 100644 --- a/src/routes/docs/users.js +++ b/src/routes/docs/users.js @@ -330,7 +330,7 @@ usersDocs[`${apiPrefix}/resetProfileImg`] = { }, }; -usersDocs[`${apiPrefix}/isBanned`] = { +usersDocs[`${apiPrefix}/getBanRecord`] = { get: { tags: [tag], summary: "본인의 현재 정지 기록을 가져움", @@ -344,10 +344,10 @@ usersDocs[`${apiPrefix}/isBanned`] = { type: "array", items: { properties: { - userId: { + userSid: { type: "string", - description: "사용자의 ObjectId", - pattern: objectId.source, + description: "사용자의 SSO ID", + pattern: "monday-sid", }, reason: { type: "string", @@ -364,85 +364,10 @@ usersDocs[`${apiPrefix}/isBanned`] = { description: "정지 만료 시각", example: "2024-05-21 12:00", }, - services: { - type: "array", - items: { - properties: { - serviceName: { - type: "string", - description: "정지를 당한 서비스 또는 이벤트 이름", - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - 400: { - content: { - "text/html": { - example: "Users/isBanned : there is no ban record", - }, - }, - }, - 500: { - content: { - "text/html": { - example: "Users/isBanned : internal server error", - }, - }, - }, - }, - }, -}; - -usersDocs[`${apiPrefix}/getBanRecord`] = { - get: { - tags: [tag], - summary: "본인의 모든 정지 기록을 가져움", - description: - "정지 기록들 중 본인인 경우에 해당하는 정지 기록을 모두 가져옴", - responses: { - 200: { - content: { - "application/json": { - schema: { - type: "array", - items: { - properties: { - userId: { - type: "string", - description: "사용자의 ObjectId", - pattern: objectId.source, - }, - reason: { + serviceName: { type: "string", - description: "정지 사유", - example: "미정산", - }, - bannedAt: { - type: "date", - description: "정지 당한 시각", - example: "2024-05-20 12:00", - }, - expireAt: { - type: "date", - description: "정지 만료 시각", - example: "2024-05-21 12:00", - }, - services: { - type: "array", - items: { - properties: { - serviceName: { - type: "string", - description: "정지를 당한 서비스 또는 이벤트 이름", - }, - }, - }, + description: "정지를 당한 서비스 또는 이벤트 이름", + example: "2023-fall-event", }, }, }, diff --git a/src/routes/rooms.js b/src/routes/rooms.js index 6345fa6f..b8bad634 100644 --- a/src/routes/rooms.js +++ b/src/routes/rooms.js @@ -35,6 +35,9 @@ router.get( // 이후 API 접근 시 로그인 필요 router.use(require("../middlewares/auth")); +// 방 생성/참여전 ban 여부 확인 +router.use(require("../middlewares/ban")); + // 특정 id 방 세부사항 보기 router.get( "/info", diff --git a/src/routes/users.js b/src/routes/users.js index d5366155..de3c0b1d 100755 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -56,9 +56,6 @@ router.get("/editProfileImg/done", userHandlers.editProfileImgDoneHandler); // 프로필 이미지를 기본값으로 재설정합니다. router.get("/resetProfileImg", userHandlers.resetProfileImgHandler); -// 유저의 현재 유효한 서비스 정지 기록들만 반환합니다. -router.get("/isBanned", userHandlers.isBannedHandler); - // 유저의 서비스 정지 기록들을 모두 반환합니다. router.get("/getBanRecord", userHandlers.getBanRecordHandler); diff --git a/src/services/rooms.js b/src/services/rooms.js index 218a23d5..0ef798fe 100644 --- a/src/services/rooms.js +++ b/src/services/rooms.js @@ -586,12 +586,7 @@ const commitSettlementHandler = async (req, res) => { }); // 이벤트 코드입니다. - await contracts?.completePayingQuest( - req.userOid, - req.timestamp, - roomObject - ); - await contracts?.completePayingAndSendingQuest( + await contracts?.completeFareSettlementQuest( req.userOid, req.timestamp, roomObject @@ -664,12 +659,7 @@ const commitPaymentHandler = async (req, res) => { }); // 이벤트 코드입니다. - await contracts?.completeSendingQuest( - req.userOid, - req.timestamp, - roomObject - ); - await contracts?.completePayingAndSendingQuest( + await contracts?.completeFarePaymentQuest( req.userOid, req.timestamp, roomObject diff --git a/src/services/users.js b/src/services/users.js index 5d8ee632..8c51c736 100644 --- a/src/services/users.js +++ b/src/services/users.js @@ -200,27 +200,14 @@ const resetProfileImgHandler = async (req, res) => { } }; -const isBannedHandler = async (req, res) => { - try { - // 현재 시각이 expireAt 보다 작고 본인인 경우(ban의 userId가 userOid랑 같은 경우)의 record를 모두 가져옴 - const result = await banModel.find({ - userId: req.userOid, - expireAt: { - $gte: req.timestamp, - }, - }); - if (!result) - return res.status(500).send("Users/isBanned : internal server error"); - res.status(200).json(result); - } catch (err) { - res.status(500).send("Users/isBanned : internal server error"); - } -}; - const getBanRecordHandler = async (req, res) => { try { - // 본인인 경우(ban의 userId가 userOid랑 같은 경우)의 record를 모두 가져옴 - const result = await banModel.find({ userId: req.userOid }); + // 본인인 경우(ban의 userId가 userSid랑 같은 경우)의 record를 모두 가져옴 + const result = await banModel + .find({ + userSid: req.session.loginInfo.sid, + }) + .sort({ expireAt: -1 }); if (!result) return res.status(500).send("Users/getBanRecord : internal server error"); res.status(200).json(result); @@ -238,6 +225,5 @@ module.exports = { editProfileImgDoneHandler, resetNicknameHandler, resetProfileImgHandler, - isBannedHandler, getBanRecordHandler, };