Files
habitica/website/server/models/challenge.js
Matteo Pagliazzi d0bc0dbe49 Add API Call to bulk score tasks (#11389)
* Add new API call to complete multiple task scorings in one call

* Improve API response

* Improve saving process

* Improve handling for multiple tasks scored at once

* Handle challenge task errors better

* Improve check for alias

* Improve check for task scorings

* Fix merge errors

* make nodemon ignore content_cache

* Fix completing group tasks

* fix test

* fix tests (again)

* typo

* WIP(a11y): task modal updates

* fix(tasks): borders in modal

* fix(tasks): circley locks

* fix(task-modal): placeholders

* WIP(task-modal): disabled states, hide empty options, +/- restyle

* fix(task-modal): box shadows instead of borders, habit control pointer

* fix(task-modal): button states?

* fix(modal): tighten up layout, new spacing utils

* fix(tasks): more stylin

* fix(tasks): habit hovers

* fix(css): checklist labels, a11y colors

* fix(css): one more missed hover issue

* fix(css): lock Challenges, label fixes

* fix(css): scope input/textarea changes

* fix(style): task tweakies

* fix(style): more button fixage

* WIP(component): start select list story

* working example of a templated selectList

* fix(style): more button corrections

* fix(lint): EOL

* fix(buttons): factor btn-secondary to better override Bootstrap

* fix(styles): standardize more buttons

* wip: difficulty select - style fixes

* selectDifficulty works! 🎉 - fix styles

* change the dropdown-item sizes only for the selectList ones

* selectTranslatedArray

* changed many label margins

* more correct dropdown style

* fix(modals): button corrections

* input-group styling + datetime picker without today button

* Style/margins for "repeat every" - extract selectTag.vue

* working tag-selection / update - cleanup

* fix stories

* fix svg color on create modal (purple)

* fix task modal bottom padding

* correct dropdown shadow

* update dropdown-toggle caret size / color

* fixed checklist style

* sync checked state

* selectTag padding

* fix spacing between positive/negative streak inputs

* toggle-checkbox + fix some spacings

* disable repeat-on when its a groupTask

* fix new checklist-item

* fix toggle-checkbox style - fix difficulty style

* fix checklist ui

* add tags label , when there arent any tags selected

* WORKING select-tag component 🎉

* fix taglist story

* show max 5 items in tag dropdown + "X more" label

* fix datetime clear button

* replace m-b-xs to mb-1 (bootstrap) - fix input-group-text style

* fix styles of advanced settings

* fix delete task styles

* always show grippy on hover of the item

* extract modal-text-input mixin + fix the borders/dropshadow

* fix(spacing): revert most to Bootstrap

* feat(checklists): make local copy of master checklist non-editable
also aggressively update checklists because they weren't syncing??

* fix(checklists): handle add/remove options better

* feat(teams): manager notes field

* fix select/dropdown styles

* input border + icon colors

* delete task underline color

* fix checklist "delete icon" vertical position

* selectTag fixes - normal open/close toggle working again - remove icon color

* fixing icons:

Trash can - Delete
Little X - Remove
Big X - Close
Block - Block

* fix taglist margins / icon sizes

* wip margin overview (in storybook)

* fix routerlink

* remove unused method

* new selectTag style + add markdown inside tagList + scrollable tag selection

* fix selectTag / selectList active border

* fix difficulty select (svg default color)

* fix input padding-left + fix reset habit streak fullwidth / padding + "repeat every" gray text (no border)

* feat(teams): improved approval request > approve > reward flow

* fix(tests): address failures

* fix(lint): oops only

* fix(tasks): short-circuit group related logic

* fix(tasks): more short circuiting

* fix(tasks): more lines, less lint

* fix(tasks): how do i keep missing these

* feat(teams): provide assigning user summary

* fix(teams): don't attempt to record assiging user if not supplied

* fix advanced-settings styling / margin

* fix merge + hide advanced streak settings when none enabled

* fix styles

* set Roboto font for advanced settings

* Add Challenge flag to the tag list

* add tag with enter, when no other tag is found

* fix styles + tag cancel button

* refactor footer / margin

* split repeat fields into option mt-3 groups

* button all the things

* fix(tasks): style updates
* no hover state for non-editable tasks on team board
* keep assign/claim footer on task after requesting approval
* disable more fields on user copy of team task, and remove hover states 
for them

* fix(tasks): functional revisions
* "Claim Rewards" instead of "x" in task approved notif
* Remove default transition supplied by Bootstrap, apply individually to 
some elements
* Delete individual tasks and related notifications when master task 
deleted from team board
* Manager notes now save when supplied at task initial creation
* Can no longer dismiss rewards from approved task by hitting Dismiss 
All

* fix(tasks): clean tasksOrder
also adjust related test expectation

* fix(tests): adjust integration expectations

* fix(test): ratzen fratzen only

* fix lint

* fix tests

* fix(teams): checklist, notes

* handleSharedCompletion: handle error, make sure it is run after the user task has been saved

* fix typo

* correctly handle errors in handleSharedCompletion when approving a task

* fix(teams): improve disabled states

* handleSharedCompletion: do not increase completions by 1 manually to adjust for last approval not saved yet

* revert changes to config.json.example

* fix(teams): more style fixage

* add unit tests for findMultipleByIdOrAlias

* exclude api v4 route from apidocs

* BREAKING(teams): return 202 instead of 401 for approval request

* fix(teams): better taskboard sync
also re-re-fix checklist borders

* scoreTasks: validate body

* fix tests, move string to api errors

* fix(tests): update expectations for breaking change

* start updating api docs, process tasks sequentially to avoid conflicts with user._tmp

* do not crash entire bulk operation in case of errors

* save task only if modified

* fix lint

* undo changes to error handling: either all tasks scoring are successfull or none

* remove stale code

* do not return user._tmp when bulk scoring, it would be the last version only

* make sure user._tmp.leveledUp is not lost when bulk scoring

* rewards tests

* mixed tests

* fix tests, allow scoring the same task multiple times

* finish integration tests

* fix api docs for the bulk score route

* refactor(task-modal): lockable label component

* wip loading spinner

* refactor(teams): move task scoring to mixin

* fix(teams): style corrections

* fix(btn): fix padding to have height of 32px

* implement loading spinner

* remove console.log warnings

* fix(tasks): spacing and wording corrections

* fix(teams): don't bork manager notes

* fix(teams): assignment fix and more approval flow revisions

* WIP(teams): use tag dropdown control for assignment

* finish merge - never throw an error when a group task requires approval (wip - needs tests)

* fix taskModal merge

* fix merge

* fix(task modal): add newline

* fix(column.vue): add newline at end of file

* mvp yesterdaily modal

* fix tests

* fix api docs for bulk scoring group tasks

* separate task scoring and _tmp handling

* handle _tmp when bulk scoring

* rya: close modal before calling cron API, prevents issues with modals

* rya: fix conflicts with other modals

* add sounds, support for group plans, analytics

* use asyncResource for group plans

* fix lint

* streak bonus: add comment about missing in rya

* move yesterdailyModal

* fix issues with level up modals and rya

* add comments for future use, fix level up modals not showing up at levels with a quest drop

* handle errors in rya modal

* bundle quest and crit notifications

Co-authored-by: Phillip Thelen <phillip@habitica.com>
Co-authored-by: Phillip Thelen <viirus@pherth.net>
Co-authored-by: Sabe Jones <sabrecat@gmail.com>
Co-authored-by: negue <eugen.bolz@gmail.com>
2020-08-21 11:46:56 +02:00

429 lines
15 KiB
JavaScript

import mongoose from 'mongoose';
import validator from 'validator';
import _ from 'lodash';
import { TaskQueue } from 'cwait';
import baseModel from '../libs/baseModel';
import * as Tasks from './task';
import { model as User } from './user'; // eslint-disable-line import/no-cycle
import { // eslint-disable-line import/no-cycle
model as Group,
} from './group';
import { removeFromArray } from '../libs/collectionManipulators';
import shared from '../../common';
import { sendTxn as txnEmail } from '../libs/email'; // eslint-disable-line import/no-cycle
import { sendNotification as sendPushNotification } from '../libs/pushNotifications'; // eslint-disable-line import/no-cycle
import { // eslint-disable-line import/no-cycle
syncableAttrs,
setNextDue,
} from '../libs/taskManager';
const { Schema } = mongoose;
const { MIN_SHORTNAME_SIZE_FOR_CHALLENGES } = shared.constants;
const { MAX_SUMMARY_SIZE_FOR_CHALLENGES } = shared.constants;
const schema = new Schema({
name: { $type: String, required: true },
shortName: { $type: String, required: true, minlength: MIN_SHORTNAME_SIZE_FOR_CHALLENGES },
summary: { $type: String, maxlength: MAX_SUMMARY_SIZE_FOR_CHALLENGES },
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: [v => validator.isUUID(v), 'Invalid uuid for challenge leader.'], required: true,
},
group: {
$type: String, ref: 'Group', validate: [v => validator.isUUID(v), 'Invalid uuid for challenge group.'], required: true,
},
memberCount: { $type: Number, default: 0 },
prize: { $type: Number, default: 0, min: 0 },
categories: [{
slug: { $type: String },
name: { $type: String },
}],
}, {
strict: true,
minimize: false, // So empty objects are returned
typeKey: '$type', // So that we can use fields named `type`
});
schema.plugin(baseModel, {
noSet: ['_id', 'memberCount', 'tasksOrder'],
timestamps: true,
});
schema.pre('init', chal => {
// The Vue website makes the summary be mandatory for all new challenges, but the
// Angular website did not, and the API does not yet for backwards-compatibility.
// When any challenge without a summary is fetched from the database, this code
// supplies the name as the summary. This can be removed when all challenges have
// a summary and the API makes it mandatory (a breaking change!)
if (!chal.summary) {
chal.summary = chal.name ? chal.name.substring(0, MAX_SUMMARY_SIZE_FOR_CHALLENGES) : ' ';
}
});
// A list of additional fields that cannot be updated (but can be set on creation)
const noUpdate = ['group', 'leader', 'official', 'shortName', 'prize'];
schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
return this.sanitize(updateObj, noUpdate);
};
// Returns true if user is the leader/owner of the challenge
schema.methods.isLeader = function isChallengeLeader (user) {
return this.leader === user._id;
};
// 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.isLeader(user);
};
// Returns true if user can join the challenge
schema.methods.canJoin = function canJoinChallenge (user, group) {
if (group.type === 'guild' && group.privacy === 'public') return true;
// for when leader has left private group that contains the challenge
if (this.isLeader(user)) return true;
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
// Different from canJoin because you can see challenges of groups
// you've been removed from if you're participating in them
schema.methods.canView = function canViewChallenge (user, group) {
if (this.isMember(user)) return true;
return this.canJoin(user, group);
};
// Sync challenge tasks to user, including tags.
// Used when user joins the challenge or to force sync.
schema.methods.syncTasksToUser = async function syncChallengeTasksToUser (user) {
const challenge = this;
challenge.shortName = challenge.shortName || challenge.name;
// Sync tags
const userTags = user.tags;
const i = _.findIndex(userTags, { id: challenge._id });
if (i !== -1) {
if (userTags[i].name !== challenge.shortName) {
// update the name - it's been changed since
// @TODO: We probably want to remove this.
// Owner is not allowed to change participant's copy of the tag.
userTags[i].name = challenge.shortName;
}
} else {
userTags.push({
id: challenge._id,
name: challenge.shortName,
challenge: true,
});
}
const [challengeTasks, userTasks] = await Promise.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(),
]);
const 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);
setNextDue(matchingTask, user);
} else {
_.merge(matchingTask, syncableAttrs(chalTask));
// Make sure the task is in user.tasksOrder
const orderList = user.tasksOrder[`${chalTask.type}s`];
if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id);
}
// don't override the notes, but provide it if not provided
if (!matchingTask.notes) matchingTask.notes = chalTask.notes;
// add tag if missing
if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id);
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 Promise.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) {
const updateTasksOrderQ = { $push: {} };
const toSave = [];
tasks.forEach(chalTask => {
const userTask = new Tasks[chalTask.type](Tasks.Task.sanitize(syncableAttrs(chalTask)));
userTask.challenge = {
taskId: chalTask._id,
id: challenge._id,
shortName: challenge.shortName,
};
userTask.userId = memberId;
// We want to sync the notes and tags when the task is first added to the challenge
userTask.notes = chalTask.notes;
userTask.tags.push(challenge._id);
const 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 tag list of the user document of a participating member of the challenge
// such that a tag representing the challenge into which the task to be added will
// be added to the user tag list if and only if the tag does not exist already.
const addToChallengeTagSet = {
$addToSet: {
tags: {
id: challenge._id,
name: challenge.shortName,
challenge: true,
},
},
};
const updateUserParams = { ...updateTasksOrderQ, ...addToChallengeTagSet };
toSave.unshift(User.update({ _id: memberId }, updateUserParams).exec());
return Promise.all(toSave);
}
// Add a new task to challenge members
schema.methods.addTasks = async function challengeAddTasks (tasks) {
const challenge = this;
const membersIds = await _fetchMembersIds(challenge._id);
const queue = new TaskQueue(Promise, 25); // process only this many users concurrently
await Promise.all(membersIds.map(queue.wrap(memberId => _addTaskFn(challenge, tasks, memberId))));
};
// Sync updated task to challenge members
schema.methods.updateTask = async function challengeUpdateTask (task) {
const challenge = this;
const updateCmd = { $set: {} };
const syncableTask = syncableAttrs(task);
for (const key of Object.keys(syncableTask)) {
updateCmd.$set[key] = syncableTask[key];
}
const 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) {
const 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, saveUser = true) {
const challengeId = this._id;
const findQuery = {
userId: user._id,
'challenge.id': challengeId,
};
removeFromArray(user.challenges, challengeId);
this.memberCount -= 1;
if (keep === 'keep-all') {
await Tasks.Task.update(findQuery, {
$set: { challenge: {} },
}, { multi: true }).exec();
const promises = [this.save()];
// When multiple tasks are being unlinked at the same time,
// save the user once outside of this function
if (saveUser) promises.push(user.save());
return Promise.all(promises);
} // keep = 'remove-all'
const tasks = await Tasks.Task.find(findQuery).select('_id type completed').exec();
const 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(this.save());
// When multiple tasks are being unlinked at the same time,
// save the user once outside of this function
if (saveUser) taskPromises.push(user.save());
return Promise.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 = {}) {
const challenge = this;
const { winner } = broken;
const brokenReason = broken.broken;
// Delete the challenge
await this.model('Challenge').remove({ _id: challenge._id }).exec();
// Refund the leader if the challenge is deleted (no winner chosen)
if (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);
// If the winner cannot get gems (because of a group policy)
// reimburse the leader
const winnerCanGetGems = await winner.canGetGems();
if (!winnerCanGetGems) {
await User.update(
{ _id: challenge.leader },
{ $inc: { balance: challenge.prize / 4 } },
).exec();
} else {
winner.balance += challenge.prize / 4;
}
winner.addNotification('WON_CHALLENGE');
const 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', savedWinner.preferences.language),
identifier: 'wonChallenge',
});
}
}
// Run some operations in the background withouth blocking the thread
const 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(),
];
Promise.all(backgroundTasks);
};
export const model = mongoose.model('Challenge', schema); // eslint-disable-line import/prefer-default-export