diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js index d827659108..a07862b0f5 100644 --- a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js @@ -91,6 +91,23 @@ describe('POST /groups/:groupId/quests/accept', () => { expect(partyMembers[0].party.quest.RSVPNeeded).to.be.false; }); + it('heals stuck RSVPNeeded when group already has the user accepted', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + + await partyMembers[0].updateOne({ 'party.quest.RSVPNeeded': true }); + await partyMembers[0].sync(); + expect(partyMembers[0].party.quest.RSVPNeeded).to.be.true; + + const res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); + expect(res).to.exist; + + await partyMembers[0].sync(); + await questingGroup.sync(); + expect(partyMembers[0].party.quest.RSVPNeeded).to.equal(false); + expect(questingGroup.quest.members[partyMembers[0]._id]).to.equal(true); + }); + it('does not accept invite for a quest already underway', async () => { await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js index 80b7f27ee0..b2a659c415 100644 --- a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js +++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js @@ -100,6 +100,23 @@ describe('POST /groups/:groupId/quests/reject', () => { expect(partyMembers[0].party.quest.RSVPNeeded).to.be.false; }); + it('heals stuck RSVPNeeded when group already has the user rejected', async () => { + await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); + await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); + + await partyMembers[0].updateOne({ 'party.quest.RSVPNeeded': true }); + await partyMembers[0].sync(); + expect(partyMembers[0].party.quest.RSVPNeeded).to.be.true; + + const res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`); + expect(res).to.exist; + + await partyMembers[0].sync(); + await questingGroup.sync(); + expect(partyMembers[0].party.quest.RSVPNeeded).to.equal(false); + expect(questingGroup.quest.members[partyMembers[0]._id]).to.equal(false); + }); + it('return an error when a user rejects an invite already accepted', async () => { await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`); await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`); diff --git a/website/server/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js index 1e46a8744f..d580c126b3 100644 --- a/website/server/controllers/api-v3/quests.js +++ b/website/server/controllers/api-v3/quests.js @@ -198,16 +198,32 @@ api.acceptQuest = { if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.key) throw new NotFound(res.t('questInviteNotFound')); if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyStartedFriendly')); - if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted')); - const acceptedSuccessfully = await group.handleQuestInvitation(user, true); + if (group.quest.members[user._id] === true) { + if (user.party.quest.RSVPNeeded) { + user.party.quest.RSVPNeeded = false; + await user.save(); + res.respond(200, group.quest); + return; + } + throw new BadRequest(res.t('questAlreadyAccepted')); + } + if (group.quest.members[user._id] === false) { + throw new BadRequest(res.t('questAlreadyAccepted')); + } + + let acceptedSuccessfully = false; + await Group.db.transaction(async session => { + acceptedSuccessfully = await group.handleQuestInvitation(user, true, session); + if (!acceptedSuccessfully) return; + user.party.quest.RSVPNeeded = false; + await user.save({ session }); + }); + if (!acceptedSuccessfully) { throw new NotAuthorized(res.t('questAlreadyAccepted')); } - user.party.quest.RSVPNeeded = false; - await user.save(); - if (canStartQuestAutomatically(group)) { await group.startQuest(user); } @@ -251,18 +267,34 @@ api.rejectQuest = { if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported')); if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist')); if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyStartedFriendly')); - if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted')); - if (group.quest.members[user._id] === false) throw new BadRequest(res.t('questAlreadyRejected')); - const rejectedSuccessfully = await group.handleQuestInvitation(user, false); + if (group.quest.members[user._id] === true) { + throw new BadRequest(res.t('questAlreadyAccepted')); + } + if (group.quest.members[user._id] === false) { + if (user.party.quest.RSVPNeeded) { + user.party.quest = Group.cleanQuestUser(user.party.quest.progress); + user.markModified('party.quest'); + await user.save(); + res.respond(200, group.quest); + return; + } + throw new BadRequest(res.t('questAlreadyRejected')); + } + + let rejectedSuccessfully = false; + await Group.db.transaction(async session => { + rejectedSuccessfully = await group.handleQuestInvitation(user, false, session); + if (!rejectedSuccessfully) return; + user.party.quest = Group.cleanQuestUser(user.party.quest.progress); + user.markModified('party.quest'); + await user.save({ session }); + }); + if (!rejectedSuccessfully) { throw new NotAuthorized(res.t('questAlreadyRejected')); } - user.party.quest = Group.cleanQuestUser(user.party.quest.progress); - user.markModified('party.quest'); - await user.save(); - if (canStartQuestAutomatically(group)) { await group.startQuest(user); } diff --git a/website/server/models/group.js b/website/server/models/group.js index 99071d83c7..2517d01162 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -654,7 +654,8 @@ schema.methods.sendChat = async function sendChat (options = {}) { return newChatMessage; }; -schema.methods.handleQuestInvitation = async function handleQuestInvitation (user, accept) { +// eslint-disable-next-line max-len +schema.methods.handleQuestInvitation = async function handleQuestInvitation (user, accept, session) { if (!user) throw new InternalServerError('Must provide user to handle quest invitation'); if (accept !== true && accept !== false) throw new InternalServerError('Must provide accept param handle quest invitation'); @@ -662,12 +663,14 @@ schema.methods.handleQuestInvitation = async function handleQuestInvitation (use // to prevent multiple concurrent requests overriding updates // see https://github.com/HabitRPG/habitica/issues/11398 const Group = this.constructor; + const options = session ? { session } : {}; const result = await Group.updateOne( { _id: this._id, [`quest.members.${user._id}`]: { $type: 10 }, // match BSON Type Null (type number 10) }, { $set: { [`quest.members.${user._id}`]: accept } }, + options, ).exec(); if (result.modifiedCount) {