diff --git a/src/lottery/modules/contracts/2023fall.js b/src/lottery/modules/contracts/2023fall.js index f5994d51..abe2f0d8 100644 --- a/src/lottery/modules/contracts/2023fall.js +++ b/src/lottery/modules/contracts/2023fall.js @@ -44,7 +44,7 @@ const quests = buildQuests({ reward: 50, maxCount: 3, }, - nicknameChaning: { + nicknameChanging: { name: "닉네임 변경", description: "", imageUrl: "", @@ -84,14 +84,17 @@ const eventPeriod = { /** * firstLogin 퀘스트의 완료를 요청합니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @returns {Promise} + * @usage lottery/globalState/createUserGlobalStateHandler */ -const completeFirstLoginQuest = async (userId) => { - return await completeQuest(userId, eventPeriod, quests.firstLogin); +const completeFirstLoginQuest = async (userId, timestamp) => { + return await completeQuest(userId, timestamp, eventPeriod, quests.firstLogin); }; /** * payingAndSending 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이거나, 모든 참가자가 정산 또는 송금을 완료하지 않았다면 요청하지 않습니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @param {Object} roomObject - 방의 정보입니다. * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. * @param {number} roomObject.settlementTotal - 정산 또는 송금이 완료된 참여자 수입니다. @@ -99,7 +102,7 @@ const completeFirstLoginQuest = async (userId) => { * @description 정산 요청 또는 송금이 이루어질 때마다 호출해 주세요. * @usage rooms/commitPaymentHandler, rooms/settlementHandler */ -const completePayingAndSendingQuest = async (roomObject) => { +const completePayingAndSendingQuest = async (timestamp, roomObject) => { if (roomObject.part.length < 2) return null; if (roomObject.part.length > roomObject.settlementTotal) return null; @@ -108,6 +111,7 @@ const completePayingAndSendingQuest = async (roomObject) => { async (participant) => await completeQuest( participant.user._id, + timestamp, eventPeriod, quests.payingAndSending ) @@ -118,12 +122,18 @@ const completePayingAndSendingQuest = async (roomObject) => { /** * firstRoomCreation 퀘스트의 완료를 요청합니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @returns {Promise} * @description 방을 만들 때마다 호출해 주세요. * @usage rooms/createHandler */ -const completeFirstRoomCreationQuest = async (userId) => { - return await completeQuest(userId, eventPeriod, quests.firstRoomCreation); +const completeFirstRoomCreationQuest = async (userId, timestamp) => { + return await completeQuest( + userId, + timestamp, + eventPeriod, + quests.firstRoomCreation + ); }; const completeRoomSharingQuest = async () => { @@ -133,60 +143,94 @@ const completeRoomSharingQuest = async () => { /** * paying 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @param {Object} roomObject - 방의 정보입니다. * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. * @returns {Promise} * @description 정산 요청이 이루어질 때마다 호출해 주세요. * @usage rooms/commitPaymentHandler */ -const completePayingQuest = async (userId, roomObject) => { +const completePayingQuest = async (userId, timestamp, roomObject) => { if (roomObject.part.length < 2) return null; - return await completeQuest(userId, eventPeriod, quests.paying); + return await completeQuest(userId, timestamp, eventPeriod, quests.paying); }; /** * sending 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @param {Object} roomObject - 방의 정보입니다. * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. * @returns {Promise} * @description 송금이 이루어질 때마다 호출해 주세요. * @usage rooms/settlementHandler */ -const completeSendingQuest = async (userId, roomObject) => { +const completeSendingQuest = async (userId, timestamp, roomObject) => { if (roomObject.part.length < 2) return null; - return await completeQuest(userId, eventPeriod, quests.sending); + return await completeQuest(userId, timestamp, eventPeriod, quests.sending); }; /** - * nicknameChaning 퀘스트의 완료를 요청합니다. + * nicknameChanging 퀘스트의 완료를 요청합니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @returns {Promise} * @description 닉네임을 변경할 때마다 호출해 주세요. * @usage users/editNicknameHandler */ -const completeNicknameChangingQuest = async (userId) => { - return await completeQuest(userId, eventPeriod, quests.nicknameChaning); +const completeNicknameChangingQuest = async (userId, timestamp) => { + return await completeQuest( + userId, + timestamp, + eventPeriod, + quests.nicknameChanging + ); }; /** * accountChanging 퀘스트의 완료를 요청합니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @param {string} newAccount - 변경된 계좌입니다. * @returns {Promise} * @description 계좌를 변경할 때마다 호출해 주세요. * @usage users/editAccountHandler */ -const completeAccountChangingQuest = async (userId, newAccount) => { +const completeAccountChangingQuest = async (userId, timestamp, newAccount) => { if (newAccount === "") return null; - return await completeQuest(userId, eventPeriod, quests.accountChanging); + return await completeQuest( + userId, + timestamp, + eventPeriod, + quests.accountChanging + ); }; -const completeAdPushAgreementQuest = async () => { - // TODO +/** + * adPushAgreementQuest 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {boolean} advertisement - 변경된 광고성 알림 수신 동의 여부입니다. + * @returns {Promise} + * @description 알림 옵션을 변경할 때마다 호출해 주세요. + * @usage notifications/editOptionsHandler + */ +const completeAdPushAgreementQuest = async ( + userId, + timestamp, + advertisement +) => { + if (!advertisement) return null; + + return await completeQuest( + userId, + timestamp, + eventPeriod, + quests.adPushAgreement + ); }; const completeEventSharingOnInstagramQuest = async () => { diff --git a/src/lottery/modules/quests.js b/src/lottery/modules/quests.js index 50371daa..ce2d22d4 100644 --- a/src/lottery/modules/quests.js +++ b/src/lottery/modules/quests.js @@ -44,6 +44,7 @@ const buildQuests = (quests) => { /** * 퀘스트 완료를 요청합니다. * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. * @param {Object} eventPeriod - 이벤트의 기간입니다. * @param {Date} eventPeriod.start - 이벤트의 시작 시각(Inclusive)입니다. * @param {Date} eventPeriod.end - 이벤트의 종료 시각(Exclusive)입니다. @@ -56,24 +57,20 @@ const buildQuests = (quests) => { * @param {number} quest.maxCount - 퀘스트의 최대 완료 가능 횟수입니다. * @returns {Object|null} 성공한 경우 Object를, 실패한 경우 null을 반환합니다. 이미 최대 완료 횟수에 도달했거나, 퀘스트가 원격으로 비활성화 된 경우에도 실패로 처리됩니다. */ -const completeQuest = async (userId, eventPeriod, quest) => { +const completeQuest = async (userId, timestamp, eventPeriod, quest) => { try { - // 1단계: 이벤트 기간인지 확인합니다. - const now = Date.now(); - if (now >= eventPeriod.end || now < eventPeriod.start) { + // 1단계: 유저의 EventStatus를 가져옵니다. + const eventStatus = await eventStatusModel.findOne({ userId }).lean(); + if (!eventStatus) return null; + + // 2단계: 이벤트 기간인지 확인합니다. + if (timestamp >= eventPeriod.end || timestamp < eventPeriod.start) { logger.info( `User ${userId} failed to complete auto-disabled ${quest.id}Quest` ); return null; } - // 2단계: 유저의 EventStatus를 가져옵니다. 없으면 새롭게 생성합니다. - let eventStatus = await eventStatusModel.findOne({ userId }).lean(); - if (!eventStatus) { - eventStatus = new eventStatusModel({ userId }); - await eventStatus.save(); - } - // 3단계: 유저의 퀘스트 완료 횟수를 확인합니다. const questCount = eventStatus.completedQuests.filter( (completedQuestId) => completedQuestId === quest.id diff --git a/src/lottery/routes/docs/globalState.js b/src/lottery/routes/docs/globalState.js index 29173691..6883ac33 100644 --- a/src/lottery/routes/docs/globalState.js +++ b/src/lottery/routes/docs/globalState.js @@ -7,7 +7,7 @@ globalStateDocs[`${apiPrefix}/`] = { tags: [`${apiPrefix}`], summary: "Frontend에서 Global state로 관리하는 정보 반환", description: - "유저의 재화 개수, 퀘스트 완료 상태 등 Frontend에서 Global state로 관리할 정보를 가져옵니다. 유저에 대한 EventStatus Document가 없을 경우 새롭게 생성합니다.", + "유저의 재화 개수, 퀘스트 완료 상태 등 Frontend에서 Global state로 관리할 정보를 가져옵니다.", responses: { 200: { description: "", @@ -16,6 +16,7 @@ globalStateDocs[`${apiPrefix}/`] = { schema: { type: "object", required: [ + "isAgree", "creditAmount", "completedQuests", "ticket1Amount", @@ -23,6 +24,11 @@ globalStateDocs[`${apiPrefix}/`] = { "quests", ], properties: { + isAgreeOnTermsOfEvent: { + type: "boolean", + description: "유저의 이벤트 참여 동의 여부", + example: true, + }, creditAmount: { type: "number", description: "재화 개수. 0 이상입니다.", @@ -116,5 +122,33 @@ globalStateDocs[`${apiPrefix}/`] = { }, }, }; +globalStateDocs[`${apiPrefix}/create`] = { + get: { + tags: [`${apiPrefix}`], + summary: "Frontend에서 Global state로 관리하는 정보 생성", + description: + "유저의 재화 개수, 퀘스트 완료 상태 등 Frontend에서 Global state로 관리할 정보를 생성합니다.", + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["result"], + properties: { + result: { + type: "boolean", + description: "성공 여부. 항상 true입니다.", + example: true, + }, + }, + }, + }, + }, + }, + }, + }, +}; module.exports = globalStateDocs; diff --git a/src/lottery/routes/docs/itemsSchema.js b/src/lottery/routes/docs/itemsSchema.js index 227f2b08..1601f237 100644 --- a/src/lottery/routes/docs/itemsSchema.js +++ b/src/lottery/routes/docs/itemsSchema.js @@ -75,6 +75,17 @@ const itemsSchema = { ...itemBase, description: "랜덤박스를 구입한 경우에만 포함됩니다.", }, + purchaseHandler: { + type: "object", + required: ["itemId"], + properties: { + itemId: { + type: "string", + pattern: "^[a-fA-F\\d]{24}$", + }, + }, + errorMessage: "validation: bad request", + }, }; module.exports = itemsSchema; diff --git a/src/lottery/routes/globalState.js b/src/lottery/routes/globalState.js index f0f75406..d0461218 100644 --- a/src/lottery/routes/globalState.js +++ b/src/lottery/routes/globalState.js @@ -3,9 +3,11 @@ const express = require("express"); const router = express.Router(); const globalStateHandlers = require("../services/globalState"); -// 라우터 접근 시 로그인 필요 +router.get("/", globalStateHandlers.getUserGlobalStateHandler); + +// 아래의 Endpoint 접근 시 로그인 필요 router.use(require("../../middlewares/auth")); -router.get("/", globalStateHandlers.getUserGlobalStateHandler); +router.post("/create", globalStateHandlers.createUserGlobalStateHandler); module.exports = router; diff --git a/src/lottery/routes/items.js b/src/lottery/routes/items.js index 539288bc..95136a5f 100644 --- a/src/lottery/routes/items.js +++ b/src/lottery/routes/items.js @@ -2,9 +2,19 @@ const express = require("express"); const router = express.Router(); const itemsHandlers = require("../services/items"); -const auth = require("../../middlewares/auth"); + +const { validateParams } = require("../../middlewares/ajv"); +const itemsSchema = require("./docs/itemsSchema"); router.get("/list", itemsHandlers.listHandler); -router.post("/purchase/:itemId", auth, itemsHandlers.purchaseHandler); + +// 아래의 Endpoint 접근 시 로그인 필요 +router.use(require("../../middlewares/auth")); + +router.post( + "/purchase/:itemId", + validateParams(itemsSchema.purchaseHandler), + itemsHandlers.purchaseHandler +); module.exports = router; diff --git a/src/lottery/services/globalState.js b/src/lottery/services/globalState.js index ff55dc22..7c7f7c5f 100644 --- a/src/lottery/services/globalState.js +++ b/src/lottery/services/globalState.js @@ -1,36 +1,67 @@ const { eventStatusModel } = require("../modules/stores/mongo"); const logger = require("../../modules/logger"); +const { isLogin, getLoginInfo } = require("../../modules/auths/login"); const { eventMode } = require("../../../loadenv"); -const quests = eventMode - ? Object.values(require(`../modules/contracts/${eventMode}`).quests) +const contract = eventMode + ? require(`../modules/contracts/${eventMode}`) : undefined; +const quests = contract ? Object.values(contract.quests) : undefined; const getUserGlobalStateHandler = async (req, res) => { + try { + const userId = isLogin(req) ? getLoginInfo(req).oid : null; + const eventStatus = + userId && + (await eventStatusModel.findOne({ userId }, "-_id -userId -__v").lean()); + if (eventStatus) + return res.json({ + isAgreeOnTermsOfEvent: true, + ...eventStatus, + quests, + }); + else + return res.json({ + isAgreeOnTermsOfEvent: false, + completedQuests: [], + creditAmount: 0, + ticket1Amount: 0, + ticket2Amount: 0, + quests, + }); + } catch (err) { + logger.error(err); + res.status(500).json({ error: "GlobalState/ : internal server error" }); + } +}; + +const createUserGlobalStateHandler = async (req, res) => { try { let eventStatus = await eventStatusModel .findOne({ userId: req.userOid }) .lean(); - if (!eventStatus) { - eventStatus = new eventStatusModel({ - userId: req.userOid, - }); - await eventStatus.save(); - } + if (eventStatus) + return res + .status(400) + .json({ error: "GlobalState/Create : already created" }); - res.json({ - creditAmount: eventStatus.creditAmount, - completedQuests: eventStatus.completedQuests, - ticket1Amount: eventStatus.ticket1Amount, - ticket2Amount: eventStatus.ticket2Amount, - quests, + eventStatus = new eventStatusModel({ + userId: req.userOid, }); + await eventStatus.save(); + + await contract.completeFirstLoginQuest(req.userOid, req.timestamp); + + res.json({ result: true }); } catch (err) { logger.error(err); - res.status(500).json({ error: "GlobalState/ : internal server error" }); + res + .status(500) + .json({ error: "GlobalState/Create : internal server error" }); } }; module.exports = { getUserGlobalStateHandler, + createUserGlobalStateHandler, }; diff --git a/src/lottery/services/items.js b/src/lottery/services/items.js index 36b4bf10..5e3a5a1c 100644 --- a/src/lottery/services/items.js +++ b/src/lottery/services/items.js @@ -120,8 +120,13 @@ const listHandler = async (_, res) => { const purchaseHandler = async (req, res) => { try { - const now = Date.now(); - if (now >= eventPeriod.end || now < eventPeriod.start) + const eventStatus = await eventStatusModel.findOne({ userId: req.userOid }); + if (!eventStatus) + return res + .status(400) + .json({ error: "Items/Purchase : nonexistent eventStatus" }); + + if (req.timestamp >= eventPeriod.end || req.timestamp < eventPeriod.start) return res.status(400).json({ error: "Items/Purchase : out of date" }); const { itemId } = req.params; @@ -129,12 +134,6 @@ const purchaseHandler = async (req, res) => { if (!item) return res.status(400).json({ error: "Items/Purchase : invalid Item" }); - const eventStatus = await eventStatusModel.find({ userId: req.userOid }); - if (!eventStatus) - return res - .status(400) - .json({ error: "Items/Purchase : invalid EventStatus" }); - // 구매 가능 조건: 크레딧이 충분하며, 재고가 남아있으며, 판매 중인 아이템이어야 합니다. if (item.isDisabled) return res.status(400).json({ error: "Items/Purchase : disabled item" }); diff --git a/src/services/notifications.js b/src/services/notifications.js index 799c8bb7..633f6739 100644 --- a/src/services/notifications.js +++ b/src/services/notifications.js @@ -4,6 +4,9 @@ const logger = require("../modules/logger"); const { registerDeviceToken, validateDeviceToken } = require("../modules/fcm"); +// 이벤트 코드입니다. +const { contracts } = require("../lottery"); + const registerDeviceTokenHandler = async (req, res) => { try { // 해당 FCM device token이 유효한지 검사합니다. @@ -104,6 +107,13 @@ const editOptionsHandler = async (req, res) => { .send("Notification/editOptions: deviceToken not found"); } + // 이벤트 코드입니다. + await contracts?.completeAdPushAgreementQuest( + req.userOid, + req.timestamp, + options.advertisement + ); + res.status(200).json(updatedNotificationOptions); } catch (err) { logger.error(err); diff --git a/src/services/rooms.js b/src/services/rooms.js index eb158278..4ef35731 100644 --- a/src/services/rooms.js +++ b/src/services/rooms.js @@ -87,7 +87,7 @@ const createHandler = async (req, res) => { const roomObjectFormated = formatSettlement(roomObject); // 이벤트 코드입니다. - await contracts?.completeFirstRoomCreationQuest(user._id); + await contracts?.completeFirstRoomCreationQuest(req.userOid, req.timestamp); return res.send(roomObjectFormated); } catch (err) { @@ -492,8 +492,12 @@ const commitPaymentHandler = async (req, res) => { }); // 이벤트 코드입니다. - await contracts?.completePayingQuest(user._id, roomObject); - await contracts?.completePayingAndSendingQuest(roomObject); + await contracts?.completePayingQuest( + req.userOid, + req.timestamp, + roomObject + ); + await contracts?.completePayingAndSendingQuest(req.timestamp, roomObject); // 수정한 방 정보를 반환합니다. res.send(formatSettlement(roomObject, { isOver: true })); @@ -562,8 +566,12 @@ const settlementHandler = async (req, res) => { }); // 이벤트 코드입니다. - await contracts?.completeSendingQuest(user._id, roomObject); - await contracts?.completePayingAndSendingQuest(roomObject); + await contracts?.completeSendingQuest( + req.userOid, + req.timestamp, + roomObject + ); + await contracts?.completePayingAndSendingQuest(req.timestamp, roomObject); // 수정한 방 정보를 반환합니다. res.send(formatSettlement(roomObject, { isOver: true })); diff --git a/src/services/users.js b/src/services/users.js index b7b47e41..f4a00d6a 100644 --- a/src/services/users.js +++ b/src/services/users.js @@ -46,7 +46,10 @@ const editNicknameHandler = async (req, res) => { if (result) { // 이벤트 코드입니다. - await contracts?.completeNicknameChangingQuest(req.userOid); + await contracts?.completeNicknameChangingQuest( + req.userOid, + req.timestamp + ); res.status(200).send("User/editNickname : edit user nickname successful"); } else { @@ -68,7 +71,11 @@ const editAccountHandler = async (req, res) => { if (result) { // 이벤트 코드입니다. - await contracts?.completeAccountChangingQuest(req.userOid, newAccount); + await contracts?.completeAccountChangingQuest( + req.userOid, + req.timestamp, + newAccount + ); res.status(200).send("User/editAccount : edit user account successful"); } else {