Merge pull request #6343 from HabitRPG/api-v3-tasks2

[API v3] Tasks 2
This commit is contained in:
Matteo Pagliazzi
2015-12-16 13:18:05 +01:00
33 changed files with 2225 additions and 320 deletions
+5 -2
View File
@@ -1,6 +1,7 @@
import validator from 'validator';
import passport from 'passport';
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import cron from '../../middlewares/api-v3/cron';
import {
NotAuthorized,
} from '../../libs/api-v3/errors';
@@ -30,7 +31,7 @@ api.registerLocal = {
url: '/user/auth/local/register',
handler (req, res, next) {
let fbUser = res.locals.user; // If adding local auth to social user
// TODO check user doesn't have local auth
req.checkBody({
email: {
notEmpty: {errorMessage: res.t('missingEmail')},
@@ -138,6 +139,7 @@ function _loginRes (user, req, res, next) {
api.loginLocal = {
method: 'POST',
url: '/user/auth/local/login',
middlewares: [cron],
handler (req, res, next) {
req.checkBody({
username: {
@@ -182,6 +184,7 @@ api.loginLocal = {
api.loginSocial = {
method: 'POST',
url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2
middlewares: [cron],
handler (req, res, next) {
let accessToken = req.body.authResponse.access_token;
let network = req.body.network;
@@ -247,7 +250,7 @@ api.loginSocial = {
api.deleteSocial = {
method: 'DELETE',
url: '/user/auth/social/:network',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
let network = req.params.network;
+9 -8
View File
@@ -1,4 +1,5 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import cron from '../../middlewares/api-v3/cron';
import { model as Tag } from '../../models/tag';
import {
NotFound,
@@ -18,7 +19,7 @@ let api = {};
api.createTag = {
method: 'POST',
url: '/tags',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
@@ -45,7 +46,7 @@ api.createTag = {
api.getTags = {
method: 'GET',
url: '/tags',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res) {
let user = res.locals.user;
res.respond(200, user.tags);
@@ -65,11 +66,11 @@ api.getTags = {
api.getTag = {
method: 'GET',
url: '/tags/:tagId',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
req.checkParams('taskId', res.t('tagIdRequired')).notEmpty().isUUID();
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
@@ -93,14 +94,14 @@ api.getTag = {
api.updateTag = {
method: 'PUT',
url: '/tags/:tagId',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
// TODO check that req.body isn't empty
let tagId = req.params.id;
let tagId = req.params.tagId;
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
@@ -127,9 +128,9 @@ api.updateTag = {
* @apiSuccess {object} empty An empty object
*/
api.deleteTag = {
method: 'GET',
method: 'DELETE',
url: '/tags/:tagId',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
+105 -27
View File
@@ -1,12 +1,16 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import cron from '../../middlewares/api-v3/cron';
import { sendTaskWebhook } from '../../libs/api-v3/webhook';
import * as Tasks from '../../models/task';
import {
NotFound,
NotAuthorized,
BadRequest,
} from '../../libs/api-v3/errors';
import shared from '../../../../common';
import Q from 'q';
import _ from 'lodash';
import scoreTask from '../../../../common/script/api-v3/scoreTask';
let api = {};
@@ -23,7 +27,7 @@ let api = {};
api.createTask = {
method: 'POST',
url: '/tasks',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes);
@@ -61,7 +65,7 @@ api.createTask = {
api.getTasks = {
method: 'GET',
url: '/tasks',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes);
@@ -117,7 +121,7 @@ api.getTasks = {
api.getTask = {
method: 'GET',
url: '/tasks/:taskId',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
@@ -151,7 +155,7 @@ api.getTask = {
api.updateTask = {
method: 'PUT',
url: '/tasks/:taskId',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
@@ -171,12 +175,22 @@ api.updateTask = {
// If checklist is updated -> replace the original one
if (req.body.checklist) {
delete req.body.checklist;
task.checklist = req.body.checklist;
delete req.body.checklist;
}
// TODO merge goes deep into objects, it's ok?
// TODO also check that array and mixed fields are updated correctly without marking modified
_.merge(task, Tasks.Task.sanitizeUpdate(req.body));
// If tags are updated -> replace the original ones
if (req.body.tags) {
task.tags = req.body.tags;
delete req.body.tags;
}
// TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, very bad for performances
// TODO regarding comment above make sure other models with nested fields are using this trick too
_.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate(req.body)));
// TODO 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
return task.save();
})
.then((savedTask) => res.respond(200, savedTask))
@@ -184,8 +198,33 @@ api.updateTask = {
},
};
function _generateWebhookTaskData (task, direction, delta, stats, user) {
let extendedStats = _.extend(stats, {
toNextLevel: shared.tnl(user.stats.lvl),
maxHealth: shared.maxHealth,
maxMP: user._statsComputed.maxMP, // TODO refactor as method not getter
});
let userData = {
_id: user._id,
_tmp: user._tmp,
stats: extendedStats,
};
let taskData = {
details: task,
direction,
delta,
};
return {
task: taskData,
user: userData,
};
}
/**
* @api {put} /tasks/score/:taskId/:direction Score a task
* @api {put} /tasks/:taskId/score/:direction Score a task
* @apiVersion 3.0.0
* @apiName ScoreTask
* @apiGroup Task
@@ -197,16 +236,17 @@ api.updateTask = {
*/
api.scoreTask = {
method: 'POST',
url: 'tasks/score/:taskId/:direction',
middlewares: [authWithHeaders()],
url: '/tasks/:taskId/score/:direction',
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']);
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); // TODO what about rewards? maybe separate route?
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
let user = res.locals.user;
let direction = req.params.direction;
Tasks.Task.findOne({
_id: req.params.taskId,
@@ -214,8 +254,47 @@ api.scoreTask = {
}).exec()
.then((task) => {
if (!task) throw new NotFound(res.t('taskNotFound'));
let wasCompleted = task.completed;
if (task.type === 'daily' || task.type === 'todo') {
task.completed = direction === 'up'; // TODO move into scoreTask
}
let delta = 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
if (task.type === 'todo') {
if (!wasCompleted && task.completed) {
let i = user.tasksOrder.todos.indexOf(task._id);
if (i !== -1) user.tasksOrder.todos.splice(i, 1);
} else if (wasCompleted && !task.completed) {
let i = user.tasksOrder.todos.indexOf(task._id);
if (i === -1) {
user.tasksOrder.todos.push(task._id); // TODO push at the top?
} else { // If for some reason it hadn't been removed TODO ok?
user.tasksOrder.todos.splice(i, 1);
user.tasksOrder.push(task._id);
}
}
}
return Q.all([
user.save(),
task.save(),
]).then((results) => {
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));
// TODO sync challenge
});
})
.then(() => res.respond(200, {})) // TODO what to return
.catch(next);
},
};
@@ -236,7 +315,7 @@ api.scoreTask = {
api.moveTask = {
method: 'POST',
url: '/tasks/move/:taskId/to/:position',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric();
@@ -288,7 +367,7 @@ api.moveTask = {
api.addChecklistItem = {
method: 'POST',
url: '/tasks/:taskId/checklist',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
@@ -306,7 +385,7 @@ api.addChecklistItem = {
if (!task) throw new NotFound(res.t('taskNotFound'));
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
task.checklist.push(req.body);
task.checklist.push(Tasks.Task.sanitizeChecklist(req.body));
return task.save();
})
.then((savedTask) => res.respond(200, savedTask)) // TODO what to return
@@ -328,7 +407,7 @@ api.addChecklistItem = {
api.scoreCheckListItem = {
method: 'POST',
url: '/tasks/:taskId/checklist/:itemId/score',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
@@ -371,7 +450,7 @@ api.scoreCheckListItem = {
api.updateChecklistItem = {
method: 'PUT',
url: '/tasks/:taskId/checklist/:itemId',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
@@ -392,8 +471,7 @@ api.updateChecklistItem = {
let item = _.find(task.checklist, {_id: req.params.itemId});
if (!item) throw new NotFound(res.t('checklistItemNotFound'));
delete req.body.id; // Simple sanitization to prevent the ID to be changed
_.merge(item, req.body);
_.merge(item, Tasks.Task.sanitizeChecklist(req.body));
return task.save();
})
.then((savedTask) => res.respond(200, savedTask)) // TODO what to return
@@ -415,7 +493,7 @@ api.updateChecklistItem = {
api.removeChecklistItem = {
method: 'DELETE',
url: '/tasks/:taskId/checklist/:itemId',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
@@ -457,8 +535,8 @@ api.removeChecklistItem = {
*/
api.addTagToTask = {
method: 'POST',
url: '/tasks/:taskId/tags',
middlewares: [authWithHeaders()],
url: '/tasks/:taskId/tags/:tagId',
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
@@ -477,7 +555,7 @@ api.addTagToTask = {
if (!task) throw new NotFound(res.t('taskNotFound'));
let tagId = req.params.tagId;
let alreadyTagged = task.tags.indexOf(tagId) === -1;
let alreadyTagged = task.tags.indexOf(tagId) !== -1;
if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged'));
task.tags.push(tagId);
@@ -502,7 +580,7 @@ api.addTagToTask = {
api.removeTagFromTask = {
method: 'DELETE',
url: '/tasks/:taskId/tags/:tagId',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
@@ -519,7 +597,7 @@ api.removeTagFromTask = {
.then((task) => {
if (!task) throw new NotFound(res.t('taskNotFound'));
let tagI = _.findIndex(task.tags, {_id: req.params.tagId});
let tagI = task.tags.indexOf(req.params.tagId);
if (tagI === -1) throw new NotFound(res.t('tagNotFound'));
task.tags.splice(tagI, 1);
@@ -559,7 +637,7 @@ function _removeTaskTasksOrder (user, taskId) {
api.deleteTask = {
method: 'DELETE',
url: '/tasks/:taskId',
middlewares: [authWithHeaders()],
middlewares: [authWithHeaders(), cron],
handler (req, res, next) {
let user = res.locals.user;
+34
View File
@@ -0,0 +1,34 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import cron from '../../middlewares/api-v3/cron';
import common from '../../../../common';
let api = {};
/**
* @api {get} /user Get the authenticated user's profile
* @apiVersion 3.0.0
* @apiName UserGet
* @apiGroup User
*
* @apiSuccess {Object} user The user object
*/
api.getUser = {
method: 'GET',
middlewares: [authWithHeaders(), cron],
url: '/user',
handler (req, res) {
let user = res.locals.user.toJSON();
// Remove apiToken from resonse TODO make it priavte at the user level? returned in signup/login
delete user.apiToken;
// TODO move to model (maybe virtuals, maybe in toJSON)
user.stats.toNextLevel = common.tnl(user.stats.lvl);
user.stats.maxHealth = common.maxHealth;
user.stats.maxMP = res.locals.user._statsComputed.maxMP;
return res.respond(200, user);
},
};
export default api;
+65
View File
@@ -0,0 +1,65 @@
import _ from 'lodash';
import {
daysSince,
} from '../../../../common/script/cron';
import cron from '../../../../common/script/api-v3/cron';
import common from '../../../../common';
import Task from '../../models/task';
// import Group from '../../models/group';
// TODO check that it's usef everywhere
export default function cronMiddleware (req, res, next) {
let user = res.locals.user;
let analytics = res.analytics;
let now = new Date();
let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences));
if (daysMissed <= 0) return next(null, user); // TODO why are we passing user down here?
// Fetch active tasks (no completed todos)
Task.find({
userId: user._id,
$or: [ // Exclude completed todos
{type: 'todo', completed: false},
{type: {$in: ['habit', 'daily', 'reward']}},
],
}).exec()
.then((tasks) => {
let tasksByType = {habits: [], dailys: [], todos: [], rewards: []};
tasks.forEach(task => tasksByType[`${task.type}s`].push(task));
// Run cron
cron({user, tasks, tasksByType, now, daysMissed, analytics});
let ranCron = user.isModified();
let quest = common.content.quests[user.party.quest.key];
// if (ranCron) res.locals.wasModified = true; // TODO remove?
if (!ranCron) return next(null, user); // TODO why are we passing user to next?
// TODO Group.tavernBoss(user, progress);
if (!quest || true /* TODO remove */) return user.save(next);
// If user is on a quest, roll for boss & player, or handle collections
// FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
// TODO do
/* async.waterfall([
function(cb){
user.save(cb); // make sure to save the cron effects
},
function(saved, count, cb){
var type = quest.boss ? 'boss' : 'collect';
Group[type+'Quest'](user,progress,cb);
},
function(){
var cb = arguments[arguments.length-1];
// User has been updated in boss-grapple, reload
User.findById(user._id, cb);
}
], function(err, saved) {
res.locals.user = saved;
next(err,saved);
user = progress = quest = null;
});*/
});
}
@@ -48,7 +48,7 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l
// Handle mongoose validation errors
if (err.name === 'ValidationError') {
responseErr = new BadRequest(err.message);
responseErr = new BadRequest(err.message); // TODO standard message? translate?
responseErr.errors = map(err.errors, (mongooseErr) => {
return {
message: mongooseErr.message,
+8 -2
View File
@@ -46,17 +46,23 @@ TaskSchema.plugin(baseModel, {
});
// A list of additional fields that cannot be set on creation (but can be set on updare)
let noCreate = ['completed'];
let noCreate = ['completed']; // TODO completed should be removed for updates too?
TaskSchema.statics.sanitizeCreate = function sanitizeCreate (createObj) {
return Task.sanitize(createObj, noCreate); // eslint-disable-line no-use-before-define
};
// A list of additional fields that cannot be updated (but can be set on creation)
let noUpdate = ['_id', 'type']; // TODO should prevent changes to checlist.*.id
let noUpdate = ['_id', 'type'];
TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define
};
// Sanitize checklist objects (disallowing _id)
TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) {
delete checklistObj._id;
return checklistObj;
};
export let Task = mongoose.model('Task', TaskSchema);
// habits and dailies shared fields
+10 -3
View File
@@ -45,6 +45,7 @@ export let schema = new Schema({
// We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which
// have been updated (http://goo.gl/gQLz41), but we want *every* update
_v: { type: Number, default: 0 },
// TODO give all this a default of 0?
achievements: {
originalUser: Boolean,
habitSurveys: Number,
@@ -65,7 +66,7 @@ export let schema = new Schema({
quests: Schema.Types.Mixed, // TODO remove, use dictionary?
rebirths: Number,
rebirthLevel: Number,
perfect: Number,
perfect: {type: Number, default: 0},
habitBirthdays: Number,
valentine: Number,
costumeContest: Boolean, // Superseded by costumeContests
@@ -373,7 +374,7 @@ export let schema = new Schema({
toolbarCollapsed: {type: Boolean, default: false},
background: String,
displayInviteToPartyWhenPartyIs1: {type: Boolean, default: true},
webhooks: {type: Schema.Types.Mixed, default: {}},
webhooks: {type: Schema.Types.Mixed, default: {}}, // TODO array? and proper controller... unless VersionError becomes problematic
// For the following fields make sure to use strict comparison when searching for falsey values (=== false)
// As users who didn't login after these were introduced may have them undefined/null
emailNotifications: {
@@ -468,7 +469,8 @@ export let schema = new Schema({
});
schema.plugin(baseModel, {
noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags'],
// TODO revisit a lot of things are missing
noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats'],
private: ['auth.local.hashed_password', 'auth.local.salt'],
toJSONTransform: function toJSON (doc) {
// FIXME? Is this a reference to `doc.filters` or just disabled code? Remove?
@@ -627,6 +629,11 @@ schema.pre('save', true, function preSaveUser (next, done) {
}
});
// TODO unit test this?
schema.methods.isSubscribed = function isSubscribed () {
return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion
};
schema.methods.unlink = function unlink (options, cb) {
let cid = options.cid;
let keep = options.keep;