mirror of
https://github.com/sudoxnym/habitica-self-host.git
synced 2026-04-14 19:47:03 +00:00
Prevent duplicate challenge tasks (#11502)
* Implement atomic user challenges update Prevents multiple concurrent requests from passing through Fixes #11295 * Move user challenges update to separate method * Rename challenge syncToUser to syncTasksToUser Now that adding the challenge to user is separated, this methods main purpose is to sync the tasks * Fix lint errors
This commit is contained in:
parent
bdb3cf25c1
commit
cd90a281c2
4 changed files with 60 additions and 17 deletions
|
|
@ -10,7 +10,7 @@ async function syncChallengeToMembers (challenges) {
|
||||||
|
|
||||||
const promises = [];
|
const promises = [];
|
||||||
users.forEach(user => {
|
users.forEach(user => {
|
||||||
promises.push(challenge.syncToUser(user));
|
promises.push(challenge.syncTasksToUser(user));
|
||||||
promises.push(challenge.save());
|
promises.push(challenge.save());
|
||||||
promises.push(user.save());
|
promises.push(user.save());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,37 @@ describe('Challenge Model', () => {
|
||||||
expect(syncedTask.tags[0]).to.eql(challenge._id);
|
expect(syncedTask.tags[0]).to.eql(challenge._id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('syncs a challenge to a user', async () => {
|
it('adds a challenge to a user', async () => {
|
||||||
|
const newMember = new User({
|
||||||
|
guilds: [guild._id],
|
||||||
|
});
|
||||||
|
await newMember.save();
|
||||||
|
|
||||||
|
const addedSuccessfully = await challenge.addToUser(newMember);
|
||||||
|
|
||||||
|
const updatedNewMember = await User.findById(newMember._id);
|
||||||
|
|
||||||
|
expect(addedSuccessfully).to.eql(true);
|
||||||
|
expect(updatedNewMember.challenges).to.contain(challenge._id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add a challenge to a user that already in the challenge', async () => {
|
||||||
|
const newMember = new User({
|
||||||
|
guilds: [guild._id],
|
||||||
|
challenges: [challenge._id],
|
||||||
|
});
|
||||||
|
await newMember.save();
|
||||||
|
|
||||||
|
const addedSuccessfully = await challenge.addToUser(newMember);
|
||||||
|
|
||||||
|
const updatedNewMember = await User.findById(newMember._id);
|
||||||
|
|
||||||
|
expect(addedSuccessfully).to.eql(false);
|
||||||
|
expect(updatedNewMember.challenges).to.contain(challenge._id);
|
||||||
|
expect(updatedNewMember.challenges.length).to.eql(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs challenge tasks to a user', async () => {
|
||||||
await challenge.addTasks([task]);
|
await challenge.addTasks([task]);
|
||||||
|
|
||||||
const newMember = new User({
|
const newMember = new User({
|
||||||
|
|
@ -98,7 +128,7 @@ describe('Challenge Model', () => {
|
||||||
});
|
});
|
||||||
await newMember.save();
|
await newMember.save();
|
||||||
|
|
||||||
await challenge.syncToUser(newMember);
|
await challenge.syncTasksToUser(newMember);
|
||||||
|
|
||||||
const updatedNewMember = await User.findById(newMember._id);
|
const updatedNewMember = await User.findById(newMember._id);
|
||||||
const updatedNewMemberTasks = await Tasks.Task.find({ _id: { $in: updatedNewMember.tasksOrder[`${taskType}s`] } });
|
const updatedNewMemberTasks = await Tasks.Task.find({ _id: { $in: updatedNewMember.tasksOrder[`${taskType}s`] } });
|
||||||
|
|
@ -110,14 +140,13 @@ describe('Challenge Model', () => {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(updatedNewMember.challenges).to.contain(challenge._id);
|
|
||||||
expect(updatedNewMember.tags[7].id).to.equal(challenge._id);
|
expect(updatedNewMember.tags[7].id).to.equal(challenge._id);
|
||||||
expect(updatedNewMember.tags[7].name).to.equal(challenge.shortName);
|
expect(updatedNewMember.tags[7].name).to.equal(challenge.shortName);
|
||||||
expect(syncedTask).to.exist;
|
expect(syncedTask).to.exist;
|
||||||
expect(syncedTask.attribute).to.eql('str');
|
expect(syncedTask.attribute).to.eql('str');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('syncs a challenge to a user with the existing task', async () => {
|
it('syncs challenge tasks to a user with the existing task', async () => {
|
||||||
await challenge.addTasks([task]);
|
await challenge.addTasks([task]);
|
||||||
|
|
||||||
let updatedLeader = await User.findOne({ _id: leader._id });
|
let updatedLeader = await User.findOne({ _id: leader._id });
|
||||||
|
|
@ -134,7 +163,7 @@ describe('Challenge Model', () => {
|
||||||
task.text = newTitle;
|
task.text = newTitle;
|
||||||
task.attribute = 'int';
|
task.attribute = 'int';
|
||||||
await task.save();
|
await task.save();
|
||||||
await challenge.syncToUser(leader);
|
await challenge.syncTasksToUser(leader);
|
||||||
|
|
||||||
updatedLeader = await User.findOne({ _id: leader._id });
|
updatedLeader = await User.findOne({ _id: leader._id });
|
||||||
updatedLeadersTasks = await Tasks.Task.find({ _id: { $in: updatedLeader.tasksOrder[`${taskType}s`] } });
|
updatedLeadersTasks = await Tasks.Task.find({ _id: { $in: updatedLeader.tasksOrder[`${taskType}s`] } });
|
||||||
|
|
|
||||||
|
|
@ -254,19 +254,23 @@ api.joinChallenge = {
|
||||||
|
|
||||||
const challenge = await Challenge.findOne({ _id: req.params.challengeId }).exec();
|
const challenge = await Challenge.findOne({ _id: req.params.challengeId }).exec();
|
||||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||||
if (challenge.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge'));
|
|
||||||
|
|
||||||
const group = await Group.getGroup({
|
const group = await Group.getGroup({
|
||||||
user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true,
|
user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true,
|
||||||
});
|
});
|
||||||
if (!group || !challenge.canJoin(user, group)) throw new NotFound(res.t('challengeNotFound'));
|
if (!group || !challenge.canJoin(user, group)) throw new NotFound(res.t('challengeNotFound'));
|
||||||
|
|
||||||
|
const addedSuccessfully = await challenge.addToUser(user);
|
||||||
|
if (!addedSuccessfully) {
|
||||||
|
throw new NotAuthorized(res.t('userAlreadyInChallenge'));
|
||||||
|
}
|
||||||
|
|
||||||
challenge.memberCount += 1;
|
challenge.memberCount += 1;
|
||||||
|
|
||||||
addUserJoinChallengeNotification(user);
|
addUserJoinChallengeNotification(user);
|
||||||
|
|
||||||
// Add all challenge's tasks to user's tasks and save the challenge
|
// Add all challenge's tasks to user's tasks and save the challenge
|
||||||
const results = await Promise.all([challenge.syncToUser(user), challenge.save()]);
|
const results = await Promise.all([challenge.syncTasksToUser(user), challenge.save()]);
|
||||||
|
|
||||||
const response = results[1].toJSON();
|
const response = results[1].toJSON();
|
||||||
response.group = getChallengeGroupResponse(group);
|
response.group = getChallengeGroupResponse(group);
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,23 @@ schema.methods.canJoin = function canJoinChallenge (user, group) {
|
||||||
return user.getGroups().indexOf(this.group) !== -1;
|
return user.getGroups().indexOf(this.group) !== -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Returns true if the challenge was successfully added to the user
|
||||||
|
// or false if the user already in the challenge
|
||||||
|
schema.methods.addToUser = async function addChallengeToUser (user) {
|
||||||
|
// Add challenge to users challenges atomically (with a condition that checks that it
|
||||||
|
// is not there already) to prevent multiple concurrent requests from passing through
|
||||||
|
// see https://github.com/HabitRPG/habitica/issues/11295
|
||||||
|
const result = await User.update(
|
||||||
|
{
|
||||||
|
_id: user._id,
|
||||||
|
challenges: { $nin: [this._id] },
|
||||||
|
},
|
||||||
|
{ $push: { challenges: this._id } },
|
||||||
|
).exec();
|
||||||
|
|
||||||
|
return !!result.nModified;
|
||||||
|
};
|
||||||
|
|
||||||
// Returns true if user can view the challenge
|
// Returns true if user can view the challenge
|
||||||
// Different from canJoin because you can see challenges of groups
|
// Different from canJoin because you can see challenges of groups
|
||||||
// you've been removed from if you're participating in them
|
// you've been removed from if you're participating in them
|
||||||
|
|
@ -102,19 +119,12 @@ schema.methods.canView = function canViewChallenge (user, group) {
|
||||||
return this.canJoin(user, group);
|
return this.canJoin(user, group);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync challenge to user, including tasks and tags.
|
// Sync challenge tasks to user, including tags.
|
||||||
// Used when user joins the challenge or to force sync.
|
// Used when user joins the challenge or to force sync.
|
||||||
schema.methods.syncToUser = async function syncChallengeToUser (user) {
|
schema.methods.syncTasksToUser = async function syncChallengeTasksToUser (user) {
|
||||||
const challenge = this;
|
const challenge = this;
|
||||||
challenge.shortName = challenge.shortName || challenge.name;
|
challenge.shortName = challenge.shortName || challenge.name;
|
||||||
|
|
||||||
// Add challenge to user.challenges
|
|
||||||
if (!_.includes(user.challenges, challenge._id)) {
|
|
||||||
// using concat because mongoose's protection against
|
|
||||||
// concurrent array modification isn't working as expected.
|
|
||||||
// see https://github.com/HabitRPG/habitica/pull/7787#issuecomment-232972394
|
|
||||||
user.challenges = user.challenges.concat([challenge._id]);
|
|
||||||
}
|
|
||||||
// Sync tags
|
// Sync tags
|
||||||
const userTags = user.tags;
|
const userTags = user.tags;
|
||||||
const i = _.findIndex(userTags, { id: challenge._id });
|
const i = _.findIndex(userTags, { id: challenge._id });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue