mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 06:37:23 +01:00
Handle simultaneous quest accept/reject (#12090)
* Implement atomic quest accept/reject * Persist quest.members early to avoid simultaneous handling of accept/reject * Fix quest accept test (missing expectation) * PR fixes
This commit is contained in:
@@ -112,7 +112,7 @@ describe('POST /groups/:groupId/quests/accept', () => {
|
|||||||
|
|
||||||
await Promise.all([partyMembers[0].sync(), questingGroup.sync()]);
|
await Promise.all([partyMembers[0].sync(), questingGroup.sync()]);
|
||||||
expect(leader.party.quest.RSVPNeeded).to.equal(false);
|
expect(leader.party.quest.RSVPNeeded).to.equal(false);
|
||||||
expect(questingGroup.quest.members[partyMembers[0]._id]);
|
expect(questingGroup.quest.members[partyMembers[0]._id]).to.equal(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not begin the quest if pending invitations remain', async () => {
|
it('does not begin the quest if pending invitations remain', async () => {
|
||||||
|
|||||||
@@ -193,12 +193,14 @@ api.acceptQuest = {
|
|||||||
if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyStartedFriendly'));
|
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]) throw new BadRequest(res.t('questAlreadyAccepted'));
|
||||||
|
|
||||||
|
const acceptedSuccessfully = await group.handleQuestInvitation(user, true);
|
||||||
|
if (!acceptedSuccessfully) {
|
||||||
|
throw new NotAuthorized(res.t('questAlreadyAccepted'));
|
||||||
|
}
|
||||||
|
|
||||||
user.party.quest.RSVPNeeded = false;
|
user.party.quest.RSVPNeeded = false;
|
||||||
await user.save();
|
await user.save();
|
||||||
|
|
||||||
group.markModified('quest');
|
|
||||||
group.quest.members[user._id] = true;
|
|
||||||
|
|
||||||
if (canStartQuestAutomatically(group)) {
|
if (canStartQuestAutomatically(group)) {
|
||||||
await group.startQuest(user);
|
await group.startQuest(user);
|
||||||
}
|
}
|
||||||
@@ -252,13 +254,15 @@ api.rejectQuest = {
|
|||||||
if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted'));
|
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'));
|
if (group.quest.members[user._id] === false) throw new BadRequest(res.t('questAlreadyRejected'));
|
||||||
|
|
||||||
|
const rejectedSuccessfully = await group.handleQuestInvitation(user, false);
|
||||||
|
if (!rejectedSuccessfully) {
|
||||||
|
throw new NotAuthorized(res.t('questAlreadyRejected'));
|
||||||
|
}
|
||||||
|
|
||||||
user.party.quest = Group.cleanQuestUser(user.party.quest.progress);
|
user.party.quest = Group.cleanQuestUser(user.party.quest.progress);
|
||||||
user.markModified('party.quest');
|
user.markModified('party.quest');
|
||||||
await user.save();
|
await user.save();
|
||||||
|
|
||||||
group.quest.members[user._id] = false;
|
|
||||||
group.markModified('quest.members');
|
|
||||||
|
|
||||||
if (canStartQuestAutomatically(group)) {
|
if (canStartQuestAutomatically(group)) {
|
||||||
await group.startQuest(user);
|
await group.startQuest(user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -651,6 +651,30 @@ schema.methods.sendChat = function sendChat (options = {}) {
|
|||||||
return newChatMessage;
|
return newChatMessage;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
schema.methods.handleQuestInvitation = async function handleQuestInvitation (user, accept) {
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Handle quest invitation atomically (update only current member when still undecided)
|
||||||
|
// to prevent multiple concurrent requests overriding updates
|
||||||
|
// see https://github.com/HabitRPG/habitica/issues/11398
|
||||||
|
const Group = this.constructor;
|
||||||
|
const result = await Group.update(
|
||||||
|
{
|
||||||
|
_id: this._id,
|
||||||
|
[`quest.members.${user._id}`]: { $type: 10 }, // match BSON Type Null (type number 10)
|
||||||
|
},
|
||||||
|
{ $set: { [`quest.members.${user._id}`]: accept } },
|
||||||
|
).exec();
|
||||||
|
|
||||||
|
if (result.nModified) {
|
||||||
|
// update also current instance so future operations will work correctly
|
||||||
|
this.quest.members[user._id] = accept;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(result.nModified);
|
||||||
|
};
|
||||||
|
|
||||||
schema.methods.startQuest = async function startQuest (user) {
|
schema.methods.startQuest = async function startQuest (user) {
|
||||||
// not using i18n strings because these errors are meant
|
// not using i18n strings because these errors are meant
|
||||||
// for devs who forgot to pass some parameters
|
// for devs who forgot to pass some parameters
|
||||||
@@ -680,6 +704,11 @@ schema.methods.startQuest = async function startQuest (user) {
|
|||||||
|
|
||||||
// Changes quest.members to only include participating members
|
// Changes quest.members to only include participating members
|
||||||
this.quest.members = _.pickBy(this.quest.members, _.identity);
|
this.quest.members = _.pickBy(this.quest.members, _.identity);
|
||||||
|
|
||||||
|
// Persist quest.members early to avoid simultaneous handling of accept/reject
|
||||||
|
// while processing the rest of this script
|
||||||
|
await this.update({ $set: { 'quest.members': this.quest.members } }).exec();
|
||||||
|
|
||||||
const nonUserQuestMembers = _.keys(this.quest.members);
|
const nonUserQuestMembers = _.keys(this.quest.members);
|
||||||
removeFromArray(nonUserQuestMembers, user._id);
|
removeFromArray(nonUserQuestMembers, user._id);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user