mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-13 20:57:24 +01:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,6 +38,7 @@ yarn.lock
|
||||
.elasticbeanstalk/*
|
||||
!.elasticbeanstalk/*.cfg.yml
|
||||
!.elasticbeanstalk/*.global.yml
|
||||
|
||||
/.vscode
|
||||
|
||||
# webstorm fake webpack for path intellisense
|
||||
|
||||
@@ -48,17 +48,17 @@ gulp.task('build:prepare-mongo', async () => {
|
||||
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
|
||||
const runRsProcess = spawn('run-rs', ['-v', '4.2.8', '-l', 'ubuntu1804', '--dbpath', 'mongodb-data', '--number', '1', '--quiet']);
|
||||
|
||||
for await (const chunk of runRsProcess.stdout) {
|
||||
const stringChunk = chunk.toString();
|
||||
console.log(stringChunk);
|
||||
console.log(stringChunk); // eslint-disable-line no-console
|
||||
// kills the process after the replica set is setup
|
||||
if (stringChunk.includes('Started replica set')) {
|
||||
console.log('MongoDB setup correctly.');
|
||||
console.log('MongoDB setup correctly.'); // eslint-disable-line no-console
|
||||
runRsProcess.kill();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 User } from '../../../../website/server/models/user';
|
||||
import * as Tasks from '../../../../website/server/models/task';
|
||||
import { InternalServerError } from '../../../../website/server/libs/errors';
|
||||
import { generateHistory } from '../../../helpers/api-unit.helper';
|
||||
|
||||
describe('Task Model', () => {
|
||||
@@ -99,7 +98,8 @@ describe('Task Model', () => {
|
||||
throw new Error('No exception when Id is None');
|
||||
} catch (err) {
|
||||
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');
|
||||
} catch (err) {
|
||||
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 ', () => {
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import apiError from '../../../../../website/server/libs/apiError';
|
||||
import {
|
||||
generateUser,
|
||||
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({
|
||||
code: 400,
|
||||
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}`);
|
||||
|
||||
expect(task.completed).to.equal(true);
|
||||
expect(task.value).to.be.greaterThan(daily.value);
|
||||
});
|
||||
|
||||
it('uncompletes daily when direction is down', async () => {
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('POST /tasks/: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'));
|
||||
|
||||
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 expect(member.post(`/tasks/${syncedTask._id}/score/up`))
|
||||
.to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('taskRequiresApproval'),
|
||||
});
|
||||
const response = await member.post(`/tasks/${syncedTask._id}/score/up`);
|
||||
expect(response.data.requiresApproval).to.equal(true);
|
||||
expect(response.message).to.equal(t('taskRequiresApproval'));
|
||||
});
|
||||
|
||||
it('allows a user to score an approved task', async () => {
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('PUT /tasks/:id', () => {
|
||||
// score up to trigger approval
|
||||
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'));
|
||||
});
|
||||
|
||||
|
||||
583
test/api/v4/tasks/POST-tasks-bulk-score.test.js
Normal file
583
test/api/v4/tasks/POST-tasks-bulk-score.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
font-weight: bold;
|
||||
line-height: 1.71;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.25rem 1rem;
|
||||
padding: 0.219rem 1rem;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 1px 3px 0 rgba($black, 0.12), 0 1px 2px 0 rgba($black, 0.24);
|
||||
color: $white;
|
||||
|
||||
@@ -48,7 +48,7 @@ export default {
|
||||
computed: {
|
||||
...mapState({
|
||||
user: 'user.data',
|
||||
groupPlans: 'groupPlans',
|
||||
groupPlans: 'groupPlans.data',
|
||||
}),
|
||||
currentGroup () {
|
||||
const groupFound = this.groupPlans.find(group => group._id === this.groupId);
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
'active': $route.path.startsWith('/group-plans')}"
|
||||
>
|
||||
<div
|
||||
v-if="groupPlans.length > 0"
|
||||
v-if="groupPlans && groupPlans.length > 0"
|
||||
class="chevron rotate"
|
||||
@click="dropdownMobile($event)"
|
||||
>
|
||||
@@ -761,7 +761,7 @@ export default {
|
||||
...mapState({
|
||||
user: 'user.data',
|
||||
userHourglasses: 'user.data.purchased.plan.consecutive.trinkets',
|
||||
groupPlans: 'groupPlans',
|
||||
groupPlans: 'groupPlans.data',
|
||||
modalStack: 'modalStack',
|
||||
}),
|
||||
navbarZIndexClass () {
|
||||
@@ -789,7 +789,7 @@ export default {
|
||||
this.isUserDropdownOpen = !this.isUserDropdownOpen;
|
||||
},
|
||||
async getUserGroupPlans () {
|
||||
this.$store.state.groupPlans = await this.$store.dispatch('guilds:getGroupPlans');
|
||||
await this.$store.dispatch('guilds:getGroupPlans');
|
||||
},
|
||||
openPartyModal () {
|
||||
this.$root.$emit('bv::show::modal', 'create-party-modal');
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<div>
|
||||
<yesterdaily-modal
|
||||
:yester-dailies="yesterDailies"
|
||||
@run-cron="runYesterDailiesAction()"
|
||||
:cron-action="runCronAction"
|
||||
@hidden="afterYesterdailies()"
|
||||
/>
|
||||
<armoire-empty />
|
||||
<new-stuff />
|
||||
@@ -116,7 +117,7 @@ import { mapState } from '@/libs/store';
|
||||
import notifications from '@/mixins/notifications';
|
||||
import guide from '@/mixins/guide';
|
||||
|
||||
import yesterdailyModal from './yesterdailyModal';
|
||||
import yesterdailyModal from './tasks/yesterdailyModal';
|
||||
import newStuff from './achievements/newStuff';
|
||||
import death from './achievements/death';
|
||||
import lowHealth from './achievements/lowHealth';
|
||||
@@ -422,7 +423,6 @@ export default {
|
||||
unlockLevels,
|
||||
lastShownNotifications,
|
||||
alreadyReadNotification,
|
||||
isRunningYesterdailies: false,
|
||||
nextCron: null,
|
||||
handledNotifications,
|
||||
};
|
||||
@@ -474,6 +474,10 @@ export default {
|
||||
|
||||
const money = after - before;
|
||||
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) {
|
||||
bonus = this.user._tmp.streakBonus || 0;
|
||||
}
|
||||
@@ -616,6 +620,7 @@ export default {
|
||||
}
|
||||
|
||||
// Lvl evaluation
|
||||
// @TODO use LEVELED_UP notification, would remove the need to check for yesterdailies
|
||||
if (afterLvl !== beforeLvl) {
|
||||
if (afterLvl <= beforeLvl || this.$store.state.isRunningYesterdailies) return;
|
||||
this.showLevelUpNotifications(afterLvl);
|
||||
@@ -656,7 +661,12 @@ export default {
|
||||
showLevelUpNotifications (newlevel) {
|
||||
this.lvl();
|
||||
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.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
|
||||
this.nextCron = Number(nextCron.format('x'));
|
||||
this.$store.state.isRunningYesterdailies = false;
|
||||
},
|
||||
async runYesterDailies () {
|
||||
if (this.$store.state.isRunningYesterdailies) return;
|
||||
this.$store.state.isRunningYesterdailies = true;
|
||||
|
||||
if (!this.user.needsCron) {
|
||||
this.scheduleNextCron();
|
||||
this.handleUserNotifications(this.user.notifications);
|
||||
this.afterYesterdailies();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -717,25 +725,25 @@ export default {
|
||||
});
|
||||
|
||||
if (this.yesterDailies.length === 0) {
|
||||
this.runYesterDailiesAction();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runCronAction();
|
||||
this.afterYesterdailies();
|
||||
} else {
|
||||
this.levelBeforeYesterdailies = this.user.stats.lvl;
|
||||
this.$root.$emit('bv::show::modal', 'yesterdaily');
|
||||
}
|
||||
},
|
||||
async runYesterDailiesAction () {
|
||||
async runCronAction () {
|
||||
// Run Cron
|
||||
await axios.post('/api/v4/cron');
|
||||
|
||||
// Notifications
|
||||
|
||||
// Sync
|
||||
await Promise.all([
|
||||
this.$store.dispatch('user:fetch', { forceLoad: true }),
|
||||
this.$store.dispatch('tasks:fetchUserTasks', { forceLoad: true }),
|
||||
]);
|
||||
|
||||
},
|
||||
afterYesterdailies () {
|
||||
this.scheduleNextCron();
|
||||
this.$store.state.isRunningYesterdailies = false;
|
||||
|
||||
if (
|
||||
@@ -744,8 +752,6 @@ export default {
|
||||
) {
|
||||
this.showLevelUpNotifications(this.user.stats.lvl);
|
||||
}
|
||||
|
||||
this.scheduleNextCron();
|
||||
this.handleUserNotifications(this.user.notifications);
|
||||
},
|
||||
async handleUserNotifications (after) {
|
||||
|
||||
@@ -371,7 +371,7 @@ export default {
|
||||
draggable,
|
||||
},
|
||||
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 where this component is called
|
||||
props: {
|
||||
|
||||
@@ -846,7 +846,18 @@ export default {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
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 () {
|
||||
return {
|
||||
random: uuid(), // used to avoid conflicts between checkboxes ids
|
||||
@@ -1039,8 +1050,14 @@ export default {
|
||||
castEnd (e, task) {
|
||||
setTimeout(() => this.$root.$emit('castEnd', task, 'task', e), 0);
|
||||
},
|
||||
score (direction) {
|
||||
async score (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) {
|
||||
if (this.$store.state.isRunningYesterdailies) return;
|
||||
|
||||
189
website/client/src/components/tasks/yesterdailyModal.vue
Normal file
189
website/client/src/components/tasks/yesterdailyModal.vue
Normal 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>
|
||||
60
website/client/src/components/ui/loadingSpinner.vue
Normal file
60
website/client/src/components/ui/loadingSpinner.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from 'axios';
|
||||
import Vue from 'vue';
|
||||
|
||||
import * as Analytics from '@/libs/analytics';
|
||||
@@ -15,29 +14,24 @@ export default {
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
async taskScore (task, direction) {
|
||||
if (this.castingSpell) return;
|
||||
async beforeTaskScore (task) {
|
||||
const { user } = this;
|
||||
|
||||
const Content = this.$store.state.content;
|
||||
if (this.castingSpell) return;
|
||||
|
||||
if (task.group.approval.required && !task.group.approval.approved) {
|
||||
task.group.approval.requested = true;
|
||||
const groupResponse = await axios.get(`/api/v4/groups/${task.group.id}`);
|
||||
const managers = Object.keys(groupResponse.data.data.managers);
|
||||
managers.push(groupResponse.data.data.leader._id);
|
||||
const { data: groupPlans } = await this.$store.dispatch('guilds:getGroupPlans');
|
||||
const groupPlan = groupPlans.find(g => g.id === task.group.id);
|
||||
if (groupPlan) {
|
||||
const managers = Object.keys(groupPlan.managers);
|
||||
managers.push(groupPlan.leader);
|
||||
if (managers.indexOf(user._id) !== -1) {
|
||||
task.group.approval.approved = true;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
scoreTask({ task, user, direction });
|
||||
} catch (err) {
|
||||
this.text(err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
},
|
||||
playTaskScoreSound (task, direction) {
|
||||
switch (task.type) { // eslint-disable-line default-case
|
||||
case 'habit':
|
||||
this.$root.$emit('playSound', direction === 'up' ? 'Plus_Habit' : 'Minus_Habit');
|
||||
@@ -52,15 +46,40 @@ export default {
|
||||
this.$root.$emit('playSound', 'Reward');
|
||||
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();
|
||||
const response = await axios.post(`/api/v4/tasks/${task._id}/score/${direction}`);
|
||||
// used to notify drops, critical hits and other bonuses
|
||||
const tmp = response.data.data._tmp || {};
|
||||
const { crit } = tmp;
|
||||
const { drop } = tmp;
|
||||
const { firstDrops } = tmp;
|
||||
const { quest } = tmp;
|
||||
const response = await this.$store.dispatch('tasks:score', {
|
||||
taskId: task._id,
|
||||
direction,
|
||||
});
|
||||
|
||||
this.handleTaskScoreNotifications(response.data.data._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) {
|
||||
const critBonus = crit * 100 - 100;
|
||||
@@ -126,6 +145,9 @@ export default {
|
||||
} else if (drop.type === 'Quest') {
|
||||
// TODO $rootScope.selectedQuest = Content.quests[drop.key];
|
||||
// $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 {
|
||||
// Keep support for another type of drops that might be added
|
||||
this.drop(drop.dialog);
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from 'axios';
|
||||
import omit from 'lodash/omit';
|
||||
import findIndex from 'lodash/findIndex';
|
||||
import * as Analytics from '@/libs/analytics';
|
||||
import { loadAsyncResource } from '@/libs/asyncResource';
|
||||
|
||||
export async function getPublicGuilds (store, payload) {
|
||||
const params = {
|
||||
@@ -201,7 +202,14 @@ export async function removeManager (store, payload) {
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function getGroupPlans () {
|
||||
const response = await axios.get('/api/v4/group-plans');
|
||||
export function getGroupPlans (store, forceLoad = false) {
|
||||
return loadAsyncResource({
|
||||
store,
|
||||
path: 'groupPlans',
|
||||
url: '/api/v4/group-plans',
|
||||
deserialize (response) {
|
||||
return response.data.data;
|
||||
},
|
||||
forceLoad,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,6 +122,18 @@ export async function save (store, editedTask) {
|
||||
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 }) {
|
||||
await axios.post(`/api/v4/tasks/${taskId}/checklist/${itemId}/score`);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function () {
|
||||
notificationStore: [],
|
||||
modalStack: [],
|
||||
equipmentDrawerOpen: true,
|
||||
groupPlans: [],
|
||||
groupPlans: asyncResourceFactory(),
|
||||
isRunningYesterdailies: false,
|
||||
privateMessageOptions: {
|
||||
userIdToMessage: '',
|
||||
|
||||
@@ -161,7 +161,6 @@
|
||||
"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 given tag.",
|
||||
"strengthExample": "Relating to exercise and activity",
|
||||
"intelligenceExample": "Relating to academic or mentally challenging pursuits",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
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}].',
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function randomDrop (user, options, req = {}, analytics) {
|
||||
size(user.items.eggs) < 1
|
||||
&& size(user.items.hatchingPotions) < 1
|
||||
) {
|
||||
// @TODO why are we using both _tmp.firstDrops and the FIRST_DROPS notification?
|
||||
user._tmp.firstDrops = firstDrops(user);
|
||||
return;
|
||||
}
|
||||
@@ -150,6 +151,7 @@ export default function randomDrop (user, options, req = {}, analytics) {
|
||||
}, req.language);
|
||||
}
|
||||
|
||||
// @TODO use notifications
|
||||
user._tmp.drop = drop;
|
||||
user.items.lastDrop.date = Number(new Date());
|
||||
user.items.lastDrop.count += 1;
|
||||
|
||||
@@ -241,8 +241,13 @@ export default function scoreTask (options = {}, req = {}, analytics) {
|
||||
// This is for setting one-time temporary flags,
|
||||
// such as streakBonus or itemDropped. Useful for notifying
|
||||
// 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 = {};
|
||||
|
||||
if (oldLeveledUp) user._tmp.leveledUp = oldLeveledUp;
|
||||
|
||||
// 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));
|
||||
|
||||
|
||||
@@ -1198,7 +1198,7 @@ api.inviteToGroup = {
|
||||
* @apiParamExample {String} party:
|
||||
* /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
|
||||
*
|
||||
@@ -1248,7 +1248,7 @@ api.addGroupManager = {
|
||||
* @apiParamExample {String} party:
|
||||
* /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
|
||||
*
|
||||
|
||||
@@ -3,14 +3,11 @@ import moment from 'moment';
|
||||
import { authWithHeaders } from '../../middlewares/auth';
|
||||
import {
|
||||
taskActivityWebhook,
|
||||
taskScoredWebhook,
|
||||
} from '../../libs/webhook';
|
||||
import { removeFromArray } from '../../libs/collectionManipulators';
|
||||
import * as Tasks from '../../models/task';
|
||||
import { handleSharedCompletion } from '../../libs/groupTasks';
|
||||
import { model as Challenge } from '../../models/challenge';
|
||||
import { model as Group } from '../../models/group';
|
||||
import { model as User } from '../../models/user';
|
||||
import {
|
||||
NotFound,
|
||||
NotAuthorized,
|
||||
@@ -21,9 +18,9 @@ import {
|
||||
getTasks,
|
||||
moveTask,
|
||||
setNextDue,
|
||||
scoreTasks,
|
||||
} from '../../libs/taskManager';
|
||||
import common from '../../../common';
|
||||
import logger from '../../libs/logger';
|
||||
import apiError from '../../libs/apiError';
|
||||
|
||||
// @TODO abstract, see api-v3/tasks/groups.js
|
||||
@@ -736,10 +733,11 @@ api.updateTask = {
|
||||
*
|
||||
* @apiSuccess {Object} data The user stats
|
||||
* @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 (202) {Boolean} data.requiresApproval Approval was requested for team task
|
||||
* @apiSuccess (202) {String} message Acknowledgment of team task approval request
|
||||
*
|
||||
* @apiSuccessExample {json} Example result:
|
||||
* {"success":true,"data":{"delta":0.9746999906450404,"_tmp":{},"hp":49.06645205596985,
|
||||
* "mp":37.2008917491047,"exp":101.93810026267543,"gp":77.09694176716997,
|
||||
@@ -766,188 +764,24 @@ api.scoreTask = {
|
||||
url: '/tasks/:taskId/score/:direction',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']);
|
||||
|
||||
const validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
// Parameters are validated in scoreTasks
|
||||
|
||||
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 { direction } = req.params;
|
||||
const userStats = user.stats.toJSON();
|
||||
|
||||
if (!task) throw new NotFound(res.t('taskNotFound'));
|
||||
|
||||
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();
|
||||
// group tasks that require a manager's approval
|
||||
if (taskResponse.requiresApproval === true) {
|
||||
res.respond(202, { requiresApproval: true }, taskResponse.message);
|
||||
} else {
|
||||
if (task.group.approval.requested) {
|
||||
throw new NotAuthorized(res.t('taskRequiresApproval'));
|
||||
}
|
||||
const resJsonData = _.assign({
|
||||
delta: taskResponse.delta,
|
||||
_tmp: user._tmp,
|
||||
}, userStats);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../../libs/taskManager';
|
||||
import { handleSharedCompletion } from '../../../libs/groupTasks';
|
||||
import apiError from '../../../libs/apiError';
|
||||
import logger from '../../../libs/logger';
|
||||
|
||||
const requiredGroupFields = '_id leader tasksOrder name';
|
||||
// @TODO: abstract to task lib
|
||||
@@ -384,13 +385,25 @@ api.approveTask = {
|
||||
direction,
|
||||
});
|
||||
|
||||
await handleSharedCompletion(task);
|
||||
|
||||
approvalPromises.push(task.save());
|
||||
approvalPromises.push(assignedUser.save());
|
||||
await Promise.all(approvalPromises);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
69
website/server/controllers/api-v4/tasks.js
Normal file
69
website/server/controllers/api-v4/tasks.js
Normal 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;
|
||||
@@ -28,7 +28,6 @@ async function _evaluateAllAssignedCompletion (masterTask) {
|
||||
'group.taskId': masterTask._id,
|
||||
'group.approval.approved': true,
|
||||
}).exec();
|
||||
completions += 1;
|
||||
} else {
|
||||
completions = await Tasks.Task.countDocuments({
|
||||
'group.taskId': masterTask._id,
|
||||
@@ -40,12 +39,8 @@ async function _evaluateAllAssignedCompletion (masterTask) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSharedCompletion (groupMemberTask) {
|
||||
const masterTask = await Tasks.Task.findOne({
|
||||
_id: groupMemberTask.group.taskId,
|
||||
}).exec();
|
||||
|
||||
if (!masterTask || !masterTask.group || masterTask.type !== 'todo') return;
|
||||
async function handleSharedCompletion (masterTask, groupMemberTask) {
|
||||
if (masterTask.type !== 'todo') return;
|
||||
|
||||
if (masterTask.group.sharedCompletion === SHARED_COMPLETION.single) {
|
||||
await _deleteUnfinishedTasks(groupMemberTask);
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
import validator from 'validator';
|
||||
import * as Tasks from '../models/task';
|
||||
import apiError from './apiError';
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
NotFound,
|
||||
} from './errors';
|
||||
import {
|
||||
SHARED_COMPLETION,
|
||||
handleSharedCompletion,
|
||||
} from './groupTasks';
|
||||
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) {
|
||||
const tasksWithAliases = tasks.filter(task => task.alias);
|
||||
@@ -291,3 +303,280 @@ export function moveTask (order, taskId, to) {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ import { removeFromArray } from '../libs/collectionManipulators';
|
||||
import shared from '../../common';
|
||||
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 { syncableAttrs, setNextDue } from '../libs/taskManager';
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
syncableAttrs,
|
||||
setNextDue,
|
||||
} from '../libs/taskManager';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import baseModel from '../libs/baseModel';
|
||||
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 {
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
syncableAttrs,
|
||||
} from '../libs/taskManager';
|
||||
import {
|
||||
|
||||
@@ -4,7 +4,6 @@ import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
import shared from '../../common';
|
||||
import baseModel from '../libs/baseModel';
|
||||
import { InternalServerError } from '../libs/errors';
|
||||
import { preenHistory } from '../libs/preening';
|
||||
import { SHARED_COMPLETION } from '../libs/groupTasks'; // eslint-disable-line import/no-cycle
|
||||
|
||||
@@ -191,10 +190,8 @@ TaskSchema.statics.findByIdOrAlias = async function findByIdOrAlias (
|
||||
userId,
|
||||
additionalQueries = {},
|
||||
) {
|
||||
// not using i18n strings because these errors
|
||||
// are meant for devs who forgot to pass some parameters
|
||||
if (!identifier) throw new InternalServerError('Task identifier is a required argument');
|
||||
if (!userId) throw new InternalServerError('User identifier is a required argument');
|
||||
if (!identifier) throw new Error('Task identifier is a required argument');
|
||||
if (!userId) throw new Error('User identifier is a required argument');
|
||||
|
||||
const query = _.cloneDeep(additionalQueries);
|
||||
|
||||
@@ -210,6 +207,38 @@ TaskSchema.statics.findByIdOrAlias = async function findByIdOrAlias (
|
||||
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
|
||||
// See http://habitica.fandom.com/wiki/Challenges#Challenge_Participant.27s_Permissions for more info
|
||||
TaskSchema.statics.sanitizeUserChallengeTask = function sanitizeUserChallengeTask (taskObj) {
|
||||
@@ -244,6 +273,7 @@ TaskSchema.statics.sanitizeReminder = function sanitizeReminder (reminderObj) {
|
||||
return reminderObj;
|
||||
};
|
||||
|
||||
// NOTE: this is used for group tasks as well
|
||||
TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta, direction) {
|
||||
const chalTask = this;
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const NOTIFICATION_TYPES = [
|
||||
'NEW_INBOX_MESSAGE',
|
||||
'NEW_STUFF',
|
||||
'NEW_CHAT_MESSAGE',
|
||||
'LEVELED_UP',
|
||||
'LEVELED_UP', // Not in use
|
||||
'FIRST_DROPS',
|
||||
'ONBOARDING_COMPLETE',
|
||||
'ACHIEVEMENT_ALL_YOUR_BASE',
|
||||
|
||||
Reference in New Issue
Block a user