Files
habitica/website/server/controllers/api-v3/tasks.js
Matteo Pagliazzi 28f2e9c356 API v3 [WIP] (#6144)
* Fixed more tests

* Added tags into user service

* Added api-v3 auth urls

* v3: fix package.json

* v3: fix package.json

* Fixed auth tests. Updated Authctrl response

* v3: remove newrelic config file in favour of env variables

* v3: upgrade some deps

* switch from Q to Bluebird

* v3 fix tests with deferred

* Removed extra consoles.log. Changed data.data to res.data

* v3 fix tests and use coroutines instead of regenerator

* v3: fix tests

* v3: do not await a non promise

* v3: q -> bluebird

* Changed id param for registration response

* Updated party query and create

* Ensured login callback happens after user sync

* Add challenges to groups. Fixed isMemberOfGuild check

* Updated party and group tests

* Fixed cron test

* return user.id and send analytics event before changing page

* fix trailing spaces

* disable redirects

* Api v3 party tavern fixes (#7191)

* Added check if user is in party before query

* Cached party query. Prevented party request when user is not in party. Updated Party create with no invites

* Update tavern ctrl to use new promise

* v3: misc fixes

* Api v3 task fixes (#7193)

* Update task view to use _id

* Added try catch to user service ops calls

* v3 client: saving after syncing is complete

* Fixed test broken by part sync change (#7195)

* v3: fix todo scoring and try to fix production testing problem

* revert changes to mongoose config

* mongoose: increase keepAlive

* test mongoose fix

* fix: Only apply captureStackTrace if it exists on the error object

* v3: fix reminders with no startDate

* mongoose: use options

* chore(): rename website/src -> website/server and website/public -> website/client (#7199)

* v3 fix GET /groups: return an error only if an invalid type is supplied not when there are 0 results (#7203)

* [API v3] Fix calls to user.ops and deleting tags (#7204)

* v3: fixes calls to user.ops from views and deleting tags

* v3: fix tests that use user._statsComputed

* Api v3 fixes continued (#7205)

* Added timzeone offset back

* Added APIToken back to settings page

* Fixed fetch recent messages for party

* Fixed returning group description

* Fixed check if user is member of challenge

* Fixed party members appearing in header

* Updated get myGroups param to include public groups. Fixed isMemberOf group

* Fixed hourglass purchase

* Fixed challenge addding tasks on first creating

* Updated tests to accomidate new changes

* fix: Correct checklist on client

Closes #7207

* fix: Pin eslint to 2.9

* minor improvements to cron code for clarity; fix inaccurate comments; add TODOs for rest-in-inn actions

* fix: Add missing type param to equip call

closes #7212

* rename and reword pubChalsMinPrize to reflect that it's only for Tavern challenges

* allows players to send gems to each other; other minor related changes - fixes https://github.com/HabitRPG/habitrpg/issues/7227

* fix tests for /members/transfer-gems

* fix: Set gems sent notification as translatable string

* chore: Remove unusued variable

* fix: Remove requirement on message paramter in transfer-gems

* add a missing variable declaration

* chore: clarify comments on cron code

* fix: Correct client request from habitrpg -> tavern

* update apidoc URL in package.json

Closes #7222

* Fixed start party by invites

* Updated spell casting to v3

* Fixed adding and removing tags on tasks

* Fixed page reload on settings change

* Fixed battle monsters with friends button

* Loaded completed todos when done is clicked

* chore: Reinstate floating version number for eslint

babel-eslint regression fixed

* Fixed reload tests

* change "an user" to "a user" in comments and text (no code changes) (#7257)

* fix: Alert user that drops were recieved

* remove userServices.js from karma.conf - it's been moved to website/client/js/services

* feat: Create debug update user route

* fix: Correct set cron debug function

* feat: Add make admin button to debug menu

* lint: Add missing semicolons in test

* fix: Temporarilly comment out udpate user debug route

* v3: fix _tmp for crit and streakBonus

* v3: execute all actions when leaving a solo party

* v3 client: fix group not found when leaving party

* v3 migration: fix challenge prize

* v3 cron: only save modified tasks

* v3: add CHALLENGE_TASK_NOT_FOUND to valid broken reasons

* v3: fix tasks chart

* v3 client: fix ability to leave challenge

* v3 client: fix filtering by tag and correctly show tag tooltip

* v3 common: fix tags tests

* v3 client: support unlinking not found challenges tasks

* v3: disable Bluebird warning for missing return, fixes #7269

* feat: Separate out update-user into set-cron and make-admin debug routes

* chore: Disable make admin debug route for v3 prod testing

* v3: misc fixes

* v3: misc fixes

* v3: fix adding multiple tasks

* Fixed join/leave button updates

* Queried only user groups to be available when creating challenges

* Fixed bulk add tasks to challenge

* Synced challenge tasks after leave and join.

* Fixed default selected group

* Fixed challenge member info. Fixed challenge winner selection

* Fixed deleting challenge tasks

* Fixed particiapting filter

* v3 client: fix casting spells

* v3: do not log sensitive data

* v3: always save user when casting spell

* v3: always save user when casting spell

* v3: more fixes for spells

* fix typos and missing information in apidocs - fixes https://github.com/HabitRPG/habitrpg/issues/7277 (#7282)

* v3: add TODO for client side spells

* feat: Add modify inventory debug menu

* Fixed viewing user progress on challenge

* Updated tests

* fix: Fix quest progress button

* fix incorrect Armoire test; remove unneeded param details from apidocs; disambiguate health potion

* v3: fix stealth casting

* v3: fix tasks saving and selection for rebirth reroll and reset (server-only)

* v3: fix auto allocation

* v3 client: misc fixes

* rename buyPotion and buy-potion to buyHealthPotion and buy-health-potion; fix apidoc param error

* Added delete for saved challenge task

* Fixed member modal on front page

* adjust text in apidocs for errors / clarity / consistency / standard terminology (no code changes) (#7298)

* fix bug in Rebirth test, add new tests, adjust apidocs (#7293)

* Updated task model to allow setting streak (#7306)

* fix: Correct missing * in apidoc comments

* Api v3 challenge fixes (#7287)

* Fixed join/leave button updates

* Queried only user groups to be available when creating challenges

* Fixed bulk add tasks to challenge

* Synced challenge tasks after leave and join.

* Fixed default selected group

* Fixed challenge member info. Fixed challenge winner selection

* Fixed deleting challenge tasks

* Fixed particiapting filter

* Fixed viewing user progress on challenge

* Updated tests

* Added delete for saved challenge task

* v3: fix sorting

* [API v3] add CRON_SAFE_MODE (#7286)

* add CRON_SAFE_MODE to example config file, fix some bugs, add an unrelated low-priority TODO

* create CRON_SAFE_MODE to disable parts of cron for use after extended outage - fixes https://github.com/HabitRPG/habitrpg/issues/7161

* fix a bug with CRON_SAFE_MODE, remove duplicated code, remove completed TODO comment

* fix check for CRON_SAFE_MODE

* v3 client: fix typo

* adjust debug menu Modify Inventory: hungrier pets, fewer Special items, "Hide" buttons

* completed To-Dos: return the 30 most recent instead of 30 oldest (#7318)

* v3 migration: fix createdAt date

* adjust locales text, key names, and files for Rebirth, Reset, and Fortify / ReRoll for consistency with existing strings (#7321)

* v3: fix unlinking multiple tasks

* v3 fix releasing pets

* v3: fix authenticating with apiUrl

* v3: fix typo

* v3 fix client tests for unlinking

* v3 client: do not show start quest button when quest is active

* v3 client: fix ability to send cards

* v3 client: fix misc challenge issues

* v3: fix notifications

* v3 client: more user friendly errors

* v3 client: only load completed todos once

* v3 client: fix tests

* v3: move TAVERN_ID to common code

* fix: Provide default type and text for new task creation in score route

* fix: Provide default history [] for habit in score route

* fix: Add _legacyId prop to tasks to support non-uuid identifiers

* chore: Change v3 migration to use _legacyId instead of legacyId

* fix: check for _legacyId in tasks if id does not exist

* refactor: Extract out finding task by id or _legacyId into a function

* Api v3 party quest fixes (#7341)

* Fix display of add challenge message when group challenges are empty

* Fixed forced quest start to update quest without reload

* Fixed needing to reload when accepting party invite

* Fix group leave and join reload

* Fixed leave current party and join another

* Updated party tests

* v3 client: remove console.log statement

* v3: misc fixes

* v3 client: fix predicatbale random

* v3: info about API v3

* v3: update footer with links to developer resources

* v3: support party invitation from email

* v3 client: fix chat flagging

* fix: Correct get tasks route to properly get todos (#7349)

* move locales strings from api-v3.json to other locales files (#7347)

* move locales strings from api-v3.json: authentication strings -> front.json

* move locales strings from api-v3.json: authentication strings -> tasks.json

* move locales strings from api-v3.json: authentication strings -> groups.json

* move locales strings from api-v3.json: authentication strings -> challenge.json

* move locales strings from api-v3.json: authentication strings -> groups.json (again)

* move locales strings from api-v3.json: authentication strings -> quests.json

* move locales strings from api-v3.json: authentication strings -> subscriber.json

* move locales strings from api-v3.json: authentication strings -> spells.json

* move locales strings from api-v3.json: authentication strings -> character.json

* move locales strings from api-v3.json: authentication strings -> groups.json (PMs)

* move locales strings from api-v3.json: authentication strings -> npc.json

* move locales strings from api-v3.json: authentication strings -> pets.json

* move locales strings from api-v3.json: authentication strings -> miscellaneous

* move locales strings from api-v3.json: authentication strings -> contrib.json and settings.json

* move locales strings from api-v3.json: delete unused string (invalidTasksOwner), delete api-v3.json, whitespace cleanup

* v3 client: fix sticky header

* v3: remove unused code

* v3 client: correctly redirect after inviting

* Removed v2 calls from views (#7351)

* v3: fix tests for challenge export

* v3: fallbackto authWithHeaders if wuthWithSession or authWithUrl fails

* Added force cache update when fetching new messages (#7360)

* v3: fetch whole user when booting from group tto avoid issues with pre save hook expecting all data

* v3: misc fixes for payments

* v3: limit fields of challenge tasks that can be updated

* fix(tests): never connect to NODE_DB_URI for tests

* Added new route for setting last cron and updated front end

* v3: fix iap url

* v3: fix build and ios IAP

* Changed route to user set custom day start

* v3: iap accessible under /api/v3, fixes to spells and groups invitations

* v3: correctly use v3 routes in client

* remove XP, GP when unticking a Daily with a completed checklist - fixes https://github.com/HabitRPG/habitrpg/issues/7246

* use natural language for error message about skills on challenge tasks (#7336), fix other gramatical error

* Updated ui when user rejects a guild invite (#7368)

* feat: complete custom day start route

Closes #7363

* fix: Correct spelling of healAll skill

fix: Correct sprite name of healAll skill

* fix: Change all instances of spookDust -> spookySparkles

* add dateCreated to all tasks; add empty challenge object to tasks that don't have one (#7386)

* add plumilla to artists for Tangle Tree in Bailey message

* Fixed quest drop modal (#7377)

* Fixed quest drop modal

* Fixed broken party test

* [API v3] Maintenance Mode (#7367)

* WIP(maintenance): maintenance

* WIP(maintenance): working locale features

* fix(maintenance): don't translate info page target

* WIP(maintenance): start adding info page

* fix(maintenance): linting

* feat: Add container to maintenance info page

* fix(maintenance): add config.json edits
Also DRY variables for main vs info pages

* fix(maintenance): linting

* refactor(maintenance): further slim down variables

* refactor: Remove unnecessary variables

* fix: Correct string interpolation in maintenace view

* feat: Dynamically add time to maintenance pages

* maintenance mode: do not connect to mongodb

* fix(maintenance): clean up timezones etc.

* fix(maintenance): remove unneeded sprite

* Tavern party challenges invites fix (#7394)

* Added challenges and invitations to party

* Loaded tavern challenges

* Updated group and quest services tests

* v3: implement automatic syncing if user is not up to date

* Removed unnecessary fields when updating groups and challenges (#7395)

* v3: do not saved populated user

* v3: correctly return user subset

* Chained party promises together (#7396)

* v3: $w -> splitWhitespace

* use bluebird

* use babel polyfill

* migration: fix items

* update links for v3

* Updated shortname validation to support multiple browsers

* Docs changes (#7401)

* chore: Clarify transfer-gems documentation

* chore: Clarify api status route documentation

* chore: Mark webhooks as BETA

* Added tags update route. Added sort to user service (#7381)

* Added tags update route. Added sort to user service

* Change update tasks route to reorder tasks

* Fixed linting issue

* Changed params for reorder tags route

* Fixed not found tag and added test

* Added password confirmation when deleteing account (#7402)

* fix production logging

* feat(commit): push

* empty commit

* feat(maintenance): post-downtime news & awards (#7406)

* fix exporting avatar

* second attempt at fixing exporting avatar

* fix production logging

* s3: convert moment to date instance

* fix avatar sharing and caching (30 minutes)

* fix: Correct missing parameter

Closes #7433

* fix: Validate challenge shortname on server

* adjust text strings - fixes https://github.com/HabitRPG/habitrpg/issues/5631 and also Short Name -> Tag Name
2016-05-23 13:58:31 +02:00

972 lines
31 KiB
JavaScript

import { authWithHeaders } from '../../middlewares/api-v3/auth';
import { sendTaskWebhook } from '../../libs/api-v3/webhook';
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
import * as Tasks from '../../models/task';
import { model as Challenge } from '../../models/challenge';
import { model as Group } from '../../models/group';
import {
NotFound,
NotAuthorized,
BadRequest,
} from '../../libs/api-v3/errors';
import common from '../../../../common';
import Bluebird from 'bluebird';
import _ from 'lodash';
import logger from '../../libs/api-v3/logger';
let api = {};
// challenge must be passed only when a challenge task is being created
async function _createTasks (req, res, user, challenge) {
let toSave = Array.isArray(req.body) ? req.body : [req.body];
toSave = toSave.map(taskData => {
// Validate that task.type is valid
if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType'));
let taskType = taskData.type;
let newTask = new Tasks[taskType](Tasks.Task.sanitize(taskData));
if (challenge) {
newTask.challenge.id = challenge.id;
} else {
newTask.userId = user._id;
}
// Validate that the task is valid and throw if it isn't
// otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality
let validationErrors = newTask.validateSync();
if (validationErrors) throw validationErrors;
// Otherwise update the user/challenge
(challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id);
return newTask;
}).map(task => task.save({ // If all tasks are valid (this is why it's not in the previous .map()), save everything, withough running validation again
validateBeforeSave: false,
}));
toSave.unshift((challenge || user).save());
let tasks = await Bluebird.all(toSave);
tasks.splice(0, 1); // Remove user or challenge
return tasks;
}
/**
* @api {post} /api/v3/tasks/user Create a new task belonging to the user
* @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks.
* @apiVersion 3.0.0
* @apiName CreateUserTasks
* @apiGroup Task
*
* @apiSuccess data An object if a single task was created, otherwise an array of tasks
*/
api.createUserTasks = {
method: 'POST',
url: '/tasks/user',
middlewares: [authWithHeaders()],
async handler (req, res) {
let tasks = await _createTasks(req, res, res.locals.user);
res.respond(201, tasks.length === 1 ? tasks[0] : tasks);
},
};
/**
* @api {post} /api/v3/tasks/challenge/:challengeId Create a new task belonging to a challenge
* @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks.
* @apiVersion 3.0.0
* @apiName CreateChallengeTasks
* @apiGroup Task
*
* @apiParam {UUID} challengeId The id of the challenge the new task(s) will belong to
*
* @apiSuccess data An object if a single task was created, otherwise an array of tasks
*/
api.createChallengeTasks = {
method: 'POST',
url: '/tasks/challenge/:challengeId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let reqValidationErrors = req.validationErrors();
if (reqValidationErrors) throw reqValidationErrors;
let user = res.locals.user;
let challengeId = req.params.challengeId;
let challenge = await Challenge.findOne({_id: challengeId}).exec();
// If the challenge does not exist, or if it exists but user is not the leader -> throw error
if (!challenge || user.challenges.indexOf(challengeId) === -1) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
let tasks = await _createTasks(req, res, user, challenge);
res.respond(201, tasks.length === 1 ? tasks[0] : tasks);
// If adding tasks to a challenge -> sync users
if (challenge) challenge.addTasks(tasks);
return null;
},
};
// challenge must be passed only when a challenge task is being created
async function _getTasks (req, res, user, challenge) {
let query = challenge ? {'challenge.id': challenge.id, userId: {$exists: false}} : {userId: user._id};
let type = req.query.type;
if (type) {
if (type === 'todos') {
query.completed = false; // Exclude completed todos
query.type = 'todo';
} else if (type === 'completedTodos') {
query = Tasks.Task.find({
userId: user._id,
type: 'todo',
completed: true,
}).limit(30).sort({ // TODO add ability to pick more than 30 completed todos
dateCompleted: -1,
});
} else {
query.type = type.slice(0, -1); // removing the final "s"
}
} else {
query.$or = [ // Exclude completed todos
{type: 'todo', completed: false},
{type: {$in: ['habit', 'daily', 'reward']}},
];
}
let tasks = await Tasks.Task.find(query).exec();
// Order tasks based on tasksOrder
if (type && type !== 'completedTodos') {
let order = (challenge || user).tasksOrder[type];
let orderedTasks = new Array(tasks.length);
let unorderedTasks = []; // what we want to add later
tasks.forEach((task, index) => {
let taskId = task._id;
let i = order[index] === taskId ? index : order.indexOf(taskId);
if (i === -1) {
unorderedTasks.push(task);
} else {
orderedTasks[i] = task;
}
});
// Remove empty values from the array and add any unordered task
orderedTasks = _.compact(orderedTasks).concat(unorderedTasks);
res.respond(200, orderedTasks);
} else {
res.respond(200, tasks);
}
}
/**
* @api {get} /api/v3/tasks/user Get a user's tasks
* @apiVersion 3.0.0
* @apiName GetUserTasks
* @apiGroup Task
*
* @apiParam {string="habits","dailys","todos","rewards","completedTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that must be requested separately.
*
* @apiSuccess {Array} data An array of tasks
*/
api.getUserTasks = {
method: 'GET',
url: '/tasks/user',
middlewares: [authWithHeaders()],
async handler (req, res) {
let types = Tasks.tasksTypes.map(type => `${type}s`);
types.push('completedTodos');
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
return await _getTasks(req, res, res.locals.user);
},
};
/**
* @api {get} /api/v3/tasks/challenge/:challengeId Get a challenge's tasks
* @apiVersion 3.0.0
* @apiName GetChallengeTasks
* @apiGroup Task
*
* @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks
* @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks
*
* @apiSuccess {Array} data An array of tasks
*/
api.getChallengeTasks = {
method: 'GET',
url: '/tasks/challenge/:challengeId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
let types = Tasks.tasksTypes.map(type => `${type}s`);
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let challengeId = req.params.challengeId;
let challenge = await Challenge.findOne({_id: challengeId}).select('group leader tasksOrder').exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
return await _getTasks(req, res, res.locals.user, challenge);
},
};
/**
* @api {get} /api/v3/task/:taskId Get a task
* @apiVersion 3.0.0
* @apiName GetTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
*
* @apiSuccess {object} data The task object
*/
api.getTask = {
method: 'GET',
url: '/tasks/:taskId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
}).exec();
if (!task) {
throw new NotFound(res.t('taskNotFound'));
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
let challenge = await Challenge.find({_id: task.challenge.id}).select('leader').exec();
if (!challenge || (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens
throw new NotFound(res.t('taskNotFound'));
}
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
res.respond(200, task);
},
};
/**
* @api {put} /api/v3/task/:taskId Update a task
* @apiVersion 3.0.0
* @apiName UpdateTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
*
* @apiSuccess {object} data The updated task
*/
api.updateTask = {
method: 'PUT',
url: '/tasks/:taskId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
}).exec();
if (!task) {
throw new NotFound(res.t('taskNotFound'));
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
// we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances?
let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req);
// Sanitize differently user tasks linked to a challenge
let sanitizedObj;
if (!challenge && task.userId && task.challenge && task.challenge.id) {
sanitizedObj = Tasks.Task.sanitizeUserChallengeTask(updatedTaskObj);
} else {
sanitizedObj = Tasks.Task.sanitize(updatedTaskObj);
}
_.assign(task, sanitizedObj);
// console.log(task.modifiedPaths(), task.toObject().repeat === tep)
// repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject()
// see https://github.com/Automattic/mongoose/issues/2749
let savedTask = await task.save();
res.respond(200, savedTask);
if (challenge) challenge.updateTask(savedTask);
return null;
},
};
function _generateWebhookTaskData (task, direction, delta, stats, user) {
let extendedStats = _.extend(stats, {
toNextLevel: common.tnl(user.stats.lvl),
maxHealth: common.maxHealth,
maxMP: common.statsComputed(user).maxMP,
});
let userData = {
_id: user._id,
_tmp: user._tmp,
stats: extendedStats,
};
let taskData = {
details: task,
direction,
delta,
};
return {
task: taskData,
user: userData,
};
}
/**
* @api {put} /api/v3/tasks/:taskId/score/:direction Score a task
* @apiVersion 3.0.0
* @apiName ScoreTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {string="up","down"} direction The direction for scoring the task
*
* @apiSuccess {object} data._tmp If an item was dropped it'll be returned in te _tmp object
* @apiSuccess {number} data.delta
* @apiSuccess {object} data The user stats
*/
api.scoreTask = {
method: 'POST',
url: '/tasks/:taskId/score/:direction',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let direction = req.params.direction;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
let wasCompleted = task.completed;
let [delta] = common.ops.scoreTask({task, user, direction}, req);
// Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results)
if (direction === 'up') user.fns.randomDrop({task, delta}, req);
// If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list
// TODO move to common code?
if (task.type === 'todo') {
if (!wasCompleted && task.completed) {
removeFromArray(user.tasksOrder.todos, task._id);
} else if (wasCompleted && !task.completed) {
let hasTask = removeFromArray(user.tasksOrder.todos, task._id);
if (!hasTask) {
user.tasksOrder.todos.push(task._id);
} // If for some reason it hadn't been removed previously don't do anything
}
}
let results = await Bluebird.all([
user.save(),
task.save(),
]);
let savedUser = results[0];
let userStats = savedUser.stats.toJSON();
let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats);
res.respond(200, resJsonData);
sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user));
if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') {
// Wrapping everything in a try/catch block because if an error occurs using `await` it MUST NOT bubble up because the request has already been handled
try {
let chalTask = await Tasks.Task.findOne({
_id: task.challenge.taskId,
}).exec();
await chalTask.scoreChallengeTask(delta);
} catch (e) {
logger.error(e);
}
}
return null;
},
};
/**
* @api {post} /api/v3/tasks/:taskId/move/to/:position Move a task to a new position
* @apiDescription Note: completed To-Dos are not sortable, do not appear in user.tasksOrder.todos, and are ordered by date of completion.
* @apiVersion 3.0.0
* @apiName MoveTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {Number} position Query parameter - Where to move the task (-1 means push to bottom). First position is 0
*
* @apiSuccess {array} data The new tasks order (user.tasksOrder.{task.type}s)
*/
api.moveTask = {
method: 'POST',
url: '/tasks/:taskId/move/to/:position',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let to = Number(req.params.position);
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
if (task.type === 'todo' && task.completed) throw new BadRequest(res.t('cantMoveCompletedTodo'));
let order = user.tasksOrder[`${task.type}s`];
let currentIndex = order.indexOf(task._id);
// If for some reason the task isn't ordered (should never happen), push it in the new position
// if the task is moved to a non existing position
// or if the task is moved to position -1 (push to bottom)
// -> push task at end of list
if (!order[to] && to !== -1) {
order.push(task._id);
} else {
if (currentIndex !== -1) order.splice(currentIndex, 1);
if (to === -1) {
order.push(task._id);
} else {
order.splice(to, 0, task._id);
}
}
await user.save();
res.respond(200, order);
},
};
/**
* @api {post} /api/v3/tasks/:taskId/checklist Add an item to the task's checklist
* @apiVersion 3.0.0
* @apiName AddChecklistItem
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
*
* @apiSuccess {object} data The updated task
*/
api.addChecklistItem = {
method: 'POST',
url: '/tasks/:taskId/checklist',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
}).exec();
if (!task) {
throw new NotFound(res.t('taskNotFound'));
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
task.checklist.push(Tasks.Task.sanitizeChecklist(req.body));
let savedTask = await task.save();
res.respond(200, savedTask);
if (challenge) challenge.updateTask(savedTask);
return null;
},
};
/**
* @api {post} /api/v3/tasks/:taskId/checklist/:itemId/score Score a checklist item
* @apiVersion 3.0.0
* @apiName ScoreChecklistItem
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {UUID} itemId The checklist item _id
*
* @apiSuccess {object} data The updated task
*/
api.scoreCheckListItem = {
method: 'POST',
url: '/tasks/:taskId/checklist/:itemId/score',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
let item = _.find(task.checklist, {id: req.params.itemId});
if (!item) throw new NotFound(res.t('checklistItemNotFound'));
item.completed = !item.completed;
let savedTask = await task.save();
res.respond(200, savedTask);
},
};
/**
* @api {put} /api/v3/tasks/:taskId/checklist/:itemId Update a checklist item
* @apiVersion 3.0.0
* @apiName UpdateChecklistItem
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {UUID} itemId The checklist item _id
*
* @apiSuccess {object} data The updated task
*/
api.updateChecklistItem = {
method: 'PUT',
url: '/tasks/:taskId/checklist/:itemId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
}).exec();
if (!task) {
throw new NotFound(res.t('taskNotFound'));
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
let item = _.find(task.checklist, {id: req.params.itemId});
if (!item) throw new NotFound(res.t('checklistItemNotFound'));
_.merge(item, Tasks.Task.sanitizeChecklist(req.body));
let savedTask = await task.save();
res.respond(200, savedTask);
if (challenge) challenge.updateTask(savedTask);
return null;
},
};
/**
* @api {delete} /api/v3/tasks/:taskId/checklist/:itemId Remove a checklist item
* @apiVersion 3.0.0
* @apiName RemoveChecklistItem
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {UUID} itemId The checklist item _id
*
* @apiSuccess {object} data The updated task
*/
api.removeChecklistItem = {
method: 'DELETE',
url: '/tasks/:taskId/checklist/:itemId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
}).exec();
if (!task) {
throw new NotFound(res.t('taskNotFound'));
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
}
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
let hasItem = removeFromArray(task.checklist, { id: req.params.itemId });
if (!hasItem) throw new NotFound(res.t('checklistItemNotFound'));
let savedTask = await task.save();
res.respond(200, savedTask);
if (challenge) challenge.updateTask(savedTask);
return null;
},
};
/**
* @api {post} /api/v3/tasks/:taskId/tags/:tagId Add a tag to a task
* @apiVersion 3.0.0
* @apiName AddTagToTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {UUID} tagId The tag id
*
* @apiSuccess {object} data The updated task
*/
api.addTagToTask = {
method: 'POST',
url: '/tasks/:taskId/tags/:tagId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let userTags = user.tags.map(tag => tag.id);
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID().isIn(userTags);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
let tagId = req.params.tagId;
let alreadyTagged = task.tags.indexOf(tagId) !== -1;
if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged'));
task.tags.push(tagId);
let savedTask = await task.save();
res.respond(200, savedTask);
},
};
/**
* @api {delete} /api/v3/tasks/:taskId/tags/:tagId Remove a tag from atask
* @apiVersion 3.0.0
* @apiName RemoveTagFromTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {UUID} tagId The tag id
*
* @apiSuccess {object} data The updated task
*/
api.removeTagFromTask = {
method: 'DELETE',
url: '/tasks/:taskId/tags/:tagId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
let hasTag = removeFromArray(task.tags, req.params.tagId);
if (!hasTag) throw new NotFound(res.t('tagNotFound'));
let savedTask = await task.save();
res.respond(200, savedTask);
},
};
/**
* @api {post} /api/v3/tasks/unlink-all/:challengeId Unlink all tasks from a challenge
* @apiVersion 3.0.0
* @apiName UnlinkAllTasks
* @apiGroup Task
*
* @apiParam {UUID} challengeId The challenge _id
* @apiParam {string} keep Query parameter - keep-all or remove-all
*
* @apiSuccess {object} data An empty object
*/
api.unlinkAllTasks = {
method: 'POST',
url: '/tasks/unlink-all/:challengeId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
req.checkQuery('keep', res.t('keepOrRemoveAll')).notEmpty().isIn(['keep-all', 'remove-all']);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let keep = req.query.keep;
let challengeId = req.params.challengeId;
let tasks = await Tasks.Task.find({
'challenge.id': challengeId,
userId: user._id,
}).exec();
let validTasks = tasks.every(task => {
return task.challenge.broken;
});
if (!validTasks) throw new BadRequest(res.t('cantOnlyUnlinkChalTask'));
if (keep === 'keep-all') {
await Bluebird.all(tasks.map(task => {
task.challenge = {};
return task.save();
}));
} else { // remove
let toSave = [];
tasks.forEach(task => {
if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if
removeFromArray(user.tasksOrder[`${task.type}s`], task._id);
}
toSave.push(task.remove());
});
toSave.push(user.save());
await Bluebird.all(toSave);
}
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/tasks/unlink-one/:taskId Unlink a challenge task
* @apiVersion 3.0.0
* @apiName UnlinkOneTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {string} keep Query parameter - keep or remove
*
* @apiSuccess {object} data An empty object
*/
api.unlinkOneTask = {
method: 'POST',
url: '/tasks/unlink-one/:taskId',
middlewares: [authWithHeaders()],
async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkQuery('keep', res.t('keepOrRemove')).notEmpty().isIn(['keep', 'remove']);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let keep = req.query.keep;
let taskId = req.params.taskId;
let task = await Tasks.Task.findOne({
_id: taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound'));
if (!task.challenge.id) throw new BadRequest(res.t('cantOnlyUnlinkChalTask'));
if (!task.challenge.broken) throw new BadRequest(res.t('cantOnlyUnlinkChalTask'));
if (keep === 'keep') {
task.challenge = {};
await task.save();
} else { // remove
if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if
removeFromArray(user.tasksOrder[`${task.type}s`], taskId);
await Bluebird.all([user.save(), task.remove()]);
} else {
await task.remove();
}
}
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/tasks/clearCompletedTodos Delete user's completed todos
* @apiVersion 3.0.0
* @apiName ClearCompletedTodos
* @apiGroup Task
*
* @apiSuccess {object} data An empty object
*/
api.clearCompletedTodos = {
method: 'POST',
url: '/tasks/clearCompletedTodos',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
// Clear completed todos
// Do not delete challenges completed todos unless the task is broken
await Tasks.Task.remove({
userId: user._id,
type: 'todo',
completed: true,
$or: [
{'challenge.id': {$exists: false}},
{'challenge.broken': {$exists: true}},
],
}).exec();
res.respond(200, {});
},
};
/**
* @api {delete} /api/v3/tasks/:taskId Delete a task given its id
* @apiVersion 3.0.0
* @apiName DeleteTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
*
* @apiSuccess {object} data An empty object
*/
api.deleteTask = {
method: 'DELETE',
url: '/tasks/:taskId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let taskId = req.params.taskId;
let task = await Tasks.Task.findById(taskId).exec();
if (!task) {
throw new NotFound(res.t('taskNotFound'));
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound'));
} else if (task.userId && task.challenge.id && !task.challenge.broken) {
throw new NotAuthorized(res.t('cantDeleteChallengeTasks'));
}
if (task.type !== 'todo' || !task.completed) {
removeFromArray((challenge || user).tasksOrder[`${task.type}s`], taskId);
await Bluebird.all([(challenge || user).save(), task.remove()]);
} else {
await task.remove();
}
res.respond(200, {});
if (challenge) challenge.removeTask(task);
return null;
},
};
module.exports = api;