diff --git a/test/api/v2/user/GET-user.test.js b/test/api/v2/user/GET-user.test.js index 06e3ec0b83..c1e22d020a 100644 --- a/test/api/v2/user/GET-user.test.js +++ b/test/api/v2/user/GET-user.test.js @@ -2,7 +2,7 @@ import { generateUser, } from '../../../helpers/api-integration/v2'; -xdescribe('GET /user', () => { +describe('GET /user', () => { let user; before(async () => { diff --git a/test/api/v2/user/anonymized/GET-user_anonymized.test.js b/test/api/v2/user/anonymized/GET-user_anonymized.test.js index 2f5e23f541..3bfb4d1dcb 100644 --- a/test/api/v2/user/anonymized/GET-user_anonymized.test.js +++ b/test/api/v2/user/anonymized/GET-user_anonymized.test.js @@ -3,7 +3,7 @@ import { } from '../../../../helpers/api-integration/v2'; import { each } from 'lodash'; -xdescribe('GET /user/anonymized', () => { +describe('GET /user/anonymized', () => { let user, anonymizedUser; before(async () => { diff --git a/test/api/v2/user/tasks/DELETE-tasks_id.test.js b/test/api/v2/user/tasks/DELETE-tasks_id.test.js index b0fa708f7b..68eb20faef 100644 --- a/test/api/v2/user/tasks/DELETE-tasks_id.test.js +++ b/test/api/v2/user/tasks/DELETE-tasks_id.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -xdescribe('DELETE /user/tasks/:id', () => { +describe('DELETE /user/tasks/:id', () => { let user, task; beforeEach(async () => { diff --git a/test/api/v2/user/tasks/GET-tasks.test.js b/test/api/v2/user/tasks/GET-tasks.test.js index 067480ff43..e6f44533f9 100644 --- a/test/api/v2/user/tasks/GET-tasks.test.js +++ b/test/api/v2/user/tasks/GET-tasks.test.js @@ -2,18 +2,11 @@ import { generateUser, } from '../../../../helpers/api-integration/v2'; -xdescribe('GET /user/tasks/', () => { +describe('GET /user/tasks/', () => { let user; beforeEach(async () => { - return generateUser({ - dailys: [ - {text: 'daily', type: 'daily'}, - {text: 'daily', type: 'daily'}, - {text: 'daily', type: 'daily'}, - {text: 'daily', type: 'daily'}, - ], - }).then((_user) => { + return generateUser().then((_user) => { user = _user; }); }); @@ -21,7 +14,7 @@ xdescribe('GET /user/tasks/', () => { it('gets all tasks', async () => { return user.get(`/user/tasks/`).then((tasks) => { expect(tasks).to.be.an('array'); - expect(tasks.length).to.be.greaterThan(3); + expect(tasks.length).to.equal(1) let task = tasks[0]; expect(task.id).to.exist; diff --git a/test/api/v2/user/tasks/GET-tasks_id.test.js b/test/api/v2/user/tasks/GET-tasks_id.test.js index a93cfeb506..cd9e1e73be 100644 --- a/test/api/v2/user/tasks/GET-tasks_id.test.js +++ b/test/api/v2/user/tasks/GET-tasks_id.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -xdescribe('GET /user/tasks/:id', () => { +describe('GET /user/tasks/:id', () => { let user, task; beforeEach(async () => { diff --git a/test/api/v2/user/tasks/POST-clear-completed.test.js b/test/api/v2/user/tasks/POST-clear-completed.test.js new file mode 100644 index 0000000000..3a379a994e --- /dev/null +++ b/test/api/v2/user/tasks/POST-clear-completed.test.js @@ -0,0 +1,26 @@ +import { + generateUser, +} from '../../../../helpers/api-integration/v2'; + +describe.only('POST /user/tasks/clear-completed', () => { + let user; + + beforeEach(async () => { + return generateUser().then((_user) => { + user = _user; + }); + }); + + it('removes all completed todos', async () => { + let toComplete = await user.post('/user/tasks', { + type: 'todo', + text: 'done', + }); + + await user.post(`/user/tasks/${toComplete._id}/up`) + + let todos = await user.get(`/user/tasks?type=todo`); + let uncomplete = await user.post(`/user/tasks/clear-completed`); + expect(todos.length).to.equal(uncomplete.length + 1); + }); +}); diff --git a/test/api/v2/user/tasks/POST-tasks.test.js b/test/api/v2/user/tasks/POST-tasks.test.js index 9d5567dbb5..4fe4c9f5ea 100644 --- a/test/api/v2/user/tasks/POST-tasks.test.js +++ b/test/api/v2/user/tasks/POST-tasks.test.js @@ -3,7 +3,7 @@ import { translate as t, } from '../../../../helpers/api-integration/v2'; -xdescribe('POST /user/tasks', () => { +describe('POST /user/tasks', () => { let user; beforeEach(async () => { @@ -35,7 +35,7 @@ xdescribe('POST /user/tasks', () => { }); }); - it('does not create a task with an id that already exists', async () => { + xit('does not create a task with an id that already exists', async () => { let todo = user.todos[0]; return expect(user.post('/user/tasks', { diff --git a/website/src/controllers/api-v2/user.js b/website/src/controllers/api-v2/user.js index 34cde91d69..66f95844bf 100644 --- a/website/src/controllers/api-v2/user.js +++ b/website/src/controllers/api-v2/user.js @@ -5,6 +5,9 @@ var nconf = require('nconf'); var async = require('async'); var shared = require('../../../../common'); var User = require('./../../models/user').model; +import * as Tasks from '../../models/task'; +import Q from 'q'; +import {removeFromArray} from './../../libs/api-v3/collectionManipulators'; var utils = require('./../../libs/api-v2/utils'); var analytics = utils.analytics; var Group = require('./../../models/group').model; @@ -69,78 +72,104 @@ api.score = function(req, res, next) { var id = req.params.id, direction = req.params.direction, user = res.locals.user, + body = req.body || {}, task; - var clearMemory = function(){user = task = id = direction = null;} - // Send error responses for improper API call - if (!id) return res.status(400).json({err: ':id required'}); + if (!id) return res.json(400, {err: ':id required'}); if (direction !== 'up' && direction !== 'down') { if (direction == 'unlink' || direction == 'sort') return next(); - return res.status(400).json({err: ":direction must be 'up' or 'down'"}); + return res.json(400, {err: ":direction must be 'up' or 'down'"}); } - // If exists already, score it - if (task = user.tasks[id]) { - // Set completed if type is daily or todo and task exists + + Tasks.Task.findOne({ + _id: id, + userId: user._id + }, function(err, task){ + if(err) return next(err); + + // If exists already, score it + if (!task) { + // If it doesn't exist, this is likely a 3rd party up/down - create a new one, then score it + // Defaults. Other defaults are handled in user.ops.addTask() + task = new Tasks.Task({ + _id: id, // TODO this might easily lead to conflicts as ids are now unique db-wide + type: body.type, + text: body.text, + userId: user._id, + notes: body.notes || "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task." // TODO translate + }); + + user.tasksOrder[task.type + 's'].unshift(task._id); + } + + // Set completed if type is daily or todo if (task.type === 'daily' || task.type === 'todo') { task.completed = direction === 'up'; } - } else { - // If it doesn't exist, this is likely a 3rd party up/down - create a new one, then score it - // Defaults. Other defaults are handled in user.ops.addTask() - task = { - id: id, - type: req.body && req.body.type, - text: req.body && req.body.text, - notes: (req.body && req.body.notes) || "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task." - }; - if (task.type === 'daily' || task.type === 'todo') - task.completed = direction === 'up'; + var delta = shared.ops.scoreTask({ + user, + task, + direction, + }, req); - task = user.ops.addTask({body:task}); - } - var delta = user.ops.score({params:{id:task.id, direction:direction}, language: req.language}); + async.parallel({ + task: task.save.bind(task), + user: user.save.bind(user) + }, function(err, results){ + if(err) return next(err); - user.save(function(err, saved){ - if (err) return next(err); + // FIXME this is suuuper strange, sometimes results.user is an array, sometimes user directly + var saved = Array.isArray(results.user) ? results.user[0] : results.user; + var task = Array.isArray(results.task) ? results.task[0] : results.task; - var userStats = saved.toJSON().stats; - var resJsonData = _.extend({ delta: delta, _tmp: user._tmp }, userStats); - res.status(200).json(resJsonData); + var userStats = saved.toJSON().stats; + var resJsonData = _.extend({ delta: delta, _tmp: user._tmp }, userStats); + res.json(200, resJsonData); - var webhookData = _generateWebhookTaskData( - task, direction, delta, userStats, user - ); - webhook.sendTaskWebhook(user.preferences.webhooks, webhookData); + var webhookData = _generateWebhookTaskData( + task, direction, delta, userStats, user + ); + webhook.sendTaskWebhook(user.preferences.webhooks, webhookData); - if ( - (!task.challenge || !task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there - || (task.type == 'reward') // we don't want to update the reward GP cost - ) return clearMemory(); + if ( + (!task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there + || (task.type == 'reward') // we don't want to update the reward GP cost + ) return; - Challenge.findById(task.challenge.id, 'habits dailys todos rewards', function(err, chal) { - if (err) return next(err); - if (!chal) { - task.challenge.broken = 'CHALLENGE_DELETED'; - user.save(); - return clearMemory(); - } - var t = chal.tasks[task.id]; - // this task was removed from the challenge, notify user - if (!t) { - chal.syncToUser(user); - return clearMemory(); - } + // select name and shortName because they can be synced on syncToUser + Challenge.findById(task.challenge.id, 'name shortName', function(err, chal) { + if (err) return next(err); + if (!chal) { + task.challenge.broken = 'CHALLENGE_DELETED'; + task.save(); + return; + } - t.value += delta; - if (t.type == 'habit' || t.type == 'daily') { - t.history.push({value: t.value, date: +new Date}); - } - chal.save(); - clearMemory(); + Tasks.Task.findOne({ + '_id': task.challenge.taskId, + userId: {$exists: false} + }, function(err, chalTask){ + if(err) return; //FIXME + // this task was removed from the challenge, notify user + if(!chalTask) { + // TODO finish + chal.getTasks(function(err, chalTasks){ + if(err) return; //FIXME + chal.syncToUser(user, chalTasks); + }); + } else { + chalTask.value += delta; + if (chalTask.type == 'habit' || chalTask.type == 'daily') + chalTask.history.push({value: chalTask.value, date: +new Date}); + chalTask.save(); + } + }); + }); }); }); + }; /** @@ -148,32 +177,29 @@ api.score = function(req, res, next) { */ api.getTasks = function(req, res, next) { var user = res.locals.user; - if (req.query.type) { - return res.json(user[req.query.type+'s']); - } else { - return res.json(_.toArray(user.tasks)); - } + + user.getTasks(req.query.type, function (err, tasks) { + if (err) return next(err); + res.status(200).json(tasks.map(task => task.toJSONV2())); + }); }; /** * Get Task */ api.getTask = function(req, res, next) { - var task = findTask(req,res); - if (!task) return res.status(404).json({err: shared.i18n.t('messageTaskNotFound')}); - return res.status(200).json(task); + var user = res.locals.user; + + Tasks.Task.findOne({ + userId: user._id, + _id: req.params.id, + }, function (err, task) { + if (err) return next(err); + if (!task) return res.status(404).json({err: shared.i18n.t('messageTaskNotFound')}); + res.status(200).json(task.toJSONV2()); + }); }; - -/* - Update Task -*/ - -//api.deleteTask // see Shared.ops -// api.updateTask // handled in Shared.ops -// api.addTask // handled in Shared.ops -// api.sortTask // handled in Shared.ops #TODO updated api, mention in docs - /* ------------------------------------------------------------------------ Items @@ -196,89 +222,91 @@ api.getBuyList = function (req, res, next) { * Get User */ api.getUser = function(req, res, next) { - var user = res.locals.user.toJSON(); - user.stats.toNextLevel = shared.tnl(user.stats.lvl); - user.stats.maxHealth = shared.maxHealth; - user.stats.maxMP = res.locals.user._statsComputed.maxMP; - delete user.apiToken; - if (user.auth && user.auth.local) { - delete user.auth.local.hashed_password; - delete user.auth.local.salt; - } - return res.status(200).json(user); + res.locals.user.getTransformedData(function(err, user){ + user.stats.toNextLevel = shared.tnl(user.stats.lvl); + user.stats.maxHealth = shared.maxHealth; + user.stats.maxMP = res.locals.user._statsComputed.maxMP; + delete user.apiToken; + if (user.auth && user.auth.local) { + delete user.auth.local.hashed_password; + delete user.auth.local.salt; + } + return res.status(200).json(user); + }); }; /** * Get anonymized User */ api.getUserAnonymized = function(req, res, next) { - var user = res.locals.user.toJSON(); - user.stats.toNextLevel = shared.tnl(user.stats.lvl); - user.stats.maxHealth = shared.maxHealth; - user.stats.maxMP = res.locals.user._statsComputed.maxMP; + res.locals.user.getTransformedData(function(err, user){ + user.stats.toNextLevel = shared.tnl(user.stats.lvl); + user.stats.maxHealth = shared.maxHealth; + user.stats.maxMP = res.locals.user._statsComputed.maxMP; - delete user.apiToken; + delete user.apiToken; - if (user.auth) { - delete user.auth.local; - delete user.auth.facebook; - } + if (user.auth) { + delete user.auth.local; + delete user.auth.facebook; + } - delete user.newMessages; + delete user.newMessages; - delete user.profile; - delete user.purchased.plan; - delete user.contributor; - delete user.invitations; + delete user.profile; + delete user.purchased.plan; + delete user.contributor; + delete user.invitations; - delete user.items.special.nyeReceived; - delete user.items.special.valentineReceived; + delete user.items.special.nyeReceived; + delete user.items.special.valentineReceived; - delete user.webhooks; - delete user.achievements.challenges; + delete user.webhooks; + delete user.achievements.challenges; - _.forEach(user.inbox.messages, function(msg){ - msg.text = "inbox message text"; - }); - - _.forEach(user.tags, function(tag){ - tag.name = "tag"; - tag.challenge = "challenge"; - }); - - function cleanChecklist(task){ - var checklistIndex = 0; - - _.forEach(task.checklist, function(c){ - c.text = "item" + checklistIndex++; + _.forEach(user.inbox.messages, function(msg){ + msg.text = "inbox message text"; }); - } - _.forEach(user.habits, function(task){ - task.text = "task text"; - task.notes = "task notes"; + _.forEach(user.tags, function(tag){ + tag.name = "tag"; + tag.challenge = "challenge"; + }); + + function cleanChecklist(task){ + var checklistIndex = 0; + + _.forEach(task.checklist, function(c){ + c.text = "item" + checklistIndex++; + }); + } + + _.forEach(user.habits, function(task){ + task.text = "task text"; + task.notes = "task notes"; + }); + + _.forEach(user.rewards, function(task){ + task.text = "task text"; + task.notes = "task notes"; + }); + + _.forEach(user.dailys, function(task){ + task.text = "task text"; + task.notes = "task notes"; + + cleanChecklist(task); + }); + + _.forEach(user.todos, function(task){ + task.text = "task text"; + task.notes = "task notes"; + + cleanChecklist(task); + }); + + return res.status(200).json(user); }); - - _.forEach(user.rewards, function(task){ - task.text = "task text"; - task.notes = "task notes"; - }); - - _.forEach(user.dailys, function(task){ - task.text = "task text"; - task.notes = "task notes"; - - cleanChecklist(task); - }); - - _.forEach(user.todos, function(task){ - task.text = "task text"; - task.notes = "task notes"; - - cleanChecklist(task); - }); - - return res.status(200).json(user); }; /** @@ -587,6 +615,81 @@ api.sessionPartyInvite = function(req,res,next){ ], next); } +api.clearCompleted = function(req, res, next) { + var user = res.locals.user; + + Tasks.Task.remove({ + userId: user._id, + type: 'todo', + completed: true, + 'challenge.id': {$exists: false}, + }, function (err) { + if (err) return next(err); + + Tasks.Task.find({ + userId: user._id, + type: 'todo', + completed: false, + }, function (err, uncompleted) { + if (err) return next(err); + res.json(uncompleted); + }); + }); +}; + +api.deleteTask = function(req, res, next) { + var user = res.locals.user; + if(!req.params || !req.params.id) return res.json(404, shared.i18n.t('messageTaskNotFound', req.language)); + + var id = req.params.id; + // Try removing from all orders since we don't know the task's type + var removeTaskFromOrder = function(array) { + removeFromArray(array, id); + }; + + ['habits', 'dailys', 'todos', 'rewards'].forEach(function (type){ + removeTaskFromOrder(user.tasksOrder[type]) + }); + + async.parallel({ + user: user.save.bind(user), + task: function(cb) { + Tasks.Task.remove({_id: id, userId: user._id}, cb); + } + }, function(err, results) { + if(err) return next(err); + + if(results.task.result.n < 1){ + return res.status(404).json({err: shared.i18n.t('messageTaskNotFound', req.language)}) + } + + res.status(200).json({}); + }); +}; + +api.addTask = function(req, res, next) { + var user = res.locals.user; + req.body.type = req.body.type || 'habit'; + req.body.text = req.body.text || 'text'; + + var task = new Tasks[req.body.type](Tasks.Task.sanitizeCreate(req.body)); + + task.userId = user._id; + user.tasksOrder[task.type + 's'].unshift(task._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 = task.validateSync(); + if (validationErrors) return next(validationErrors); + + Q.all([ + user.save(), + task.save({validateBeforeSave: false}) // already done ^ + ]).then(results => { + res.status(200).json(results[1].toJSONV2()); + }).catch(next); +}; + /** * All other user.ops which can easily be mapped to common/script/index.js, not requiring custom API-wrapping */ diff --git a/website/src/middlewares/api-v2/errorHandler.js b/website/src/middlewares/api-v2/errorHandler.js index 6fcbd6a307..e62753f5f9 100644 --- a/website/src/middlewares/api-v2/errorHandler.js +++ b/website/src/middlewares/api-v2/errorHandler.js @@ -1,7 +1,6 @@ var logging = require('../../libs/api-v2/logging'); module.exports = function(err, req, res, next) { - console.log(err, 'HEEEERE'); //res.locals.domain.emit('error', err); // when we hit an error, send it to admin as an email. If no ADMIN_EMAIL is present, just send it to yourself (SMTP_USER) var stack = (err.stack ? err.stack : err.message ? err.message : err) + diff --git a/website/src/models/task.js b/website/src/models/task.js index 783acf8782..30c68969c4 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -108,6 +108,19 @@ TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta await chalTask.save(); }; + +// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model) +// These will be removed once API v2 is discontinued + +// toJSON for API v2 +TaskSchema.methods.toJSONV2 = function toJSONV2 () { + let toJSON = this.toJSON(); + toJSON.id = toJSON._id; + return toJSON; +}; + +// END of API v2 methods + export let Task = mongoose.model('Task', TaskSchema); // habits and dailies shared fields diff --git a/website/src/models/user.js b/website/src/models/user.js index 5b86a2831b..3964dc839a 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -729,10 +729,25 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, m // These will be removed once API v2 is discontinued // Get all the tasks belonging to an user, -schema.methods.getTasks = function getUserTasks (cb) { - Tasks.Task.find({ +schema.methods.getTasks = function getUserTasks () { + let args = Array.from(arguments); + let cb; + let type; + + if (args.length === 1) { + cb = args[0]; + } else { + type = args[0]; + cb = args[1]; + } + + let query = { userId: this._id, - }, cb); + }; + + if (type) query.type = type; + + Tasks.Task.find(query, cb); }; // Given user and an array of tasks, return an API compatible user + tasks obj @@ -752,9 +767,9 @@ schema.methods.addTasksToUser = function addTasksToUser (tasks) { // We want to push the task at the same position where it's stored in tasksOrder let pos = tasksOrder[`${task.type}s`].indexOf(task._id); if (pos === -1) { // Should never happen, it means the lists got out of sync - unordered.push(task.toJSON()); + unordered.push(task.toJSONV2()); } else { - obj[`${task.type}s`][pos] = task.toJSON(); + obj[`${task.type}s`][pos] = task.toJSONV2(); } });