Merge branch 'develop' into party-chat-translations

This commit is contained in:
Mateus Etto
2018-07-21 16:06:38 +09:00
286 changed files with 9915 additions and 8661 deletions
+12 -8
View File
@@ -101,21 +101,25 @@ api.registerLocal = {
let existingUser = res.locals.user; // If adding local auth to social user
req.checkBody({
email: {
notEmpty: {errorMessage: res.t('missingEmail')},
isEmail: {errorMessage: res.t('notAnEmail')},
},
username: {
notEmpty: {errorMessage: res.t('missingUsername')},
isLength: {options: {min: USERNAME_LENGTH_MIN, max: USERNAME_LENGTH_MAX}, errorMessage: res.t('usernameWrongLength')},
notEmpty: true,
errorMessage: res.t('missingUsername'),
// TODO use the constants in the error message above
isLength: {options: {min: USERNAME_LENGTH_MIN, max: USERNAME_LENGTH_MAX}, errorMessage: res.t('usernameWrongLength')},
matches: {options: /^[-_a-zA-Z0-9]+$/, errorMessage: res.t('usernameBadCharacters')},
},
email: {
notEmpty: true,
errorMessage: res.t('missingEmail'),
isEmail: {errorMessage: res.t('notAnEmail')},
},
password: {
notEmpty: {errorMessage: res.t('missingPassword')},
notEmpty: true,
errorMessage: res.t('missingPassword'),
equals: {options: [req.body.confirmPassword], errorMessage: res.t('passwordConfirmationMatch')},
},
});
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
@@ -476,7 +480,7 @@ api.updateUsername = {
notEmpty: {errorMessage: res.t('missingPassword')},
},
username: {
notEmpty: { errorMessage: res.t('missingUsername') },
notEmpty: {errorMessage: res.t('missingUsername')},
},
});
@@ -449,7 +449,7 @@ api.getGroupChallenges = {
method: 'GET',
url: '/challenges/groups/:groupId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
userFieldsToInclude: ['_id', 'party', 'guilds'],
})],
async handler (req, res) {
let user = res.locals.user;
@@ -463,10 +463,10 @@ api.getGroupChallenges = {
if (groupId === 'party') groupId = user.party._id;
if (groupId === 'habitrpg') groupId = TAVERN_ID;
let group = await Group.getGroup({user, groupId});
const group = await Group.getGroup({ user, groupId });
if (!group) throw new NotFound(res.t('groupNotFound'));
let challenges = await Challenge.find({group: groupId})
const challenges = await Challenge.find({ group: groupId })
.sort('-createdAt')
// .populate('leader', nameFields) // Only populate the leader as the group is implicit
.exec();
+1 -2
View File
@@ -196,7 +196,6 @@ api.createGroupPlan = {
// @TODO: Change message
if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate'));
group.memberCount = await User.count({ $or: [{ 'party._id': group._id }, { guilds: group._id }] }).exec();
group.leader = user._id;
user.guilds.push(group._id);
@@ -385,7 +384,7 @@ api.getGroup = {
method: 'GET',
url: '/groups/:groupId',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
userFieldsToInclude: ['_id', 'party', 'guilds', 'contributor'],
})],
async handler (req, res) {
let user = res.locals.user;
+18 -6
View File
@@ -3,7 +3,7 @@ import { authWithHeaders } from '../../middlewares/auth';
let api = {};
// @TODO export this const, cannot export it from here because only routes are exported from controllers
const LAST_ANNOUNCEMENT_TITLE = 'SPLASHY SKINS';
const LAST_ANNOUNCEMENT_TITLE = 'HABITICA COMIC-CON MEETUP AND WIKI SPOTLIGHT ON THE POMODORO TECHNIQUE';
const worldDmg = { // @TODO
bailey: false,
};
@@ -30,14 +30,26 @@ api.getNews = {
<div class="mr-3 ${baileyClass}"></div>
<div class="media-body">
<h1 class="align-self-center">${res.t('newStuff')}</h1>
<h2>7/10/2018 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
<h2>7/19/2018 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
</div>
<div class="promo_splashy_skins"></div>
</div>
<hr/>
<p>The Seasonal Edition Splashy Skins are available until July 31st! You can complete your summer avatar look with Clownfish, Deep Ocean, Tropical Water, Mergold, Mergreen, Merblue, Merruby, and Shark Skins.</p>
<p>This Seasonal Edition customization set will only be available to purchase until July 31st, after which they'll be gone until next year, so be sure to swoop them up now! You can find them in User > Edit Avatar!</p>
<div class="small mb-3">by Lemoness and UncommonCriminal</div>
<div class="media align-items-center">
<div class="media-body">
<h3>Habitica at San Diego Comic Con!</h3>
<p>Beffymaroo will be representing Habitica at San Diego Comic Con this year. If youd like to meet her, along with other fellow Habiticans, join us at the Habitica SDCC Meetup! Beffymaroo will be handing out Habitica stickers, promo codes for the Unconventional Armor set, and other exciting special swag (quantities limited!).</p>
<p>You can find the meetup on Saturday, July 21, at the San Diego Bayfront Hilton lobby from 12:00-1:00 PM! Look for the purple Gryphon banner. Cant wait to meet you :)</p>
</div>
<div class="promo_unconventional_armor ml-3 mb-3"></div>
</div>
<div class="media align-items-center">
<div class="scene_pomodoro mr-3"></div>
<div class="media-body">
<h3>Wiki Spotlight: The Pomodoro Technique</h3>
<p>This month's <a href='https://habitica.wordpress.com/2018/07/18/pomodoro/' target='_blank'>featured Wiki article</a> is about the Pomodoro Technique! We hope that it will help you as you look for new productivity strategies. Be sure to check it out, and let us know what you think by reaching out on <a href='https://twitter.com/habitica' target='_blank'>Twitter</a>, <a href='http://blog.habitrpg.com' target='_blank'>Tumblr</a>, and <a href='https://facebook.com/habitica' target='_blank'>Facebook</a>.</p>
<div class="small mb-3">by shanaqui and the Wiki Wizards</div>
</div>
</div>
</div>
`,
});
+14 -4
View File
@@ -5,6 +5,7 @@ import {
} from '../../libs/webhook';
import { removeFromArray } from '../../libs/collectionManipulators';
import * as Tasks from '../../models/task';
import { handleSharedCompletion } from '../../libs/groupTasks';
import { model as Challenge } from '../../models/challenge';
import { model as Group } from '../../models/group';
import { model as User } from '../../models/user';
@@ -287,7 +288,7 @@ api.getUserTasks = {
method: 'GET',
url: '/tasks/user',
middlewares: [authWithHeaders({
userFieldsToExclude: ['inbox'],
userFieldsToInclude: ['_id', 'tasksOrder', 'preferences'],
})],
async handler (req, res) {
let types = Tasks.tasksTypes.map(type => `${type}s`);
@@ -297,10 +298,10 @@ api.getUserTasks = {
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let dueDate = req.query.dueDate;
const user = res.locals.user;
const dueDate = req.query.dueDate;
let tasks = await getTasks(req, res, {user, dueDate});
const tasks = await getTasks(req, res, { user, dueDate });
return res.respond(200, tasks);
},
};
@@ -490,6 +491,9 @@ api.updateTask = {
if (sanitizedObj.requiresApproval) {
task.group.approval.required = true;
}
if (sanitizedObj.sharedCompletion) {
task.group.sharedCompletion = sanitizedObj.sharedCompletion;
}
setNextDue(task, user);
let savedTask = await task.save();
@@ -653,6 +657,12 @@ api.scoreTask = {
user.save(),
task.save(),
];
if (task.group && task.group.taskId) {
await handleSharedCompletion(task);
}
// Save results and handle request
if (taskOrderPromise) promises.push(taskOrderPromise);
let results = await Promise.all(promises);
@@ -12,10 +12,13 @@ import {
getTasks,
moveTask,
} from '../../../libs/taskManager';
import { handleSharedCompletion } from '../../../libs/groupTasks';
import apiError from '../../../libs/apiError';
let requiredGroupFields = '_id leader tasksOrder name';
// @TODO: abstract to task lib
let types = Tasks.tasksTypes.map(type => `${type}s`);
types.push('completedTodos', '_allCompletedTodos'); // _allCompletedTodos is currently in BETA and is likely to be removed in future
function canNotEditTasks (group, user, assignedUserId) {
let isNotGroupLeader = group.leader !== user._id;
@@ -345,7 +348,7 @@ api.approveTask = {
}
// Remove old notifications
let managerPromises = [];
let approvalPromises = [];
managers.forEach((manager) => {
let notificationIndex = manager.notifications.findIndex(function findNotification (notification) {
return notification && notification.data && notification.data.taskId === task._id && notification.type === 'GROUP_TASK_APPROVAL';
@@ -353,7 +356,7 @@ api.approveTask = {
if (notificationIndex !== -1) {
manager.notifications.splice(notificationIndex, 1);
managerPromises.push(manager.save());
approvalPromises.push(manager.save());
}
});
@@ -369,9 +372,11 @@ api.approveTask = {
direction,
});
managerPromises.push(task.save());
managerPromises.push(assignedUser.save());
await Promise.all(managerPromises);
await handleSharedCompletion(task);
approvalPromises.push(task.save());
approvalPromises.push(assignedUser.save());
await Promise.all(approvalPromises);
res.respond(200, task);
},
+2 -2
View File
@@ -5,7 +5,7 @@ let api = {};
// Internal authentication routes
// Set a new password after having requested a password reset (GET route to input password)
api.resetPasswordSetNewOne = {
api.resetPasswordSetNewOne = {
method: 'GET',
url: '/static/user/auth/local/reset-password-set-new-one',
runCron: false,
@@ -24,7 +24,7 @@ api.resetPasswordSetNewOne = {
// Logout the user from the website.
api.logout = {
method: 'GET',
url: '/logout',
url: '/logout-server',
async handler (req, res) {
if (req.logout) req.logout(); // passportjs method
req.session = null;
@@ -83,7 +83,7 @@ api.exportUserHistory = {
// Convert user to json and attach tasks divided by type
// at user.tasks[`${taskType}s`] (user.tasks.{dailys/habits/...})
async function _getUserDataForExport (user) {
async function _getUserDataForExport (user, xmlMode = false) {
let userData = user.toJSON();
userData.tasks = {};
@@ -98,6 +98,33 @@ async function _getUserDataForExport (user) {
userData.tasks[`${taskType}s`] = tasksPerType;
});
if (xmlMode) {
// object maps cant be parsed
userData.inbox.messages = _(userData.inbox.messages)
.map(m => {
const flags = Object.keys(m.flags);
m.flags = flags;
return m;
})
.value();
// _id gets parsed as an bytearray => which gets casted to a chararray => "weird chars"
userData.unpinnedItems = userData.unpinnedItems.map(i => {
return {
path: i.path,
type: i.type,
};
});
userData.pinnedItems = userData.pinnedItems.map(i => {
return {
path: i.path,
type: i.type,
};
});
}
return userData;
}
@@ -137,7 +164,7 @@ api.exportUserDataXml = {
url: '/export/userdata.xml',
middlewares: [authWithSession],
async handler (req, res) {
let userData = await _getUserDataForExport(res.locals.user);
let userData = await _getUserDataForExport(res.locals.user, true);
res.set({
'Content-Type': 'text/xml',
+61
View File
@@ -0,0 +1,61 @@
import * as Tasks from '../models/task';
const SHARED_COMPLETION = {
default: 'recurringCompletion',
single: 'singleCompletion',
every: 'allAssignedCompletion',
};
async function _completeMasterTask (masterTask) {
masterTask.completed = true;
await masterTask.save();
}
async function _deleteUnfinishedTasks (groupMemberTask) {
await Tasks.Task.deleteMany({
'group.taskId': groupMemberTask.group.taskId,
$and: [
{userId: {$exists: true}},
{userId: {$ne: groupMemberTask.userId}},
],
}).exec();
}
async function _evaluateAllAssignedCompletion (masterTask) {
let completions;
if (masterTask.group.approval && masterTask.group.approval.required) {
completions = await Tasks.Task.count({
'group.taskId': masterTask._id,
'group.approval.approved': true,
}).exec();
completions++;
} else {
completions = await Tasks.Task.count({
'group.taskId': masterTask._id,
completed: true,
}).exec();
}
if (completions >= masterTask.group.assignedUsers.length) {
await _completeMasterTask(masterTask);
}
}
async function handleSharedCompletion (groupMemberTask) {
let masterTask = await Tasks.Task.findOne({
_id: groupMemberTask.group.taskId,
}).exec();
if (!masterTask || !masterTask.group || masterTask.type !== 'todo') return;
if (masterTask.group.sharedCompletion === SHARED_COMPLETION.single) {
await _deleteUnfinishedTasks(groupMemberTask);
await _completeMasterTask(masterTask);
} else if (masterTask.group.sharedCompletion === SHARED_COMPLETION.every) {
await _evaluateAllAssignedCompletion(masterTask);
}
}
export {
SHARED_COMPLETION,
handleSharedCompletion,
};
+23 -35
View File
@@ -1,48 +1,26 @@
import _ from 'lodash';
import nconf from 'nconf';
// @TODO remove this lib and use directly the apn module
import pushNotify from 'push-notify';
import apn from 'apn';
import logger from './logger';
import {
S3,
} from './aws';
import gcmLib from 'node-gcm'; // works with FCM notifications too
const FCM_API_KEY = nconf.get('PUSH_CONFIGS:FCM_SERVER_API_KEY');
const fcmSender = FCM_API_KEY ? new gcmLib.Sender(FCM_API_KEY) : undefined;
let apn;
let apnProvider;
// Load APN certificate and key from S3
const APN_ENABLED = nconf.get('PUSH_CONFIGS:APN_ENABLED') === 'true';
const S3_BUCKET = nconf.get('S3:bucket');
if (APN_ENABLED) {
Promise.all([
S3.getObject({
Bucket: S3_BUCKET,
Key: 'apple_apn/cert.pem',
}).promise(),
S3.getObject({
Bucket: S3_BUCKET,
Key: 'apple_apn/key.pem',
}).promise(),
])
.then(([certObj, keyObj]) => {
let cert = certObj.Body.toString();
let key = keyObj.Body.toString();
apn = pushNotify.apn({
key,
cert,
});
apn.on('error', err => logger.error('APN error', err));
apn.on('transmissionError', (errorCode, notification, device) => {
logger.error('APN transmissionError', errorCode, notification, device);
});
});
apnProvider = APN_ENABLED ? new apn.Provider({
token: {
key: nconf.get('PUSH_CONFIGS:APN_KEY'),
keyId: nconf.get('PUSH_CONFIGS:APN_KEY_ID'),
teamId: nconf.get('PUSH_CONFIGS:APN_TEAM_ID'),
},
production: nconf.get('IS_PROD'),
}) : undefined;
}
function sendNotification (user, details = {}) {
@@ -76,14 +54,24 @@ function sendNotification (user, details = {}) {
break;
case 'ios':
if (apn) {
apn.send({
token: pushDevice.regId,
if (apnProvider) {
const notification = new apn.Notification({
alert: details.message,
sound: 'default',
category: details.category,
topic: 'com.habitrpg.ios.Habitica',
payload,
});
apnProvider.send(notification, pushDevice.regId)
.then((response) => {
response.failed.forEach((failure) => {
if (failure.error) {
logger.error('APN error', failure.error);
} else {
logger.error('APN transmissionError', failure.status, notification, failure.device);
}
});
});
}
break;
}
+10 -5
View File
@@ -3,6 +3,9 @@ import * as Tasks from '../models/task';
import {
BadRequest,
} from './errors';
import {
SHARED_COMPLETION,
} from './groupTasks';
import _ from 'lodash';
import shared from '../../common';
@@ -96,6 +99,7 @@ export async function createTasks (req, res, options = {}) {
if (taskData.requiresApproval) {
newTask.group.approval.required = true;
}
newTask.group.sharedCompletion = taskData.sharedCompletion || SHARED_COMPLETION.default;
} else {
newTask.userId = user._id;
}
@@ -183,11 +187,12 @@ export async function getTasks (req, res, options = {}) {
limit = 0; // no limit
}
query = {
userId: user._id,
type: 'todo',
completed: true,
};
query.type = 'todo';
query.completed = true;
if (owner._id === user._id) {
query.userId = user._id;
}
sort = {
dateCompleted: -1,
+8 -4
View File
@@ -9,15 +9,19 @@ import url from 'url';
const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS:COMMUNITY_MANAGER_EMAIL');
function getUserFields (userFieldsToExclude, req) {
function getUserFields (options, req) {
// A list of user fields that aren't needed for the route and are not loaded from the db.
// Must be an array
if (userFieldsToExclude) {
return userFieldsToExclude.map(field => {
if (options.userFieldsToExclude) {
return options.userFieldsToExclude.map(field => {
return `-${field}`; // -${field} means exclude ${field} in mongodb
}).join(' ');
}
if (options.userFieldsToInclude) {
return options.userFieldsToInclude.join(' ');
}
// Allows GET requests to /user to specify a list of user fields to return instead of the entire doc
// Notifications are always included
const urlPath = url.parse(req.url).pathname;
@@ -50,7 +54,7 @@ export function authWithHeaders (options = {}) {
apiToken,
};
const fields = getUserFields(options.userFieldsToExclude, req);
const fields = getUserFields(options, req);
const findPromise = fields ? User.findOne(userQuery).select(fields) : User.findOne(userQuery);
return findPromise
+3 -1
View File
@@ -571,7 +571,7 @@ export function chatDefaults (msg, user, info = {}) {
const message = {
id,
_id: id,
text: msg,
text: msg.substring(0, 3000),
info,
timestamp: Number(new Date()),
likes: {},
@@ -1489,6 +1489,7 @@ schema.methods.updateTask = async function updateTask (taskToSync, options = {})
updateCmd.$set['group.approval.required'] = taskToSync.group.approval.required;
updateCmd.$set['group.assignedUsers'] = taskToSync.group.assignedUsers;
updateCmd.$set['group.sharedCompletion'] = taskToSync.group.sharedCompletion;
let taskSchema = Tasks[taskToSync.type];
@@ -1584,6 +1585,7 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, user) {
matchingTask.group.approval.required = taskToSync.group.approval.required;
matchingTask.group.assignedUsers = taskToSync.group.assignedUsers;
matchingTask.group.sharedCompletion = taskToSync.group.sharedCompletion;
// sync checklist
if (taskToSync.checklist) {
+2
View File
@@ -6,6 +6,7 @@ import baseModel from '../libs/baseModel';
import { InternalServerError } from '../libs/errors';
import _ from 'lodash';
import { preenHistory } from '../libs/preening';
import { SHARED_COMPLETION } from '../libs/groupTasks';
const Schema = mongoose.Schema;
@@ -111,6 +112,7 @@ export let TaskSchema = new Schema({
requested: {type: Boolean, default: false},
requestedDate: {type: Date},
},
sharedCompletion: {type: String, enum: _.values(SHARED_COMPLETION), default: SHARED_COMPLETION.default},
},
reminders: [{