fix(quests): stuck "you were invited" banner after accept/reject (#15647)

wrap the conditional group update and the user save in a single transaction so a failure                                                                                                              between them can't leave members[uid] and RSVPNeeded out of sync
This commit is contained in:
Fiz
2026-05-05 11:09:30 -05:00
committed by GitHub
parent 819ed2b355
commit 1178da3a26
4 changed files with 82 additions and 13 deletions
@@ -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`);
@@ -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`);
+44 -12
View File
@@ -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);
}
+4 -1
View File
@@ -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) {