Merge pull request #6309 from HabitRPG/api-v3-tasks

[API v3] Port tasks and tags
This commit is contained in:
Matteo Pagliazzi
2015-12-06 17:14:44 +01:00
21 changed files with 1591 additions and 217 deletions
+1 -1
View File
@@ -83,7 +83,7 @@
"max-nested-callbacks": [2, 3],
"new-cap": 2,
"new-parens": 2,
"newline-after-var": 2,
"newline-after-var": 0,
"no-array-constructor": 2,
"no-continue": 2,
"no-lonely-if": 2,
File diff suppressed because one or more lines are too long
+14 -1
View File
@@ -14,5 +14,18 @@
"onlyFbSupported": "Only Facebook supported currently.",
"cantDetachFb": "Account lacks another authentication method, can't detach Facebook.",
"onlySocialAttachLocal": "Local auth can only be added to a social account.",
"invalidReqParams": "Invalid request parameters."
"invalidReqParams": "Invalid request parameters.",
"taskIdRequired": "\"taskId\" must be a valid UUID",
"taskNotFound": "Task not found.",
"invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".",
"cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.",
"checklistOnlyDailyTodo": "Checklists are supported only on dailies and todos",
"checklistItemNotFound": "No checklist item was found with given id.",
"itemIdRequired": "\"itemId\" must be a valid UUID.",
"tagNotFound": "No tag item was found with given id.",
"tagIdRequired": "\"tagId\" must be a valid UUID corresponding to a tag belonging to the user.",
"positionRequired": "\"position\" is required and must be a number.",
"cantMoveCompletedTodo": "Can't move a completed todo.",
"directionUpDown": "\"direction\" is required and must be 'up' or 'down'",
"alreadyTagged": "The task is already tagged with give tag."
}
+1
View File
@@ -4,6 +4,7 @@ import eslint from 'gulp-eslint';
const SERVER_FILES = [
'./website/src/**/api-v3/**/*.js',
'./website/src/models/user.js',
'./website/src/models/task.js',
'./website/src/models/emailUnsubscription.js',
'./website/src/server.js',
];
@@ -0,0 +1,69 @@
import {
generateUser,
requester,
translate as t,
} from '../../../../helpers/api-integration.helper';
describe('DELETE /tasks/:id', () => {
let user, api;
before(() => {
return generateUser().then((generatedUser) => {
user = generatedUser;
api = requester(user);
});
});
context('task can be deleted', () => {
let task;
beforeEach(() => {
return api.post('/tasks', {
text: 'test habit',
type: 'habit',
}).then((createdTask) => {
task = createdTask;
});
});
it('deletes a user\'s task', () => {
return api.del('/tasks/' + task._id)
.then(() => {
return expect(api.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('taskNotFound'),
});
});
});
});
context('task cannot be deleted', () => {
it('cannot delete a non-existant task', () => {
return expect(api.del('/tasks/550e8400-e29b-41d4-a716-446655440000')).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('taskNotFound'),
});
});
it('cannot delete a task owned by someone else', () => {
return generateUser()
.then((user2) => {
return requester(user2).post('/tasks', {
text: 'test habit',
type: 'habit',
})
})
.then((task2) => {
return expect(api.del('/tasks/' + task2._id)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('taskNotFound'),
});
});
});
it('cannot delete active challenge tasks'); // TODO after challenges are implemented
});
});
@@ -0,0 +1,47 @@
import {
generateUser,
requester,
translate as t,
} from '../../../../helpers/api-integration.helper';
import Q from 'q';
describe('GET /tasks', () => {
let user, api;
before(() => {
return generateUser().then((generatedUser) => {
user = generatedUser;
api = requester(user);
});
});
it('returns all user\'s tasks', () => {
let length;
return Q.all([
api.post('/tasks', {text: 'test habit', type: 'habit'}),
])
.then((createdTasks) => {
length = createdTasks.length;
return api.get('/tasks');
})
.then((tasks) => {
expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task
});
});
it('returns only a type of user\'s tasks if req.query.type is specified', () => {
let habitId;
api.post('/tasks', {text: 'test habit', type: 'habit'})
.then((task) => {
habitId = task._id;
return api.get('/tasks?type=habit');
})
.then((tasks) => {
expect(tasks.length).to.equal(1);
expect(tasks[0]._id).to.equal(habitId);
});
});
// TODO complete after task scoring is done
it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified')
});
@@ -0,0 +1,35 @@
import {
generateUser,
requester,
translate as t,
} from '../../../../helpers/api-integration.helper';
describe('GET /tasks/:id', () => {
let user, api;
before(() => {
return generateUser().then((generatedUser) => {
user = generatedUser;
api = requester(user);
});
});
context('task can be accessed', () => {
let task;
beforeEach(() => {
// generate task
// task = generatedTask;
});
it('gets specified task');
it('can get active challenge task that user does not own'); // Yes?
});
context('task cannot accessed', () => {
it('cannot get a non-existant task');
it('cannot get a task owned by someone else');
});
});
@@ -0,0 +1,163 @@
import {
generateUser,
requester,
translate as t,
} from '../../../../helpers/api-integration.helper';
import { v4 as generateRandomUserName } from 'uuid';
import { each } from 'lodash';
describe('POST /tasks', () => {
let user, api;
before(() => {
return generateUser().then((generatedUser) => {
user = generatedUser;
api = requester(user);
});
});
context('validates params', () => {
it('returns an error if req.body.type is absent', () => {
return expect(api.post('/tasks', {
notType: 'habit',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('returns an error if req.body.type is not valid', () => {
return expect(api.post('/tasks', {
type: 'habitF',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('returns an error if req.body.text is absent');
it('ignores setting userId field');
it('automatically sets "task.userId" to user\'s uuid', () => {
return api.post('/tasks', {
text: 'test habit',
type: 'habit',
}).then((task) => {
expect(task.userId).to.equal(user._id);
});
});
it('ignores setting history field');
it('ignores setting createdAt field');
it('ignores setting updatedAt field');
it('ignores setting challenge field');
it('ignores setting completed field');
it('ignores setting streak field');
it('ignores setting dateCompleted field');
it('ignores invalid fields');
});
context('habits', () => {
it('creates a habit', () => {
return api.post('/tasks', {
text: 'test habit',
type: 'habit',
up: false,
down: true,
notes: 1976,
}).then((task) => {
expect(task.userId).to.equal(user._id);
expect(task.text).to.eql('test habit');
expect(task.notes).to.eql('1976');
expect(task.type).to.eql('habit');
expect(task.up).to.eql(false);
expect(task.down).to.eql(true);
});
});
it('defaults to setting up and down to true');
it('cannot create checklists');
});
context('todos', () => {
it('creates a todo', () => {
return api.post('/tasks', {
text: 'test todo',
type: 'todo',
notes: 1976,
}).then((task) => {
expect(task.userId).to.equal(user._id);
expect(task.text).to.eql('test todo');
expect(task.notes).to.eql('1976');
expect(task.type).to.eql('todo');
});
});
it('can create checklists');
});
context('dailys', () => {
it('creates a daily', () => {
let now = new Date();
return api.post('/tasks', {
text: 'test daily',
type: 'daily',
notes: 1976,
frequency: 'daily',
everyX: 5,
startDate: now,
}).then((task) => {
expect(task.userId).to.equal(user._id);
expect(task.text).to.eql('test daily');
expect(task.notes).to.eql('1976');
expect(task.type).to.eql('daily');
expect(task.frequency).to.eql('daily');
expect(task.everyX).to.eql(5);
expect(new Date(task.startDate)).to.eql(now);
});
});
it('defaults to a weekly frequency, with every day set');
it('allows repeat field to be configured');
it('defaults startDate to today');
it('can create checklists');
});
context('rewards', () => {
it('creates a reward', () => {
return api.post('/tasks', {
text: 'test reward',
type: 'reward',
notes: 1976,
value: 10,
}).then((task) => {
expect(task.userId).to.equal(user._id);
expect(task.text).to.eql('test reward');
expect(task.notes).to.eql('1976');
expect(task.type).to.eql('reward');
expect(task.value).to.eql(10);
});
});
it('defaults to a 0 value');
it('requires value to be coerced into a number');
it('cannot create checklists');
});
});
@@ -0,0 +1,121 @@
import {
generateUser,
requester,
translate as t,
} from '../../../../helpers/api-integration.helper';
describe('POST /tasks/score/:id/:direction', () => {
let user, api;
before(() => {
return generateUser().then((generatedUser) => {
user = generatedUser;
api = requester(user);
});
});
context('all', () => {
it('requires a task id');
it('requires a task direction');
});
context('todos', () => {
let todo;
beforeEach(() => {
// todo = createdTodo
});
it('completes todo when direction is up');
it('uncompletes todo when direction is down');
it('scores up todo even if it is already completed'); // Yes?
it('scores down todo even if it is already uncompleted'); // Yes?
it('increases user\'s mp when direction is up');
it('decreases user\'s mp when direction is down');
it('increases user\'s exp when direction is up');
it('decreases user\'s exp when direction is down');
it('increases user\'s gold when direction is up');
it('decreases user\'s gold when direction is down');
});
context('dailys', () => {
let daily;
beforeEach(() => {
// daily = createdDaily
});
it('completes daily when direction is up');
it('uncompletes daily when direction is down');
it('scores up daily even if it is already completed'); // Yes?
it('scores down daily even if it is already uncompleted'); // Yes?
it('increases user\'s mp when direction is up');
it('decreases user\'s mp when direction is down');
it('increases user\'s exp when direction is up');
it('decreases user\'s exp when direction is down');
it('increases user\'s gold when direction is up');
it('decreases user\'s gold when direction is down');
});
context('habits', () => {
let habit, minusHabit, plusHabit, neitherHabit;
beforeEach(() => {
// habit = createdHabit
// plusHabit = createdPlusHabit
// minusHabit = createdMinusHabit
// neitherHabit = createdNeitherHabit
});
it('prevents plus only habit from scoring down'); // Yes?
it('prevents minus only habit from scoring up'); // Yes?
it('increases user\'s mp when direction is up');
it('decreases user\'s mp when direction is down');
it('increases user\'s exp when direction is up');
it('decreases user\'s exp when direction is down');
it('increases user\'s gold when direction is up');
it('decreases user\'s gold when direction is down');
});
context('reward', () => {
let reward;
beforeEach(() => {
// reward = createdReward
});
it('purchases reward');
it('does not change user\'s mp');
it('does not change user\'s exp');
it('does not allow a down direction');
});
});
@@ -0,0 +1,168 @@
import {
generateUser,
requester,
translate as t,
} from '../../../../helpers/api-integration.helper';
describe('PUT /tasks/:id', () => {
let user, api;
before(() => {
return generateUser().then((generatedUser) => {
user = generatedUser;
api = requester(user);
});
});
context('validates params', () => {
let task;
beforeEach(() => {
// create sample task
// task = createdTask
});
it('ignores setting type field');
it('ignores setting userId field');
it('ignores setting history field');
it('ignores setting createdAt field');
it('ignores setting updatedAt field');
it('ignores setting challenge field');
it('ignores setting value field');
it('ignores setting completed field');
it('ignores setting streak field');
it('ignores setting dateCompleted field');
it('ignores invalid fields');
});
context('habits', () => {
let habit;
beforeEach(() => {
return api.post('/tasks', {
text: 'test habit',
type: 'habit',
notes: 1976,
}).then((createdHabit) => {
habit = createdHabit;
});
});
it('updates a habit', () => {
return api.put(`/tasks/${habit._id}`, {
text: 'some new text',
up: false,
down: false,
notes: 'some new notes',
}).then((task) => {
expect(task.text).to.eql('some new text');
expect(task.notes).to.eql('some new notes');
expect(task.up).to.eql(false);
expect(task.down).to.eql(false);
});
});
});
context('todos', () => {
let todo;
beforeEach(() => {
return api.post('/tasks', {
text: 'test todo',
type: 'todo',
notes: 1976,
}).then((createdTodo) => {
todo = createdTodo;
});
});
it('updates a todo', () => {
return api.put(`/tasks/${todo._id}`, {
text: 'some new text',
notes: 'some new notes',
}).then((task) => {
expect(task.text).to.eql('some new text');
expect(task.notes).to.eql('some new notes');
});
});
it('can update checklists'); // Can it?
});
context('dailys', () => {
let daily;
beforeEach(() => {
return api.post('/tasks', {
text: 'test daily',
type: 'daily',
notes: 1976,
}).then((createdDaily) => {
daily = createdDaily;
});
});
it('updates a daily', () => {
let now = new Date();
return api.put(`/tasks/${daily._id}`, {
text: 'some new text',
notes: 'some new notes',
frequency: 'daily',
everyX: 5,
}).then((task) => {
expect(task.text).to.eql('some new text');
expect(task.notes).to.eql('some new notes');
expect(task.frequency).to.eql('daily');
expect(task.everyX).to.eql(5);
});
});
it('can update checklists'); // Can it?
it('updates repeat, even if frequency is set to daily');
it('updates everyX, even if frequency is set to weekly');
it('defaults startDate to today if none date object is passed in');
});
context('rewards', () => {
let reward;
beforeEach(() => {
return api.post('/tasks', {
text: 'test reward',
type: 'reward',
notes: 1976,
value: 10,
}).then((createdReward) => {
reward = createdReward;
});
});
it('updates a reward', () => {
return api.put(`/tasks/${reward._id}`, {
text: 'some new text',
notes: 'some new notes',
value: 10,
}).then((task) => {
expect(task.text).to.eql('some new text');
expect(task.notes).to.eql('some new notes');
expect(task.value).to.eql(10);
});
});
it('requires value to be coerced into a number');
});
});
@@ -194,10 +194,10 @@ describe('POST /user/auth/local/register', () => {
password,
confirmPassword: password,
}).then((user) => {
expect(user.todos).to.not.be.empty;
expect(user.dailys).to.be.empty;
expect(user.habits).to.be.empty;
expect(user.rewards).to.be.empty;
expect(user.tasksOrder.todos).to.not.be.empty;
expect(user.tasksOrder.dailys).to.be.empty;
expect(user.tasksOrder.habits).to.be.empty;
expect(user.tasksOrder.rewards).to.be.empty;
});
});
@@ -245,10 +245,10 @@ describe('POST /user/auth/local/register', () => {
password,
confirmPassword: password,
}).then((user) => {
expect(user.todos).to.not.be.empty;
expect(user.dailys).to.be.empty;
expect(user.habits).to.not.be.empty;
expect(user.rewards).to.not.be.empty;
expect(user.tasksOrder.todos).to.not.be.empty;
expect(user.tasksOrder.dailys).to.be.empty;
expect(user.tasksOrder.habits).to.not.be.empty;
expect(user.tasksOrder.rewards).to.not.be.empty;
});
});
+14
View File
@@ -43,6 +43,20 @@ describe('Base model plugin', () => {
expect(sanitized.noUpdateForMe).to.equal(undefined);
});
it('accepts an array of additional fields to sanitize at runtime', () => {
baseModel(schema, {
noSet: ['noUpdateForMe']
});
expect(schema.statics.sanitize).to.exist;
let sanitized = schema.statics.sanitize({ok: true, noUpdateForMe: true, usuallySettable: true}, ['usuallySettable']);
expect(sanitized).to.have.property('ok');
expect(sanitized).not.to.have.property('noUpdateForMe');
expect(sanitized).not.to.have.property('usuallySettable');
});
it('can make fields private', () => {
baseModel(schema, {
private: ['amPrivate']
@@ -44,7 +44,6 @@ api.registerLocal = {
});
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
let { email, username, password } = req.body;
@@ -152,7 +151,6 @@ api.loginLocal = {
});
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
req.sanitizeBody('username').trim();
@@ -244,7 +242,7 @@ api.loginSocial = {
* @apiName UserDeleteSocial
* @apiGroup User
*
* @apiSuccess {Boolean=true} success Always true
* @apiSuccess {Object} response Empty object
*/
api.deleteSocial = {
method: 'DELETE',
+151
View File
@@ -0,0 +1,151 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import { model as Tag } from '../../models/tag';
import {
NotFound,
} from '../../libs/api-v3/errors';
import _ from 'lodash';
let api = {};
/**
* @api {post} /tags Create a new tag
* @apiVersion 3.0.0
* @apiName CreateTag
* @apiGroup Tag
*
* @apiSuccess {Object} tag The newly created tag
*/
api.createTag = {
method: 'POST',
url: '/tags',
middlewares: [authWithHeaders()],
handler (req, res, next) {
let user = res.locals.user;
user.tags.push(Tag.sanitize(req.body));
user.save()
.then((savedUser) => {
let l = savedUser.tags.length;
let tag = savedUser.tags[l - 1];
res.respond(201, tag);
})
.catch(next);
},
};
/**
* @api {get} /tag Get an user's tags
* @apiVersion 3.0.0
* @apiName GetTags
* @apiGroup Tag
*
* @apiSuccess {Array} tags An array of tag objects
*/
api.getTags = {
method: 'GET',
url: '/tags',
middlewares: [authWithHeaders()],
handler (req, res) {
let user = res.locals.user;
res.respond(200, user.tags);
},
};
/**
* @api {get} /tags/:tagId Get a tag given its id
* @apiVersion 3.0.0
* @apiName GetTag
* @apiGroup Tag
*
* @apiParam {UUID} tagId The tag _id
*
* @apiSuccess {object} tag The tag object
*/
api.getTag = {
method: 'GET',
url: '/tags/:tagId',
middlewares: [authWithHeaders()],
handler (req, res, next) {
let user = res.locals.user;
req.checkParams('taskId', res.t('tagIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
let tag = user.tags.id(req.params.tagId);
if (!tag) return next(new NotFound(res.t('tagNotFound')));
res.respond(200, tag);
},
};
/**
* @api {put} /tag/:tagId Update a tag
* @apiVersion 3.0.0
* @apiName UpdateTag
* @apiGroup Tag
*
* @apiParam {UUID} tagId The tag _id
*
* @apiSuccess {object} tag The updated tag
*/
api.updateTag = {
method: 'PUT',
url: '/tags/:tagId',
middlewares: [authWithHeaders()],
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 validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
let tag = user.tags.id(tagId);
if (!tag) return next(new NotFound(res.t('tagNotFound')));
_.merge(tag, Tag.sanitize(req.body));
user.save()
.then((savedUser) => res.respond(200, savedUser.tags.id(tagId)))
.catch(next);
},
};
/**
* @api {delete} /tag/:tagId Delete a user tag given its id
* @apiVersion 3.0.0
* @apiName DeleteTag
* @apiGroup Tag
*
* @apiParam {UUID} tagId The tag _id
*
* @apiSuccess {object} empty An empty object
*/
api.deleteTag = {
method: 'GET',
url: '/tags/:tagId',
middlewares: [authWithHeaders()],
handler (req, res, next) {
let user = res.locals.user;
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
let tag = user.tags.id(req.params.tagId);
if (!tag) return next(new NotFound(res.t('tagNotFound')));
tag.remove();
user.save()
.then(() => res.respond(200, {}))
.catch(next);
},
};
export default api;
+590
View File
@@ -0,0 +1,590 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import * as Tasks from '../../models/task';
import {
NotFound,
NotAuthorized,
BadRequest,
} from '../../libs/api-v3/errors';
import Q from 'q';
import _ from 'lodash';
let api = {};
/**
* @api {post} /tasks Create a new task
* @apiVersion 3.0.0
* @apiName CreateTask
* @apiGroup Task
*
* @apiSuccess {Object} task The newly created task
*/
// TODO should allow to create multiple tasks at once
// TODO gives problems when creating tasks concurrently because of how mongoose treats arrays (VersionErrors - treated as 500s)
api.createTask = {
method: 'POST',
url: '/tasks',
middlewares: [authWithHeaders()],
handler (req, res, next) {
req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes);
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
let user = res.locals.user;
let taskType = req.body.type;
let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(req.body));
newTask.userId = user._id;
user.tasksOrder[`${taskType}s`].unshift(newTask._id);
Q.all([
newTask.save(),
user.save(),
])
.then((results) => res.respond(201, results[0]))
.catch(next);
},
};
/**
* @api {get} /tasks Get an user's tasks
* @apiVersion 3.0.0
* @apiName GetTasks
* @apiGroup Task
*
* @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks
* @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo"
*
* @apiSuccess {Array} tasks An array of task objects
*/
api.getTasks = {
method: 'GET',
url: '/tasks',
middlewares: [authWithHeaders()],
handler (req, res, next) {
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes);
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
let user = res.locals.user;
let query = {userId: user._id};
let type = req.query.type;
if (type) {
query.type = type;
if (type === 'todo') query.completed = false; // Exclude completed todos
} else {
query.$or = [ // Exclude completed todos
{type: 'todo', completed: false},
{type: {$in: ['habit', 'daily', 'reward']}},
];
}
if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) {
let queryCompleted = Tasks.Task.find({
type: 'todo',
completed: true,
}).limit(30).sort({ // TODO add ability to pick more than 30 completed todos
dateCompleted: 1,
});
Q.all([
queryCompleted.exec(),
Tasks.Task.find(query).exec(),
])
.then((results) => res.respond(200, results[1].concat(results[0])))
.catch(next);
} else {
Tasks.Task.find(query).exec()
.then((tasks) => res.respond(200, tasks))
.catch(next);
}
},
};
/**
* @api {get} /task/:taskId Get a task given its id
* @apiVersion 3.0.0
* @apiName GetTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
*
* @apiSuccess {object} task The task object
*/
api.getTask = {
method: 'GET',
url: '/tasks/:taskId',
middlewares: [authWithHeaders()],
handler (req, res, next) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec()
.then((task) => {
if (!task) throw new NotFound(res.t('taskNotFound'));
res.respond(200, task);
})
.catch(next);
},
};
/**
* @api {put} /task/:taskId Update a task
* @apiVersion 3.0.0
* @apiName UpdateTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
*
* @apiSuccess {object} task The updated task
*/
api.updateTask = {
method: 'PUT',
url: '/tasks/:taskId',
middlewares: [authWithHeaders()],
handler (req, res, next) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
// TODO check that req.body isn't empty
// TODO make sure tags are updated correctly (they aren't set as modified!) maybe use specific routes
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec()
.then((task) => {
if (!task) throw new NotFound(res.t('taskNotFound'));
// If checklist is updated -> replace the original one
if (req.body.checklist) {
delete req.body.checklist;
task.checklist = 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));
return task.save();
})
.then((savedTask) => res.respond(200, savedTask))
.catch(next);
},
};
/**
* @api {put} /tasks/score/:taskId/: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} empty An empty object
*/
api.scoreTask = {
method: 'POST',
url: 'tasks/score/:taskId/:direction',
middlewares: [authWithHeaders()],
handler (req, res, next) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']);
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
let user = res.locals.user;
Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec()
.then((task) => {
if (!task) throw new NotFound(res.t('taskNotFound'));
})
.then(() => res.respond(200, {})) // TODO what to return
.catch(next);
},
};
// completed todos cannot be moved, they'll be returned ordered by date of completion
// TODO check that it works when a tag is selected or todos are split between dated and due
/**
* @api {post} /tasks/move/:taskId/to/:position Move a task to a new position
* @apiVersion 3.0.0
* @apiName MoveTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {Number} position Where to move the task (-1 means push to bottom)
*
* @apiSuccess {object} empty An empty object
*/
api.moveTask = {
method: 'POST',
url: '/tasks/move/:taskId/to/:position',
middlewares: [authWithHeaders()],
handler (req, res, next) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric();
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
let user = res.locals.user;
let to = Number(req.params.position);
Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec()
.then((task) => {
if (!task) throw new NotFound(res.t('taskNotFound'));
if (task.type === 'todo' && task.completed) throw new NotFound(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)
// or if the task is moved to a non existing position
// or if the task is moved to postion -1 (push to bottom)
// -> push task at end of list
if (currentIndex === -1 || !order[to] || to === -1) {
order.push(task._id);
} else {
let taskToMove = order.splice(currentIndex, 1)[0];
order.splice(to, 0, taskToMove);
}
return user.save();
})
.then(() => res.respond(200, {})) // TODO what to return
.catch(next);
},
};
/**
* @api {post} /tasks/:taskId/checklist Add an item to a checklist, creating the checklist if it doesn't exist
* @apiVersion 3.0.0
* @apiName AddChecklistItem
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
*
* @apiSuccess {object} task The updated task
*/
api.addChecklistItem = {
method: 'POST',
url: '/tasks/:taskId/checklist',
middlewares: [authWithHeaders()],
handler (req, res, next) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
// TODO check that req.body isn't empty and is an array
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec()
.then((task) => {
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);
return task.save();
})
.then((savedTask) => res.respond(200, savedTask)) // TODO what to return
.catch(next);
},
};
/**
* @api {post} /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} task The updated task
*/
api.scoreCheckListItem = {
method: 'POST',
url: '/tasks/:taskId/checklist/:itemId/score',
middlewares: [authWithHeaders()],
handler (req, res, next) {
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) return next(validationErrors);
Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec()
.then((task) => {
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;
return task.save();
})
.then((savedTask) => res.respond(200, savedTask)) // TODO what to return
.catch(next);
},
};
/**
* @api {put} /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} task The updated task
*/
api.updateChecklistItem = {
method: 'PUT',
url: '/tasks/:taskId/checklist/:itemId',
middlewares: [authWithHeaders()],
handler (req, res, next) {
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) return next(validationErrors);
Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec()
.then((task) => {
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'));
delete req.body.id; // Simple sanitization to prevent the ID to be changed
_.merge(item, req.body);
return task.save();
})
.then((savedTask) => res.respond(200, savedTask)) // TODO what to return
.catch(next);
},
};
/**
* @api {delete} /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} empty An empty object
*/
api.removeChecklistItem = {
method: 'DELETE',
url: '/tasks/:taskId/checklist/:itemId',
middlewares: [authWithHeaders()],
handler (req, res, next) {
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) return next(validationErrors);
Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec()
.then((task) => {
if (!task) throw new NotFound(res.t('taskNotFound'));
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
let itemI = _.findIndex(task.checklist, {_id: req.params.itemId});
if (itemI === -1) throw new NotFound(res.t('checklistItemNotFound'));
task.checklist.splice(itemI, 1);
return task.save();
})
.then(() => res.respond(200, {})) // TODO what to return
.catch(next);
},
};
/**
* @api {post} /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} task The updated task
*/
api.addTagToTask = {
method: 'POST',
url: '/tasks/:taskId/tags',
middlewares: [authWithHeaders()],
handler (req, res, next) {
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) return next(validationErrors);
Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec()
.then((task) => {
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);
return task.save();
})
.then((savedTask) => res.respond(200, savedTask)) // TODO what to return
.catch(next);
},
};
/**
* @api {delete} /tasks/:taskId/tags/:tagId Remove a tag
* @apiVersion 3.0.0
* @apiName RemoveTagFromTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
* @apiParam {UUID} tagId The tag id
*
* @apiSuccess {object} empty An empty object
*/
api.removeTagFromTask = {
method: 'DELETE',
url: '/tasks/:taskId/tags/:tagId',
middlewares: [authWithHeaders()],
handler (req, res, next) {
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) return next(validationErrors);
Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec()
.then((task) => {
if (!task) throw new NotFound(res.t('taskNotFound'));
let tagI = _.findIndex(task.tags, {_id: req.params.tagId});
if (tagI === -1) throw new NotFound(res.t('tagNotFound'));
task.tags.splice(tagI, 1);
return task.save();
})
.then(() => res.respond(200, {})) // TODO what to return
.catch(next);
},
};
// Remove a task from user.tasksOrder
function _removeTaskTasksOrder (user, taskId) {
// Loop through all lists and when the task is found, remove it and return
for (let i = 0; i < Tasks.tasksTypes.length; i++) {
let list = user.tasksOrder[`${Tasks.tasksTypes[i]}s`];
let index = list.indexOf(taskId);
if (index !== -1) {
list.splice(index, 1);
break;
}
}
return;
}
/**
* @api {delete} /task/:taskId Delete a user task given its id
* @apiVersion 3.0.0
* @apiName DeleteTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
*
* @apiSuccess {object} empty An empty object
*/
api.deleteTask = {
method: 'DELETE',
url: '/tasks/:taskId',
middlewares: [authWithHeaders()],
handler (req, res, next) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) return next(validationErrors);
Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
}).exec()
.then((task) => {
if (!task) throw new NotFound(res.t('taskNotFound'));
if (task.challenge.id) throw new NotAuthorized(res.t('cantDeleteChallengeTasks'));
_removeTaskTasksOrder(user, req.params.taskId);
return Q.all([
user.save(),
task.remove(),
]);
})
.then(() => res.respond(200, {}))
.catch(next);
},
};
export default api;
+3 -2
View File
@@ -35,8 +35,9 @@ export default function baseModel (schema, options = {}) {
let privateFields = ['__v'];
if (Array.isArray(options.noSet)) noSetFields.push(...options.noSet);
schema.statics.sanitize = function sanitize (objToSanitize = {}) {
noSetFields.forEach((fieldPath) => {
// This method accepts an additional array of fields to be sanitized that can be passed at runtime
schema.statics.sanitize = function sanitize (objToSanitize = {}, additionalFields = []) {
noSetFields.concat(additionalFields).forEach((fieldPath) => {
objectPath.del(objToSanitize, fieldPath);
});
+3 -2
View File
@@ -11,8 +11,8 @@ import {
// If optional is true, don't error on missing authentication
export function authWithHeaders (optional = false) {
return function authWithHeadersHandler (req, res, next) {
let userId = req.header['x-api-user'];
let apiToken = req.header['x-api-key'];
let userId = req.header('x-api-user');
let apiToken = req.header('x-api-key');
if (!userId || !apiToken) {
if (optional) return next();
@@ -30,6 +30,7 @@ export function authWithHeaders (optional = false) {
res.locals.user = user;
// TODO use either session/cookie or headers, not both
req.session = req.session || {};
req.session.userId = user._id;
next();
})
+4 -4
View File
@@ -10,10 +10,10 @@ var ChallengeSchema = new Schema({
shortName: String,
description: String,
official: {type: Boolean,'default':false},
habits: [TaskSchemas.HabitSchema],
dailys: [TaskSchemas.DailySchema],
todos: [TaskSchemas.TodoSchema],
rewards: [TaskSchemas.RewardSchema],
//habits: [TaskSchemas.HabitSchema],
//dailys: [TaskSchemas.DailySchema],
//todos: [TaskSchemas.TodoSchema],
//rewards: [TaskSchemas.RewardSchema],
leader: {type: String, ref: 'User'},
group: {type: String, ref: 'Group'},
timestamp: {type: Date, 'default': Date.now},
+18
View File
@@ -0,0 +1,18 @@
import mongoose from 'mongoose';
import baseModel from '../libs/api-v3/baseModel';
let Schema = mongoose.Schema;
export let schema = new Schema({
name: {type: String, required: true},
challenge: {type: String}, // TODO validate
}, {
minimize: true, // So empty objects are returned
strict: true,
});
schema.plugin(baseModel, {
noSet: ['_id', 'challenge'],
});
export let model = mongoose.model('Tag', schema);
+107 -97
View File
@@ -1,108 +1,118 @@
// User.js
// =======
// Defines the user data model (schema) for use via the API.
import mongoose from 'mongoose';
import shared from '../../../common';
import validator from 'validator';
import moment from 'moment';
import baseModel from '../libs/api-v3/baseModel';
import _ from 'lodash';
// Dependencies
// ------------
var mongoose = require("mongoose");
var Schema = mongoose.Schema;
var shared = require('../../../common');
var _ = require('lodash');
var moment = require('moment');
let Schema = mongoose.Schema;
let discriminatorOptions = {
discriminatorKey: 'type', // the key that distinguishes task types
};
let subDiscriminatorOptions = _.defaults(_.cloneDeep(discriminatorOptions), {_id: false});
// Task Schema
// -----------
export let tasksTypes = ['habit', 'daily', 'todo', 'reward'];
export let TaskSchema = new Schema({
type: {type: String, enum: tasksTypes, required: true, default: tasksTypes[0]},
text: {type: String, required: true},
notes: {type: String, default: ''},
tags: [{
type: String,
validate: [validator.isUUID, 'Invalid uuid.'],
}],
value: {type: Number, default: 0}, // redness or cost for rewards
priority: {type: Number, default: 1, required: true}, // TODO enum?
attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']},
userId: {type: String, ref: 'User'}, // When null it belongs to a challenge
var TaskSchema = {
//_id:{type: String,'default': helpers.uuid},
id: {type: String,'default': shared.uuid},
dateCreated: {type:Date, 'default':Date.now},
text: String,
notes: {type: String, 'default': ''},
tags: {type: Schema.Types.Mixed, 'default': {}}, //{ "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true },
value: {type: Number, 'default': 0}, // redness
priority: {type: Number, 'default': '1'},
attribute: {type: String, 'default': "str", enum: ['str','con','int','per']},
challenge: {
id: {type: 'String', ref:'Challenge'},
broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED
winner: String // user.profile.name
// group: {type: 'Strign', ref: 'Group'} // if we restore this, rename `id` above to `challenge`
}
id: {type: String, ref: 'Challenge'},
taskId: {type: String, ref: 'Task'}, // When null but challenge.id defined it's the original task
broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED TODO enum
winner: String, // user.profile.name TODO necessary?
},
}, _.defaults({
minimize: true, // So empty objects are returned
strict: true,
}, discriminatorOptions));
TaskSchema.plugin(baseModel, {
// TODO checklist fields editable?
// TODO value should be settable only for rewards
noSet: ['challenge', 'userId', 'completed', 'history', 'streak', 'dateCompleted'],
private: [],
timestamps: true,
});
// A list of additional fields that cannot be set on creation (but can be set on updare)
let noCreate = ['completed'];
TaskSchema.statics.sanitizeCreate = function sanitizeCreate (createObj) {
return Task.sanitize(createObj, noCreate); // eslint-disable-line no-use-before-define
};
var HabitSchema = new Schema(
_.defaults({
type: {type:String, 'default': 'habit'},
history: Array, // [{date:Date, value:Number}], // this causes major performance problems
up: {type: Boolean, 'default': true},
down: {type: Boolean, 'default': true}
}, TaskSchema)
, { _id: false, minimize:false }
);
// 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
TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define
};
var collapseChecklist = {type:Boolean, 'default':false};
var checklist = [{
completed:{type:Boolean,'default':false},
text: String,
_id:false,
id: {type:String,'default':shared.uuid}
}];
export let Task = mongoose.model('Task', TaskSchema);
var DailySchema = new Schema(
_.defaults({
type: {type: String, 'default': 'daily'},
frequency: {type: String, 'default': 'weekly', enum: ['daily', 'weekly']},
everyX: {type: Number, 'default': 1}, // e.g. once every X weeks
startDate: {type: Date, 'default': moment().startOf('day').toDate()},
history: Array,
completed: {type: Boolean, 'default': false},
repeat: { // used only for 'weekly' frequency,
m: {type: Boolean, 'default': true},
t: {type: Boolean, 'default': true},
w: {type: Boolean, 'default': true},
th: {type: Boolean, 'default': true},
f: {type: Boolean, 'default': true},
s: {type: Boolean, 'default': true},
su: {type: Boolean, 'default': true}
// habits and dailies shared fields
let habitDailySchema = () => {
return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems TODO revisit
};
// dailys and todos shared fields
let dailyTodoSchema = () => {
return {
completed: {type: Boolean, default: false},
// Checklist fields (dailies and todos)
collapseChecklist: {type: Boolean, default: false},
checklist: [{
completed: {type: Boolean, default: false},
text: {type: String, required: true},
_id: {type: String, default: shared.uuid, validate: [validator.isUUID, 'Invalid uuid.']},
}],
};
};
export let HabitSchema = new Schema(_.defaults({
up: {type: Boolean, default: true},
down: {type: Boolean, default: true},
}, habitDailySchema()), subDiscriminatorOptions);
export let habit = Task.discriminator('habit', HabitSchema);
export let DailySchema = new Schema(_.defaults({
frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly']},
everyX: {type: Number, default: 1}, // e.g. once every X weeks
startDate: {
type: Date,
default () {
return moment().startOf('day').toDate();
},
collapseChecklist:collapseChecklist,
checklist:checklist,
streak: {type: Number, 'default': 0}
}, TaskSchema)
, { _id: false, minimize:false }
)
},
repeat: { // used only for 'weekly' frequency,
m: {type: Boolean, default: true},
t: {type: Boolean, default: true},
w: {type: Boolean, default: true},
th: {type: Boolean, default: true},
f: {type: Boolean, default: true},
s: {type: Boolean, default: true},
su: {type: Boolean, default: true},
},
streak: {type: Number, default: 0},
}, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions);
export let daily = Task.discriminator('daily', DailySchema);
var TodoSchema = new Schema(
_.defaults({
type: {type:String, 'default': 'todo'},
completed: {type: Boolean, 'default': false},
dateCompleted: Date,
date: String, // due date for todos // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date
collapseChecklist:collapseChecklist,
checklist:checklist
}, TaskSchema)
, { _id: false, minimize:false }
);
export let TodoSchema = new Schema(_.defaults({
dateCompleted: Date,
// FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date
// TODO change field name
date: String, // due date for todos
}, dailyTodoSchema()), subDiscriminatorOptions);
export let todo = Task.discriminator('todo', TodoSchema);
var RewardSchema = new Schema(
_.defaults({
type: {type:String, 'default': 'reward'}
}, TaskSchema)
, { _id: false, minimize:false }
);
/**
* Workaround for bug when _id & id were out of sync, we can remove this after challenges has been running for a while
*/
//_.each([HabitSchema, DailySchema, TodoSchema, RewardSchema], function(schema){
// schema.post('init', function(doc){
// if (!doc.id && doc._id) doc.id = doc._id;
// })
//})
module.exports.TaskSchema = TaskSchema;
module.exports.HabitSchema = HabitSchema;
module.exports.DailySchema = DailySchema;
module.exports.TodoSchema = TodoSchema;
module.exports.RewardSchema = RewardSchema;
export let RewardSchema = new Schema({}, subDiscriminatorOptions);
export let reward = Task.discriminator('reward', RewardSchema);
+72 -98
View File
@@ -1,10 +1,11 @@
// User schema and model
import mongoose from 'mongoose';
import shared from '../../../common';
import _ from 'lodash';
import validator from 'validator';
import moment from 'moment';
import TaskSchemas from './task';
import * as Tasks from './task';
import Q from 'q';
import { schema as TagSchema } from './tag';
import baseModel from '../libs/api-v3/baseModel';
// import {model as Challenge} from './challenge';
@@ -41,11 +42,9 @@ export let schema = new Schema({
loggedin: {type: Date, default: Date.now},
},
},
// 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 },
achievements: {
originalUser: Boolean,
habitSurveys: Number,
@@ -440,13 +439,7 @@ export let schema = new Schema({
},
},
tags: {type: [{
_id: false,
id: {type: String, default: shared.uuid},
name: String,
challenge: String,
}]},
tags: [TagSchema],
challenges: [{type: String, ref: 'Challenge'}],
inbox: {
@@ -455,14 +448,14 @@ export let schema = new Schema({
messages: {type: Schema.Types.Mixed, default: {}},
optOut: {type: Boolean, default: false},
},
habits: {type: [TaskSchemas.HabitSchema]},
dailys: {type: [TaskSchemas.DailySchema]},
todos: {type: [TaskSchemas.TodoSchema]},
rewards: {type: [TaskSchemas.RewardSchema]},
tasksOrder: {
habits: [{type: String, ref: 'Task'}],
dailys: [{type: String, ref: 'Task'}],
todos: [{type: String, ref: 'Task'}],
completedTodos: [{type: String, ref: 'Task'}],
rewards: [{type: String, ref: 'Task'}],
},
extra: Schema.Types.Mixed,
pushDevices: {
type: [{
regId: {type: String},
@@ -476,11 +469,9 @@ export let schema = new Schema({
});
schema.plugin(baseModel, {
noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt'],
noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags'],
private: ['auth.local.hashed_password', 'auth.local.salt'],
toJSONTransform: function toJSON (doc) {
doc.id = doc._id;
// FIXME? Is this a reference to `doc.filters` or just disabled code? Remove?
doc.filters = {};
doc._tmp = this._tmp; // be sure to send down drop notifs
@@ -489,101 +480,80 @@ schema.plugin(baseModel, {
},
});
schema.methods.deleteTask = function deleteTask (tid) {
this.ops.deleteTask({params: {id: tid}}, () => {}); // TODO remove this whole method, since it just proxies, and change all references to this method
};
// schema.virtual('tasks').get(function () {
// var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards);
// var tasks = _.object(_.pluck(tasks,'id'), tasks);
// return tasks;
// });
schema.post('init', function postInitUser (doc) {
shared.wrap(doc);
});
function _populateDefaultTasks (user, taskTypes) {
let tagsI = taskTypes.indexOf('tag');
if (tagsI !== -1) {
user.tags = _.map(shared.content.userDefaults.tags, (tag) => {
let newTag = _.cloneDeep(tag);
// tasks automatically get _id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here
newTag.id = shared.uuid();
// Render tag's name in user's language
newTag.name = newTag.name(user.preferences.language);
return newTag;
});
}
let tasksToCreate = [];
if (tagsI !== -1) {
taskTypes = _.clone(taskTypes);
taskTypes.splice(tagsI, 1);
}
_.each(taskTypes, (taskType) => {
user[taskType] = _.map(shared.content.userDefaults[taskType], (task) => {
let newTask = _.cloneDeep(task);
let tasksOfType = _.map(shared.content.userDefaults[`${taskType}s`], (taskDefaults) => {
let newTask = new Tasks[taskType](taskDefaults);
// Render task's text and notes in user's language
if (taskType === 'tags') {
// tasks automatically get id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here
newTask.id = shared.uuid();
newTask.name = newTask.name(user.preferences.language);
} else {
newTask.text = newTask.text(user.preferences.language);
if (newTask.notes) {
newTask.notes = newTask.notes(user.preferences.language);
}
if (newTask.checklist) {
newTask.checklist = _.map(newTask.checklist, (checklistItem) => {
checklistItem.text = checklistItem.text(user.preferences.language);
return checklistItem;
});
}
newTask.userId = user._id;
newTask.text = taskDefaults.text(user.preferences.language);
if (newTask.notes) newTask.notes = taskDefaults.notes(user.preferences.language);
if (taskDefaults.checklist) {
newTask.checklist = _.map(taskDefaults.checklist, (checklistItem) => {
checklistItem.text = checklistItem.text(user.preferences.language);
return checklistItem;
});
}
return newTask;
return newTask.save();
});
tasksToCreate.push(...tasksOfType); // TODO find better way since this creates each task individually
});
return Q.all(tasksToCreate)
.then((tasksCreated) => {
_.each(tasksCreated, (task) => {
user.tasksOrder[`${task.type}s`].push(task._id);
});
});
}
function _populateDefaultsForNewUser (user) {
let taskTypes;
let iterableFlags = user.flags.toObject();
if (user.registeredThrough === 'habitica-web') {
taskTypes = ['habits', 'dailys', 'todos', 'rewards', 'tags'];
taskTypes = ['habit', 'daily', 'todo', 'reward', 'tag'];
let tutorialCommonSections = [
'habits',
'dailies',
'todos',
'rewards',
'party',
'pets',
'gems',
'skills',
'classes',
'tavern',
'equipment',
'items',
'inviteParty',
];
_.each(tutorialCommonSections, (section) => {
_.each(iterableFlags.tutorial.common, (val, section) => {
user.flags.tutorial.common[section] = true;
});
} else {
taskTypes = ['todos', 'tags'];
taskTypes = ['todo', 'tag'];
user.flags.showTour = false;
let tourSections = [
'showTour',
'intro',
'classes',
'stats',
'tavern',
'party',
'guilds',
'challenges',
'market',
'pets',
'mounts',
'hall',
'equipment',
];
_.each(tourSections, (section) => {
_.each(iterableFlags.tour, (val, section) => {
user.flags.tour[section] = -2;
});
}
_populateDefaultTasks(user, taskTypes);
return _populateDefaultTasks(user, taskTypes);
}
function _setProfileName (user) {
@@ -596,13 +566,10 @@ function _setProfileName (user) {
return localUsername || facebookUsername || anonymous;
}
schema.pre('save', function postSaveUser (next) {
// Populate new users with default content
if (this.isNew) {
_populateDefaultsForNewUser(this);
}
schema.pre('save', true, function preSaveUser (next, done) {
next();
// this.markModified('tasks');
// TODO remove all unnecessary checks
if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) {
this.preferences.dayStart = 0;
}
@@ -651,7 +618,14 @@ schema.pre('save', function postSaveUser (next) {
if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0;
this._v++;
next();
// Populate new users with default content
if (this.isNew) {
_populateDefaultsForNewUser(this)
.then(() => done())
.catch(done);
} else {
done();
}
});
schema.methods.unlink = function unlink (options, cb) {
@@ -668,7 +642,7 @@ schema.methods.unlink = function unlink (options, cb) {
if (keep === 'keep') {
self.tasks[tid].challenge = {};
} else if (keep === 'remove') {
self.deleteTask(tid);
self.ops.deleteTask({params: {id: tid}}, () => {});
} else if (keep === 'keep-all') {
_.each(self.tasks, (t) => {
if (t.challenge && t.challenge.id === cid) {
@@ -678,7 +652,7 @@ schema.methods.unlink = function unlink (options, cb) {
} else if (keep === 'remove-all') {
_.each(self.tasks, (t) => {
if (t.challenge && t.challenge.id === cid) {
self.deleteTask(t.id);
this.ops.deleteTask({params: {id: tid}}, () => {});
}
});
}