mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-18 15:17:25 +01:00
* Leaving a group or a guild no longer removes the user from the challenges of that group or guild. * Updating api docs for leaving group to take into account the default path no longer leaving challenges when leaving a group. * Updating api docs for leaving group to take into account the default path no longer leaving challenges when leaving a group. * refactored according to blade's comments to not be a breaking change. The api now accepts a body parameter to specify wether the user should remain in the groups challenges or leave them. The change also adds more tests around this behavior to confirm that it works as expected.
334 lines
11 KiB
JavaScript
334 lines
11 KiB
JavaScript
import mongoose from 'mongoose';
|
|
import Bluebird from 'bluebird';
|
|
import validator from 'validator';
|
|
import baseModel from '../libs/baseModel';
|
|
import _ from 'lodash';
|
|
import * as Tasks from './task';
|
|
import { model as User } from './user';
|
|
import {
|
|
model as Group,
|
|
TAVERN_ID,
|
|
} from './group';
|
|
import { removeFromArray } from '../libs/collectionManipulators';
|
|
import shared from '../../common';
|
|
import { sendTxn as txnEmail } from '../libs/email';
|
|
import { sendNotification as sendPushNotification } from '../libs/pushNotifications';
|
|
import cwait from 'cwait';
|
|
import { syncableAttrs } from '../libs/taskManager';
|
|
|
|
const Schema = mongoose.Schema;
|
|
|
|
let schema = new Schema({
|
|
name: {type: String, required: true},
|
|
shortName: {type: String, required: true, minlength: 3},
|
|
description: String,
|
|
official: {type: Boolean, default: false},
|
|
tasksOrder: {
|
|
habits: [{type: String, ref: 'Task'}],
|
|
dailys: [{type: String, ref: 'Task'}],
|
|
todos: [{type: String, ref: 'Task'}],
|
|
rewards: [{type: String, ref: 'Task'}],
|
|
},
|
|
leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
|
|
group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
|
|
memberCount: {type: Number, default: 1},
|
|
prize: {type: Number, default: 0, min: 0},
|
|
}, {
|
|
strict: true,
|
|
minimize: false, // So empty objects are returned
|
|
});
|
|
|
|
schema.plugin(baseModel, {
|
|
noSet: ['_id', 'memberCount', 'tasksOrder'],
|
|
timestamps: true,
|
|
});
|
|
|
|
// A list of additional fields that cannot be updated (but can be set on creation)
|
|
let noUpdate = ['group', 'official', 'shortName', 'prize'];
|
|
schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
|
|
return this.sanitize(updateObj, noUpdate);
|
|
};
|
|
|
|
// Returns true if user is a member of the challenge
|
|
schema.methods.isMember = function isChallengeMember (user) {
|
|
return user.challenges.indexOf(this._id) !== -1;
|
|
};
|
|
|
|
// Returns true if the user can modify (close, selectWinner, ...) the challenge
|
|
schema.methods.canModify = function canModifyChallenge (user) {
|
|
return user.contributor.admin || this.leader === user._id;
|
|
};
|
|
|
|
// Returns true if user has access to the challenge (can join)
|
|
schema.methods.hasAccess = function hasAccessToChallenge (user, group) {
|
|
if (group.type === 'guild' && group.privacy === 'public') return true;
|
|
return user.getGroups().indexOf(this.group) !== -1;
|
|
};
|
|
|
|
// Returns true if user can view the challenge
|
|
// Different from hasAccess because you can see challenges of groups you've been removed from if you're partecipating in them
|
|
schema.methods.canView = function canViewChallenge (user, group) {
|
|
if (this.isMember(user)) return true;
|
|
return this.hasAccess(user, group);
|
|
};
|
|
|
|
// Sync challenge to user, including tasks and tags.
|
|
// Used when user joins the challenge or to force sync.
|
|
schema.methods.syncToUser = async function syncChallengeToUser (user) {
|
|
let challenge = this;
|
|
challenge.shortName = challenge.shortName || challenge.name;
|
|
|
|
// Add challenge to user.challenges
|
|
if (!_.contains(user.challenges, challenge._id)) {
|
|
// using concat because mongoose's protection against concurrent array modification isn't working as expected.
|
|
// see https://github.com/HabitRPG/habitrpg/pull/7787#issuecomment-232972394
|
|
user.challenges = user.challenges.concat([challenge._id]);
|
|
}
|
|
// Sync tags
|
|
let userTags = user.tags;
|
|
let i = _.findIndex(userTags, {id: challenge._id});
|
|
|
|
if (i !== -1) {
|
|
if (userTags[i].name !== challenge.shortName) {
|
|
// update the name - it's been changed since
|
|
userTags[i].name = challenge.shortName;
|
|
}
|
|
} else {
|
|
userTags.push({
|
|
id: challenge._id,
|
|
name: challenge.shortName,
|
|
challenge: true,
|
|
});
|
|
}
|
|
|
|
let [challengeTasks, userTasks] = await Bluebird.all([
|
|
// Find original challenge tasks
|
|
Tasks.Task.find({
|
|
userId: {$exists: false},
|
|
'challenge.id': challenge._id,
|
|
}).exec(),
|
|
// Find user's tasks linked to this challenge
|
|
Tasks.Task.find({
|
|
userId: user._id,
|
|
'challenge.id': challenge._id,
|
|
}).exec(),
|
|
]);
|
|
|
|
let toSave = []; // An array of things to save
|
|
|
|
challengeTasks.forEach(chalTask => {
|
|
let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id);
|
|
|
|
if (!matchingTask) { // If the task is new, create it
|
|
matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitize(syncableAttrs(chalTask)));
|
|
matchingTask.challenge = {taskId: chalTask._id, id: challenge._id, shortName: challenge.shortName};
|
|
matchingTask.userId = user._id;
|
|
user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id);
|
|
} else {
|
|
_.merge(matchingTask, syncableAttrs(chalTask));
|
|
// Make sure the task is in user.tasksOrder
|
|
let orderList = user.tasksOrder[`${chalTask.type}s`];
|
|
if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id);
|
|
}
|
|
|
|
if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided
|
|
if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing
|
|
toSave.push(matchingTask.save());
|
|
});
|
|
|
|
// Flag deleted tasks as "broken"
|
|
userTasks.forEach(userTask => {
|
|
if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) {
|
|
userTask.challenge.broken = 'TASK_DELETED';
|
|
toSave.push(userTask.save());
|
|
}
|
|
});
|
|
|
|
toSave.push(user.save());
|
|
return Bluebird.all(toSave);
|
|
};
|
|
|
|
async function _fetchMembersIds (challengeId) {
|
|
return (await User.find({challenges: {$in: [challengeId]}}).select('_id').lean().exec()).map(member => member._id);
|
|
}
|
|
|
|
async function _addTaskFn (challenge, tasks, memberId) {
|
|
let updateTasksOrderQ = {$push: {}};
|
|
let toSave = [];
|
|
|
|
tasks.forEach(chalTask => {
|
|
let userTask = new Tasks[chalTask.type](Tasks.Task.sanitize(syncableAttrs(chalTask)));
|
|
userTask.challenge = {taskId: chalTask._id, id: challenge._id};
|
|
userTask.userId = memberId;
|
|
userTask.notes = chalTask.notes; // We want to sync the notes when the task is first added to the challenge
|
|
|
|
let tasksOrderList = updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`];
|
|
if (!tasksOrderList) {
|
|
updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`] = {
|
|
$position: 0, // unshift
|
|
$each: [userTask._id],
|
|
};
|
|
} else {
|
|
tasksOrderList.$each.unshift(userTask._id);
|
|
}
|
|
|
|
toSave.push(userTask.save({
|
|
validateBeforeSave: false, // no user data supplied
|
|
}));
|
|
});
|
|
|
|
// Update the user
|
|
toSave.unshift(User.update({_id: memberId}, updateTasksOrderQ).exec());
|
|
return await Bluebird.all(toSave);
|
|
}
|
|
|
|
// Add a new task to challenge members
|
|
schema.methods.addTasks = async function challengeAddTasks (tasks) {
|
|
let challenge = this;
|
|
let membersIds = await _fetchMembersIds(challenge._id);
|
|
|
|
let queue = new cwait.TaskQueue(Bluebird, 25); // process only 5 users concurrently
|
|
|
|
await Bluebird.map(membersIds, queue.wrap((memberId) => {
|
|
return _addTaskFn(challenge, tasks, memberId);
|
|
}));
|
|
};
|
|
|
|
// Sync updated task to challenge members
|
|
schema.methods.updateTask = async function challengeUpdateTask (task) {
|
|
let challenge = this;
|
|
|
|
let updateCmd = {$set: {}};
|
|
|
|
let syncableTask = syncableAttrs(task);
|
|
for (let key in syncableTask) {
|
|
updateCmd.$set[key] = syncableTask[key];
|
|
}
|
|
|
|
let taskSchema = Tasks[task.type];
|
|
// Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks
|
|
await taskSchema.update({
|
|
userId: {$exists: true},
|
|
'challenge.id': challenge.id,
|
|
'challenge.taskId': task._id,
|
|
}, updateCmd, {multi: true}).exec();
|
|
};
|
|
|
|
// Remove a task from challenge members
|
|
schema.methods.removeTask = async function challengeRemoveTask (task) {
|
|
let challenge = this;
|
|
|
|
// Set the task as broken
|
|
await Tasks.Task.update({
|
|
userId: {$exists: true},
|
|
'challenge.id': challenge.id,
|
|
'challenge.taskId': task._id,
|
|
}, {
|
|
$set: {'challenge.broken': 'TASK_DELETED'},
|
|
}, {multi: true}).exec();
|
|
};
|
|
|
|
// Unlink challenges tasks (and the challenge itself) from user. TODO rename to 'leave'
|
|
schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) {
|
|
let challengeId = this._id;
|
|
let findQuery = {
|
|
userId: user._id,
|
|
'challenge.id': challengeId,
|
|
};
|
|
|
|
removeFromArray(user.challenges, challengeId);
|
|
this.memberCount--;
|
|
|
|
if (keep === 'keep-all') {
|
|
await Tasks.Task.update(findQuery, {
|
|
$set: {challenge: {}},
|
|
}, {multi: true}).exec();
|
|
|
|
return Bluebird.all([user.save(), this.save()]);
|
|
} else { // keep = 'remove-all'
|
|
let tasks = await Tasks.Task.find(findQuery).select('_id type completed').exec();
|
|
let taskPromises = tasks.map(task => {
|
|
// Remove task from user.tasksOrder and delete them
|
|
if (task.type !== 'todo' || !task.completed) {
|
|
removeFromArray(user.tasksOrder[`${task.type}s`], task._id);
|
|
}
|
|
|
|
return task.remove();
|
|
});
|
|
user.markModified('tasksOrder');
|
|
taskPromises.push(user.save(), this.save());
|
|
return Bluebird.all(taskPromises);
|
|
}
|
|
};
|
|
|
|
// TODO everything here should be moved to a worker
|
|
// actually even for a worker it's probably just too big and will kill mongo, figure out something else
|
|
schema.methods.closeChal = async function closeChal (broken = {}) {
|
|
let challenge = this;
|
|
|
|
let winner = broken.winner;
|
|
let brokenReason = broken.broken;
|
|
|
|
// Delete the challenge
|
|
await this.model('Challenge').remove({_id: challenge._id}).exec();
|
|
|
|
// Refund the leader if the challenge is closed and the group not the tavern
|
|
if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') {
|
|
await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec();
|
|
}
|
|
|
|
// Update the challengeCount on the group
|
|
await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec();
|
|
|
|
// Award prize to winner and notify
|
|
if (winner) {
|
|
winner.achievements.challenges.push(challenge.name);
|
|
winner.balance += challenge.prize / 4;
|
|
|
|
winner.addNotification('WON_CHALLENGE');
|
|
|
|
let savedWinner = await winner.save();
|
|
|
|
if (savedWinner.preferences.emailNotifications.wonChallenge !== false) {
|
|
txnEmail(savedWinner, 'won-challenge', [
|
|
{name: 'CHALLENGE_NAME', content: challenge.name},
|
|
]);
|
|
}
|
|
if (savedWinner.preferences.pushNotifications.wonChallenge !== false) {
|
|
sendPushNotification(savedWinner,
|
|
{
|
|
title: challenge.name,
|
|
message: shared.i18n.t('wonChallenge'),
|
|
identifier: 'wonChallenge',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Run some operations in the background withouth blocking the thread
|
|
let backgroundTasks = [
|
|
// And it's tasks
|
|
Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(),
|
|
// Set the challenge tag to non-challenge status and remove the challenge from the user's challenges
|
|
User.update({
|
|
challenges: challenge._id,
|
|
'tags._id': challenge._id,
|
|
}, {
|
|
$set: {'tags.$.challenge': false},
|
|
$pull: {challenges: challenge._id},
|
|
}, {multi: true}).exec(),
|
|
// Break users' tasks
|
|
Tasks.Task.update({
|
|
'challenge.id': challenge._id,
|
|
}, {
|
|
$set: {
|
|
'challenge.broken': brokenReason,
|
|
'challenge.winner': winner && winner.profile.name,
|
|
},
|
|
}, {multi: true}).exec(),
|
|
];
|
|
|
|
Bluebird.all(backgroundTasks);
|
|
};
|
|
|
|
export let model = mongoose.model('Challenge', schema);
|