diff --git a/.eslintrc b/.eslintrc
index 7613632120..85937b0e6e 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -23,7 +23,6 @@
"no-lone-blocks": 2,
"no-loop-func": 2,
"no-implicit-coercion": 2,
- "no-implied-eval": 2,
"no-native-reassign": 2,
"no-new-func": 2,
"no-new-wrappers": 2,
@@ -59,7 +58,6 @@
"no-path-concat": 2,
"arrow-spacing": 2,
"constructor-super": 2,
- "generator-star-spacing": 2,
"no-arrow-condition": 2,
"no-class-assign": 2,
"no-const-assign": 2,
diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json
index 935fdf2a1d..649adf8c94 100644
--- a/common/locales/en/api-v3.json
+++ b/common/locales/en/api-v3.json
@@ -15,7 +15,8 @@
"cantDetachFb": "Account lacks another authentication method, can't detach Facebook.",
"onlySocialAttachLocal": "Local auth can only be added to a social account.",
"invalidReqParams": "Invalid request parameters.",
- "taskIdRequired": "\"taskId\" must be a valid UUID",
+ "memberIdRequired": "\"member\" must be a valid UUID.",
+ "taskIdRequired": "\"taskId\" must be a valid UUID.",
"taskNotFound": "Task not found.",
"invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".",
"cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.",
@@ -27,5 +28,62 @@
"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."
+ "alreadyTagged": "The task is already tagged with given tag.",
+ "groupIdRequired": "\"groupId\" must be a valid UUID",
+ "groupNotFound": "Group not found.",
+ "groupTypesRequired": "You must supply a valid \"type\" query string.",
+ "questLeaderCannotLeaveGroup": "You cannot leave your party when you have started a quest. Abort the quest first.",
+ "cannotLeaveWhileActiveQuest": "You cannot leave party during an active quest. Please leave the quest first.",
+ "onlyLeaderCanRemoveMember": "Only group leader can remove a member!",
+ "memberCannotRemoveYourself": "You cannot remove yourself!",
+ "groupMemberNotFound": "User not found among group's members",
+ "challengeMemberNotFound": "User not found among challenge's members",
+ "mustBeGroupMember": "Must be member of the group.",
+ "keepOrRemoveAll": "req.query.keep must be either \"keep-all\" or \"remove-all\"",
+ "keepOrRemove": "req.query.keep must be either \"keep\" or \"remove\"",
+ "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.",
+ "inviteMissingEmail": "Missing email address in invite.",
+ "onlyGroupLeaderChal": "Only the group leader can create challenges",
+ "pubChalsMinPrize": "Prize must be at least 1 Gem for public challenges.",
+ "cantAfford": "You can't afford this prize. Purchase more gems or lower the prize amount.",
+ "challengeIdRequired": "\"challengeId\" must be a valid UUID.",
+ "winnerIdRequired": "\"winnerId\" must be a valid UUID.",
+ "challengeNotFound": "Challenge not found.",
+ "onlyLeaderDeleteChal": "Only the challenge leader can delete it.",
+ "onlyLeaderUpdateChal": "Only the challenge leader can update it.",
+ "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge.",
+ "noCompletedTodosChallenge": "\"includeComepletedTodos\" is not supported when fetching a challenge tasks.",
+ "userTasksNoChallengeId": "When \"tasksOwner\" is \"user\" \"challengeId\" can't be passed.",
+ "onlyChalLeaderEditTasks": "Tasks belonging to a challenge can only be edited by the leader.",
+ "userAlreadyInChallenge": "User is already participating in this challenge.",
+ "invalidTasksOwner": "\"tasksOwner\" must be \"user\" or \"challenge\".",
+ "partyMustbePrivate": "Parties must be private",
+ "userAlreadyInGroup": "User already in that group.",
+ "userAlreadyInvitedToGroup": "User already invited to that group.",
+ "userAlreadyPendingInvitation": "User already pending invitation.",
+ "userAlreadyInAParty": "User already in a party.",
+ "userWithIDNotFound": "User with id \"<%= userId %>\" not found.",
+ "uuidsMustBeAnArray": "UUIDs invites must be a an Array.",
+ "emailsMustBeAnArray": "Email invites must be a an Array.",
+ "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time",
+ "cantOnlyUnlinkChalTask": "Only challenges tasks can be unlinked.",
+ "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!",
+ "questInviteNotFound": "No quest invitation found.",
+ "guildQuestsNotSupported": "Guilds cannot be invited on quests.",
+ "questNotFound": "Quest \"<%= key %>\" not found.",
+ "questNotOwned": "You don't own that quest scroll.",
+ "questLevelTooHigh": "You must be Level <%= level %> to begin this quest.",
+ "questAlreadyUnderway": "Your party is already on a quest. Try again when the current quest has ended.",
+ "questAlreadyAccepted": "You already accepted the quest invitation.",
+ "noActiveQuestToLeave": "No active quest to leave",
+ "questLeaderCannotLeaveQuest": "Quest leader cannot leave quest",
+ "notPartOfQuest": "You are not part of the quest",
+ "noActiveQuestToAbort": "There is no active quest to abort.",
+ "onlyLeaderAbortQuest": "Only the group or quest leader can abort a quest.",
+ "questAlreadyRejected": "You already rejected the quest invitation.",
+ "cantCancelActiveQuest": "You can not cancel an active quest, use the abort functionality.",
+ "onlyLeaderCancelQuest": "Only the group or quest leader can cancel the quest.",
+ "questInvitationDoesNotExist": "No quest invitation has been sent out yet.",
+ "questNotPending": "There is no quest to start.",
+ "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest"
}
diff --git a/common/locales/en/quests.json b/common/locales/en/quests.json
index 7b3fe7af43..5edae258e5 100644
--- a/common/locales/en/quests.json
+++ b/common/locales/en/quests.json
@@ -78,5 +78,6 @@
"whichQuestStart": "Which quest do you want to start?",
"getMoreQuests": "Get more quests",
"unlockedAQuest": "You unlocked a quest!",
- "leveledUpReceivedQuest": "You leveled up to Level <%= level %> and received a quest scroll!"
+ "leveledUpReceivedQuest": "You leveled up to Level <%= level %> and received a quest scroll!",
+ "questInvitationDoesNotExist": "No quest invitation has been sent out yet."
}
diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js
deleted file mode 100644
index 1bc8f29927..0000000000
--- a/common/script/api-v3/cron.js
+++ /dev/null
@@ -1,250 +0,0 @@
-import moment from 'moment';
-import _ from 'lodash';
-import scoreTask from './scoreTask';
-import common from '../../';
-import {
- shouldDo,
-} from '../cron';
-
-let clearBuffs = {
- str: 0,
- int: 0,
- per: 0,
- con: 0,
- stealth: 0,
- streaks: false,
-};
-
-// At end of day, add value to all incomplete Daily & Todo tasks (further incentive)
-// For incomplete Dailys, deduct experience
-// Make sure to run this function once in a while as server will not take care of overnight calculations.
-// And you have to run it every time client connects.
-export default function cron (options = {}) {
- let {user, tasks, tasksByType, analytics, now, daysMissed} = options;
-
- user.auth.timestamps.loggedin = now;
- user.lastCron = now;
- // Reset the lastDrop count to zero
- if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0;
-
- // "Perfect Day" achievement for perfect-days
- let perfect = true;
-
- // end-of-month perks for subscribers
- let plan = user.purchased.plan;
- if (user.isSubscribed()) {
- if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) {
- plan.gemsBought = 0; // reset gem-cap
- plan.dateUpdated = now;
- // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks
- // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0
- // TODO use month diff instead of ++ / --?
- _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317
- plan.consecutive.count++;
- if (plan.consecutive.offset > 0) {
- plan.consecutive.offset--;
- } else if (plan.consecutive.count % 3 === 0) { // every 3 months
- plan.consecutive.trinkets++;
- plan.consecutive.gemCapExtra += 5;
- if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25)
- }
- }
-
- // If user cancelled subscription, we give them until 30day's end until it terminates
- if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) {
- _.merge(plan, {
- planId: null,
- customerId: null,
- paymentMethod: null,
- });
-
- _.merge(plan.consecutive, {
- count: 0,
- offset: 0,
- gemCapExtra: 0,
- });
-
- user.markModified('purchased.plan'); // TODO necessary?
- }
- }
-
- // User is resting at the inn.
- // On cron, buffs are cleared and all dailies are reset without performing damage
- if (user.preferences.sleep === true) {
- user.stats.buffs = _.cloneDeep(clearBuffs);
-
- tasksByType.dailys.forEach((daily) => {
- let completed = daily.completed;
- let thatDay = moment(now).subtract({days: 1});
-
- if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) {
- daily.checklist.forEach(box => box.completed = false);
- }
- daily.completed = false;
- });
-
- return;
- }
-
- let multiDaysCountAsOneDay = true;
- // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day.
- // When site-wide difficulty settings are introduced, this can be a user preference option.
-
- // Tally each task
- let todoTally = 0;
-
- tasksByType.todos.forEach((task) => { // make uncompleted todos redder
- let completed = task.completed;
- scoreTask({
- task,
- user,
- direction: 'down',
- cron: true,
- times: multiDaysCountAsOneDay ? 1 : daysMissed,
- // TODO pass req for analytics?
- });
-
- let absVal = completed ? Math.abs(task.value) : task.value;
- todoTally += absVal;
- });
-
- let dailyChecked = 0; // how many dailies were checked?
- let dailyDueUnchecked = 0; // how many dailies were cun-hecked?
- if (!user.party.quest.progress.down) user.party.quest.progress.down = 0;
-
- tasksByType.dailys.forEach((task) => {
- let completed = task.completed;
- // Deduct points for missed Daily tasks
- let EvadeTask = 0;
- let scheduleMisses = daysMissed;
-
- if (completed) {
- dailyChecked += 1;
- } else {
- // dailys repeat, so need to calculate how many they've missed according to their own schedule
- scheduleMisses = 0;
-
- for (let i = 0; i < daysMissed; i++) {
- let thatDay = moment(now).subtract({days: i + 1});
-
- if (shouldDo(thatDay.toDate(), task, user.preferences)) {
- scheduleMisses++;
- if (user.stats.buffs.stealth) {
- user.stats.buffs.stealth--;
- EvadeTask++;
- }
- if (multiDaysCountAsOneDay) break;
- }
- }
-
- if (scheduleMisses > EvadeTask) {
- perfect = false;
-
- if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points
- let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length;
- dailyDueUnchecked += 1 - fractionChecked;
- dailyChecked += fractionChecked;
- } else {
- dailyDueUnchecked += 1;
- }
-
- let delta = scoreTask({
- user,
- task,
- direction: 'down',
- times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask,
- cron: true,
- });
-
- // Apply damage from a boss, less damage for Trivial priority (difficulty)
- user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1);
- // NB: Medium and Hard priorities do not increase damage from boss. This was by accident
- // initially, and when we realised, we could not fix it because users are used to
- // their Medium and Hard Dailies doing an Easy amount of damage from boss.
- // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future
- // setting between Trivial and Easy.
- }
- }
-
- task.history.push({
- date: Number(new Date()),
- value: task.value,
- });
- task.completed = false;
-
- if (completed || scheduleMisses > 0) {
- task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed
- }
- });
-
- tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0
- if (task.up === false || task.down === false) {
- task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2;
- }
- });
-
- // Finished tallying
- user.history.todos({date: now, value: todoTally});
- // tally experience
- let expTally = user.stats.exp;
- let lvl = 0; // iterator
- while (lvl < user.stats.lvl - 1) {
- lvl++;
- expTally += common.tnl(lvl);
- }
- user.history.exp.push({date: now, value: expTally});
-
- // preen user history so that it doesn't become a performance problem
- // also for subscribed users but differentyly
- // premium subscribers can keep their full history.
- user.fns.preenUserHistory(tasks);
-
- if (perfect) {
- user.achievements.perfect++;
- let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2);
- user.stats.buffs = {
- str: lvlDiv2,
- int: lvlDiv2,
- per: lvlDiv2,
- con: lvlDiv2,
- stealth: 0,
- streaks: false,
- };
- } else {
- user.stats.buffs = _.cloneDeep(clearBuffs);
- }
-
- // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit
- // Adjust for fraction of dailies completed
- user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked);
- if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP;
-
- if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1;
- user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked);
- if (user.stats.mp > user._statsComputed.maxMP) {
- user.stats.mp = user._statsComputed.maxMP;
- }
-
- // After all is said and done, progress up user's effect on quest, return those values & reset the user's
- let progress = user.party.quest.progress;
- let _progress = _.cloneDeep(progress);
- _.merge(progress, {down: 0, up: 0});
- progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0);
-
-
- // Analytics
- user.flags.cronCount++;
- analytics.track('Cron', {
- category: 'behavior',
- gaLabel: 'Cron Count',
- gaValue: user.flags.cronCount,
- uuid: user._id,
- user, // TODO is it really necessary passing the whole user object?
- resting: user.preferences.sleep,
- cronCount: user.flags.cronCount,
- progressUp: _.min([_progress.up, 900]),
- progressDown: _progress.down,
- });
-
- return _progress;
-}
diff --git a/common/script/api-v3/preenHistory.js b/common/script/api-v3/preenHistory.js
deleted file mode 100644
index 50ffb1518f..0000000000
--- a/common/script/api-v3/preenHistory.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import moment from 'moment';
-import _ from 'lodash';
-
-function _preen (newHistory, history, amount, groupBy) {
- _.chain(history)
- .groupBy(h => moment(h.date).format(groupBy))
- .sortBy((h, k) => k)
- .slice(-amount)
- .pop()
- .each((group) => {
- newHistory.push({
- date: moment(group[0].date).toDate(),
- value: _.reduce(group, (m, obj) => m + obj.value, 0) / group.length,
- });
- })
- .value();
-}
-
-// Free users:
-// Preen history for users with > 7 history entries
-// This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array
-// of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week
-// of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite]
-//
-// Subscribers:
-// TODO implement
-
-// TODO Probably the description ^ is not too correct, this method actually takes 1 value each for the last 50 years,
-// then the X last months, where X is the month we're in (september = 8 starting from 0)
-// and all the days in this month
-// Allowing for multiple values in a single day for habits we probably want something different:
-// For free users:
-// - At max 30 values for today (max 30)
-// - 1 value each for the previous 61 days (2 months)
-// - 1 value each for the previous 10 months (max 10)
-// - 1 value each for the previous 50 years
-// - Total: 30+61+10+ a few years ~= 105
-//
-// For subscribed users
-// - At max 30 values for today (max 30)
-// - 1 value each for the previous 364 days (max 364)
-// - 1 value each for the previous 12 months (max 12)
-// - 1 value each for the previous 50 years
-// - Total: 30+364+12+ a few years ~= 410
-//
-export function preenHistory (history) {
- // TODO remember to add this to migration
- /* history = _.filter(history, function(h) {
- return !!h;
- }); */
- let newHistory = [];
-
- _preen(newHistory, history, 50, 'YYYY');
- _preen(newHistory, history, moment().format('MM'), 'YYYYMM');
-
- let thisMonth = moment().format('YYYYMM');
- newHistory = newHistory.concat(history.filter(h => {
- return moment(h.date).format('YYYYMM') === thisMonth;
- }));
-
- return newHistory;
-}
-
-export function preenUserHistory (user, tasksByType, minHistLen = 7) {
- tasksByType.habits.concat(user.dailys).forEach((task) => {
- if (task.history.length > minHistLen) {
- task.history = preenHistory(user, task.history);
- task.markModified('history');
- }
- });
-
- if (user.history.exp.length > minHistLen) {
- user.history.exp = preenHistory(user, user.history.exp);
- user.markModified('history.exp');
- }
-
- if (user.history.todos.length > minHistLen) {
- user.history.todos = preenHistory(user, user.history.todos);
- user.markModified('history.todos');
- }
-}
diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js
index 25c59393b6..91db45ce53 100644
--- a/common/script/api-v3/scoreTask.js
+++ b/common/script/api-v3/scoreTask.js
@@ -224,7 +224,7 @@ export default function scoreTask (options = {}, req = {}) {
if (cron) { // don't touch stats on cron
delta += _changeTaskValue(user, task, direction, times, cron);
} else {
- if (direction === 'up') task.dateCompleted = new Date();
+ task.dateCompleted = direction === 'up' ? new Date() : undefined;
delta += _changeTaskValue(user, task, direction, times, cron);
if (direction === 'down') delta = _calculateDelta(task, direction, delta); // recalculate delta for unchecking so the gp and exp come out correctly
diff --git a/common/script/cron.js b/common/script/cron.js
index 639bbf6b65..7eca56cb54 100644
--- a/common/script/cron.js
+++ b/common/script/cron.js
@@ -1,3 +1,4 @@
+// TODO what can be moved to /website/src?
/*
------------------------------------------------------
Cron and time / day functions
diff --git a/package.json b/package.json
index fd5659a079..a068f199e1 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"cookie-parser": "^1.4.0",
"cookie-session": "^1.2.0",
"coupon-code": "~0.3.0",
+ "csv-stringify": "^1.0.1",
"domain-middleware": "~0.1.0",
"estraverse": "^4.1.1",
"express": "~4.13.3",
@@ -93,12 +94,13 @@
"npm": "^3.3.10"
},
"scripts": {
- "test": "gulp test",
+ "test": "gulp test:api-v3",
"test:api-v2:unit": "mocha test/server_side",
"test:api-v2:integration": "mocha test/api/v2 --recursive",
- "test:api-v3": "mocha test/api/v3 --recursive",
- "test:api-v3:unit": "mocha test/api/v3/unit --recursive",
- "test:api-v3:integration": "mocha test/api/v3/integration --recursive",
+ "test:api-v3": "gulp test:api-v3",
+ "test:api-v3:unit": "gulp test:api-v3:unit",
+ "test:api-v3:integration": "gulp test:api-v3:integration",
+ "test:api-v3:integration:separate-server": "gulp test:api-v3:integration:separate-server",
"test:api-legacy": "istanbul cover -i \"website/src/**\" --dir coverage/api ./node_modules/mocha/bin/_mocha test/api-legacy",
"test:common": "mocha test/common",
"test:content": "mocha test/content",
diff --git a/tasks/gulp-eslint.js b/tasks/gulp-eslint.js
index ad14f4a54f..41d28f7a69 100644
--- a/tasks/gulp-eslint.js
+++ b/tasks/gulp-eslint.js
@@ -5,6 +5,9 @@ const SERVER_FILES = [
'./website/src/**/api-v3/**/*.js',
'./website/src/models/user.js',
'./website/src/models/task.js',
+ './website/src/models/group.js',
+ './website/src/models/challenge.js',
+ './website/src/models/tag.js',
'./website/src/models/emailUnsubscription.js',
'./website/src/server.js',
];
diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js
index 5fbd32cac8..6256440998 100644
--- a/tasks/gulp-tests.js
+++ b/tasks/gulp-tests.js
@@ -13,6 +13,8 @@ import Q from 'q';
import runSequence from 'run-sequence';
import os from 'os';
+// TODO rewrite
+
const TEST_SERVER_PORT = 3003
const TEST_DB = 'habitrpg_test'
let server;
@@ -42,9 +44,9 @@ let testBin = (string, additionalEnvVariables = '') => {
additionalEnvVariables = additionalEnvVariables.split(' ').join('&&set ');
additionalEnvVariables = 'set ' + additionalEnvVariables + '&&';
}
- return `set NODE_ENV=testing&&${additionalEnvVariables}${string}`;
+ return `set NODE_ENV=test&&${additionalEnvVariables}${string}`;
} else {
- return `NODE_ENV=testing ${additionalEnvVariables} ${string}`;
+ return `NODE_ENV=test ${additionalEnvVariables} ${string}`;
}
};
@@ -342,13 +344,53 @@ gulp.task('test:api-v2:safe', ['test:prepare:server'], (done) => {
});
});
+gulp.task('test:api-v3:unit', (done) => {
+ let runner = exec(
+ testBin('mocha test/api/v3/unit --recursive'),
+ (err, stdout, stderr) => done(err)
+ )
+
+ pipe(runner);
+});
+
+gulp.task('test:api-v3:integration', (done) => {
+ let runner = exec(
+ testBin('mocha test/api/v3/integration --recursive'),
+ {maxBuffer: 500*1024},
+ (err, stdout, stderr) => done(err)
+ )
+
+ pipe(runner);
+});
+
+gulp.task('test:api-v3:integration:separate-server', (done) => {
+ let runner = exec(
+ testBin('mocha test/api/v3/integration --recursive', 'LOAD_SERVER=0'),
+ {maxBuffer: 500*1024},
+ (err, stdout, stderr) => done(err)
+ )
+
+ pipe(runner);
+});
+
+gulp.task('test:api-v3', (done) => {
+ runSequence(
+ 'lint',
+ 'test:api-v3:unit',
+ 'test:api-v3:integration',
+ done
+ );
+});
+
+// Old tests tasks
+/*
gulp.task('test:api-v3', ['test:api-v3:unit', 'test:api-v3:integration']);
gulp.task('test:api-v3:watch', ['test:api-v3:unit:watch', 'test:api-v3:integration:watch']);
-gulp.task('test:api-v3:unit', (done) => {
- runMochaTests('./test/api/v3/unit/**/*.js', null, done)
-});
+gulp.task('test:api-v3:unit', (done) => {*/
+// runMochaTests('./test/api/v3/unit/**/*.js', null, done)
+/*});
gulp.task('test:api-v3:unit:watch', () => {
gulp.watch(['website/src/**', 'test/api/v3/unit/**'], ['test:api-v3:unit']);
@@ -356,9 +398,9 @@ gulp.task('test:api-v3:unit:watch', () => {
gulp.task('test:api-v3:integration', ['test:prepare:server'], (done) => {
process.env.API_VERSION = 'v3';
- awaitPort(TEST_SERVER_PORT).then(() => {
- runMochaTests('./test/api/v3/integration/**/*.js', server, done)
- });
+ awaitPort(TEST_SERVER_PORT).then(() => {*/
+// runMochaTests('./test/api/v3/integration/**/*.js', server, done)
+/* });
});
gulp.task('test:api-v3:integration:watch', ['test:prepare:server'], () => {
@@ -430,4 +472,4 @@ gulp.task('test', ['test:all'], () => {
console.log('\n\x1b[36mThanks for helping keep Habitica clean!\x1b[0m');
process.exit();
}
-});
+});*/
diff --git a/test/README.md b/test/README.md
deleted file mode 100644
index 8115753727..0000000000
--- a/test/README.md
+++ /dev/null
@@ -1,5 +0,0 @@
-We need to clean up this directory. The *real* tests are in spec/ mock/ e2e/ and api.mocha.coffee. We want to:
-
-1. Move all old / deprecated tests from casper, test2, etc into spec, mock, e2e
-1. Remove dependency of api.mocha.coffee on Derby, port it to Mongoose
-1. Add better test-coverage
diff --git a/test/api-legacy/api-helper.js b/test/api-legacy/api-helper.js
index 57817173c2..9ade0a2aa2 100644
--- a/test/api-legacy/api-helper.js
+++ b/test/api-legacy/api-helper.js
@@ -6,6 +6,8 @@ superagentDefaults = require("superagent-defaults");
global.request = superagentDefaults();
global.mongoose = require("mongoose");
+var Q = require('q');
+mongoose.Promise = Q.Promise;
global.moment = require("moment");
diff --git a/test/api/README.md b/test/api/README.md
index b5223cc0f4..ff3296721f 100644
--- a/test/api/README.md
+++ b/test/api/README.md
@@ -1,5 +1,7 @@
# So you want to write API integration tests?
+@TODO rewrite
+
That's great! This README will serve as a quick primer for style conventions and practices for these tests.
## What is this?
@@ -73,7 +75,7 @@ POST-groups_id_leave.test.js
To mitigate [callback hell](http://callbackhell.com/) :imp:, we've written a helper method to generate a user object that can make http requests that [return promises](https://babeljs.io/docs/learn-es2015/#promises). This makes it very easy to chain together commands. All you need to do to make a subsequent request is return another promise and then call `.then((result) => {})` on the surrounding block, like so:
```js
-it('does something', () => {
+it('does something', () => {
let user;
return generateUser().then((_user) => { // We return the initial promise so this test can be run asyncronously
@@ -97,7 +99,7 @@ it('does something', () => {
If the test is simple, you can use the [chai-as-promised](http://chaijs.com/plugins/chai-as-promised) `return expect(somePromise).to.eventually` syntax to make your assertion.
```js
-it('makes the party creator the leader automatically', () => {
+it('makes the party creator the leader automatically', () => {
return expect(user.post('/groups', {
type: 'party',
})).to.eventually.have.deep.property('leader._id', user._id);
@@ -107,7 +109,7 @@ it('makes the party creator the leader automatically', () => {
If the test is checking that the request returns an error, use the `.eventually.be.rejected.and.eql` syntax.
```js
-it('returns an error', () => {
+it('returns an error', () => {
return expect(user.get('/groups/id-of-a-party-that-user-does-not-belong-to'))
.to.eventually.be.rejected.and.eql({
code: 404,
diff --git a/test/api/v3/README.md b/test/api/v3/README.md
new file mode 100644
index 0000000000..ad9b55a32c
--- /dev/null
+++ b/test/api/v3/README.md
@@ -0,0 +1,4 @@
+# How to run tests:
+
+1. `npm test` is equivalent to `gulp test:api-v3` which will run, in order, `gulp lint`, `gulp test:api-v3:unit` and `gulp test:api-v3:integration`. If one of these fails, the whole `npm test` command blocks and fails. Each of these commands can also be run as a standalone command.
+2. To run the server and the integrations tests in two different terminals (to better inspect the output in the server) run `npm start` in one and `npm test:api-v3:integration:separate-server` in the other
diff --git a/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js
new file mode 100644
index 0000000000..e8bd7a66d9
--- /dev/null
+++ b/test/api/v3/integration/challenges/DELETE-challenges_challengeId.test.js
@@ -0,0 +1,95 @@
+import {
+ generateUser,
+ generateChallenge,
+ createAndPopulateGroup,
+ sleep,
+ checkExistence,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('DELETE /challenges/:challengeId', () => {
+ it('returns error when challengeId is not a valid UUID', async () => {
+ let user = await generateUser();
+
+ await expect(user.del(`/challenges/test`)).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('returns error when challengeId is not for a valid challenge', async () => {
+ let user = await generateUser();
+
+ await expect(user.del(`/challenges/${generateUUID()}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ context('Deleting a valid challenge', () => {
+ let groupLeader;
+ let group;
+ let challenge;
+ let taskText = 'A challenge task text';
+
+ beforeEach(async () => {
+ let populatedGroup = await createAndPopulateGroup();
+
+ groupLeader = populatedGroup.groupLeader;
+ group = populatedGroup.group;
+
+ challenge = await generateChallenge(groupLeader, group);
+
+ await groupLeader.post(`/tasks/challenge/${challenge._id}`, [
+ {type: 'habit', text: taskText},
+ ]);
+
+ await challenge.sync();
+ });
+
+ it('returns an error when user doesn\'t have permissions to delete the challenge', async () => {
+ let user = await generateUser();
+
+ await expect(user.del(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('onlyLeaderDeleteChal'),
+ });
+ });
+
+ it('deletes challenge', async () => {
+ await groupLeader.del(`/challenges/${challenge._id}`);
+
+ await sleep(0.5);
+
+ await expect(checkExistence('challenges', challenge._id)).to.eventually.equal(false);
+ });
+
+ it('refunds gems to group leader', async () => {
+ let oldBalance = (await groupLeader.sync()).balance;
+
+ await groupLeader.del(`/challenges/${challenge._id}`);
+
+ await sleep(0.5);
+
+ await expect(groupLeader.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4);
+ });
+
+ it('sets broken and doesn\'t set winner flags for user\'s challenge tasks', async () => {
+ await groupLeader.del(`/challenges/${challenge._id}`);
+
+ await sleep(0.5);
+
+ let tasks = await groupLeader.get('/tasks/user');
+ let testTask = _.find(tasks, (task) => {
+ return task.text === taskText;
+ });
+
+ expect(testTask.challenge.broken).to.eql('CHALLENGE_DELETED');
+ expect(testTask.challenge.winner).to.be.null;
+ });
+ });
+});
diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js
new file mode 100644
index 0000000000..8528fee892
--- /dev/null
+++ b/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js
@@ -0,0 +1,139 @@
+import {
+ generateUser,
+ createAndPopulateGroup,
+ generateChallenge,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('GET /challenges/:challengeId', () => {
+ it('fails if challenge doesn\'t exists', async () => {
+ let user = await generateUser();
+ await expect(user.get(`/challenges/${generateUUID()}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ context('public guild', () => {
+ let groupLeader;
+ let group;
+ let challenge;
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+
+ let populatedGroup = await createAndPopulateGroup({
+ groupDetails: {type: 'guild', privacy: 'public'},
+ });
+
+ groupLeader = populatedGroup.groupLeader;
+ group = populatedGroup.group;
+
+ challenge = await generateChallenge(groupLeader, group);
+ });
+
+ it('should return challenge data', async () => {
+ let chal = await user.get(`/challenges/${challenge._id}`);
+ expect(chal.memberCount).to.equal(challenge.memberCount);
+ expect(chal.name).to.equal(challenge.name);
+ expect(chal._id).to.equal(challenge._id);
+
+ expect(chal.leader).to.eql({
+ _id: groupLeader._id,
+ profile: {name: groupLeader.profile.name},
+ });
+ expect(chal.group).to.eql(_.pick(group, ['_id', 'name', 'type', 'privacy']));
+ });
+ });
+
+ context('private guild', () => {
+ let groupLeader;
+ let group;
+ let challenge;
+ let members;
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+
+ let populatedGroup = await createAndPopulateGroup({
+ groupDetails: {type: 'guild', privacy: 'private'},
+ members: 1,
+ });
+
+ groupLeader = populatedGroup.groupLeader;
+ group = populatedGroup.group;
+ members = populatedGroup.members;
+
+ challenge = await generateChallenge(groupLeader, group);
+ await members[0].post(`/challenges/${challenge._id}/join`);
+ });
+
+ it('fails if user doesn\'t have access to the challenge', async () => {
+ await expect(user.get(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('should return challenge data', async () => {
+ let chal = await members[0].get(`/challenges/${challenge._id}`);
+ expect(chal.name).to.equal(challenge.name);
+ expect(chal._id).to.equal(challenge._id);
+
+ expect(chal.leader).to.eql({
+ _id: groupLeader._id,
+ profile: {name: groupLeader.profile.name},
+ });
+ expect(chal.group).to.eql(_.pick(group, ['_id', 'name', 'type', 'privacy']));
+ });
+ });
+
+ context('party', () => {
+ let groupLeader;
+ let group;
+ let challenge;
+ let members;
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+
+ let populatedGroup = await createAndPopulateGroup({
+ groupDetails: {type: 'party'},
+ members: 1,
+ });
+
+ groupLeader = populatedGroup.groupLeader;
+ group = populatedGroup.group;
+ members = populatedGroup.members;
+
+ challenge = await generateChallenge(groupLeader, group);
+ await members[0].post(`/challenges/${challenge._id}/join`);
+ });
+
+ it('fails if user doesn\'t have access to the challenge', async () => {
+ await expect(user.get(`/challenges/${challenge._id}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('should return challenge data', async () => {
+ let chal = await members[0].get(`/challenges/${challenge._id}`);
+ expect(chal.name).to.equal(challenge.name);
+ expect(chal._id).to.equal(challenge._id);
+
+ expect(chal.leader).to.eql({
+ _id: groupLeader._id,
+ profile: {name: groupLeader.profile.name},
+ });
+ expect(chal.group).to.eql(_.pick(group, ['_id', 'name', 'type', 'privacy']));
+ });
+ });
+});
diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js
new file mode 100644
index 0000000000..e4c1ceee34
--- /dev/null
+++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_export_csv.test.js
@@ -0,0 +1,71 @@
+import {
+ generateUser,
+ createAndPopulateGroup,
+ generateChallenge,
+ translate as t,
+ sleep,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('GET /challenges/:challengeId/export/csv', () => {
+ let groupLeader;
+ let group;
+ let challenge;
+ let members;
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+
+ let populatedGroup = await createAndPopulateGroup({
+ members: 3,
+ });
+
+ groupLeader = populatedGroup.groupLeader;
+ group = populatedGroup.group;
+ members = populatedGroup.members;
+
+ challenge = await generateChallenge(groupLeader, group);
+ await members[0].post(`/challenges/${challenge._id}/join`);
+ await members[1].post(`/challenges/${challenge._id}/join`);
+ await members[2].post(`/challenges/${challenge._id}/join`);
+
+ await groupLeader.post(`/tasks/challenge/${challenge._id}`, [
+ {type: 'habit', text: 'Task 1'},
+ {type: 'todo', text: 'Task 2'},
+ ]);
+ await sleep(0.5); // Make sure tasks are synced to the users
+ await members[0].sync();
+ await members[1].sync();
+ await members[2].sync();
+ });
+
+ it('fails if challenge doesn\'t exists', async () => {
+ await expect(user.get(`/challenges/${generateUUID()}/export/csv`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('fails if user doesn\'t have access to the challenge', async () => {
+ await expect(user.get(`/challenges/${challenge._id}/export/csv`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('should return a valid CSV file with export data', async () => {
+ let res = await members[0].get(`/challenges/${challenge._id}/export/csv`);
+ let sortedMembers = _.sortBy([members[0], members[1], members[2], groupLeader], '_id');
+ let splitRes = res.split('\n');
+
+ expect(splitRes[0]).to.equal('UUID,name,Task,Value,Notes,Task,Value,Notes');
+ expect(splitRes[1]).to.equal(`${sortedMembers[0]._id},${sortedMembers[0].profile.name},habit:Task 1,0,,todo:Task 2,0,`);
+ expect(splitRes[2]).to.equal(`${sortedMembers[1]._id},${sortedMembers[1].profile.name},habit:Task 1,0,,todo:Task 2,0,`);
+ expect(splitRes[3]).to.equal(`${sortedMembers[2]._id},${sortedMembers[2].profile.name},habit:Task 1,0,,todo:Task 2,0,`);
+ expect(splitRes[4]).to.equal(`${sortedMembers[3]._id},${sortedMembers[3].profile.name},habit:Task 1,0,,todo:Task 2,0,`);
+ expect(splitRes[5]).to.equal('');
+ });
+});
diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js
new file mode 100644
index 0000000000..ae037b53e4
--- /dev/null
+++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js
@@ -0,0 +1,107 @@
+import {
+ generateUser,
+ generateGroup,
+ generateChallenge,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('GET /challenges/:challengeId/members', () => {
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+ });
+
+ it('validates optional req.query.lastId to be an UUID', async () => {
+ await expect(user.get(`/challenges/${generateUUID()}/members?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('fails if challenge doesn\'t exists', async () => {
+ await expect(user.get(`/challenges/${generateUUID()}/members`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('fails if user doesn\'t have access to the challenge', async () => {
+ let group = await generateGroup(user);
+ let challenge = await generateChallenge(user, group);
+ let anotherUser = await generateUser();
+
+ await expect(anotherUser.get(`/challenges/${challenge._id}/members`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('works with challenges belonging to public guild', async () => {
+ let leader = await generateUser({balance: 4});
+ let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()});
+ let challenge = await generateChallenge(leader, group);
+ let res = await user.get(`/challenges/${challenge._id}/members`);
+ expect(res[0]).to.eql({
+ _id: leader._id,
+ profile: {name: leader.profile.name},
+ });
+ expect(res[0]).to.have.all.keys(['_id', 'profile']);
+ expect(res[0].profile).to.have.all.keys(['name']);
+ });
+
+ it('populates only some fields', async () => {
+ let anotherUser = await generateUser({balance: 3});
+ let group = await generateGroup(anotherUser, {type: 'guild', privacy: 'public', name: generateUUID()});
+ let challenge = await generateChallenge(anotherUser, group);
+ let res = await user.get(`/challenges/${challenge._id}/members`);
+ expect(res[0]).to.eql({
+ _id: anotherUser._id,
+ profile: {name: anotherUser.profile.name},
+ });
+ expect(res[0]).to.have.all.keys(['_id', 'profile']);
+ expect(res[0].profile).to.have.all.keys(['name']);
+ });
+
+ it('returns only first 30 members', async () => {
+ let group = await generateGroup(user, {type: 'party', name: generateUUID()});
+ let challenge = await generateChallenge(user, group);
+
+ let usersToGenerate = [];
+ for (let i = 0; i < 31; i++) {
+ usersToGenerate.push(generateUser({challenges: [challenge._id]}));
+ }
+ await Promise.all(usersToGenerate);
+
+ let res = await user.get(`/challenges/${challenge._id}/members`);
+ expect(res.length).to.equal(30);
+ res.forEach(member => {
+ expect(member).to.have.all.keys(['_id', 'profile']);
+ expect(member.profile).to.have.all.keys(['name']);
+ });
+ });
+
+ it('supports using req.query.lastId to get more members', async () => {
+ let group = await generateGroup(user, {type: 'party', name: generateUUID()});
+ let challenge = await generateChallenge(user, group);
+
+ let usersToGenerate = [];
+ for (let i = 0; i < 57; i++) {
+ usersToGenerate.push(generateUser({challenges: [challenge._id]}));
+ }
+ let generatedUsers = await Promise.all(usersToGenerate); // Group has 59 members (1 is the leader)
+ let expectedIds = [user._id].concat(generatedUsers.map(generatedUser => generatedUser._id));
+
+ let res = await user.get(`/challenges/${challenge._id}/members`);
+ expect(res.length).to.equal(30);
+ let res2 = await user.get(`/challenges/${challenge._id}/members?lastId=${res[res.length - 1]._id}`);
+ expect(res2.length).to.equal(28);
+
+ let resIds = res.concat(res2).map(member => member._id);
+ expect(resIds).to.eql(expectedIds.sort());
+ });
+});
diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js
new file mode 100644
index 0000000000..315d7bef2c
--- /dev/null
+++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js
@@ -0,0 +1,107 @@
+import {
+ generateUser,
+ generateChallenge,
+ generateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('GET /challenges/:challengeId/members/:memberId', () => {
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+ });
+
+ it('validates req.params.memberId to be an UUID', async () => {
+ await expect(user.get(`/challenges/invalidUUID/members/${generateUUID()}`)).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('validates req.params.memberId to be an UUID', async () => {
+ await expect(user.get(`/challenges/${generateUUID()}/members/invalidUUID`)).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('fails if member doesn\'t exists', async () => {
+ let userId = generateUUID();
+ await expect(user.get(`/challenges/${generateUUID()}/members/${userId}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('userWithIDNotFound', {userId}),
+ });
+ });
+
+ it('fails if challenge doesn\'t exists', async () => {
+ let member = await generateUser();
+ await expect(user.get(`/challenges/${generateUUID()}/members/${member._id}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('fails if user doesn\'t have access to the challenge', async () => {
+ let group = await generateGroup(user, {type: 'party', name: generateUUID()});
+ let challenge = await generateChallenge(user, group);
+ let anotherUser = await generateUser();
+ let member = await generateUser();
+ await expect(anotherUser.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('fails if member is not part of the challenge', async () => {
+ let group = await generateGroup(user, {type: 'party', name: generateUUID()});
+ let challenge = await generateChallenge(user, group);
+ let member = await generateUser();
+ await expect(user.get(`/challenges/${challenge._id}/members/${member._id}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeMemberNotFound'),
+ });
+ });
+
+ it('works with challenges belonging to a public guild', async () => {
+ let groupLeader = await generateUser({balance: 4});
+ let group = await generateGroup(groupLeader, {type: 'guild', privacy: 'public', name: generateUUID()});
+ let challenge = await generateChallenge(groupLeader, group);
+ let taskText = 'Test Text';
+ await groupLeader.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]);
+
+ let memberProgress = await user.get(`/challenges/${challenge._id}/members/${groupLeader._id}`);
+ expect(memberProgress).to.have.all.keys(['_id', 'profile', 'tasks']);
+ expect(memberProgress.profile).to.have.all.keys(['name']);
+ expect(memberProgress.tasks.length).to.equal(1);
+ });
+
+ it('returns the member tasks for the challenges', async () => {
+ let group = await generateGroup(user, {type: 'party', name: generateUUID()});
+ let challenge = await generateChallenge(user, group);
+ await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: 'Test Text'}]);
+
+ let memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`);
+ let chalTasks = await user.get(`/tasks/challenge/${challenge._id}`);
+ expect(memberProgress.tasks.length).to.equal(chalTasks.length);
+ expect(memberProgress.tasks[0].challenge.id).to.equal(challenge._id);
+ expect(memberProgress.tasks[0].challenge.taskId).to.equal(chalTasks[0]._id);
+ });
+
+ it('returns the tasks without the tags', async () => {
+ let group = await generateGroup(user, {type: 'party', name: generateUUID()});
+ let challenge = await generateChallenge(user, group);
+ let taskText = 'Test Text';
+ await user.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]);
+
+ let memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`);
+ expect(memberProgress.tasks[0]).not.to.have.key('tags');
+ });
+});
diff --git a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js
new file mode 100644
index 0000000000..3f116d833d
--- /dev/null
+++ b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js
@@ -0,0 +1,112 @@
+import {
+ generateUser,
+ generateChallenge,
+ createAndPopulateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('GET challenges/group/:groupId', () => {
+ context('Public Guild', () => {
+ let publicGuild, user, nonMember, challenge, challenge2;
+
+ before(async () => {
+ let { group, groupLeader } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'TestGuild',
+ type: 'guild',
+ privacy: 'public',
+ },
+ });
+
+ publicGuild = group;
+ user = groupLeader;
+
+ nonMember = await generateUser();
+
+ challenge = await generateChallenge(user, group);
+ challenge2 = await generateChallenge(user, group);
+ });
+
+ it('should return group challenges for non member with populated leader', async () => {
+ let challenges = await nonMember.get(`/challenges/groups/${publicGuild._id}`);
+
+ let foundChallenge1 = _.find(challenges, { _id: challenge._id });
+ expect(foundChallenge1).to.exist;
+ expect(foundChallenge1.leader).to.eql({
+ _id: publicGuild.leader._id,
+ profile: {name: user.profile.name},
+ });
+ let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
+ expect(foundChallenge2).to.exist;
+ expect(foundChallenge2.leader).to.eql({
+ _id: publicGuild.leader._id,
+ profile: {name: user.profile.name},
+ });
+ });
+
+ it('should return group challenges for member with populated leader', async () => {
+ let challenges = await user.get(`/challenges/groups/${publicGuild._id}`);
+
+ let foundChallenge1 = _.find(challenges, { _id: challenge._id });
+ expect(foundChallenge1).to.exist;
+ expect(foundChallenge1.leader).to.eql({
+ _id: publicGuild.leader._id,
+ profile: {name: user.profile.name},
+ });
+ let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
+ expect(foundChallenge2).to.exist;
+ expect(foundChallenge2.leader).to.eql({
+ _id: publicGuild.leader._id,
+ profile: {name: user.profile.name},
+ });
+ });
+ });
+
+ context('Private Guild', () => {
+ let privateGuild, user, nonMember, challenge, challenge2;
+
+ before(async () => {
+ let { group, groupLeader } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'TestPrivateGuild',
+ type: 'guild',
+ privacy: 'private',
+ },
+ });
+
+ privateGuild = group;
+ user = groupLeader;
+
+ nonMember = await generateUser();
+
+ challenge = await generateChallenge(user, group);
+ challenge2 = await generateChallenge(user, group);
+ });
+
+ it('should prevent non-member from seeing challenges', async () => {
+ await expect(nonMember.get(`/challenges/groups/${privateGuild._id}`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('should return group challenges for member with populated leader', async () => {
+ let challenges = await user.get(`/challenges/groups/${privateGuild._id}`);
+
+ let foundChallenge1 = _.find(challenges, { _id: challenge._id });
+ expect(foundChallenge1).to.exist;
+ expect(foundChallenge1.leader).to.eql({
+ _id: privateGuild.leader._id,
+ profile: {name: user.profile.name},
+ });
+ let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
+ expect(foundChallenge2).to.exist;
+ expect(foundChallenge2.leader).to.eql({
+ _id: privateGuild.leader._id,
+ profile: {name: user.profile.name},
+ });
+ });
+ });
+});
diff --git a/test/api/v3/integration/challenges/GET-challenges_user.test.js b/test/api/v3/integration/challenges/GET-challenges_user.test.js
new file mode 100644
index 0000000000..21ae0aa96b
--- /dev/null
+++ b/test/api/v3/integration/challenges/GET-challenges_user.test.js
@@ -0,0 +1,122 @@
+import {
+ generateUser,
+ generateChallenge,
+ createAndPopulateGroup,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('GET challenges/user', () => {
+ let user, member, nonMember, challenge, challenge2, publicGuild;
+
+ before(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'TestGuild',
+ type: 'guild',
+ privacy: 'public',
+ },
+ members: 1,
+ });
+
+ user = groupLeader;
+ publicGuild = group;
+ member = members[0];
+ nonMember = await generateUser();
+
+ challenge = await generateChallenge(user, group);
+ challenge2 = await generateChallenge(user, group);
+ });
+
+ it('should return challenges user has joined', async () => {
+ await nonMember.post(`/challenges/${challenge._id}/join`);
+
+ let challenges = await nonMember.get(`/challenges/user`);
+
+ let foundChallenge = _.find(challenges, { _id: challenge._id });
+ expect(foundChallenge).to.exist;
+ expect(foundChallenge.leader).to.eql({
+ _id: publicGuild.leader._id,
+ profile: {name: user.profile.name},
+ });
+ expect(foundChallenge.group).to.eql({
+ _id: publicGuild._id,
+ type: publicGuild.type,
+ privacy: publicGuild.privacy,
+ name: publicGuild.name,
+ });
+ });
+
+ it('should return challenges user has created', async () => {
+ let challenges = await user.get(`/challenges/user`);
+
+ let foundChallenge1 = _.find(challenges, { _id: challenge._id });
+ expect(foundChallenge1).to.exist;
+ expect(foundChallenge1.leader).to.eql({
+ _id: publicGuild.leader._id,
+ profile: {name: user.profile.name},
+ });
+ expect(foundChallenge1.group).to.eql({
+ _id: publicGuild._id,
+ type: publicGuild.type,
+ privacy: publicGuild.privacy,
+ name: publicGuild.name,
+ });
+ let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
+ expect(foundChallenge2).to.exist;
+ expect(foundChallenge2.leader).to.eql({
+ _id: publicGuild.leader._id,
+ profile: {name: user.profile.name},
+ });
+ expect(foundChallenge2.group).to.eql({
+ _id: publicGuild._id,
+ type: publicGuild.type,
+ privacy: publicGuild.privacy,
+ name: publicGuild.name,
+ });
+ });
+
+ it('should return challenges in user\'s group', async () => {
+ let challenges = await member.get(`/challenges/user`);
+
+ let foundChallenge1 = _.find(challenges, { _id: challenge._id });
+ expect(foundChallenge1).to.exist;
+ expect(foundChallenge1.leader).to.eql({
+ _id: publicGuild.leader._id,
+ profile: {name: user.profile.name},
+ });
+ expect(foundChallenge1.group).to.eql({
+ _id: publicGuild._id,
+ type: publicGuild.type,
+ privacy: publicGuild.privacy,
+ name: publicGuild.name,
+ });
+ let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
+ expect(foundChallenge2).to.exist;
+ expect(foundChallenge2.leader).to.eql({
+ _id: publicGuild.leader._id,
+ profile: {name: user.profile.name},
+ });
+ expect(foundChallenge2.group).to.eql({
+ _id: publicGuild._id,
+ type: publicGuild.type,
+ privacy: publicGuild.privacy,
+ name: publicGuild.name,
+ });
+ });
+
+ it('should not return challenges user doesn\'t have access to', async () => {
+ let { group, groupLeader } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'TestPrivateGuild',
+ type: 'guild',
+ privacy: 'private',
+ },
+ });
+
+ let privateChallenge = await generateChallenge(groupLeader, group);
+
+ let challenges = await nonMember.get(`/challenges/user`);
+
+ let foundChallenge = _.find(challenges, { _id: privateChallenge._id });
+ expect(foundChallenge).to.not.exist;
+ });
+});
diff --git a/test/api/v3/integration/challenges/POST-challenges.test.js b/test/api/v3/integration/challenges/POST-challenges.test.js
new file mode 100644
index 0000000000..2f4878d91a
--- /dev/null
+++ b/test/api/v3/integration/challenges/POST-challenges.test.js
@@ -0,0 +1,308 @@
+import {
+ generateUser,
+ createAndPopulateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /challenges', () => {
+ it('returns error when group is empty', async () => {
+ let user = await generateUser();
+
+ await expect(user.post('/challenges')).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('returns error when groupId is not for a valid group', async () => {
+ let user = await generateUser();
+
+ await expect(user.post(`/challenges`, {
+ group: generateUUID(),
+ })).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns error when creating a challenge in the tavern with no prize', async () => {
+ let user = await generateUser();
+
+ await expect(user.post(`/challenges`, {
+ group: 'habitrpg',
+ prize: 0,
+ })).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('pubChalsMinPrize'),
+ });
+ });
+
+ it('returns error when creating a challenge in a public guild and you are not a member of it', async () => {
+ let user = await generateUser();
+ let { group } = await createAndPopulateGroup({
+ groupDetails: {
+ type: 'guild',
+ privacy: 'public',
+ },
+ });
+
+ await expect(user.post(`/challenges`, {
+ group: group._id,
+ prize: 4,
+ })).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('mustBeGroupMember'),
+ });
+ });
+
+ context('Creating a challenge for a valid group', () => {
+ let groupLeader;
+ let group;
+ let groupMember;
+
+ beforeEach(async () => {
+ let populatedGroup = await createAndPopulateGroup({
+ members: 1,
+ leaderDetails: {
+ balance: 3,
+ },
+ groupDetails: {
+ type: 'guild',
+ leaderOnly: {
+ challenges: true,
+ },
+ },
+ });
+
+ groupLeader = await populatedGroup.groupLeader.sync();
+ group = populatedGroup.group;
+ groupMember = populatedGroup.members[0];
+ });
+
+ it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => {
+ await expect(groupMember.post(`/challenges`, {
+ group: group._id,
+ })).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('onlyGroupLeaderChal'),
+ });
+ });
+
+ it('returns an error when non-leader member creates a challenge in leaderOnly group', async () => {
+ await expect(groupMember.post(`/challenges`, {
+ group: group._id,
+ })).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('onlyGroupLeaderChal'),
+ });
+ });
+
+ it('allows non-leader member to create a challenge', async () => {
+ let populatedGroup = await createAndPopulateGroup({
+ members: 1,
+ });
+
+ group = populatedGroup.group;
+ groupMember = populatedGroup.members[0];
+
+ let chal = await groupMember.post(`/challenges`, {
+ group: group._id,
+ name: 'Test Challenge',
+ shortName: 'TC',
+ });
+
+ expect(chal.leader).to.eql({
+ _id: groupMember._id,
+ profile: {name: groupMember.profile.name},
+ });
+ });
+
+ it('doesn\'t take gems from user or group when challenge has no prize', async () => {
+ let oldUserBalance = groupLeader.balance;
+ let oldGroupBalance = group.balance;
+
+ await groupLeader.post(`/challenges`, {
+ group: group._id,
+ name: 'Test Challenge',
+ shortName: 'TC',
+ prize: 0,
+ });
+
+ await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance);
+ await expect(group.sync()).to.eventually.have.property('balance', oldGroupBalance);
+ });
+
+ it('returns error when user and group can\'t pay prize', async () => {
+ await expect(groupLeader.post(`/challenges`, {
+ group: group._id,
+ name: 'Test Challenge',
+ shortName: 'TC',
+ prize: 20,
+ })).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('cantAfford'),
+ });
+ });
+
+ it('takes prize out of group if it has sufficient funds', async () => {
+ let oldUserBalance = groupLeader.balance;
+ let oldGroupBalance = group.balance;
+ let prize = 4;
+
+ await groupLeader.post(`/challenges`, {
+ group: group._id,
+ name: 'Test Challenge',
+ shortName: 'TC',
+ prize,
+ });
+
+ await expect(group.sync()).to.eventually.have.property('balance', oldGroupBalance - prize / 4);
+ await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance);
+ });
+
+ it('takes prize out of both group and user if group doesn\'t have enough', async () => {
+ let oldUserBalance = groupLeader.balance;
+ let prize = 8;
+
+ await groupLeader.post(`/challenges`, {
+ group: group._id,
+ name: 'Test Challenge',
+ shortName: 'TC',
+ prize,
+ });
+
+ await expect(group.sync()).to.eventually.have.property('balance', 0);
+ await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance - (prize / 4 - 1));
+ });
+
+ it('takes prize out of user if group has no balance', async () => {
+ let oldUserBalance = groupLeader.balance;
+ let prize = 8;
+
+ await group.update({ balance: 0});
+ await groupLeader.post(`/challenges`, {
+ group: group._id,
+ name: 'Test Challenge',
+ shortName: 'TC',
+ prize,
+ });
+
+ await expect(group.sync()).to.eventually.have.property('balance', 0);
+ await expect(groupLeader.sync()).to.eventually.have.property('balance', oldUserBalance - prize / 4);
+ });
+
+ it('increases challenge count of group', async () => {
+ let oldChallengeCount = group.challengeCount;
+
+ await groupLeader.post(`/challenges`, {
+ group: group._id,
+ name: 'Test Challenge',
+ shortName: 'TC',
+ });
+
+ await expect(group.sync()).to.eventually.have.property('challengeCount', oldChallengeCount + 1);
+ });
+
+ it('sets challenge as official if created by admin and official flag is set', async () => {
+ await groupLeader.update({
+ contributor: {
+ admin: true,
+ },
+ });
+
+ let challenge = await groupLeader.post(`/challenges`, {
+ group: group._id,
+ name: 'Test Challenge',
+ shortName: 'TC',
+ official: true,
+ });
+
+ expect(challenge.official).to.eql(true);
+ });
+
+ it('doesn\'t set challenge as official if official flag is set by non-admin', async () => {
+ let challenge = await groupLeader.post(`/challenges`, {
+ group: group._id,
+ name: 'Test Challenge',
+ shortName: 'TC',
+ official: true,
+ });
+
+ expect(challenge.official).to.be.undefined;
+ });
+
+ it('returns an error when challenge validation fails; doesn\'s save user or group', async () => {
+ let oldChallengeCount = group.challengeCount;
+ let oldUserBalance = groupLeader.balance;
+ let oldUserChallenges = groupLeader.challenges;
+ let oldGroupBalance = group.balance;
+
+ await expect(groupLeader.post(`/challenges`, {
+ group: group._id,
+ prize: 8,
+ })).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: 'Challenge validation failed',
+ });
+
+ group = await group.sync();
+ groupLeader = await groupLeader.sync();
+
+ expect(group.challengeCount).to.eql(oldChallengeCount);
+ expect(group.balance).to.eql(oldGroupBalance);
+ expect(groupLeader.balance).to.eql(oldUserBalance);
+ expect(groupLeader.challenges).to.eql(oldUserChallenges);
+ });
+
+ it('sets all properites of the challenge as passed', async () => {
+ let name = 'Test Challenge';
+ let shortName = 'TC';
+ let description = 'Test Description';
+ let prize = 4;
+
+ let challenge = await groupLeader.post(`/challenges`, {
+ group: group._id,
+ name,
+ shortName,
+ description,
+ prize,
+ });
+
+ expect(challenge.leader).to.eql({
+ _id: groupLeader._id,
+ profile: {name: groupLeader.profile.name},
+ });
+ expect(challenge.name).to.eql(name);
+ expect(challenge.shortName).to.eql(shortName);
+ expect(challenge.description).to.eql(description);
+ expect(challenge.official).to.be.undefined;
+ expect(challenge.group).to.eql({
+ _id: group._id,
+ privacy: group.privacy,
+ name: group.name,
+ type: group.type,
+ });
+ expect(challenge.memberCount).to.eql(1);
+ expect(challenge.prize).to.eql(prize);
+ });
+
+ it('adds challenge to creator\'s challenges', async () => {
+ let challenge = await groupLeader.post(`/challenges`, {
+ group: group._id,
+ name: 'Test Challenge',
+ shortName: 'TC',
+ });
+
+ await expect(groupLeader.sync()).to.eventually.have.property('challenges').to.include(challenge._id);
+ });
+ });
+});
diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js
new file mode 100644
index 0000000000..34e018d69d
--- /dev/null
+++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js
@@ -0,0 +1,126 @@
+import {
+ generateUser,
+ generateChallenge,
+ createAndPopulateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /challenges/:challengeId/join', () => {
+ it('returns error when challengeId is not a valid UUID', async () => {
+ let user = await generateUser({ balance: 1});
+
+ await expect(user.post('/challenges/test/join')).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('returns error when challengeId is not for a valid challenge', async () => {
+ let user = await generateUser({ balance: 1});
+
+ await expect(user.post(`/challenges/${generateUUID()}/join`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ context('Joining a valid challenge', () => {
+ let groupLeader;
+ let group;
+ let challenge;
+ let authorizedUser;
+
+ beforeEach(async () => {
+ let populatedGroup = await createAndPopulateGroup({
+ members: 1,
+ });
+
+ groupLeader = populatedGroup.groupLeader;
+ group = populatedGroup.group;
+ authorizedUser = populatedGroup.members[0];
+
+ challenge = await generateChallenge(groupLeader, group);
+ });
+
+ it('returns an error when user doesn\'t have permissions to access the challenge', async () => {
+ let unauthorizedUser = await generateUser();
+
+ await expect(unauthorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('returns challenge data', async () => {
+ let res = await authorizedUser.post(`/challenges/${challenge._id}/join`);
+
+ expect(res.group).to.eql({
+ _id: group._id,
+ privacy: group.privacy,
+ name: group.name,
+ type: group.type,
+ });
+ expect(res.leader).to.eql({
+ _id: groupLeader._id,
+ profile: {name: groupLeader.profile.name},
+ });
+ expect(res.name).to.equal(challenge.name);
+ });
+
+ it('adds challenge to user challenges', async () => {
+ await authorizedUser.post(`/challenges/${challenge._id}/join`);
+
+ await authorizedUser.sync();
+
+ expect(authorizedUser).to.have.property('challenges').to.include(challenge._id);
+ });
+
+ it('returns error when user has already joined the challenge', async () => {
+ await authorizedUser.post(`/challenges/${challenge._id}/join`);
+
+ await expect(authorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('userAlreadyInChallenge'),
+ });
+ });
+
+ it('increases memberCount of challenge', async () => {
+ let oldMemberCount = challenge.memberCount;
+
+ await authorizedUser.post(`/challenges/${challenge._id}/join`);
+
+ await challenge.sync();
+
+ expect(challenge).to.have.property('memberCount', oldMemberCount + 1);
+ });
+
+ it('syncs challenge tasks to joining user', async () => {
+ let taskText = 'A challenge task text';
+
+ await groupLeader.post(`/tasks/challenge/${challenge._id}`, [
+ {type: 'habit', text: taskText},
+ ]);
+
+ await authorizedUser.post(`/challenges/${challenge._id}/join`);
+ let tasks = await authorizedUser.get('/tasks/user');
+ let tasksTexts = tasks.map((task) => {
+ return task.text;
+ });
+
+ expect(tasksTexts).to.include(taskText);
+ });
+
+ it('adds challenge tag to user tags', async () => {
+ let userTagsLength = (await authorizedUser.get('/tags')).length;
+
+ await authorizedUser.post(`/challenges/${challenge._id}/join`);
+
+ await expect(authorizedUser.get('/tags')).to.eventually.have.length(userTagsLength + 1);
+ });
+ });
+});
diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js
new file mode 100644
index 0000000000..9694b263a9
--- /dev/null
+++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_leave.test.js
@@ -0,0 +1,123 @@
+import {
+ generateUser,
+ generateChallenge,
+ createAndPopulateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /challenges/:challengeId/leave', () => {
+ it('returns error when challengeId is not a valid UUID', async () => {
+ let user = await generateUser();
+
+ await expect(user.post('/challenges/test/leave')).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('returns error when challengeId is not for a valid challenge', async () => {
+ let user = await generateUser();
+
+ await expect(user.post(`/challenges/${generateUUID()}/leave`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ context('Leaving a valid challenge', () => {
+ let groupLeader;
+ let group;
+ let challenge;
+ let notInChallengeUser;
+ let leavingUser;
+ let taskText;
+
+ beforeEach(async () => {
+ let populatedGroup = await createAndPopulateGroup({
+ members: 2,
+ });
+
+ groupLeader = populatedGroup.groupLeader;
+ group = populatedGroup.group;
+ leavingUser = populatedGroup.members[0];
+ notInChallengeUser = populatedGroup.members[1];
+
+ challenge = await generateChallenge(groupLeader, group);
+
+ taskText = 'A challenge task text';
+
+ await groupLeader.post(`/tasks/challenge/${challenge._id}`, [
+ {type: 'habit', text: taskText},
+ ]);
+
+ await leavingUser.post(`/challenges/${challenge._id}/join`);
+
+ await challenge.sync();
+ });
+
+ it('returns an error when user doesn\'t have permissions to view the challenge', async () => {
+ let unauthorizedUser = await generateUser();
+
+ await expect(unauthorizedUser.post(`/challenges/${challenge._id}/leave`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('returns an error when user isn\'t a member of the challenge', async () => {
+ await expect(notInChallengeUser.post(`/challenges/${challenge._id}/leave`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('challengeMemberNotFound'),
+ });
+ });
+
+ it('removes challenge from user challenges', async () => {
+ await leavingUser.post(`/challenges/${challenge._id}/leave`);
+
+ await leavingUser.sync();
+
+ expect(leavingUser).to.have.property('challenges').to.not.include(challenge._id);
+ });
+
+ it('decreases memberCount of challenge', async () => {
+ let oldMemberCount = challenge.memberCount;
+
+ await leavingUser.post(`/challenges/${challenge._id}/leave`);
+
+ await challenge.sync();
+
+ expect(challenge).to.have.property('memberCount', oldMemberCount - 1);
+ });
+
+ it('unlinks challenge tasks from leaving user when remove-all is passed', async () => {
+ await leavingUser.post(`/challenges/${challenge._id}/leave`, {
+ keep: 'remove-all',
+ });
+ let tasks = await leavingUser.get('/tasks/user');
+ let tasksTexts = tasks.map((task) => {
+ return task.text;
+ });
+
+ expect(tasksTexts).to.not.include(taskText);
+ });
+
+ it('doesn\'t unlink challenge tasks from leaving user when remove-all isn\'t passed', async () => {
+ await leavingUser.post(`/challenges/${challenge._id}/leave`, {
+ keep: 'test',
+ });
+
+ let tasks = await leavingUser.get('/tasks/user');
+ let testTask = _.find(tasks, (task) => {
+ return task.text === taskText;
+ });
+
+ expect(testTask).to.not.be.undefined;
+ expect(testTask.challenge).to.be.undefined;
+ });
+ });
+});
diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js
new file mode 100644
index 0000000000..98dfc20926
--- /dev/null
+++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_winner_winnerId.test.js
@@ -0,0 +1,139 @@
+import {
+ generateUser,
+ generateChallenge,
+ createAndPopulateGroup,
+ sleep,
+ checkExistence,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /challenges/:challengeId/winner/:winnerId', () => {
+ it('returns error when challengeId is not a valid UUID', async () => {
+ let user = await generateUser();
+
+ await expect(user.post(`/challenges/test/selectWinner/${user._id}`)).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('returns error when winnerId is not a valid UUID', async () => {
+ let user = await generateUser();
+
+ await expect(user.post(`/challenges/${generateUUID()}/selectWinner/test`)).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('returns error when challengeId is not for a valid challenge', async () => {
+ let user = await generateUser();
+
+ await expect(user.post(`/challenges/${generateUUID()}/selectWinner/${user._id}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ context('Selecting winner for a valid challenge', () => {
+ let groupLeader;
+ let group;
+ let challenge;
+ let winningUser;
+ let taskText = 'A challenge task text';
+
+ beforeEach(async () => {
+ let populatedGroup = await createAndPopulateGroup({
+ members: 1,
+ });
+
+ groupLeader = populatedGroup.groupLeader;
+ group = populatedGroup.group;
+ winningUser = populatedGroup.members[0];
+
+ challenge = await generateChallenge(groupLeader, group, {
+ prize: 1,
+ });
+
+ await groupLeader.post(`/tasks/challenge/${challenge._id}`, [
+ {type: 'habit', text: taskText},
+ ]);
+
+ await winningUser.post(`/challenges/${challenge._id}/join`);
+
+ await challenge.sync();
+ });
+
+ it('returns an error when user doesn\'t have permissions to select winner', async () => {
+ await expect(winningUser.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('onlyLeaderDeleteChal'),
+ });
+ });
+
+ it('returns an error when winning user isn\'t part of the challenge', async () => {
+ let notInChallengeUser = await generateUser();
+
+ await expect(groupLeader.post(`/challenges/${challenge._id}/selectWinner/${notInChallengeUser._id}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('winnerNotFound', {userId: notInChallengeUser._id}),
+ });
+ });
+
+ it('deletes challenge after winner is selected', async () => {
+ await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`);
+
+ await sleep(0.5);
+
+ await expect(checkExistence('challenges', challenge._id)).to.eventually.equal(false);
+ });
+
+ it('adds challenge to winner\'s achievements', async () => {
+ await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`);
+
+ await sleep(0.5);
+
+ await expect(winningUser.sync()).to.eventually.have.deep.property('achievements.challenges').to.include(challenge.name);
+ });
+
+ it('gives winner gems as reward', async () => {
+ let oldBalance = winningUser.balance;
+
+ await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`);
+
+ await sleep(0.5);
+
+ await expect(winningUser.sync()).to.eventually.have.property('balance', oldBalance + challenge.prize / 4);
+ });
+
+ it('doesn\'t refund gems to group leader', async () => {
+ let oldBalance = (await groupLeader.sync()).balance;
+
+ await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`);
+
+ await sleep(0.5);
+
+ await expect(groupLeader.sync()).to.eventually.have.property('balance', oldBalance);
+ });
+
+ it('sets broken and winner flags for user\'s challenge tasks', async () => {
+ await groupLeader.post(`/challenges/${challenge._id}/selectWinner/${winningUser._id}`);
+
+ await sleep(0.5);
+
+ let tasks = await winningUser.get('/tasks/user');
+ let testTask = _.find(tasks, (task) => {
+ return task.text === taskText;
+ });
+
+ expect(testTask.challenge.broken).to.eql('CHALLENGE_CLOSED');
+ expect(testTask.challenge.winner).to.eql(winningUser.profile.name);
+ });
+ });
+});
diff --git a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js
new file mode 100644
index 0000000000..63d2102968
--- /dev/null
+++ b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js
@@ -0,0 +1,84 @@
+import {
+ generateUser,
+ generateChallenge,
+ createAndPopulateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('PUT /challenges/:challengeId', () => {
+ let privateGuild, user, nonMember, challenge, member;
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'TestPrivateGuild',
+ type: 'guild',
+ privacy: 'private',
+ },
+ members: 1,
+ });
+
+ privateGuild = group;
+ user = groupLeader;
+
+ nonMember = await generateUser();
+ member = members[0];
+
+ challenge = await generateChallenge(user, group);
+ await member.post(`/challenges/${challenge._id}/join`);
+ });
+
+ it('fails if the user can\'t view the challenge', async () => {
+ await expect(nonMember.put(`/challenges/${challenge._id}`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('should only allow the leader or an admin to update the challenge', async () => {
+ await expect(member.put(`/challenges/${challenge._id}`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('onlyLeaderUpdateChal'),
+ });
+ });
+
+ it('only updates allowed fields', async () => {
+ let res = await user.put(`/challenges/${challenge._id}`, {
+ // ignored
+ prize: 33,
+ group: 'blabla',
+ memberCount: 33,
+ tasksOrder: 'new order',
+ official: true,
+ shortName: 'new short name',
+
+ // applied
+ name: 'New Challenge Name',
+ description: 'New challenge description.',
+ leader: member._id,
+ });
+
+ expect(res.prize).to.equal(0);
+ expect(res.group).to.eql({
+ _id: privateGuild._id,
+ privacy: privateGuild.privacy,
+ name: privateGuild.name,
+ type: privateGuild.type,
+ });
+ expect(res.memberCount).to.equal(2);
+ expect(res.tasksOrder).not.to.equal('new order');
+ expect(res.official).to.equal(false);
+ expect(res.shortName).not.to.equal('new short name');
+
+ expect(res.leader).to.eql({
+ _id: member._id,
+ profile: {name: member.profile.name},
+ });
+ expect(res.name).to.equal('New Challenge Name');
+ expect(res.description).to.equal('New challenge description.');
+ });
+});
diff --git a/test/api/v3/integration/chat/DELETE-chat_id.test.js b/test/api/v3/integration/chat/DELETE-chat_id.test.js
new file mode 100644
index 0000000000..5d1f73f867
--- /dev/null
+++ b/test/api/v3/integration/chat/DELETE-chat_id.test.js
@@ -0,0 +1,81 @@
+import {
+ createAndPopulateGroup,
+ generateUser,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('DELETE /groups/:groupId/chat/:chatId', () => {
+ let groupWithChat, message, user, userThatDidNotCreateChat, admin;
+
+ before(async () => {
+ let { group, groupLeader } = await createAndPopulateGroup({
+ groupDetails: {
+ type: 'guild',
+ privacy: 'public',
+ },
+ });
+
+ groupWithChat = group;
+ user = groupLeader;
+ message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' });
+ message = message.message;
+ userThatDidNotCreateChat = await generateUser();
+ admin = await generateUser({'contributor.admin': true});
+ });
+
+ context('Chat errors', () => {
+ it('returns an error is message does not exist', async () => {
+ let fakeChatId = generateUUID();
+ await expect(user.del(`/groups/${groupWithChat._id}/chat/${fakeChatId}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('messageGroupChatNotFound'),
+ });
+ });
+
+ it('returns an error when user does not have permission to delete', async () => {
+ await expect(userThatDidNotCreateChat.del(`/groups/${groupWithChat._id}/chat/${message.id}`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('onlyCreatorOrAdminCanDeleteChat'),
+ });
+ });
+ });
+
+ context('Chat success', () => {
+ let nextMessage;
+
+ beforeEach(async () => {
+ nextMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some new message' });
+ nextMessage = nextMessage.message;
+ });
+
+ it('allows creator to delete a their message', async () => {
+ await user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}`);
+ let messages = await user.get(`/groups/${groupWithChat._id}/chat/`);
+ expect(messages).is.an('array');
+ expect(messages).to.not.include(nextMessage);
+ });
+
+ it('allows admin to delete another user\'s message', async () => {
+ await admin.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}`);
+ let messages = await user.get(`/groups/${groupWithChat._id}/chat/`);
+ expect(messages).is.an('array');
+ expect(messages).to.not.include(nextMessage);
+ });
+
+ it('returns empty when previous message parameter is passed and the last message was deleted', async () => {
+ await expect(user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}?previousMsg=${nextMessage.id}`))
+ .to.eventually.be.empty;
+ });
+
+ it('returns the update chat when previous message parameter is passed and the chat is updated', async () => {
+ await expect(user.del(`/groups/${groupWithChat._id}/chat/${nextMessage.id}?previousMsg=${message.id}`))
+ .eventually
+ .is.an('array')
+ .to.include(message)
+ .to.be.lengthOf(1);
+ });
+ });
+});
diff --git a/test/api/v3/integration/chat/GET-chat.test.js b/test/api/v3/integration/chat/GET-chat.test.js
new file mode 100644
index 0000000000..91424d655e
--- /dev/null
+++ b/test/api/v3/integration/chat/GET-chat.test.js
@@ -0,0 +1,65 @@
+import {
+ generateUser,
+ generateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('GET /groups/:groupId/chat', () => {
+ let user;
+
+ before(async () => {
+ user = await generateUser();
+ });
+
+ context('public Guild', () => {
+ let group;
+
+ before(async () => {
+ let leader = await generateUser({balance: 2});
+
+ group = await generateGroup(leader, {
+ name: 'test group',
+ type: 'guild',
+ privacy: 'public',
+ }, {
+ chat: [
+ {text: 'Hello', flags: {}},
+ {text: 'Welcome to the Guild', flags: {}},
+ ],
+ });
+ });
+
+ it('returns Guild chat', async () => {
+ let chat = await user.get(`/groups/${group._id}/chat`);
+
+ expect(chat).to.eql(group.chat);
+ });
+ });
+
+ context('private Guild', () => {
+ let group;
+
+ before(async () => {
+ let leader = await generateUser({balance: 2});
+
+ group = await generateGroup(leader, {
+ name: 'test group',
+ type: 'guild',
+ privacy: 'private',
+ }, {
+ chat: [
+ 'Hello',
+ 'Welcome to the Guild',
+ ],
+ });
+ });
+
+ it('returns error if user is not member of requested private group', async () => {
+ await expect(user.get(`/groups/${group._id}/chat`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+ });
+});
diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js
new file mode 100644
index 0000000000..51a3abb164
--- /dev/null
+++ b/test/api/v3/integration/chat/POST-chat.flag.test.js
@@ -0,0 +1,84 @@
+import {
+ generateUser,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { find } from 'lodash';
+
+describe('POST /chat/:chatId/flag', () => {
+ let user, admin, anotherUser, group;
+ const TEST_MESSAGE = 'Test Message';
+
+ before(async () => {
+ user = await generateUser({balance: 1});
+ admin = await generateUser({balance: 1, 'contributor.admin': true});
+ anotherUser = await generateUser();
+
+ group = await user.post('/groups', {
+ name: 'Test Guild',
+ type: 'guild',
+ privacy: 'public',
+ });
+ });
+
+ it('Returns an error when chat message is not found', async () => {
+ await expect(user.post(`/groups/${group._id}/chat/incorrectMessage/flag`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('messageGroupChatNotFound'),
+ });
+ });
+
+ it('Returns an error when user tries to flag their own message', async () => {
+ let message = await user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE });
+ await expect(user.post(`/groups/${group._id}/chat/${message.message.id}/flag`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('messageGroupChatFlagOwnMessage'),
+ });
+ });
+
+ it('Flags a chat', async () => {
+ let message = await anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE});
+ message = message.message;
+
+ let flagResult = await user.post(`/groups/${group._id}/chat/${message.id}/flag`);
+ expect(flagResult.flags[user._id]).to.equal(true);
+ expect(flagResult.flagCount).to.equal(1);
+
+ let groupWithFlags = await admin.get(`/groups/${group._id}`);
+
+ let messageToCheck = find(groupWithFlags.chat, {id: message.id});
+ expect(messageToCheck.flags[user._id]).to.equal(true);
+ });
+
+ it('Flags a chat with a higher flag acount when an admin flags the message', async () => {
+ let message = await user.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE});
+ message = message.message;
+
+ let flagResult = await admin.post(`/groups/${group._id}/chat/${message.id}/flag`);
+ expect(flagResult.flags[admin._id]).to.equal(true);
+ expect(flagResult.flagCount).to.equal(5);
+
+ let groupWithFlags = await admin.get(`/groups/${group._id}`);
+
+ let messageToCheck = find(groupWithFlags.chat, {id: message.id});
+ expect(messageToCheck.flags[admin._id]).to.equal(true);
+ expect(messageToCheck.flagCount).to.equal(5);
+ });
+
+ it('Returns an error when user tries to flag a message that is already flagged', async () => {
+ let message = await anotherUser.post(`/groups/${group._id}/chat`, { message: TEST_MESSAGE});
+ message = message.message;
+
+ await user.post(`/groups/${group._id}/chat/${message.id}/flag`);
+
+ await expect(user.post(`/groups/${group._id}/chat/${message.id}/flag`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('messageGroupChatFlagAlreadyReported'),
+ });
+ });
+});
diff --git a/test/api/v3/integration/chat/POST-chat.like.test.js b/test/api/v3/integration/chat/POST-chat.like.test.js
new file mode 100644
index 0000000000..d7ae6047df
--- /dev/null
+++ b/test/api/v3/integration/chat/POST-chat.like.test.js
@@ -0,0 +1,75 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { find } from 'lodash';
+
+describe('POST /chat/:chatId/like', () => {
+ let user;
+ let groupWithChat;
+ let testMessage = 'Test Message';
+ let anotherUser;
+
+ before(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'Test Guild',
+ type: 'guild',
+ privacy: 'public',
+ },
+ members: 1,
+ });
+
+ user = groupLeader;
+ groupWithChat = group;
+ anotherUser = members[0];
+ });
+
+ it('Returns an error when chat message is not found', async () => {
+ await expect(user.post(`/groups/${groupWithChat._id}/chat/incorrectMessage/like`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('messageGroupChatNotFound'),
+ });
+ });
+
+ it('Returns an error when user tries to like their own message', async () => {
+ let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage});
+
+ await expect(user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('messageGroupChatLikeOwnMessage'),
+ });
+ });
+
+ it('Likes a chat', async () => {
+ let message = await anotherUser.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage});
+
+ let likeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`);
+
+ expect(likeResult.likes[user._id]).to.equal(true);
+
+ let groupWithChatLikes = await user.get(`/groups/${groupWithChat._id}`);
+
+ let messageToCheck = find(groupWithChatLikes.chat, {id: message.message.id});
+ expect(messageToCheck.likes[user._id]).to.equal(true);
+ });
+
+ it('Unlikes a chat', async () => {
+ let message = await anotherUser.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage});
+
+ let likeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`);
+ expect(likeResult.likes[user._id]).to.equal(true);
+
+ let unlikeResult = await user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`);
+ expect(unlikeResult.likes[user._id]).to.equal(false);
+
+ let groupWithoutChatLikes = await user.get(`/groups/${groupWithChat._id}`);
+
+ let messageToCheck = find(groupWithoutChatLikes.chat, {id: message.message.id});
+ expect(messageToCheck.likes[user._id]).to.equal(false);
+ });
+});
diff --git a/test/api/v3/integration/chat/POST-chat.test.js b/test/api/v3/integration/chat/POST-chat.test.js
new file mode 100644
index 0000000000..99d1469af8
--- /dev/null
+++ b/test/api/v3/integration/chat/POST-chat.test.js
@@ -0,0 +1,81 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('POST /chat', () => {
+ let user, groupWithChat, userWithChatRevoked, member;
+ let testMessage = 'Test Message';
+
+ before(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'Test Guild',
+ type: 'guild',
+ privacy: 'public',
+ },
+ members: 2,
+ });
+
+ user = groupLeader;
+ groupWithChat = group;
+ userWithChatRevoked = await members[0].update({'flags.chatRevoked': true});
+ member = members[0];
+ });
+
+ it('Returns an error when no message is provided', async () => {
+ await expect(user.post(`/groups/${groupWithChat._id}/chat`, { message: ''}))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('Returns an error when group is not found', async () => {
+ await expect(user.post('/groups/invalidID/chat', { message: testMessage})).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('Returns an error when chat privileges are revoked', async () => {
+ await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: 'Your chat privileges have been revoked.',
+ });
+ });
+
+ it('creates a chat', async () => {
+ let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage});
+
+ expect(message.message.id).to.exist;
+ });
+
+ it('notifies other users of new messages for a guild', async () => {
+ let message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage});
+ let memberWithNotification = await member.get('/user');
+
+ expect(message.message.id).to.exist;
+ expect(memberWithNotification.newMessages[`${groupWithChat._id}`]).to.exist;
+ });
+
+ it('notifies other users of new messages for a party', async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'Test Party',
+ type: 'party',
+ privacy: 'private',
+ },
+ members: 1,
+ });
+
+ let message = await groupLeader.post(`/groups/${group._id}/chat`, { message: testMessage});
+ let memberWithNotification = await members[0].get('/user');
+
+ expect(message.message.id).to.exist;
+ expect(memberWithNotification.newMessages[`${group._id}`]).to.exist;
+ });
+});
diff --git a/test/api/v3/integration/chat/POST-chat_seen.test.js b/test/api/v3/integration/chat/POST-chat_seen.test.js
new file mode 100644
index 0000000000..8b22461a04
--- /dev/null
+++ b/test/api/v3/integration/chat/POST-chat_seen.test.js
@@ -0,0 +1,63 @@
+import {
+ createAndPopulateGroup,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('POST /groups/:id/chat/seen', () => {
+ context('Guild', () => {
+ let guild, guildLeader, guildMember, guildMessage;
+
+ before(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: {
+ type: 'guild',
+ privacy: 'public',
+ },
+ members: 1,
+ });
+
+ guild = group;
+ guildLeader = groupLeader;
+ guildMember = members[0];
+
+ guildMessage = await guildLeader.post(`/groups/${guild._id}/chat`, { message: 'Some guild message' });
+ guildMessage = guildMessage.message;
+ });
+
+ it('clears new messages for a guild', async () => {
+ await guildMember.post(`/groups/${guild._id}/chat/seen`);
+
+ let guildThatHasSeenChat = await guildMember.get('/user');
+
+ expect(guildThatHasSeenChat.newMessages).to.be.empty;
+ });
+ });
+
+ context('Party', () => {
+ let party, partyLeader, partyMember, partyMessage;
+
+ before(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: {
+ type: 'party',
+ privacy: 'private',
+ },
+ members: 1,
+ });
+
+ party = group;
+ partyLeader = groupLeader;
+ partyMember = members[0];
+
+ partyMessage = await partyLeader.post(`/groups/${party._id}/chat`, { message: 'Some party message' });
+ partyMessage = partyMessage.message;
+ });
+
+ it('clears new messages for a party', async () => {
+ await partyMember.post(`/groups/${party._id}/chat/seen`);
+
+ let partyMemberThatHasSeenChat = await partyMember.get('/user');
+
+ expect(partyMemberThatHasSeenChat.newMessages).to.be.empty;
+ });
+ });
+});
diff --git a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js
new file mode 100644
index 0000000000..87cad5d6f0
--- /dev/null
+++ b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js
@@ -0,0 +1,101 @@
+import {
+ createAndPopulateGroup,
+ generateUser,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /groups/:id/chat/:id/clearflags', () => {
+ let groupWithChat, message, author, nonAdmin, admin;
+
+ before(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: {
+ type: 'guild',
+ privacy: 'public',
+ },
+ members: 1,
+ });
+
+ groupWithChat = group;
+ author = groupLeader;
+ nonAdmin = members[0];
+ admin = await generateUser({'contributor.admin': true});
+
+ message = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' });
+ message = message.message;
+ admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/flag`);
+ });
+
+ context('Single Message', () => {
+ it('returns error when non-admin attempts to clear flags', async () => {
+ return expect(nonAdmin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('messageGroupChatAdminClearFlagCount'),
+ });
+ });
+
+ it('returns error if message does not exist', async () => {
+ let fakeMessageID = generateUUID();
+
+ await expect(admin.post(`/groups/${groupWithChat._id}/chat/${fakeMessageID}/clearflags`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('messageGroupChatNotFound'),
+ });
+ });
+
+ it('clears flags and leaves old flags on the flag object', async () => {
+ await admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`);
+ let messages = await admin.get(`/groups/${groupWithChat._id}/chat`);
+ expect(messages[0].flagCount).to.eql(0);
+ expect(messages[0].flags).to.have.property(admin._id, true);
+ });
+ });
+
+ context('admin user, group with multiple messages', () => {
+ let message2, message3, message4;
+
+ before(async () => {
+ message2 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 2' });
+ message2 = message2.message;
+ await admin.post(`/groups/${groupWithChat._id}/chat/${message2.id}/flag`);
+
+ message3 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 3' });
+ message3 = message3.message;
+ await admin.post(`/groups/${groupWithChat._id}/chat/${message3.id}/flag`);
+ await nonAdmin.post(`/groups/${groupWithChat._id}/chat/${message3.id}/flag`);
+
+ message4 = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message 4' });
+ message4 = message4.message;
+ });
+
+ it('changes only the message that is flagged', async () => {
+ await admin.post(`/groups/${groupWithChat._id}/chat/${message.id}/clearflags`);
+ let messages = await admin.get(`/groups/${groupWithChat._id}/chat`);
+
+ expect(messages).to.have.lengthOf(4);
+
+ let messageThatWasUnflagged = messages[3];
+ let messageWith1Flag = messages[2];
+ let messageWith2Flag = messages[1];
+ let messageWithoutFlags = messages[0];
+
+ expect(messageThatWasUnflagged.flagCount).to.eql(0);
+ expect(messageThatWasUnflagged.flags).to.have.property(admin._id, true);
+
+ expect(messageWith1Flag.flagCount).to.eql(5);
+ expect(messageWith1Flag.flags).to.have.property(admin._id, true);
+
+ expect(messageWith2Flag.flagCount).to.eql(6);
+ expect(messageWith2Flag.flags).to.have.property(admin._id, true);
+ expect(messageWith2Flag.flags).to.have.property(nonAdmin._id, true);
+
+ expect(messageWithoutFlags.flagCount).to.eql(0);
+ expect(messageWithoutFlags.flags).to.eql({});
+ });
+ });
+});
diff --git a/test/api/v3/integration/groups/GET-groups.test.js b/test/api/v3/integration/groups/GET-groups.test.js
new file mode 100644
index 0000000000..b37b87f890
--- /dev/null
+++ b/test/api/v3/integration/groups/GET-groups.test.js
@@ -0,0 +1,96 @@
+import {
+ generateUser,
+ resetHabiticaDB,
+ generateGroup,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('GET /groups', () => {
+ let user;
+ const NUMBER_OF_PUBLIC_GUILDS = 3;
+ const NUMBER_OF_USERS_PRIVATE_GUILDS = 1;
+ const NUMBER_OF_GROUPS_USER_CAN_VIEW = 5;
+
+ before(async () => {
+ await resetHabiticaDB();
+
+ let leader = await generateUser({ balance: 10 });
+ user = await generateUser({balance: 4});
+
+ let publicGuildUserIsMemberOf = await generateGroup(leader, {
+ name: 'public guild - is member',
+ type: 'guild',
+ privacy: 'public',
+ });
+ await leader.post(`/groups/${publicGuildUserIsMemberOf._id}/invite`, { uuids: [user._id]});
+ await user.post(`/groups/${publicGuildUserIsMemberOf._id}/join`);
+
+ await generateGroup(leader, {
+ name: 'public guild - is not member',
+ type: 'guild',
+ privacy: 'public',
+ });
+
+ let privateGuildUserIsMemberOf = await generateGroup(leader, {
+ name: 'private guild - is member',
+ type: 'guild',
+ privacy: 'private',
+ });
+ await leader.post(`/groups/${privateGuildUserIsMemberOf._id}/invite`, { uuids: [user._id]});
+ await user.post(`/groups/${privateGuildUserIsMemberOf._id}/join`);
+
+ await generateGroup(leader, {
+ name: 'private guild - is not member',
+ type: 'guild',
+ privacy: 'private',
+ });
+
+ await generateGroup(leader, {
+ name: 'party - is not member',
+ type: 'party',
+ privacy: 'private',
+ });
+
+ await user.post('/groups', {
+ name: 'party - is member',
+ type: 'party',
+ privacy: 'private',
+ });
+ });
+
+ it('returns error when no query passed in', async () => {
+ await expect(user.get('/groups'))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: 'Invalid request parameters.',
+ });
+ });
+
+ it('returns only the tavern when tavern passed in as query', async () => {
+ await expect(user.get('/groups?type=tavern'))
+ .to.eventually.have.a.lengthOf(1)
+ .and.to.have.deep.property('[0]')
+ .and.to.have.property('_id', 'habitrpg');
+ });
+
+ it('returns only the user\'s party when party passed in as query', async () => {
+ await expect(user.get('/groups?type=party'))
+ .to.eventually.have.a.lengthOf(1)
+ .and.to.have.deep.property('[0]');
+ });
+
+ it('returns all public guilds when publicGuilds passed in as query', async () => {
+ await expect(user.get('/groups?type=publicGuilds'))
+ .to.eventually.have.a.lengthOf(NUMBER_OF_PUBLIC_GUILDS);
+ });
+
+ it('returns all private guilds user is a part of when privateGuilds passed in as query', async () => {
+ await expect(user.get('/groups?type=privateGuilds'))
+ .to.eventually.have.a.lengthOf(NUMBER_OF_USERS_PRIVATE_GUILDS);
+ });
+
+ it('returns a list of groups user has access to', async () => {
+ await expect(user.get('/groups?type=privateGuilds,publicGuilds,party,tavern'))
+ .to.eventually.have.lengthOf(NUMBER_OF_GROUPS_USER_CAN_VIEW);
+ });
+});
diff --git a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js
new file mode 100644
index 0000000000..d1604a4d84
--- /dev/null
+++ b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js
@@ -0,0 +1,101 @@
+import {
+ generateUser,
+ generateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('GET /groups/:groupId/invites', () => {
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+ });
+
+ it('validates optional req.query.lastId to be an UUID', async () => {
+ await expect(user.get(`/groups/groupId/invites?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('fails if group doesn\'t exists', async () => {
+ await expect(user.get(`/groups/${generateUUID()}/invites`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('fails if user doesn\'t have access to the group', async () => {
+ let group = await generateGroup(user, {type: 'party', name: generateUUID()});
+ let anotherUser = await generateUser();
+ await expect(anotherUser.get(`/groups/${group._id}/invites`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('works when passing party as req.params.groupId', async () => {
+ let group = await generateGroup(user, {type: 'party', name: generateUUID()});
+ let invited = await generateUser();
+ await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]});
+ let res = await user.get(`/groups/party/invites`);
+
+ expect(res).to.be.an('array');
+ expect(res.length).to.equal(1);
+ expect(res[0]).to.eql({
+ _id: invited._id,
+ profile: {name: invited.profile.name},
+ });
+ });
+
+ it('populates only some fields', async () => {
+ let group = await generateGroup(user, {type: 'party', name: generateUUID()});
+ let invited = await generateUser();
+ await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]});
+ let res = await user.get(`/groups/party/invites`);
+ expect(res[0]).to.have.all.keys(['_id', 'profile']);
+ expect(res[0].profile).to.have.all.keys(['name']);
+ });
+
+ it('returns only first 30 invites', async () => {
+ let group = await generateGroup(user, {type: 'party', name: generateUUID()});
+ let invitesToGenerate = [];
+ for (let i = 0; i < 31; i++) {
+ invitesToGenerate.push(generateUser());
+ }
+ let generatedInvites = await Promise.all(invitesToGenerate);
+ await user.post(`/groups/${group._id}/invite`, {uuids: generatedInvites.map(invite => invite._id)});
+
+ let res = await user.get(`/groups/party/invites`);
+ expect(res.length).to.equal(30);
+ res.forEach(member => {
+ expect(member).to.have.all.keys(['_id', 'profile']);
+ expect(member.profile).to.have.all.keys(['name']);
+ });
+ });
+
+ it('supports using req.query.lastId to get more invites', async () => {
+ let leader = await generateUser({balance: 4});
+ let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()});
+
+ let invitesToGenerate = [];
+ for (let i = 0; i < 32; i++) {
+ invitesToGenerate.push(generateUser());
+ }
+ let generatedInvites = await Promise.all(invitesToGenerate); // Group has 32 invites
+ let expectedIds = generatedInvites.map(generatedInvite => generatedInvite._id);
+ await user.post(`/groups/${group._id}/invite`, {uuids: expectedIds});
+
+ let res = await user.get(`/groups/${group._id}/invites`);
+ expect(res.length).to.equal(30);
+ let res2 = await user.get(`/groups/${group._id}/invites?lastId=${res[res.length - 1]._id}`);
+ expect(res2.length).to.equal(2);
+
+ let resIds = res.concat(res2).map(invite => invite._id);
+ expect(resIds).to.eql(expectedIds.sort());
+ });
+});
diff --git a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js
new file mode 100644
index 0000000000..15193db012
--- /dev/null
+++ b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js
@@ -0,0 +1,95 @@
+import {
+ generateUser,
+ generateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('GET /groups/:groupId/members', () => {
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+ });
+
+ it('validates optional req.query.lastId to be an UUID', async () => {
+ await expect(user.get(`/groups/groupId/members?lastId=invalidUUID`)).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('fails if group doesn\'t exists', async () => {
+ await expect(user.get(`/groups/${generateUUID()}/members`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('fails if user doesn\'t have access to the group', async () => {
+ let group = await generateGroup(user, {type: 'party', name: generateUUID()});
+ let anotherUser = await generateUser();
+ await expect(anotherUser.get(`/groups/${group._id}/members`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('works when passing party as req.params.groupId', async () => {
+ await generateGroup(user, {type: 'party', name: generateUUID()});
+ let res = await user.get(`/groups/party/members`);
+ expect(res).to.be.an('array');
+ expect(res.length).to.equal(1);
+ expect(res[0]).to.eql({
+ _id: user._id,
+ profile: {name: user.profile.name},
+ });
+ });
+
+ it('populates only some fields', async () => {
+ await generateGroup(user, {type: 'party', name: generateUUID()});
+ let res = await user.get(`/groups/party/members`);
+ expect(res[0]).to.have.all.keys(['_id', 'profile']);
+ expect(res[0].profile).to.have.all.keys(['name']);
+ });
+
+ it('returns only first 30 members', async () => {
+ let group = await generateGroup(user, {type: 'party', name: generateUUID()});
+
+ let usersToGenerate = [];
+ for (let i = 0; i < 31; i++) {
+ usersToGenerate.push(generateUser({party: {_id: group._id}}));
+ }
+ await Promise.all(usersToGenerate);
+
+ let res = await user.get(`/groups/party/members`);
+ expect(res.length).to.equal(30);
+ res.forEach(member => {
+ expect(member).to.have.all.keys(['_id', 'profile']);
+ expect(member.profile).to.have.all.keys(['name']);
+ });
+ });
+
+ it('supports using req.query.lastId to get more members', async () => {
+ let leader = await generateUser({balance: 4});
+ let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()});
+
+ let usersToGenerate = [];
+ for (let i = 0; i < 57; i++) {
+ usersToGenerate.push(generateUser({guilds: [group._id]}));
+ }
+ let generatedUsers = await Promise.all(usersToGenerate); // Group has 59 members (1 is the leader)
+ let expectedIds = [leader._id].concat(generatedUsers.map(generatedUser => generatedUser._id));
+
+ let res = await user.get(`/groups/${group._id}/members`);
+ expect(res.length).to.equal(30);
+ let res2 = await user.get(`/groups/${group._id}/members?lastId=${res[res.length - 1]._id}`);
+ expect(res2.length).to.equal(28);
+
+ let resIds = res.concat(res2).map(member => member._id);
+ expect(resIds).to.eql(expectedIds.sort());
+ });
+});
diff --git a/test/api/v3/integration/groups/GET-groups_id.test.js b/test/api/v3/integration/groups/GET-groups_id.test.js
new file mode 100644
index 0000000000..2ecb53a33f
--- /dev/null
+++ b/test/api/v3/integration/groups/GET-groups_id.test.js
@@ -0,0 +1,298 @@
+import {
+ generateUser,
+ createAndPopulateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+
+import {
+ each,
+} from 'lodash';
+
+describe('GET /groups/:id', () => {
+ let typesOfGroups = {};
+ typesOfGroups['public guild'] = { type: 'guild', privacy: 'public' };
+ typesOfGroups['private guild'] = { type: 'guild', privacy: 'private' };
+ typesOfGroups.party = { type: 'party', privacy: 'private' };
+
+ each(typesOfGroups, (groupDetails, groupType) => {
+ context(`Member of a ${groupType}`, () => {
+ let leader, member, createdGroup;
+
+ before(async () => {
+ let groupData = await createAndPopulateGroup({
+ members: 30,
+ groupDetails,
+ });
+
+ leader = groupData.groupLeader;
+ member = groupData.members[0];
+ createdGroup = groupData.group;
+ });
+
+ it('returns the group object', async () => {
+ let group = await member.get(`/groups/${createdGroup._id}`);
+
+ expect(group._id).to.eql(createdGroup._id);
+ expect(group.name).to.eql(createdGroup.name);
+ expect(group.type).to.eql(createdGroup.type);
+ expect(group.privacy).to.eql(createdGroup.privacy);
+ });
+
+ it('transforms leader id to leader object', async () => {
+ let group = await member.get(`/groups/${createdGroup._id}`);
+
+ expect(group.leader._id).to.eql(leader._id);
+ expect(group.leader.profile.name).to.eql(leader.profile.name);
+ });
+ });
+ });
+
+ context('Non-member of a public guild', () => {
+ let nonMember, createdGroup;
+
+ before(async () => {
+ let groupData = await createAndPopulateGroup({
+ members: 1,
+ groupDetails: {
+ name: 'test guild',
+ type: 'guild',
+ privacy: 'public',
+ },
+ });
+
+ createdGroup = groupData.group;
+ nonMember = await generateUser();
+ });
+
+ it('returns the group object for a non-member', async () => {
+ let group = await nonMember.get(`/groups/${createdGroup._id}`);
+
+ expect(group._id).to.eql(createdGroup._id);
+ expect(group.name).to.eql(createdGroup.name);
+ expect(group.type).to.eql(createdGroup.type);
+ expect(group.privacy).to.eql(createdGroup.privacy);
+ });
+ });
+
+ context('Non-member of a private guild', () => {
+ let nonMember, createdGroup;
+
+ before(async () => {
+ let groupData = await createAndPopulateGroup({
+ members: 1,
+ groupDetails: {
+ name: 'test guild',
+ type: 'guild',
+ privacy: 'private',
+ },
+ });
+
+ createdGroup = groupData.group;
+ nonMember = await generateUser();
+ });
+
+ it('does not return the group object for a non-member', async () => {
+ await expect(nonMember.get(`/groups/${createdGroup._id}`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+ });
+
+ context('Non-member of a party', () => {
+ let nonMember, createdGroup;
+
+ before(async () => {
+ let groupData = await createAndPopulateGroup({
+ members: 1,
+ groupDetails: {
+ name: 'test party',
+ type: 'party',
+ privacy: 'private',
+ },
+ });
+
+ createdGroup = groupData.group;
+ nonMember = await generateUser();
+ });
+
+ it('does not return the group object for a non-member', async () => {
+ await expect(nonMember.get(`/groups/${createdGroup._id}`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+ });
+
+ context('Member of a party', () => {
+ let member, createdGroup;
+
+ before(async () => {
+ let groupData = await createAndPopulateGroup({
+ members: 1,
+ groupDetails: {
+ name: 'test party',
+ type: 'party',
+ privacy: 'private',
+ },
+ });
+
+ createdGroup = groupData.group;
+ member = groupData.members[0];
+ });
+
+ it('returns the user\'s party if an id of "party" is passed in', async () => {
+ let group = await member.get('/groups/party');
+
+ expect(group._id).to.eql(createdGroup._id);
+ expect(group.name).to.eql(createdGroup.name);
+ expect(group.type).to.eql(createdGroup.type);
+ expect(group.privacy).to.eql(createdGroup.privacy);
+ });
+ });
+
+ context('Non-existent group', () => {
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+ });
+
+ it('returns error if group does not exist', async () => {
+ await expect(user.get('/groups/group-that-does-not-exist'))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+ });
+
+ context('Flagged messages', () => {
+ let group;
+
+ let chat1 = {
+ id: 'chat1',
+ text: 'chat 1',
+ flags: {},
+ };
+
+ let chat2 = {
+ id: 'chat2',
+ text: 'chat 2',
+ flags: {},
+ flagCount: 0,
+ };
+
+ let chat3 = {
+ id: 'chat3',
+ text: 'chat 3',
+ flags: {
+ 'user-id': true,
+ },
+ flagCount: 1,
+ };
+
+ let chat4 = {
+ id: 'chat4',
+ text: 'chat 4',
+ flags: {
+ 'user-id': true,
+ 'other-user-id': true,
+ },
+ flagCount: 2,
+ };
+
+ let chat5 = {
+ id: 'chat5',
+ text: 'chat 5',
+ flags: {
+ 'user-id': true,
+ 'other-user-id': true,
+ 'yet-another-user-id': true,
+ },
+ flagCount: 3,
+ };
+
+ beforeEach(async () => {
+ let groupData = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'test guild',
+ type: 'guild',
+ privacy: 'public',
+ chat: [
+ chat1,
+ chat2,
+ chat3,
+ chat4,
+ chat5,
+ ],
+ },
+ });
+
+ group = groupData.group;
+
+ await group.addChat([chat1, chat2, chat3, chat4, chat5]);
+ });
+
+ context('non-admin', () => {
+ let nonAdmin;
+
+ beforeEach(async () => {
+ nonAdmin = await generateUser();
+ });
+
+ it('does not include messages with a flag count of 2 or greater', async () => {
+ let fetchedGroup = await nonAdmin.get(`/groups/${group._id}`);
+
+ expect(fetchedGroup.chat).to.have.lengthOf(3);
+ expect(fetchedGroup.chat[0].id).to.eql(chat1.id);
+ expect(fetchedGroup.chat[1].id).to.eql(chat2.id);
+ expect(fetchedGroup.chat[2].id).to.eql(chat3.id);
+ });
+
+ it('does not include user ids in flags object', async () => {
+ let fetchedGroup = await nonAdmin.get(`/groups/${group._id}`);
+ let chatWithOneFlag = fetchedGroup.chat[2];
+
+ expect(chatWithOneFlag.id).to.eql(chat3.id);
+ expect(chat3.flags).to.eql({ 'user-id': true });
+ expect(chatWithOneFlag.flags).to.eql({});
+ });
+ });
+
+ context('admin', () => {
+ let admin;
+
+ beforeEach(async () => {
+ admin = await generateUser({
+ 'contributor.admin': true,
+ });
+ });
+
+ it('includes all messages', async () => {
+ let fetchedGroup = await admin.get(`/groups/${group._id}`);
+
+ expect(fetchedGroup.chat).to.have.lengthOf(5);
+ expect(fetchedGroup.chat[0].id).to.eql(chat1.id);
+ expect(fetchedGroup.chat[1].id).to.eql(chat2.id);
+ expect(fetchedGroup.chat[2].id).to.eql(chat3.id);
+ expect(fetchedGroup.chat[3].id).to.eql(chat4.id);
+ expect(fetchedGroup.chat[4].id).to.eql(chat5.id);
+ });
+
+ it('includes user ids in flags object', async () => {
+ let fetchedGroup = await admin.get(`/groups/${group._id}`);
+ let chatWithOneFlag = fetchedGroup.chat[2];
+
+ expect(chatWithOneFlag.id).to.eql(chat3.id);
+ expect(chat3.flags).to.eql({ 'user-id': true });
+ expect(chatWithOneFlag.flags).to.eql(chat3.flags);
+ });
+ });
+ });
+});
diff --git a/test/api/v3/integration/groups/POST-groups.test.js b/test/api/v3/integration/groups/POST-groups.test.js
new file mode 100644
index 0000000000..8036d6b0d8
--- /dev/null
+++ b/test/api/v3/integration/groups/POST-groups.test.js
@@ -0,0 +1,217 @@
+import {
+ generateUser,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('POST /group', () => {
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser({ balance: 10 });
+ });
+
+ context('All Groups', () => {
+ it('it returns validation error when type is not provided', async () => {
+ await expect(
+ user.post('/groups', { name: 'Test Group Without Type' })
+ ).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: 'Group validation failed',
+ });
+ });
+
+ it('it returns validation error when type is not supported', async () => {
+ await expect(
+ user.post('/groups', { name: 'Group with unsupported type', type: 'foo' })
+ ).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: 'Group validation failed',
+ });
+ });
+
+ it('sets the group leader to the user who created the group', async () => {
+ let group = await user.post('/groups', {
+ name: 'Test Public Guild',
+ type: 'guild',
+ });
+
+ expect(group.leader).to.eql({
+ _id: user._id,
+ profile: {
+ name: user.profile.name,
+ },
+ });
+ });
+ });
+
+ context('Guilds', () => {
+ it('returns an error when a user with insufficient funds attempts to create a guild', async () => {
+ await user.update({ balance: 0 });
+
+ await expect(
+ user.post('/groups', {
+ name: 'Test Public Guild',
+ type: 'guild',
+ })
+ ).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('messageInsufficientGems'),
+ });
+ });
+
+ it('adds guild to user\'s list of guilds', async () => {
+ let guild = await user.post('/groups', {
+ name: 'some guild',
+ type: 'guild',
+ privacy: 'public',
+ });
+
+ let updatedUser = await user.get('/user');
+
+ expect(updatedUser.guilds).to.include(guild._id);
+ });
+
+ context('public guild', () => {
+ it('creates a group', async () => {
+ let groupName = 'Test Public Guild';
+ let groupType = 'guild';
+ let groupPrivacy = 'public';
+
+ let publicGuild = await user.post('/groups', {
+ name: groupName,
+ type: groupType,
+ privacy: groupPrivacy,
+ });
+
+ expect(publicGuild._id).to.exist;
+ expect(publicGuild.name).to.equal(groupName);
+ expect(publicGuild.type).to.equal(groupType);
+ expect(publicGuild.memberCount).to.equal(1);
+ expect(publicGuild.privacy).to.equal(groupPrivacy);
+ expect(publicGuild.leader).to.eql({
+ _id: user._id,
+ profile: {
+ name: user.profile.name,
+ },
+ });
+ });
+ });
+
+ context('private guild', () => {
+ let groupName = 'Test Private Guild';
+ let groupType = 'guild';
+ let groupPrivacy = 'private';
+
+ it('creates a group', async () => {
+ let privateGuild = await user.post('/groups', {
+ name: groupName,
+ type: groupType,
+ privacy: groupPrivacy,
+ });
+
+ expect(privateGuild._id).to.exist;
+ expect(privateGuild.name).to.equal(groupName);
+ expect(privateGuild.type).to.equal(groupType);
+ expect(privateGuild.memberCount).to.equal(1);
+ expect(privateGuild.privacy).to.equal(groupPrivacy);
+ expect(privateGuild.leader).to.eql({
+ _id: user._id,
+ profile: {
+ name: user.profile.name,
+ },
+ });
+ });
+
+ it('deducts gems from user and adds them to guild bank', async () => {
+ let privateGuild = await user.post('/groups', {
+ name: groupName,
+ type: groupType,
+ privacy: groupPrivacy,
+ });
+
+ expect(privateGuild.balance).to.eql(1);
+
+ let updatedUser = await user.get('/user');
+
+ expect(updatedUser.balance).to.eql(user.balance - 1);
+ });
+ });
+ });
+
+ context('Parties', () => {
+ let partyName = 'Test Party';
+ let partyType = 'party';
+
+ it('creates a party', async () => {
+ let party = await user.post('/groups', {
+ name: partyName,
+ type: partyType,
+ });
+
+ expect(party._id).to.exist;
+ expect(party.name).to.equal(partyName);
+ expect(party.type).to.equal(partyType);
+ expect(party.memberCount).to.equal(1);
+ expect(party.leader).to.eql({
+ _id: user._id,
+ profile: {
+ name: user.profile.name,
+ },
+ });
+ });
+
+ it('does not require gems to create a party', async () => {
+ await user.update({ balance: 0 });
+
+ let party = await user.post('/groups', {
+ name: partyName,
+ type: partyType,
+ });
+
+ expect(party._id).to.exist;
+
+ let updatedUser = await user.get('/user');
+
+ expect(updatedUser.balance).to.eql(user.balance);
+ });
+
+ it('sets party id on user object', async () => {
+ let party = await user.post('/groups', {
+ name: partyName,
+ type: partyType,
+ });
+
+ let updatedUser = await user.get('/user');
+
+ expect(updatedUser.party._id).to.eql(party._id);
+ });
+
+ it('prevents user in a party from creating another party', async () => {
+ await user.post('/groups', {
+ name: partyName,
+ type: partyType,
+ });
+
+ await expect(user.post('/groups')).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('messageGroupAlreadyInParty'),
+ });
+ });
+
+ it('prevents creating a public party', async () => {
+ await expect(user.post('/groups', {
+ name: partyName,
+ type: partyType,
+ privacy: 'public',
+ })).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('partyMustbePrivate'),
+ });
+ });
+ });
+});
diff --git a/test/api/v3/integration/groups/POST-groups_groupId_join.test.js b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js
new file mode 100644
index 0000000000..b9b759bed9
--- /dev/null
+++ b/test/api/v3/integration/groups/POST-groups_groupId_join.test.js
@@ -0,0 +1,223 @@
+import {
+ generateUser,
+ createAndPopulateGroup,
+ checkExistence,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /group/:groupId/join', () => {
+ it('returns error when groupId is not for a valid group', async () => {
+ let joiningUser = await generateUser();
+
+ await expect(joiningUser.post(`/groups/${generateUUID()}/join`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ context('Joining a public guild', () => {
+ let user, joiningUser, publicGuild;
+
+ beforeEach(async () => {
+ let {group, groupLeader} = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'Test Guild',
+ type: 'guild',
+ privacy: 'public',
+ },
+ });
+
+ publicGuild = group;
+ user = groupLeader;
+ joiningUser = await generateUser();
+ });
+
+ it('allows non-invited users to join public guilds', async () => {
+ let res = await joiningUser.post(`/groups/${publicGuild._id}/join`);
+
+ await expect(joiningUser.get('/user')).to.eventually.have.property('guilds').to.include(publicGuild._id);
+ expect(res.leader._id).to.eql(user._id);
+ expect(res.leader.profile.name).to.eql(user.profile.name);
+ });
+
+ it('promotes joining member in a public empty guild to leader', async () => {
+ await user.post(`/groups/${publicGuild._id}/leave`);
+
+ await joiningUser.post(`/groups/${publicGuild._id}/join`);
+
+ await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.deep.property('leader._id', joiningUser._id);
+ });
+
+ it('increments memberCount when joining guilds', async () => {
+ let oldMemberCount = publicGuild.memberCount;
+
+ await joiningUser.post(`/groups/${publicGuild._id}/join`);
+
+ await expect(joiningUser.get(`/groups/${publicGuild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1);
+ });
+ });
+
+ context('Joining a private guild', () => {
+ let user, invitedUser, guild;
+
+ beforeEach(async () => {
+ let { group, groupLeader, invitees } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'Test Guild',
+ type: 'guild',
+ privacy: 'private',
+ },
+ invites: 1,
+ });
+
+ guild = group;
+ user = groupLeader;
+ invitedUser = invitees[0];
+ });
+
+ it('returns error when user is not invited to private guild', async () => {
+ let userWithoutInvite = await generateUser();
+
+ await expect(userWithoutInvite.post(`/groups/${guild._id}/join`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('messageGroupRequiresInvite'),
+ });
+ });
+
+ context('User is invited', () => {
+ it('allows invited user to join private guilds', async () => {
+ await invitedUser.post(`/groups/${guild._id}/join`);
+
+ await expect(invitedUser.get('/user')).to.eventually.have.property('guilds').to.include(guild._id);
+ });
+
+ it('clears invitation from user when joining guilds', async () => {
+ await invitedUser.post(`/groups/${guild._id}/join`);
+
+ await expect(invitedUser.get('/user'))
+ .to.eventually.have.deep.property('invitations.guilds')
+ .to.not.include({id: guild._id});
+ });
+
+ it('increments memberCount when joining guilds', async () => {
+ let oldMemberCount = guild.memberCount;
+
+ await invitedUser.post(`/groups/${guild._id}/join`);
+
+ await expect(invitedUser.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1);
+ });
+
+ it('does not give basilist quest to inviter when joining a guild', async () => {
+ await invitedUser.post(`/groups/${guild._id}/join`);
+
+ await expect(user.get('/user')).to.eventually.not.have.deep.property('items.quests.basilist');
+ });
+
+ it('does not increment basilist quest count to inviter with basilist when joining a guild', async () => {
+ await user.update({ 'items.quests.basilist': 1 });
+
+ await invitedUser.post(`/groups/${guild._id}/join`);
+
+ await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1);
+ });
+ });
+ });
+
+ context('Joining a party', () => {
+ let user, invitedUser, party;
+
+ beforeEach(async () => {
+ let { group, groupLeader, invitees } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'Test Party',
+ type: 'party',
+ },
+ invites: 1,
+ });
+
+ party = group;
+ user = groupLeader;
+ invitedUser = invitees[0];
+ });
+
+ it('returns error when user is not invited to party', async () => {
+ let userWithoutInvite = await generateUser();
+
+ await expect(userWithoutInvite.post(`/groups/${party._id}/join`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('messageGroupRequiresInvite'),
+ });
+ });
+
+ context('User is invited', () => {
+ it('allows invited user to join party', async () => {
+ await invitedUser.post(`/groups/${party._id}/join`);
+
+ await expect(invitedUser.get('/user')).to.eventually.have.deep.property('party._id', party._id);
+ });
+
+ it('clears invitation from user when joining party', async () => {
+ await invitedUser.post(`/groups/${party._id}/join`);
+
+ await expect(invitedUser.get('/user')).to.eventually.not.have.deep.property('invitations.party.id');
+ });
+
+ it('increments memberCount when joining party', async () => {
+ let oldMemberCount = party.memberCount;
+
+ await invitedUser.post(`/groups/${party._id}/join`);
+
+ await expect(invitedUser.get(`/groups/${party._id}`)).to.eventually.have.property('memberCount', oldMemberCount + 1);
+ });
+
+ it('gives basilist quest item to the inviter when joining a party', async () => {
+ await invitedUser.post(`/groups/${party._id}/join`);
+
+ await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 1);
+ });
+
+ it('increments basilist quest item count to inviter when joining a party', async () => {
+ await user.update({'items.quests.basilist': 1 });
+
+ await invitedUser.post(`/groups/${party._id}/join`);
+
+ await expect(user.get('/user')).to.eventually.have.deep.property('items.quests.basilist', 2);
+ });
+
+ it('deletes previous party where the user was the only member', async () => {
+ let userToInvite = await generateUser();
+ let oldParty = await userToInvite.post('/groups', { // add user to a party
+ name: 'Another Test Party',
+ type: 'party',
+ });
+
+ await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(true);
+ await user.post(`/groups/${party._id}/invite`, {
+ uuids: [userToInvite._id],
+ });
+ await userToInvite.post(`/groups/${party._id}/join`);
+
+ await expect(user.get('/user')).to.eventually.have.deep.property('party._id', party._id);
+ await expect(checkExistence('groups', oldParty._id)).to.eventually.equal(false);
+ });
+
+ xit('invites joining member to active quest', async () => {
+ // TODO start quest
+
+ await invitedUser.post(`/groups/${party._id}/join`);
+
+ invitedUser = await user.get('/user');
+ party = await user.get(`/groups/${party._id}`);
+
+ expect(user).to.have.deep.property('party.quest.RSVPNeeded', true);
+ expect(user).to.have.deep.property('party.quest.key', party.quest.key);
+
+ expect(party.quest.members[invitedUser._id]).to.be.undefined;
+ });
+ });
+ });
+});
diff --git a/test/api/v3/integration/groups/POST-groups_groupId_leave.js b/test/api/v3/integration/groups/POST-groups_groupId_leave.js
new file mode 100644
index 0000000000..59031300ba
--- /dev/null
+++ b/test/api/v3/integration/groups/POST-groups_groupId_leave.js
@@ -0,0 +1,195 @@
+import {
+ generateChallenge,
+ checkExistence,
+ createAndPopulateGroup,
+ sleep,
+} from '../../../../helpers/api-v3-integration.helper';
+import {
+ each,
+} from 'lodash';
+
+describe('POST /groups/:groupId/leave', () => {
+ let typesOfGroups = {
+ 'public guild': { type: 'guild', privacy: 'public' },
+ 'private guild': { type: 'guild', privacy: 'private' },
+ party: { type: 'party', privacy: 'private' },
+ };
+
+ each(typesOfGroups, (groupDetails, groupType) => {
+ context(`Leaving a ${groupType}`, () => {
+ let groupToLeave;
+ let leader;
+ let member;
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails,
+ members: 1,
+ });
+
+ groupToLeave = group;
+ leader = groupLeader;
+ member = members[0];
+ });
+
+ it(`lets user leave a ${groupType}`, async () => {
+ await member.post(`/groups/${groupToLeave._id}/leave`);
+
+ let userThatLeftGroup = await member.get('/user');
+
+ expect(userThatLeftGroup.guilds).to.be.empty;
+ expect(userThatLeftGroup.party._id).to.not.exist;
+ });
+
+ it(`sets a new group leader when leader leaves a ${groupType}`, async () => {
+ await leader.post(`/groups/${groupToLeave._id}/leave`);
+
+ let groupToLeaveWithNewLeader = await member.get(`/groups/${groupToLeave._id}`);
+
+ expect(groupToLeaveWithNewLeader.leader._id).to.equal(member._id);
+ });
+
+ context('With challenges', () => {
+ let challenge;
+
+ beforeEach(async () => {
+ challenge = await generateChallenge(leader, groupToLeave);
+
+ await leader.post(`/tasks/challenge/${challenge._id}`, {
+ text: 'test habit',
+ type: 'habit',
+ });
+
+ await sleep(0.5);
+ });
+
+ it('removes all challenge tasks when keep parameter is set to remove', async () => {
+ await leader.post(`/groups/${groupToLeave._id}/leave?keep=remove-all`);
+
+ let userWithoutChallengeTasks = await leader.get('/user');
+
+ expect(userWithoutChallengeTasks.challenges).to.not.include(challenge._id);
+ expect(userWithoutChallengeTasks.tasksOrder.habits).to.be.empty;
+ });
+
+ it('keeps all challenge tasks when keep parameter is not set', async () => {
+ await leader.post(`/groups/${groupToLeave._id}/leave`);
+
+ let userWithChallengeTasks = await leader.get('/user');
+
+ expect(userWithChallengeTasks.challenges).to.not.include(challenge._id);
+ // @TODO find elegant way to assert against the task existing
+ expect(userWithChallengeTasks.tasksOrder.habits).to.not.be.empty;
+ });
+ });
+
+ it('prevents quest leader from leaving a groupToLeave');
+ it('prevents a user from leaving during an active quest');
+ });
+ });
+
+ context('Leaving a group as the last member', () => {
+ context('private guild', () => {
+ let privateGuild;
+ let leader;
+ let invitedUser;
+
+ beforeEach(async () => {
+ let { group, groupLeader, invitees } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'Test Private Guild',
+ type: 'guild',
+ },
+ invites: 1,
+ });
+
+ privateGuild = group;
+ leader = groupLeader;
+ invitedUser = invitees[0];
+ });
+
+ it('removes a group when the last member leaves', async () => {
+ await leader.post(`/groups/${privateGuild._id}/leave`);
+
+ await expect(checkExistence('groups', privateGuild._id)).to.eventually.equal(false);
+ });
+
+ it('removes invitations when the last member leaves', async () => {
+ await leader.post(`/groups/${privateGuild._id}/leave`);
+
+ let userWithoutInvitation = await invitedUser.get('/user');
+
+ expect(userWithoutInvitation.invitations.guilds).to.be.empty;
+ });
+ });
+
+ context('public guild', () => {
+ let publicGuild;
+ let leader;
+ let invitedUser;
+
+ beforeEach(async () => {
+ let { group, groupLeader, invitees } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'Test Public Guild',
+ type: 'guild',
+ privacy: 'public',
+ },
+ invites: 1,
+ });
+
+ publicGuild = group;
+ leader = groupLeader;
+ invitedUser = invitees[0];
+ });
+
+ it('keeps the group when the last member leaves', async () => {
+ await leader.post(`/groups/${publicGuild._id}/leave`);
+
+ await expect(checkExistence('groups', publicGuild._id)).to.eventually.equal(true);
+ });
+
+ it('keeps the invitations when the last member leaves a public guild', async () => {
+ await leader.post(`/groups/${publicGuild._id}/leave`);
+
+ let userWithoutInvitation = await invitedUser.get('/user');
+
+ expect(userWithoutInvitation.invitations.guilds).to.not.be.empty;
+ });
+ });
+
+ context('party', () => {
+ let party;
+ let leader;
+ let invitedUser;
+
+ beforeEach(async () => {
+ let { group, groupLeader, invitees } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'Test Party',
+ type: 'party',
+ },
+ invites: 1,
+ });
+
+ party = group;
+ leader = groupLeader;
+ invitedUser = invitees[0];
+ });
+
+ it('removes a group when the last member leaves a party', async () => {
+ await leader.post(`/groups/${party._id}/leave`);
+
+ await expect(checkExistence('party', party._id)).to.eventually.equal(false);
+ });
+
+ it('removes invitations when the last member leaves a party', async () => {
+ await leader.post(`/groups/${party._id}/leave`);
+
+ let userWithoutInvitation = await invitedUser.get('/user');
+
+ expect(userWithoutInvitation.invitations.party).to.be.empty;
+ });
+ });
+ });
+});
diff --git a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js
new file mode 100644
index 0000000000..0ebde39361
--- /dev/null
+++ b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js
@@ -0,0 +1,130 @@
+import {
+ generateUser,
+ createAndPopulateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('POST /groups/:groupId/removeMember/:memberId', () => {
+ let leader;
+ let invitedUser;
+ let guild;
+ let member;
+ let member2;
+
+ beforeEach(async () => {
+ let { group, groupLeader, invitees, members } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'Test Guild',
+ type: 'guild',
+ privacy: 'private',
+ },
+ invites: 1,
+ members: 2,
+ });
+
+ guild = group;
+ leader = groupLeader;
+ invitedUser = invitees[0];
+ member = members[0];
+ member2 = members[1];
+ });
+
+ context('All Groups', () => {
+ it('returns an error when user is not member of the group', async () => {
+ let nonMember = await generateUser();
+
+ expect(nonMember.post(`/groups/${guild._id}/removeMember/${member._id}`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ type: 'NotAuthorized',
+ message: t('onlyLeaderCanRemoveMember'),
+ });
+ });
+
+ it('returns an error when user is a non-leader member of a group', async () => {
+ expect(member2.post(`/groups/${guild._id}/removeMember/${member._id}`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ type: 'NotAuthorized',
+ message: t('onlyLeaderCanRemoveMember'),
+ });
+ });
+
+ it('does not allow leader to remove themselves', async () => {
+ expect(leader.post(`/groups/${guild._id}/removeMember/${leader._id}`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ text: t('messageGroupCannotRemoveSelf'),
+ });
+ });
+ });
+
+ context('Guilds', () => {
+ it('can remove other members', async () => {
+ await leader.post(`/groups/${guild._id}/removeMember/${member._id}`);
+ let memberRemoved = await member.get('/user');
+
+ expect(memberRemoved.guilds.indexOf(guild._id)).eql(-1);
+ });
+
+ it('updates memberCount', async () => {
+ let oldMemberCount = guild.memberCount;
+ await leader.post(`/groups/${guild._id}/removeMember/${member._id}`);
+ await expect(leader.get(`/groups/${guild._id}`)).to.eventually.have.property('memberCount', oldMemberCount - 1);
+ });
+
+ it('can remove other invites', async () => {
+ await leader.post(`/groups/${guild._id}/removeMember/${invitedUser._id}`);
+
+ let invitedUserWithoutInvite = await invitedUser.get('/user');
+
+ expect(_.findIndex(invitedUserWithoutInvite.invitations.guilds, {id: guild._id})).eql(-1);
+ });
+ });
+
+ context('Party', () => {
+ let party;
+ let partyleader;
+ let partyInvitedUser;
+ let partyMember;
+
+ beforeEach(async () => {
+ let { group, groupLeader, invitees, members } = await createAndPopulateGroup({
+ groupDetails: {
+ name: 'Test Party',
+ type: 'party',
+ privacy: 'private',
+ },
+ invites: 1,
+ members: 1,
+ });
+
+ party = group;
+ partyleader = groupLeader;
+ partyInvitedUser = invitees[0];
+ partyMember = members[0];
+ });
+
+ it('can remove other members', async () => {
+ await partyleader.post(`/groups/${party._id}/removeMember/${partyMember._id}`);
+
+ let memberRemoved = await partyMember.get('/user');
+
+ expect(memberRemoved.party._id).eql(undefined);
+ });
+
+ it('updates memberCount', async () => {
+ let oldMemberCount = party.memberCount;
+ await partyleader.post(`/groups/${party._id}/removeMember/${partyMember._id}`);
+ await expect(partyleader.get(`/groups/${party._id}`)).to.eventually.have.property('memberCount', oldMemberCount - 1);
+ });
+
+ it('can remove other invites', async () => {
+ await partyleader.post(`/groups/${party._id}/removeMember/${partyInvitedUser._id}`);
+
+ let invitedUserWithoutInvite = await partyInvitedUser.get('/user');
+
+ expect(_.findIndex(invitedUserWithoutInvite.invitations.party, {id: party._id})).eql(-1);
+ });
+ });
+});
diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js
new file mode 100644
index 0000000000..55c25d9153
--- /dev/null
+++ b/test/api/v3/integration/groups/POST-groups_invite.test.js
@@ -0,0 +1,325 @@
+import {
+ generateUser,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+const INVITES_LIMIT = 100;
+
+describe('Post /groups/:groupId/invite', () => {
+ let inviter;
+ let group;
+ let groupName = 'Test Public Guild';
+
+ beforeEach(async () => {
+ inviter = await generateUser({balance: 1});
+ group = await inviter.post('/groups', {
+ name: groupName,
+ type: 'guild',
+ });
+ });
+
+ describe('user id invites', () => {
+ it('returns an error when invited user is not found', async () => {
+ let fakeID = generateUUID();
+
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ uuids: [fakeID],
+ }))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('userWithIDNotFound', {userId: fakeID}),
+ });
+ });
+
+ it('returns an error when uuids is not an array', async () => {
+ let fakeID = generateUUID();
+
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ uuids: {fakeID},
+ }))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('uuidsMustBeAnArray'),
+ });
+ });
+
+ it('returns empty when uuids is empty', async () => {
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ uuids: [],
+ }))
+ .to.eventually.be.empty;
+ });
+
+ it('returns an error when there are more than INVITES_LIMIT uuids', async () => {
+ let uuids = [];
+
+ for (let i = 0; i < 101; i += 1) {
+ uuids.push(generateUUID());
+ }
+
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ uuids,
+ }))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}),
+ });
+ });
+
+ it('invites a user to a group by uuid', async () => {
+ let userToInvite = await generateUser();
+
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ uuids: [userToInvite._id],
+ })).to.eventually.deep.equal([{
+ id: group._id,
+ name: groupName,
+ inviter: inviter._id,
+ }]);
+ await expect(userToInvite.get('/user'))
+ .to.eventually.have.deep.property('invitations.guilds[0].id', group._id);
+ });
+
+ it('invites multiple users to a group by uuid', async () => {
+ let userToInvite = await generateUser();
+ let userToInvite2 = await generateUser();
+
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ uuids: [userToInvite._id, userToInvite2._id],
+ })).to.eventually.deep.equal([
+ {
+ id: group._id,
+ name: groupName,
+ inviter: inviter._id,
+ },
+ {
+ id: group._id,
+ name: groupName,
+ inviter: inviter._id,
+ },
+ ]);
+ await expect(userToInvite.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id);
+ await expect(userToInvite2.get('/user')).to.eventually.have.deep.property('invitations.guilds[0].id', group._id);
+ });
+
+ it('returns an error when inviting multiple users and a user is not found', async () => {
+ let userToInvite = await generateUser();
+ let fakeID = generateUUID();
+
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ uuids: [userToInvite._id, fakeID],
+ }))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('userWithIDNotFound', {userId: fakeID}),
+ });
+ });
+ });
+
+ describe('email invites', () => {
+ let testInvite = {name: 'test', email: 'test@habitica.com'};
+
+ it('returns an error when invite is missing an email', async () => {
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ emails: [{name: 'test'}],
+ }))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('inviteMissingEmail'),
+ });
+ });
+
+ it('returns an error when emails is not an array', async () => {
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ emails: {testInvite},
+ }))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('emailsMustBeAnArray'),
+ });
+ });
+
+ it('returns empty when emails is an empty array', async () => {
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ emails: [],
+ }))
+ .to.eventually.be.empty;
+ });
+
+ it('returns an error when there are more than INVITES_LIMIT emails', async () => {
+ let emails = [];
+
+ for (let i = 0; i < 101; i += 1) {
+ emails.push(`${generateUUID()}@habitica.com`);
+ }
+
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ emails,
+ }))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}),
+ });
+ });
+
+ it('invites a user to a group by email', async () => {
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ emails: [testInvite],
+ })).to.exist;
+ });
+
+ it('invites multiple users to a group by email', async () => {
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ emails: [testInvite, {name: 'test2', email: 'test2@habitica.com'}],
+ })).to.exist;
+ });
+ });
+
+ describe('user and email invites', () => {
+ it('returns an error when emails and uuids are not provided', async () => {
+ await expect(inviter.post(`/groups/${group._id}/invite`))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('canOnlyInviteEmailUuid'),
+ });
+ });
+
+ it('returns an error when there are more than INVITES_LIMIT uuids and emails', async () => {
+ let emails = [];
+ let uuids = [];
+
+ for (let i = 0; i < 50; i += 1) {
+ emails.push(`${generateUUID()}@habitica.com`);
+ }
+
+ for (let i = 0; i < 51; i += 1) {
+ uuids.push(generateUUID());
+ }
+
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ emails,
+ uuids,
+ }))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}),
+ });
+ });
+
+ it('invites users to a group by uuid and email', async () => {
+ let newUser = await generateUser();
+ let invite = await inviter.post(`/groups/${group._id}/invite`, {
+ uuids: [newUser._id],
+ emails: [{name: 'test', email: 'test@habitica.com'}],
+ });
+ let invitedUser = await newUser.get('/user');
+
+ expect(invite).to.exist;
+ expect(invitedUser.invitations.guilds[0].id).to.equal(group._id);
+ });
+ });
+
+ describe('guild invites', () => {
+ it('returns an error when invited user is already invited to the group', async () => {
+ let userToInivite = await generateUser();
+ await inviter.post(`/groups/${group._id}/invite`, {
+ uuids: [userToInivite._id],
+ });
+
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ uuids: [userToInivite._id],
+ }))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('userAlreadyInvitedToGroup'),
+ });
+ });
+
+ it('returns an error when invited user is already in the group', async () => {
+ let userToInvite = await generateUser();
+ await inviter.post(`/groups/${group._id}/invite`, {
+ uuids: [userToInvite._id],
+ });
+ await userToInvite.post(`/groups/${group._id}/join`);
+
+ await expect(inviter.post(`/groups/${group._id}/invite`, {
+ uuids: [userToInvite._id],
+ }))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('userAlreadyInGroup'),
+ });
+ });
+ });
+
+ describe('party invites', () => {
+ let party;
+
+ beforeEach(async () => {
+ party = await inviter.post('/groups', {
+ name: 'Test Party',
+ type: 'party',
+ });
+ });
+
+ it('returns an error when invited user has a pending invitation to the party', async () => {
+ let userToInvite = await generateUser();
+ await inviter.post(`/groups/${party._id}/invite`, {
+ uuids: [userToInvite._id],
+ });
+
+ await expect(inviter.post(`/groups/${party._id}/invite`, {
+ uuids: [userToInvite._id],
+ }))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('userAlreadyPendingInvitation'),
+ });
+ });
+
+ it('returns an error when invited user is already in a party of more than 1 member', async () => {
+ let userToInvite = await generateUser();
+ let userToInvite2 = await generateUser();
+ await inviter.post(`/groups/${party._id}/invite`, {
+ uuids: [userToInvite._id, userToInvite2._id],
+ });
+ await userToInvite.post(`/groups/${party._id}/join`);
+ await userToInvite2.post(`/groups/${party._id}/join`);
+
+ await expect(inviter.post(`/groups/${party._id}/invite`, {
+ uuids: [userToInvite._id],
+ }))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('userAlreadyInAParty'),
+ });
+ });
+
+ it('allow inviting an user to a party if he\'s partying solo', async () => {
+ let userToInvite = await generateUser();
+ await userToInvite.post('/groups', { // add user to a party
+ name: 'Another Test Party',
+ type: 'party',
+ });
+
+ await inviter.post(`/groups/${party._id}/invite`, {
+ uuids: [userToInvite._id],
+ });
+ expect((await userToInvite.get('/user')).invitations.party.id).to.equal(party._id);
+ });
+ });
+});
diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js
new file mode 100644
index 0000000000..8d581d56ca
--- /dev/null
+++ b/test/api/v3/integration/groups/PUT-groups.test.js
@@ -0,0 +1,46 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('PUT /group', () => {
+ let leader, nonLeader, groupToUpdate;
+ let groupName = 'Test Public Guild';
+ let groupType = 'guild';
+ let groupUpdatedName = 'Test Public Guild Updated';
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: {
+ name: groupName,
+ type: groupType,
+ privacy: 'public',
+ },
+ members: 1,
+ });
+
+ groupToUpdate = group;
+ leader = groupLeader;
+ nonLeader = members[0];
+ });
+
+ it('returns an error when a non group leader tries to update', async () => {
+ await expect(nonLeader.put(`/groups/${groupToUpdate._id}`, {
+ name: groupUpdatedName,
+ })).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('messageGroupOnlyLeaderCanUpdate'),
+ });
+ });
+
+ it('updates a group', async () => {
+ let updatedGroup = await leader.put(`/groups/${groupToUpdate._id}`, {
+ name: groupUpdatedName,
+ });
+
+ expect(updatedGroup.leader._id).to.eql(leader._id);
+ expect(updatedGroup.leader.profile.name).to.eql(leader.profile.name);
+ expect(updatedGroup.name).to.equal(groupUpdatedName);
+ });
+});
diff --git a/test/api/v3/integration/members/GET-members_id.test.js b/test/api/v3/integration/members/GET-members_id.test.js
new file mode 100644
index 0000000000..9b4c734ea9
--- /dev/null
+++ b/test/api/v3/integration/members/GET-members_id.test.js
@@ -0,0 +1,49 @@
+import {
+ generateUser,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('GET /members/:memberId', () => {
+ let user;
+
+ before(async () => {
+ user = await generateUser();
+ });
+
+ it('validates req.params.memberId', async () => {
+ await expect(user.get(`/members/invalidUUID`)).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('returns a member public data only', async () => {
+ let member = await generateUser({ // make sure user has all the fields that can be returned by the getMember call
+ contributor: {level: 1},
+ backer: {tier: 3},
+ preferences: {
+ costume: false,
+ background: 'volcano',
+ },
+ });
+ let memberRes = await user.get(`/members/${member._id}`);
+ expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys
+ '_id', 'preferences', 'profile', 'stats', 'achievements', 'party',
+ 'backer', 'contributor', 'auth', 'items',
+ ]);
+ expect(Object.keys(memberRes.auth)).to.eql(['timestamps']);
+ expect(Object.keys(memberRes.preferences).sort()).to.eql(['size', 'hair', 'skin', 'shirt',
+ 'costume', 'sleep', 'background'].sort());
+ });
+
+ it('handles non-existing members', async () => {
+ let dummyId = generateUUID();
+ await expect(user.get(`/members/${dummyId}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('userWithIDNotFound', {userId: dummyId}),
+ });
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js
new file mode 100644
index 0000000000..665a185279
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_accept.test.js
@@ -0,0 +1,119 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ generateUser,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('POST /groups/:groupId/quests/accept', () => {
+ const PET_QUEST = 'whale';
+
+ let questingGroup;
+ let leader;
+ let partyMembers;
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 2,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ partyMembers = members;
+
+ await leader.update({
+ [`items.quests.${PET_QUEST}`]: 1,
+ });
+ });
+
+ context('failure conditions', () => {
+ it('does not accept quest without an invite', async () => {
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/accept`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('questInviteNotFound'),
+ });
+ });
+
+ it('does not accept quest for a group in which user is not a member', async () => {
+ await expect(user.post(`/groups/${questingGroup._id}/quests/accept`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('does not accept quest for a guild', async () => {
+ let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'private' },
+ });
+
+ await expect(guildLeader.post(`/groups/${guild._id}/quests/accept`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('does not accept invite twice', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('questAlreadyAccepted'),
+ });
+ });
+
+ it('does not accept invite for a quest already underway', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ // quest will start after everyone has accepted
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questAlreadyUnderway'),
+ });
+ });
+ });
+
+ context('successfully accepting a quest invitation', () => {
+ it('joins a quest from an invitation', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await Promise.all([partyMembers[0].sync(), questingGroup.sync()]);
+ expect(leader.party.quest.RSVPNeeded).to.equal(false);
+ expect(questingGroup.quest.members[partyMembers[0]._id]);
+ });
+
+ it('does not begin the quest if pending invitations remain', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await questingGroup.sync();
+ expect(questingGroup.quest.active).to.equal(false);
+ });
+
+ it('begins the quest if accepting the last pending invite', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ // quest will start after everyone has accepted
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await questingGroup.sync();
+ expect(questingGroup.quest.active).to.equal(true);
+ });
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js
new file mode 100644
index 0000000000..b6d43f826b
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_force-start.test.js
@@ -0,0 +1,126 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ generateUser,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('POST /groups/:groupId/quests/force-start', () => {
+ const PET_QUEST = 'whale';
+
+ let questingGroup;
+ let leader;
+ let partyMembers;
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 2,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ partyMembers = members;
+
+ await leader.update({
+ [`items.quests.${PET_QUEST}`]: 1,
+ });
+ });
+
+ context('failure conditions', () => {
+ it('does not force start a quest for a group in which user is not a member', async () => {
+ let nonMember = await generateUser();
+
+ await expect(nonMember.post(`/groups/${questingGroup._id}/quests/force-start`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('does not force start quest for a guild', async () => {
+ let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'private' },
+ });
+
+ await expect(guildLeader.post(`/groups/${guild._id}/quests/force-start`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('does not force start for a party without a pending quest', async () => {
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/force-start`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('questNotPending'),
+ });
+ });
+
+ it('does not force start for a quest already underway', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ // quest will start after everyone has accepted
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/force-start`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questAlreadyUnderway'),
+ });
+ });
+
+ it('does not allow non-quest leader or non-group leader to force start a quest', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/force-start`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questOrGroupLeaderOnlyStartQuest'),
+ });
+ });
+ });
+
+ context('successfully force starting a quest', () => {
+ it('allows quest leader to force start quest', async () => {
+ let questLeader = partyMembers[0];
+ await questLeader.update({[`items.quests.${PET_QUEST}`]: 1});
+ await questLeader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await questLeader.post(`/groups/${questingGroup._id}/quests/force-start`);
+
+ await questingGroup.sync();
+
+ expect(questingGroup.quest.active).to.eql(true);
+ });
+
+ it('allows group leader to force start quest', async () => {
+ let questLeader = partyMembers[0];
+ await questLeader.update({[`items.quests.${PET_QUEST}`]: 1});
+ await questLeader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await leader.post(`/groups/${questingGroup._id}/quests/force-start`);
+
+ await questingGroup.sync();
+
+ expect(questingGroup.quest.active).to.eql(true);
+ });
+
+ it('sends back the quest object', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ let quest = await leader.post(`/groups/${questingGroup._id}/quests/force-start`);
+
+ expect(quest.active).to.eql(true);
+ expect(quest.key).to.eql(PET_QUEST);
+ expect(quest.members).to.eql({
+ [`${leader._id}`]: true,
+ });
+ });
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js
new file mode 100644
index 0000000000..973a51a1a5
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupId_quests_invite.test.js
@@ -0,0 +1,192 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ sleep,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+import { quests as questScrolls } from '../../../../../common/script/content';
+
+describe('POST /groups/:groupId/quests/invite/:questKey', () => {
+ let questingGroup;
+ let leader;
+ let member;
+ const PET_QUEST = 'whale';
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 1,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ member = members[0];
+ });
+
+ context('failure conditions', () => {
+ it('does not issue invites with an invalid group ID', async () => {
+ await expect(leader.post(`/groups/${generateUUID()}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('does not issue invites for a group in which user is not a member', async () => {
+ let { group } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 1,
+ });
+
+ let alternateGroup = group;
+
+ await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('does not issue invites for Guilds', async () => {
+ let { group } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'public' },
+ members: 1,
+ });
+
+ let alternateGroup = group;
+
+ await expect(leader.post(`/groups/${alternateGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('does not issue invites with an invalid quest key', async () => {
+ const FAKE_QUEST = 'herkimer';
+
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${FAKE_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('questNotFound', {key: FAKE_QUEST}),
+ });
+ });
+
+ it('does not issue invites for a quest the user does not own', async () => {
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questNotOwned'),
+ });
+ });
+
+ it('does not issue invites if the user is of insufficient Level', async () => {
+ const LEVELED_QUEST = 'atom1';
+ const LEVELED_QUEST_REQ = questScrolls[LEVELED_QUEST].lvl;
+ const leaderUpdate = {};
+ leaderUpdate[`items.quests.${LEVELED_QUEST}`] = 1;
+ leaderUpdate['stats.lvl'] = LEVELED_QUEST_REQ - 1;
+
+ await leader.update(leaderUpdate);
+
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${LEVELED_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questLevelTooHigh', {level: LEVELED_QUEST_REQ}),
+ });
+ });
+
+ it('does not issue invites if a quest is already underway', async () => {
+ const QUEST_IN_PROGRESS = 'atom1';
+ const leaderUpdate = {};
+ leaderUpdate[`items.quests.${PET_QUEST}`] = 1;
+
+ await leader.update(leaderUpdate);
+ await questingGroup.update({ 'quest.key': QUEST_IN_PROGRESS });
+
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`)).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questAlreadyUnderway'),
+ });
+ });
+ });
+
+ context('successfully issuing a quest invitation', () => {
+ beforeEach(async () => {
+ const memberUpdate = {};
+ memberUpdate[`items.quests.${PET_QUEST}`] = 1;
+
+ await Promise.all([
+ leader.update(memberUpdate),
+ member.update(memberUpdate),
+ ]);
+ });
+
+ it('adds quest details to group object', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await questingGroup.sync();
+
+ let quest = questingGroup.quest;
+
+ expect(quest.key).to.eql(PET_QUEST);
+ expect(quest.active).to.eql(false);
+ expect(quest.leader).to.eql(leader._id);
+ expect(quest.members).to.have.property(leader._id, true);
+ expect(quest.members).to.have.property(member._id, null);
+ expect(quest).to.have.property('progress');
+ });
+
+ it('adds quest details to user objects', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await sleep(0.1); // member updates happen in the background
+
+ await Promise.all([
+ leader.sync(),
+ member.sync(),
+ ]);
+
+ expect(leader.party.quest.key).to.eql(PET_QUEST);
+ expect(member.party.quest.key).to.eql(PET_QUEST);
+ expect(leader.party.quest.RSVPNeeded).to.eql(false);
+ expect(member.party.quest.RSVPNeeded).to.eql(true);
+ });
+
+ it('sends back the quest object', async () => {
+ let inviteResponse = await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ expect(inviteResponse.key).to.eql(PET_QUEST);
+ expect(inviteResponse.active).to.eql(false);
+ expect(inviteResponse.leader).to.eql(leader._id);
+ expect(inviteResponse.members).to.have.property(leader._id, true);
+ expect(inviteResponse.members).to.have.property(member._id, null);
+ expect(inviteResponse).to.have.property('progress');
+ });
+
+ it('allows non-party-leader party members to send invites', async () => {
+ let inviteResponse = await member.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await questingGroup.sync();
+
+ expect(inviteResponse.key).to.eql(PET_QUEST);
+ expect(questingGroup.quest.key).to.eql(PET_QUEST);
+ });
+
+ it('starts quest automatically if user is in a solo party', async () => {
+ let leaderDetails = { balance: 10 };
+ leaderDetails[`items.quests.${PET_QUEST}`] = 1;
+ let { group, groupLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ leaderDetails,
+ });
+
+ await groupLeader.post(`/groups/${group._id}/quests/invite/${PET_QUEST}`);
+
+ await group.sync();
+
+ expect(group.quest.active).to.eql(true);
+ });
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js
new file mode 100644
index 0000000000..850cf53646
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_abort.test.js
@@ -0,0 +1,126 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ generateUser,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /groups/:groupId/quests/abort', () => {
+ let questingGroup;
+ let partyMembers;
+ let user;
+ let leader;
+
+ const PET_QUEST = 'whale';
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 2,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ partyMembers = members;
+
+ await leader.update({
+ [`items.quests.${PET_QUEST}`]: 1,
+ });
+ user = await generateUser();
+ });
+
+ context('failure conditions', () => {
+ it('returns an error when group is not found', async () => {
+ await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/abort`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns an error for a group in which user is not a member', async () => {
+ await expect(user.post(`/groups/${questingGroup._id}/quests/abort`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns an error when group is a guild', async () => {
+ let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'private' },
+ });
+
+ await expect(guildLeader.post(`/groups/${guild._id}/quests/abort`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('returns an error when quest is not active', async () => {
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/abort`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('noActiveQuestToAbort'),
+ });
+ });
+
+ it('returns an error when non quest leader attempts to abort', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/abort`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('onlyLeaderAbortQuest'),
+ });
+ });
+ });
+
+ it('aborts a quest', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ let res = await leader.post(`/groups/${questingGroup._id}/quests/abort`);
+ await Promise.all([
+ leader.sync(),
+ questingGroup.sync(),
+ partyMembers[0].sync(),
+ partyMembers[1].sync(),
+ ]);
+
+ let cleanUserQuestObj = {
+ key: null,
+ progress: {
+ up: 0,
+ down: 0,
+ collect: {},
+ },
+ completed: null,
+ RSVPNeeded: false,
+ };
+
+ expect(leader.party.quest).to.eql(cleanUserQuestObj);
+ expect(partyMembers[0].party.quest).to.eql(cleanUserQuestObj);
+ expect(partyMembers[1].party.quest).to.eql(cleanUserQuestObj);
+ expect(leader.items.quests[PET_QUEST]).to.equal(1);
+ expect(questingGroup.quest).to.deep.equal(res);
+ expect(questingGroup.quest).to.eql({
+ key: null,
+ active: false,
+ leader: null,
+ progress: {
+ collect: {},
+ },
+ members: {},
+ });
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js
new file mode 100644
index 0000000000..f3bd03a180
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_cancel.test.js
@@ -0,0 +1,138 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ generateUser,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /groups/:groupId/quests/cancel', () => {
+ let questingGroup;
+ let partyMembers;
+ let user;
+ let leader;
+
+ const PET_QUEST = 'whale';
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 2,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ partyMembers = members;
+
+ await leader.update({
+ [`items.quests.${PET_QUEST}`]: 1,
+ });
+ user = await generateUser();
+ });
+
+ context('failure conditions', () => {
+ it('returns an error when group is not found', async () => {
+ await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/cancel`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('does not reject quest for a group in which user is not a member', async () => {
+ await expect(user.post(`/groups/${questingGroup._id}/quests/cancel`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns an error when group is a guild', async () => {
+ let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'private' },
+ });
+
+ await expect(guildLeader.post(`/groups/${guild._id}/quests/cancel`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('returns an error when group is not on a quest', async () => {
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/cancel`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('questInvitationDoesNotExist'),
+ });
+ });
+
+ it('only the leader can cancel the quest', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/cancel`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('onlyLeaderCancelQuest'),
+ });
+ });
+
+ it('does not cancel a quest already underway', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ // quest will start after everyone has accepted
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/cancel`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('cantCancelActiveQuest'),
+ });
+ });
+ });
+
+ it('cancels a quest', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ let res = await leader.post(`/groups/${questingGroup._id}/quests/cancel`);
+
+ await Promise.all([
+ leader.sync(),
+ partyMembers[0].sync(),
+ partyMembers[1].sync(),
+ questingGroup.sync(),
+ ]);
+
+ let clean = {
+ key: null,
+ progress: {
+ up: 0,
+ down: 0,
+ collect: {},
+ },
+ completed: null,
+ RSVPNeeded: false,
+ };
+
+ expect(leader.party.quest).to.eql(clean);
+ expect(partyMembers[1].party.quest).to.eql(clean);
+ expect(partyMembers[0].party.quest).to.eql(clean);
+
+ expect(res).to.eql(questingGroup.quest);
+ expect(questingGroup.quest).to.eql({
+ key: null,
+ active: false,
+ leader: null,
+ progress: {
+ collect: {},
+ },
+ members: {},
+ });
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js
new file mode 100644
index 0000000000..65d781c163
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_leave.test.js
@@ -0,0 +1,124 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ generateUser,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /groups/:groupId/quests/leave', () => {
+ let questingGroup;
+ let partyMembers;
+ let user;
+ let leader;
+
+ const PET_QUEST = 'whale';
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 2,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ partyMembers = members;
+
+ await leader.update({
+ [`items.quests.${PET_QUEST}`]: 1,
+ });
+ user = await generateUser();
+ });
+
+ context('failure conditions', () => {
+ it('returns an error when group is not found', async () => {
+ await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/leave`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns an error for a group in which user is not a member', async () => {
+ await expect(user.post(`/groups/${questingGroup._id}/quests/leave`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns an error when group is a guild', async () => {
+ let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'private' },
+ });
+
+ await expect(guildLeader.post(`/groups/${guild._id}/quests/leave`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('returns an error when quest is not active', async () => {
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('noActiveQuestToLeave'),
+ });
+ });
+
+ it('returns an error when quest leader attempts to leave', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(leader.post(`/groups/${questingGroup._id}/quests/leave`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questLeaderCannotLeaveQuest'),
+ });
+ });
+
+ it('returns an error when non quest member attempts to leave', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`);
+
+ await expect(partyMembers[1].post(`/groups/${questingGroup._id}/quests/leave`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('notPartOfQuest'),
+ });
+ });
+ });
+
+ it('leaves a quest', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ let leaveResult = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/leave`);
+ await Promise.all([
+ partyMembers[0].sync(),
+ questingGroup.sync(),
+ ]);
+
+ expect(partyMembers[0].party.quest).to.eql({
+ key: null,
+ progress: {
+ up: 0,
+ down: 0,
+ collect: {},
+ },
+ completed: null,
+ RSVPNeeded: false,
+ });
+ expect(questingGroup.quest).to.deep.equal(leaveResult);
+ expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false;
+ });
+});
diff --git a/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js
new file mode 100644
index 0000000000..1eb62aa0c6
--- /dev/null
+++ b/test/api/v3/integration/quests/POST-groups_groupid_quests_reject.test.js
@@ -0,0 +1,146 @@
+import {
+ createAndPopulateGroup,
+ translate as t,
+ generateUser,
+} from '../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /groups/:groupId/quests/reject', () => {
+ let questingGroup;
+ let partyMembers;
+ let user;
+ let leader;
+
+ const PET_QUEST = 'whale';
+
+ beforeEach(async () => {
+ let { group, groupLeader, members } = await createAndPopulateGroup({
+ groupDetails: { type: 'party', privacy: 'private' },
+ members: 2,
+ });
+
+ questingGroup = group;
+ leader = groupLeader;
+ partyMembers = members;
+
+ await leader.update({
+ [`items.quests.${PET_QUEST}`]: 1,
+ });
+ user = await generateUser();
+ });
+
+ context('failure conditions', () => {
+ it('returns an error when group is not found', async () => {
+ await expect(partyMembers[0].post(`/groups/${generateUUID()}/quests/reject`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('does not accept quest for a group in which user is not a member', async () => {
+ await expect(user.post(`/groups/${questingGroup._id}/quests/accept`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('groupNotFound'),
+ });
+ });
+
+ it('returns an error when group is a guild', async () => {
+ let { group: guild, groupLeader: guildLeader } = await createAndPopulateGroup({
+ groupDetails: { type: 'guild', privacy: 'private' },
+ });
+
+ await expect(guildLeader.post(`/groups/${guild._id}/quests/reject`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('guildQuestsNotSupported'),
+ });
+ });
+
+ it('returns an error when group is not on a quest', async () => {
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`))
+ .to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('questInvitationDoesNotExist'),
+ });
+ });
+
+ it('return an error when an user rejects an invite twice', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('questAlreadyRejected'),
+ });
+ });
+
+ it('return an error when an user rejects an invite already accepted', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`))
+ .to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('questAlreadyAccepted'),
+ });
+ });
+
+ it('does not reject invite for a quest already underway', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ // quest will start after everyone has accepted
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/accept`);
+
+ await expect(partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`))
+ .to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('questAlreadyUnderway'),
+ });
+ });
+ });
+
+ context('successfully quest rejection', () => {
+ let cleanUserQuestObj = {
+ key: null,
+ progress: {
+ up: 0,
+ down: 0,
+ collect: {},
+ },
+ completed: null,
+ RSVPNeeded: false,
+ };
+
+ it('rejects a quest invitation', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+
+ let res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`);
+ await partyMembers[0].sync();
+ await questingGroup.sync();
+
+ expect(partyMembers[0].party.quest).to.eql(cleanUserQuestObj);
+ expect(questingGroup.quest.members[partyMembers[0]._id]).to.be.false;
+ expect(questingGroup.quest.active).to.be.false;
+ expect(res).to.eql(questingGroup.quest);
+ });
+
+ it('starts the quest when the last user reject', async () => {
+ await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
+ await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
+ await partyMembers[1].post(`/groups/${questingGroup._id}/quests/reject`);
+ await questingGroup.sync();
+
+ expect(questingGroup.quest.active).to.be.true;
+ });
+ });
+});
diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js
index 9393c1db12..9dec366104 100644
--- a/test/api/v3/integration/tags/GET-tags_id.test.js
+++ b/test/api/v3/integration/tags/GET-tags_id.test.js
@@ -15,4 +15,6 @@ describe('GET /tags/:tagId', () => {
expect(tag).to.deep.equal(createdTag);
});
+
+ it('handles non-existing tags');
});
diff --git a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js
index 8be31c7a51..f3dca7c02a 100644
--- a/test/api/v3/integration/tasks/DELETE-tasks_id.test.js
+++ b/test/api/v3/integration/tasks/DELETE-tasks_id.test.js
@@ -14,7 +14,7 @@ describe('DELETE /tasks/:id', () => {
let task;
beforeEach(async () => {
- task = await user.post('/tasks', {
+ task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
@@ -42,7 +42,7 @@ describe('DELETE /tasks/:id', () => {
it('cannot delete a task owned by someone else', async () => {
let anotherUser = await generateUser();
- let anotherUsersTask = await anotherUser.post('/tasks', {
+ let anotherUsersTask = await anotherUser.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
diff --git a/test/api/v3/integration/tasks/GET-tasks.test.js b/test/api/v3/integration/tasks/GET-tasks.test.js
deleted file mode 100644
index 9053fa7226..0000000000
--- a/test/api/v3/integration/tasks/GET-tasks.test.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import {
- generateUser,
-} from '../../../../helpers/api-integration/v3';
-import Q from 'q';
-
-describe('GET /tasks', () => {
- let user;
-
- beforeEach(async () => {
- user = await generateUser();
- });
-
- it('returns all user\'s tasks', async () => {
- let createdTasks = await Q.all([
- user.post('/tasks', {text: 'test habit', type: 'habit'}),
- ]);
-
- let length = createdTasks.length;
- let tasks = await user.get('/tasks');
-
- expect(tasks.length).to.equal(length + 1); // + 1 because 1 is a default task
- });
-
- it('returns only a type of user\'s tasks if req.query.type is specified', async () => {
- let task = await user.post('/tasks', {text: 'test habit', type: 'habit'});
- let tasks = await user.get('/tasks?type=habit');
-
- expect(tasks.length).to.equal(1);
- expect(tasks[0]._id).to.equal(task._id);
- });
-
- // TODO complete after task scoring is done
- it('returns completed todos sorted by creation date if req.query.includeCompletedTodos is specified');
-});
diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js
index d05473b843..99f2d769f4 100644
--- a/test/api/v3/integration/tasks/GET-tasks_id.test.js
+++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js
@@ -15,7 +15,7 @@ describe('GET /tasks/:id', () => {
let task;
beforeEach(async () => {
- task = await user.post('/tasks', {
+ task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
@@ -43,7 +43,7 @@ describe('GET /tasks/:id', () => {
it('cannot get a task owned by someone else', async () => {
let anotherUser = await generateUser();
- let task = await user.post('/tasks', {
+ let task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
diff --git a/test/api/v3/integration/tasks/GET-tasks_user.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js
new file mode 100644
index 0000000000..cd11100599
--- /dev/null
+++ b/test/api/v3/integration/tasks/GET-tasks_user.test.js
@@ -0,0 +1,42 @@
+import {
+ generateUser,
+} from '../../../../helpers/api-integration/v3';
+
+describe('GET /tasks/user', () => {
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+ });
+
+ it('returns all user\'s tasks', async () => {
+ let createdTasks = await user.post('/tasks/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]);
+ let tasks = await user.get('/tasks/user');
+ expect(tasks.length).to.equal(createdTasks.length + 1); // + 1 because 1 is a default task
+ });
+
+ it('returns only a type of user\'s tasks if req.query.type is specified', async () => {
+ let createdTasks = await user.post('/tasks/user', [{text: 'test habit', type: 'habit'}, {text: 'test todo', type: 'todo'}]);
+ let tasks = await user.get('/tasks/user?type=habits');
+ expect(tasks.length).to.equal(1);
+ expect(tasks[0]._id).to.equal(createdTasks[0]._id);
+ });
+
+ it('returns completed todos sorted by completion date if req.query.type === "completeTodos"', async () => {
+ let todo1 = await user.post('/tasks/user', {text: 'todo to complete 1', type: 'todo'});
+ let todo2 = await user.post('/tasks/user', {text: 'todo to complete 2', type: 'todo'});
+
+ await user.sync();
+ let initialTodoCount = user.tasksOrder.todos.length;
+
+ await user.post(`/tasks/${todo2._id}/score/up`);
+ await user.post(`/tasks/${todo1._id}/score/up`);
+ await user.sync();
+
+ expect(user.tasksOrder.todos.length).to.equal(initialTodoCount - 2);
+
+ let completedTodos = await user.get('/tasks/user?type=completedTodos');
+ expect(completedTodos.length).to.equal(2);
+ expect(completedTodos[completedTodos.length - 1].text).to.equal('todo to complete 1'); // last is the todo that was completed later
+ });
+});
diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js
deleted file mode 100644
index 0f467a9ef2..0000000000
--- a/test/api/v3/integration/tasks/POST-tasks.test.js
+++ /dev/null
@@ -1,339 +0,0 @@
-import {
- generateUser,
- translate as t,
-} from '../../../../helpers/api-integration/v3';
-
-describe('POST /tasks', () => {
- let user;
-
- before(async () => {
- user = await generateUser();
- });
-
- context('validates params', async () => {
- it('returns an error if req.body.type is absent', async () => {
- await expect(user.post('/tasks', {
- notType: 'habit',
- })).to.eventually.be.rejected.and.eql({
- code: 400,
- error: 'BadRequest',
- message: t('invalidReqParams'),
- });
- });
-
- it('returns an error if req.body.type is not valid', async () => {
- await expect(user.post('/tasks', {
- type: 'habitF',
- })).to.eventually.be.rejected.and.eql({
- code: 400,
- error: 'BadRequest',
- message: t('invalidReqParams'),
- });
- });
-
- it('returns an error if req.body.text is absent', async () => {
- await expect(user.post('/tasks', {
- type: 'habit',
- })).to.eventually.be.rejected.and.eql({
- code: 400,
- error: 'BadRequest',
- message: 'habit validation failed',
- });
- });
-
- it('automatically sets "task.userId" to user\'s uuid', async () => {
- let task = await user.post('/tasks', {
- text: 'test habit',
- type: 'habit',
- });
-
- expect(task.userId).to.equal(user._id);
- });
-
- it(`ignores setting userId, history, createdAt,
- updatedAt, challenge, completed, streak,
- dateCompleted fields`, async () => {
- let task = await user.post('/tasks', {
- text: 'test daily',
- type: 'daily',
- userId: 123,
- history: [123],
- createdAt: 'yesterday',
- updatedAt: 'tomorrow',
- challenge: 'no',
- completed: true,
- streak: 25,
- dateCompleted: 'never',
- });
-
- expect(task.userId).to.equal(user._id);
- expect(task.history).to.eql([]);
- expect(task.createdAt).not.to.equal('yesterday');
- expect(task.updatedAt).not.to.equal('tomorrow');
- expect(task.challenge).not.to.equal('no');
- expect(task.completed).to.equal(false);
- expect(task.streak).to.equal(0);
- expect(task.streak).not.to.equal('never');
- });
-
- it('ignores invalid fields', async () => {
- let task = await user.post('/tasks', {
- text: 'test daily',
- type: 'daily',
- notValid: true,
- });
-
- expect(task).not.to.have.property('notValid');
- });
- });
-
- context('habits', () => {
- it('creates a habit', async () => {
- let task = await user.post('/tasks', {
- text: 'test habit',
- type: 'habit',
- up: false,
- down: true,
- notes: 1976,
- });
-
- expect(task.userId).to.equal(user._id);
- expect(task.text).to.eql('test habit');
- expect(task.notes).to.eql('1976');
- expect(task.type).to.eql('habit');
- expect(task.up).to.eql(false);
- expect(task.down).to.eql(true);
- });
-
- it('defaults to setting up and down to true', async () => {
- let task = await user.post('/tasks', {
- text: 'test habit',
- type: 'habit',
- notes: 1976,
- });
-
- expect(task.up).to.eql(true);
- expect(task.down).to.eql(true);
- });
-
- it('cannot create checklists', async () => {
- let task = await user.post('/tasks', {
- text: 'test habit',
- type: 'habit',
- checklist: [
- {_id: 123, completed: false, text: 'checklist'},
- ],
- });
-
- expect(task).not.to.have.property('checklist');
- });
- });
-
- context('todos', () => {
- it('creates a todo', async () => {
- let task = await user.post('/tasks', {
- text: 'test todo',
- type: 'todo',
- notes: 1976,
- });
-
- expect(task.userId).to.equal(user._id);
- expect(task.text).to.eql('test todo');
- expect(task.notes).to.eql('1976');
- expect(task.type).to.eql('todo');
- });
-
- it('updates user.tasksOrder.todos when a new todo is created', async () => {
- let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length;
- let task = await user.post('/tasks', {
- type: 'todo',
- text: 'a todo',
- });
-
- let updatedUser = await user.get('/user');
- expect(updatedUser.tasksOrder.todos[0]).to.eql(task._id);
- expect(updatedUser.tasksOrder.todos.length).to.eql(originalTodosOrderLen + 1);
- });
-
- it('can create checklists', async () => {
- let task = await user.post('/tasks', {
- text: 'test todo',
- type: 'todo',
- checklist: [
- {completed: false, text: 'checklist'},
- ],
- });
-
- expect(task.checklist).to.be.an('array');
- expect(task.checklist.length).to.eql(1);
- expect(task.checklist[0]).to.be.an('object');
- expect(task.checklist[0].text).to.eql('checklist');
- expect(task.checklist[0].completed).to.eql(false);
- expect(task.checklist[0]._id).to.be.a('string');
- });
- });
-
- context('dailys', () => {
- it('creates a daily', async () => {
- let now = new Date();
-
- let task = await user.post('/tasks', {
- text: 'test daily',
- type: 'daily',
- notes: 1976,
- frequency: 'daily',
- everyX: 5,
- startDate: now,
- });
-
- expect(task.userId).to.equal(user._id);
- expect(task.text).to.eql('test daily');
- expect(task.notes).to.eql('1976');
- expect(task.type).to.eql('daily');
- expect(task.frequency).to.eql('daily');
- expect(task.everyX).to.eql(5);
- expect(new Date(task.startDate)).to.eql(now);
- });
-
- it('updates user.tasksOrder.dailys when a new daily is created', async () => {
- let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length;
- let task = await user.post('/tasks', {
- type: 'daily',
- text: 'a daily',
- });
-
- let updatedUser = await user.get('/user');
- expect(updatedUser.tasksOrder.dailys[0]).to.eql(task._id);
- expect(updatedUser.tasksOrder.dailys.length).to.eql(originalDailysOrderLen + 1);
- });
-
- it('defaults to a weekly frequency, with every day set', async () => {
- let task = await user.post('/tasks', {
- text: 'test daily',
- type: 'daily',
- });
-
- expect(task.frequency).to.eql('weekly');
- expect(task.everyX).to.eql(1);
- expect(task.repeat).to.eql({
- m: true,
- t: true,
- w: true,
- th: true,
- f: true,
- s: true,
- su: true,
- });
- });
-
- it('allows repeat field to be configured', async () => {
- let task = await user.post('/tasks', {
- text: 'test daily',
- type: 'daily',
- repeat: {
- m: false,
- w: false,
- su: false,
- },
- });
-
- expect(task.repeat).to.eql({
- m: false,
- t: true,
- w: false,
- th: true,
- f: true,
- s: true,
- su: false,
- });
- });
-
- it('defaults startDate to today', async () => {
- let today = (new Date()).getDay();
-
- let task = await user.post('/tasks', {
- text: 'test daily',
- type: 'daily',
- });
-
- expect((new Date(task.startDate)).getDay()).to.eql(today);
- });
-
- it('can create checklists', async () => {
- let task = await user.post('/tasks', {
- text: 'test daily',
- type: 'daily',
- checklist: [
- {completed: false, text: 'checklist'},
- ],
- });
-
- expect(task.checklist).to.be.an('array');
- expect(task.checklist.length).to.eql(1);
- expect(task.checklist[0]).to.be.an('object');
- expect(task.checklist[0].text).to.eql('checklist');
- expect(task.checklist[0].completed).to.eql(false);
- expect(task.checklist[0]._id).to.be.a('string');
- });
- });
-
- context('rewards', () => {
- it('creates a reward', async () => {
- let task = await user.post('/tasks', {
- text: 'test reward',
- type: 'reward',
- notes: 1976,
- value: 10,
- });
-
- expect(task.userId).to.equal(user._id);
- expect(task.text).to.eql('test reward');
- expect(task.notes).to.eql('1976');
- expect(task.type).to.eql('reward');
- expect(task.value).to.eql(10);
- });
-
- it('updates user.tasksOrder.rewards when a new reward is created', async () => {
- let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length;
- let task = await user.post('/tasks', {
- type: 'reward',
- text: 'a reward',
- });
-
- let updatedUser = await user.get('/user');
- expect(updatedUser.tasksOrder.rewards[0]).to.eql(task._id);
- expect(updatedUser.tasksOrder.rewards.length).to.eql(originalRewardsOrderLen + 1);
- });
-
- it('defaults to a 0 value', async () => {
- let task = await user.post('/tasks', {
- text: 'test reward',
- type: 'reward',
- });
-
- expect(task.value).to.eql(0);
- });
-
- it('requires value to be coerced into a number', async () => {
- let task = await user.post('/tasks', {
- text: 'test reward',
- type: 'reward',
- value: '10',
- });
-
- expect(task.value).to.eql(10);
- });
-
- it('cannot create checklists', async () => {
- let task = await user.post('/tasks', {
- text: 'test reward',
- type: 'reward',
- checklist: [
- {_id: 123, completed: false, text: 'checklist'},
- ],
- });
-
- expect(task).not.to.have.property('checklist');
- });
- });
-});
diff --git a/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js
new file mode 100644
index 0000000000..d6cdf21749
--- /dev/null
+++ b/test/api/v3/integration/tasks/POST-tasks_clearCompletedTodos.test.js
@@ -0,0 +1,43 @@
+import {
+ generateUser,
+ generateGroup,
+ generateChallenge,
+} from '../../../../helpers/api-integration/v3';
+
+describe('POST /tasks/clearCompletedTodos', () => {
+ it('deletes all completed todos except the ones from a challenge', async () => {
+ let user = await generateUser({balance: 1});
+ let guild = await generateGroup(user);
+ let challenge = await generateChallenge(user, guild);
+
+ let initialTodoCount = user.tasksOrder.todos.length;
+ await user.post('/tasks/user', [
+ {text: 'todo 1', type: 'todo'},
+ {text: 'todo 2', type: 'todo'},
+ {text: 'todo 3', type: 'todo'},
+ {text: 'todo 4', type: 'todo'},
+ {text: 'todo 5', type: 'todo'},
+ ]);
+
+ await user.post(`/tasks/challenge/${challenge._id}`, {
+ text: 'todo 6',
+ type: 'todo',
+ });
+
+ let tasks = await user.get('/tasks/user?type=todos');
+ expect(tasks.length).to.equal(initialTodoCount + 6);
+
+ for (let task of tasks) {
+ if (['todo 2', 'todo 3', 'todo 6'].indexOf(task.text) !== -1) {
+ await user.post(`/tasks/${task._id}/score/up`); // eslint-disable-line babel/no-await-in-loop
+ }
+ }
+
+ await user.post('/tasks/clearCompletedTodos');
+ let completedTodos = await user.get('/tasks/user?type=completedTodos');
+ let todos = await user.get('/tasks/user?type=todos');
+ let allTodos = todos.concat(completedTodos);
+ expect(allTodos.length).to.equal(initialTodoCount + 4); // + 6 - 3 completed (but one is from challenge)
+ expect(allTodos[allTodos.length - 1].text).to.equal('todo 6');
+ });
+});
diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js
index d3897df87d..c1204a7341 100644
--- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js
+++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js
@@ -35,7 +35,7 @@ describe('POST /tasks/:id/score/:direction', () => {
let todo;
beforeEach(async () => {
- todo = await user.post('/tasks', {
+ todo = await user.post('/tasks/user', {
text: 'test todo',
type: 'todo',
});
@@ -46,6 +46,7 @@ describe('POST /tasks/:id/score/:direction', () => {
let 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 () => {
@@ -81,6 +82,7 @@ describe('POST /tasks/:id/score/:direction', () => {
let updatedTask = await user.get(`/tasks/${todo._id}`);
expect(updatedTask.completed).to.equal(false);
+ expect(updatedTask.dateCompleted).to.be.a('undefined');
});
it('scores up todo even if it is already completed'); // Yes?
@@ -134,7 +136,7 @@ describe('POST /tasks/:id/score/:direction', () => {
let daily;
beforeEach(async () => {
- daily = await user.post('/tasks', {
+ daily = await user.post('/tasks/user', {
text: 'test daily',
type: 'daily',
});
@@ -205,24 +207,24 @@ describe('POST /tasks/:id/score/:direction', () => {
let habit, minusHabit, plusHabit, neitherHabit; // eslint-disable-line no-unused-vars
beforeEach(async () => {
- habit = await user.post('/tasks', {
+ habit = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
- minusHabit = await user.post('/tasks', {
+ minusHabit = await user.post('/tasks/user', {
text: 'test min habit',
type: 'habit',
up: false,
});
- plusHabit = await user.post('/tasks', {
+ plusHabit = await user.post('/tasks/user', {
text: 'test plus habit',
type: 'habit',
down: false,
});
- neitherHabit = await user.post('/tasks', {
+ neitherHabit = await user.post('/tasks/user', {
text: 'test neither habit',
type: 'habit',
up: false,
@@ -267,7 +269,7 @@ describe('POST /tasks/:id/score/:direction', () => {
let reward, updatedUser;
beforeEach(async () => {
- reward = await user.post('/tasks', {
+ reward = await user.post('/tasks/user', {
text: 'test reward',
type: 'reward',
value: 5,
diff --git a/test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js b/test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js
new file mode 100644
index 0000000000..211c7ea975
--- /dev/null
+++ b/test/api/v3/integration/tasks/POST-tasks_move_taskId_to_position.test.js
@@ -0,0 +1,84 @@
+import {
+ generateUser,
+ translate as t,
+} from '../../../../helpers/api-integration/v3';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /tasks/:taskId/move/to/:position', () => {
+ let user;
+
+ beforeEach(async () => {
+ user = await generateUser();
+ });
+
+ it('requires a valid taskId', async () => {
+ await expect(user.post('/tasks/123/move/to/1')).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('requires a numeric position parameter', async () => {
+ await expect(user.post(`/tasks/${generateUUID()}/move/to/notANumber`)).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidReqParams'),
+ });
+ });
+
+ it('taskId must match a valid task', async () => {
+ await expect(user.post(`/tasks/${generateUUID()}/move/to/1`)).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('taskNotFound'),
+ });
+ });
+
+ it('can move task to new position', async () => {
+ let tasks = await user.post('/tasks/user', [
+ {type: 'habit', text: 'habit 1'},
+ {type: 'habit', text: 'habit 2'},
+ {type: 'daily', text: 'daily 1'},
+ {type: 'habit', text: 'habit 3'},
+ {type: 'habit', text: 'habit 4'},
+ {type: 'todo', text: 'todo 1'},
+ {type: 'habit', text: 'habit 5'},
+ ]);
+
+ let taskToMove = tasks[1];
+ expect(taskToMove.text).to.equal('habit 2');
+ let newOrder = await user.post(`/tasks/${tasks[1]._id}/move/to/3`);
+ expect(newOrder[3]).to.equal(taskToMove._id);
+ expect(newOrder.length).to.equal(5);
+ });
+
+ it('can\'t move completed todo', async () => {
+ let task = await user.post('/tasks/user', {type: 'todo', text: 'todo 1'});
+ await user.post(`/tasks/${task._id}/score/up`);
+
+ await expect(user.post(`/tasks/${task._id}/move/to/1`)).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('cantMoveCompletedTodo'),
+ });
+ });
+
+ it('can push to bottom', async () => {
+ let tasks = await user.post('/tasks/user', [
+ {type: 'habit', text: 'habit 1'},
+ {type: 'habit', text: 'habit 2'},
+ {type: 'daily', text: 'daily 1'},
+ {type: 'habit', text: 'habit 3'},
+ {type: 'habit', text: 'habit 4'},
+ {type: 'todo', text: 'todo 1'},
+ {type: 'habit', text: 'habit 5'},
+ ]);
+
+ let taskToMove = tasks[1];
+ expect(taskToMove.text).to.equal('habit 2');
+ let newOrder = await user.post(`/tasks/${tasks[1]._id}/move/to/-1`);
+ expect(newOrder[4]).to.equal(taskToMove._id);
+ expect(newOrder.length).to.equal(5);
+ });
+});
diff --git a/test/api/v3/integration/tasks/POST-tasks_user.test.js b/test/api/v3/integration/tasks/POST-tasks_user.test.js
new file mode 100644
index 0000000000..afae951fb1
--- /dev/null
+++ b/test/api/v3/integration/tasks/POST-tasks_user.test.js
@@ -0,0 +1,571 @@
+import {
+ generateUser,
+ translate as t,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('POST /tasks/user', () => {
+ let user;
+
+ before(async () => {
+ user = await generateUser();
+ });
+
+ context('validates params', async () => {
+ it('returns an error if req.body.type is absent', async () => {
+ await expect(user.post('/tasks/user', {
+ notType: 'habit',
+ })).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidTaskType'),
+ });
+ });
+
+ it('returns an error if req.body.type is not valid', async () => {
+ await expect(user.post('/tasks/user', {
+ type: 'habitF',
+ })).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidTaskType'),
+ });
+ });
+
+ it('returns an error if one object inside an array is invalid', async () => {
+ await expect(user.post('/tasks/user', [
+ {type: 'habitF'},
+ {type: 'habit'},
+ ])).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: t('invalidTaskType'),
+ });
+ });
+
+ it('returns an error if req.body.text is absent', async () => {
+ await expect(user.post('/tasks/user', {
+ type: 'habit',
+ })).to.eventually.be.rejected.and.eql({
+ code: 400,
+ error: 'BadRequest',
+ message: 'habit validation failed',
+ });
+ });
+
+ it('does not update user.tasksOrder.{taskType} when the task is not saved because invalid', async () => {
+ let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits;
+ await expect(user.post('/tasks/user', {
+ type: 'habit',
+ })).to.eventually.be.rejected.and.eql({ // this block is necessary
+ code: 400,
+ error: 'BadRequest',
+ message: 'habit validation failed',
+ });
+
+ let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits;
+ expect(updatedHabitsOrder).to.eql(originalHabitsOrder);
+ });
+
+ it('does not update user.tasksOrder.{taskType} when a task inside an array is not saved because invalid', async () => {
+ let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits;
+ await expect(user.post('/tasks/user', [
+ {type: 'habit'}, // Missing text
+ {type: 'habit', text: 'valid'}, // Valid
+ ])).to.eventually.be.rejected.and.eql({ // this block is necessary
+ code: 400,
+ error: 'BadRequest',
+ message: 'habit validation failed',
+ });
+
+ let updatedHabitsOrder = (await user.get('/user')).tasksOrder.habits;
+ expect(updatedHabitsOrder).to.eql(originalHabitsOrder);
+ });
+
+ it('does not save any task sent in an array when 1 is invalid', async () => {
+ let originalTasks = await user.get('/tasks/user');
+ await expect(user.post('/tasks/user', [
+ {type: 'habit'}, // Missing text
+ {type: 'habit', text: 'valid'}, // Valid
+ ])).to.eventually.be.rejected.and.eql({ // this block is necessary
+ code: 400,
+ error: 'BadRequest',
+ message: 'habit validation failed',
+ }).then(async () => {
+ let updatedTasks = await user.get('/tasks/user');
+
+ expect(updatedTasks).to.eql(originalTasks);
+ });
+ });
+
+ it('automatically sets "task.userId" to user\'s uuid', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test habit',
+ type: 'habit',
+ });
+
+ expect(task.userId).to.equal(user._id);
+ });
+
+ it(`ignores setting userId, history, createdAt,
+ updatedAt, challenge, completed, streak,
+ dateCompleted fields`, async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test daily',
+ type: 'daily',
+ userId: 123,
+ history: [123],
+ createdAt: 'yesterday',
+ updatedAt: 'tomorrow',
+ challenge: 'no',
+ completed: true,
+ streak: 25,
+ dateCompleted: 'never',
+ });
+
+ expect(task.userId).to.equal(user._id);
+ expect(task.history).to.eql([]);
+ expect(task.createdAt).not.to.equal('yesterday');
+ expect(task.updatedAt).not.to.equal('tomorrow');
+ expect(task.challenge).not.to.equal('no');
+ expect(task.completed).to.equal(false);
+ expect(task.streak).to.equal(0);
+ expect(task.streak).not.to.equal('never');
+ });
+
+ it('ignores invalid fields', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test daily',
+ type: 'daily',
+ notValid: true,
+ });
+
+ expect(task).not.to.have.property('notValid');
+ });
+ });
+
+ context('habits', () => {
+ it('creates a habit', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test habit',
+ type: 'habit',
+ up: false,
+ down: true,
+ notes: 1976,
+ });
+
+ expect(task.userId).to.equal(user._id);
+ expect(task.text).to.eql('test habit');
+ expect(task.notes).to.eql('1976');
+ expect(task.type).to.eql('habit');
+ expect(task.up).to.eql(false);
+ expect(task.down).to.eql(true);
+ });
+
+ it('updates user.tasksOrder.habits when a new habit is created', async () => {
+ let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length;
+ let task = await user.post('/tasks/user', {
+ type: 'habit',
+ text: 'an habit',
+ });
+
+ let updatedUser = await user.get('/user');
+ expect(updatedUser.tasksOrder.habits[0]).to.eql(task._id);
+ expect(updatedUser.tasksOrder.habits.length).to.eql(originalHabitsOrderLen + 1);
+ });
+
+ it('updates user.tasksOrder.habits when multiple habits are created', async () => {
+ let originalHabitsOrderLen = (await user.get('/user')).tasksOrder.habits.length;
+ let [task, task2] = await user.post('/tasks/user', [{
+ type: 'habit',
+ text: 'an habit',
+ }, {
+ type: 'habit',
+ text: 'another habit',
+ }]);
+
+ let updatedUser = await user.get('/user');
+ expect(updatedUser.tasksOrder.habits[0]).to.eql(task2._id);
+ expect(updatedUser.tasksOrder.habits[1]).to.eql(task._id);
+ expect(updatedUser.tasksOrder.habits.length).to.eql(originalHabitsOrderLen + 2);
+ });
+
+ it('creates multiple habits', async () => {
+ let [task, task2] = await user.post('/tasks/user', [{
+ text: 'test habit',
+ type: 'habit',
+ up: false,
+ down: true,
+ notes: 1976,
+ }, {
+ text: 'test habit 2',
+ type: 'habit',
+ up: true,
+ down: false,
+ notes: 1977,
+ }]);
+
+ expect(task.userId).to.equal(user._id);
+ expect(task.text).to.eql('test habit');
+ expect(task.notes).to.eql('1976');
+ expect(task.type).to.eql('habit');
+ expect(task.up).to.eql(false);
+ expect(task.down).to.eql(true);
+
+ expect(task2.userId).to.equal(user._id);
+ expect(task2.text).to.eql('test habit 2');
+ expect(task2.notes).to.eql('1977');
+ expect(task2.type).to.eql('habit');
+ expect(task2.up).to.eql(true);
+ expect(task2.down).to.eql(false);
+ });
+
+ it('defaults to setting up and down to true', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test habit',
+ type: 'habit',
+ notes: 1976,
+ });
+
+ expect(task.up).to.eql(true);
+ expect(task.down).to.eql(true);
+ });
+
+ it('cannot create checklists', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test habit',
+ type: 'habit',
+ checklist: [
+ {_id: 123, completed: false, text: 'checklist'},
+ ],
+ });
+
+ expect(task).not.to.have.property('checklist');
+ });
+ });
+
+ context('todos', () => {
+ it('creates a todo', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test todo',
+ type: 'todo',
+ notes: 1976,
+ });
+
+ expect(task.userId).to.equal(user._id);
+ expect(task.text).to.eql('test todo');
+ expect(task.notes).to.eql('1976');
+ expect(task.type).to.eql('todo');
+ });
+
+ it('creates multiple todos', async () => {
+ let [task, task2] = await user.post('/tasks/user', [{
+ text: 'test todo',
+ type: 'todo',
+ notes: 1976,
+ }, {
+ text: 'test todo 2',
+ type: 'todo',
+ notes: 1977,
+ }]);
+
+ expect(task.userId).to.equal(user._id);
+ expect(task.text).to.eql('test todo');
+ expect(task.notes).to.eql('1976');
+ expect(task.type).to.eql('todo');
+
+ expect(task2.userId).to.equal(user._id);
+ expect(task2.text).to.eql('test todo 2');
+ expect(task2.notes).to.eql('1977');
+ expect(task2.type).to.eql('todo');
+ });
+
+ it('updates user.tasksOrder.todos when a new todo is created', async () => {
+ let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length;
+ let task = await user.post('/tasks/user', {
+ type: 'todo',
+ text: 'a todo',
+ });
+
+ let updatedUser = await user.get('/user');
+ expect(updatedUser.tasksOrder.todos[0]).to.eql(task._id);
+ expect(updatedUser.tasksOrder.todos.length).to.eql(originalTodosOrderLen + 1);
+ });
+
+ it('updates user.tasksOrder.todos when multiple todos are created', async () => {
+ let originalTodosOrderLen = (await user.get('/user')).tasksOrder.todos.length;
+ let [task, task2] = await user.post('/tasks/user', [{
+ type: 'todo',
+ text: 'a todo',
+ }, {
+ type: 'todo',
+ text: 'another todo',
+ }]);
+
+ let updatedUser = await user.get('/user');
+ expect(updatedUser.tasksOrder.todos[0]).to.eql(task2._id);
+ expect(updatedUser.tasksOrder.todos[1]).to.eql(task._id);
+ expect(updatedUser.tasksOrder.todos.length).to.eql(originalTodosOrderLen + 2);
+ });
+
+ it('can create checklists', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test todo',
+ type: 'todo',
+ checklist: [
+ {completed: false, text: 'checklist'},
+ ],
+ });
+
+ expect(task.checklist).to.be.an('array');
+ expect(task.checklist.length).to.eql(1);
+ expect(task.checklist[0]).to.be.an('object');
+ expect(task.checklist[0].text).to.eql('checklist');
+ expect(task.checklist[0].completed).to.eql(false);
+ expect(task.checklist[0]._id).to.be.a('string');
+ });
+ });
+
+ context('dailys', () => {
+ it('creates a daily', async () => {
+ let now = new Date();
+
+ let task = await user.post('/tasks/user', {
+ text: 'test daily',
+ type: 'daily',
+ notes: 1976,
+ frequency: 'daily',
+ everyX: 5,
+ startDate: now,
+ });
+
+ expect(task.userId).to.equal(user._id);
+ expect(task.text).to.eql('test daily');
+ expect(task.notes).to.eql('1976');
+ expect(task.type).to.eql('daily');
+ expect(task.frequency).to.eql('daily');
+ expect(task.everyX).to.eql(5);
+ expect(new Date(task.startDate)).to.eql(now);
+ });
+
+ it('creates multiple dailys', async () => {
+ let [task, task2] = await user.post('/tasks/user', [{
+ text: 'test daily',
+ type: 'daily',
+ notes: 1976,
+ }, {
+ text: 'test daily 2',
+ type: 'daily',
+ notes: 1977,
+ }]);
+
+ expect(task.userId).to.equal(user._id);
+ expect(task.text).to.eql('test daily');
+ expect(task.notes).to.eql('1976');
+ expect(task.type).to.eql('daily');
+
+ expect(task2.userId).to.equal(user._id);
+ expect(task2.text).to.eql('test daily 2');
+ expect(task2.notes).to.eql('1977');
+ expect(task2.type).to.eql('daily');
+ });
+
+ it('updates user.tasksOrder.dailys when a new daily is created', async () => {
+ let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length;
+ let task = await user.post('/tasks/user', {
+ type: 'daily',
+ text: 'a daily',
+ });
+
+ let updatedUser = await user.get('/user');
+ expect(updatedUser.tasksOrder.dailys[0]).to.eql(task._id);
+ expect(updatedUser.tasksOrder.dailys.length).to.eql(originalDailysOrderLen + 1);
+ });
+
+ it('updates user.tasksOrder.dailys when multiple dailys are created', async () => {
+ let originalDailysOrderLen = (await user.get('/user')).tasksOrder.dailys.length;
+ let [task, task2] = await user.post('/tasks/user', [{
+ type: 'daily',
+ text: 'a daily',
+ }, {
+ type: 'daily',
+ text: 'another daily',
+ }]);
+
+ let updatedUser = await user.get('/user');
+ expect(updatedUser.tasksOrder.dailys[0]).to.eql(task2._id);
+ expect(updatedUser.tasksOrder.dailys[1]).to.eql(task._id);
+ expect(updatedUser.tasksOrder.dailys.length).to.eql(originalDailysOrderLen + 2);
+ });
+
+ it('defaults to a weekly frequency, with every day set', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test daily',
+ type: 'daily',
+ });
+
+ expect(task.frequency).to.eql('weekly');
+ expect(task.everyX).to.eql(1);
+ expect(task.repeat).to.eql({
+ m: true,
+ t: true,
+ w: true,
+ th: true,
+ f: true,
+ s: true,
+ su: true,
+ });
+ });
+
+ it('allows repeat field to be configured', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test daily',
+ type: 'daily',
+ repeat: {
+ m: false,
+ w: false,
+ su: false,
+ },
+ });
+
+ expect(task.repeat).to.eql({
+ m: false,
+ t: true,
+ w: false,
+ th: true,
+ f: true,
+ s: true,
+ su: false,
+ });
+ });
+
+ it('defaults startDate to today', async () => {
+ let today = (new Date()).getDay();
+
+ let task = await user.post('/tasks/user', {
+ text: 'test daily',
+ type: 'daily',
+ });
+
+ expect((new Date(task.startDate)).getDay()).to.eql(today);
+ });
+
+ it('can create checklists', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test daily',
+ type: 'daily',
+ checklist: [
+ {completed: false, text: 'checklist'},
+ ],
+ });
+
+ expect(task.checklist).to.be.an('array');
+ expect(task.checklist.length).to.eql(1);
+ expect(task.checklist[0]).to.be.an('object');
+ expect(task.checklist[0].text).to.eql('checklist');
+ expect(task.checklist[0].completed).to.eql(false);
+ expect(task.checklist[0]._id).to.be.a('string');
+ });
+ });
+
+ context('rewards', () => {
+ it('creates a reward', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test reward',
+ type: 'reward',
+ notes: 1976,
+ value: 10,
+ });
+
+ expect(task.userId).to.equal(user._id);
+ expect(task.text).to.eql('test reward');
+ expect(task.notes).to.eql('1976');
+ expect(task.type).to.eql('reward');
+ expect(task.value).to.eql(10);
+ });
+
+ it('creates multiple rewards', async () => {
+ let [task, task2] = await user.post('/tasks/user', [{
+ text: 'test reward',
+ type: 'reward',
+ notes: 1976,
+ value: 11,
+ }, {
+ text: 'test reward 2',
+ type: 'reward',
+ notes: 1977,
+ value: 12,
+ }]);
+
+ expect(task.userId).to.equal(user._id);
+ expect(task.text).to.eql('test reward');
+ expect(task.notes).to.eql('1976');
+ expect(task.type).to.eql('reward');
+ expect(task.value).to.eql(11);
+
+ expect(task2.userId).to.equal(user._id);
+ expect(task2.text).to.eql('test reward 2');
+ expect(task2.notes).to.eql('1977');
+ expect(task2.type).to.eql('reward');
+ expect(task2.value).to.eql(12);
+ });
+
+ it('updates user.tasksOrder.rewards when a new reward is created', async () => {
+ let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length;
+ let task = await user.post('/tasks/user', {
+ type: 'reward',
+ text: 'a reward',
+ });
+
+ let updatedUser = await user.get('/user');
+ expect(updatedUser.tasksOrder.rewards[0]).to.eql(task._id);
+ expect(updatedUser.tasksOrder.rewards.length).to.eql(originalRewardsOrderLen + 1);
+ });
+
+ it('updates user.tasksOrder.dreward when multiple rewards are created', async () => {
+ let originalRewardsOrderLen = (await user.get('/user')).tasksOrder.rewards.length;
+ let [task, task2] = await user.post('/tasks/user', [{
+ type: 'reward',
+ text: 'a reward',
+ }, {
+ type: 'reward',
+ text: 'another reward',
+ }]);
+
+ let updatedUser = await user.get('/user');
+ expect(updatedUser.tasksOrder.rewards[0]).to.eql(task2._id);
+ expect(updatedUser.tasksOrder.rewards[1]).to.eql(task._id);
+ expect(updatedUser.tasksOrder.rewards.length).to.eql(originalRewardsOrderLen + 2);
+ });
+
+ it('defaults to a 0 value', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test reward',
+ type: 'reward',
+ });
+
+ expect(task.value).to.eql(0);
+ });
+
+ it('requires value to be coerced into a number', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test reward',
+ type: 'reward',
+ value: '10',
+ });
+
+ expect(task.value).to.eql(10);
+ });
+
+ it('cannot create checklists', async () => {
+ let task = await user.post('/tasks/user', {
+ text: 'test reward',
+ type: 'reward',
+ checklist: [
+ {_id: 123, completed: false, text: 'checklist'},
+ ],
+ });
+
+ expect(task).not.to.have.property('checklist');
+ });
+ });
+});
diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js
index dc171d0400..c1cf952197 100644
--- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js
+++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js
@@ -14,7 +14,7 @@ describe('PUT /tasks/:id', () => {
let task;
beforeEach(async () => {
- task = await user.post('/tasks', {
+ task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
@@ -61,7 +61,7 @@ describe('PUT /tasks/:id', () => {
let habit;
beforeEach(async () => {
- habit = await user.post('/tasks', {
+ habit = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
notes: 1976,
@@ -87,7 +87,7 @@ describe('PUT /tasks/:id', () => {
let todo;
beforeEach(async () => {
- todo = await user.post('/tasks', {
+ todo = await user.post('/tasks/user', {
text: 'test todo',
type: 'todo',
notes: 1976,
@@ -142,7 +142,7 @@ describe('PUT /tasks/:id', () => {
let daily;
beforeEach(async () => {
- daily = await user.post('/tasks', {
+ daily = await user.post('/tasks/user', {
text: 'test daily',
type: 'daily',
notes: 1976,
@@ -244,7 +244,7 @@ describe('PUT /tasks/:id', () => {
let reward;
beforeEach(async () => {
- reward = await user.post('/tasks', {
+ reward = await user.post('/tasks/user', {
text: 'test reward',
type: 'reward',
notes: 1976,
diff --git a/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js
new file mode 100644
index 0000000000..62899ffb59
--- /dev/null
+++ b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js
@@ -0,0 +1,125 @@
+import {
+ generateUser,
+ generateGroup,
+ generateChallenge,
+ translate as t,
+} from '../../../../../helpers/api-v3-integration.helper';
+import { v4 as generateUUID } from 'uuid';
+
+describe('POST /tasks/challenge/:challengeId', () => {
+ let user;
+ let guild;
+ let challenge;
+
+ beforeEach(async () => {
+ user = await generateUser({balance: 1});
+ guild = await generateGroup(user);
+ challenge = await generateChallenge(user, guild);
+ });
+
+ it('returns error when challenge is not found', async () => {
+ let fakeChallengeId = generateUUID();
+
+ await expect(user.post(`/tasks/challenge/${fakeChallengeId}`, {
+ text: 'test habit',
+ type: 'habit',
+ up: false,
+ down: true,
+ notes: 1976,
+ })).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('returns error when user does not have the challenge', async () => {
+ let userWithoutChallenge = await generateUser();
+
+ await expect(userWithoutChallenge.post(`/tasks/challenge/${challenge._id}`, {
+ text: 'test habit',
+ type: 'habit',
+ up: false,
+ down: true,
+ notes: 1976,
+ })).to.eventually.be.rejected.and.eql({
+ code: 404,
+ error: 'NotFound',
+ message: t('challengeNotFound'),
+ });
+ });
+
+ it('returns error when non leader tries to edit challenge', async () => {
+ let userThatIsNotLeaderOfChallenge = await generateUser({
+ challenges: [challenge._id],
+ });
+
+ await expect(userThatIsNotLeaderOfChallenge.post(`/tasks/challenge/${challenge._id}`, {
+ text: 'test habit',
+ type: 'habit',
+ up: false,
+ down: true,
+ notes: 1976,
+ })).to.eventually.be.rejected.and.eql({
+ code: 401,
+ error: 'NotAuthorized',
+ message: t('onlyChalLeaderEditTasks'),
+ });
+ });
+
+ it('creates a habit', async () => {
+ let task = await user.post(`/tasks/challenge/${challenge._id}`, {
+ text: 'test habit',
+ type: 'habit',
+ up: false,
+ down: true,
+ notes: 1976,
+ });
+ let challengeWithTask = await user.get(`/challenges/${challenge._id}`);
+
+ expect(challengeWithTask.tasksOrder.habits.indexOf(task._id)).to.be.above(-1);
+ expect(task.challenge.id).to.equal(challenge._id);
+ expect(task.text).to.eql('test habit');
+ expect(task.notes).to.eql('1976');
+ expect(task.type).to.eql('habit');
+ expect(task.up).to.eql(false);
+ expect(task.down).to.eql(true);
+ });
+
+ it('creates a todo', async () => {
+ let task = await user.post(`/tasks/challenge/${challenge._id}`, {
+ text: 'test todo',
+ type: 'todo',
+ notes: 1976,
+ });
+ let challengeWithTask = await user.get(`/challenges/${challenge._id}`);
+
+ expect(challengeWithTask.tasksOrder.todos.indexOf(task._id)).to.be.above(-1);
+ expect(task.challenge.id).to.equal(challenge._id);
+ expect(task.text).to.eql('test todo');
+ expect(task.notes).to.eql('1976');
+ expect(task.type).to.eql('todo');
+ });
+
+ it('creates a daily', async () => {
+ let now = new Date();
+ let task = await user.post(`/tasks/challenge/${challenge._id}`, {
+ text: 'test daily',
+ type: 'daily',
+ notes: 1976,
+ frequency: 'daily',
+ everyX: 5,
+ startDate: now,
+ });
+ let challengeWithTask = await user.get(`/challenges/${challenge._id}`);
+
+ expect(challengeWithTask.tasksOrder.dailys.indexOf(task._id)).to.be.above(-1);
+ expect(task.challenge.id).to.equal(challenge._id);
+ expect(task.text).to.eql('test daily');
+ expect(task.notes).to.eql('1976');
+ expect(task.type).to.eql('daily');
+ expect(task.frequency).to.eql('daily');
+ expect(task.everyX).to.eql(5);
+ expect(new Date(task.startDate)).to.eql(now);
+ });
+});
diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js
index 000bc83d60..7b00896738 100644
--- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js
+++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js
@@ -12,7 +12,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => {
});
it('deletes a checklist item', async () => {
- let task = await user.post('/tasks', {
+ let task = await user.post('/tasks/user', {
type: 'daily',
text: 'Daily with checklist',
});
@@ -26,7 +26,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => {
});
it('does not work with habits', async () => {
- let habit = await user.post('/tasks', {
+ let habit = await user.post('/tasks/user', {
type: 'habit',
text: 'habit with checklist',
});
@@ -39,7 +39,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => {
});
it('does not work with rewards', async () => {
- let reward = await user.post('/tasks', {
+ let reward = await user.post('/tasks/user', {
type: 'reward',
text: 'reward with checklist',
});
@@ -60,7 +60,7 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => {
});
it('fails on checklist item not found', async () => {
- let createdTask = await user.post('/tasks', {
+ let createdTask = await user.post('/tasks/user', {
type: 'daily',
text: 'daily with checklist',
});
diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js
index b685dc5457..ce9e2dab7c 100644
--- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js
+++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js
@@ -12,7 +12,7 @@ describe('POST /tasks/:taskId/checklist/', () => {
});
it('adds a checklist item to a task', async () => {
- let task = await user.post('/tasks', {
+ let task = await user.post('/tasks/user', {
type: 'daily',
text: 'Daily with checklist',
});
@@ -32,7 +32,7 @@ describe('POST /tasks/:taskId/checklist/', () => {
});
it('does not add a checklist to habits', async () => {
- let habit = await user.post('/tasks', {
+ let habit = await user.post('/tasks/user', {
type: 'habit',
text: 'habit with checklist',
});
@@ -47,7 +47,7 @@ describe('POST /tasks/:taskId/checklist/', () => {
});
it('does not add a checklist to rewards', async () => {
- let reward = await user.post('/tasks', {
+ let reward = await user.post('/tasks/user', {
type: 'reward',
text: 'reward with checklist',
});
diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js
index 6090b89125..3efdd9642f 100644
--- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js
+++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js
@@ -12,7 +12,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => {
});
it('scores a checklist item', async () => {
- let task = await user.post('/tasks', {
+ let task = await user.post('/tasks/user', {
type: 'daily',
text: 'Daily with checklist',
});
@@ -29,7 +29,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => {
});
it('fails on habits', async () => {
- let habit = await user.post('/tasks', {
+ let habit = await user.post('/tasks/user', {
type: 'habit',
text: 'habit with checklist',
});
@@ -44,7 +44,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => {
});
it('fails on rewards', async () => {
- let reward = await user.post('/tasks', {
+ let reward = await user.post('/tasks/user', {
type: 'reward',
text: 'reward with checklist',
});
@@ -65,7 +65,7 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => {
});
it('fails on checklist item not found', async () => {
- let createdTask = await user.post('/tasks', {
+ let createdTask = await user.post('/tasks/user', {
type: 'daily',
text: 'daily with checklist',
});
diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js
index 8332497565..3f375c3c77 100644
--- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js
+++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js
@@ -12,7 +12,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => {
});
it('updates a checklist item', async () => {
- let task = await user.post('/tasks', {
+ let task = await user.post('/tasks/user', {
type: 'daily',
text: 'Daily with checklist',
});
@@ -35,7 +35,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => {
});
it('fails on habits', async () => {
- let habit = await user.post('/tasks', {
+ let habit = await user.post('/tasks/user', {
type: 'habit',
text: 'habit with checklist',
});
@@ -48,7 +48,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => {
});
it('fails on rewards', async () => {
- let reward = await user.post('/tasks', {
+ let reward = await user.post('/tasks/user', {
type: 'reward',
text: 'reward with checklist',
});
@@ -69,7 +69,7 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => {
});
it('fails on checklist item not found', async () => {
- let createdTask = await user.post('/tasks', {
+ let createdTask = await user.post('/tasks/user', {
type: 'daily',
text: 'daily with checklist',
});
diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js
index 71348fb680..fecb64b405 100644
--- a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js
+++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js
@@ -12,7 +12,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => {
});
it('removes a tag from a task', async () => {
- let task = await user.post('/tasks', {
+ let task = await user.post('/tasks/user', {
type: 'habit',
text: 'Task with tag',
});
@@ -28,7 +28,7 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => {
});
it('only deletes existing tags', async () => {
- let createdTask = await user.post('/tasks', {
+ let createdTask = await user.post('/tasks/user', {
type: 'habit',
text: 'Task with tag',
});
diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js
index 2030bc9981..b6e7174022 100644
--- a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js
+++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js
@@ -12,7 +12,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => {
});
it('adds a tag to a task', async () => {
- let task = await user.post('/tasks', {
+ let task = await user.post('/tasks/user', {
type: 'habit',
text: 'Task with tag',
});
@@ -24,7 +24,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => {
});
it('does not add a tag to a task twice', async () => {
- let task = await user.post('/tasks', {
+ let task = await user.post('/tasks/user', {
type: 'habit',
text: 'Task with tag',
});
@@ -41,7 +41,7 @@ describe('POST /tasks/:taskId/tags/:tagId', () => {
});
it('does not add a non existing tag to a task', async () => {
- let task = await user.post('/tasks', {
+ let task = await user.post('/tasks/user', {
type: 'habit',
text: 'Task with tag',
});
diff --git a/test/api/v3/unit/libs/baseModel.test.js b/test/api/v3/unit/libs/baseModel.test.js
index 95960c704d..ebde4ee3ea 100644
--- a/test/api/v3/unit/libs/baseModel.test.js
+++ b/test/api/v3/unit/libs/baseModel.test.js
@@ -1,23 +1,16 @@
import baseModel from '../../../../../website/src/libs/api-v3/baseModel';
+import mongoose from 'mongoose';
describe('Base model plugin', () => {
- let schema = {
- add () {
- return true;
- },
- statics: {},
- options: {},
- pre () {
- return true;
- },
- };
+ let schema;
beforeEach(() => {
+ schema = new mongoose.Schema();
sandbox.stub(schema, 'add');
});
it('adds a _id field to the schema', () => {
- baseModel(schema);
+ schema.plugin(baseModel);
expect(schema.add).to.be.calledWith(sinon.match({
_id: sinon.match.object,
@@ -25,13 +18,13 @@ describe('Base model plugin', () => {
});
it('can add timestamps fields', () => {
- baseModel(schema, {timestamps: true});
+ schema.plugin(baseModel, {timestamps: true});
expect(schema.add).to.be.calledTwice;
});
it('can sanitize input objects', () => {
- baseModel(schema, {
+ schema.plugin(baseModel, {
noSet: ['noUpdateForMe'],
});
@@ -44,7 +37,7 @@ describe('Base model plugin', () => {
});
it('accepts an array of additional fields to sanitize at runtime', () => {
- baseModel(schema, {
+ schema.plugin(baseModel, {
noSet: ['noUpdateForMe'],
});
@@ -58,7 +51,7 @@ describe('Base model plugin', () => {
it('can make fields private', () => {
- baseModel(schema, {
+ schema.plugin(baseModel, {
private: ['amPrivate'],
});
@@ -76,7 +69,7 @@ describe('Base model plugin', () => {
toJSONTransform: sandbox.stub().returns(true),
};
- baseModel(schema, options);
+ schema.plugin(baseModel, options);
let objToTransform = {ok: true, amPrivate: true};
let privatized = schema.options.toJSON.transform({}, objToTransform);
@@ -91,7 +84,7 @@ describe('Base model plugin', () => {
sanitizeTransform: sandbox.stub().returns(true),
};
- baseModel(schema, options);
+ schema.plugin(baseModel, options);
expect(schema.options.toJSON.transform).to.exist;
let objToSanitize = {ok: true, noUpdateForMe: true};
diff --git a/test/api/v3/unit/libs/collectionManipulators.test.js b/test/api/v3/unit/libs/collectionManipulators.test.js
new file mode 100644
index 0000000000..e32e953d51
--- /dev/null
+++ b/test/api/v3/unit/libs/collectionManipulators.test.js
@@ -0,0 +1,88 @@
+import mongoose from 'mongoose';
+import {
+ removeFromArray,
+} from '../../../../../website/src/libs/api-v3/collectionManipulators';
+
+describe('Collection Manipulators', () => {
+ describe('removeFromArray', () => {
+ it('removes element from array', () => {
+ let array = ['a', 'b', 'c', 'd'];
+
+ removeFromArray(array, 'c');
+
+ expect(array).to.not.include('c');
+ });
+
+ it('removes object from array', () => {
+ let array = [
+ { id: 'a', foo: 'bar' },
+ { id: 'b', foo: 'bar' },
+ { id: 'c', foo: 'bar' },
+ { id: 'd', foo: 'bar' },
+ { id: 'e', foo: 'bar' },
+ ];
+
+ removeFromArray(array, { id: 'c' });
+
+ expect(array).to.not.include({ id: 'c', foo: 'bar' });
+ });
+
+ it('does not change array if value is not found', () => {
+ let array = ['a', 'b', 'c', 'd'];
+
+ removeFromArray(array, 'z');
+
+ expect(array).to.have.a.lengthOf(4);
+ expect(array[0]).to.eql('a');
+ expect(array[1]).to.eql('b');
+ expect(array[2]).to.eql('c');
+ expect(array[3]).to.eql('d');
+ });
+
+ it('returns the removed element', () => {
+ let array = ['a', 'b', 'c'];
+
+ let result = removeFromArray(array, 'b');
+
+ expect(result).to.eql('b');
+ });
+
+ it('returns the removed object element', () => {
+ let array = [
+ { id: 'a', foo: 'bar' },
+ { id: 'b', foo: 'bar' },
+ { id: 'c', foo: 'bar' },
+ { id: 'd', foo: 'bar' },
+ { id: 'e', foo: 'bar' },
+ ];
+
+ let result = removeFromArray(array, { id: 'c' });
+
+ expect(result).to.eql({ id: 'c', foo: 'bar' });
+ });
+
+ it('returns false if item is not found', () => {
+ let array = ['a', 'b', 'c'];
+
+ let result = removeFromArray(array, 'z');
+
+ expect(result).to.eql(false);
+ });
+
+ it('persists removal of element when mongoose document is saved', async () => {
+ let schema = new mongoose.Schema({
+ array: Array,
+ });
+ let Model = mongoose.model('ModelToTestRemoveFromArray', schema);
+ let model = await new Model({
+ array: ['a', 'b', 'c'],
+ }).save(); // Initial creation
+
+ removeFromArray(model.array, 'b');
+
+ let savedModel = await model.save();
+
+ expect(savedModel.array).to.not.include('b');
+ });
+ });
+});
diff --git a/test/api/v3/unit/middlewares/analytics.test.js b/test/api/v3/unit/middlewares/analytics.test.js
index bcaa7898e2..eb238c6fa9 100644
--- a/test/api/v3/unit/middlewares/analytics.test.js
+++ b/test/api/v3/unit/middlewares/analytics.test.js
@@ -53,4 +53,3 @@ describe('analytics middleware', () => {
expect(res.analytics.trackPurchase).to.eql(analyticsService.trackPurchase);
});
});
-
diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js
new file mode 100644
index 0000000000..ef76e7b984
--- /dev/null
+++ b/test/api/v3/unit/models/group.test.js
@@ -0,0 +1,300 @@
+import { sleep } from '../../../../helpers/api-unit.helper';
+import { model as Group } from '../../../../../website/src/models/group';
+import { model as User } from '../../../../../website/src/models/user';
+import { quests as questScrolls } from '../../../../../common/script/content';
+import * as email from '../../../../../website/src/libs/api-v3/email';
+
+describe('Group Model', () => {
+ context('Instance Methods', () => {
+ describe('#startQuest', () => {
+ let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember;
+
+ beforeEach(async () => {
+ sandbox.stub(email, 'sendTxn');
+
+ party = new Group({
+ name: 'test party',
+ type: 'party',
+ privacy: 'private',
+ });
+
+ questLeader = new User({
+ party: { _id: party._id },
+ items: {
+ quests: {
+ whale: 1,
+ },
+ },
+ });
+
+ party.leader = questLeader._id;
+
+ participatingMember = new User({
+ party: { _id: party._id },
+ });
+ nonParticipatingMember = new User({
+ party: { _id: party._id },
+ });
+ undecidedMember = new User({
+ party: { _id: party._id },
+ });
+
+ await Promise.all([
+ party.save(),
+ questLeader.save(),
+ participatingMember.save(),
+ nonParticipatingMember.save(),
+ undecidedMember.save(),
+ ]);
+ });
+
+ context('Failure Conditions', () => {
+ it('throws an error if group is not a party', async () => {
+ let guild = new Group({
+ type: 'guild',
+ });
+
+ await expect(guild.startQuest(participatingMember)).to.eventually.be.rejected;
+ });
+
+ it('throws an error if party is not on a quest', async () => {
+ await expect(party.startQuest(participatingMember)).to.eventually.be.rejected;
+ });
+
+ it('throws an error if quest is already active', async () => {
+ party.quest.key = 'whale';
+ party.quest.active = true;
+
+ await expect(party.startQuest(participatingMember)).to.eventually.be.rejected;
+ });
+ });
+
+ context('Successes', () => {
+ beforeEach(() => {
+ party.quest.key = 'whale';
+ party.quest.active = false;
+ party.quest.leader = questLeader._id;
+ party.quest.members = { };
+ party.quest.members[questLeader._id] = true;
+ party.quest.members[participatingMember._id] = true;
+ party.quest.members[nonParticipatingMember._id] = false;
+ party.quest.members[undecidedMember._id] = null;
+ });
+
+ it('activates quest', () => {
+ party.startQuest(participatingMember);
+
+ expect(party.quest.active).to.eql(true);
+ });
+
+ it('sets up boss quest', () => {
+ let bossQuest = questScrolls.whale;
+ party.quest.key = bossQuest.key;
+
+ party.startQuest(participatingMember);
+
+ expect(party.quest.progress.hp).to.eql(bossQuest.boss.hp);
+ });
+
+ it('sets up rage meter for rage boss quest', () => {
+ let rageBossQuest = questScrolls.trex_undead;
+ party.quest.key = rageBossQuest.key;
+
+ party.startQuest(participatingMember);
+
+ expect(party.quest.progress.rage).to.eql(0);
+ });
+
+ it('sets up collection quest', () => {
+ let collectionQuest = questScrolls.vice2;
+ party.quest.key = collectionQuest.key;
+ party.startQuest(participatingMember);
+
+ expect(party.quest.progress.collect).to.eql({
+ lightCrystal: 0,
+ });
+ });
+
+ it('sets up collection quest with multiple items', () => {
+ let collectionQuest = questScrolls.evilsanta2;
+ party.quest.key = collectionQuest.key;
+ party.startQuest(participatingMember);
+
+ expect(party.quest.progress.collect).to.eql({
+ tracks: 0,
+ branches: 0,
+ });
+ });
+
+ it('prunes non-participating members from quest members object', () => {
+ party.startQuest(participatingMember);
+
+ let expectedQuestMembers = {};
+ expectedQuestMembers[questLeader._id] = true;
+ expectedQuestMembers[participatingMember._id] = true;
+
+ expect(party.quest.members).to.eql(expectedQuestMembers);
+ });
+
+ it('applies updates to user object directly if user is participating', async () => {
+ await party.startQuest(participatingMember);
+
+ expect(participatingMember.party.quest.key).to.eql('whale');
+ expect(participatingMember.party.quest.progress.down).to.eql(0);
+ expect(participatingMember.party.quest.collect).to.eql({});
+ expect(participatingMember.party.quest.completed).to.eql(null);
+ });
+
+ it('applies updates to other participating members', async () => {
+ await party.startQuest(nonParticipatingMember);
+
+ questLeader = await User.findById(questLeader._id);
+ participatingMember = await User.findById(participatingMember._id);
+
+ expect(participatingMember.party.quest.key).to.eql('whale');
+ expect(participatingMember.party.quest.progress.down).to.eql(0);
+ expect(participatingMember.party.quest.progress.collect).to.eql({});
+ expect(participatingMember.party.quest.completed).to.eql(null);
+
+ expect(questLeader.party.quest.key).to.eql('whale');
+ expect(questLeader.party.quest.progress.down).to.eql(0);
+ expect(questLeader.party.quest.progress.collect).to.eql({});
+ expect(questLeader.party.quest.completed).to.eql(null);
+ });
+
+ it('does not apply updates to nonparticipating members', async () => {
+ await party.startQuest(participatingMember);
+
+ nonParticipatingMember = await User.findById(nonParticipatingMember ._id);
+ undecidedMember = await User.findById(undecidedMember._id);
+
+ expect(nonParticipatingMember.party.quest.key).to.not.eql('whale');
+ expect(undecidedMember.party.quest.key).to.not.eql('whale');
+ });
+
+ it('sends email to participating members that quest has started', async () => {
+ participatingMember.preferences.emailNotifications.questStarted = true;
+ questLeader.preferences.emailNotifications.questStarted = true;
+ await Promise.all([
+ participatingMember.save(),
+ questLeader.save(),
+ ]);
+
+ await party.startQuest(nonParticipatingMember);
+
+ await sleep(0.5);
+
+ expect(email.sendTxn).to.be.calledOnce;
+
+ let memberIds = _.pluck(email.sendTxn.args[0][0], '_id');
+ let typeOfEmail = email.sendTxn.args[0][1];
+
+ expect(memberIds).to.have.a.lengthOf(2);
+ expect(memberIds).to.include(participatingMember._id);
+ expect(memberIds).to.include(questLeader._id);
+ expect(typeOfEmail).to.eql('quest-started');
+ });
+
+ it('sends email only to members who have not opted out', async () => {
+ participatingMember.preferences.emailNotifications.questStarted = false;
+ questLeader.preferences.emailNotifications.questStarted = true;
+ await Promise.all([
+ participatingMember.save(),
+ questLeader.save(),
+ ]);
+
+ await party.startQuest(nonParticipatingMember);
+
+ await sleep(0.5);
+
+ expect(email.sendTxn).to.be.calledOnce;
+
+ let memberIds = _.pluck(email.sendTxn.args[0][0], '_id');
+
+ expect(memberIds).to.have.a.lengthOf(1);
+ expect(memberIds).to.not.include(participatingMember._id);
+ expect(memberIds).to.include(questLeader._id);
+ });
+
+ it('does not send email to initiating member', async () => {
+ participatingMember.preferences.emailNotifications.questStarted = true;
+ questLeader.preferences.emailNotifications.questStarted = true;
+ await Promise.all([
+ participatingMember.save(),
+ questLeader.save(),
+ ]);
+
+ await party.startQuest(participatingMember);
+
+ await sleep(0.5);
+
+ expect(email.sendTxn).to.be.calledOnce;
+
+ let memberIds = _.pluck(email.sendTxn.args[0][0], '_id');
+
+ expect(memberIds).to.have.a.lengthOf(1);
+ expect(memberIds).to.not.include(participatingMember._id);
+ expect(memberIds).to.include(questLeader._id);
+ });
+
+ it('updates participting members (not including user)', async () => {
+ sandbox.spy(User, 'update');
+
+ await party.startQuest(nonParticipatingMember);
+
+ let members = [questLeader._id, participatingMember._id];
+
+ expect(User.update).to.be.calledWith(
+ { _id: { $in: members } },
+ {
+ $set: {
+ 'party.quest.key': 'whale',
+ 'party.quest.progress.down': 0,
+ 'party.quest.collect': {},
+ 'party.quest.completed': null,
+ },
+ }
+ );
+ });
+
+ it('updates non-user quest leader and decrements quest scroll', async () => {
+ sandbox.spy(User, 'update');
+
+ await party.startQuest(participatingMember);
+
+ expect(User.update).to.be.calledWith(
+ { _id: questLeader._id },
+ {
+ $inc: {
+ 'items.quests.whale': -1,
+ },
+ }
+ );
+ });
+
+ it('modifies the participating initiating user directly', async () => {
+ await party.startQuest(participatingMember);
+
+ let userQuest = participatingMember.party.quest;
+
+ expect(userQuest.key).to.eql('whale');
+ expect(userQuest.progress.down).to.eql(0);
+ expect(userQuest.collect).to.eql({});
+ expect(userQuest.completed).to.eql(null);
+ });
+
+ it('does not modify user if not participating', async () => {
+ await party.startQuest(nonParticipatingMember);
+
+ expect(nonParticipatingMember.party.quest.key).to.not.eql('whale');
+ });
+
+ it('removes the quest directly if initiating user is the quest leader', async () => {
+ await party.startQuest(questLeader);
+
+ expect(questLeader.items.quests.whale).to.eql(0);
+ });
+ });
+ });
+ });
+});
diff --git a/test/common/algos.mocha.js b/test/common/algos.mocha.js
index 8c18e3c92c..0528d62933 100644
--- a/test/common/algos.mocha.js
+++ b/test/common/algos.mocha.js
@@ -938,124 +938,6 @@ describe('Cron', () => {
expect(beforeTasks).to.eql(afterTasks);
});
- describe('preening', () => {
- beforeEach(function () {
- this.clock = sinon.useFakeTimers(Date.parse('2013-11-20'), 'Date');
- });
- afterEach(function () {
- return this.clock.restore();
- });
-
- it('should preen user history', function () {
- let ref = beforeAfter({
- daysAgo: 1,
- });
- let after = ref.after;
-
- let history = [
- {
- date: '09/01/2012',
- value: 0,
- }, {
- date: '10/01/2012',
- value: 0,
- }, {
- date: '11/01/2012',
- value: 2,
- }, {
- date: '12/01/2012',
- value: 2,
- }, {
- date: '01/01/2013',
- value: 1,
- }, {
- date: '01/15/2013',
- value: 3,
- }, {
- date: '02/01/2013',
- value: 2,
- }, {
- date: '02/15/2013',
- value: 4,
- }, {
- date: '03/01/2013',
- value: 3,
- }, {
- date: '03/15/2013',
- value: 5,
- }, {
- date: '04/01/2013',
- value: 4,
- }, {
- date: '04/15/2013',
- value: 6,
- }, {
- date: '05/01/2013',
- value: 5,
- }, {
- date: '05/15/2013',
- value: 7,
- }, {
- date: '06/01/2013',
- value: 6,
- }, {
- date: '06/15/2013',
- value: 8,
- }, {
- date: '07/01/2013',
- value: 7,
- }, {
- date: '07/15/2013',
- value: 9,
- }, {
- date: '08/01/2013',
- value: 8,
- }, {
- date: '08/15/2013',
- value: 10,
- }, {
- date: '09/01/2013',
- value: 9,
- }, {
- date: '09/15/2013',
- value: 11,
- }, {
- date: '010/01/2013',
- value: 10,
- }, {
- date: '010/15/2013',
- value: 12,
- }, {
- date: '011/01/2013',
- value: 12,
- }, {
- date: '011/02/2013',
- value: 13,
- }, {
- date: '011/03/2013',
- value: 14,
- }, {
- date: '011/04/2013',
- value: 15,
- },
- ];
-
- after.history = {
- exp: _.cloneDeep(history),
- todos: _.cloneDeep(history),
- };
- after.habits[0].history = _.cloneDeep(history);
- after.fns.cron();
- after.history.exp.pop();
- after.history.todos.pop();
- _.each([after.history.exp, after.history.todos, after.habits[0].history], function (arr) {
- expect(_.map(arr, (x) => {
- return x.value;
- })).to.eql([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
- });
- });
- });
-
describe('Todos', () => {
it('1 day missed', () => {
let ref = beforeAfter({
diff --git a/test/common/preening.test.js b/test/common/preening.test.js
new file mode 100644
index 0000000000..f19a3c26b5
--- /dev/null
+++ b/test/common/preening.test.js
@@ -0,0 +1,70 @@
+import { preenHistory } from '../../common/script/preening';
+import moment from 'moment';
+import sinon from 'sinon'; // eslint-disable-line no-shadow
+
+function generateHistory (days) {
+ let history = [];
+ let now = Number(moment().toDate());
+
+ while (days > 0) {
+ history.push({
+ value: days,
+ date: Number(moment(now).subtract(days, 'days').toDate()),
+ });
+ days--;
+ }
+
+ return history;
+}
+
+describe('preenHistory', () => {
+ let clock;
+
+ beforeEach(() => {
+ // Replace system clocks so we can get predictable results
+ clock = sinon.useFakeTimers(Number(moment('2013-10-20').zone(0).startOf('day').toDate()), 'Date');
+ });
+ afterEach(() => {
+ return clock.restore();
+ });
+
+ it('does not modify history if all entries are more recent than cutoff (free users)', () => {
+ let h = generateHistory(60);
+ expect(preenHistory(_.cloneDeep(h), false, 0)).to.eql(h);
+ });
+
+ it('does not modify history if all entries are more recent than cutoff (subscribers)', () => {
+ let h = generateHistory(365);
+ expect(preenHistory(_.cloneDeep(h), true, 0)).to.eql(h);
+ });
+
+ it('does aggregate data in monthly entries before cutoff (free users)', () => {
+ let h = generateHistory(81); // Jumps to July
+ let preened = preenHistory(_.cloneDeep(h), false, 0);
+ expect(preened.length).to.eql(62); // Keeps 60 days + 2 entries per august and july
+ });
+
+ it('does aggregate data in monthly entries before cutoff (subscribers)', () => {
+ let h = generateHistory(396); // Jumps to September 2012
+ let preened = preenHistory(_.cloneDeep(h), true, 0);
+ expect(preened.length).to.eql(367); // Keeps 365 days + 2 entries per october and september
+ });
+
+ it('does aggregate data in monthly and yearly entries before cutoff (free users)', () => {
+ let h = generateHistory(731); // Jumps to October 21 2012
+ let preened = preenHistory(_.cloneDeep(h), false, 0);
+ expect(preened.length).to.eql(73); // Keeps 60 days + 11 montly entries and 2 yearly entry for 2011 and 2012
+ });
+
+ it('does aggregate data in monthly and yearly entries before cutoff (subscribers)', () => {
+ let h = generateHistory(1031); // Jumps to October 21 2012
+ let preened = preenHistory(_.cloneDeep(h), true, 0);
+ expect(preened.length).to.eql(380); // Keeps 365 days + 13 montly entries and 2 yearly entries for 2011 and 2010
+ });
+
+ it('correctly aggregates values', () => {
+ let h = generateHistory(63); // Compress last 3 days
+ let preened = preenHistory(_.cloneDeep(h), false, 0);
+ expect(preened[0].value).to.eql((61 + 62 + 63) / 3);
+ });
+});
diff --git a/test/helpers/api-integration/api-classes.js b/test/helpers/api-integration/api-classes.js
index 3fe7adbca7..f584826520 100644
--- a/test/helpers/api-integration/api-classes.js
+++ b/test/helpers/api-integration/api-classes.js
@@ -4,7 +4,7 @@ import { requester } from './requester';
import {
getDocument as getDocumentFromMongo,
updateDocument as updateDocumentInMongo,
-} from './mongo';
+} from '../mongo';
import {
assign,
each,
@@ -59,6 +59,29 @@ export class ApiGroup extends ApiObject {
this._docType = 'groups';
}
+
+ async addChat (chat) {
+ let group = this;
+
+ if (!chat) {
+ chat = {
+ id: 'Test_ID',
+ text: 'Test message',
+ flagCount: 0,
+ timestamp: Date(),
+ likes: {},
+ flags: {},
+ uuid: group.leader,
+ contributor: {},
+ backer: {},
+ user: group.leader,
+ };
+ }
+
+ let update = { chat };
+
+ return await this.update(update);
+ }
}
export class ApiChallenge extends ApiObject {
diff --git a/test/helpers/api-integration/requester.js b/test/helpers/api-integration/requester.js
index a55da4d3d8..bcf580e653 100644
--- a/test/helpers/api-integration/requester.js
+++ b/test/helpers/api-integration/requester.js
@@ -1,8 +1,9 @@
/* eslint-disable no-use-before-define */
import superagent from 'superagent';
+import nconf from 'nconf';
-const API_TEST_SERVER_PORT = 3003;
+const API_TEST_SERVER_PORT = nconf.get('PORT');
let apiVersion;
// Sets up an abject that can make all REST requests
@@ -51,7 +52,8 @@ function _requestMaker (user, method, additionalSets) {
reject(parsedError);
}
- resolve(response.body);
+ let contentType = response.headers['content-type'] || '';
+ resolve(contentType.indexOf('json') !== -1 ? response.body : response.text);
});
});
};
diff --git a/test/helpers/api-integration/v3/index.js b/test/helpers/api-integration/v3/index.js
index 0c440ade26..6ae15d7ca6 100644
--- a/test/helpers/api-integration/v3/index.js
+++ b/test/helpers/api-integration/v3/index.js
@@ -6,5 +6,6 @@ requester.setApiVersion('v3');
export { requester };
export { translate } from '../translate';
-export { checkExistence, resetHabiticaDB } from '../mongo';
+export { checkExistence, resetHabiticaDB } from '../../mongo';
export * from './object-generators';
+export { sleep } from '../../sleep';
diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js
index f14ecdad67..57f6e2163b 100644
--- a/test/helpers/api-integration/v3/object-generators.js
+++ b/test/helpers/api-integration/v3/object-generators.js
@@ -3,7 +3,7 @@ import {
} from 'lodash';
import Q from 'q';
import { v4 as generateUUID } from 'uuid';
-import { ApiUser, ApiGroup } from '../api-classes';
+import { ApiUser, ApiGroup, ApiChallenge } from '../api-classes';
import { requester } from '../requester';
// Creates a new user and returns it
@@ -72,18 +72,18 @@ export async function createAndPopulateGroup (settings = {}) {
let groupLeader = await generateUser(leaderDetails);
let group = await generateGroup(groupLeader, groupDetails);
+ const groupMembershipTypes = {
+ party: { 'party._id': group._id},
+ guild: { guilds: [group._id] },
+ };
+
let members = await Q.all(
times(numberOfMembers, () => {
- return generateUser();
+ return generateUser(groupMembershipTypes[group.type]);
})
);
- let memberIds = members.map((member) => {
- return member._id;
- });
- memberIds.push(groupLeader._id);
-
- await group.update({ members: memberIds });
+ group.update({ memberCount: numberOfMembers + 1});
let invitees = await Q.all(
times(numberOfInvites, () => {
@@ -106,3 +106,23 @@ export async function createAndPopulateGroup (settings = {}) {
invitees,
};
}
+
+// Generates a new challenge. Requires an ApiUser object and a
+// group-like object (can just be {_id: 'your-group-id'}). The group
+// will will become the group that owns the challenge. It takes an
+// optional details argument for the initial challenge creation and an
+// optional update argument which will update the challenge via the db
+export async function generateChallenge (challengeCreator, group, details = {}, update = {}) {
+ details.group = group._id;
+ details.name = details.name || 'a challenge';
+ details.shortName = details.shortName || 'aChallenge';
+ details.prize = details.prize || 0;
+ details.official = details.official || false;
+
+ let challenge = await challengeCreator.post('/challenges', details);
+ let apiChallenge = new ApiChallenge(challenge);
+
+ await apiChallenge.update(update);
+
+ return apiChallenge;
+}
diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js
index cefd1eeeeb..1a590f8a3a 100644
--- a/test/helpers/api-unit.helper.js
+++ b/test/helpers/api-unit.helper.js
@@ -1,12 +1,17 @@
import '../../website/src/libs/api-v3/i18n';
+import mongoose from 'mongoose';
import { defaultsDeep as defaults } from 'lodash';
import { model as User } from '../../website/src/models/user';
import { model as Group } from '../../website/src/models/group';
+import mongo from './mongo'; // eslint-disable-line
-afterEach(() => {
+afterEach((done) => {
sandbox.restore();
+ mongoose.connection.db.dropDatabase(done);
});
+export { sleep } from './sleep';
+
export function generateUser (options = {}) {
return new User(options).toObject();
}
diff --git a/test/helpers/api-v3-integration.helper.js b/test/helpers/api-v3-integration.helper.js
index a90aba70c0..4f703efdd6 100644
--- a/test/helpers/api-v3-integration.helper.js
+++ b/test/helpers/api-v3-integration.helper.js
@@ -1 +1 @@
-export * from './api-integration/v3';
+module.exports = require('./api-integration/v3');
diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js
index 19f3ebfc71..9e92a85de9 100644
--- a/test/helpers/globals.helper.js
+++ b/test/helpers/globals.helper.js
@@ -1,5 +1,6 @@
/* eslint-disable no-undef */
-require('babel-core/register');
+/* eslint-disable global-require */
+/* eslint-disable no-process-env */
//------------------------------
// Global modules
//------------------------------
@@ -12,7 +13,25 @@ global.expect = chai.expect;
global.sinon = require('sinon');
global.sandbox = sinon.sandbox.create();
+import nconf from 'nconf';
+import mongoose from 'mongoose';
+import Q from 'q';
+
//------------------------------
// Load nconf for unit tests
//------------------------------
-require('../../website/src/libs/api-v3/setupNconf')('./config.json.example');
+if (process.env.LOAD_SERVER === '0') { // when the server is in a different process we simply connect to mongoose
+ require('../../website/src/libs/api-v3/setupNconf')('./config.json');
+ // Use Q promises instead of mpromise in mongoose
+ mongoose.Promise = Q.Promise;
+ mongoose.connect(nconf.get('NODE_DB_URI'));
+} else { // When running tests and the server in the same process
+ require('../../website/src/libs/api-v3/setupNconf')('./config.json.example');
+ nconf.set('NODE_DB_URI', 'mongodb://localhost/habitrpg_test');
+ nconf.set('NODE_ENV', 'test');
+ nconf.set('IS_TEST', true);
+ // We require src/server and npt src/index because
+ // 1. nconf is already setup
+ // 2. we don't need clustering
+ require('../../website/src/server');
+}
diff --git a/test/helpers/api-integration/mongo.js b/test/helpers/mongo.js
similarity index 52%
rename from test/helpers/api-integration/mongo.js
rename to test/helpers/mongo.js
index 52be5263b4..d99c195c3c 100644
--- a/test/helpers/api-integration/mongo.js
+++ b/test/helpers/mongo.js
@@ -1,24 +1,17 @@
-/* eslint-disable no-use-before-define */
-
-import { MongoClient as mongo } from 'mongodb';
-
-const DB_URI = 'mongodb://localhost/habitrpg_test';
+import mongoose from 'mongoose';
// Useful for checking things that have been deleted,
// but you no longer have access to,
// like private parties or users
export async function checkExistence (collectionName, id) {
- let db = await connectToMongo();
-
return new Promise((resolve, reject) => {
- let collection = db.collection(collectionName);
+ let collection = mongoose.connection.db.collection(collectionName);
collection.find({_id: id}, {_id: 1}).limit(1).toArray((findError, docs) => {
if (findError) return reject(findError);
let exists = docs.length > 0;
- db.close();
resolve(exists);
});
});
@@ -27,66 +20,67 @@ export async function checkExistence (collectionName, id) {
// Specifically helpful for the GET /groups tests,
// resets the db to an empty state and creates a tavern document
export async function resetHabiticaDB () {
- let db = await connectToMongo();
-
return new Promise((resolve, reject) => {
- db.dropDatabase((dbErr) => {
+ mongoose.connection.db.dropDatabase((dbErr) => {
if (dbErr) return reject(dbErr);
- let groups = db.collection('groups');
+ let groups = mongoose.connection.db.collection('groups');
- groups.insertOne({
- _id: 'habitrpg',
- chat: [],
- leader: '9',
- name: 'HabitRPG',
- type: 'guild',
- privacy: 'public',
- members: [],
- }, (insertErr) => {
- if (insertErr) return reject(insertErr);
+ // For some mysterious reason after a dropDatabase there can still be a group...
+ groups.count({_id: 'habitrpg'}, (err, count) => {
+ if (err) return reject(err);
+ if (count > 0) return resolve();
- db.close();
- resolve();
+ groups.insertOne({
+ _id: 'habitrpg',
+ chat: [],
+ leader: '9',
+ name: 'HabitRPG',
+ type: 'guild',
+ privacy: 'public',
+ }, (insertErr) => {
+ if (insertErr) return reject(insertErr);
+
+ resolve();
+ });
});
});
});
}
export async function updateDocument (collectionName, doc, update) {
- let db = await connectToMongo();
-
- let collection = db.collection(collectionName);
+ let collection = mongoose.connection.db.collection(collectionName);
return new Promise((resolve) => {
collection.updateOne({ _id: doc._id }, { $set: update }, (updateErr) => {
if (updateErr) throw new Error(`Error updating ${collectionName}: ${updateErr}`);
- db.close();
resolve();
});
});
}
export async function getDocument (collectionName, doc) {
- let db = await connectToMongo();
-
- let collection = db.collection(collectionName);
+ let collection = mongoose.connection.db.collection(collectionName);
return new Promise((resolve) => {
collection.findOne({ _id: doc._id }, (lookupErr, found) => {
if (lookupErr) throw new Error(`Error looking up ${collectionName}: ${lookupErr}`);
- db.close();
resolve(found);
});
});
}
-export function connectToMongo () {
- return new Promise((resolve, reject) => {
- mongo.connect(DB_URI, (err, db) => {
- if (err) return reject(err);
-
- resolve(db);
- });
+before((done) => {
+ mongoose.connection.on('open', (err) => {
+ if (err) return done(err);
+ resetHabiticaDB()
+ .then(() => done())
+ .catch(done);
});
-}
+});
+after((done) => {
+ mongoose.connection.db.dropDatabase((err) => {
+ if (err) return done(err);
+ mongoose.connection.close(done);
+ });
+});
diff --git a/test/helpers/sleep.js b/test/helpers/sleep.js
new file mode 100644
index 0000000000..f8dd9ab165
--- /dev/null
+++ b/test/helpers/sleep.js
@@ -0,0 +1,7 @@
+export async function sleep (seconds) {
+ let milliseconds = seconds * 1000;
+
+ return new Promise((resolve) => {
+ setTimeout(resolve, milliseconds);
+ });
+}
diff --git a/test/mocha.opts b/test/mocha.opts
index bcdefd3e55..c55b351ceb 100644
--- a/test/mocha.opts
+++ b/test/mocha.opts
@@ -5,5 +5,4 @@
--growl
--globals io
--compilers js:babel-core/register
---require test/api-legacy/api-helper
--require ./test/helpers/globals.helper
diff --git a/website/src/controllers/api-v2/groups.js b/website/src/controllers/api-v2/groups.js
index de4d21022c..5f840c4330 100644
--- a/website/src/controllers/api-v2/groups.js
+++ b/website/src/controllers/api-v2/groups.js
@@ -1101,6 +1101,7 @@ api.questLeave = function(req, res, next) {
});
}
+// TODO port to api v3? in tojson?
function _purgeFlagInfoFromChat(group, user) {
group.chat = _.filter(group.chat, function(message) { return !message.flagCount || message.flagCount < 2; });
_.each(group.chat, function (message) {
diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js
index 2c7e00cadc..c66b254e9b 100644
--- a/website/src/controllers/api-v3/auth.js
+++ b/website/src/controllers/api-v3/auth.js
@@ -5,6 +5,7 @@ import cron from '../../middlewares/api-v3/cron';
import {
NotAuthorized,
} from '../../libs/api-v3/errors';
+import Q from 'q';
import * as passwordUtils from '../../libs/api-v3/password';
import { model as User } from '../../models/user';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
@@ -29,7 +30,7 @@ api.registerLocal = {
method: 'POST',
middlewares: [authWithHeaders(true)],
url: '/user/auth/local/register',
- handler (req, res, next) {
+ async handler (req, res) {
let fbUser = res.locals.user; // If adding local auth to social user
// TODO check user doesn't have local auth
req.checkBody({
@@ -45,7 +46,7 @@ api.registerLocal = {
});
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
let { email, username, password } = req.body;
@@ -55,72 +56,70 @@ api.registerLocal = {
let lowerCaseUsername = username.toLowerCase();
// Search for duplicates using lowercase version of username
- User.findOne({$or: [
+ let user = await User.findOne({$or: [
{'auth.local.email': email},
{'auth.local.lowerCaseUsername': lowerCaseUsername},
- ]}, {'auth.local': 1})
- .exec()
- .then((user) => {
- if (user) {
- if (email === user.auth.local.email) throw new NotAuthorized(res.t('emailTaken'));
- // Check that the lowercase username isn't already used
- if (lowerCaseUsername === user.auth.local.lowerCaseUsername) throw new NotAuthorized(res.t('usernameTaken'));
- }
+ ]}, {'auth.local': 1}).exec();
- let salt = passwordUtils.makeSalt();
- let hashed_password = passwordUtils.encrypt(password, salt); // eslint-disable-line camelcase
- let newUser = {
- auth: {
- local: {
- username,
- lowerCaseUsername,
- email,
- salt,
- hashed_password, // eslint-disable-line camelcase
- },
+ if (user) {
+ if (email === user.auth.local.email) throw new NotAuthorized(res.t('emailTaken'));
+ // Check that the lowercase username isn't already used
+ if (lowerCaseUsername === user.auth.local.lowerCaseUsername) throw new NotAuthorized(res.t('usernameTaken'));
+ }
+
+ let salt = passwordUtils.makeSalt();
+ let hashed_password = passwordUtils.encrypt(password, salt); // eslint-disable-line camelcase
+ let newUser = {
+ auth: {
+ local: {
+ username,
+ lowerCaseUsername,
+ email,
+ salt,
+ hashed_password, // eslint-disable-line camelcase
},
- preferences: {
- language: req.language,
- },
- };
+ },
+ preferences: {
+ language: req.language,
+ },
+ };
- if (fbUser) {
- if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal'));
- fbUser.auth.local = newUser;
- return fbUser.save();
- } else {
- newUser = new User(newUser);
- newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere?
- return newUser.save();
- }
- })
- .then((savedUser) => {
- if (savedUser.auth.facebook.id) {
- res.respond(200, savedUser.auth.local); // TODO make sure this used .toJSON and removes private fields
- } else {
- res.respond(201, savedUser);
- }
+ let savedUser;
- // Clean previous email preferences
- EmailUnsubscription
- .remove({email: savedUser.auth.local.email})
- .then(() => sendTxnEmail(savedUser, 'welcome'));
+ if (fbUser) {
+ if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal'));
+ fbUser.auth.local = newUser;
+ savedUser = await fbUser.save();
+ } else {
+ newUser = new User(newUser);
+ newUser.registeredThrough = req.headers['x-client']; // TODO is this saved somewhere?
+ savedUser = await newUser.save();
+ }
- if (!savedUser.auth.facebook.id) {
- res.analytics.track('register', {
- category: 'acquisition',
- type: 'local',
- gaLabel: 'local',
- uuid: savedUser._id,
- });
- }
- })
- .catch(next);
+ if (savedUser.auth.facebook.id) {
+ res.respond(200, savedUser.auth.local); // TODO make sure this used .toJSON and removes private fields
+ } else {
+ res.respond(201, savedUser);
+ }
+
+ // Clean previous email preferences
+ EmailUnsubscription
+ .remove({email: savedUser.auth.local.email})
+ .then(() => sendTxnEmail(savedUser, 'welcome'));
+
+ if (!savedUser.auth.facebook.id) {
+ res.analytics.track('register', {
+ category: 'acquisition',
+ type: 'local',
+ gaLabel: 'local',
+ uuid: savedUser._id,
+ });
+ }
},
};
-function _loginRes (user, req, res, next) {
- if (user.auth.blocked) return next(new NotAuthorized(res.t('accountSuspended', {userId: user._id})));
+function _loginRes (user, req, res) {
+ if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {userId: user._id}));
res.respond(200, {id: user._id, apiToken: user.apiToken});
}
@@ -140,7 +139,7 @@ api.loginLocal = {
method: 'POST',
url: '/user/auth/local/login',
middlewares: [cron],
- handler (req, res, next) {
+ async handler (req, res) {
req.checkBody({
username: {
notEmpty: true,
@@ -153,7 +152,7 @@ api.loginLocal = {
});
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
req.sanitizeBody('username').trim();
req.sanitizeBody('password').trim();
@@ -167,75 +166,80 @@ api.loginLocal = {
login = {'auth.local.username': username};
}
- User
- .findOne(login, {auth: 1, apiToken: 1}).exec()
- .then((user) => {
- // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."});
- let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt);
+ let user = await User.findOne(login, {auth: 1, apiToken: 1}).exec();
- if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentials'));
- _loginRes(user, ...arguments);
- })
- .catch(next);
+ // TODO place back long error message return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."});
+ let isValidPassword = user && user.auth.local.hashed_password !== passwordUtils.encrypt(req.body.password, user.auth.local.salt);
+
+ if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentials'));
+ _loginRes(user, ...arguments);
},
};
+function _passportFbProfile (accessToken) {
+ let deferred = Q.defer();
+
+ passport._strategies.facebook.userProfile(accessToken, (err, profile) => {
+ if (err) {
+ deferred.rejec();
+ } else {
+ deferred.resolve(profile);
+ }
+ });
+
+ return deferred.promise;
+}
+
// Called as a callback by Facebook (or other social providers)
api.loginSocial = {
method: 'POST',
url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2
middlewares: [cron],
- handler (req, res, next) {
+ async handler (req, res) {
let accessToken = req.body.authResponse.access_token;
let network = req.body.network;
- if (network !== 'facebook') return next(new NotAuthorized(res.t('onlyFbSupported')));
+ if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported'));
- passport._strategies[network].userProfile(accessToken, (err, profile) => {
- if (err) return next(err);
+ let profile = await _passportFbProfile(accessToken);
- User.findOne({
- [`auth.${network}.id`]: profile.id,
- }, {_id: 1, apiToken: 1, auth: 1}).exec()
- .then((user) => {
- // User already signed up
- if (user) {
- return _loginRes(user, ...arguments);
- } else { // Create new user
- user = new User({
- auth: {
- [network]: profile,
- },
- preferences: {
- language: req.language,
- },
- });
- user.registeredThrough = req.headers['x-client'];
+ let user = await User.findOne({
+ [`auth.${network}.id`]: profile.id,
+ }, {_id: 1, apiToken: 1, auth: 1}).exec();
- user.save()
- .then((savedUser) => {
- _loginRes(user, ...arguments);
+ // User already signed up
+ if (user) {
+ _loginRes(user, ...arguments);
+ } else { // Create new user
+ user = new User({
+ auth: {
+ [network]: profile,
+ },
+ preferences: {
+ language: req.language,
+ },
+ });
+ user.registeredThrough = req.headers['x-client'];
- // Clean previous email preferences
- if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) {
- EmailUnsubscription
- .remove({email: savedUser.auth[network].emails[0].value.toLowerCase()})
- .exec()
- .then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks
- }
+ let savedUser = await user.save();
- res.analytics.track('register', {
- category: 'acquisition',
- type: network,
- gaLabel: network,
- uuid: savedUser._id,
- });
- })
- .catch(next);
- }
- })
- .catch(next);
- });
+ _loginRes(user, ...arguments);
+
+ // Clean previous email preferences
+ if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) {
+ EmailUnsubscription
+ .remove({email: savedUser.auth[network].emails[0].value.toLowerCase()})
+ .exec()
+ .then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks
+ }
+
+ res.analytics.track('register', {
+ category: 'acquisition',
+ type: network,
+ gaLabel: network,
+ uuid: savedUser._id,
+ });
+ }
},
};
@@ -251,16 +255,16 @@ api.deleteSocial = {
method: 'DELETE',
url: '/user/auth/social/:network',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
let network = req.params.network;
- if (network !== 'facebook') return next(new NotAuthorized(res.t('onlyFbSupported')));
- if (!user.auth.local.username) return next(new NotAuthorized(res.t('cantDetachFb'))); // TODO move to model validation?
+ if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported'));
+ if (!user.auth.local.username) throw new NotAuthorized(res.t('cantDetachFb'));
- User.update({_id: user._id}, {$unset: {'auth.facebook': 1}})
- .then(() => res.respond(200))
- .catch(next);
+ await User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}).exec();
+
+ res.respond(200, {});
},
};
diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js
new file mode 100644
index 0000000000..c239a63dd3
--- /dev/null
+++ b/website/src/controllers/api-v3/challenges.js
@@ -0,0 +1,568 @@
+import { authWithHeaders } from '../../middlewares/api-v3/auth';
+import _ from 'lodash';
+import cron from '../../middlewares/api-v3/cron';
+import { model as Challenge } from '../../models/challenge';
+import {
+ model as Group,
+ basicFields as basicGroupFields,
+} from '../../models/group';
+import {
+ model as User,
+ nameFields,
+} from '../../models/user';
+import {
+ NotFound,
+ NotAuthorized,
+} from '../../libs/api-v3/errors';
+import shared from '../../../../common';
+import * as Tasks from '../../models/task';
+import { sendTxn as txnEmail } from '../../libs/api-v3/email';
+import pushNotify from '../../libs/api-v3/pushNotifications';
+import Q from 'q';
+import csvStringify from '../../libs/api-v3/csvStringify';
+
+let api = {};
+
+/**
+ * @api {post} /challenges Create a new challenge
+ * @apiVersion 3.0.0
+ * @apiName CreateChallenge
+ * @apiGroup Challenge
+ *
+ * @apiSuccess {object} challenge The newly created challenge
+ */
+api.createChallenge = {
+ method: 'POST',
+ url: '/challenges',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkBody('group', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let groupId = req.body.group;
+ let prize = req.body.prize;
+
+ let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (!group.isMember(user)) throw new NotAuthorized(res.t('mustBeGroupMember'));
+
+ if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) {
+ throw new NotAuthorized(res.t('onlyGroupLeaderChal'));
+ }
+
+ if (groupId === 'habitrpg' && prize < 1) {
+ throw new NotAuthorized(res.t('pubChalsMinPrize'));
+ }
+
+ if (prize > 0) {
+ let groupBalance = group.balance && group.leader === user._id ? group.balance : 0;
+ let prizeCost = prize / 4;
+
+ if (prizeCost > user.balance + groupBalance) {
+ throw new NotAuthorized(res.t('cantAfford'));
+ }
+
+ if (groupBalance >= prizeCost) {
+ // Group pays for all of prize
+ group.balance -= prizeCost;
+ } else if (groupBalance > 0) {
+ // User pays remainder of prize cost after group
+ let remainder = prizeCost - group.balance;
+ group.balance = 0;
+ user.balance -= remainder;
+ } else {
+ // User pays for all of prize
+ user.balance -= prizeCost;
+ }
+ }
+
+ group.challengeCount += 1;
+
+ req.body.leader = user._id;
+ req.body.official = user.contributor.admin && req.body.official;
+ let challenge = new Challenge(Challenge.sanitize(req.body));
+
+ // First validate challenge so we don't save group if it's invalid (only runs sync validators)
+ let challengeValidationErrors = challenge.validateSync();
+ if (challengeValidationErrors) throw challengeValidationErrors;
+
+ let results = await Q.all([challenge.save({
+ validateBeforeSave: false, // already validate
+ }), group.save()]);
+ let savedChal = results[0];
+
+ await savedChal.syncToUser(user); // (it also saves the user)
+
+ let response = savedChal.toJSON();
+ response.leader = { // the leader is the authenticated user
+ _id: user._id,
+ profile: {name: user.profile.name},
+ };
+ response.group = { // we already have the group data
+ _id: group._id,
+ name: group.name,
+ type: group.type,
+ privacy: group.privacy,
+ };
+ res.respond(201, response);
+ },
+};
+
+/**
+ * @api {post} /challenges/:challengeId/join Joins a challenge
+ * @apiVersion 3.0.0
+ * @apiName JoinChallenge
+ * @apiGroup Challenge
+ * @apiParam {UUID} challengeId The challenge _id
+ *
+ * @apiSuccess {object} challenge The challenge the user joined
+ */
+api.joinChallenge = {
+ method: 'POST',
+ url: '/challenges/:challengeId/join',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let challenge = await Challenge.findOne({ _id: req.params.challengeId });
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+ if (challenge.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge'));
+
+ let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true});
+ if (!group || !challenge.hasAccess(user, group)) throw new NotFound(res.t('challengeNotFound'));
+
+ challenge.memberCount += 1;
+
+ // Add all challenge's tasks to user's tasks and save the challenge
+ let results = await Q.all([challenge.syncToUser(user), challenge.save()]);
+
+ let response = results[1].toJSON();
+ response.group = { // we already have the group data
+ _id: group._id,
+ name: group.name,
+ type: group.type,
+ privacy: group.privacy,
+ };
+ response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true});
+
+ res.respond(200, response);
+ },
+};
+
+/**
+ * @api {post} /challenges/:challengeId/leave Leaves a challenge
+ * @apiVersion 3.0.0
+ * @apiName LeaveChallenge
+ * @apiGroup Challenge
+ * @apiParam {UUID} challengeId The challenge _id
+ *
+ * @apiSuccess {object} empty An empty object
+ */
+api.leaveChallenge = {
+ method: 'POST',
+ url: '/challenges/:challengeId/leave',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let keep = req.body.keep === 'remove-all' ? 'remove-all' : 'keep-all';
+
+ req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let challenge = await Challenge.findOne({ _id: req.params.challengeId });
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+
+ let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy'});
+ if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
+
+ if (!challenge.isMember(user)) throw new NotAuthorized(res.t('challengeMemberNotFound'));
+
+ challenge.memberCount -= 1;
+
+ // Unlink challenge's tasks from user's tasks and save the challenge
+ await Q.all([user.unlinkChallengeTasks(challenge._id, keep), challenge.save()]);
+ res.respond(200, {});
+ },
+};
+
+/**
+ * @api {get} /challenges/user Get challenges for a user
+ * @apiVersion 3.0.0
+ * @apiName GetUserChallenges
+ * @apiGroup Challenge
+ *
+ * @apiSuccess {Array} challenges An array of challenges
+ */
+api.getUserChallenges = {
+ method: 'GET',
+ url: '/challenges/user',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ let challenges = await Challenge.find({
+ $or: [
+ {_id: {$in: user.challenges}}, // Challenges where the user is participating
+ {group: {$in: user.getGroups()}}, // Challenges in groups where I'm a member
+ {leader: user._id}, // Challenges where I'm the leader
+ ],
+ _id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit
+ })
+ .sort('-official -timestamp')
+ // .populate('group', basicGroupFields)
+ // .populate('leader', nameFields)
+ .exec();
+
+ let resChals = challenges.map(challenge => challenge.toJSON());
+ // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
+ await Q.all(resChals.map((chal, index) => {
+ return Q.all([
+ User.findById(chal.leader).select(nameFields).exec(),
+ Group.findById(chal.group).select(basicGroupFields).exec(),
+ ]).then(populatedData => {
+ resChals[index].leader = populatedData[0].toJSON({minimize: true});
+ resChals[index].group = populatedData[1].toJSON({minimize: true});
+ });
+ }));
+
+ res.respond(200, resChals);
+ },
+};
+
+/**
+ * @api {get} /challenges/group/group:Id Get challenges for a group
+ * @apiVersion 3.0.0
+ * @apiName GetGroupChallenges
+ * @apiGroup Challenge
+ *
+ * @apiParam {groupId} groupId The group _id
+ *
+ * @apiSuccess {Array} challenges An array of challenges
+ */
+api.getGroupChallenges = {
+ method: 'GET',
+ url: '/challenges/groups/:groupId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+
+ let challenges = await Challenge.find({group: groupId})
+ .sort('-official -timestamp')
+ // .populate('leader', nameFields) // Only populate the leader as the group is implicit
+ .exec();
+
+ let resChals = challenges.map(challenge => challenge.toJSON());
+ // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
+ await Q.all(resChals.map((chal, index) => {
+ return User.findById(chal.leader).select(nameFields).exec().then(populatedLeader => {
+ resChals[index].leader = populatedLeader.toJSON({minimize: true});
+ });
+ }));
+
+ res.respond(200, resChals);
+ },
+};
+
+/**
+ * @api {get} /challenges/:challengeId Get a challenge given its id
+ * @apiVersion 3.0.0
+ * @apiName GetChallenge
+ * @apiGroup Challenge
+ *
+ * @apiParam {UUID} challengeId The challenge _id
+ *
+ * @apiSuccess {object} challenge The challenge object
+ */
+api.getChallenge = {
+ method: 'GET',
+ url: '/challenges/:challengeId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let user = res.locals.user;
+ let challengeId = req.params.challengeId;
+
+ let challenge = await Challenge.findById(challengeId)
+ // Don't populate the group as we'll fetch it manually later
+ // .populate('leader', nameFields)
+ .exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+
+ // Fetching basic group data
+ let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true});
+ if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
+
+ let chalRes = challenge.toJSON();
+ chalRes.group = group.toJSON({minimize: true});
+ // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
+ chalRes.leader = (await User.findById(chalRes.leader).select(nameFields).exec()).toJSON({minimize: true});
+
+ res.respond(200, chalRes);
+ },
+};
+
+/**
+ * @api {get} /challenges/:challengeId/export/csv Export a challenge in CSV
+ * @apiVersion 3.0.0
+ * @apiName ExportChallengeCsv
+ * @apiGroup Challenge
+ *
+ * @apiParam {UUID} challengeId The challenge _id
+ *
+ * @apiSuccess {object} challenge The challenge object
+ */
+api.exportChallengeCsv = {
+ method: 'GET',
+ url: '/challenges/:challengeId/export/csv',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let user = res.locals.user;
+ let challengeId = req.params.challengeId;
+
+ let challenge = await Challenge.findById(challengeId).select('_id group leader tasksOrder').exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+ let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
+ if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
+
+ // In v2 this used the aggregation framework to run some computation on MongoDB but then iterated through all
+ // results on the server so the perf difference isn't that big (hopefully)
+
+ let [members, tasks] = await Q.all([
+ User.find({challenges: challengeId})
+ .select(nameFields)
+ .sort({_id: 1})
+ .lean() // so we don't involve mongoose
+ .exec(),
+
+ Tasks.Task.find({'challenge.id': challengeId, userId: {$exists: true}})
+ .sort({userId: 1, text: 1}).select('userId type text value notes').lean().exec(),
+ ]);
+
+ let resArray = members.map(member => [member._id, member.profile.name]);
+
+ // We assume every user in the challenge as at least some data so we can say that members[0] tasks will be at tasks [0]
+ let lastUserId;
+ let index = -1;
+ tasks.forEach(task => {
+ if (task.userId !== lastUserId) {
+ lastUserId = task.userId;
+ index++;
+ }
+
+ resArray[index].push(`${task.type}:${task.text}`, task.value, task.notes);
+ });
+
+ // The first row is going to be UUID name Task Value Notes repeated n times for the n challenge tasks
+ let challengeTasks = _.reduce(challenge.tasksOrder.toObject(), (result, array) => {
+ return result.concat(array);
+ }, []).sort();
+ resArray.unshift(['UUID', 'name']);
+ _.times(challengeTasks.length, () => resArray[0].push('Task', 'Value', 'Notes'));
+
+ res.set({
+ 'Content-Type': 'text/csv',
+ 'Content-disposition': `attachment; filename=${challengeId}.csv`,
+ });
+
+ let csvRes = await csvStringify(resArray);
+ res.status(200).send(csvRes);
+ },
+};
+
+/**
+ * @api {put} /challenges/:challengeId Update a challenge
+ * @apiVersion 3.0.0
+ * @apiName UpdateChallenge
+ * @apiGroup Challenge
+ *
+ * @apiParam {UUID} challengeId The challenge _id
+ *
+ * @apiSuccess {object} challenge The updated challenge object
+ */
+api.updateChallenge = {
+ method: 'PUT',
+ url: '/challenges/:challengeId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let user = res.locals.user;
+ let challengeId = req.params.challengeId;
+
+ let challenge = await Challenge.findById(challengeId).exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+
+ let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true});
+ if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
+ if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderUpdateChal'));
+
+ _.merge(challenge, Challenge.sanitizeUpdate(req.body));
+
+ let savedChal = await challenge.save();
+ let response = savedChal.toJSON();
+ response.group = { // we already have the group data
+ _id: group._id,
+ name: group.name,
+ type: group.type,
+ privacy: group.privacy,
+ };
+ response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true});
+ res.respond(200, response);
+ },
+};
+
+// TODO everything here should be moved to a worker
+// actually even for a worker it's probably just too big and will kill mongo
+async function _closeChal (challenge, broken = {}) {
+ let winner = broken.winner;
+ let brokenReason = broken.broken;
+
+ // Delete the challenge
+ await Challenge.remove({_id: challenge._id}).exec();
+
+ // Refund the leader if the challenge is closed and the group not the tavern
+ if (challenge.group !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') {
+ await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec();
+ }
+
+ // Update the challengeCount on the group
+ await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec();
+
+ // Award prize to winner and notify
+ if (winner) {
+ winner.achievements.challenges.push(challenge.name);
+ winner.balance += challenge.prize / 4;
+ let savedWinner = await winner.save();
+ if (savedWinner.preferences.emailNotifications.wonChallenge !== false) {
+ txnEmail(savedWinner, 'won-challenge', [
+ {name: 'CHALLENGE_NAME', content: challenge.name},
+ ]);
+ }
+
+ pushNotify(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); // TODO translate
+ }
+
+ // Run some operations in the background withouth blocking the thread
+ let backgroundTasks = [
+ // And it's tasks
+ Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(),
+ // Set the challenge tag to non-challenge status and remove the challenge from the user's challenges
+ User.update({
+ challenges: challenge._id,
+ 'tags._id': challenge._id,
+ }, {
+ $set: {'tags.$.challenge': false},
+ $pull: {challenges: challenge._id},
+ }, {multi: true}).exec(),
+ // Break users' tasks
+ Tasks.Task.update({
+ 'challenge.id': challenge._id,
+ }, {
+ $set: {
+ 'challenge.broken': brokenReason,
+ 'challenge.winner': winner && winner.profile.name,
+ },
+ }, {multi: true}).exec(),
+ ];
+
+ Q.allSettled(backgroundTasks); // TODO look if allSettled could be useful somewhere else
+ // TODO catch and handle
+}
+
+/**
+ * @api {delete} /challenges/:challengeId Delete a challenge
+ * @apiVersion 3.0.0
+ * @apiName DeleteChallenge
+ * @apiGroup Challenge
+ *
+ * @apiSuccess {object} empty An empty object
+ */
+api.deleteChallenge = {
+ method: 'DELETE',
+ url: '/challenges/:challengeId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+ if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
+
+ // Close channel in background, some ops are run in the background without `await`ing
+ await _closeChal(challenge, {broken: 'CHALLENGE_DELETED'});
+ res.respond(200, {});
+ },
+};
+
+/**
+ * @api {post} /challenges/:challengeId/selectWinner/:winnerId Select winner for challenge
+ * @apiVersion 3.0.0
+ * @apiName SelectChallengeWinner
+ * @apiGroup Challenge
+ *
+ * @apiSuccess {object} empty An empty object
+ */
+api.selectChallengeWinner = {
+ method: 'POST',
+ url: '/challenges/:challengeId/selectWinner/:winnerId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
+ req.checkParams('winnerId', res.t('winnerIdRequired')).notEmpty().isUUID();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+ if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
+
+ let winner = await User.findOne({_id: req.params.winnerId}).exec();
+ if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.params.winnerId}));
+
+ // Close channel in background, some ops are run in the background without `await`ing
+ await _closeChal(challenge, {broken: 'CHALLENGE_CLOSED', winner});
+ res.respond(200, {});
+ },
+};
+
+export default api;
diff --git a/website/src/controllers/api-v3/chat.js b/website/src/controllers/api-v3/chat.js
new file mode 100644
index 0000000000..079fbb66cd
--- /dev/null
+++ b/website/src/controllers/api-v3/chat.js
@@ -0,0 +1,397 @@
+import { authWithHeaders } from '../../middlewares/api-v3/auth';
+import cron from '../../middlewares/api-v3/cron';
+import { model as Group } from '../../models/group';
+import { model as User } from '../../models/user';
+import {
+ NotFound,
+ NotAuthorized,
+} from '../../libs/api-v3/errors';
+import _ from 'lodash';
+import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
+import { sendTxn } from '../../libs/api-v3/email';
+import nconf from 'nconf';
+
+let api = {};
+
+/**
+ * @api {get} /groups/:groupId/chat Get chat messages from a group
+ * @apiVersion 3.0.0
+ * @apiName GetChat
+ * @apiGroup Chat
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Array} chat An array of chat messages
+ */
+api.getChat = {
+ method: 'GET',
+ url: '/groups/:groupId/chat',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'chat'});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+
+ res.respond(200, Group.toJSONCleanChat(group, user).chat);
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/chat Post chat message to a group
+ * @apiVersion 3.0.0
+ * @apiName PostCat
+ * @apiGroup Chat
+ *
+ * @apiParam {UUID} groupId The group _id
+ * @apiParam {message} message The chat's message
+ * @apiParam {previousMsg} previousMsg The previous chat message which will force a return of the full group chat
+ *
+ * @apiSuccess {Array} chat An array of chat messages
+ */
+api.postChat = {
+ method: 'POST',
+ url: '/groups/:groupId/chat',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+ let chatUpdated;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+ req.checkBody('message', res.t('messageGroupChatBlankMessage')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId});
+
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party' && user.flags.chatRevoked) {
+ throw new NotFound('Your chat privileges have been revoked.');
+ }
+
+ let lastClientMsg = req.query.previousMsg;
+ chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false;
+
+ group.sendChat(req.body.message, user);
+
+ if (group.type === 'party') {
+ user.party.lastMessageSeen = group.chat[0].id;
+ user.save(); // TODO why this is non-blocking? must catch?
+ }
+
+ let savedGroup = await group.save();
+ if (chatUpdated) {
+ res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat});
+ } else {
+ res.respond(200, {message: savedGroup.chat[0]});
+ }
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/chat/:chatId/like Like a group chat message
+ * @apiVersion 3.0.0
+ * @apiName LikeChat
+ * @apiGroup Chat
+ *
+ * @apiParam {groupId} groupId The group _id
+ * @apiParam {chatId} chatId The chat message _id
+ *
+ * @apiSuccess {Array} chat An array of chat messages
+ */
+api.likeChat = {
+ method: 'POST',
+ url: '/groups/:groupId/chat/:chatId/like',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+ req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+
+ let message = _.find(group.chat, {id: req.params.chatId});
+ if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
+ if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage'));
+
+ let update = {$set: {}};
+
+ if (!message.likes) message.likes = {};
+
+ message.likes[user._id] = !message.likes[user._id];
+ update.$set[`chat.$.likes.${user._id}`] = message.likes[user._id];
+
+ await Group.update(
+ {_id: group._id, 'chat.id': message.id},
+ update
+ );
+ res.respond(200, message); // TODO what if the message is flagged and shouldn't be returned?
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/chat/:chatId/like Like a group chat message
+ * @apiVersion 3.0.0
+ * @apiName LikeChat
+ * @apiGroup Chat
+ *
+ * @apiParam {groupId} groupId The group _id
+ * @apiParam {chatId} chatId The chat message _id
+ *
+ * @apiSuccess {Array} chat An array of chat messages
+ */
+api.flagChat = {
+ method: 'POST',
+ url: '/groups/:groupId/chat/:chatId/flag',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+ req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ let message = _.find(group.chat, {id: req.params.chatId});
+
+ if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
+
+ if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatFlagOwnMessage'));
+
+ let author = await User.findOne({_id: message.uuid}, {auth: 1});
+
+ let update = {$set: {}};
+
+ // Log user ids that have flagged the message
+ if (!message.flags) message.flags = {};
+ if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported'));
+ message.flags[user._id] = true;
+ update.$set[`chat.$.flags.${user._id}`] = true;
+
+ // Log total number of flags (publicly viewable)
+ if (!message.flagCount) message.flagCount = 0;
+ if (user.contributor.admin) {
+ // Arbitraty amount, higher than 2
+ message.flagCount = 5;
+ } else {
+ message.flagCount++;
+ }
+ update.$set['chat.$.flagCount'] = message.flagCount;
+
+ await Group.update(
+ {_id: group._id, 'chat.id': message.id},
+ update
+ );
+
+ let addressesToSendTo = nconf.get('FLAG_REPORT_EMAIL');
+ addressesToSendTo = typeof addressesToSendTo === 'string' ? JSON.parse(addressesToSendTo) : addressesToSendTo;
+
+ if (Array.isArray(addressesToSendTo)) {
+ addressesToSendTo = addressesToSendTo.map((email) => {
+ return {email, canSend: true};
+ });
+ } else {
+ addressesToSendTo = {email: addressesToSendTo};
+ }
+
+ let reporterEmailContent;
+ if (user.auth.local) {
+ reporterEmailContent = user.auth.local.email;
+ } else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) {
+ reporterEmailContent = user.auth.facebook.emails[0].value;
+ }
+
+ let authorEmailContent;
+ if (author.auth.local) {
+ authorEmailContent = author.auth.local.email;
+ } else if (author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) {
+ authorEmailContent = author.auth.facebook.emails[0].value;
+ }
+
+ let groupUrl;
+ if (group._id === 'habitrpg') {
+ groupUrl = '/#/options/groups/tavern';
+ } else if (group.type === 'guild') {
+ groupUrl = `/#/options/groups/guilds/{$group._id}`;
+ } else {
+ groupUrl = 'party';
+ }
+
+ sendTxn(addressesToSendTo, 'flag-report-to-mods', [
+ {name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()},
+ {name: 'MESSAGE_TEXT', content: message.text},
+
+ {name: 'REPORTER_USERNAME', content: user.profile.name},
+ {name: 'REPORTER_UUID', content: user._id},
+ {name: 'REPORTER_EMAIL', content: reporterEmailContent},
+ {name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId={$user._id}`},
+
+ {name: 'AUTHOR_USERNAME', content: message.user},
+ {name: 'AUTHOR_UUID', content: message.uuid},
+ {name: 'AUTHOR_EMAIL', content: authorEmailContent},
+ {name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId={$message.uuid}`},
+
+ {name: 'GROUP_NAME', content: group.name},
+ {name: 'GROUP_TYPE', content: group.type},
+ {name: 'GROUP_ID', content: group._id},
+ {name: 'GROUP_URL', content: groupUrl},
+ ]);
+
+ res.respond(200, message);
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/chat/:chatId/clear-flags Clear a group chat message's flags
+ * @apiVersion 3.0.0
+ * @apiName ClearFlags
+ * @apiGroup Chat
+ *
+ * @apiParam {groupId} groupId The group _id
+ * @apiParam {chatId} chatId The chat message _id
+ *
+ * @apiSuccess {Object} An empty object
+ */
+api.clearChatFlags = {
+ method: 'Post',
+ url: '/groups/:groupId/chat/:chatId/clearflags',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+ let chatId = req.params.chatId;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+ req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ if (!user.contributor.admin) {
+ throw new NotAuthorized(res.t('messageGroupChatAdminClearFlagCount'));
+ }
+
+ let group = await Group.getGroup({user, groupId});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+
+ let message = _.find(group.chat, {id: chatId});
+ if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
+
+ message.flagCount = 0;
+
+ await Group.update(
+ {_id: group._id, 'chat.id': message.id},
+ {$set: {'chat.$.flagCount': message.flagCount}}
+ );
+
+ res.respond(200, {});
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/chat/:chatId/seen Seen a group chat message
+ * @apiVersion 3.0.0
+ * @apiName SeenChat
+ * @apiGroup Chat
+ *
+ * @apiParam {groupId} groupId The group _id
+ *
+ * @apiSuccess {None}
+ */
+api.seenChat = {
+ method: 'POST',
+ url: '/groups/:groupId/chat/seen',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+
+ let update = {$unset: {}};
+ update.$unset[`newMessages.${groupId}`] = true;
+
+ await User.update({_id: user._id}, update).exec();
+ res.respond(200);
+ },
+};
+
+/**
+ * @api {delete} /groups/:groupId/chat/:chatId Delete chat message from a group
+ * @apiVersion 3.0.0
+ * @apiName DeleteChat
+ * @apiGroup Chat
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ * @apiParam {string} chatId The chat _id
+ *
+ * @apiSuccess {Array} The update chat array
+ * @apiSuccess {Object} An empty object when the previous message was deleted
+ */
+api.deleteChat = {
+ method: 'DELETE',
+ url: '/groups/:groupId/chat/:chatId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+ let chatId = req.params.chatId;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+ req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId, fields: 'chat'});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+
+ let message = _.find(group.chat, {id: chatId});
+ if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
+
+ if (user._id !== message.uuid && !user.contributor.admin) {
+ throw new NotAuthorized(res.t('onlyCreatorOrAdminCanDeleteChat'));
+ }
+
+ let lastClientMsg = req.query.previousMsg;
+ let chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false;
+
+ await Group.update(
+ {_id: group._id},
+ {$pull: {chat: {id: chatId}}}
+ );
+
+ if (chatUpdated) {
+ let chatRes = Group.toJSONCleanChat(group, user).chat;
+ removeFromArray(chatRes, {id: chatId});
+ res.respond(200, chatRes);
+ } else {
+ res.respond(200, {});
+ }
+ },
+};
+
+export default api;
diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js
new file mode 100644
index 0000000000..d4f19ea2ec
--- /dev/null
+++ b/website/src/controllers/api-v3/groups.js
@@ -0,0 +1,641 @@
+import { authWithHeaders } from '../../middlewares/api-v3/auth';
+import Q from 'q';
+import _ from 'lodash';
+import cron from '../../middlewares/api-v3/cron';
+import {
+ INVITES_LIMIT,
+ model as Group,
+ basicFields as basicGroupFields,
+} from '../../models/group';
+import {
+ model as User,
+ nameFields,
+} from '../../models/user';
+import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
+import {
+ NotFound,
+ BadRequest,
+ NotAuthorized,
+} from '../../libs/api-v3/errors';
+import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
+import * as firebase from '../../libs/api-v3/firebase';
+import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email';
+import { encrypt } from '../../libs/api-v3/encryption';
+
+let api = {};
+
+// TODO shall we accept party as groupId in all routes?
+
+/**
+ * @api {post} /groups Create group
+ * @apiVersion 3.0.0
+ * @apiName CreateGroup
+ * @apiGroup Group
+ *
+ * @apiSuccess {Object} group The group object
+ */
+api.createGroup = {
+ method: 'POST',
+ url: '/groups',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let group = new Group(Group.sanitize(req.body)); // TODO validate empty req.body
+ group.leader = user._id;
+
+ if (group.type === 'guild') {
+ if (user.balance < 1) throw new NotAuthorized(res.t('messageInsufficientGems'));
+
+ group.balance = 1;
+
+ user.balance--;
+ user.guilds.push(group._id);
+ } else {
+ if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate'));
+ if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty'));
+
+ user.party._id = group._id;
+ }
+
+ let results = await Q.all([user.save(), group.save()]);
+ let savedGroup = results[1];
+
+ // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
+ // await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise
+ let response = savedGroup.toJSON();
+ // the leader is the authenticated user
+ response.leader = {
+ _id: user._id,
+ profile: {name: user.profile.name},
+ };
+ res.respond(201, response); // do not remove chat flags data as we've just created the group
+
+ firebase.updateGroupData(savedGroup);
+ firebase.addUserToGroup(savedGroup._id, user._id);
+ },
+};
+
+/**
+ * @api {get} /groups Get groups
+ * @apiVersion 3.0.0
+ * @apiName GetGroups
+ * @apiGroup Group
+ *
+ * @apiParam {string} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, privateGuilds, publicGuilds, tavern
+ *
+ * @apiSuccess {Array} groups An array of the requested groups
+ */
+api.getGroups = {
+ method: 'GET',
+ url: '/groups',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkQuery('type', res.t('groupTypesRequired')).notEmpty(); // TODO better validation
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ // TODO validate types are acceptable? probably not necessary
+ let types = req.query.type.split(',');
+ let groupFields = basicGroupFields.concat('description memberCount balance');
+ let sort = '-memberCount';
+ let queries = [];
+
+ types.forEach(type => {
+ switch (type) {
+ case 'party':
+ queries.push(Group.getGroup({user, groupId: 'party', fields: groupFields}));
+ break;
+ case 'privateGuilds':
+ queries.push(Group.find({
+ type: 'guild',
+ privacy: 'private',
+ _id: {$in: user.guilds},
+ }).select(groupFields).sort(sort).exec());
+ break;
+ case 'publicGuilds':
+ queries.push(Group.find({
+ type: 'guild',
+ privacy: 'public',
+ }).select(groupFields).sort(sort).exec()); // TODO use lean?
+ break;
+ case 'tavern':
+ if (types.indexOf('publicGuilds') === -1) {
+ queries.push(Group.getGroup({user, groupId: 'habitrpg', fields: groupFields}));
+ }
+ break;
+ }
+ });
+
+ // If no valid value for type was supplied, return an error
+ if (queries.length === 0) throw new BadRequest(res.t('groupTypesRequired'));
+
+ // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328
+ let results = _.reduce(await Q.all(queries), (previousValue, currentValue) => {
+ if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array
+ return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue
+ }, []);
+
+ res.respond(200, results);
+ },
+};
+
+/**
+ * @api {get} /groups/:groupId Get group
+ * @apiVersion 3.0.0
+ * @apiName GetGroup
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} group The group object
+ */
+api.getGroup = {
+ method: 'GET',
+ url: '/groups/:groupId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: false});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+
+ group = Group.toJSONCleanChat(group, user);
+ // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
+ group.leader = (await User.findById(group.leader).select(nameFields).exec()).toJSON({minimize: true});
+
+ res.respond(200, group);
+ },
+};
+
+/**
+ * @api {put} /groups/:groupId Update group
+ * @apiVersion 3.0.0
+ * @apiName UpdateGroup
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} group The updated group object
+ */
+api.updateGroup = {
+ method: 'PUT',
+ url: '/groups/:groupId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+
+ if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
+
+ _.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body)));
+
+ let savedGroup = await group.save();
+ let response = Group.toJSONCleanChat(savedGroup, user);
+ // If the leader changed fetch new data, otherwise use authenticated user
+ if (response.leader !== user._id) {
+ response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true});
+ } else {
+ response.leader = {
+ _id: user._id,
+ profile: {name: user.profile.name},
+ };
+ }
+ res.respond(200, response);
+
+ firebase.updateGroupData(savedGroup);
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/join Join a group
+ * @apiVersion 3.0.0
+ * @apiName JoinGroup
+ * @apiGroup Group
+ *
+ * @apiParam {UUID} groupId The group _id
+ *
+ * @apiSuccess {Object} group The group
+ */
+api.joinGroup = {
+ method: 'POST',
+ url: '/groups/:groupId/join',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let inviter;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ // Works even if the user is not yet a member of the group
+ let group = await Group.getGroup({user, groupId: req.params.groupId, optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+
+ let isUserInvited = false;
+
+ if (group.type === 'party' && group._id === (user.invitations.party && user.invitations.party.id)) {
+ inviter = user.invitations.party.inviter;
+ user.invitations.party = {}; // Clear invite TODO mark modified?
+
+ // invite new user to pending quest
+ if (group.quest.key && !group.quest.active) {
+ user.party.quest.RSVPNeeded = true;
+ user.party.quest.key = group.quest.key;
+ user.party.quest.progress = undefined; // Make sure to reset progress from ay previous quest
+ group.quest.members[user._id] = undefined;
+ group.markModified('quest.members');
+ }
+
+ // If user was in a different party (when partying solo you can be invited to a new party)
+ // make him leave that party before doing anything
+ if (user.party._id) {
+ let userPreviousParty = await Group.getGroup({user, groupId: user.party._id});
+ if (userPreviousParty) await userPreviousParty.leave(user);
+ }
+
+ user.party._id = group._id; // Set group as user's party
+
+ isUserInvited = true;
+ } else if (group.type === 'guild') {
+ let hasInvitation = removeFromArray(user.invitations.guilds, { id: group._id });
+
+ if (hasInvitation) {
+ isUserInvited = true;
+ } else {
+ isUserInvited = group.privacy === 'private' ? false : true;
+ }
+ }
+
+ if (isUserInvited && group.type === 'guild') user.guilds.push(group._id); // Add group to user's guilds
+ if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite'));
+
+ if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader
+
+ group.memberCount += 1;
+
+ let promises = [group.save(), user.save()];
+
+ if (group.type === 'party' && inviter) {
+ promises.push(User.update({_id: inviter}, {$inc: {'items.quests.basilist': 1}}).exec()); // Reward inviter
+ }
+
+ await Q.all(promises);
+
+ let response = Group.toJSONCleanChat(promises[0], user);
+ response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true});
+ res.respond(200, response);
+
+ firebase.addUserToGroup(group._id, user._id);
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/leave Leave a group
+ * @apiVersion 3.0.0
+ * @apiName LeaveGroup
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ * @apiParam {string="remove-all","keep-all"} keep Wheter to keep or not challenges' tasks, as an optional query string
+ *
+ * @apiSuccess {Object} empty An empty object
+ */
+api.leaveGroup = {
+ method: 'POST',
+ url: '/groups/:groupId/leave',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+ // When removing the user from challenges, should we keep the tasks?
+ req.checkQuery('keep', res.t('keepOrRemoveAll')).optional().isIn(['keep-all', 'remove-all']);
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+
+ // During quests, checke wheter user can leave
+ if (group.type === 'party') {
+ if (group.quest && group.quest.leader === user._id) {
+ throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup'));
+ }
+
+ if (group.quest && group.quest.active && group.quest.members && group.quest.members[user._id]) {
+ throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest'));
+ }
+ }
+
+ await group.leave(user, req.query.keep);
+ res.respond(200, {});
+ },
+};
+
+// Send an email to the removed user with an optional message from the leader
+function _sendMessageToRemoved (group, removedUser, message) {
+ if (removedUser.preferences.emailNotifications.kickedGroup !== false) {
+ sendTxnEmail(removedUser, `kicked-from-${group.type}`, [
+ {name: 'GROUP_NAME', content: group.name},
+ {name: 'MESSAGE', content: message},
+ {name: 'GUILDS_LINK', content: '/#/options/groups/guilds/public'},
+ {name: 'PARTY_WANTED_GUILD', content: '/#/options/groups/guilds/f2db2a7f-13c5-454d-b3ee-ea1f5089e601'},
+ ]);
+ }
+}
+
+/**
+ * @api {post} /groups/:groupId/removeMember/:memberId Remove a member from a group
+ * @apiVersion 3.0.0
+ * @apiName RemoveGroupMember
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ * @apiParam {UUID} memberId The _id of the member to remove
+ * @apiParam {string} message The message to send to the removed members, as a query string // TODO in req.body?
+ *
+ * @apiSuccess {Object} empty An empty object
+ */
+api.removeGroupMember = {
+ method: 'POST',
+ url: '/groups/:groupId/removeMember/:memberId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+ req.checkParams('memberId', res.t('userIdRequired')).notEmpty().isUUID();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+
+ let uuid = req.params.memberId;
+
+ if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember'));
+ if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself'));
+
+ let member = await User.findOne({_id: uuid}).select('party guilds invitations newMessages').exec();
+
+ // We're removing the user from a guild or a party? is the user invited only?
+ let isInGroup;
+ if (member.party._id === group._id) {
+ isInGroup = 'party';
+ } else if (member.guilds.indexOf(group._id) !== -1) {
+ isInGroup = 'guild';
+ }
+
+ let isInvited;
+ if (member.invitations.party && member.invitations.party.id === group._id) {
+ isInvited = 'party';
+ } else if (_.findIndex(member.invitations.guilds, {id: group._id}) !== -1) {
+ isInvited = 'guild';
+ }
+
+ if (isInGroup) {
+ group.memberCount -= 1;
+
+ if (group.quest && group.quest.leader === member._id) {
+ group.quest.key = undefined;
+ group.quest.leader = undefined;
+ } else if (group.quest && group.quest.members) {
+ // remove member from quest
+ group.quest.members[member._id] = undefined; // TODO remmeber to check these are mark modified everywhere
+ group.markModified('quest.members');
+ }
+
+ if (isInGroup === 'guild') {
+ removeFromArray(member.guilds, group._id);
+ }
+ if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too?
+
+ if (member.newMessages[group._id]) {
+ member.newMessages[group._id] = undefined;
+ member.markModified('newMessages');
+ }
+
+ if (group.quest && group.quest.active && group.quest.leader === member._id) {
+ member.items.quests[group.quest.key] += 1; // TODO why this?
+ }
+ } else if (isInvited) {
+ if (isInvited === 'guild') {
+ removeFromArray(member.invitations.guilds, { id: group._id });
+ }
+ if (isInvited === 'party') user.invitations.party = {}; // TODO mark modified?
+ } else {
+ throw new NotFound(res.t('groupMemberNotFound'));
+ }
+
+ let message = req.query.message;
+ if (message) _sendMessageToRemoved(group, member, message);
+
+ await Q.all([
+ member.save(),
+ group.save(),
+ ]);
+ res.respond(200, {});
+ },
+};
+
+async function _inviteByUUID (uuid, group, inviter, req, res) {
+ // TODO: Add Push Notifications
+ let userToInvite = await User.findById(uuid).exec();
+
+ if (!userToInvite) {
+ throw new NotFound(res.t('userWithIDNotFound', {userId: uuid}));
+ }
+
+ if (group.type === 'guild') {
+ if (_.contains(userToInvite.guilds, group._id)) {
+ throw new NotAuthorized(res.t('userAlreadyInGroup'));
+ }
+ if (_.find(userToInvite.invitations.guilds, {id: group._id})) {
+ throw new NotAuthorized(res.t('userAlreadyInvitedToGroup'));
+ }
+ userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: inviter._id});
+ } else if (group.type === 'party') {
+ if (!_.isEmpty(userToInvite.invitations.party)) {
+ throw new NotAuthorized(res.t('userAlreadyPendingInvitation'));
+ }
+
+ if (userToInvite.party._id) {
+ let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'});
+
+ // Allow user to be invited to a new party when they're partying solo
+ if (userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty'));
+ }
+
+ userToInvite.invitations.party = {id: group._id, name: group.name, inviter: inviter._id};
+ }
+
+ let groupLabel = group.type === 'guild' ? 'Guild' : 'Party';
+ if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) {
+ let emailVars = [
+ {name: 'INVITER', content: inviter.profile.name},
+ {name: 'REPLY_TO_ADDRESS', content: inviter.email},
+ ];
+
+ if (group.type === 'guild') {
+ emailVars.push(
+ {name: 'GUILD_NAME', content: group.name},
+ {name: 'GUILD_URL', content: '/#/options/groups/guilds/public'},
+ );
+ } else {
+ emailVars.push(
+ {name: 'PARTY_NAME', content: group.name},
+ {name: 'PARTY_URL', content: '/#/options/groups/party'},
+ );
+ }
+
+ sendTxnEmail(userToInvite, `invited-${groupLabel}`, emailVars);
+ }
+
+ let userInvited = await userToInvite.save();
+ if (group.type === 'guild') {
+ return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1];
+ } else if (group.type === 'party') {
+ return userInvited.invitations.party;
+ }
+}
+
+async function _inviteByEmail (invite, group, inviter, req, res) {
+ let userReturnInfo;
+
+ if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail'));
+
+ let userToContact = await User.findOne({$or: [
+ {'auth.local.email': invite.email},
+ {'auth.facebook.emails.value': invite.email},
+ ]})
+ .select({_id: true, 'preferences.emailNotifications': true})
+ .exec();
+
+ if (userToContact) {
+ userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res);
+ } else {
+ userReturnInfo = invite.email;
+ // yeah, it supports guild too but for backward compatibility we'll use partyInvite as query
+ // TODO absolutely refactor this horrible code
+ const partyQueryString = JSON.stringify({id: group._id, inviter, name: group.name});
+ const encryptedPartyqueryString = encrypt(partyQueryString);
+ let link = `?partyInvite=${encryptedPartyqueryString}`;
+
+ let variables = [
+ {name: 'LINK', content: link},
+ {name: 'INVITER', content: inviter || inviter.profile.name},
+ {name: 'REPLY_TO_ADDRESS', content: inviter.email},
+ ];
+
+ if (group.type === 'guild') {
+ variables.push({name: 'GUILD_NAME', content: group.name});
+ }
+
+ // TODO implement "users can only be invited once"
+ // Check for the email address not to be unsubscribed
+ let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec();
+ let groupLabel = group.type === 'guild' ? '-guild' : '';
+ if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables);
+ }
+
+ return userReturnInfo;
+}
+
+/**
+ * @api {post} /groups/:groupId/invite Invite users to a group using their UUIDs or email addresses
+ * @apiVersion 3.0.0
+ * @apiName InviteToGroup
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiParam {array} emails An array of emails addresses to invite (optional) (inside body)
+ * @apiParam {array} uuids An array of uuids to invite (optional) (inside body)
+ * @apiParam {string} inviter The inviters' name (optional) (inside body)
+ *
+ * @apiSuccess {Object} empty An empty object
+ */
+api.inviteToGroup = {
+ method: 'POST',
+ url: '/groups/:groupId/invite',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat TODO other fields too?
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+
+ let uuids = req.body.uuids;
+ let emails = req.body.emails;
+
+ let uuidsIsArray = Array.isArray(uuids);
+ let emailsIsArray = Array.isArray(emails);
+
+ if (!uuids && !emails) {
+ throw new BadRequest(res.t('canOnlyInviteEmailUuid'));
+ }
+
+ let results = [];
+ let totalInvites = 0;
+
+ if (uuids) {
+ if (!uuidsIsArray) {
+ throw new BadRequest(res.t('uuidsMustBeAnArray'));
+ } else {
+ totalInvites += uuids.length;
+ }
+ }
+
+ if (emails) {
+ if (!emailsIsArray) {
+ throw new BadRequest(res.t('emailsMustBeAnArray'));
+ } else {
+ totalInvites += emails.length;
+ }
+ }
+
+ if (totalInvites > INVITES_LIMIT) {
+ throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}));
+ }
+
+ if (uuids) {
+ let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res));
+ let uuidResults = await Q.all(uuidInvites);
+ results.push(...uuidResults);
+ }
+
+ if (emails) {
+ let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res));
+ let emailResults = await Q.all(emailInvites);
+ results.push(...emailResults);
+ }
+
+ res.respond(200, results);
+ },
+};
+
+export default api;
diff --git a/website/src/controllers/api-v3/members.js b/website/src/controllers/api-v3/members.js
new file mode 100644
index 0000000000..de2213d3b6
--- /dev/null
+++ b/website/src/controllers/api-v3/members.js
@@ -0,0 +1,234 @@
+import { authWithHeaders } from '../../middlewares/api-v3/auth';
+import cron from '../../middlewares/api-v3/cron';
+import {
+ model as User,
+ publicFields as memberFields,
+ nameFields,
+} from '../../models/user';
+import { model as Group } from '../../models/group';
+import { model as Challenge } from '../../models/challenge';
+import {
+ NotFound,
+} from '../../libs/api-v3/errors';
+import * as Tasks from '../../models/task';
+
+let api = {};
+
+/**
+ * @api {get} /members/:memberId Get a member profile
+ * @apiVersion 3.0.0
+ * @apiName GetMember
+ * @apiGroup Member
+ *
+ * @apiParam {UUID} memberId The member's id
+ *
+ * @apiSuccess {object} member The member object
+ */
+api.getMember = {
+ method: 'GET',
+ url: '/members/:memberId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let memberId = req.params.memberId;
+
+ let member = await User
+ .findById(memberId)
+ .select(memberFields)
+ .exec();
+
+ if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
+
+ // manually call toJSON with minimize: true so empty paths aren't returned
+ res.respond(200, member.toJSON({minimize: true}));
+ },
+};
+
+// Return a request handler for getMembersForGroup / getInvitesForGroup / getMembersForChallenge
+// type is `invites` or `members`
+function _getMembersForItem (type) {
+ if (['group-members', 'group-invites', 'challenge-members'].indexOf(type) === -1) {
+ throw new Error('Type must be one of "group-members", "group-invites", "challenge-members"');
+ }
+
+ return async function handleGetMembersForItem (req, res) {
+ if (type === 'challenge-members') {
+ req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
+ } else {
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+ }
+ req.checkQuery('lastId').optional().notEmpty().isUUID();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let groupId = req.params.groupId;
+ let challengeId = req.params.challengeId;
+ let lastId = req.query.lastId;
+ let user = res.locals.user;
+ let challenge;
+ let group;
+
+ if (type === 'challenge-members') {
+ challenge = await Challenge.findById(challengeId).select('_id type leader group').exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+
+ // optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge
+ // for example if you've been booted from it, are the leader or a site admin
+ group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
+ if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
+ } else {
+ group = await Group.getGroup({user, groupId, fields: '_id type'});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ }
+
+ let query = {};
+ let fields = nameFields;
+
+ if (type === 'challenge-members') {
+ query.challenges = challenge._id;
+ } else if (type === 'group-members') {
+ if (group.type === 'guild') {
+ query.guilds = group._id;
+ } else {
+ query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party'
+
+ if (req.query.includeAllPublicFields === 'true') {
+ fields = memberFields;
+ }
+ }
+ } else if (type === 'group-invites') {
+ if (group.type === 'guild') { // eslint-disable-line no-lonely-if
+ query['invitations.guilds.id'] = group._id;
+ } else {
+ query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party'
+ }
+ }
+
+ if (lastId) query._id = {$gt: lastId};
+
+ let members = await User
+ .find(query)
+ .sort({_id: 1})
+ .limit(30)
+ .select(fields)
+ .exec();
+
+ // manually call toJSON with minimize: true so empty paths aren't returned
+ res.respond(200, members.map(member => member.toJSON({minimize: true})));
+ };
+}
+
+/**
+ * @api {get} /groups/:groupId/members Get members for a group with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results.
+ * @apiVersion 3.0.0
+ * @apiName GetMembersForGroup
+ * @apiGroup Member
+ *
+ * @apiParam {UUID} groupId The group id
+ * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results
+ * @apiParam {boolean} includeAllPublicFields Query parameter avalaible only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member)
+ *
+ * @apiSuccess {array} members An array of members, sorted by _id
+ */
+api.getMembersForGroup = {
+ method: 'GET',
+ url: '/groups/:groupId/members',
+ middlewares: [authWithHeaders(), cron],
+ handler: _getMembersForItem('group-members'),
+};
+
+/**
+ * @api {get} /groups/:groupId/invites Get invites for a group with a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results.
+ * @apiVersion 3.0.0
+ * @apiName GetInvitesForGroup
+ * @apiGroup Member
+ *
+ * @apiParam {UUID} groupId The group id
+ * @apiParam {UUID} lastId Query parameter to specify the last invite returned in a previous request to this route and get the next batch of results
+ *
+ * @apiSuccess {array} invites An array of invites, sorted by _id
+ */
+api.getInvitesForGroup = {
+ method: 'GET',
+ url: '/groups/:groupId/invites',
+ middlewares: [authWithHeaders(), cron],
+ handler: _getMembersForItem('group-invites'),
+};
+
+/**
+ * @api {get} /challenges/:challengeId/members Get members for a challenge with a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results.
+ * @apiVersion 3.0.0
+ * @apiName GetMembersForChallenge
+ * @apiGroup Member
+ *
+ * @apiParam {UUID} challengeId The challenge id
+ * @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results
+ *
+ * @apiSuccess {array} members An array of members, sorted by _id
+ */
+api.getMembersForChallenge = {
+ method: 'GET',
+ url: '/challenges/:challengeId/members',
+ middlewares: [authWithHeaders(), cron],
+ handler: _getMembersForItem('challenge-members'),
+};
+
+/**
+ * @api {get} /challenges/:challengeId/members/:memberId Get a challenge member progress
+ * @apiVersion 3.0.0
+ * @apiName GetChallenge
+ * @apiGroup Challenge
+ *
+ * @apiParam {UUID} challengeId The challenge _id
+ * @apiParam {UUID} member The member _id
+ *
+ * @apiSuccess {object} member Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member
+ */
+api.getChallengeMemberProgress = {
+ method: 'GET',
+ url: '/challenges/:challengeId/members/:memberId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
+ req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let user = res.locals.user;
+ let challengeId = req.params.challengeId;
+ let memberId = req.params.memberId;
+
+ let member = await User.findById(memberId).select(`${nameFields} challenges`).exec();
+ if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
+
+ let challenge = await Challenge.findById(challengeId).exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+
+ // optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge
+ // for example if you've been booted from it, are the leader or a site admin
+ let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
+ if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
+ if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound'));
+
+ let chalTasks = await Tasks.Task.find({
+ userId: memberId,
+ 'challenge.id': challengeId,
+ })
+ .select('-tags') // We don't want to return the tags publicly TODO same for other data?
+ .exec();
+
+ // manually call toJSON with minimize: true so empty paths aren't returned
+ let response = member.toJSON({minimize: true});
+ delete response.challenges;
+ response.tasks = chalTasks.map(chalTask => chalTask.toJSON({minimize: true}));
+ res.respond(200, response);
+ },
+};
+
+export default api;
diff --git a/website/src/controllers/api-v3/quests.js b/website/src/controllers/api-v3/quests.js
new file mode 100644
index 0000000000..e0ad3b86d4
--- /dev/null
+++ b/website/src/controllers/api-v3/quests.js
@@ -0,0 +1,443 @@
+import _ from 'lodash';
+import Q from 'q';
+import { authWithHeaders } from '../../middlewares/api-v3/auth';
+import cron from '../../middlewares/api-v3/cron';
+import analytics from '../../libs/api-v3/analyticsService';
+import {
+ model as Group,
+} from '../../models/group';
+import { model as User } from '../../models/user';
+import {
+ NotFound,
+ NotAuthorized,
+ BadRequest,
+} from '../../libs/api-v3/errors';
+import {
+ getUserInfo,
+ sendTxn as sendTxnEmail,
+} from '../../libs/api-v3/email';
+import { quests as questScrolls } from '../../../../common/script/content';
+
+function canStartQuestAutomatically (group) {
+ // If all members are either true (accepted) or false (rejected) return true
+ // If any member is null/undefined (undecided) return false
+ return _.every(group.quest.members, _.isBoolean);
+}
+
+let api = {};
+
+/**
+ * @api {post} /groups/:groupId/quests/invite Invite users to a quest
+ * @apiVersion 3.0.0
+ * @apiName InviteToQuest
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.inviteToQuest = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/invite/:questKey',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let questKey = req.params.questKey;
+ let quest = questScrolls[questKey];
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'});
+
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey }));
+ if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned'));
+ if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl }));
+ if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway'));
+
+ let members = await User.find({
+ 'party._id': group._id,
+ _id: {$ne: user._id},
+ }).select('auth.facebook auth.local preferences.emailNotifications profile.name')
+ .exec();
+
+ group.markModified('quest');
+ group.quest.key = questKey;
+ group.quest.leader = user._id;
+ group.quest.members = {};
+ group.quest.members[user._id] = true;
+
+ user.party.quest.RSVPNeeded = false;
+ user.party.quest.key = questKey;
+
+ await User.update({
+ 'party._id': group._id,
+ _id: {$ne: user._id},
+ }, {
+ $set: {
+ 'party.quest.RSVPNeeded': true,
+ 'party.quest.key': questKey,
+ },
+ }, {multi: true}).exec();
+
+ _.each(members, (member) => {
+ group.quest.members[member._id] = null;
+ });
+
+ if (canStartQuestAutomatically(group)) {
+ await group.startQuest(user);
+ }
+
+ let [savedGroup] = await Q.all([
+ group.save(),
+ user.save(),
+ ]);
+
+ res.respond(200, savedGroup.quest);
+
+ // send out invites
+ let inviterVars = getUserInfo(user, ['name', 'email']);
+ let membersToEmail = members.filter(member => {
+ return member.preferences.emailNotifications.invitedQuest !== false;
+ });
+ sendTxnEmail(membersToEmail, `invite-${quest.boss ? 'boss' : 'collection'}-quest`, [
+ {name: 'QUEST_NAME', content: quest.text()},
+ {name: 'INVITER', content: inviterVars.name},
+ {name: 'REPLY_TO_ADDRESS', content: inviterVars.email},
+ {name: 'PARTY_URL', content: '/#/options/groups/party'},
+ ]);
+
+ // track that the inviting user has accepted the quest
+ analytics.track('quest', {
+ category: 'behavior',
+ owner: true,
+ response: 'accept',
+ gaLabel: 'accept',
+ questName: questKey,
+ uuid: user._id,
+ });
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/quests/accept Accept a pending quest
+ * @apiVersion 3.0.0
+ * @apiName AcceptQuest
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.acceptQuest = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/accept',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'});
+
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!group.quest.key) throw new NotFound(res.t('questInviteNotFound'));
+ if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway'));
+ if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted'));
+
+ group.markModified('quest');
+ group.quest.members[user._id] = true;
+ user.party.quest.RSVPNeeded = false;
+
+ if (canStartQuestAutomatically(group)) {
+ await group.startQuest(user);
+ }
+
+ let [savedGroup] = await Q.all([
+ group.save(),
+ user.save(),
+ ]);
+
+ res.respond(200, savedGroup.quest);
+
+ // track that an user has accepted the quest
+ analytics.track('quest', {
+ category: 'behavior',
+ owner: false,
+ response: 'accept',
+ gaLabel: 'accept',
+ questName: group.quest.key,
+ uuid: user._id,
+ });
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/quests/reject Reject a quest
+ * @apiVersion 3.0.0
+ * @apiName RejectQuest
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.rejectQuest = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/reject',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist'));
+ if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway'));
+ if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted'));
+ if (group.quest.members[user._id] === false) throw new BadRequest(res.t('questAlreadyRejected'));
+
+ group.quest.members[user._id] = false;
+ group.markModified('quest.members');
+
+ user.party.quest = Group.cleanQuestProgress();
+ user.markModified('party.quest');
+
+ if (canStartQuestAutomatically(group)) {
+ await group.startQuest(user);
+ }
+
+ let [savedGroup] = await Q.all([
+ group.save(),
+ user.save(),
+ ]);
+
+ res.respond(200, savedGroup.quest);
+
+ analytics.track('quest', {
+ category: 'behavior',
+ owner: false,
+ response: 'reject',
+ gaLabel: 'reject',
+ questName: group.quest.key,
+ uuid: user._id,
+ });
+ },
+};
+
+
+/**
+ * @api {post} /groups/:groupId/quests/force-start Accept a pending quest
+ * @apiVersion 3.0.0
+ * @apiName forceStart
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.forceStart = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/force-start',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest leader'});
+
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!group.quest.key) throw new NotFound(res.t('questNotPending'));
+ if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway'));
+ if (!(user._id === group.quest.leader || user._id === group.leader)) throw new NotAuthorized(res.t('questOrGroupLeaderOnlyStartQuest'));
+
+ group.markModified('quest');
+
+ await group.startQuest(user);
+
+ let [savedGroup] = await Q.all([
+ group.save(),
+ user.save(),
+ ]);
+
+ res.respond(200, savedGroup.quest);
+
+ analytics.track('quest', {
+ category: 'behavior',
+ owner: user._id === group.quest.leader,
+ response: 'force-start',
+ gaLabel: 'force-start',
+ questName: group.quest.key,
+ uuid: user._id,
+ });
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/quests/cancel Cancels a quest
+ * @apiVersion 3.0.0
+ * @apiName CancelQuest
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.cancelQuest = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/cancel',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ // Cancel a quest BEFORE it has begun (i.e., in the invitation stage)
+ // Quest scroll has not yet left quest owner's inventory so no need to return it.
+ // Do not wipe quest progress for members because they'll want it to be applied to the next quest that's started.
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId, fields: 'type leader quest'});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist'));
+ if (user._id !== group.leader && group.quest.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCancelQuest'));
+ if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest'));
+
+ group.quest = Group.cleanGroupQuest();
+ group.markModified('quest');
+
+ let [savedGroup] = await Promise.all([
+ group.save(),
+ User.update(
+ {'party._id': groupId},
+ {$set: {'party.quest': Group.cleanQuestProgress()}},
+ {multi: true}
+ ),
+ ]);
+
+ res.respond(200, savedGroup.quest);
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/quests/abort Abort the current quest
+ * @apiVersion 3.0.0
+ * @apiName AbortQuest
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.abortQuest = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/abort',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ // Abort a quest AFTER it has begun (see questCancel for BEFORE)
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId, fields: 'type quest leader'});
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToAbort'));
+ if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest'));
+
+ let memberUpdates = User.update({
+ 'party._id': groupId,
+ }, {
+ $set: {'party.quest': Group.cleanQuestProgress()},
+ $inc: {_v: 1}, // TODO update middleware
+ }, {multi: true}).exec();
+
+ let questLeaderUpdate = User.update({
+ _id: group.quest.leader,
+ }, {
+ $inc: {
+ [`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader
+ },
+ }).exec();
+
+ group.quest = Group.cleanGroupQuest();
+ group.markModified('quest');
+
+ let [groupSaved] = await Q.all([group.save(), memberUpdates, questLeaderUpdate]);
+
+ res.respond(200, groupSaved.quest);
+ },
+};
+
+/**
+ * @api {post} /groups/:groupId/quests/leave Leaves the active quest
+ * @apiVersion 3.0.0
+ * @apiName LeaveQuest
+ * @apiGroup Group
+ *
+ * @apiParam {string} groupId The group _id (or 'party')
+ *
+ * @apiSuccess {Object} quest Quest Object
+ */
+api.leaveQuest = {
+ method: 'POST',
+ url: '/groups/:groupId/quests/leave',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+ let groupId = req.params.groupId;
+
+ req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let group = await Group.getGroup({user, groupId, fields: 'type quest'});
+
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
+ if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToLeave'));
+ if (group.quest.leader === user._id) throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest'));
+ if (!group.quest.members[user._id]) throw new NotAuthorized(res.t('notPartOfQuest'));
+
+ group.quest.members[user._id] = false;
+ group.markModified('quest.members');
+
+ user.party.quest = Group.cleanQuestProgress();
+ user.markModified('party.quest');
+
+ let [savedGroup] = await Q.all([
+ group.save(),
+ user.save(),
+ ]);
+
+ res.respond(200, savedGroup.quest);
+ },
+};
+
+export default api;
diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js
index 20d3f2f5ed..27191bf0ee 100644
--- a/website/src/controllers/api-v3/tags.js
+++ b/website/src/controllers/api-v3/tags.js
@@ -20,18 +20,15 @@ api.createTag = {
method: 'POST',
url: '/tags',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
user.tags.push(Tag.sanitize(req.body));
+ let savedUser = await user.save();
- user.save()
- .then((savedUser) => {
- let l = savedUser.tags.length;
- let tag = savedUser.tags[l - 1];
- res.respond(201, tag);
- })
- .catch(next);
+ let l = savedUser.tags.length;
+ let tag = savedUser.tags[l - 1];
+ res.respond(201, tag);
},
};
@@ -47,7 +44,7 @@ api.getTags = {
method: 'GET',
url: '/tags',
middlewares: [authWithHeaders(), cron],
- handler (req, res) {
+ async handler (req, res) {
let user = res.locals.user;
res.respond(200, user.tags);
},
@@ -67,16 +64,16 @@ api.getTag = {
method: 'GET',
url: '/tags/:tagId',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
let tag = user.tags.id(req.params.tagId);
- if (!tag) return next(new NotFound(res.t('tagNotFound')));
+ if (!tag) throw new NotFound(res.t('tagNotFound'));
res.respond(200, tag);
},
};
@@ -95,7 +92,7 @@ api.updateTag = {
method: 'PUT',
url: '/tags/:tagId',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
@@ -104,16 +101,15 @@ api.updateTag = {
let tagId = req.params.tagId;
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
let tag = user.tags.id(tagId);
- if (!tag) return next(new NotFound(res.t('tagNotFound')));
+ if (!tag) throw new NotFound(res.t('tagNotFound'));
_.merge(tag, Tag.sanitize(req.body));
- user.save()
- .then((savedUser) => res.respond(200, savedUser.tags.id(tagId)))
- .catch(next);
+ let savedUser = await user.save();
+ res.respond(200, savedUser.tags.id(tagId));
},
};
@@ -131,21 +127,20 @@ api.deleteTag = {
method: 'DELETE',
url: '/tags/:tagId',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
let tag = user.tags.id(req.params.tagId);
- if (!tag) return next(new NotFound(res.t('tagNotFound')));
+ if (!tag) throw new NotFound(res.t('tagNotFound'));
tag.remove();
- user.save()
- .then(() => res.respond(200, {}))
- .catch(next);
+ await user.save();
+ res.respond(200, {});
},
};
diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js
index ef7180088e..9ec66296a1 100644
--- a/website/src/controllers/api-v3/tasks.js
+++ b/website/src/controllers/api-v3/tasks.js
@@ -1,7 +1,10 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import cron from '../../middlewares/api-v3/cron';
import { sendTaskWebhook } from '../../libs/api-v3/webhook';
+import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
import * as Tasks from '../../models/task';
+import { model as Challenge } from '../../models/challenge';
+import { model as Group } from '../../models/group';
import {
NotFound,
NotAuthorized,
@@ -10,101 +13,215 @@ import {
import shared from '../../../../common';
import Q from 'q';
import _ from 'lodash';
+import moment from 'moment';
import scoreTask from '../../../../common/script/api-v3/scoreTask';
+import { preenHistory } from '../../libs/api-v3/preening';
let api = {};
+// challenge must be passed only when a challenge task is being created
+async function _createTasks (req, res, user, challenge) {
+ let toSave = Array.isArray(req.body) ? req.body : [req.body];
+
+ toSave = toSave.map(taskData => {
+ // Validate that task.type is valid
+ if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType'));
+
+ let taskType = taskData.type;
+ let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(taskData));
+
+ if (challenge) {
+ newTask.challenge.id = challenge.id;
+ } else {
+ newTask.userId = user._id;
+ }
+
+ // Validate that the task is valid and throw if it isn't
+ // otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality
+ let validationErrors = newTask.validateSync();
+ if (validationErrors) throw validationErrors;
+
+ // Otherwise update the user/challenge
+ (challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id);
+
+ return newTask;
+ }).map(task => task.save({ // If all tasks are valid (this is why it's not in the previous .map()), save everything, withough running validation again
+ validateBeforeSave: false,
+ }));
+
+ toSave.unshift((challenge || user).save());
+
+ let tasks = await Q.all(toSave);
+ tasks.splice(0, 1); // Remove user or challenge
+ return tasks;
+}
+
/**
- * @api {post} /tasks Create a new task
+ * @api {post} /tasks/user Create a new task belonging to the autheticated user. Can be passed an object to create a single task or an array of objects to create multiple tasks.
* @apiVersion 3.0.0
- * @apiName CreateTask
+ * @apiName CreateUserTasks
* @apiGroup Task
*
- * @apiSuccess {Object} task The newly created task
+ * @apiSuccess {Object|Array} task The newly created task(s)
*/
-// TODO should allow to create multiple tasks at once
-// TODO gives problems when creating tasks concurrently because of how mongoose treats arrays (VersionErrors - treated as 500s)
-api.createTask = {
+api.createUserTasks = {
method: 'POST',
- url: '/tasks',
+ url: '/tasks/user',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
- req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes);
-
- let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
-
- let user = res.locals.user;
- let taskType = req.body.type;
-
- let newTask = new Tasks[taskType](Tasks.Task.sanitizeCreate(req.body));
- newTask.userId = user._id;
-
- user.tasksOrder[`${taskType}s`].unshift(newTask._id);
-
- Q.all([
- newTask.save(),
- user.save(),
- ])
- .then((results) => res.respond(201, results[0]))
- .catch(next);
+ async handler (req, res) {
+ let tasks = await _createTasks(req, res, res.locals.user);
+ res.respond(201, tasks.length === 1 ? tasks[0] : tasks);
},
};
/**
- * @api {get} /tasks Get an user's tasks
+ * @api {post} /tasks/challenge/:challengeId Create a new task belonging to the challenge. Can be passed an object to create a single task or an array of objects to create multiple tasks.
* @apiVersion 3.0.0
- * @apiName GetTasks
+ * @apiName CreateChallengeTasks
* @apiGroup Task
*
- * @apiParam {string="habit","daily","todo","reward"} type Optional query parameter to return just a type of tasks
- * @apiParam {boolean} includeCompletedTodos Optional query parameter to include completed todos when "type" is "todo"
+ * @apiParam {UUID} challengeId The id of the challenge the new task(s) will belong to.
*
- * @apiSuccess {Array} tasks An array of task objects
+ * @apiSuccess {Object|Array} task The newly created task(s)
*/
-api.getTasks = {
- method: 'GET',
- url: '/tasks',
+api.createChallengeTasks = {
+ method: 'POST',
+ url: '/tasks/challenge/:challengeId', // TODO should be /tasks/challengeS/:challengeId ? plural?
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
- req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes);
+ async handler (req, res) {
+ req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
- let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ let reqValidationErrors = req.validationErrors();
+ if (reqValidationErrors) throw reqValidationErrors;
let user = res.locals.user;
- let query = {userId: user._id};
- let type = req.query.type;
+ let challengeId = req.params.challengeId;
- if (type) {
- query.type = type;
- if (type === 'todo') query.completed = false; // Exclude completed todos
- } else {
- query.$or = [ // Exclude completed todos
- {type: 'todo', completed: false},
- {type: {$in: ['habit', 'daily', 'reward']}},
- ];
- }
+ let challenge = await Challenge.findOne({_id: challengeId}).exec();
- if (req.query.includeCompletedTodos === 'true' && (!type || type === 'todo')) {
- let queryCompleted = Tasks.Task.find({
+ // If the challenge does not exist, or if it exists but user is not the leader -> throw error
+ if (!challenge || user.challenges.indexOf(challengeId) === -1) throw new NotFound(res.t('challengeNotFound'));
+ if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
+
+ let tasks = await _createTasks(req, res, user, challenge);
+
+ res.respond(201, tasks.length === 1 ? tasks[0] : tasks);
+
+ // If adding tasks to a challenge -> sync users
+ if (challenge) challenge.addTasks(tasks); // TODO catch/log
+ },
+};
+
+// challenge must be passed only when a challenge task is being created
+async function _getTasks (req, res, user, challenge) {
+ let query = challenge ? {'challenge.id': challenge.id, userId: {$exists: false}} : {userId: user._id};
+ let type = req.query.type;
+
+ if (type) {
+ if (type === 'todos') {
+ query.completed = false; // Exclude completed todos
+ } else if (type === 'completedTodos') {
+ query = Tasks.Task.find({
+ userId: user._id,
type: 'todo',
completed: true,
}).limit(30).sort({ // TODO add ability to pick more than 30 completed todos
dateCompleted: 1,
});
-
- Q.all([
- queryCompleted.exec(),
- Tasks.Task.find(query).exec(),
- ])
- .then((results) => res.respond(200, results[1].concat(results[0])))
- .catch(next);
} else {
- Tasks.Task.find(query).exec()
- .then((tasks) => res.respond(200, tasks))
- .catch(next);
+ query.type = type.slice(0, -1); // removing the final "s"
}
+ } else {
+ query.$or = [ // Exclude completed todos
+ {type: 'todo', completed: false},
+ {type: {$in: ['habit', 'daily', 'reward']}},
+ ];
+ }
+
+ let tasks = await Tasks.Task.find(query).exec();
+
+ // Order tasks based on tasksOrder
+ if (type && type !== 'completedTodos') {
+ let order = (challenge || user).tasksOrder[type];
+ let orderedTasks = new Array(tasks.length);
+ let unorderedTasks = []; // what we want to add later
+
+ tasks.forEach((task, index) => {
+ let taskId = task._id;
+ let i = order[index] === taskId ? index : order.indexOf(taskId);
+ if (i === -1) {
+ unorderedTasks.push(task);
+ } else {
+ orderedTasks[i] = task;
+ }
+ });
+
+ // Remove empty values from the array and add any unordered task
+ orderedTasks = _.compact(orderedTasks).concat(unorderedTasks);
+ res.respond(200, orderedTasks);
+ } else {
+ res.respond(200, tasks);
+ }
+}
+
+/**
+ * @api {get} /tasks/user Get an user's tasks
+ * @apiVersion 3.0.0
+ * @apiName GetUserTasks
+ * @apiGroup Task
+ *
+ * @apiParam {string="habits","dailys","todos","rewards","completedTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that requested separately.
+ *
+ * @apiSuccess {Array} tasks An array of task objects
+ */
+api.getUserTasks = {
+ method: 'GET',
+ url: '/tasks/user',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let types = Tasks.tasksTypes.map(type => `${type}s`);
+ types.push('completedTodos');
+ req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ await _getTasks(req, res, res.locals.user);
+ },
+};
+
+/**
+ * @api {get} /tasks/challenge/:challengeId Get a challenge's tasks
+ * @apiVersion 3.0.0
+ * @apiName GetChallengeTasks
+ * @apiGroup Task
+ *
+ * @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks.
+ * @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks
+ *
+ * @apiSuccess {Array} tasks An array of task objects
+ */
+api.getChallengeTasks = {
+ method: 'GET',
+ url: '/tasks/challenge/:challengeId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
+ let types = Tasks.tasksTypes.map(type => `${type}s`);
+ req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
+
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let user = res.locals.user;
+ let challengeId = req.params.challengeId;
+
+ let challenge = await Challenge.findOne({_id: challengeId}).select('group leader tasksOrder').exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+ let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
+ if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
+
+ await _getTasks(req, res, res.locals.user, challenge);
},
};
@@ -122,23 +239,30 @@ api.getTask = {
method: 'GET',
url: '/tasks/:taskId',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
- Tasks.Task.findOne({
+ let task = await Tasks.Task.findOne({
_id: req.params.taskId,
- userId: user._id,
- }).exec()
- .then((task) => {
- if (!task) throw new NotFound(res.t('taskNotFound'));
- res.respond(200, task);
- })
- .catch(next);
+ }).exec();
+
+ if (!task) {
+ throw new NotFound(res.t('taskNotFound'));
+ } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
+ let challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec();
+ if (!challenge || (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens
+ throw new NotFound(res.t('taskNotFound'));
+ }
+ } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one
+ throw new NotFound(res.t('taskNotFound'));
+ }
+
+ res.respond(200, task);
},
};
@@ -156,45 +280,53 @@ api.updateTask = {
method: 'PUT',
url: '/tasks/:taskId',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
+ let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
// TODO check that req.body isn't empty
// TODO make sure tags are updated correctly (they aren't set as modified!) maybe use specific routes
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
- Tasks.Task.findOne({
+ let task = await Tasks.Task.findOne({
_id: req.params.taskId,
- userId: user._id,
- }).exec()
- .then((task) => {
- if (!task) throw new NotFound(res.t('taskNotFound'));
+ }).exec();
- // If checklist is updated -> replace the original one
- if (req.body.checklist) {
- task.checklist = req.body.checklist;
- delete req.body.checklist;
- }
+ if (!task) {
+ throw new NotFound(res.t('taskNotFound'));
+ } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
+ challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+ if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
+ } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one
+ throw new NotFound(res.t('taskNotFound'));
+ }
- // If tags are updated -> replace the original ones
- if (req.body.tags) {
- task.tags = req.body.tags;
- delete req.body.tags;
- }
+ // If checklist is updated -> replace the original one
+ if (req.body.checklist) {
+ task.checklist = req.body.checklist;
+ delete req.body.checklist;
+ }
- // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, very bad for performances
- // TODO regarding comment above make sure other models with nested fields are using this trick too
- _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate(req.body)));
- // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep)
- // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject()
- // see https://github.com/Automattic/mongoose/issues/2749
- return task.save();
- })
- .then((savedTask) => res.respond(200, savedTask))
- .catch(next);
+ // If tags are updated -> replace the original ones
+ if (req.body.tags) {
+ task.tags = req.body.tags;
+ delete req.body.tags;
+ }
+
+ // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, bad for performances?
+ // TODO regarding comment above make sure other models with nested fields are using this trick too
+ _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate(req.body)));
+ // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep)
+ // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject()
+ // see https://github.com/Automattic/mongoose/issues/2749
+
+ let savedTask = await task.save();
+ res.respond(200, savedTask);
+ if (challenge) challenge.updateTask(savedTask); // TODO catch/log
},
};
@@ -238,119 +370,154 @@ api.scoreTask = {
method: 'POST',
url: '/tasks/:taskId/score/:direction',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); // TODO what about rewards? maybe separate route?
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
let user = res.locals.user;
let direction = req.params.direction;
- Tasks.Task.findOne({
+ let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
- }).exec()
- .then((task) => {
- if (!task) throw new NotFound(res.t('taskNotFound'));
+ }).exec();
- let wasCompleted = task.completed;
- if (task.type === 'daily' || task.type === 'todo') {
- task.completed = direction === 'up'; // TODO move into scoreTask
- }
+ if (!task) throw new NotFound(res.t('taskNotFound'));
- let delta = scoreTask({task, user, direction}, req);
- // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results)
- if (direction === 'up') user.fns.randomDrop({task, delta}, req);
+ let wasCompleted = task.completed;
+ if (task.type === 'daily' || task.type === 'todo') {
+ task.completed = direction === 'up'; // TODO move into scoreTask
+ }
- // If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list
- if (task.type === 'todo') {
- if (!wasCompleted && task.completed) {
- let i = user.tasksOrder.todos.indexOf(task._id);
- if (i !== -1) user.tasksOrder.todos.splice(i, 1);
- } else if (wasCompleted && !task.completed) {
- let i = user.tasksOrder.todos.indexOf(task._id);
- if (i === -1) {
- user.tasksOrder.todos.push(task._id); // TODO push at the top?
- } else { // If for some reason it hadn't been removed TODO ok?
- user.tasksOrder.todos.splice(i, 1);
- user.tasksOrder.push(task._id);
- }
+ let delta = scoreTask({task, user, direction}, req);
+ // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results)
+ if (direction === 'up') user.fns.randomDrop({task, delta}, req);
+
+ // If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list
+ if (task.type === 'todo') {
+ if (!wasCompleted && task.completed) {
+ removeFromArray(user.tasksOrder.todos, task._id);
+ } else if (wasCompleted && !task.completed) {
+ let hasTask = removeFromArray(user.tasksOrder.todos, task._id);
+ if (!hasTask) {
+ user.tasksOrder.todos.push(task._id); // TODO push at the top?
+ } else { // If for some reason it hadn't been removed TODO ok?
+ user.tasksOrder.push(task._id);
}
}
+ }
- return Q.all([
- user.save(),
- task.save(),
- ]).then((results) => {
- let savedUser = results[0];
+ let results = await Q.all([
+ user.save(),
+ task.save(),
+ ]);
- let userStats = savedUser.stats.toJSON();
- let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats);
- res.respond(200, resJsonData);
+ let savedUser = results[0];
- sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user));
+ let userStats = savedUser.stats.toJSON();
+ let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats);
+ res.respond(200, resJsonData);
- // TODO sync challenge
- });
- })
- .catch(next);
+ sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user));
+
+ // TODO test?
+ if (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 {
+ let chalTask = await Tasks.Task.findOne({
+ _id: task.challenge.taskId,
+ }).exec();
+
+ chalTask.value += delta;
+
+ if (chalTask.type === 'habit' || chalTask.type === 'daily') {
+ // Add only one history entry per day
+ if (moment(chalTask.history[chalTask.history.length - 1].date).isSame(new Date(), 'day')) {
+ chalTask.history[chalTask.history.length - 1] = {
+ date: Number(new Date()),
+ value: chalTask.value,
+ };
+ chalTask.markModified(`history.${chalTask.history.length - 1}`);
+ } else {
+ chalTask.history.push({
+ date: Number(new Date()),
+ value: chalTask.value,
+ });
+
+ // Only preen task history once a day when the task is scored first
+ if (chalTask.history.length > 365) {
+ chalTask.history = preenHistory(chalTask.history, true); // true means the challenge will retain as much entries as a subscribed user
+ chalTask.markModified(`history.${chalTask.history.length - 1}`);
+ }
+ }
+ }
+
+ await chalTask.save();
+ } catch (e) {
+ // TODO handle
+ }
+ }
},
};
// completed todos cannot be moved, they'll be returned ordered by date of completion
// TODO check that it works when a tag is selected or todos are split between dated and due
+// TODO support challenges?
/**
- * @api {post} /tasks/move/:taskId/to/:position Move a task to a new position
+ * @api {post} /tasks/:taskId/move/to/:position Move a task to a new position
* @apiVersion 3.0.0
* @apiName MoveTask
* @apiGroup Task
*
* @apiParam {UUID} taskId The task _id
- * @apiParam {Number} position Where to move the task (-1 means push to bottom)
+ * @apiParam {Number} position Where to move the task (-1 means push to bottom). First position is 0
*
- * @apiSuccess {object} empty An empty object
+ * @apiSuccess {object} tasksOrder The new tasks order (user.tasksOrder.{task.type}s)
*/
api.moveTask = {
method: 'POST',
- url: '/tasks/move/:taskId/to/:position',
+ url: '/tasks/:taskId/move/to/:position',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric();
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
let user = res.locals.user;
let to = Number(req.params.position);
- Tasks.Task.findOne({
+ let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
- }).exec()
- .then((task) => {
- if (!task) throw new NotFound(res.t('taskNotFound'));
- if (task.type === 'todo' && task.completed) throw new NotFound(res.t('cantMoveCompletedTodo'));
- let order = user.tasksOrder[`${task.type}s`];
- let currentIndex = order.indexOf(task._id);
+ }).exec();
- // If for some reason the task isn't ordered (should never happen)
- // or if the task is moved to a non existing position
- // or if the task is moved to postion -1 (push to bottom)
- // -> push task at end of list
- if (currentIndex === -1 || !order[to] || to === -1) {
+ if (!task) throw new NotFound(res.t('taskNotFound'));
+ if (task.type === 'todo' && task.completed) throw new BadRequest(res.t('cantMoveCompletedTodo'));
+ let order = user.tasksOrder[`${task.type}s`];
+ let currentIndex = order.indexOf(task._id);
+
+ // If for some reason the task isn't ordered (should never happen), push it in the new position
+ // if the task is moved to a non existing position
+ // or if the task is moved to position -1 (push to bottom)
+ // -> push task at end of list
+ if (!order[to] && to !== -1) {
+ order.push(task._id);
+ } else {
+ if (currentIndex !== -1) order.splice(currentIndex, 1);
+ if (to === -1) {
order.push(task._id);
} else {
- let taskToMove = order.splice(currentIndex, 1)[0];
- order.splice(to, 0, taskToMove);
+ order.splice(to, 0, task._id);
}
+ }
- return user.save();
- })
- .then(() => res.respond(200, {})) // TODO what to return
- .catch(next);
+ await user.save();
+ res.respond(200, order);
},
};
@@ -368,28 +535,37 @@ api.addChecklistItem = {
method: 'POST',
url: '/tasks/:taskId/checklist',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
+ let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
// TODO check that req.body isn't empty and is an array
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
- Tasks.Task.findOne({
+ let task = await Tasks.Task.findOne({
_id: req.params.taskId,
- userId: user._id,
- }).exec()
- .then((task) => {
- if (!task) throw new NotFound(res.t('taskNotFound'));
- if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
+ }).exec();
- task.checklist.push(Tasks.Task.sanitizeChecklist(req.body));
- return task.save();
- })
- .then((savedTask) => res.respond(200, savedTask)) // TODO what to return
- .catch(next);
+ if (!task) {
+ throw new NotFound(res.t('taskNotFound'));
+ } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
+ challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+ if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
+ } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one
+ throw new NotFound(res.t('taskNotFound'));
+ }
+
+ if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
+
+ task.checklist.push(Tasks.Task.sanitizeChecklist(req.body));
+ let savedTask = await task.save();
+
+ res.respond(200, savedTask); // TODO what to return
+ if (challenge) challenge.updateTask(savedTask);
},
};
@@ -408,31 +584,30 @@ api.scoreCheckListItem = {
method: 'POST',
url: '/tasks/:taskId/checklist/:itemId/score',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
- Tasks.Task.findOne({
+ let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
- }).exec()
- .then((task) => {
- if (!task) throw new NotFound(res.t('taskNotFound'));
- if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
+ }).exec();
- let item = _.find(task.checklist, {_id: req.params.itemId});
+ if (!task) throw new NotFound(res.t('taskNotFound'));
+ if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
- if (!item) throw new NotFound(res.t('checklistItemNotFound'));
- item.completed = !item.completed;
- return task.save();
- })
- .then((savedTask) => res.respond(200, savedTask)) // TODO what to return
- .catch(next);
+ let item = _.find(task.checklist, {_id: req.params.itemId});
+
+ if (!item) throw new NotFound(res.t('checklistItemNotFound'));
+ item.completed = !item.completed;
+ let savedTask = await task.save();
+
+ res.respond(200, savedTask); // TODO what to return
},
};
@@ -451,31 +626,39 @@ api.updateChecklistItem = {
method: 'PUT',
url: '/tasks/:taskId/checklist/:itemId',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
+ let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
- Tasks.Task.findOne({
+ let task = await Tasks.Task.findOne({
_id: req.params.taskId,
- userId: user._id,
- }).exec()
- .then((task) => {
- if (!task) throw new NotFound(res.t('taskNotFound'));
- if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
+ }).exec();
- let item = _.find(task.checklist, {_id: req.params.itemId});
- if (!item) throw new NotFound(res.t('checklistItemNotFound'));
+ if (!task) {
+ throw new NotFound(res.t('taskNotFound'));
+ } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
+ challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+ if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
+ } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one
+ throw new NotFound(res.t('taskNotFound'));
+ }
+ if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
- _.merge(item, Tasks.Task.sanitizeChecklist(req.body));
- return task.save();
- })
- .then((savedTask) => res.respond(200, savedTask)) // TODO what to return
- .catch(next);
+ let item = _.find(task.checklist, {_id: req.params.itemId});
+ if (!item) throw new NotFound(res.t('checklistItemNotFound'));
+
+ _.merge(item, Tasks.Task.sanitizeChecklist(req.body));
+ let savedTask = await task.save();
+
+ res.respond(200, savedTask); // TODO what to return
+ if (challenge) challenge.updateTask(savedTask);
},
};
@@ -494,31 +677,37 @@ api.removeChecklistItem = {
method: 'DELETE',
url: '/tasks/:taskId/checklist/:itemId',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
+ let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
- Tasks.Task.findOne({
+ let task = await Tasks.Task.findOne({
_id: req.params.taskId,
- userId: user._id,
- }).exec()
- .then((task) => {
- if (!task) throw new NotFound(res.t('taskNotFound'));
- if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
+ }).exec();
- let itemI = _.findIndex(task.checklist, {_id: req.params.itemId});
- if (itemI === -1) throw new NotFound(res.t('checklistItemNotFound'));
+ if (!task) {
+ throw new NotFound(res.t('taskNotFound'));
+ } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
+ challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+ if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
+ } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one
+ throw new NotFound(res.t('taskNotFound'));
+ }
+ if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
- task.checklist.splice(itemI, 1);
- return task.save();
- })
- .then(() => res.respond(200, {})) // TODO what to return
- .catch(next);
+ let hasItem = removeFromArray(task.checklist, { _id: req.params.itemId });
+ if (!hasItem) throw new NotFound(res.t('checklistItemNotFound'));
+
+ let savedTask = await task.save();
+ res.respond(200, {}); // TODO what to return
+ if (challenge) challenge.updateTask(savedTask);
},
};
@@ -537,7 +726,7 @@ api.addTagToTask = {
method: 'POST',
url: '/tasks/:taskId/tags/:tagId',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
@@ -545,24 +734,23 @@ api.addTagToTask = {
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID().isIn(userTags);
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
- Tasks.Task.findOne({
+ let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
- }).exec()
- .then((task) => {
- if (!task) throw new NotFound(res.t('taskNotFound'));
- let tagId = req.params.tagId;
+ }).exec();
- let alreadyTagged = task.tags.indexOf(tagId) !== -1;
- if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged'));
+ if (!task) throw new NotFound(res.t('taskNotFound'));
+ let tagId = req.params.tagId;
- task.tags.push(tagId);
- return task.save();
- })
- .then((savedTask) => res.respond(200, savedTask)) // TODO what to return
- .catch(next);
+ let alreadyTagged = task.tags.indexOf(tagId) !== -1;
+ if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged'));
+
+ task.tags.push(tagId);
+
+ let savedTask = await task.save();
+ res.respond(200, savedTask); // TODO what to return
},
};
@@ -581,51 +769,110 @@ api.removeTagFromTask = {
method: 'DELETE',
url: '/tasks/:taskId/tags/:tagId',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
- Tasks.Task.findOne({
+ let task = await Tasks.Task.findOne({
_id: req.params.taskId,
userId: user._id,
- }).exec()
- .then((task) => {
- if (!task) throw new NotFound(res.t('taskNotFound'));
+ }).exec();
- let tagI = task.tags.indexOf(req.params.tagId);
- if (tagI === -1) throw new NotFound(res.t('tagNotFound'));
+ if (!task) throw new NotFound(res.t('taskNotFound'));
- task.tags.splice(tagI, 1);
- return task.save();
- })
- .then(() => res.respond(200, {})) // TODO what to return
- .catch(next);
+ let hasTag = removeFromArray(task.tags, req.params.tagId);
+ if (!hasTag) throw new NotFound(res.t('tagNotFound'));
+
+ await task.save();
+ res.respond(200, {}); // TODO what to return
},
};
-// Remove a task from user.tasksOrder
-function _removeTaskTasksOrder (user, taskId) {
- // Loop through all lists and when the task is found, remove it and return
- for (let i = 0; i < Tasks.tasksTypes.length; i++) {
- let list = user.tasksOrder[`${Tasks.tasksTypes[i]}s`];
- let index = list.indexOf(taskId);
+// TODO this method needs some limitation, like to check if the challenge is really broken?
+/**
+ * @api {post} /tasks/unlink/:taskId Unlink a challenge task
+ * @apiVersion 3.0.0
+ * @apiName UnlinkTask
+ * @apiGroup Task
+ *
+ * @apiParam {UUID} taskId The task _id
+ *
+ * @apiSuccess {object} empty An empty object
+ */
+api.unlinkTask = {
+ method: 'POST',
+ url: '/tasks/unlink/:taskId',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
+ req.checkQuery('keep', res.t('keepOrRemove')).notEmpty().isIn(['keep', 'remove']);
- if (index !== -1) {
- list.splice(index, 1);
- break;
+ let validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ let user = res.locals.user;
+ let keep = req.query.keep;
+ let taskId = req.params.taskId;
+
+ let task = await Tasks.Task.findOne({
+ _id: taskId,
+ userId: user._id,
+ }).exec();
+
+ if (!task) throw new NotFound(res.t('taskNotFound'));
+ if (!task.challenge.id) throw new BadRequest(res.t('cantOnlyUnlinkChalTask'));
+
+ if (keep === 'keep') {
+ task.challenge = {};
+ await task.save();
+ } else { // remove
+ if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if
+ removeFromArray(user.tasksOrder[`${task.type}s`], taskId);
+ await Q.all([user.save(), task.remove()]);
+ } else {
+ await task.remove();
+ }
}
- }
- return;
-}
+ res.respond(200, {}); // TODO what to return
+ },
+};
/**
- * @api {delete} /task/:taskId Delete a user task given its id
+ * @api {post} /tasks/clearCompletedTodos Delete user's completed todos
+ * @apiVersion 3.0.0
+ * @apiName ClearCompletedTodos
+ * @apiGroup Task
+ *
+ * @apiSuccess {object} empty An empty object
+ */
+api.clearCompletedTodos = {
+ method: 'POST',
+ url: '/tasks/clearCompletedTodos',
+ middlewares: [authWithHeaders(), cron],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ // Clear completed todos
+ // Do not delete challenges completed todos TODO unless the task is broken?
+ await Tasks.Task.remove({
+ userId: user._id,
+ type: 'todo',
+ completed: true,
+ 'challenge.id': {$exists: false},
+ }).exec();
+
+ res.respond(200, {});
+ },
+};
+
+/**
+ * @api {delete} /tasks/:taskId Delete a user task given its id
* @apiVersion 3.0.0
* @apiName DeleteTask
* @apiGroup Task
@@ -638,30 +885,39 @@ api.deleteTask = {
method: 'DELETE',
url: '/tasks/:taskId',
middlewares: [authWithHeaders(), cron],
- handler (req, res, next) {
+ async handler (req, res) {
let user = res.locals.user;
+ let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
- if (validationErrors) return next(validationErrors);
+ if (validationErrors) throw validationErrors;
- Tasks.Task.findOne({
- _id: req.params.taskId,
- userId: user._id,
- }).exec()
- .then((task) => {
- if (!task) throw new NotFound(res.t('taskNotFound'));
- if (task.challenge.id) throw new NotAuthorized(res.t('cantDeleteChallengeTasks'));
+ let taskId = req.params.taskId;
+ let task = await Tasks.Task.findById(taskId).exec();
- _removeTaskTasksOrder(user, req.params.taskId);
- return Q.all([
- user.save(),
- task.remove(),
- ]);
- })
- .then(() => res.respond(200, {}))
- .catch(next);
+ if (!task) {
+ throw new NotFound(res.t('taskNotFound'));
+ } else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
+ challenge = await Challenge.find().selec({_id: task.challenge.id}).select('leader').exec();
+ if (!challenge) throw new NotFound(res.t('challengeNotFound'));
+ if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
+ } else if (task.userId !== user._id) { // If the task is owned by an user make it's the current one
+ throw new NotFound(res.t('taskNotFound'));
+ } else if (task.userId && task.challenge.id) {
+ throw new NotAuthorized(res.t('cantDeleteChallengeTasks'));
+ }
+
+ if (task.type !== 'todo' || !task.completed) {
+ removeFromArray((challenge || user).tasksOrder[`${task.type}s`], taskId);
+ await Q.all([(challenge || user).save(), task.remove()]);
+ } else {
+ await task.remove();
+ }
+
+ res.respond(200, {});
+ if (challenge) challenge.removeTask(task);
},
};
diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js
index ef6749209c..39efc002f9 100644
--- a/website/src/controllers/api-v3/user.js
+++ b/website/src/controllers/api-v3/user.js
@@ -16,7 +16,7 @@ api.getUser = {
method: 'GET',
middlewares: [authWithHeaders(), cron],
url: '/user',
- handler (req, res) {
+ async handler (req, res) {
let user = res.locals.user.toJSON();
// Remove apiToken from resonse TODO make it priavte at the user level? returned in signup/login
diff --git a/website/src/controllers/pushNotifications.js b/website/src/controllers/pushNotifications.js
index d1c728f365..860cfbf56a 100644
--- a/website/src/controllers/pushNotifications.js
+++ b/website/src/controllers/pushNotifications.js
@@ -1,3 +1,4 @@
+// TODO move to /api-v2
var api = module.exports;
var _ = require('lodash');
var nconf = require('nconf');
diff --git a/website/src/libs/api-v3/analyticsService.js b/website/src/libs/api-v3/analyticsService.js
index ae9ad6f60f..2c435da9dd 100644
--- a/website/src/libs/api-v3/analyticsService.js
+++ b/website/src/libs/api-v3/analyticsService.js
@@ -210,6 +210,7 @@ let _sendPurchaseDataToGoogle = (data) => {
});
};
+// TODO log errors...
function track (eventType, data) {
return Q.all([
_sendDataToAmplitude(eventType, data),
diff --git a/website/src/libs/api-v3/collectionManipulators.js b/website/src/libs/api-v3/collectionManipulators.js
new file mode 100644
index 0000000000..95d3981601
--- /dev/null
+++ b/website/src/libs/api-v3/collectionManipulators.js
@@ -0,0 +1,22 @@
+import {
+ findIndex,
+ isPlainObject,
+} from 'lodash';
+
+export function removeFromArray (array, element) {
+ let elementIndex;
+
+ if (isPlainObject(element)) {
+ elementIndex = findIndex(array, element);
+ } else {
+ elementIndex = array.indexOf(element);
+ }
+
+ if (elementIndex !== -1) {
+ let removedElement = array[elementIndex];
+ array.splice(elementIndex, 1);
+ return removedElement;
+ }
+
+ return false;
+}
diff --git a/website/src/libs/api-v3/csvStringify.js b/website/src/libs/api-v3/csvStringify.js
new file mode 100644
index 0000000000..3a597ff55c
--- /dev/null
+++ b/website/src/libs/api-v3/csvStringify.js
@@ -0,0 +1,11 @@
+import csvStringify from 'csv-stringify';
+import Q from 'q';
+
+export default function (input) {
+ return Q.promise((resolve, reject) => {
+ csvStringify(input, (err, output) => {
+ if (err) return reject(err);
+ return resolve(output);
+ });
+ });
+}
diff --git a/website/src/libs/api-v3/logger.js b/website/src/libs/api-v3/logger.js
index 0d00ca7f4b..3b259eda6b 100644
--- a/website/src/libs/api-v3/logger.js
+++ b/website/src/libs/api-v3/logger.js
@@ -4,12 +4,15 @@ import winston from 'winston';
import nconf from 'nconf';
const IS_PROD = nconf.get('IS_PROD');
+const IS_TEST = nconf.get('IS_TEST');
let logger = new winston.Logger();
if (IS_PROD) {
// TODO production logging, use loggly
// log errors to console too
+} else if (IS_TEST) {
+ // Do not log anything when testing
} else {
logger
.add(winston.transports.Console, {
diff --git a/website/src/libs/api-v3/preening.js b/website/src/libs/api-v3/preening.js
new file mode 100644
index 0000000000..ee6a201b3a
--- /dev/null
+++ b/website/src/libs/api-v3/preening.js
@@ -0,0 +1,82 @@
+import _ from 'lodash';
+import moment from 'moment';
+
+// Aggregate entries
+function _aggregate (history, aggregateBy) {
+ return _.chain(history)
+ .groupBy(entry => { // group entries by aggregateBy
+ return moment(entry.date).format(aggregateBy);
+ })
+ .sortBy((entry, key) => key) // sort by date
+ .map(entries => {
+ return {
+ date: Number(entries[0].date),
+ value: _.reduce(entries, (previousValue, entry) => {
+ return previousValue + entry.value;
+ }, 0) / entries.length,
+ };
+ })
+ .value();
+}
+
+/* Preen an array of history entries
+Free users:
+- 1 value for each day of the past 60 days (no compression)
+- 1 value each month for the previous 10 months
+- 1 value each year for the previous years
+Subscribers and challenges:
+- 1 value for each day of the past 365 days (no compression)
+- 1 value each month for the previous 12 months
+- 1 value each year for the previous years
+ */
+export function preenHistory (history, isSubscribed, timezoneOffset) {
+ // history = _.filter(history, historyEntry => Boolean(historyEntry)); // Filter missing entries TODO add to migration
+ let now = timezoneOffset ? moment().zone(timezoneOffset) : moment();
+ // Date after which to begin compressing data
+ let cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day');
+
+ // Keep uncompressed entries (modifies history)
+ let newHistory = _.remove(history, entry => {
+ let date = moment(entry.date);
+ return date.isSame(cutOff) || date.isAfter(cutOff);
+ });
+
+ // Date after which to begin compressing data by year
+ let monthsCutOff = cutOff.subtract(isSubscribed ? 12 : 10, 'months').startOf('day');
+ let aggregateByMonth = _.remove(history, entry => {
+ let date = moment(entry.date);
+ return date.isSame(monthsCutOff) || date.isAfter(monthsCutOff);
+ });
+ // Aggregate remaining entries by month and year
+ if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM'));
+ if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY'));
+
+ return newHistory;
+}
+
+// Preen history for users and tasks. This code runs only on the server.
+export function preenUserHistory (user, tasksByType) {
+ let isSubscribed = user.isSubscribed();
+ let timezoneOffset = user.preferences.timezoneOffset;
+ let minHistoryLength = isSubscribed ? 365 : 60;
+
+ function _processTask (task) {
+ if (task.history && task.history.length > minHistoryLength) {
+ task.history = preenHistory(task.history, isSubscribed, timezoneOffset);
+ task.markModified('history');
+ }
+ }
+
+ tasksByType.habits.forEach(_processTask);
+ tasksByType.dailys.forEach(_processTask);
+
+ if (user.history.exp.length > minHistoryLength) {
+ user.history.exp = preenHistory(user.history.exp, isSubscribed, timezoneOffset);
+ user.markModified('history.exp');
+ }
+
+ if (user.history.todos.length > minHistoryLength) {
+ user.history.todos = preenHistory(user.history.todos, isSubscribed, timezoneOffset);
+ user.markModified('history.todos');
+ }
+}
diff --git a/website/src/libs/api-v3/pushNotifications.js b/website/src/libs/api-v3/pushNotifications.js
new file mode 100644
index 0000000000..fbf5e2666a
--- /dev/null
+++ b/website/src/libs/api-v3/pushNotifications.js
@@ -0,0 +1,56 @@
+import _ from 'lodash';
+import nconf from 'nconf';
+import pushNotify from 'push-notify';
+
+const GCM_API_KEY = nconf.get('PUSH_CONFIGS:GCM_SERVER_API_KEY');
+
+let gcm = GCM_API_KEY ? pushNotify.gcm({
+ apiKey: GCM_API_KEY,
+ retries: 3,
+}) : undefined;
+
+// TODO log
+if (gcm) {
+ gcm.on('transmitted', (/* result, message, registrationId */) => {
+ // console.info("transmitted", result, message, registrationId);
+ });
+
+ gcm.on('transmissionError', (/* error, message, registrationId */) => {
+ // console.info("transmissionError", error, message, registrationId);
+ });
+
+ gcm.on('updated', (/* result, registrationId */) => {
+ // console.info("updated", result, registrationId);
+ });
+}
+
+// TODO test
+export default function sendNotify (user, title, message, timeToLive = 15) {
+ // TODO need investigation:
+ // https://github.com/HabitRPG/habitrpg/issues/5252
+
+ if (!user) throw new Error('User is required');
+
+ _.each(user.pushDevices, pushDevice => {
+ switch (pushDevice.type) {
+ case 'android':
+ if (gcm) {
+ gcm.send({
+ registrationId: pushDevice.regId,
+ // collapseKey: 'COLLAPSE_KEY',
+ delayWhileIdle: true,
+ timeToLive,
+ data: {
+ title,
+ message,
+ },
+ });
+ }
+
+ break;
+
+ case 'ios':
+ break;
+ }
+ });
+}
diff --git a/website/src/libs/api-v3/setupNconf.js b/website/src/libs/api-v3/setupNconf.js
index e5a49bc3e9..f55f593bad 100644
--- a/website/src/libs/api-v3/setupNconf.js
+++ b/website/src/libs/api-v3/setupNconf.js
@@ -13,4 +13,5 @@ export default function setupNconf (file) {
nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production');
nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development');
+ nconf.set('IS_TEST', nconf.get('NODE_ENV') === 'test');
}
diff --git a/website/src/libs/api-v3/setupRoutes.js b/website/src/libs/api-v3/setupRoutes.js
index 1228786011..bfe2ab93e7 100644
--- a/website/src/libs/api-v3/setupRoutes.js
+++ b/website/src/libs/api-v3/setupRoutes.js
@@ -6,6 +6,10 @@ import _ from 'lodash';
const CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/');
let router = express.Router(); // eslint-disable-line babel/new-cap
+// Wrapper function to handler `async` route handlers that return promises
+// It takes the async function, execute it and pass any error to next (args[2])
+let _wrapAsyncFn = fn => (...args) => fn(...args).catch(args[2]);
+
fs
.readdirSync(CONTROLLERS_PATH)
.filter(fileName => fileName.match(/\.js$/))
@@ -17,7 +21,7 @@ fs
let {method, url, middlewares = [], handler} = action;
method = method.toLowerCase();
- router[method](url, ...middlewares, handler);
+ router[method](url, ...middlewares, _wrapAsyncFn(handler));
});
});
diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js
index d0c24d700d..d57bfb28f8 100644
--- a/website/src/middlewares/api-v3/cron.js
+++ b/website/src/middlewares/api-v3/cron.js
@@ -1,21 +1,282 @@
import _ from 'lodash';
+import moment from 'moment';
import {
daysSince,
+ shouldDo,
} from '../../../../common/script/cron';
-import cron from '../../../../common/script/api-v3/cron';
import common from '../../../../common';
import Task from '../../models/task';
-// import Group from '../../models/group';
+import Q from 'q';
+import Group from '../../models/group';
+import User from '../../models/user';
+import scoreTask from '../../../../common/script/api-v3/scoreTask';
+import { preenUserHistory } from '../../libs/api-v3/preening';
-// TODO check that it's usef everywhere
-export default function cronMiddleware (req, res, next) {
+let clearBuffs = {
+ str: 0,
+ int: 0,
+ per: 0,
+ con: 0,
+ stealth: 0,
+ streaks: false,
+};
+
+// At end of day, add value to all incomplete Daily & Todo tasks (further incentive)
+// For incomplete Dailys, deduct experience
+// Make sure to run this function once in a while as server will not take care of overnight calculations.
+// And you have to run it every time client connects.
+export function cron (options = {}) {
+ let {user, tasksByType, analytics, now, daysMissed} = options;
+
+ user.auth.timestamps.loggedin = now;
+ user.lastCron = now;
+ // Reset the lastDrop count to zero
+ if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0;
+
+ // "Perfect Day" achievement for perfect-days
+ let perfect = true;
+
+ // end-of-month perks for subscribers
+ let plan = user.purchased.plan;
+ if (user.isSubscribed()) {
+ if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) {
+ plan.gemsBought = 0; // reset gem-cap
+ plan.dateUpdated = now;
+ // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks
+ // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0
+ // TODO use month diff instead of ++ / --?
+ _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317
+ plan.consecutive.count++;
+ if (plan.consecutive.offset > 0) {
+ plan.consecutive.offset--;
+ } else if (plan.consecutive.count % 3 === 0) { // every 3 months
+ plan.consecutive.trinkets++;
+ plan.consecutive.gemCapExtra += 5;
+ if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25)
+ }
+ }
+
+ // If user cancelled subscription, we give them until 30day's end until it terminates
+ if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) {
+ _.merge(plan, {
+ planId: null,
+ customerId: null,
+ paymentMethod: null,
+ });
+
+ _.merge(plan.consecutive, {
+ count: 0,
+ offset: 0,
+ gemCapExtra: 0,
+ });
+
+ user.markModified('purchased.plan');
+ }
+ }
+
+ // User is resting at the inn.
+ // On cron, buffs are cleared and all dailies are reset without performing damage
+ if (user.preferences.sleep === true) {
+ user.stats.buffs = _.cloneDeep(clearBuffs);
+
+ tasksByType.dailys.forEach((daily) => {
+ let completed = daily.completed;
+ let thatDay = moment(now).subtract({days: 1});
+
+ if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) {
+ daily.checklist.forEach(box => box.completed = false);
+ }
+ daily.completed = false;
+ });
+
+ return;
+ }
+
+ let multiDaysCountAsOneDay = true;
+ // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day.
+ // When site-wide difficulty settings are introduced, this can be a user preference option.
+
+ // Tally each task
+ let todoTally = 0;
+
+ tasksByType.todos.forEach(task => { // make uncompleted todos redder
+ scoreTask({
+ task,
+ user,
+ direction: 'down',
+ cron: true,
+ times: multiDaysCountAsOneDay ? 1 : daysMissed,
+ // TODO pass req for analytics?
+ });
+
+ todoTally += task.value;
+ });
+
+ let dailyChecked = 0; // how many dailies were checked?
+ let dailyDueUnchecked = 0; // how many dailies were cun-hecked?
+ if (!user.party.quest.progress.down) user.party.quest.progress.down = 0;
+
+ tasksByType.dailys.forEach((task) => {
+ let completed = task.completed;
+ // Deduct points for missed Daily tasks
+ let EvadeTask = 0;
+ let scheduleMisses = daysMissed;
+
+ if (completed) {
+ dailyChecked += 1;
+ } else {
+ // dailys repeat, so need to calculate how many they've missed according to their own schedule
+ scheduleMisses = 0;
+
+ for (let i = 0; i < daysMissed; i++) {
+ let thatDay = moment(now).subtract({days: i + 1});
+
+ if (shouldDo(thatDay.toDate(), task, user.preferences)) {
+ scheduleMisses++;
+ if (user.stats.buffs.stealth) {
+ user.stats.buffs.stealth--;
+ EvadeTask++;
+ }
+ if (multiDaysCountAsOneDay) break;
+ }
+ }
+
+ if (scheduleMisses > EvadeTask) {
+ perfect = false;
+
+ if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points
+ let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length;
+ dailyDueUnchecked += 1 - fractionChecked;
+ dailyChecked += fractionChecked;
+ } else {
+ dailyDueUnchecked += 1;
+ }
+
+ let delta = scoreTask({
+ user,
+ task,
+ direction: 'down',
+ times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask,
+ cron: true,
+ });
+
+ // Apply damage from a boss, less damage for Trivial priority (difficulty)
+ user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1);
+ // NB: Medium and Hard priorities do not increase damage from boss. This was by accident
+ // initially, and when we realised, we could not fix it because users are used to
+ // their Medium and Hard Dailies doing an Easy amount of damage from boss.
+ // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future
+ // setting between Trivial and Easy.
+ }
+ }
+
+ task.history.push({
+ date: Number(new Date()),
+ value: task.value,
+ });
+ task.completed = false;
+
+ if (completed || scheduleMisses > 0) {
+ task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed
+ }
+ });
+
+ tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0
+ if (task.up === false || task.down === false) {
+ task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2;
+ }
+ });
+
+ // Finished tallying
+ user.history.todos.push({date: now, value: todoTally});
+
+ // tally experience
+ let expTally = user.stats.exp;
+ let lvl = 0; // iterator
+ while (lvl < user.stats.lvl - 1) {
+ lvl++;
+ expTally += common.tnl(lvl);
+ }
+ user.history.exp.push({date: now, value: expTally});
+
+ // preen user history so that it doesn't become a performance problem
+ // also for subscribed users but differentyly
+ // premium subscribers can keep their full history.
+ preenUserHistory(user, tasksByType, user.preferences.timezoneOffset);
+
+ if (perfect) {
+ user.achievements.perfect++;
+ let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2);
+ user.stats.buffs = {
+ str: lvlDiv2,
+ int: lvlDiv2,
+ per: lvlDiv2,
+ con: lvlDiv2,
+ stealth: 0,
+ streaks: false,
+ };
+ } else {
+ user.stats.buffs = _.cloneDeep(clearBuffs);
+ }
+
+ // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit
+ // Adjust for fraction of dailies completed
+ user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked);
+ if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP;
+
+ if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1;
+ user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked);
+ if (user.stats.mp > user._statsComputed.maxMP) {
+ user.stats.mp = user._statsComputed.maxMP;
+ }
+
+ // After all is said and done, progress up user's effect on quest, return those values & reset the user's
+ let progress = user.party.quest.progress;
+ let _progress = _.cloneDeep(progress);
+ _.merge(progress, {down: 0, up: 0});
+ progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0);
+
+ // Clean PMs - keep 200 for subscribers and 50 for free users
+ // TODO tests
+ let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too
+ let numberOfPMs = Object.keys(user.inbox.messages).length;
+ if (Object.keys(user.inbox.messages).length > maxPMs) {
+ _(user.inbox.messages)
+ .sortBy('timestamp')
+ .takeRight(numberOfPMs - maxPMs)
+ .each(pm => {
+ user.inbox.messages[pm.id] = undefined;
+ }).value();
+
+ user.markModified('inbox.messages');
+ }
+
+ // Analytics
+ user.flags.cronCount++;
+ analytics.track('Cron', {
+ category: 'behavior',
+ gaLabel: 'Cron Count',
+ gaValue: user.flags.cronCount,
+ uuid: user._id,
+ user, // TODO is it really necessary passing the whole user object?
+ resting: user.preferences.sleep,
+ cronCount: user.flags.cronCount,
+ progressUp: _.min([_progress.up, 900]),
+ progressDown: _progress.down,
+ });
+
+ return _progress;
+}
+
+// TODO check that it's used everywhere
+export default async function cronMiddleware (req, res, next) {
let user = res.locals.user;
let analytics = res.analytics;
let now = new Date();
let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences));
- if (daysMissed <= 0) return next(null, user); // TODO why are we passing user down here?
+ if (daysMissed <= 0) return next();
// Fetch active tasks (no completed todos)
Task.find({
@@ -25,41 +286,53 @@ export default function cronMiddleware (req, res, next) {
{type: {$in: ['habit', 'daily', 'reward']}},
],
}).exec()
- .then((tasks) => {
+ .then(tasks => {
let tasksByType = {habits: [], dailys: [], todos: [], rewards: []};
tasks.forEach(task => tasksByType[`${task.type}s`].push(task));
// Run cron
- cron({user, tasks, tasksByType, now, daysMissed, analytics});
+ let progress = cron({user, tasksByType, now, daysMissed, analytics});
+
+ // Clear old completed todos - 30 days for free users, 90 for subscribers
+ // Do not delete challenges completed todos TODO unless the task is broken?
+ Task.remove({
+ userId: user._id,
+ type: 'todo',
+ completed: true,
+ dateCompleted: {
+ $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'),
+ },
+ 'challenge.id': {$exists: false},
+ }).exec(); // TODO catch error or at least log it, wait before returning?
let ranCron = user.isModified();
let quest = common.content.quests[user.party.quest.key];
// if (ranCron) res.locals.wasModified = true; // TODO remove?
- if (!ranCron) return next(null, user); // TODO why are we passing user to next?
- // TODO Group.tavernBoss(user, progress);
- if (!quest || true /* TODO remove */) return user.save(next);
+ if (!ranCron) return next();
- // If user is on a quest, roll for boss & player, or handle collections
- // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
- // TODO do
- /* async.waterfall([
- function(cb){
- user.save(cb); // make sure to save the cron effects
- },
- function(saved, count, cb){
- var type = quest.boss ? 'boss' : 'collect';
- Group[type+'Quest'](user,progress,cb);
- },
- function(){
- var cb = arguments[arguments.length-1];
- // User has been updated in boss-grapple, reload
- User.findById(user._id, cb);
- }
- ], function(err, saved) {
- res.locals.user = saved;
- next(err,saved);
- user = progress = quest = null;
- });*/
+ // Group.tavernBoss(user, progress);
+
+ // Save user and tasks
+ let toSave = [user.save()];
+ tasks.forEach(task => {
+ if (task.isModified) toSave.push(task.save());
+ });
+ Q.all(toSave)
+ .then(saved => {
+ user = res.locals.user = saved[0];
+ if (!quest) return;
+
+ // If user is on a quest, roll for boss & player, or handle collections
+ let questType = quest.boss ? 'boss' : 'collect';
+ // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
+ return Group[`${questType}Quest`](user, progress)
+ .then(() => User.findById(user._id).exec()) // fetch the updated user...
+ .then(updatedUser => {
+ res.locals.user = updatedUser;
+ });
+ })
+ .then(() => next())
+ .catch(next);
});
}
diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js
index f3b4f65cdb..0590ad9f55 100644
--- a/website/src/middlewares/api-v3/errorHandler.js
+++ b/website/src/middlewares/api-v3/errorHandler.js
@@ -74,5 +74,7 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l
if (responseErr.errors) jsonRes.errors = responseErr.errors;
- return res.respond(responseErr.httpCode, jsonRes);
+ // In some occasions like when invalid JSON is supplied `res.respond` might be not yet avalaible,
+ // in this case we use the standard res.status(...).json(...)
+ return res.respond ? res.respond(responseErr.httpCode, jsonRes) : res.status(responseErr.httpCode).json(jsonRes);
}
diff --git a/website/src/middlewares/api-v3/index.js b/website/src/middlewares/api-v3/index.js
index fdb2db7bd8..01da4d4004 100644
--- a/website/src/middlewares/api-v3/index.js
+++ b/website/src/middlewares/api-v3/index.js
@@ -9,6 +9,7 @@ import notFoundHandler from './notFound';
import nconf from 'nconf';
import morgan from 'morgan';
import responseHandler from './response';
+import setupBody from './setupBody';
const IS_PROD = nconf.get('IS_PROD');
const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING');
@@ -23,6 +24,7 @@ export default function attachMiddlewares (app) {
app.use(bodyParser.json());
app.use(expressValidator());
app.use(analytics);
+ app.use(setupBody);
app.use(responseHandler);
app.use(getUserLanguage);
diff --git a/website/src/middlewares/api-v3/setupBody.js b/website/src/middlewares/api-v3/setupBody.js
new file mode 100644
index 0000000000..82e1b798bb
--- /dev/null
+++ b/website/src/middlewares/api-v3/setupBody.js
@@ -0,0 +1,5 @@
+// TODO tests?
+export default function setupBodyMiddleware (req, res, next) {
+ req.body = req.body || {};
+ next();
+}
diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js
index a59f0d0939..b67e8b7832 100644
--- a/website/src/models/challenge.js
+++ b/website/src/models/challenge.js
@@ -1,120 +1,216 @@
-var mongoose = require("mongoose");
-var Schema = mongoose.Schema;
-var shared = require('../../../common');
-var _ = require('lodash');
-var TaskSchemas = require('./task');
+import mongoose from 'mongoose';
+import Q from 'q';
+import validator from 'validator';
+import baseModel from '../libs/api-v3/baseModel';
+import _ from 'lodash';
+import * as Tasks from './task';
+import { model as User } from './user';
-var ChallengeSchema = new Schema({
- _id: {type: String, 'default': shared.uuid},
- name: String,
- shortName: String,
+let Schema = mongoose.Schema;
+
+let schema = new Schema({
+ name: {type: String, required: true},
+ shortName: {type: String, required: true},
description: String,
- official: {type: Boolean,'default':false},
- //habits: [TaskSchemas.HabitSchema],
- //dailys: [TaskSchemas.DailySchema],
- //todos: [TaskSchemas.TodoSchema],
- //rewards: [TaskSchemas.RewardSchema],
- leader: {type: String, ref: 'User'},
- group: {type: String, ref: 'Group'},
- timestamp: {type: Date, 'default': Date.now},
- members: [{type: String, ref: 'User'}],
- memberCount: {type: Number, 'default': 0},
- prize: {type: Number, 'default': 0}
+ official: {type: Boolean, default: false},
+ tasksOrder: {
+ habits: [{type: String, ref: 'Task'}],
+ dailys: [{type: String, ref: 'Task'}],
+ todos: [{type: String, ref: 'Task'}],
+ rewards: [{type: String, ref: 'Task'}],
+ },
+ leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
+ group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
+ memberCount: {type: Number, default: 1},
+ prize: {type: Number, default: 0, min: 0}, // TODO no update?
});
-ChallengeSchema.virtual('tasks').get(function () {
- var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards);
- var tasks = _.object(_.pluck(tasks,'id'), tasks);
- return tasks;
+schema.plugin(baseModel, {
+ noSet: ['_id', 'memberCount', 'tasksOrder'],
+ timestamps: true,
});
-ChallengeSchema.methods.toJSON = function(){
- var doc = this.toObject();
- doc._isMember = this._isMember;
- return doc;
-}
+// A list of additional fields that cannot be updated (but can be set on creation)
+let noUpdate = ['group', 'official', 'shortName', 'prize'];
+schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
+ return this.sanitize(updateObj, noUpdate);
+};
-// --------------
-// Syncing logic
-// --------------
+// Returns true if user is a member of the challenge
+schema.methods.isMember = function isChallengeMember (user) {
+ return user.challenges.indexOf(this._id) !== -1;
+};
-function syncableAttrs(task) {
- var t = (task.toObject) ? task.toObject() : task; // lodash doesn't seem to like _.omit on EmbeddedDocument
+// Returns true if the user can modify (close, selectWinner, ...) the challenge
+schema.methods.canModify = function canModifyChallenge (user) {
+ return user.contributor.admin || this.leader === user._id;
+};
+
+// Returns true if user has access to the challenge (can join)
+schema.methods.hasAccess = function hasAccessToChallenge (user, group) {
+ if (group.type === 'guild' && group.privacy === 'public') return true;
+ return user.getGroups().indexOf(this.group) !== -1;
+};
+
+// Returns true if user can view the challenge
+// Different from hasAccess because you can see challenges of groups you've been removed from if you're partecipating in them
+schema.methods.canView = function canViewChallenge (user, group) {
+ if (this.isMember(user)) return true;
+ return this.hasAccess(user, group);
+};
+
+// Takes a Task document and return a plain object of attributes that can be synced to the user
+function _syncableAttrs (task) {
+ let t = task.toObject(); // lodash doesn't seem to like _.omit on Document
// only sync/compare important attrs
- var omitAttrs = 'challenge history tags completed streak notes'.split(' ');
- if (t.type != 'reward') omitAttrs.push('value');
+ let omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes']; // TODO what to do with updatedAt?
+ if (t.type !== 'reward') omitAttrs.push('value');
return _.omit(t, omitAttrs);
}
-/**
- * Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers
- */
-function comparableData(obj) {
- return JSON.stringify(
- _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards))
- .sortBy('id') // we don't want to update if they're sort-order is different
- .transform(function(result, task){
- result.push(syncableAttrs(task));
- })
- .value())
-}
-
-ChallengeSchema.methods.isOutdated = function(newData) {
- return comparableData(this) !== comparableData(newData);
-}
-
-/**
- * Syncs all new tasks, deleted tasks, etc to the user object
- * @param user
- * @return nothing, user is modified directly. REMEMBER to save the user!
- */
-ChallengeSchema.methods.syncToUser = function(user, cb) {
- if (!user) return;
- var self = this;
- self.shortName = self.shortName || self.name;
+// Sync challenge to user, including tasks and tags.
+// Used when user joins the challenge or to force sync.
+schema.methods.syncToUser = async function syncChallengeToUser (user) {
+ let challenge = this;
+ challenge.shortName = challenge.shortName || challenge.name;
// Add challenge to user.challenges
- if (!_.contains(user.challenges, self._id)) {
- user.challenges.push(self._id);
- }
+ if (!_.contains(user.challenges, challenge._id)) user.challenges.push(challenge._id);
// Sync tags
- var tags = user.tags || [];
- var i = _.findIndex(tags, {id: self._id})
- if (~i) {
- if (tags[i].name !== self.shortName) {
+ let userTags = user.tags;
+ let i = _.findIndex(userTags, {_id: challenge._id});
+
+ if (i !== -1) {
+ if (userTags[i].name !== challenge.shortName) {
// update the name - it's been changed since
- user.tags[i].name = self.shortName;
+ userTags[i].name = challenge.shortName;
}
} else {
- user.tags.push({
- id: self._id,
- name: self.shortName,
- challenge: true
+ userTags.push({
+ _id: challenge._id,
+ name: challenge.shortName,
+ challenge: true,
});
}
- // Sync new tasks and updated tasks
- _.each(self.tasks, function(task){
- var list = user[task.type+'s'];
- var userTask = user.tasks[task.id] || (list.push(syncableAttrs(task)), list[list.length-1]);
- if (!userTask.notes) userTask.notes = task.notes; // don't override the notes, but provide it if not provided
- userTask.challenge = {id:self._id};
- userTask.tags = userTask.tags || {};
- userTask.tags[self._id] = true;
- _.merge(userTask, syncableAttrs(task));
- })
+ let [challengeTasks, userTasks] = await Q.all([
+ // Find original challenge tasks
+ Tasks.Task.find({
+ userId: {$exists: false},
+ 'challenge.id': challenge._id,
+ }).exec(),
+ // Find user's tasks linked to this challenge
+ Tasks.Task.find({
+ userId: user._id,
+ 'challenge.id': challenge._id,
+ }).exec(),
+ ]);
+
+ let toSave = []; // An array of things to save
+
+ challengeTasks.forEach(chalTask => {
+ let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id);
+
+ if (!matchingTask) { // If the task is new, create it
+ matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask)));
+ matchingTask.challenge = {taskId: chalTask._id, id: challenge._id};
+ matchingTask.userId = user._id;
+ user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id);
+ } else {
+ _.merge(matchingTask, _syncableAttrs(chalTask));
+ // Make sure the task is in user.tasksOrder TODO necessary?
+ let orderList = user.tasksOrder[`${chalTask.type}s`];
+ if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id);
+ }
+
+ if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided
+ if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing
+ toSave.push(matchingTask.save());
+ });
// Flag deleted tasks as "broken"
- _.each(user.tasks, function(task){
- if (task.challenge && task.challenge.id==self._id && !self.tasks[task.id]) {
- task.challenge.broken = 'TASK_DELETED';
+ userTasks.forEach(userTask => {
+ if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) {
+ userTask.challenge.broken = 'TASK_DELETED';
+ toSave.push(userTask.save());
}
- })
+ });
- user.save(cb);
+ toSave.push(user.save());
+ return Q.all(toSave);
};
+async function _fetchMembersIds (challengeId) {
+ return (await User.find({challenges: {$in: [challengeId]}}).select('_id').lean().exec()).map(member => member._id);
+}
-module.exports.schema = ChallengeSchema;
-module.exports.model = mongoose.model("Challenge", ChallengeSchema);
+// Add a new task to challenge members
+schema.methods.addTasks = async function challengeAddTasks (tasks) {
+ let challenge = this;
+ let membersIds = await _fetchMembersIds(challenge._id);
+
+ // Sync each user sequentially
+ for (let memberId of membersIds) {
+ let updateTasksOrderQ = {$push: {}};
+ let toSave = [];
+
+ // TODO eslint complaints about ahving a function inside a loop -> make sure it works
+ tasks.forEach(chalTask => { // eslint-disable-line no-loop-func
+ let userTask = new Tasks[chalTask.type](Tasks.Task.sanitizeCreate(_syncableAttrs(chalTask)));
+ userTask.challenge = {taskId: chalTask._id, id: challenge._id};
+ userTask.userId = memberId;
+
+ let tasksOrderList = updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`];
+ if (!tasksOrderList) {
+ updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`] = {
+ $position: 0, // unshift
+ $each: [userTask._id],
+ };
+ } else {
+ tasksOrderList.$each.unshift(userTask._id);
+ }
+
+ toSave.push(userTask.save());
+ });
+
+ // Update the user
+ toSave.unshift(User.update({_id: memberId}, updateTasksOrderQ).exec());
+ await Q.all(toSave); // eslint-disable-line babel/no-await-in-loop
+ }
+};
+
+// Sync updated task to challenge members
+schema.methods.updateTask = async function challengeUpdateTask (task) {
+ let challenge = this;
+
+ let updateCmd = {$set: {}};
+
+ _syncableAttrs(task).forEach((value, key) => {
+ updateCmd.$set[key] = value;
+ });
+
+ // TODO reveiw
+ // Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks
+ await Tasks.Task.update({
+ userId: {$exists: true},
+ 'challenge.id': challenge.id,
+ 'challenge.taskId': task._id,
+ }, updateCmd, {multi: true}).exec();
+};
+
+// Remove a task from challenge members
+schema.methods.removeTask = async function challengeRemoveTask (task) {
+ let challenge = this;
+
+ // Set the task as broken
+ await Tasks.Task.update({
+ userId: {$exists: true},
+ 'challenge.id': challenge.id,
+ 'challenge.taskId': task._id,
+ }, {
+ $set: {'challenge.broken': 'TASK_DELETED'}, // TODO what about updatedAt?
+ }, {multi: true}).exec();
+};
+
+export let model = mongoose.model('Challenge', schema);
diff --git a/website/src/models/group.js b/website/src/models/group.js
index 0d3011cc38..f89147f246 100644
--- a/website/src/models/group.js
+++ b/website/src/models/group.js
@@ -1,26 +1,34 @@
-var mongoose = require("mongoose");
-var Schema = mongoose.Schema;
-var User = require('./user').model;
-var shared = require('../../../common');
-var _ = require('lodash');
-var async = require('async');
-var logging = require('../libs/api-v2/logging');
-var Challenge = require('./../models/challenge').model;
-var firebase = require('../libs/api-v2/firebase');
+import mongoose from 'mongoose';
+import {
+ model as User,
+ nameFields,
+} from './user';
+import shared from '../../../common';
+import _ from 'lodash';
+import { model as Challenge} from './challenge';
+import validator from 'validator';
+import { removeFromArray } from '../libs/api-v3/collectionManipulators';
+import { InternalServerError } from '../libs/api-v3/errors';
+import * as firebase from '../libs/api-v2/firebase';
+import baseModel from '../libs/api-v3/baseModel';
+import { sendTxn as sendTxnEmail } from '../libs/api-v3/email';
+import { quests as questScrolls } from '../../../common/script/content';
+import Q from 'q';
+import nconf from 'nconf';
-// NOTE any change to groups' members in MongoDB will have to be run through the API
+let Schema = mongoose.Schema;
+
+// NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API
// changes made directly to the db will cause Firebase to get out of sync
-var GroupSchema = new Schema({
- _id: {type: String, 'default': shared.uuid},
- name: String,
+export let schema = new Schema({
+ // TODO don't break validation on _id === 'habitrpg'
+ name: {type: String, required: true},
description: String,
- leader: {type: String, ref: 'User'},
- members: [{type: String, ref: 'User'}],
- invites: [{type: String, ref: 'User'}],
- type: {type: String, "enum": ['guild', 'party']},
- privacy: {type: String, "enum": ['private', 'public'], 'default':'private'},
- //_v: {type: Number,'default': 0},
- chat: Array,
+ leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
+ type: {type: String, enum: ['guild', 'party'], required: true},
+ privacy: {type: String, enum: ['private', 'public'], default: 'private', required: true},
+ // _v: {type: Number,'default': 0}, // TODO ?
+ chat: Array, // TODO ?
/*
# [{
# timestamp: Date
@@ -32,41 +40,62 @@ var GroupSchema = new Schema({
# }]
*/
leaderOnly: { // restrict group actions to leader (members can't do them)
- challenges: {type:Boolean, 'default':false},
- //invites: {type:Boolean, 'default':false}
+ challenges: {type: Boolean, default: false, required: true},
+ // invites: {type:Boolean, 'default':false} // TODO ?
},
- memberCount: {type: Number, 'default': 0},
- challengeCount: {type: Number, 'default': 0},
- balance: Number,
+ memberCount: {type: Number, default: 1},
+ challengeCount: {type: Number, default: 0},
+ balance: {type: Number, default: 0},
logo: String,
leaderMessage: String,
- challenges: [{type:'String', ref:'Challenge'}], // do we need this? could depend on back-ref instead (Challenge.find({group:GID}))
quest: {
key: String,
- active: {type:Boolean, 'default':false},
- leader: {type:String, ref:'User'},
- progress:{
+ active: {type: Boolean, default: false},
+ leader: {type: String, ref: 'User'},
+ progress: {
hp: Number,
- collect: {type:Schema.Types.Mixed, 'default':{}}, // {feather: 5, ingot: 3}
+ collect: {type: Schema.Types.Mixed, default: () => {
+ return {};
+ }}, // {feather: 5, ingot: 3}
rage: Number, // limit break / "energy stored in shell", for explosion-attacks
},
- //Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click
- //'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them.
- //TODO when booting user, remove from .joined and check again if we can now start the quest
- members: Schema.Types.Mixed,
- extra: Schema.Types.Mixed
- }
+ // Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click
+ // 'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them.
+ // TODO when booting user, remove from .joined and check again if we can now start the quest
+ // TODO as long as quests are party only we can keep it here
+ // TODO are we sure we need this type of default for this to work?
+ members: {type: Schema.Types.Mixed, default: () => {
+ return {};
+ }},
+ extra: {type: Schema.Types.Mixed, default: () => {
+ return {};
+ }},
+ },
}, {
- strict: 'throw',
- minimize: false // So empty objects are returned
+ strict: true,
+ minimize: false, // So empty objects are returned
});
+schema.plugin(baseModel, {
+ noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount'],
+});
+
+// A list of additional fields that cannot be updated (but can be set on creation)
+let noUpdate = ['privacy', 'type'];
+schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
+ return this.sanitize(updateObj, noUpdate);
+};
+
+// Basic fields to fetch for populating a group info
+export let basicFields = 'name type privacy';
+
+// TODO migration
/**
* Derby duplicated stuff. This is a temporary solution, once we're completely off derby we'll run an mongo migration
* to remove duplicates, then take these fucntions out
*/
-function removeDuplicates(doc){
+/* function removeDuplicates(doc){
// Remove duplicate members
if (doc.members) {
var uniqMembers = _.uniq(doc.members);
@@ -74,425 +103,526 @@ function removeDuplicates(doc){
doc.members = uniqMembers;
}
}
-}
+}*/
-// FIXME this isn't always triggered, since we sometimes use update() or findByIdAndUpdate()
-// @see https://github.com/LearnBoost/mongoose/issues/964
-GroupSchema.pre('save', function(next){
- removeDuplicates(this);
- this.memberCount = _.size(this.members);
- this.challengeCount = _.size(this.challenges);
+// TODO test
+schema.pre('remove', true, async function preRemoveGroup (next, done) {
next();
-})
-
-GroupSchema.pre('remove', function(next) {
- var group = this;
- async.waterfall([
- function(cb) {
- var invitationQuery = {};
- var groupType = group.type;
- //Add an 's' to group type guild because the model has the plural version
- if (group.type == "guild") groupType += "s";
- invitationQuery['invitations.' + groupType + '.id'] = group._id;
- User.find(invitationQuery, cb);
- },
- function(users, cb) {
- if (users) {
- users.forEach(function (user, index, array) {
- if ( group.type == "party" ) {
- user.invitations.party = {};
- } else {
- var i = _.findIndex(user.invitations.guilds, {id: group._id});
- user.invitations.guilds.splice(i, 1);
- }
- user.save();
- });
- }
- cb();
- }
- ], next);
+ try {
+ await this.removeGroupInvitations();
+ done();
+ } catch (err) {
+ done(err);
+ }
});
-GroupSchema.post('remove', function(group) {
+schema.post('remove', function postRemoveGroup (group) {
firebase.deleteGroup(group._id);
});
-GroupSchema.methods.toJSON = function(){
- var doc = this.toObject();
- removeDuplicates(doc);
- doc._isMember = this._isMember;
+schema.statics.getGroup = function getGroup (options = {}) {
+ let {user, groupId, fields, optionalMembership = false, populateLeader = false} = options;
+ let query;
- //fix(groups): temp fix to remove chat entries stored as strings (not sure why that's happening..).
- // Required as angular 1.3 is strict on dupes, and no message.id to `track by`
- _.remove(doc.chat,function(msg){return !msg.id});
+ // When optionalMembership is true it's not required for the user to be a member of the group
+ if (groupId === 'party' || user.party._id === groupId) {
+ query = {type: 'party', _id: user.party._id};
+ } else if (optionalMembership === true) {
+ query = {_id: groupId};
+ } else if (user.guilds.indexOf(groupId) !== -1) {
+ query = {type: 'guild', _id: groupId};
+ } else {
+ query = {type: 'guild', privacy: 'public', _id: groupId};
+ }
- // @see pre('save') comment above
- this.memberCount = _.size(this.members);
- this.challengeCount = _.size(this.challenges);
+ let mQuery = this.findOne(query);
+ if (fields) mQuery.select(fields);
+ if (populateLeader === true) mQuery.populate('leader', nameFields);
+ return mQuery.exec();
+};
- return doc;
-}
+// When converting to json remove chat messages with more than 1 flag and remove all flags info
+// unless the user is an admin
+// Not putting into toJSON because there we can't access user
+schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) {
+ let toJSON = group.toJSON();
+ if (!user.contributor.admin) {
+ _.remove(toJSON.chat, chatMsg => {
+ chatMsg.flags = {};
+ return chatMsg.flagCount >= 2;
+ });
+ }
+ return toJSON;
+};
-var chatDefaults = module.exports.chatDefaults = function(msg,user){
- var message = {
+schema.methods.removeGroupInvitations = async function removeGroupInvitations () {
+ let group = this;
+
+ let usersToRemoveInvitationsFrom = await User.find({
+ // TODO id -> _id ?
+ [`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id,
+ }).exec();
+
+ let userUpdates = usersToRemoveInvitationsFrom.map(user => {
+ if (group.type === 'party') {
+ user.invitations.party = {}; // TODO mark modified
+ } else {
+ removeFromArray(user.invitations.guilds, { id: group._id });
+ }
+ return user.save();
+ });
+
+ return Q.all(userUpdates);
+};
+
+// Return true if user is a member of the group
+schema.methods.isMember = function isGroupMember (user) {
+ if (this._id === 'habitrpg') {
+ return true; // everyone is considered part of the tavern
+ } else if (this.type === 'party') {
+ return user.party._id === this._id ? true : false;
+ } else { // guilds
+ return user.guilds.indexOf(this._id) !== -1;
+ }
+};
+
+export function chatDefaults (msg, user) {
+ let message = {
id: shared.uuid(),
text: msg,
- timestamp: +new Date,
+ timestamp: Number(new Date()),
likes: {},
flags: {},
- flagCount: 0
+ flagCount: 0,
};
+
if (user) {
_.defaults(message, {
uuid: user._id,
contributor: user.contributor && user.contributor.toObject(),
backer: user.backer && user.backer.toObject(),
- user: user.profile.name
+ user: user.profile.name,
});
} else {
message.uuid = 'system';
}
+
return message;
}
-GroupSchema.methods.sendChat = function(message, user){
- var group = this;
- group.chat.unshift(chatDefaults(message,user));
- group.chat.splice(200);
- // Kick off chat notifications in the background.
- var lastSeenUpdate = {$set:{}, $inc:{_v:1}};
- lastSeenUpdate['$set']['newMessages.'+group._id] = {name:group.name,value:true};
- if (group._id == 'habitrpg') {
+
+schema.methods.sendChat = function sendChat (message, user) {
+ this.chat.unshift(chatDefaults(message, user));
+ this.chat.splice(200);
+
+ // Kick off chat notifications in the background. // TODO refactor
+ let lastSeenUpdate = {$set: {}, $inc: {_v: 1}};
+ lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true};
+
+ if (this._id === 'habitrpg') {
// TODO For Tavern, only notify them if their name was mentioned
// var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names?
// User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec();
} else {
- mongoose.model('User').update({_id:{$in:group.members, $ne: user ? user._id : ''}},lastSeenUpdate,{multi:true}).exec();
- }
-}
+ let query = {};
-var cleanQuestProgress = function(merge){
- var clean = {
+ if (this.type === 'party') {
+ query['party._id'] = this._id;
+ } else {
+ query.guilds = this._id;
+ }
+
+ query._id = { $ne: user ? user._id : ''};
+
+ User.update(query, lastSeenUpdate, {multi: true}).exec();
+ }
+};
+
+schema.methods.startQuest = async function startQuest (user) {
+ // not using i18n strings because these errors are meant for devs who forgot to pass some parameters
+ if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method');
+ if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest');
+ if (this.quest.active) throw new InternalServerError('Quest is already active');
+
+ let userIsParticipating = this.quest.members[user._id];
+ let quest = questScrolls[this.quest.key];
+ let collected = {};
+ if (quest.collect) {
+ collected = _.transform(quest.collect, (result, n, itemToCollect) => {
+ result[itemToCollect] = 0;
+ });
+ }
+
+ this.markModified('quest');
+ this.quest.active = true;
+ if (quest.boss) {
+ this.quest.progress.hp = quest.boss.hp;
+ if (quest.boss.rage) this.quest.progress.rage = 0;
+ } else if (quest.collect) {
+ this.quest.progress.collect = collected;
+ }
+
+ // Changes quest.members to only include participating members
+ // TODO: is that important? What does it matter if the non-participating members
+ // are still on the object?
+ // TODO: is it important to run clean quest progress on non-members like we did in v2?
+ this.quest.members = _.pick(this.quest.members, _.identity);
+ let nonUserQuestMembers = _.keys(this.quest.members);
+ removeFromArray(nonUserQuestMembers, user._id);
+
+ if (userIsParticipating) {
+ user.party.quest.key = this.quest.key;
+ user.party.quest.progress.down = 0;
+ user.party.quest.collect = collected;
+ user.party.quest.completed = null;
+ user.markModified('party.quest');
+ }
+
+ // Remove the quest from the quest leader items (if they are the current user)
+ if (this.quest.leader === user._id) {
+ user.items.quests[this.quest.key] -= 1;
+ user.markModified('items.quests');
+ } else { // another user is starting the quest, update the leader separately
+ await User.update({_id: this.quest.leader}, {
+ $inc: {
+ [`items.quests.${this.quest.key}`]: -1,
+ },
+ }).exec();
+ }
+
+ // update the remaining users
+ await User.update({
+ _id: { $in: nonUserQuestMembers },
+ }, {
+ $set: {
+ 'party.quest.key': this.quest.key,
+ 'party.quest.progress.down': 0,
+ 'party.quest.collect': collected,
+ 'party.quest.completed': null,
+ },
+ }, { multi: true }).exec();
+
+ // send notifications in the background without blocking
+ User.find(
+ { _id: { $in: nonUserQuestMembers } },
+ 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications profile.name',
+ ).exec().then(membersToEmail => {
+ membersToEmail = _.filter(membersToEmail, (member) => {
+ return member.preferences.emailNotifications.questStarted !== false &&
+ member._id !== user._id;
+ });
+ sendTxnEmail(membersToEmail, 'quest-started', [
+ { name: 'PARTY_URL', content: '/#/options/groups/party' },
+ ]);
+ });
+};
+
+// return a clean object for user.quest
+function _cleanQuestProgress (merge) {
+ let clean = {
key: null,
progress: {
up: 0,
down: 0,
- collect: {}
+ collect: {},
},
completed: null,
- RSVPNeeded: false
+ RSVPNeeded: false,
};
- merge = merge || {progress:{}};
- _.merge(clean, _.omit(merge,'progress'));
- _.merge(clean.progress, merge.progress);
- return clean;
-}
-GroupSchema.statics.cleanQuestProgress = cleanQuestProgress;
-// Participants: Grant rewards & achievements, finish quest
-GroupSchema.methods.finishQuest = function(quest, cb) {
- var group = this;
- var questK = quest.key;
- var updates = {$inc:{},$set:{}};
-
- updates['$inc']['achievements.quests.' + questK] = 1;
- updates['$inc']['stats.gp'] = +quest.drop.gp;
- updates['$inc']['stats.exp'] = +quest.drop.exp;
- updates['$inc']['_v'] = 1;
- if (group._id == 'habitrpg') {
- updates['$set']['party.quest.completed'] = questK; // Just show the notif
- } else {
- updates['$set']['party.quest'] = cleanQuestProgress({completed: questK}); // clear quest progress
+ if (merge) {
+ _.merge(clean, _.omit(merge, 'progress'));
+ if (merge.progress) _.merge(clean.progress, merge.progress);
}
- _.each(quest.drop.items, function(item){
- var dropK = item.key;
+ return clean;
+}
+
+// TODO move to User.cleanQuestProgress?
+schema.statics.cleanQuestProgress = _cleanQuestProgress;
+
+// returns a clean object for group.quest
+schema.statics.cleanGroupQuest = function cleanGroupQuest () {
+ return {
+ key: null,
+ active: false,
+ leader: null,
+ progress: {
+ collect: {},
+ },
+ members: {},
+ };
+};
+
+// Participants: Grant rewards & achievements, finish quest
+// Returns the promise from update().exec()
+schema.methods.finishQuest = function finishQuest (quest) {
+ let questK = quest.key;
+ let updates = {$inc: {}, $set: {}};
+
+ updates.$inc[`achievements.quests.${questK}`] = 1;
+ updates.$inc['stats.gp'] = Number(quest.drop.gp); // TODO are this castings necessary?
+ updates.$inc['stats.exp'] = Number(quest.drop.exp);
+ updates.$inc._v = 1;
+
+ if (this._id === 'habitrpg') {
+ updates.$set['party.quest.completed'] = questK; // Just show the notif
+ } else {
+ updates.$set['party.quest'] = _cleanQuestProgress({completed: questK}); // clear quest progress
+ }
+
+ _.each(quest.drop.items, (item) => {
+ let dropK = item.key;
+
switch (item.type) {
case 'gear':
// TODO This means they can lose their new gear on death, is that what we want?
- updates['$set']['items.gear.owned.'+dropK] = true;
+ updates.$set[`items.gear.owned.${dropK}`] = true;
break;
case 'eggs':
case 'food':
case 'hatchingPotions':
case 'quests':
- updates['$inc']['items.'+item.type+'.'+dropK] = _.where(quest.drop.items,{type:item.type,key:item.key}).length;
+ updates.$inc[`items.${item.type}.${dropK}`] = _.where(quest.drop.items, {type: item.type, key: item.key}).length;
break;
case 'pets':
- updates['$set']['items.pets.'+dropK] = 5;
+ updates.$set[`items.pets.${dropK}`] = 5;
break;
case 'mounts':
- updates['$set']['items.mounts.'+dropK] = true;
+ updates.$set[`items.mounts.${dropK}`] = true;
break;
}
- })
- var q = group._id === 'habitrpg' ? {} : {_id:{$in:_.keys(group.quest.members)}};
- group.quest = {};group.markModified('quest');
- mongoose.model('User').update(q, updates, {multi:true}, cb);
-}
+ });
-function isOnQuest(user,progress,group){
+ let q = this._id === 'habitrpg' ? {} : {_id: {$in: _.keys(this.quest.members)}};
+ this.quest = {};
+ this.markModified('quest');
+ return User.update(q, updates, {multi: true}).exec();
+};
+
+function _isOnQuest (user, progress, group) {
return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true;
}
-GroupSchema.statics.collectQuest = function(user, progress, cb) {
- this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){
- if (!isOnQuest(user,progress,group)) return cb(null);
- var quest = shared.content.quests[group.quest.key];
+// Returns a promise
+schema.statics.collectQuest = async function collectQuest (user, progress) {
+ let group = await this.getGroup({user, groupId: 'party'});
- _.each(progress.collect,function(v,k){
- group.quest.progress.collect[k] += v;
- });
+ if (!_isOnQuest(user, progress, group)) return;
+ let quest = shared.content.quests[group.quest.key];
- var foundText = _.reduce(progress.collect, function(m,v,k){
- m.push(v + ' ' + quest.collect[k].text('en'));
- return m;
- }, []);
- foundText = foundText ? foundText.join(', ') : 'nothing';
- group.sendChat("`" + user.profile.name + " found "+foundText+".`");
- group.markModified('quest.progress.collect');
+ _.each(progress.collect, (v, k) => {
+ group.quest.progress.collect[k] += v;
+ });
- // Still needs completing
- if (_.find(shared.content.quests[group.quest.key].collect, function(v,k){
- return group.quest.progress.collect[k] < v.count;
- })) return group.save(cb);
+ let foundText = _.reduce(progress.collect, (m, v, k) => {
+ m.push(`${v} ${quest.collect[k].text('en')}`);
+ return m;
+ }, []);
- async.series([
- function(cb2){
- group.finishQuest(quest,cb2);
- },
- function(cb2){
- group.sendChat('`All items found! Party has received their rewards.`');
- group.save(cb2);
- }
- ],cb);
- })
-}
+ foundText = foundText ? foundText.join(', ') : 'nothing';
+ group.sendChat(`\`${user.profile.name} found ${foundText}.\``);
+ group.markModified('quest.progress.collect');
+
+ // Still needs completing
+ if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => {
+ return group.quest.progress.collect[k] < v.count;
+ })) return group.save();
+
+ await group.finishQuest(quest);
+ group.sendChat('`All items found! Party has received their rewards.`');
+ return group.save();
+};
+
+schema.statics.bossQuest = async function bossQuest (user, progress) {
+ let group = await this.getGroup({user, groupId: 'party'});
+ if (!_isOnQuest(user, progress, group)) return;
+
+ let quest = shared.content.quests[group.quest.key];
+ if (!progress || !quest) return; // FIXME why is this ever happening, progress should be defined at this point, log?
+
+ let down = progress.down * quest.boss.str; // multiply by boss strength
+
+ group.quest.progress.hp -= progress.up;
+ // TODO Create a party preferred language option so emits like this can be localized
+ group.sendChat(`\`${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage, ${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.\``);
+
+ // If boss has Rage, increment Rage as well
+ if (quest.boss.rage) {
+ group.quest.progress.rage += Math.abs(down);
+ if (group.quest.progress.rage >= quest.boss.rage.value) {
+ group.sendChat(quest.boss.rage.effect('en'));
+ group.quest.progress.rage = 0;
+
+ // TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage
+ if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing;
+ if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp;
+ }
+ }
+
+ // Everyone takes damage
+ await User.update({
+ _id: {$in: _.keys(group.quest.members)},
+ }, {
+ $inc: {'stats.hp': down, _v: 1},
+ }, {multi: true}).exec();
+ // Apply changes the currently cronning user locally so we don't have to reload it to get the updated state
+ // TODO how to mark not modified? https://github.com/Automattic/mongoose/pull/1167
+ // must be notModified or otherwise could overwrite future changes
+ // if (down) user.stats.hp += down;
+
+ // Boss slain, finish quest
+ if (group.quest.progress.hp <= 0) {
+ group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``);
+
+ // Participants: Grant rewards & achievements, finish quest
+ await group.finishQuest(shared.content.quests[group.quest.key]);
+ return group.save();
+ }
+
+ return group.save();
+};
// to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})`
-module.exports.tavernQuest = {};
-var tavernQ = {_id:'habitrpg','quest.key':{$ne:null}};
-process.nextTick(function(){
- mongoose.model('Group').findOne(tavernQ, function(err,tavern){
+// we export an empty object that is then populated with the query-returned data
+export let tavernQuest = {};
+let tavernQ = {_id: 'habitrpg', 'quest.key': {$ne: null}};
+
+// we use process.nextTick because at this point the model is not yet avalaible
+process.nextTick(() => {
+ model // eslint-disable-line no-use-before-define
+ .findOne(tavernQ).exec()
+ .then(tavern => {
if (!tavern) return; // No tavern quest
- var quest = tavern.quest.toObject();
// Using _assign so we don't lose the reference to the exported tavernQuest
- _.assign(module.exports.tavernQuest, quest);
+ _.assign(tavernQuest, tavern.quest.toObject());
+ })
+ .catch(err => {
+ throw err;
});
});
-GroupSchema.statics.tavernBoss = function(user,progress) {
+// returns a promise
+schema.statics.tavernBoss = async function tavernBoss (user, progress) {
if (!progress) return;
// hack: prevent crazy damage to world boss
- var dmg = Math.min(900, Math.abs(progress.up||0)),
- rage = -Math.min(900, Math.abs(progress.down||0));
+ let dmg = Math.min(900, Math.abs(progress.up || 0));
+ let rage = -Math.min(900, Math.abs(progress.down || 0));
- async.waterfall([
- function(cb){
- mongoose.model('Group').findOne(tavernQ,cb);
- },
- function(tavern,cb){
- if (!(tavern && tavern.quest && tavern.quest.key)) return cb(true);
+ let tavern = await this.findOne(tavernQ).exec();
+ if (!(tavern && tavern.quest && tavern.quest.key)) return;
- var quest = shared.content.quests[tavern.quest.key];
- if (tavern.quest.progress.hp <= 0) {
- tavern.sendChat(quest.completionChat('en'));
- tavern.finishQuest(quest, function(){});
- tavern.save(cb);
- _.assign(module.exports.tavernQuest, {extra: null});
+ let quest = shared.content.quests[tavern.quest.key];
+
+ if (tavern.quest.progress.hp <= 0) {
+ tavern.sendChat(quest.completionChat('en'));
+ await tavern.finishQuest(quest);
+ _.assign(tavernQuest, {extra: null});
+ return tavern.save();
+ } else {
+ // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database,
+ // use those first - which allows us to update the boss on the go if things are too easy/hard.
+ if (!tavern.quest.extra) tavern.quest.extra = {};
+ tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def);
+ tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str);
+
+ if (tavern.quest.progress.rage >= quest.boss.rage.value) {
+ if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {};
+
+ let wd = tavern.quest.extra.worldDmg;
+ // Burnout attacks Ian, Seasonal Sorceress, tavern
+ let scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // eslint-disable-line no-nested-ternary
+
+ if (!scene) {
+ tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``);
+ tavern.quest.progress.rage = 0; // quest.boss.rage.value;
} else {
- // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database,
- // use those first - which allows us to update the boss on the go if things are too easy/hard.
- if (!tavern.quest.extra) tavern.quest.extra = {};
- tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def);
- tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str);
- if (tavern.quest.progress.rage >= quest.boss.rage.value) {
- if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {};
- var wd = tavern.quest.extra.worldDmg;
- var scene = wd.quests ? wd.seasonalShop ? wd.tavern ? false : 'tavern' : 'seasonalShop' : 'quests'; // Burnout attacks Ian, Seasonal Sorceress, tavern
- if (!scene) {
- tavern.sendChat('`'+quest.boss.name('en')+' tries to unleash '+quest.boss.rage.title('en')+', but is too tired.`');
- tavern.quest.progress.rage = 0 //quest.boss.rage.value;
- } else {
- tavern.sendChat(quest.boss.rage[scene]('en'));
- tavern.quest.extra.worldDmg[scene] = true;
- tavern.quest.extra.worldDmg.recent = scene;
- tavern.markModified('quest.extra.worldDmg');
- tavern.quest.progress.rage = 0;
- if (quest.boss.rage.healing) {
- tavern.quest.progress.hp += (quest.boss.rage.healing * tavern.quest.progress.hp);
- }
- }
+ tavern.sendChat(quest.boss.rage[scene]('en'));
+ tavern.quest.extra.worldDmg[scene] = true;
+ tavern.quest.extra.worldDmg.recent = scene;
+ tavern.markModified('quest.extra.worldDmg');
+ tavern.quest.progress.rage = 0;
+ if (quest.boss.rage.healing) {
+ tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp;
}
- if (quest.boss.desperation && (tavern.quest.progress.hp < quest.boss.desperation.threshold) && !tavern.quest.extra.desperate) {
- tavern.sendChat(quest.boss.desperation.text('en'));
- tavern.quest.extra.desperate = true;
- tavern.quest.extra.def = quest.boss.desperation.def;
- tavern.quest.extra.str = quest.boss.desperation.str;
- tavern.markModified('quest.extra');
- }
-
- _.assign(module.exports.tavernQuest, tavern.quest.toObject());
- tavern.save(cb);
}
}
- ],function(err,res){
- if (err === true) return; // no current quest
- if (err) return logging.error(err);
- dmg = rage = null;
- })
-}
-GroupSchema.statics.bossQuest = function(user, progress, cb) {
- this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){
- if (!isOnQuest(user,progress,group)) return cb(null);
- var quest = shared.content.quests[group.quest.key];
- if (!progress || !quest) return cb(null); // FIXME why is this ever happening, progress should be defined at this point
- var down = progress.down * quest.boss.str; // multiply by boss strength
-
- group.quest.progress.hp -= progress.up;
- group.sendChat("`" + user.profile.name + " attacks " + quest.boss.name('en') + " for " + (progress.up.toFixed(1)) + " damage, " + quest.boss.name('en') + " attacks party for " + Math.abs(down).toFixed(1) + " damage.`"); //TODO Create a party preferred language option so emits like this can be localized
-
- // If boss has Rage, increment Rage as well
- if (quest.boss.rage) {
- group.quest.progress.rage += Math.abs(down);
- if (group.quest.progress.rage >= quest.boss.rage.value) {
- group.sendChat(quest.boss.rage.effect('en'));
- group.quest.progress.rage = 0;
- if (quest.boss.rage.healing) group.quest.progress.hp += (group.quest.progress.hp * quest.boss.rage.healing); //TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage
- if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp;
- }
- }
- // Everyone takes damage
- var series = [
- function(cb2){
- mongoose.models.User.update({_id:{$in: _.keys(group.quest.members)}}, {$inc:{'stats.hp':down, _v:1}}, {multi:true}, cb2);
- }
- ]
-
- // Boss slain, finish quest
- if (group.quest.progress.hp <= 0) {
- group.sendChat('`You defeated ' + quest.boss.name('en') + '! Questing party members receive the rewards of victory.`');
- // Participants: Grant rewards & achievements, finish quest
- series.push(function(cb2){
- group.finishQuest(quest,cb2);
- });
+ if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) {
+ tavern.sendChat(quest.boss.desperation.text('en'));
+ tavern.quest.extra.desperate = true;
+ tavern.quest.extra.def = quest.boss.desperation.def;
+ tavern.quest.extra.str = quest.boss.desperation.str;
+ tavern.markModified('quest.extra');
}
- series.push(function(cb2){group.save(cb2)});
- async.series(series,cb);
- })
-}
-
-// Remove user from this group
-GroupSchema.methods.leave = function(user, keep, mainCb){
- if(!user) return mainCb(new Error('Missing user.'));
-
- if(keep && typeof keep === 'function'){
- mainCb = keep;
- keep = null;
+ _.assign(tavernQuest, tavern.quest.toObject());
+ return tavern.save();
}
- if(typeof keep !== 'string') keep = 'keep-all'; // can be also 'remove-all'
+ // TODO catch
+};
- var group = this;
+schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
+ let group = this;
- async.parallel([
- // Remove user from group challenges
- function(cb){
- async.waterfall([
- // Find relevant challenges
- function(cb2) {
- Challenge.find({
- _id: {$in: user.challenges}, // Challenges I am in
- group: group._id // that belong to the group I am leaving
- }, cb2);
- },
-
- // Update each challenge
- function(challenges, cb2) {
- Challenge.update(
- {_id: {$in: _.pluck(challenges, '_id')}},
- {$pull: {members: user._id}},
- {multi: true},
- function(err) {
- cb2(err, challenges); // pass `challenges` above to cb
- }
- );
- },
-
- // Unlink the challenge tasks from user
- function(challenges, cb2) {
- async.waterfall(challenges.map(function(chal) {
- return function(cb3) {
- var i = user.challenges.indexOf(chal._id)
- if (~i) user.challenges.splice(i,1);
- user.unlink({cid: chal._id, keep: keep}, cb3);
- }
- }), cb2);
- }
- ], cb);
- },
-
- // Update the group
- function(cb){
- // If user is the last one in group and group is private, delete it
- if(group.members.length === 1 && (
- group.type === 'party' ||
- (group.type === 'guild' && group.privacy === 'private')
- )){
- group.remove(cb)
- }else{ // otherwise just remove a member
- var update = {$pull: {members: user._id}};
-
- // If the leader is leaving (or if the leader previously left, and this wasn't accounted for)
- var leader = group.leader;
-
- if(leader == user._id || !~group.members.indexOf(leader)){
- var seniorMember = _.find(group.members, function (m) {return m != user._id});
-
- // could not exist in case of public guild with 1 member who is leaving
- if(seniorMember){
- if (leader == user._id || !~group.members.indexOf(leader)) {
- update['$set'] = update['$set'] || {};
- update['$set'].leader = seniorMember;
- }
- }
- }
-
- update['$inc'] = {memberCount: -1};
- Group.update({_id: group._id}, update, cb);
- }
- }
- ], function(err){
- if(err) return mainCb(err);
-
- firebase.removeUserFromGroup(group._id, user._id);
- return mainCb();
+ let challenges = await Challenge.find({
+ _id: {$in: user.challenges},
+ group: group._id,
});
+
+ let challengesToRemoveUserFrom = challenges.map(chal => {
+ return user.unlinkChallengeTasks(chal._id, keep);
+ });
+ await Q.all(challengesToRemoveUserFrom);
+
+ let promises = [];
+
+ // If user is the last one in group and group is private, delete it
+ if (group.memberCount <= 1 && group.privacy === 'private') {
+ return await group.remove();
+ }
+
+ // otherwise just remove a member TODO create User.methods.removeFromGroup?
+ if (group.type === 'guild') {
+ promises.push(User.update({_id: user._id}, {$pull: {guilds: group._id } }).exec());
+ } else {
+ promises.push(User.update({_id: user._id}, {$set: {party: {} } }).exec());
+ }
+
+ // If the leader is leaving (or if the leader previously left, and this wasn't accounted for)
+ let update = { memberCount: group.memberCount - 1 };
+ if (group.leader === user._id) {
+ let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id};
+ query._id = {$ne: user._id};
+ let seniorMember = await User.findOne(query).select('_id').exec();
+
+ // could be missing in case of public guild (that can have 0 members) with 1 member who is leaving
+ if (seniorMember) update.$set = {leader: seniorMember._id};
+ }
+ promises.push(group.update(update).exec());
+ firebase.removeUserFromGroup(group._id, user._id);
+
+ return Q.all(promises);
};
-
-GroupSchema.methods.toJSON = function() {
- var doc = this.toObject();
-
- return doc;
-};
-
-
-module.exports.schema = GroupSchema;
-var Group = module.exports.model = mongoose.model("Group", GroupSchema);
+export const INVITES_LIMIT = 100;
+export let model = mongoose.model('Group', schema);
// initialize tavern if !exists (fresh installs)
-Group.count({_id: 'habitrpg'}, function(err, ct){
- if (ct > 0) return;
-
- new Group({
- _id: 'habitrpg',
- chat: [],
- leader: '9',
- name: 'HabitRPG',
- type: 'guild',
- privacy: 'public'
- }).save();
-});
+// do not run when testing as it's handled by the tests and can easily cause a race condition
+if (!nconf.get('IS_TEST')) {
+ model.count({_id: 'habitrpg'}, (err, ct) => {
+ if (err) throw err;
+ if (ct > 0) return;
+ new model({ // eslint-disable-line babel/new-cap
+ _id: 'habitrpg',
+ leader: '9', // TODO change this user id
+ name: 'HabitRPG',
+ type: 'guild',
+ privacy: 'public',
+ }).save({
+ validateBeforeSave: false, // _id = 'habitrpg' would not be valid otherwise
+ }); // TODO catch/log?
+ });
+}
diff --git a/website/src/models/task.js b/website/src/models/task.js
index a4e376446d..c6a326999a 100644
--- a/website/src/models/task.js
+++ b/website/src/models/task.js
@@ -21,15 +21,15 @@ export let TaskSchema = new Schema({
type: String,
validate: [validator.isUUID, 'Invalid uuid.'],
}],
- value: {type: Number, default: 0}, // redness or cost for rewards
+ value: {type: Number, default: 0, required: true}, // redness or cost for rewards Required because it must be settable (for rewards)
priority: {type: Number, default: 1, required: true}, // TODO enum?
attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']},
- userId: {type: String, ref: 'User'}, // When null it belongs to a challenge
+ userId: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set it belongs to a challenge
challenge: {
- id: {type: String, ref: 'Challenge'},
- taskId: {type: String, ref: 'Task'}, // When null but challenge.id defined it's the original task
- broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED TODO enum
+ id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task
+ taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task TODO unique index?
+ broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']},
winner: String, // user.profile.name TODO necessary?
},
}, _.defaults({
@@ -48,13 +48,13 @@ TaskSchema.plugin(baseModel, {
// A list of additional fields that cannot be set on creation (but can be set on updare)
let noCreate = ['completed']; // TODO completed should be removed for updates too?
TaskSchema.statics.sanitizeCreate = function sanitizeCreate (createObj) {
- return Task.sanitize(createObj, noCreate); // eslint-disable-line no-use-before-define
+ return this.sanitize(createObj, noCreate);
};
// A list of additional fields that cannot be updated (but can be set on creation)
let noUpdate = ['_id', 'type'];
TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
- return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define
+ return this.sanitize(updateObj, noUpdate);
};
// Sanitize checklist objects (disallowing _id)
diff --git a/website/src/models/user.js b/website/src/models/user.js
index e517fb498f..31272dc874 100644
--- a/website/src/models/user.js
+++ b/website/src/models/user.js
@@ -6,6 +6,7 @@ import moment from 'moment';
import * as Tasks from './task';
import Q from 'q';
import { schema as TagSchema } from './tag';
+import { removeFromArray } from '../libs/api-v3/collectionManipulators';
import baseModel from '../libs/api-v3/baseModel';
// import {model as Challenge} from './challenge';
@@ -206,11 +207,6 @@ export let schema = new Schema({
todos: Array, // [{data: Date, value: Number}] // big peformance issues if these are defined
},
- invitations: {
- guilds: {type: Array, default: []},
- party: Schema.Types.Mixed, // TODO dictionary
- },
-
// TODO we're storing too many fields here, find a way to reduce them
items: {
gear: {
@@ -324,15 +320,25 @@ export let schema = new Schema({
lastCron: {type: Date, default: Date.now},
// {GROUP_ID: Boolean}, represents whether they have unseen chat messages
- newMessages: {type: Schema.Types.Mixed, default: {}},
+ newMessages: {type: Schema.Types.Mixed, default: () => {
+ return {};
+ }},
+
+ challenges: [{type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}],
+
+ invitations: {
+ guilds: {type: Array}, // TODO what are we storing here
+ party: Schema.Types.Mixed, // TODO dictionary TODO what are we storing here?
+ },
+
+ guilds: [{type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}],
party: {
- // id // FIXME can we use a populated doc instead of fetching party separate from user?
+ _id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Group'},
order: {type: String, default: 'level'},
orderAscending: {type: String, default: 'ascending'},
quest: {
key: String,
- // TODO why are we storing quest progress here too and not only on party object?
progress: {
up: {type: Number, default: 0},
down: {type: Number, default: 0},
@@ -441,12 +447,13 @@ export let schema = new Schema({
},
tags: [TagSchema],
- challenges: [{type: String, ref: 'Challenge'}],
inbox: {
newMessages: {type: Number, default: 0},
blocks: {type: Array, default: []},
- messages: {type: Schema.Types.Mixed, default: {}},
+ messages: {type: Schema.Types.Mixed, default: () => {
+ return {};
+ }},
optOut: {type: Boolean, default: false},
},
tasksOrder: {
@@ -466,22 +473,34 @@ export let schema = new Schema({
},
}, {
strict: true,
- minimize: false, // So empty objects are returned
+ minimize: false, // So empty objects are returned TODO make sure it's in every model
});
schema.plugin(baseModel, {
- // TODO revisit a lot of things are missing
- noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats'],
+ // TODO revisit a lot of things are missing. Given how many attributes we do have here we should white-list the ones that can be updated
+ noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password',
+ 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest',
+ 'invitations', 'balance', 'backer', 'contributor'],
private: ['auth.local.hashed_password', 'auth.local.salt'],
- toJSONTransform: function toJSON (doc) {
+ toJSONTransform: function userToJSON (doc) {
// FIXME? Is this a reference to `doc.filters` or just disabled code? Remove?
- doc.filters = {};
- doc._tmp = this._tmp; // be sure to send down drop notifs
+ // TODO this works?
+ // doc.filters = {};
+ // doc._tmp = this._tmp; // be sure to send down drop notifs
return doc;
},
});
+// A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private)
+// TODO is all party data meant to be public?
+export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt
+ preferences.costume preferences.sleep preferences.background profile stats achievements party
+ backer contributor auth.timestamps items`;
+
+// The minimum amount of data needed when populating multiple users
+export let nameFields = `profile.name`;
+
schema.post('init', function postInitUser (doc) {
shared.wrap(doc);
});
@@ -635,40 +654,44 @@ schema.methods.isSubscribed = function isSubscribed () {
return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion
};
-schema.methods.unlink = function unlink (options, cb) {
- let cid = options.cid;
- let keep = options.keep;
- let tid = options.tid;
+// Get an array of groups ids the user is member of
+schema.methods.getGroups = function getUserGroups () {
+ let userGroups = this.guilds.slice(0); // clone user.guilds so we don't modify the original
+ if (this.party._id) userGroups.push(this.party._id);
+ userGroups.push('habitrpg'); // tavern
+ return userGroups;
+};
- if (!cid) {
- return cb('Could not remove challenge tasks. Please delete them manually.');
- }
+// Unlink challenges tasks (and the challenge itself) from user
+schema.methods.unlinkChallengeTasks = async function unlinkChallengeTasks (challengeId, keep) {
+ let user = this;
+ let findQuery = {
+ userId: user._id,
+ 'challenge.id': challengeId,
+ };
- let self = this;
+ removeFromArray(user.challenges, challengeId);
- if (keep === 'keep') {
- self.tasks[tid].challenge = {};
- } else if (keep === 'remove') {
- self.ops.deleteTask({params: {id: tid}}, () => {});
- } else if (keep === 'keep-all') {
- _.each(self.tasks, (t) => {
- if (t.challenge && t.challenge.id === cid) {
- t.challenge = {};
+ if (keep === 'keep-all') {
+ await Tasks.Task.update(findQuery, {
+ $set: {challenge: {}}, // TODO what about updatedAt?
+ }, {multi: true}).exec();
+
+ await user.save();
+ } else { // keep = 'remove-all'
+ let tasks = await Tasks.Task.find(findQuery).select('_id type completed').exec();
+ let taskPromises = tasks.map(task => {
+ // Remove task from user.tasksOrder and delete them
+ if (task.type !== 'todo' || !task.completed) {
+ removeFromArray(user.tasksOrder[`${task.type}s`], task._id);
}
- });
- } else if (keep === 'remove-all') {
- _.each(self.tasks, (t) => {
- if (t.challenge && t.challenge.id === cid) {
- this.ops.deleteTask({params: {id: tid}}, () => {});
- }
- });
- }
- self.markModified('habits');
- self.markModified('dailys');
- self.markModified('todos');
- self.markModified('rewards');
- self.save(cb);
+ return task.remove();
+ });
+
+ taskPromises.push(user.save());
+ return Q.all(taskPromises);
+ }
};
export let model = mongoose.model('User', schema);