Add API Call to bulk score tasks (#11389)

* Add new API call to complete multiple task scorings in one call

* Improve API response

* Improve saving process

* Improve handling for multiple tasks scored at once

* Handle challenge task errors better

* Improve check for alias

* Improve check for task scorings

* Fix merge errors

* make nodemon ignore content_cache

* Fix completing group tasks

* fix test

* fix tests (again)

* typo

* WIP(a11y): task modal updates

* fix(tasks): borders in modal

* fix(tasks): circley locks

* fix(task-modal): placeholders

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

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

* fix(task-modal): button states?

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

* fix(tasks): more stylin

* fix(tasks): habit hovers

* fix(css): checklist labels, a11y colors

* fix(css): one more missed hover issue

* fix(css): lock Challenges, label fixes

* fix(css): scope input/textarea changes

* fix(style): task tweakies

* fix(style): more button fixage

* WIP(component): start select list story

* working example of a templated selectList

* fix(style): more button corrections

* fix(lint): EOL

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

* fix(styles): standardize more buttons

* wip: difficulty select - style fixes

* selectDifficulty works! 🎉 - fix styles

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

* selectTranslatedArray

* changed many label margins

* more correct dropdown style

* fix(modals): button corrections

* input-group styling + datetime picker without today button

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

* working tag-selection / update - cleanup

* fix stories

* fix svg color on create modal (purple)

* fix task modal bottom padding

* correct dropdown shadow

* update dropdown-toggle caret size / color

* fixed checklist style

* sync checked state

* selectTag padding

* fix spacing between positive/negative streak inputs

* toggle-checkbox + fix some spacings

* disable repeat-on when its a groupTask

* fix new checklist-item

* fix toggle-checkbox style - fix difficulty style

* fix checklist ui

* add tags label , when there arent any tags selected

* WORKING select-tag component 🎉

* fix taglist story

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

* fix datetime clear button

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

* fix styles of advanced settings

* fix delete task styles

* always show grippy on hover of the item

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

* fix(spacing): revert most to Bootstrap

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

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

* feat(teams): manager notes field

* fix select/dropdown styles

* input border + icon colors

* delete task underline color

* fix checklist "delete icon" vertical position

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

* fixing icons:

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

* fix taglist margins / icon sizes

* wip margin overview (in storybook)

* fix routerlink

* remove unused method

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

* fix selectTag / selectList active border

* fix difficulty select (svg default color)

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

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

* fix(tests): address failures

* fix(lint): oops only

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

* fix(tasks): more short circuiting

* fix(tasks): more lines, less lint

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

* feat(teams): provide assigning user summary

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

* fix advanced-settings styling / margin

* fix merge + hide advanced streak settings when none enabled

* fix styles

* set Roboto font for advanced settings

* Add Challenge flag to the tag list

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

* fix styles + tag cancel button

* refactor footer / margin

* split repeat fields into option mt-3 groups

* button all the things

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

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

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

* fix(tests): adjust integration expectations

* fix(test): ratzen fratzen only

* fix lint

* fix tests

* fix(teams): checklist, notes

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

* fix typo

* correctly handle errors in handleSharedCompletion when approving a task

* fix(teams): improve disabled states

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

* revert changes to config.json.example

* fix(teams): more style fixage

* add unit tests for findMultipleByIdOrAlias

* exclude api v4 route from apidocs

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

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

* scoreTasks: validate body

* fix tests, move string to api errors

* fix(tests): update expectations for breaking change

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

* do not crash entire bulk operation in case of errors

* save task only if modified

* fix lint

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

* remove stale code

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

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

* rewards tests

* mixed tests

* fix tests, allow scoring the same task multiple times

* finish integration tests

* fix api docs for the bulk score route

* refactor(task-modal): lockable label component

* wip loading spinner

* refactor(teams): move task scoring to mixin

* fix(teams): style corrections

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

* implement loading spinner

* remove console.log warnings

* fix(tasks): spacing and wording corrections

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

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

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

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

* fix taskModal merge

* fix merge

* fix(task modal): add newline

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

* mvp yesterdaily modal

* fix tests

* fix api docs for bulk scoring group tasks

* separate task scoring and _tmp handling

* handle _tmp when bulk scoring

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

* rya: fix conflicts with other modals

* add sounds, support for group plans, analytics

* use asyncResource for group plans

* fix lint

* streak bonus: add comment about missing in rya

* move yesterdailyModal

* fix issues with level up modals and rya

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

* handle errors in rya modal

* bundle quest and crit notifications

Co-authored-by: Phillip Thelen <phillip@habitica.com>
Co-authored-by: Phillip Thelen <viirus@pherth.net>
Co-authored-by: Sabe Jones <sabrecat@gmail.com>
Co-authored-by: negue <eugen.bolz@gmail.com>
This commit is contained in:
Matteo Pagliazzi
2020-08-21 11:46:56 +02:00
committed by GitHub
parent 46b5efcaf6
commit d0bc0dbe49
34 changed files with 1541 additions and 385 deletions

1
.gitignore vendored
View File

@@ -38,6 +38,7 @@ yarn.lock
.elasticbeanstalk/* .elasticbeanstalk/*
!.elasticbeanstalk/*.cfg.yml !.elasticbeanstalk/*.cfg.yml
!.elasticbeanstalk/*.global.yml !.elasticbeanstalk/*.global.yml
/.vscode /.vscode
# webstorm fake webpack for path intellisense # webstorm fake webpack for path intellisense

View File

@@ -48,17 +48,17 @@ gulp.task('build:prepare-mongo', async () => {
return; return;
} }
console.log('MongoDB data folder is missing, setting up.'); console.log('MongoDB data folder is missing, setting up.'); // eslint-disable-line no-console
// use run-rs without --keep, kill it as soon as the replica set starts // use run-rs without --keep, kill it as soon as the replica set starts
const runRsProcess = spawn('run-rs', ['-v', '4.2.8', '-l', 'ubuntu1804', '--dbpath', 'mongodb-data', '--number', '1', '--quiet']); const runRsProcess = spawn('run-rs', ['-v', '4.2.8', '-l', 'ubuntu1804', '--dbpath', 'mongodb-data', '--number', '1', '--quiet']);
for await (const chunk of runRsProcess.stdout) { for await (const chunk of runRsProcess.stdout) {
const stringChunk = chunk.toString(); const stringChunk = chunk.toString();
console.log(stringChunk); console.log(stringChunk); // eslint-disable-line no-console
// kills the process after the replica set is setup // kills the process after the replica set is setup
if (stringChunk.includes('Started replica set')) { if (stringChunk.includes('Started replica set')) {
console.log('MongoDB setup correctly.'); console.log('MongoDB setup correctly.'); // eslint-disable-line no-console
runRsProcess.kill(); runRsProcess.kill();
} }
} }

View File

@@ -3,7 +3,6 @@ import { model as Challenge } from '../../../../website/server/models/challenge'
import { model as Group } from '../../../../website/server/models/group'; import { model as Group } from '../../../../website/server/models/group';
import { model as User } from '../../../../website/server/models/user'; import { model as User } from '../../../../website/server/models/user';
import * as Tasks from '../../../../website/server/models/task'; import * as Tasks from '../../../../website/server/models/task';
import { InternalServerError } from '../../../../website/server/libs/errors';
import { generateHistory } from '../../../helpers/api-unit.helper'; import { generateHistory } from '../../../helpers/api-unit.helper';
describe('Task Model', () => { describe('Task Model', () => {
@@ -99,7 +98,8 @@ describe('Task Model', () => {
throw new Error('No exception when Id is None'); throw new Error('No exception when Id is None');
} catch (err) { } catch (err) {
expect(err).to.exist; expect(err).to.exist;
expect(err).to.eql(new InternalServerError('Task identifier is a required argument')); expect(err).to.be.an.instanceOf(Error);
expect(err.message).to.eql('Task identifier is a required argument');
} }
}); });
@@ -109,7 +109,8 @@ describe('Task Model', () => {
throw new Error('No exception when user_id is undefined'); throw new Error('No exception when user_id is undefined');
} catch (err) { } catch (err) {
expect(err).to.exist; expect(err).to.exist;
expect(err).to.eql(new InternalServerError('User identifier is a required argument')); expect(err).to.be.an.instanceOf(Error);
expect(err.message).to.eql('User identifier is a required argument');
} }
}); });
@@ -153,6 +154,132 @@ describe('Task Model', () => {
}); });
}); });
describe('findMultipleByIdOrAlias', () => {
let taskWithAlias;
let secondTask;
let user;
beforeEach(async () => {
user = new User();
await user.save();
taskWithAlias = new Tasks.todo({ // eslint-disable-line new-cap
text: 'some text',
alias: 'short-name',
userId: user.id,
});
await taskWithAlias.save();
secondTask = new Tasks.habit({ // eslint-disable-line new-cap
text: 'second task',
alias: 'second-short-name',
userId: user.id,
});
await secondTask.save();
sandbox.spy(Tasks.Task, 'find');
});
it('throws an error if task identifiers is not passed in', async () => {
try {
await Tasks.Task.findMultipleByIdOrAlias(null, user._id);
throw new Error('No exception when Id is None');
} catch (err) {
expect(err).to.exist;
expect(err).to.be.an.instanceOf(Error);
expect(err.message).to.eql('Task identifiers is a required array argument');
}
});
it('throws an error if task identifiers is not an array', async () => {
try {
await Tasks.Task.findMultipleByIdOrAlias('string', user._id);
throw new Error('No exception when Id is None');
} catch (err) {
expect(err).to.exist;
expect(err).to.be.an.instanceOf(Error);
expect(err.message).to.eql('Task identifiers is a required array argument');
}
});
it('throws an error if user identifier is not passed in', async () => {
try {
await Tasks.Task.findMultipleByIdOrAlias([taskWithAlias._id]);
throw new Error('No exception when user_id is undefined');
} catch (err) {
expect(err).to.exist;
expect(err).to.be.an.instanceOf(Error);
expect(err.message).to.eql('User identifier is a required argument');
}
});
it('returns task by id', async () => {
const foundTasks = await Tasks.Task.findMultipleByIdOrAlias([taskWithAlias._id], user._id);
expect(foundTasks[0].text).to.eql(taskWithAlias.text);
});
it('returns task by alias', async () => {
const foundTasks = await Tasks.Task.findMultipleByIdOrAlias(
[taskWithAlias.alias], user._id,
);
expect(foundTasks[0].text).to.eql(taskWithAlias.text);
});
it('returns multiple tasks', async () => {
const foundTasks = await Tasks.Task.findMultipleByIdOrAlias(
[taskWithAlias.alias, secondTask._id], user._id,
);
expect(foundTasks.length).to.eql(2);
expect(foundTasks[0]._id).to.eql(taskWithAlias._id);
expect(foundTasks[1]._id).to.eql(secondTask._id);
});
it('returns a task only once if searched by both id and alias', async () => {
const foundTasks = await Tasks.Task.findMultipleByIdOrAlias(
[taskWithAlias.alias, taskWithAlias._id], user._id,
);
expect(foundTasks.length).to.eql(1);
expect(foundTasks[0].text).to.eql(taskWithAlias.text);
});
it('scopes alias lookup to user', async () => {
await Tasks.Task.findMultipleByIdOrAlias([taskWithAlias.alias], user._id);
expect(Tasks.Task.find).to.be.calledOnce;
expect(Tasks.Task.find).to.be.calledWithMatch({
$or: [
{ _id: { $in: [] } },
{ alias: { $in: [taskWithAlias.alias] } },
],
userId: user._id,
});
});
it('returns empty array if tasks cannot be found', async () => {
const foundTasks = await Tasks.Task.findMultipleByIdOrAlias(['not-found'], user._id);
expect(foundTasks).to.eql([]);
});
it('accepts additional query parameters', async () => {
await Tasks.Task.findMultipleByIdOrAlias([taskWithAlias.alias], user._id, { foo: 'bar' });
expect(Tasks.Task.find).to.be.calledOnce;
expect(Tasks.Task.find).to.be.calledWithMatch({
$or: [
{ _id: { $in: [] } },
{ alias: { $in: [taskWithAlias.alias] } },
],
userId: user._id,
foo: 'bar',
});
});
});
describe('sanitizeUserChallengeTask ', () => { describe('sanitizeUserChallengeTask ', () => {
}); });

View File

@@ -1,4 +1,5 @@
import { v4 as generateUUID } from 'uuid'; import { v4 as generateUUID } from 'uuid';
import apiError from '../../../../../website/server/libs/apiError';
import { import {
generateUser, generateUser,
sleep, sleep,
@@ -44,7 +45,7 @@ describe('POST /tasks/:id/score/:direction', () => {
await expect(user.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ await expect(user.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({
code: 400, code: 400,
error: 'BadRequest', error: 'BadRequest',
message: t('invalidReqParams'), message: apiError('directionUpDown'),
}); });
}); });
@@ -261,6 +262,7 @@ describe('POST /tasks/:id/score/:direction', () => {
const task = await user.get(`/tasks/${daily._id}`); const task = await user.get(`/tasks/${daily._id}`);
expect(task.completed).to.equal(true); expect(task.completed).to.equal(true);
expect(task.value).to.be.greaterThan(daily.value);
}); });
it('uncompletes daily when direction is down', async () => { it('uncompletes daily when direction is down', async () => {

View File

@@ -46,7 +46,7 @@ describe('POST /tasks/:id/score/:direction', () => {
const response = await member.post(`/tasks/${syncedTask._id}/score/${direction}`); const response = await member.post(`/tasks/${syncedTask._id}/score/${direction}`);
expect(response.data.approvalRequested).to.equal(true); expect(response.data.requiresApproval).to.equal(true);
expect(response.message).to.equal(t('taskApprovalHasBeenRequested')); expect(response.message).to.equal(t('taskApprovalHasBeenRequested'));
const updatedTask = await member.get(`/tasks/${syncedTask._id}`); const updatedTask = await member.get(`/tasks/${syncedTask._id}`);
@@ -107,12 +107,9 @@ describe('POST /tasks/:id/score/:direction', () => {
await member.post(`/tasks/${syncedTask._id}/score/up`); await member.post(`/tasks/${syncedTask._id}/score/up`);
await expect(member.post(`/tasks/${syncedTask._id}/score/up`)) const response = await member.post(`/tasks/${syncedTask._id}/score/up`);
.to.eventually.be.rejected.and.eql({ expect(response.data.requiresApproval).to.equal(true);
code: 401, expect(response.message).to.equal(t('taskRequiresApproval'));
error: 'NotAuthorized',
message: t('taskRequiresApproval'),
});
}); });
it('allows a user to score an approved task', async () => { it('allows a user to score an approved task', async () => {

View File

@@ -73,7 +73,7 @@ describe('PUT /tasks/:id', () => {
// score up to trigger approval // score up to trigger approval
const response = await member2.post(`/tasks/${syncedTask._id}/score/up`); const response = await member2.post(`/tasks/${syncedTask._id}/score/up`);
expect(response.data.approvalRequested).to.equal(true); expect(response.data.requiresApproval).to.equal(true);
expect(response.message).to.equal(t('taskApprovalHasBeenRequested')); expect(response.message).to.equal(t('taskApprovalHasBeenRequested'));
}); });

View File

@@ -0,0 +1,583 @@
import { v4 as generateUUID } from 'uuid';
import {
generateUser,
sleep,
translate as t,
server,
} from '../../../helpers/api-integration/v4';
describe('POST /tasks/bulk-score', () => {
let user;
beforeEach(async () => {
user = await generateUser({
'stats.gp': 100,
});
});
context('all', () => {
it('can use id to identify the task', async () => {
const todo = await user.post('/tasks/user', {
text: 'test todo',
type: 'todo',
alias: 'alias',
});
const res = await user.post('/tasks/bulk-score', [{ id: todo.id, direction: 'up' }]);
expect(res).to.be.ok;
expect(res.tasks.length).to.equal(1);
expect(res.tasks[0].id).to.equal(todo._id);
expect(res.tasks[0].delta).to.be.greaterThan(0);
});
it('can use a alias in place of the id', async () => {
const todo = await user.post('/tasks/user', {
text: 'test todo',
type: 'todo',
alias: 'alias',
});
const res = await user.post('/tasks/bulk-score', [{ id: todo.alias, direction: 'up' }]);
expect(res).to.be.ok;
expect(res.tasks.length).to.equal(1);
expect(res.tasks[0].id).to.equal(todo._id);
expect(res.tasks[0].delta).to.be.greaterThan(0);
});
it('sends task scored webhooks', async () => {
const uuid = generateUUID();
await server.start();
await user.post('/user/webhook', {
url: `http://localhost:${server.port}/webhooks/${uuid}`,
type: 'taskActivity',
enabled: true,
options: {
created: false,
scored: true,
},
});
const task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
await user.post('/tasks/bulk-score', [{ id: task.id, direction: 'up' }]);
await sleep();
await server.close();
const body = server.getWebhookData(uuid);
expect(body.user).to.have.all.keys('_id', '_tmp', 'stats');
expect(body.user.stats).to.have.all.keys('hp', 'mp', 'exp', 'gp', 'lvl', 'class', 'points', 'str', 'con', 'int', 'per', 'buffs', 'training', 'maxHealth', 'maxMP', 'toNextLevel');
expect(body.task.id).to.eql(task.id);
expect(body.direction).to.eql('up');
expect(body.delta).to.be.greaterThan(0);
});
context('sending user activity webhooks', () => {
before(async () => {
await server.start();
});
after(async () => {
await server.close();
});
it('sends user activity webhook when the user levels up', async () => {
const uuid = generateUUID();
await user.post('/user/webhook', {
url: `http://localhost:${server.port}/webhooks/${uuid}`,
type: 'userActivity',
enabled: true,
options: {
leveledUp: true,
},
});
const initialLvl = user.stats.lvl;
await user.update({
'stats.exp': 3000,
});
const task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
await user.post('/tasks/bulk-score', [{ id: task.id, direction: 'up' }]);
await user.sync();
await sleep();
const body = server.getWebhookData(uuid);
expect(body.type).to.eql('leveledUp');
expect(body.initialLvl).to.eql(initialLvl);
expect(body.finalLvl).to.eql(user.stats.lvl);
});
});
it('fails the entire op if one task scoring fails', async () => {
const todo = await user.post('/tasks/user', {
text: 'test todo',
type: 'todo',
});
const habit = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
await expect(user.post('/tasks/bulk-score', [
{ id: todo.id, direction: 'down' },
{ id: habit.id, direction: 'down' },
])).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('sessionOutdated'),
});
const updatedHabit = await user.get(`/tasks/${habit._id}`);
expect(updatedHabit.history.length).to.equal(0);
expect(updatedHabit.value).to.equal(0);
const updatedTodo = await user.get(`/tasks/${todo._id}`);
expect(updatedTodo.value).to.equal(0);
});
it('sends _tmp for each task', async () => {
const habit1 = await user.post('/tasks/user', {
text: 'test habit 1',
type: 'habit',
});
const habit2 = await user.post('/tasks/user', {
text: 'test habit 2',
type: 'habit',
});
await user.update({
'party.quest.key': 'gryphon',
});
const res = await user.post('/tasks/bulk-score', [
{ id: habit1._id, direction: 'up' },
{ id: habit2._id, direction: 'up' },
]);
await user.sync();
expect(res.tasks[0]._tmp.quest.progressDelta).to.be.greaterThan(0);
expect(res.tasks[1]._tmp.quest.progressDelta).to.be.greaterThan(0);
expect(user.party.quest.progress.up).to
.eql(res.tasks[0]._tmp.quest.progressDelta + res.tasks[1]._tmp.quest.progressDelta);
});
});
context('todos', () => {
let todo;
beforeEach(async () => {
todo = await user.post('/tasks/user', {
text: 'test todo',
type: 'todo',
});
});
it('completes todo when direction is up', async () => {
await user.post('/tasks/bulk-score', [{ id: todo.id, direction: 'up' }]);
const task = await user.get(`/tasks/${todo._id}`);
expect(task.completed).to.equal(true);
expect(task.dateCompleted).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type
});
it('moves completed todos out of user.tasksOrder.todos', async () => {
const getUser = await user.get('/user');
expect(getUser.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1);
await user.post('/tasks/bulk-score', [{ id: todo.id, direction: 'up' }]);
const updatedTask = await user.get(`/tasks/${todo._id}`);
expect(updatedTask.completed).to.equal(true);
const updatedUser = await user.get('/user');
expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).to.equal(-1);
});
it('moves un-completed todos back into user.tasksOrder.todos', async () => {
const getUser = await user.get('/user');
expect(getUser.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1);
await user.post('/tasks/bulk-score', [{ id: todo.id, direction: 'up' }]);
await user.post('/tasks/bulk-score', [{ id: todo.id, direction: 'down' }]);
const updatedTask = await user.get(`/tasks/${todo._id}`);
expect(updatedTask.completed).to.equal(false);
const updatedUser = await user.get('/user');
const l = updatedUser.tasksOrder.todos.length;
expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1);
// Check that it was pushed at the bottom
expect(updatedUser.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1);
});
it('uncompletes todo when direction is down', async () => {
await user.post('/tasks/bulk-score', [{ id: todo.id, direction: 'up' }, { id: todo.id, direction: 'down' }]);
const updatedTask = await user.get(`/tasks/${todo._id}`);
expect(updatedTask.completed).to.equal(false);
expect(updatedTask.dateCompleted).to.be.a('undefined');
});
it('doesn\'t let a todo be uncompleted twice', async () => {
await expect(user.post('/tasks/bulk-score', [{ id: todo.id, direction: 'down' }])).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('sessionOutdated'),
});
});
context('user stats when direction is up', () => {
let updatedUser; let res;
beforeEach(async () => {
res = await user.post('/tasks/bulk-score', [{ id: todo.id, direction: 'up' }]);
updatedUser = await user.get('/user');
});
it('increases user\'s mp', () => {
expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp);
expect(res.mp).to.equal(updatedUser.stats.mp);
});
it('increases user\'s exp', () => {
expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp);
expect(res.exp).to.equal(updatedUser.stats.exp);
});
it('increases user\'s gold', () => {
expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp);
expect(res.gp).to.equal(updatedUser.stats.gp);
});
});
context('user stats when direction is down', () => {
let updatedUser; let initialUser; let res;
beforeEach(async () => {
await user.post('/tasks/bulk-score', [{ id: todo.id, direction: 'up' }]);
initialUser = await user.get('/user');
res = await user.post('/tasks/bulk-score', [{ id: todo.id, direction: 'down' }]);
updatedUser = await user.get('/user');
});
it('decreases user\'s mp', () => {
expect(updatedUser.stats.mp).to.be.lessThan(initialUser.stats.mp);
});
it('decreases user\'s exp', () => {
expect(updatedUser.stats.exp).to.be.lessThan(initialUser.stats.exp);
expect(res.exp).to.equal(updatedUser.stats.exp);
});
it('decreases user\'s gold', () => {
expect(updatedUser.stats.gp).to.be.lessThan(initialUser.stats.gp);
expect(res.gp).to.equal(updatedUser.stats.gp);
});
});
});
context('dailys', () => {
let daily;
beforeEach(async () => {
daily = await user.post('/tasks/user', {
text: 'test daily',
type: 'daily',
});
});
it('completes daily when direction is up', async () => {
await user.post('/tasks/bulk-score', [{ id: daily.id, direction: 'up' }]);
const task = await user.get(`/tasks/${daily._id}`);
expect(task.completed).to.equal(true);
});
it('uncompletes daily when direction is down', async () => {
await user.post('/tasks/bulk-score', [{ id: daily.id, direction: 'up' }, { id: daily.id, direction: 'down' }]);
const task = await user.get(`/tasks/${daily._id}`);
expect(task.completed).to.equal(false);
});
it('computes isDue', async () => {
await user.post('/tasks/bulk-score', [{ id: daily.id, direction: 'up' }]);
const task = await user.get(`/tasks/${daily._id}`);
expect(task.isDue).to.equal(true);
});
it('computes nextDue', async () => {
await user.post('/tasks/bulk-score', [{ id: daily.id, direction: 'up' }]);
const task = await user.get(`/tasks/${daily._id}`);
expect(task.nextDue.length).to.eql(6);
});
context('user stats when direction is up', () => {
let updatedUser; let res;
beforeEach(async () => {
res = await user.post('/tasks/bulk-score', [{ id: daily.id, direction: 'up' }]);
updatedUser = await user.get('/user');
});
it('increases user\'s mp', () => {
expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp);
expect(res.mp).to.equal(updatedUser.stats.mp);
});
it('increases user\'s exp', () => {
expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp);
expect(res.exp).to.equal(updatedUser.stats.exp);
});
it('increases user\'s gold', () => {
expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp);
expect(res.gp).to.equal(updatedUser.stats.gp);
});
});
context('user stats when direction is down', () => {
let updatedUser; let initialUser; let res;
beforeEach(async () => {
await user.post('/tasks/bulk-score', [{ id: daily.id, direction: 'up' }]);
initialUser = await user.get('/user');
res = await user.post('/tasks/bulk-score', [{ id: daily.id, direction: 'down' }]);
updatedUser = await user.get('/user');
});
it('decreases user\'s mp', () => {
expect(updatedUser.stats.mp).to.be.lessThan(initialUser.stats.mp);
expect(res.mp).to.equal(updatedUser.stats.mp);
});
it('decreases user\'s exp', () => {
expect(updatedUser.stats.exp).to.be.lessThan(initialUser.stats.exp);
expect(res.exp).to.equal(updatedUser.stats.exp);
});
it('decreases user\'s gold', () => {
expect(updatedUser.stats.gp).to.be.lessThan(initialUser.stats.gp);
expect(res.gp).to.equal(updatedUser.stats.gp);
});
});
});
context('habits', () => {
let habit; let minusHabit; let plusHabit; let
neitherHabit; // eslint-disable-line no-unused-vars
beforeEach(async () => {
habit = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
minusHabit = await user.post('/tasks/user', {
text: 'test min habit',
type: 'habit',
up: false,
});
plusHabit = await user.post('/tasks/user', {
text: 'test plus habit',
type: 'habit',
down: false,
});
neitherHabit = await user.post('/tasks/user', {
text: 'test neither habit',
type: 'habit',
up: false,
down: false,
});
});
it('increases user\'s mp when direction is up', async () => {
const res = await user.post('/tasks/bulk-score', [{ id: habit.id, direction: 'up' }, {
id: plusHabit.id,
direction: 'up',
}]);
const updatedUser = await user.get('/user');
expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp);
expect(res.mp).to.equal(updatedUser.stats.mp);
});
it('decreases user\'s mp when direction is down', async () => {
const res = await user.post('/tasks/bulk-score', [{
id: habit.id,
direction: 'down',
}, {
id: minusHabit.id,
direction: 'down',
}]);
const updatedUser = await user.get('/user');
expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp);
expect(res.mp).to.equal(updatedUser.stats.mp);
});
it('increases user\'s exp when direction is up', async () => {
const res = await user.post('/tasks/bulk-score', [{
id: habit.id,
direction: 'up',
}, {
id: plusHabit.id,
direction: 'up',
}]);
const updatedUser = await user.get('/user');
expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp);
expect(res.exp).to.equal(updatedUser.stats.exp);
});
it('increases user\'s gold when direction is up', async () => {
const res = await user.post('/tasks/bulk-score', [{
id: habit.id,
direction: 'up',
}, {
id: plusHabit.id,
direction: 'up',
}]);
const updatedUser = await user.get('/user');
expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp);
expect(res.gp).to.equal(updatedUser.stats.gp);
});
it('records only one history entry per day', async () => {
const initialHistoryLength = habit.history.length;
await user.post('/tasks/bulk-score', [{
id: habit.id,
direction: 'up',
}, {
id: habit.id,
direction: 'up',
}, {
id: habit.id,
direction: 'down',
}, {
id: habit.id,
direction: 'up',
}]);
const updatedTask = await user.get(`/tasks/${habit._id}`);
expect(updatedTask.history.length).to.eql(initialHistoryLength + 1);
const lastHistoryEntry = updatedTask.history[updatedTask.history.length - 1];
expect(lastHistoryEntry.scoredUp).to.equal(3);
expect(lastHistoryEntry.scoredDown).to.equal(1);
});
});
context('mixed', () => {
let habit; let daily; let todo;
beforeEach(async () => {
habit = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
daily = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
todo = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
});
it('scores habits, dailies, todos', async () => {
const res = await user.post('/tasks/bulk-score', [
{ id: habit.id, direction: 'down' },
{ id: daily.id, direction: 'up' },
{ id: todo.id, direction: 'up' },
]);
expect(res.tasks[0].id).to.eql(habit.id);
expect(res.tasks[0].delta).to.be.below(0);
expect(res.tasks[0]._tmp).to.exist;
expect(res.tasks[1].id).to.eql(daily.id);
expect(res.tasks[1].delta).to.be.greaterThan(0);
expect(res.tasks[1]._tmp).to.exist;
expect(res.tasks[2].id).to.eql(todo.id);
expect(res.tasks[2].delta).to.be.greaterThan(0);
expect(res.tasks[2]._tmp).to.exist;
const updatedHabit = await user.get(`/tasks/${habit._id}`);
const updatedDaily = await user.get(`/tasks/${daily._id}`);
const updatedTodo = await user.get(`/tasks/${todo._id}`);
expect(habit.value).to.be.greaterThan(updatedHabit.value);
expect(updatedHabit.counterDown).to.equal(1);
expect(updatedDaily.value).to.be.greaterThan(daily.value);
expect(updatedTodo.value).to.be.greaterThan(todo.value);
});
});
context('reward', () => {
it('correctly handles rewards', async () => {
const reward = await user.post('/tasks/user', {
text: 'test reward',
type: 'reward',
value: 5,
});
const res = await user.post('/tasks/bulk-score', [{ id: reward.id, direction: 'up' }]);
const updatedUser = await user.get('/user');
// purchases reward
expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5);
expect(res.gp).to.equal(updatedUser.stats.gp);
// does not change user\'s mp
expect(user.stats.mp).to.equal(updatedUser.stats.mp);
expect(res.mp).to.equal(updatedUser.stats.mp);
// does not change user\'s exp
expect(user.stats.exp).to.equal(updatedUser.stats.exp);
expect(res.exp).to.equal(updatedUser.stats.exp);
});
it('fails if the user does not have enough gold', async () => {
const reward = await user.post('/tasks/user', {
text: 'test reward',
type: 'reward',
value: 500,
});
await expect(user.post('/tasks/bulk-score', [{ id: reward.id, direction: 'up' }])).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('messageNotEnoughGold'),
});
const updatedUser = await user.get('/user');
// does not purchase reward
expect(user.stats.gp).to.equal(updatedUser.stats.gp);
});
});
});

View File

@@ -5,7 +5,7 @@
font-weight: bold; font-weight: bold;
line-height: 1.71; line-height: 1.71;
border: 1px solid transparent; border: 1px solid transparent;
padding: 0.25rem 1rem; padding: 0.219rem 1rem;
border-radius: 2px; border-radius: 2px;
box-shadow: 0 1px 3px 0 rgba($black, 0.12), 0 1px 2px 0 rgba($black, 0.24); box-shadow: 0 1px 3px 0 rgba($black, 0.12), 0 1px 2px 0 rgba($black, 0.24);
color: $white; color: $white;

View File

@@ -48,7 +48,7 @@ export default {
computed: { computed: {
...mapState({ ...mapState({
user: 'user.data', user: 'user.data',
groupPlans: 'groupPlans', groupPlans: 'groupPlans.data',
}), }),
currentGroup () { currentGroup () {
const groupFound = this.groupPlans.find(group => group._id === this.groupId); const groupFound = this.groupPlans.find(group => group._id === this.groupId);

View File

@@ -212,7 +212,7 @@
'active': $route.path.startsWith('/group-plans')}" 'active': $route.path.startsWith('/group-plans')}"
> >
<div <div
v-if="groupPlans.length > 0" v-if="groupPlans && groupPlans.length > 0"
class="chevron rotate" class="chevron rotate"
@click="dropdownMobile($event)" @click="dropdownMobile($event)"
> >
@@ -761,7 +761,7 @@ export default {
...mapState({ ...mapState({
user: 'user.data', user: 'user.data',
userHourglasses: 'user.data.purchased.plan.consecutive.trinkets', userHourglasses: 'user.data.purchased.plan.consecutive.trinkets',
groupPlans: 'groupPlans', groupPlans: 'groupPlans.data',
modalStack: 'modalStack', modalStack: 'modalStack',
}), }),
navbarZIndexClass () { navbarZIndexClass () {
@@ -789,7 +789,7 @@ export default {
this.isUserDropdownOpen = !this.isUserDropdownOpen; this.isUserDropdownOpen = !this.isUserDropdownOpen;
}, },
async getUserGroupPlans () { async getUserGroupPlans () {
this.$store.state.groupPlans = await this.$store.dispatch('guilds:getGroupPlans'); await this.$store.dispatch('guilds:getGroupPlans');
}, },
openPartyModal () { openPartyModal () {
this.$root.$emit('bv::show::modal', 'create-party-modal'); this.$root.$emit('bv::show::modal', 'create-party-modal');

View File

@@ -2,7 +2,8 @@
<div> <div>
<yesterdaily-modal <yesterdaily-modal
:yester-dailies="yesterDailies" :yester-dailies="yesterDailies"
@run-cron="runYesterDailiesAction()" :cron-action="runCronAction"
@hidden="afterYesterdailies()"
/> />
<armoire-empty /> <armoire-empty />
<new-stuff /> <new-stuff />
@@ -116,7 +117,7 @@ import { mapState } from '@/libs/store';
import notifications from '@/mixins/notifications'; import notifications from '@/mixins/notifications';
import guide from '@/mixins/guide'; import guide from '@/mixins/guide';
import yesterdailyModal from './yesterdailyModal'; import yesterdailyModal from './tasks/yesterdailyModal';
import newStuff from './achievements/newStuff'; import newStuff from './achievements/newStuff';
import death from './achievements/death'; import death from './achievements/death';
import lowHealth from './achievements/lowHealth'; import lowHealth from './achievements/lowHealth';
@@ -422,7 +423,6 @@ export default {
unlockLevels, unlockLevels,
lastShownNotifications, lastShownNotifications,
alreadyReadNotification, alreadyReadNotification,
isRunningYesterdailies: false,
nextCron: null, nextCron: null,
handledNotifications, handledNotifications,
}; };
@@ -474,6 +474,10 @@ export default {
const money = after - before; const money = after - before;
let bonus; let bonus;
// NOTE: the streak bonus snackbar
// is not shown when bulk scoring (for example in the RYA modal)
// is used as it bypass the client side scoring
// and doesn't populate the _tmp object
if (this.user._tmp) { if (this.user._tmp) {
bonus = this.user._tmp.streakBonus || 0; bonus = this.user._tmp.streakBonus || 0;
} }
@@ -616,6 +620,7 @@ export default {
} }
// Lvl evaluation // Lvl evaluation
// @TODO use LEVELED_UP notification, would remove the need to check for yesterdailies
if (afterLvl !== beforeLvl) { if (afterLvl !== beforeLvl) {
if (afterLvl <= beforeLvl || this.$store.state.isRunningYesterdailies) return; if (afterLvl <= beforeLvl || this.$store.state.isRunningYesterdailies) return;
this.showLevelUpNotifications(afterLvl); this.showLevelUpNotifications(afterLvl);
@@ -656,7 +661,12 @@ export default {
showLevelUpNotifications (newlevel) { showLevelUpNotifications (newlevel) {
this.lvl(); this.lvl();
this.playSound('Level_Up'); this.playSound('Level_Up');
if (this.user._tmp && this.user._tmp.drop && this.user._tmp.drop.type === 'Quest') return; // NOTE this code isn't actually used because no modal is shown when a quest is dropped
// In case it's added again it should keep in mind that it will not work
// when the user progress to the next level using the RYA modal
// as it doesn't score the tasks on the client side and thus this.user._tmp is not filled
// with any value
// if (this.user._tmp && this.user._tmp.drop && this.user._tmp.drop.type === 'Quest') return;
if (this.unlockLevels[`${newlevel}`]) return; if (this.unlockLevels[`${newlevel}`]) return;
if (!this.user.preferences.suppressModals.levelUp) this.$root.$emit('bv::show::modal', 'level-up'); if (!this.user.preferences.suppressModals.levelUp) this.$root.$emit('bv::show::modal', 'level-up');
}, },
@@ -691,15 +701,13 @@ export default {
// Setup a listener that executes 10 seconds after the next cron time // Setup a listener that executes 10 seconds after the next cron time
this.nextCron = Number(nextCron.format('x')); this.nextCron = Number(nextCron.format('x'));
this.$store.state.isRunningYesterdailies = false;
}, },
async runYesterDailies () { async runYesterDailies () {
if (this.$store.state.isRunningYesterdailies) return; if (this.$store.state.isRunningYesterdailies) return;
this.$store.state.isRunningYesterdailies = true; this.$store.state.isRunningYesterdailies = true;
if (!this.user.needsCron) { if (!this.user.needsCron) {
this.scheduleNextCron(); this.afterYesterdailies();
this.handleUserNotifications(this.user.notifications);
return; return;
} }
@@ -717,25 +725,25 @@ export default {
}); });
if (this.yesterDailies.length === 0) { if (this.yesterDailies.length === 0) {
this.runYesterDailiesAction(); await this.runCronAction();
return; this.afterYesterdailies();
} else {
this.levelBeforeYesterdailies = this.user.stats.lvl;
this.$root.$emit('bv::show::modal', 'yesterdaily');
} }
this.levelBeforeYesterdailies = this.user.stats.lvl;
this.$root.$emit('bv::show::modal', 'yesterdaily');
}, },
async runYesterDailiesAction () { async runCronAction () {
// Run Cron // Run Cron
await axios.post('/api/v4/cron'); await axios.post('/api/v4/cron');
// Notifications
// Sync // Sync
await Promise.all([ await Promise.all([
this.$store.dispatch('user:fetch', { forceLoad: true }), this.$store.dispatch('user:fetch', { forceLoad: true }),
this.$store.dispatch('tasks:fetchUserTasks', { forceLoad: true }), this.$store.dispatch('tasks:fetchUserTasks', { forceLoad: true }),
]); ]);
},
afterYesterdailies () {
this.scheduleNextCron();
this.$store.state.isRunningYesterdailies = false; this.$store.state.isRunningYesterdailies = false;
if ( if (
@@ -744,8 +752,6 @@ export default {
) { ) {
this.showLevelUpNotifications(this.user.stats.lvl); this.showLevelUpNotifications(this.user.stats.lvl);
} }
this.scheduleNextCron();
this.handleUserNotifications(this.user.notifications); this.handleUserNotifications(this.user.notifications);
}, },
async handleUserNotifications (after) { async handleUserNotifications (after) {

View File

@@ -371,7 +371,7 @@ export default {
draggable, draggable,
}, },
mixins: [buyMixin, notifications], mixins: [buyMixin, notifications],
// Set default values for props // @TODO Set default values for props
// allows for better control of props values // allows for better control of props values
// allows for better control of where this component is called // allows for better control of where this component is called
props: { props: {

View File

@@ -846,7 +846,18 @@ export default {
markdown: markdownDirective, markdown: markdownDirective,
}, },
mixins: [scoreTask], mixins: [scoreTask],
props: ['task', 'isUser', 'group', 'challenge', 'dueDate'], // @TODO: maybe we should store the group on state? // @TODO: maybe we should store the group on state?
props: {
task: {},
isUser: {},
group: {},
challenge: {},
dueDate: {},
isYesterdaily: {
type: Boolean,
default: false,
},
},
data () { data () {
return { return {
random: uuid(), // used to avoid conflicts between checkboxes ids random: uuid(), // used to avoid conflicts between checkboxes ids
@@ -1039,8 +1050,14 @@ export default {
castEnd (e, task) { castEnd (e, task) {
setTimeout(() => this.$root.$emit('castEnd', task, 'task', e), 0); setTimeout(() => this.$root.$emit('castEnd', task, 'task', e), 0);
}, },
score (direction) { async score (direction) {
this.taskScore(this.task, direction); if (this.isYesterdaily === true) {
await this.beforeTaskScore(this.task);
this.task.completed = !this.task.completed;
this.playTaskScoreSound(this.task, direction);
} else {
this.taskScore(this.task, direction);
}
}, },
handleBrokenTask (task) { handleBrokenTask (task) {
if (this.$store.state.isRunningYesterdailies) return; if (this.$store.state.isRunningYesterdailies) return;

View File

@@ -0,0 +1,189 @@
<template>
<b-modal
id="yesterdaily"
size="m"
:hide-header="true"
:hide-footer="true"
:no-close-on-backdrop="true"
:no-close-on-esc="true"
@hide="$emit('hide')"
@hidden="$emit('hidden')"
>
<h1 class="header-welcome text-center">
{{ $t('welcomeBack') }}
</h1>
<p class="call-to-action text-center">
{{ $t('checkOffYesterDailies') }}
</p>
<div class="tasks-list">
<task
v-for="task in tasksByType.daily"
:key="task.id"
:task="task"
:is-user="true"
:due-date="dueDate"
:is-yesterdaily="true"
/>
</div>
<div class="start-day text-center">
<button
:disabled="isLoading"
class="btn btn-primary"
@click="processYesterdailies()"
>
<span v-if="!isLoading">{{ $t('yesterDailiesCallToAction') }}</span>
<loading-spinner v-else />
</button>
</div>
</b-modal>
</template>
<style lang="scss">
#yesterdaily {
.modal-dialog {
width: 22.625rem;
}
.task-wrapper:not(:last-of-type) {
margin-bottom: 2px;
}
.modal-content {
border-radius: 8px;
}
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.header-welcome {
color: $purple-200;
margin-top: 1rem;
}
.call-to-action {
font-size: 14px;
font-weight: bold;
}
.tasks-list {
border-radius: 4px;
background: $gray-600;
padding: 8px;
position: relative;
overflow: auto;
}
.start-day {
margin: 1.5rem auto 1rem auto;
}
</style>
<script>
import moment from 'moment';
import * as Analytics from '@/libs/analytics';
import { mapState } from '@/libs/store';
import scoreTask from '@/mixins/scoreTask';
import Task from './task';
import LoadingSpinner from '../ui/loadingSpinner';
export default {
components: {
Task,
LoadingSpinner,
},
mixins: [scoreTask],
props: {
yesterDailies: {
type: Array,
},
cronAction: {
type: Function,
},
},
data () {
return {
isLoading: false,
dueDate: moment().subtract(1, 'days'),
};
},
computed: {
...mapState({ user: 'user.data' }),
tasksByType () {
this.dueDate = moment().subtract(1, 'days'); // eslint-disable-line vue/no-side-effects-in-computed-properties
return {
daily: this.yesterDailies,
};
},
},
methods: {
async processYesterdailies () {
if (this.isLoading) return;
this.isLoading = true;
const bulkScoreParams = this.yesterDailies
.filter(yesterdaily => yesterdaily.completed)
.map(yesterdaily => ({ id: yesterdaily._id, direction: 'up' }));
if (bulkScoreParams.length > 0) {
try {
const bulkScoresponse = await this.$store.dispatch('tasks:bulkScore', bulkScoreParams);
// Bundle critical hits and quests updates into a single notification
const bundledTmp = {};
bulkScoresponse.data.data.tasks.forEach(taskResponse => {
taskResponse._tmp = taskResponse._tmp || {};
const tmp = taskResponse._tmp;
if (tmp.crit) {
if (!bundledTmp.crit) {
bundledTmp.crit = 0;
}
bundledTmp.crit += tmp.crit;
tmp.crit = undefined;
}
if (tmp.quest) {
if (!bundledTmp.quest) {
bundledTmp.quest = { progressDelta: 0, collection: 0 };
}
if (tmp.quest.progressDelta) {
bundledTmp.quest.progressDelta += tmp.quest.progressDelta;
}
if (tmp.quest.collection) bundledTmp.quest.collection += tmp.quest.collection;
tmp.quest = undefined;
}
});
this.handleTaskScoreNotifications(bundledTmp);
bulkScoresponse.data.data.tasks.forEach(taskResponse => {
this.handleTaskScoreNotifications(taskResponse._tmp);
});
} catch (err) {
// Reset the modal so that it can be used again
// Then throw the error again to make sure it's handled correctly
// and the user is notified.
this.yesterDailies.forEach(y => { y.completed = false; });
this.isLoading = false;
throw err;
}
}
await this.cronAction();
this.isLoading = false;
this.$root.$emit('bv::hide::modal', 'yesterdaily');
Analytics.updateUser();
},
},
};
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div
v-once
class="loading-spinner"
role="text"
:aria-label="$t('loading')"
>
<div></div>
<div></div>
<div></div>
</div>
</template>
<style lang='scss' scoped>
@import '~@/assets/scss/colors.scss';
// NOTE: the loader is currently set to work inside standard buttons
// To properly work outside of them some abstraction will be needed
// for the height and width
// Original CSS from https://loading.io/css/ released under the CC0 License
.loading-spinner {
width: 20px;
height: 20px;
margin-top: 1.5px;
margin-bottom: 1.5px;
}
.loading-spinner div {
box-sizing: border-box;
display: block;
position: absolute;
width: 20px;
height: 20px;
border: 2px solid $white;
border-radius: 50%;
animation: loading-spinner 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: $white transparent transparent transparent;
}
.loading-spinner div:nth-child(1) {
animation-delay: -0.45s;
}
.loading-spinner div:nth-child(2) {
animation-delay: -0.3s;
}
.loading-spinner div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes loading-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,111 +0,0 @@
<template>
<b-modal
id="yesterdaily"
size="m"
:hide-header="true"
:hide-footer="true"
:no-close-on-backdrop="true"
:no-close-on-esc="true"
@hide="$emit('hide')"
>
<h1 class="header-welcome text-center">
{{ $t('welcomeBack') }}
</h1>
<p class="call-to-action text-center">
{{ $t('checkOffYesterDailies') }}
</p>
<div class="tasks-list">
<task
v-for="task in tasksByType.daily"
:key="task.id"
:task="task"
:is-user="true"
:due-date="dueDate"
/>
</div>
<div class="start-day text-center">
<button
class="btn btn-primary"
@click="close()"
>
{{ $t('yesterDailiesCallToAction') }}
</button>
</div>
</b-modal>
</template>
<style lang="scss">
#yesterdaily {
.modal-dialog {
width: 22.625rem;
}
.task-wrapper:not(:last-of-type) {
margin-bottom: 2px;
}
.modal-content {
border-radius: 8px;
}
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.header-welcome {
color: $purple-200;
margin-top: 1rem;
}
.call-to-action {
font-size: 14px;
font-weight: bold;
}
.tasks-list {
border-radius: 4px;
background: $gray-600;
padding: 8px;
position: relative;
overflow: auto;
}
.start-day {
margin: 1.5rem auto 1rem auto;
}
</style>
<script>
import moment from 'moment';
import { mapState } from '@/libs/store';
import Task from './tasks/task';
export default {
components: {
Task,
},
props: ['yesterDailies'],
data () {
return {
dueDate: moment().subtract(1, 'days'),
};
},
computed: {
...mapState({ user: 'user.data' }),
tasksByType () {
this.dueDate = moment().subtract(1, 'days'); // eslint-disable-line vue/no-side-effects-in-computed-properties
return {
daily: this.yesterDailies,
};
},
},
methods: {
async close () {
this.$root.$emit('bv::hide::modal', 'yesterdaily');
this.$emit('run-cron');
},
},
};
</script>

View File

@@ -1,4 +1,3 @@
import axios from 'axios';
import Vue from 'vue'; import Vue from 'vue';
import * as Analytics from '@/libs/analytics'; import * as Analytics from '@/libs/analytics';
@@ -15,29 +14,24 @@ export default {
}), }),
}, },
methods: { methods: {
async taskScore (task, direction) { async beforeTaskScore (task) {
if (this.castingSpell) return;
const { user } = this; const { user } = this;
if (this.castingSpell) return;
const Content = this.$store.state.content;
if (task.group.approval.required && !task.group.approval.approved) { if (task.group.approval.required && !task.group.approval.approved) {
task.group.approval.requested = true; task.group.approval.requested = true;
const groupResponse = await axios.get(`/api/v4/groups/${task.group.id}`); const { data: groupPlans } = await this.$store.dispatch('guilds:getGroupPlans');
const managers = Object.keys(groupResponse.data.data.managers); const groupPlan = groupPlans.find(g => g.id === task.group.id);
managers.push(groupResponse.data.data.leader._id); if (groupPlan) {
if (managers.indexOf(user._id) !== -1) { const managers = Object.keys(groupPlan.managers);
task.group.approval.approved = true; managers.push(groupPlan.leader);
if (managers.indexOf(user._id) !== -1) {
task.group.approval.approved = true;
}
} }
} }
},
try { playTaskScoreSound (task, direction) {
scoreTask({ task, user, direction });
} catch (err) {
this.text(err.message);
return;
}
switch (task.type) { // eslint-disable-line default-case switch (task.type) { // eslint-disable-line default-case
case 'habit': case 'habit':
this.$root.$emit('playSound', direction === 'up' ? 'Plus_Habit' : 'Minus_Habit'); this.$root.$emit('playSound', direction === 'up' ? 'Plus_Habit' : 'Minus_Habit');
@@ -52,15 +46,40 @@ export default {
this.$root.$emit('playSound', 'Reward'); this.$root.$emit('playSound', 'Reward');
break; break;
} }
},
async taskScore (task, direction) {
const { user } = this;
await this.beforeTaskScore(task);
try {
scoreTask({ task, user, direction });
} catch (err) {
this.text(err.message);
return;
}
this.playTaskScoreSound(task, direction);
Analytics.updateUser(); Analytics.updateUser();
const response = await axios.post(`/api/v4/tasks/${task._id}/score/${direction}`); const response = await this.$store.dispatch('tasks:score', {
// used to notify drops, critical hits and other bonuses taskId: task._id,
const tmp = response.data.data._tmp || {}; direction,
const { crit } = tmp; });
const { drop } = tmp;
const { firstDrops } = tmp; this.handleTaskScoreNotifications(response.data.data._tmp || {});
const { quest } = tmp; },
async handleTaskScoreNotifications (tmpObject = {}) {
const { user } = this;
const Content = this.$store.state.content;
// _tmp is used to notify drops, critical hits and other bonuses
const {
crit,
drop,
firstDrops,
quest,
} = tmpObject;
if (crit) { if (crit) {
const critBonus = crit * 100 - 100; const critBonus = crit * 100 - 100;
@@ -126,6 +145,9 @@ export default {
} else if (drop.type === 'Quest') { } else if (drop.type === 'Quest') {
// TODO $rootScope.selectedQuest = Content.quests[drop.key]; // TODO $rootScope.selectedQuest = Content.quests[drop.key];
// $rootScope.openModal('questDrop', {controller:'PartyCtrl', size:'sm'}); // $rootScope.openModal('questDrop', {controller:'PartyCtrl', size:'sm'});
// NOTE if a modal is shown again for quest drops
// this code will likely need changes, see the NOTE
// https://github.com/HabitRPG/habitica/blob/develop/website/client/src/components/notifications.vue#L640-L646
} else { } else {
// Keep support for another type of drops that might be added // Keep support for another type of drops that might be added
this.drop(drop.dialog); this.drop(drop.dialog);

View File

@@ -2,6 +2,7 @@ import axios from 'axios';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import findIndex from 'lodash/findIndex'; import findIndex from 'lodash/findIndex';
import * as Analytics from '@/libs/analytics'; import * as Analytics from '@/libs/analytics';
import { loadAsyncResource } from '@/libs/asyncResource';
export async function getPublicGuilds (store, payload) { export async function getPublicGuilds (store, payload) {
const params = { const params = {
@@ -201,7 +202,14 @@ export async function removeManager (store, payload) {
return response; return response;
} }
export async function getGroupPlans () { export function getGroupPlans (store, forceLoad = false) {
const response = await axios.get('/api/v4/group-plans'); return loadAsyncResource({
return response.data.data; store,
path: 'groupPlans',
url: '/api/v4/group-plans',
deserialize (response) {
return response.data.data;
},
forceLoad,
});
} }

View File

@@ -122,6 +122,18 @@ export async function save (store, editedTask) {
if (originalTask) Object.assign(originalTask, response.data.data); if (originalTask) Object.assign(originalTask, response.data.data);
} }
export async function score (store, { taskId, direction }) {
const res = await axios.post(`/api/v4/tasks/${taskId}/score/${direction}`);
return res;
}
// params must be an array of objects with this format
// [ {id: task1Id, direction: task1Direction } , {id: task2Id, direction: task2Direction } ]
export async function bulkScore (store, params) {
const res = await axios.post('/api/v4/tasks/bulk-score', params);
return res;
}
export async function scoreChecklistItem (store, { taskId, itemId }) { export async function scoreChecklistItem (store, { taskId, itemId }) {
await axios.post(`/api/v4/tasks/${taskId}/checklist/${itemId}/score`); await axios.post(`/api/v4/tasks/${taskId}/checklist/${itemId}/score`);
} }

View File

@@ -132,7 +132,7 @@ export default function () {
notificationStore: [], notificationStore: [],
modalStack: [], modalStack: [],
equipmentDrawerOpen: true, equipmentDrawerOpen: true,
groupPlans: [], groupPlans: asyncResourceFactory(),
isRunningYesterdailies: false, isRunningYesterdailies: false,
privateMessageOptions: { privateMessageOptions: {
userIdToMessage: '', userIdToMessage: '',

View File

@@ -161,7 +161,6 @@
"tagIdRequired": "\"tagId\" must be a valid UUID corresponding to a tag belonging to the user.", "tagIdRequired": "\"tagId\" must be a valid UUID corresponding to a tag belonging to the user.",
"positionRequired": "\"position\" is required and must be a number.", "positionRequired": "\"position\" is required and must be a number.",
"cantMoveCompletedTodo": "Can't move a completed todo.", "cantMoveCompletedTodo": "Can't move a completed todo.",
"directionUpDown": "\"direction\" is required and must be 'up' or 'down'.",
"alreadyTagged": "The task is already tagged with given tag.", "alreadyTagged": "The task is already tagged with given tag.",
"strengthExample": "Relating to exercise and activity", "strengthExample": "Relating to exercise and activity",
"intelligenceExample": "Relating to academic or mentally challenging pursuits", "intelligenceExample": "Relating to academic or mentally challenging pursuits",

View File

@@ -30,4 +30,8 @@ export default {
clientRateLimited: 'This User ID or IP address has been rate limited due to an excess amount of requests to the Habitica API v3. More info can be found in the response headers and at https://habitica.fandom.com/wiki/Guidance_for_Comrades#Rules_for_Third-Party_Tools under the section Rate Limiting.', clientRateLimited: 'This User ID or IP address has been rate limited due to an excess amount of requests to the Habitica API v3. More info can be found in the response headers and at https://habitica.fandom.com/wiki/Guidance_for_Comrades#Rules_for_Third-Party_Tools under the section Rate Limiting.',
invalidPlatform: 'Invalid platform specified', invalidPlatform: 'Invalid platform specified',
directionUpDown: '"direction" is required and must be "up" or "down".',
invalidTaskIdentifier: 'A task is identified by its UUID or alias.',
invalidTaskScorings: 'This API route expects a body in the form of [{id, direction}].',
}; };

View File

@@ -37,6 +37,7 @@ export default function randomDrop (user, options, req = {}, analytics) {
size(user.items.eggs) < 1 size(user.items.eggs) < 1
&& size(user.items.hatchingPotions) < 1 && size(user.items.hatchingPotions) < 1
) { ) {
// @TODO why are we using both _tmp.firstDrops and the FIRST_DROPS notification?
user._tmp.firstDrops = firstDrops(user); user._tmp.firstDrops = firstDrops(user);
return; return;
} }
@@ -150,6 +151,7 @@ export default function randomDrop (user, options, req = {}, analytics) {
}, req.language); }, req.language);
} }
// @TODO use notifications
user._tmp.drop = drop; user._tmp.drop = drop;
user.items.lastDrop.date = Number(new Date()); user.items.lastDrop.date = Number(new Date());
user.items.lastDrop.count += 1; user.items.lastDrop.count += 1;

View File

@@ -241,8 +241,13 @@ export default function scoreTask (options = {}, req = {}, analytics) {
// This is for setting one-time temporary flags, // This is for setting one-time temporary flags,
// such as streakBonus or itemDropped. Useful for notifying // such as streakBonus or itemDropped. Useful for notifying
// the API consumer, then cleared afterwards // the API consumer, then cleared afterwards
// Keep user._tmp.leveledUp if it already exists
// To make sure infos on level ups don't get lost when bulk scoring multiple tasks
const oldLeveledUp = user._tmp && user._tmp.leveledUp;
user._tmp = {}; user._tmp = {};
if (oldLeveledUp) user._tmp.leveledUp = oldLeveledUp;
// If they're trying to purchase a too-expensive reward, don't allow them to do that. // If they're trying to purchase a too-expensive reward, don't allow them to do that.
if (task.value > user.stats.gp && task.type === 'reward') throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); if (task.value > user.stats.gp && task.type === 'reward') throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language));

View File

@@ -1198,7 +1198,7 @@ api.inviteToGroup = {
* @apiParamExample {String} party: * @apiParamExample {String} party:
* /api/v3/groups/party/add-manager * /api/v3/groups/party/add-manager
* *
* @apiBody (Body) {UUID} managerId The user _id of the member to promote to manager * @apiParam (Body) {UUID} managerId The user _id of the member to promote to manager
* *
* @apiSuccess {Object} data An empty object * @apiSuccess {Object} data An empty object
* *
@@ -1248,7 +1248,7 @@ api.addGroupManager = {
* @apiParamExample {String} party: * @apiParamExample {String} party:
* /api/v3/groups/party/add-manager * /api/v3/groups/party/add-manager
* *
* @apiBody (Body) {UUID} managerId The user _id of the member to remove * @apiParam (Body) {UUID} managerId The user _id of the member to remove
* *
* @apiSuccess {Object} group The group * @apiSuccess {Object} group The group
* *

View File

@@ -3,14 +3,11 @@ import moment from 'moment';
import { authWithHeaders } from '../../middlewares/auth'; import { authWithHeaders } from '../../middlewares/auth';
import { import {
taskActivityWebhook, taskActivityWebhook,
taskScoredWebhook,
} from '../../libs/webhook'; } from '../../libs/webhook';
import { removeFromArray } from '../../libs/collectionManipulators'; import { removeFromArray } from '../../libs/collectionManipulators';
import * as Tasks from '../../models/task'; import * as Tasks from '../../models/task';
import { handleSharedCompletion } from '../../libs/groupTasks';
import { model as Challenge } from '../../models/challenge'; import { model as Challenge } from '../../models/challenge';
import { model as Group } from '../../models/group'; import { model as Group } from '../../models/group';
import { model as User } from '../../models/user';
import { import {
NotFound, NotFound,
NotAuthorized, NotAuthorized,
@@ -21,9 +18,9 @@ import {
getTasks, getTasks,
moveTask, moveTask,
setNextDue, setNextDue,
scoreTasks,
} from '../../libs/taskManager'; } from '../../libs/taskManager';
import common from '../../../common'; import common from '../../../common';
import logger from '../../libs/logger';
import apiError from '../../libs/apiError'; import apiError from '../../libs/apiError';
// @TODO abstract, see api-v3/tasks/groups.js // @TODO abstract, see api-v3/tasks/groups.js
@@ -736,10 +733,11 @@ api.updateTask = {
* *
* @apiSuccess {Object} data The user stats * @apiSuccess {Object} data The user stats
* @apiSuccess {Object} data._tmp If an item was dropped it'll be returned in te _tmp object * @apiSuccess {Object} data._tmp If an item was dropped it'll be returned in te _tmp object
* @apiSuccess (202) {Boolean} data.approvalRequested Approval was requested for team task
* @apiSuccess (202) {String} message Acknowledgment of team task approval request
* @apiSuccess {Number} data.delta The delta * @apiSuccess {Number} data.delta The delta
* *
* @apiSuccess (202) {Boolean} data.requiresApproval Approval was requested for team task
* @apiSuccess (202) {String} message Acknowledgment of team task approval request
*
* @apiSuccessExample {json} Example result: * @apiSuccessExample {json} Example result:
* {"success":true,"data":{"delta":0.9746999906450404,"_tmp":{},"hp":49.06645205596985, * {"success":true,"data":{"delta":0.9746999906450404,"_tmp":{},"hp":49.06645205596985,
* "mp":37.2008917491047,"exp":101.93810026267543,"gp":77.09694176716997, * "mp":37.2008917491047,"exp":101.93810026267543,"gp":77.09694176716997,
@@ -766,188 +764,24 @@ api.scoreTask = {
url: '/tasks/:taskId/score/:direction', url: '/tasks/:taskId/score/:direction',
middlewares: [authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); // Parameters are validated in scoreTasks
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const { user } = res.locals; const { user } = res.locals;
const { taskId } = req.params; const { taskId, direction } = req.params;
const [taskResponse] = await scoreTasks(user, [{ id: taskId, direction }], req, res);
const task = await Tasks.Task.findByIdOrAlias(taskId, user._id, { userId: user._id }); const userStats = user.stats.toJSON();
const { direction } = req.params;
if (!task) throw new NotFound(res.t('taskNotFound')); // group tasks that require a manager's approval
if (taskResponse.requiresApproval === true) {
res.respond(202, { requiresApproval: true }, taskResponse.message);
} else {
const resJsonData = _.assign({
delta: taskResponse.delta,
_tmp: user._tmp,
}, userStats);
if (task.type === 'daily' || task.type === 'todo') { res.respond(200, resJsonData);
if (task.completed && direction === 'up') {
throw new NotAuthorized(res.t('sessionOutdated'));
} else if (!task.completed && direction === 'down') {
throw new NotAuthorized(res.t('sessionOutdated'));
}
}
if (task.group.approval.required && !task.group.approval.approved) {
const fields = requiredGroupFields.concat(' managers');
const group = await Group.getGroup({ user, groupId: task.group.id, fields });
const managerIds = Object.keys(group.managers);
managerIds.push(group.leader);
if (managerIds.indexOf(user._id) !== -1) {
task.group.approval.approved = true;
task.group.approval.requested = true;
task.group.approval.requestedDate = new Date();
} else {
if (task.group.approval.requested) {
throw new NotAuthorized(res.t('taskRequiresApproval'));
}
task.group.approval.requested = true;
task.group.approval.requestedDate = new Date();
const managers = await User.find({ _id: managerIds }, 'notifications preferences').exec(); // Use this method so we can get access to notifications
// @TODO: we can use the User.pushNotification function because
// we need to ensure notifications are translated
const managerPromises = [];
managers.forEach(manager => {
manager.addNotification('GROUP_TASK_APPROVAL', {
message: res.t('userHasRequestedTaskApproval', {
user: user.profile.name,
taskName: task.text,
}, manager.preferences.language),
groupId: group._id,
// user task id, used to match the notification when the task is approved
taskId: task._id,
userId: user._id,
groupTaskId: task.group.taskId, // the original task id
direction,
});
managerPromises.push(manager.save());
});
managerPromises.push(task.save());
await Promise.all(managerPromises);
res.respond(
202,
{ approvalRequested: true },
res.t('taskApprovalHasBeenRequested'),
);
return;
}
}
if (task.group.approval.required && task.group.approval.approved) {
const notificationIndex = user.notifications.findIndex(notification => notification
&& notification.data && notification.data.task
&& notification.data.task._id === task._id && notification.type === 'GROUP_TASK_APPROVED');
if (notificationIndex !== -1) {
user.notifications.splice(notificationIndex, 1);
}
}
const wasCompleted = task.completed;
const firstTask = !user.achievements.completedTask;
const [delta] = common.ops.scoreTask({ task, user, direction }, req, res.analytics);
// 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' && !firstTask) common.fns.randomDrop(user, { task, delta }, req, res.analytics);
// If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list
// TODO move to common code?
let taskOrderPromise;
if (task.type === 'todo') {
if (!wasCompleted && task.completed) {
// @TODO: mongoose's push and pull should be atomic and help with
// our concurrency issues. If not, we need to use this update $pull and $push
taskOrderPromise = user.update({
$pull: { 'tasksOrder.todos': task._id },
}).exec();
// user.tasksOrder.todos.pull(task._id);
} else if (
wasCompleted
&& !task.completed
&& user.tasksOrder.todos.indexOf(task._id) === -1
) {
taskOrderPromise = user.update({
$push: { 'tasksOrder.todos': task._id },
}).exec();
// user.tasksOrder.todos.push(task._id);
}
}
setNextDue(task, user);
const promises = [
user.save(),
task.save(),
];
if (task.group && task.group.taskId) {
await handleSharedCompletion(task);
try {
const groupTask = await Tasks.Task.findOne({
_id: task.group.taskId,
}).exec();
if (groupTask) {
const groupDelta = groupTask.group.assignedUsers
? delta / groupTask.group.assignedUsers.length
: delta;
await groupTask.scoreChallengeTask(groupDelta, direction);
}
} catch (e) {
logger.error(e);
}
}
// Save results and handle request
if (taskOrderPromise) promises.push(taskOrderPromise);
const results = await Promise.all(promises);
const savedUser = results[0];
const userStats = savedUser.stats.toJSON();
const resJsonData = _.assign({ delta, _tmp: user._tmp }, userStats);
res.respond(200, resJsonData);
taskScoredWebhook.send(user, {
task,
direction,
delta,
user,
});
if (task.challenge && task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') {
// Wrapping everything in a try/catch block because if an error occurs
// using `await` it MUST NOT bubble up because the request has already been handled
try {
const chalTask = await Tasks.Task.findOne({
_id: task.challenge.taskId,
}).exec();
if (!chalTask) return;
await chalTask.scoreChallengeTask(delta, direction);
} catch (e) {
logger.error(e);
}
}
// Track when new users (first 7 days) score tasks
if (moment().diff(user.auth.timestamps.created, 'days') < 7) {
res.analytics.track('task score', {
uuid: user._id,
hitType: 'event',
category: 'behavior',
taskType: task.type,
direction,
headers: req.headers,
});
} }
}, },
}; };

View File

@@ -14,6 +14,7 @@ import {
} from '../../../libs/taskManager'; } from '../../../libs/taskManager';
import { handleSharedCompletion } from '../../../libs/groupTasks'; import { handleSharedCompletion } from '../../../libs/groupTasks';
import apiError from '../../../libs/apiError'; import apiError from '../../../libs/apiError';
import logger from '../../../libs/logger';
const requiredGroupFields = '_id leader tasksOrder name'; const requiredGroupFields = '_id leader tasksOrder name';
// @TODO: abstract to task lib // @TODO: abstract to task lib
@@ -384,13 +385,25 @@ api.approveTask = {
direction, direction,
}); });
await handleSharedCompletion(task);
approvalPromises.push(task.save()); approvalPromises.push(task.save());
approvalPromises.push(assignedUser.save()); approvalPromises.push(assignedUser.save());
await Promise.all(approvalPromises); await Promise.all(approvalPromises);
res.respond(200, task); res.respond(200, task);
// Wrapping everything in a try/catch block because if an error occurs
// using `await` it MUST NOT bubble up because the request has already been handled
try {
const groupTask = await Tasks.Task.findOne({
_id: task.group.taskId,
}).exec();
if (groupTask) {
await handleSharedCompletion(groupTask, task);
}
} catch (e) {
logger.error('Error handling group task', e);
}
}, },
}; };

View File

@@ -0,0 +1,69 @@
import _ from 'lodash';
import { authWithHeaders } from '../../middlewares/auth';
import { scoreTasks } from '../../libs/taskManager';
const api = {};
/**
* @apiIgnore
* @api {post} /api/v4/tasks/bulk-score Score multiple tasks
* @apiName ScoreTasks
* @apiGroup Task
*
* @apiParam (Body) {Object[]} body An array with the data on the tasks to score
* @apiParam (Body) {String} body.*.id A task identifier, either the id or alias
* @apiParam (Body) {String="up","down"} body.*.direction The direction in which to score the task
*
* @apiParamExample {json} Request-Example:
* [{ "id": "a task id", "direction": "up" },
* { "id": "a 2nd task id", "direction": "down" }]
*
* @apiSuccess {Object} data The user stats and a tasks object
* @apiSuccess {Object[]} data.tasks An array of results with an object for each scored task
* @apiSuccess {Object[]} data.tasks.*.id The id of the task scored
* @apiSuccess {Object[]} data.tasks.*.delta The delta
* @apiSuccess {Object[]} data.tasks.*._tmp If an item was dropped when scoring a task it'll
* be returned in the _tmp object for that task
*
* @apiSuccess {Boolean} data.requiresApproval Approval was requested for team task
* @apiSuccess {String} message Acknowledgment of team task approval request
*
* @apiSuccessExample {json} Example result:
* {"success":true,"data":{"tasks": [{"id": "task id", "delta":0.9746999906450404,
* "_tmp":{}],"hp":50,
* "mp":37.2008917491047,"exp":101.93810026267543,"gp":77.09694176716997,
* "lvl":19,"class":"rogue","points":0,"str":5,"con":3,"int":3,
* "per":8,"buffs":{"str":9,"int":9,"per":9,"con":9,"stealth":0,"streaks":false,
* "snowball":false,"spookySparkles":false,"shinySeed":false,"seafoam":false},
* "training":{"int":0,"per":0,"str":0,"con":0}},"notifications":[]}
*
* @apiSuccessExample {json} Example result with item drop:
* {"success":true,"data":{"tasks": [{"id": "task-id", delta":1.0259567046270648,
* "_tmp":{"quest":{"progressDelta":1.2362778290756147,"collection":1},"drop":{"target":"Zombie",
* "canDrop":true,"value":1,"key":"RottenMeat","type":"Food",
* "dialog":"You've found Rotten Meat! Feed this to a pet and it may grow into a sturdy steed."}}],
* "hp":50,"mp":66.2390716654227,"exp":143.93810026267545,"gp":135.12889840462591,"lvl":20,
* "class":"rogue","points":0,"str":6,"con":3,"int":3,"per":8,"buffs":{"str":10,"int":10,"per":10,
* "con":10,"stealth":0,"streaks":false,"snowball":false,"spookySparkles":false,
* "shinySeed":false,"seafoam":false},"training":{"int":0,"per":0,"str":0,"con":0}},
* "notifications":[]}
*
* @apiUse TaskNotFound
*/
api.scoreTasks = {
method: 'POST',
url: '/tasks/bulk-score',
middlewares: [authWithHeaders()],
async handler (req, res) {
// Body is validated in scoreTasks
const { user } = res.locals;
const tasksResponses = await scoreTasks(user, req.body, req, res);
const userStats = user.stats.toJSON();
const resJsonData = _.assign({ tasks: tasksResponses }, userStats);
res.respond(200, resJsonData);
},
};
export default api;

View File

@@ -28,7 +28,6 @@ async function _evaluateAllAssignedCompletion (masterTask) {
'group.taskId': masterTask._id, 'group.taskId': masterTask._id,
'group.approval.approved': true, 'group.approval.approved': true,
}).exec(); }).exec();
completions += 1;
} else { } else {
completions = await Tasks.Task.countDocuments({ completions = await Tasks.Task.countDocuments({
'group.taskId': masterTask._id, 'group.taskId': masterTask._id,
@@ -40,12 +39,8 @@ async function _evaluateAllAssignedCompletion (masterTask) {
} }
} }
async function handleSharedCompletion (groupMemberTask) { async function handleSharedCompletion (masterTask, groupMemberTask) {
const masterTask = await Tasks.Task.findOne({ if (masterTask.type !== 'todo') return;
_id: groupMemberTask.group.taskId,
}).exec();
if (!masterTask || !masterTask.group || masterTask.type !== 'todo') return;
if (masterTask.group.sharedCompletion === SHARED_COMPLETION.single) { if (masterTask.group.sharedCompletion === SHARED_COMPLETION.single) {
await _deleteUnfinishedTasks(groupMemberTask); await _deleteUnfinishedTasks(groupMemberTask);

View File

@@ -1,13 +1,25 @@
import moment from 'moment'; import moment from 'moment';
import _ from 'lodash'; import _ from 'lodash';
import validator from 'validator';
import * as Tasks from '../models/task'; import * as Tasks from '../models/task';
import apiError from './apiError';
import { import {
BadRequest, BadRequest,
NotAuthorized,
NotFound,
} from './errors'; } from './errors';
import { import {
SHARED_COMPLETION, SHARED_COMPLETION,
handleSharedCompletion,
} from './groupTasks'; } from './groupTasks';
import shared from '../../common'; import shared from '../../common';
import { model as Group } from '../models/group'; // eslint-disable-line import/no-cycle
import { model as User } from '../models/user'; // eslint-disable-line import/no-cycle
import { taskScoredWebhook } from './webhook'; // eslint-disable-line import/no-cycle
import logger from './logger';
const requiredGroupFields = '_id leader tasksOrder name';
async function _validateTaskAlias (tasks, res) { async function _validateTaskAlias (tasks, res) {
const tasksWithAliases = tasks.filter(task => task.alias); const tasksWithAliases = tasks.filter(task => task.alias);
@@ -291,3 +303,280 @@ export function moveTask (order, taskId, to) {
order.splice(to, 0, taskId); order.splice(to, 0, taskId);
} }
} }
async function handleChallengeTask (task, delta, direction) {
if (task.challenge && task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') {
// Wrapping everything in a try/catch block because if an error occurs
// using `await` it MUST NOT bubble up because the request has already been handled
try {
const chalTask = await Tasks.Task.findOne({
_id: task.challenge.taskId,
}).exec();
if (!chalTask) return;
await chalTask.scoreChallengeTask(delta, direction);
} catch (e) {
logger.error(e, 'Error scoring challenge task');
}
}
}
async function handleGroupTask (task, delta, direction) {
if (task.group && task.group.taskId) {
// Wrapping everything in a try/catch block because if an error occurs
// using `await` it MUST NOT bubble up because the request has already been handled
try {
const groupTask = await Tasks.Task.findOne({
_id: task.group.taskId,
}).exec();
if (groupTask) {
await handleSharedCompletion(groupTask, task);
const groupDelta = groupTask.group.assignedUsers
? delta / groupTask.group.assignedUsers.length
: delta;
await groupTask.scoreChallengeTask(groupDelta, direction);
}
} catch (e) {
logger.error(e, 'Error scoring group task');
}
}
}
/**
* Scores a task.
* @param user the user that is making the operation
* @param task The task to score
* @param direction "up" or "down" depending on how the task should be scored
*
* @return Response Data
*/
async function scoreTask (user, task, direction, req, res) {
if (task.type === 'daily' || task.type === 'todo') {
if (task.completed && direction === 'up') {
throw new NotAuthorized(res.t('sessionOutdated'));
} else if (!task.completed && direction === 'down') {
throw new NotAuthorized(res.t('sessionOutdated'));
}
}
if (task.group.approval.required && !task.group.approval.approved) {
const fields = requiredGroupFields.concat(' managers');
const group = await Group.getGroup({ user, groupId: task.group.id, fields });
const managerIds = Object.keys(group.managers);
managerIds.push(group.leader);
if (managerIds.indexOf(user._id) !== -1) {
task.group.approval.approved = true;
task.group.approval.requested = true;
task.group.approval.requestedDate = new Date();
} else {
if (task.group.approval.requested) {
return {
task,
requiresApproval: true,
message: res.t('taskRequiresApproval'),
};
}
task.group.approval.requested = true;
task.group.approval.requestedDate = new Date();
const managers = await User.find({ _id: managerIds }, 'notifications preferences').exec(); // Use this method so we can get access to notifications
// @TODO: we can use the User.pushNotification function because
// we need to ensure notifications are translated
const managerPromises = [];
managers.forEach(manager => {
manager.addNotification('GROUP_TASK_APPROVAL', {
message: res.t('userHasRequestedTaskApproval', {
user: user.profile.name,
taskName: task.text,
}, manager.preferences.language),
groupId: group._id,
// user task id, used to match the notification when the task is approved
taskId: task._id,
userId: user._id,
groupTaskId: task.group.taskId, // the original task id
direction,
});
managerPromises.push(manager.save());
});
managerPromises.push(task.save());
await Promise.all(managerPromises);
return {
task,
requiresApproval: true,
message: res.t('taskApprovalHasBeenRequested'),
};
}
}
if (task.group.approval.required && task.group.approval.approved) {
const notificationIndex = user.notifications.findIndex(notification => notification
&& notification.data && notification.data.task
&& notification.data.task._id === task._id && notification.type === 'GROUP_TASK_APPROVED');
if (notificationIndex !== -1) {
user.notifications.splice(notificationIndex, 1);
}
}
const wasCompleted = task.completed;
const firstTask = !user.achievements.completedTask;
const [delta] = shared.ops.scoreTask({ task, user, direction }, req, res.analytics);
// 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' && !firstTask) shared.fns.randomDrop(user, { task, delta }, req, res.analytics);
// If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list
// TODO move to common code?
let pullTask = false;
let pushTask = false;
if (task.type === 'todo') {
if (!wasCompleted && task.completed) {
// @TODO: mongoose's push and pull should be atomic and help with
// our concurrency issues. If not, we need to use this update $pull and $push
pullTask = true;
// user.tasksOrder.todos.pull(task._id);
} else if (
wasCompleted
&& !task.completed
&& user.tasksOrder.todos.indexOf(task._id) === -1
) {
pushTask = true;
// user.tasksOrder.todos.push(task._id);
}
}
setNextDue(task, user);
taskScoredWebhook.send(user, {
task,
direction,
delta,
user,
});
// Track when new users (first 7 days) score tasks
if (moment().diff(user.auth.timestamps.created, 'days') < 7) {
res.analytics.track('task score', {
uuid: user._id,
hitType: 'event',
category: 'behavior',
taskType: task.type,
direction,
headers: req.headers,
});
}
return {
task,
delta,
direction,
pullTask,
pushTask,
// clone user._tmp so that it's not overwritten by other score operations
// when using the bulk scoring API
_tmp: _.cloneDeep(user._tmp),
};
}
export async function scoreTasks (user, taskScorings, req, res) {
// Validate the parameters
// taskScorings must be array with at least one value
if (!taskScorings || !Array.isArray(taskScorings) || taskScorings.length < 1) {
throw new BadRequest(apiError('invalidTaskScorings'));
}
taskScorings.forEach(({ id, direction }) => {
if (!['up', 'down'].includes(direction)) throw new BadRequest(apiError('directionUpDown'));
if (typeof id !== 'string') throw new BadRequest(apiError('invalidTaskIdentifier'));
});
// Get an array of tasks identifiers
const taskIds = taskScorings.map(taskScoring => taskScoring.id);
const tasks = {};
(await Tasks.Task.findMultipleByIdOrAlias(taskIds, user._id)).forEach(task => {
tasks[task._id] = task;
if (task.alias) {
tasks[task.alias] = task;
}
});
if (Object.keys(tasks).length === 0) throw new NotFound(res.t('taskNotFound'));
// Score each task separately to make sure changes to user._tmp don't overlap.
// scoreTask is an async function but the only async operation happens when a group task
// is involved
// @TODO refactor user._tmp to allow more than one task scoring - breaking change
const returnDatas = [];
for (const taskScoring of taskScorings) {
const task = tasks[taskScoring.id];
if (task) {
// If one of the task scoring fails the entire operation will result in a failure
// It's the only way to ensure the user doesn't end up in an inconsistent state.
returnDatas.push(await scoreTask( // eslint-disable-line no-await-in-loop
user,
task,
taskScoring.direction,
req,
res,
));
}
}
const savePromises = [user.save()];
// Save the tasks, use the tasks object and not returnDatas.*.task to avoid saving the same
// task twice, allows scoring the same task multiple times in a single request
Object.keys(tasks).forEach(identifier => {
// Tasks identified by an alias exists with two keys (id and alias) in the tasks object
// ignore the alias to avoid saving them twice
if (validator.isUUID(String(identifier)) && tasks[identifier].isModified()) {
savePromises.push(tasks[identifier].save());
}
});
// Handle todos removal or addition to the tasksOrder array
const pullIDs = [];
const pushIDs = [];
returnDatas.forEach(returnData => {
if (returnData.pushTask === true) pushIDs.push(returnData.task._id);
if (returnData.pullTask === true) pullIDs.push(returnData.task._id);
});
const moveUpdateObject = {};
if (pushIDs.length > 0) moveUpdateObject.$push = { 'tasksOrder.todos': { $each: pushIDs } };
if (pullIDs.length > 0) moveUpdateObject.$pull = { 'tasksOrder.todos': { $in: pullIDs } };
if (pushIDs.length > 0 || pullIDs.length > 0) {
savePromises.push(user.updateOne(moveUpdateObject).exec());
}
await Promise.all(savePromises);
return returnDatas.map(data => {
// Handle challenge and group tasks tasks here because the task must have been saved first
handleChallengeTask(data.task, data.delta, data.direction);
handleGroupTask(data.task, data.delta, data.direction);
// Handle group tasks that require approval
if (data.requiresApproval === true) {
return {
id: data.task._id, message: data.message, requiresApproval: true,
};
}
return { id: data.task._id, delta: data.delta, _tmp: data._tmp };
});
}

View File

@@ -12,7 +12,10 @@ import { removeFromArray } from '../libs/collectionManipulators';
import shared from '../../common'; import shared from '../../common';
import { sendTxn as txnEmail } from '../libs/email'; // eslint-disable-line import/no-cycle import { sendTxn as txnEmail } from '../libs/email'; // eslint-disable-line import/no-cycle
import { sendNotification as sendPushNotification } from '../libs/pushNotifications'; // eslint-disable-line import/no-cycle import { sendNotification as sendPushNotification } from '../libs/pushNotifications'; // eslint-disable-line import/no-cycle
import { syncableAttrs, setNextDue } from '../libs/taskManager'; import { // eslint-disable-line import/no-cycle
syncableAttrs,
setNextDue,
} from '../libs/taskManager';
const { Schema } = mongoose; const { Schema } = mongoose;

View File

@@ -29,7 +29,7 @@ import {
import baseModel from '../libs/baseModel'; import baseModel from '../libs/baseModel';
import { sendTxn as sendTxnEmail } from '../libs/email'; // eslint-disable-line import/no-cycle import { sendTxn as sendTxnEmail } from '../libs/email'; // eslint-disable-line import/no-cycle
import { sendNotification as sendPushNotification } from '../libs/pushNotifications'; // eslint-disable-line import/no-cycle import { sendNotification as sendPushNotification } from '../libs/pushNotifications'; // eslint-disable-line import/no-cycle
import { import { // eslint-disable-line import/no-cycle
syncableAttrs, syncableAttrs,
} from '../libs/taskManager'; } from '../libs/taskManager';
import { import {

View File

@@ -4,7 +4,6 @@ import moment from 'moment';
import _ from 'lodash'; import _ from 'lodash';
import shared from '../../common'; import shared from '../../common';
import baseModel from '../libs/baseModel'; import baseModel from '../libs/baseModel';
import { InternalServerError } from '../libs/errors';
import { preenHistory } from '../libs/preening'; import { preenHistory } from '../libs/preening';
import { SHARED_COMPLETION } from '../libs/groupTasks'; // eslint-disable-line import/no-cycle import { SHARED_COMPLETION } from '../libs/groupTasks'; // eslint-disable-line import/no-cycle
@@ -191,10 +190,8 @@ TaskSchema.statics.findByIdOrAlias = async function findByIdOrAlias (
userId, userId,
additionalQueries = {}, additionalQueries = {},
) { ) {
// not using i18n strings because these errors if (!identifier) throw new Error('Task identifier is a required argument');
// are meant for devs who forgot to pass some parameters if (!userId) throw new Error('User identifier is a required argument');
if (!identifier) throw new InternalServerError('Task identifier is a required argument');
if (!userId) throw new InternalServerError('User identifier is a required argument');
const query = _.cloneDeep(additionalQueries); const query = _.cloneDeep(additionalQueries);
@@ -210,6 +207,38 @@ TaskSchema.statics.findByIdOrAlias = async function findByIdOrAlias (
return task; return task;
}; };
TaskSchema.statics.findMultipleByIdOrAlias = async function findByIdOrAlias (
identifiers,
userId,
additionalQueries = {},
) {
if (!identifiers || !Array.isArray(identifiers)) throw new Error('Task identifiers is a required array argument');
if (!userId) throw new Error('User identifier is a required argument');
const query = _.cloneDeep(additionalQueries);
query.userId = userId;
const ids = [];
const aliases = [];
identifiers.forEach(identifier => {
if (validator.isUUID(String(identifier))) {
ids.push(identifier);
} else {
aliases.push(identifier);
}
});
query.$or = [
{ _id: { $in: ids } },
{ alias: { $in: aliases } },
];
const tasks = await this.find(query).exec();
return tasks;
};
// Sanitize user tasks linked to a challenge // Sanitize user tasks linked to a challenge
// See http://habitica.fandom.com/wiki/Challenges#Challenge_Participant.27s_Permissions for more info // See http://habitica.fandom.com/wiki/Challenges#Challenge_Participant.27s_Permissions for more info
TaskSchema.statics.sanitizeUserChallengeTask = function sanitizeUserChallengeTask (taskObj) { TaskSchema.statics.sanitizeUserChallengeTask = function sanitizeUserChallengeTask (taskObj) {
@@ -244,6 +273,7 @@ TaskSchema.statics.sanitizeReminder = function sanitizeReminder (reminderObj) {
return reminderObj; return reminderObj;
}; };
// NOTE: this is used for group tasks as well
TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta, direction) { TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta, direction) {
const chalTask = this; const chalTask = this;

View File

@@ -33,7 +33,7 @@ const NOTIFICATION_TYPES = [
'NEW_INBOX_MESSAGE', 'NEW_INBOX_MESSAGE',
'NEW_STUFF', 'NEW_STUFF',
'NEW_CHAT_MESSAGE', 'NEW_CHAT_MESSAGE',
'LEVELED_UP', 'LEVELED_UP', // Not in use
'FIRST_DROPS', 'FIRST_DROPS',
'ONBOARDING_COMPLETE', 'ONBOARDING_COMPLETE',
'ACHIEVEMENT_ALL_YOUR_BASE', 'ACHIEVEMENT_ALL_YOUR_BASE',