mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-18 07:07:35 +01:00
Remove localstorage and add notifications (#7588)
* move remaining files frm /common/script/public to website/public * remove localstorage * add back noscript template and put all javascript in the footer * fixes client side tests * remove double quotes where possible * simplify jade code and add tests for buildManifest * loading page with logo and spinner * better loading screen in landscape mode * icon on top of text logo * wip: user.notifications * notifications: simpler and working code * finish implementing notifications * correct loading screen css and re-inline images * add tests for user notifications * split User model in multiple files * remove old comment about missing .catch() * correctly setup hooks and methods for User model. Cleanup localstorage * include UserNotificationsService in static page js and split loading-screen css in its own file * add cron notification and misc fixes * remove console.log * fix tests * fix multiple notifications
This commit is contained in:
@@ -154,6 +154,8 @@ api.updateHero = {
|
||||
tierDiff--;
|
||||
newTier--; // give them gems for the next tier down if they weren't aready that tier
|
||||
}
|
||||
|
||||
hero.addNotification('NEW_CONTRIBUTOR_LEVEL');
|
||||
}
|
||||
|
||||
if (updateData.contributor) _.assign(hero.contributor, updateData.contributor);
|
||||
|
||||
47
website/server/controllers/api-v3/notifications.js
Normal file
47
website/server/controllers/api-v3/notifications.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { authWithHeaders } from '../../middlewares/api-v3/auth';
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
NotFound,
|
||||
} from '../../libs/api-v3/errors';
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @apiIgnore Not yet part of the public API
|
||||
* @api {post} /api/v3/notifications/:notificationId/read Mark one notification as read
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ReadNotification
|
||||
* @apiGroup Notification
|
||||
*
|
||||
* @apiParam {UUID} notificationId
|
||||
*
|
||||
* @apiSuccess {Object} data user.notifications
|
||||
*/
|
||||
api.readNotification = {
|
||||
method: 'POST',
|
||||
url: '/notifications/:notificationId/read',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('notificationId', res.t('notificationIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let index = _.findIndex(user.notifications, {
|
||||
id: req.params.notificationId,
|
||||
});
|
||||
|
||||
if (index === -1) {
|
||||
throw new NotFound(res.t('messageNotificationNotFound'));
|
||||
}
|
||||
|
||||
user.notifications.splice(index, 1);
|
||||
await user.save();
|
||||
|
||||
res.respond(200, user.notifications);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -39,7 +39,7 @@ export function getBuildUrl (url) {
|
||||
return `/${buildFiles[url] || url}`;
|
||||
}
|
||||
|
||||
export function getManifestFiles (page) {
|
||||
export function getManifestFiles (page, type) {
|
||||
let files = manifestFiles[page];
|
||||
|
||||
if (!files) throw new Error(`Page "${page}" not found!`);
|
||||
@@ -47,15 +47,25 @@ export function getManifestFiles (page) {
|
||||
let htmlCode = '';
|
||||
|
||||
if (IS_PROD) {
|
||||
htmlCode += `<link rel="stylesheet" type="text/css" href="${getBuildUrl(page + '.css')}">`; // eslint-disable-line prefer-template
|
||||
htmlCode += `<script type="text/javascript" src="${getBuildUrl(page + '.js')}"></script>`; // eslint-disable-line prefer-template
|
||||
if (type !== 'js') {
|
||||
htmlCode += `<link rel="stylesheet" type="text/css" href="${getBuildUrl(page + '.css')}">`; // eslint-disable-line prefer-template
|
||||
}
|
||||
|
||||
if (type !== 'css') {
|
||||
htmlCode += `<script type="text/javascript" src="${getBuildUrl(page + '.js')}"></script>`; // eslint-disable-line prefer-template
|
||||
}
|
||||
} else {
|
||||
files.css.forEach((file) => {
|
||||
htmlCode += `<link rel="stylesheet" type="text/css" href="${getBuildUrl(file)}">`;
|
||||
});
|
||||
files.js.forEach((file) => {
|
||||
htmlCode += `<script type="text/javascript" src="${getBuildUrl(file)}"></script>`;
|
||||
});
|
||||
if (type !== 'js') {
|
||||
files.css.forEach((file) => {
|
||||
htmlCode += `<link rel="stylesheet" type="text/css" href="${getBuildUrl(file)}">`;
|
||||
});
|
||||
}
|
||||
|
||||
if (type !== 'css') {
|
||||
files.js.forEach((file) => {
|
||||
htmlCode += `<script type="text/javascript" src="${getBuildUrl(file)}"></script>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return htmlCode;
|
||||
|
||||
@@ -111,6 +111,9 @@ function performSleepTasks (user, tasksByType, now) {
|
||||
export function cron (options = {}) {
|
||||
let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options;
|
||||
|
||||
// Record pre-cron values of HP and MP to show notifications later
|
||||
let beforeCronStats = _.pick(user.stats, ['hp', 'mp']);
|
||||
|
||||
user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs;
|
||||
// User is only allowed a certain number of drops a day. This resets the count.
|
||||
if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0;
|
||||
@@ -279,6 +282,25 @@ export function cron (options = {}) {
|
||||
let _progress = _.cloneDeep(progress);
|
||||
_.merge(progress, {down: 0, up: 0, collectedItems: 0});
|
||||
|
||||
// Send notification for changes in HP and MP
|
||||
|
||||
// First remove a possible previous cron notification
|
||||
// we don't want to flood the users with many cron notifications at once
|
||||
|
||||
let oldCronNotif = user.notifications.toObject().find((notif, index) => {
|
||||
if (notif.type === 'CRON') {
|
||||
user.notifications.splice(index, 1);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
user.addNotification('CRON', {
|
||||
hp: user.stats.hp - beforeCronStats.hp - (oldCronNotif ? oldCronNotif.data.hp : 0),
|
||||
mp: user.stats.mp - beforeCronStats.mp - (oldCronNotif ? oldCronNotif.data.mp : 0),
|
||||
});
|
||||
|
||||
// TODO: Clean PMs - keep 200 for subscribers and 50 for free users. Should also be done while resting in the inn
|
||||
// let numberOfPMs = Object.keys(user.inbox.messages).length;
|
||||
// if (numberOfPMs > maxPMs) {
|
||||
|
||||
@@ -14,8 +14,11 @@ module.exports = function responseHandler (req, res, next) {
|
||||
// sends back the current user._v in the response so that the client
|
||||
// can verify if it's the most up to date data.
|
||||
// Considered part of the private API for now and not officially supported
|
||||
if (user && req.query.userV) {
|
||||
response.userV = user._v;
|
||||
if (user) {
|
||||
response.notifications = user.notifications.map(notification => notification.toJSON());
|
||||
if (req.query.userV) {
|
||||
response.userV = user._v;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(status).json(response);
|
||||
|
||||
@@ -12,7 +12,6 @@ module.exports = function staticMiddleware (expressApp) {
|
||||
expressApp.use(express.static(BUILD_DIR, { maxAge: MAX_AGE }));
|
||||
expressApp.use('/common/dist', express.static(`${PUBLIC_DIR}/../../common/dist`, { maxAge: MAX_AGE }));
|
||||
expressApp.use('/common/audio', express.static(`${PUBLIC_DIR}/../../common/audio`, { maxAge: MAX_AGE }));
|
||||
expressApp.use('/common/script/public', express.static(`${PUBLIC_DIR}/../../common/script/public`, { maxAge: MAX_AGE }));
|
||||
expressApp.use('/common/img', express.static(`${PUBLIC_DIR}/../../common/img`, { maxAge: MAX_AGE }));
|
||||
expressApp.use(express.static(PUBLIC_DIR));
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import { sendTxn as txnEmail } from '../libs/api-v3/email';
|
||||
import sendPushNotification from '../libs/api-v3/pushNotifications';
|
||||
import cwait from 'cwait';
|
||||
|
||||
let Schema = mongoose.Schema;
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
let schema = new Schema({
|
||||
name: {type: String, required: true},
|
||||
@@ -286,7 +286,11 @@ schema.methods.closeChal = async function closeChal (broken = {}) {
|
||||
if (winner) {
|
||||
winner.achievements.challenges.push(challenge.name);
|
||||
winner.balance += challenge.prize / 4;
|
||||
|
||||
winner.addNotification('WON_CHALLENGE');
|
||||
|
||||
let savedWinner = await winner.save();
|
||||
|
||||
if (savedWinner.preferences.emailNotifications.wonChallenge !== false) {
|
||||
txnEmail(savedWinner, 'won-challenge', [
|
||||
{name: 'CHALLENGE_NAME', content: challenge.name},
|
||||
|
||||
@@ -3,7 +3,7 @@ import baseModel from '../libs/api-v3/baseModel';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import validator from 'validator';
|
||||
|
||||
let Schema = mongoose.Schema;
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
export let schema = new Schema({
|
||||
id: {
|
||||
|
||||
@@ -6,7 +6,8 @@ import baseModel from '../libs/api-v3/baseModel';
|
||||
import _ from 'lodash';
|
||||
import { preenHistory } from '../libs/api-v3/preening';
|
||||
|
||||
let Schema = mongoose.Schema;
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
let discriminatorOptions = {
|
||||
discriminatorKey: 'type', // the key that distinguishes task types
|
||||
};
|
||||
|
||||
170
website/server/models/user/hooks.js
Normal file
170
website/server/models/user/hooks.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import shared from '../../../../common';
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import * as Tasks from '../task';
|
||||
import Bluebird from 'bluebird';
|
||||
import baseModel from '../../libs/api-v3/baseModel';
|
||||
|
||||
import schema from './schema';
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
// noSet is not used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...)
|
||||
noSet: [],
|
||||
private: ['auth.local.hashed_password', 'auth.local.salt', '_cronSignature'],
|
||||
toJSONTransform: function userToJSON (plainObj, originalDoc) {
|
||||
plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs
|
||||
|
||||
return plainObj;
|
||||
},
|
||||
});
|
||||
|
||||
schema.post('init', function postInitUser (doc) {
|
||||
shared.wrap(doc);
|
||||
});
|
||||
|
||||
function _populateDefaultTasks (user, taskTypes) {
|
||||
let tagsI = taskTypes.indexOf('tag');
|
||||
|
||||
if (tagsI !== -1) {
|
||||
user.tags = _.map(shared.content.userDefaults.tags, (tag) => {
|
||||
let newTag = _.cloneDeep(tag);
|
||||
|
||||
// tasks automatically get _id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here
|
||||
newTag.id = shared.uuid();
|
||||
// Render tag's name in user's language
|
||||
newTag.name = newTag.name(user.preferences.language);
|
||||
return newTag;
|
||||
});
|
||||
}
|
||||
|
||||
let tasksToCreate = [];
|
||||
|
||||
if (tagsI !== -1) {
|
||||
taskTypes = _.clone(taskTypes);
|
||||
taskTypes.splice(tagsI, 1);
|
||||
}
|
||||
|
||||
_.each(taskTypes, (taskType) => {
|
||||
let tasksOfType = _.map(shared.content.userDefaults[`${taskType}s`], (taskDefaults) => {
|
||||
let newTask = new Tasks[taskType](taskDefaults);
|
||||
|
||||
newTask.userId = user._id;
|
||||
newTask.text = taskDefaults.text(user.preferences.language);
|
||||
if (newTask.notes) newTask.notes = taskDefaults.notes(user.preferences.language);
|
||||
if (taskDefaults.checklist) {
|
||||
newTask.checklist = _.map(taskDefaults.checklist, (checklistItem) => {
|
||||
checklistItem.text = checklistItem.text(user.preferences.language);
|
||||
return checklistItem;
|
||||
});
|
||||
}
|
||||
|
||||
return newTask.save();
|
||||
});
|
||||
|
||||
tasksToCreate.push(...tasksOfType);
|
||||
});
|
||||
|
||||
return Bluebird.all(tasksToCreate)
|
||||
.then((tasksCreated) => {
|
||||
_.each(tasksCreated, (task) => {
|
||||
user.tasksOrder[`${task.type}s`].push(task._id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _populateDefaultsForNewUser (user) {
|
||||
let taskTypes;
|
||||
let iterableFlags = user.flags.toObject();
|
||||
|
||||
if (user.registeredThrough === 'habitica-web' || user.registeredThrough === 'habitica-android') {
|
||||
taskTypes = ['habit', 'daily', 'todo', 'reward', 'tag'];
|
||||
|
||||
_.each(iterableFlags.tutorial.common, (val, section) => {
|
||||
user.flags.tutorial.common[section] = true;
|
||||
});
|
||||
} else {
|
||||
taskTypes = ['todo', 'tag'];
|
||||
user.flags.showTour = false;
|
||||
|
||||
_.each(iterableFlags.tour, (val, section) => {
|
||||
user.flags.tour[section] = -2;
|
||||
});
|
||||
}
|
||||
|
||||
return _populateDefaultTasks(user, taskTypes);
|
||||
}
|
||||
|
||||
function _setProfileName (user) {
|
||||
let fb = user.auth.facebook;
|
||||
|
||||
let localUsername = user.auth.local && user.auth.local.username;
|
||||
let facebookUsername = fb && (fb.displayName || fb.name || fb.username || `${fb.first_name && fb.first_name} ${fb.last_name}`);
|
||||
let anonymous = 'Anonymous';
|
||||
|
||||
return localUsername || facebookUsername || anonymous;
|
||||
}
|
||||
|
||||
schema.pre('save', true, function preSaveUser (next, done) {
|
||||
next();
|
||||
|
||||
if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) {
|
||||
this.preferences.dayStart = 0;
|
||||
}
|
||||
|
||||
if (!this.profile.name) {
|
||||
this.profile.name = _setProfileName(this);
|
||||
}
|
||||
|
||||
// Determines if Beast Master should be awarded
|
||||
let beastMasterProgress = shared.count.beastMasterProgress(this.items.pets);
|
||||
|
||||
if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) {
|
||||
this.achievements.beastMaster = true;
|
||||
}
|
||||
|
||||
// Determines if Mount Master should be awarded
|
||||
let mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts);
|
||||
|
||||
if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) {
|
||||
this.achievements.mountMaster = true;
|
||||
}
|
||||
|
||||
// Determines if Triad Bingo should be awarded
|
||||
|
||||
let dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets);
|
||||
let qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90;
|
||||
|
||||
if (qualifiesForTriad || this.achievements.triadBingoCount > 0) {
|
||||
this.achievements.triadBingo = true;
|
||||
}
|
||||
|
||||
// Enable weekly recap emails for old users who sign in
|
||||
if (this.flags.lastWeeklyRecapDiscriminator) {
|
||||
// Enable weekly recap emails in 24 hours
|
||||
this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate();
|
||||
// Unset the field so this is run only once
|
||||
this.flags.lastWeeklyRecapDiscriminator = undefined;
|
||||
}
|
||||
|
||||
// EXAMPLE CODE for allowing all existing and new players to be
|
||||
// automatically granted an item during a certain time period:
|
||||
// if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01'))
|
||||
// this.items.pets['JackOLantern-Base'] = 5;
|
||||
|
||||
// our own version incrementer
|
||||
if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0;
|
||||
this._v++;
|
||||
|
||||
// Populate new users with default content
|
||||
if (this.isNew) {
|
||||
_populateDefaultsForNewUser(this)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
schema.pre('update', function preUpdateUser () {
|
||||
this.update({}, {$inc: {_v: 1}});
|
||||
});
|
||||
33
website/server/models/user/index.js
Normal file
33
website/server/models/user/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
import schema from './schema';
|
||||
|
||||
require('./hooks');
|
||||
require('./methods');
|
||||
|
||||
// A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private)
|
||||
export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt
|
||||
preferences.chair 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';
|
||||
|
||||
export { schema };
|
||||
|
||||
export let model = mongoose.model('User', schema);
|
||||
|
||||
// Initially export an empty object so external requires will get
|
||||
// the right object by reference when it's defined later
|
||||
// Otherwise it would remain undefined if requested before the query executes
|
||||
export let mods = [];
|
||||
|
||||
mongoose.model('User')
|
||||
.find({'contributor.admin': true})
|
||||
.sort('-contributor.level -backer.npc profile.name')
|
||||
.select('profile contributor backer')
|
||||
.exec()
|
||||
.then((foundMods) => {
|
||||
// Using push to maintain the reference to mods
|
||||
mods.push(...foundMods);
|
||||
});
|
||||
129
website/server/models/user/methods.js
Normal file
129
website/server/models/user/methods.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import shared from '../../../../common';
|
||||
import _ from 'lodash';
|
||||
import * as Tasks from '../task';
|
||||
import Bluebird from 'bluebird';
|
||||
import {
|
||||
chatDefaults,
|
||||
TAVERN_ID,
|
||||
} from '../group';
|
||||
import { defaults } from 'lodash';
|
||||
|
||||
import schema from './schema';
|
||||
|
||||
schema.methods.isSubscribed = function isSubscribed () {
|
||||
return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion
|
||||
};
|
||||
|
||||
// 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(TAVERN_ID);
|
||||
return userGroups;
|
||||
};
|
||||
|
||||
schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, message) {
|
||||
let sender = this;
|
||||
|
||||
shared.refPush(userToReceiveMessage.inbox.messages, chatDefaults(message, sender));
|
||||
userToReceiveMessage.inbox.newMessages++;
|
||||
userToReceiveMessage._v++;
|
||||
userToReceiveMessage.markModified('inbox.messages');
|
||||
|
||||
shared.refPush(sender.inbox.messages, defaults({sent: true}, chatDefaults(message, userToReceiveMessage)));
|
||||
sender.markModified('inbox.messages');
|
||||
|
||||
let promises = [userToReceiveMessage.save(), sender.save()];
|
||||
await Bluebird.all(promises);
|
||||
};
|
||||
|
||||
schema.methods.addNotification = function addUserNotification (type, data = {}) {
|
||||
this.notifications.push({
|
||||
type,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model)
|
||||
// These will be removed once API v2 is discontinued
|
||||
|
||||
// Get all the tasks belonging to a user,
|
||||
schema.methods.getTasks = function getUserTasks () {
|
||||
let args = Array.from(arguments);
|
||||
let cb;
|
||||
let type;
|
||||
|
||||
if (args.length === 1) {
|
||||
cb = args[0];
|
||||
} else {
|
||||
type = args[0];
|
||||
cb = args[1];
|
||||
}
|
||||
|
||||
let query = {
|
||||
userId: this._id,
|
||||
};
|
||||
|
||||
if (type) query.type = type;
|
||||
|
||||
Tasks.Task.find(query, cb);
|
||||
};
|
||||
|
||||
// Given user and an array of tasks, return an API compatible user + tasks obj
|
||||
schema.methods.addTasksToUser = function addTasksToUser (tasks) {
|
||||
let obj = this.toJSON();
|
||||
|
||||
obj.id = obj._id;
|
||||
obj.filters = {};
|
||||
|
||||
obj.tags = obj.tags.map(tag => {
|
||||
return {
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
challenge: tag.challenge,
|
||||
};
|
||||
});
|
||||
|
||||
let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it
|
||||
|
||||
obj.habits = [];
|
||||
obj.dailys = [];
|
||||
obj.todos = [];
|
||||
obj.rewards = [];
|
||||
|
||||
obj.tasksOrder = undefined;
|
||||
let unordered = [];
|
||||
|
||||
tasks.forEach((task) => {
|
||||
// We want to push the task at the same position where it's stored in tasksOrder
|
||||
let pos = tasksOrder[`${task.type}s`].indexOf(task._id);
|
||||
if (pos === -1) { // Should never happen, it means the lists got out of sync
|
||||
unordered.push(task.toJSONV2());
|
||||
} else {
|
||||
obj[`${task.type}s`][pos] = task.toJSONV2();
|
||||
}
|
||||
});
|
||||
|
||||
// Reconcile unordered items
|
||||
unordered.forEach((task) => {
|
||||
obj[`${task.type}s`].push(task);
|
||||
});
|
||||
|
||||
// Remove null values that can be created when inserting tasks at an index > length
|
||||
['habits', 'dailys', 'rewards', 'todos'].forEach((type) => {
|
||||
obj[type] = _.compact(obj[type]);
|
||||
});
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
// Return the data maintaining backward compatibility
|
||||
schema.methods.getTransformedData = function getTransformedData (cb) {
|
||||
let self = this;
|
||||
this.getTasks((err, tasks) => {
|
||||
if (err) return cb(err);
|
||||
cb(null, self.addTasksToUser(tasks));
|
||||
});
|
||||
};
|
||||
|
||||
// END of API v2 methods
|
||||
@@ -1,22 +1,16 @@
|
||||
import mongoose from 'mongoose';
|
||||
import shared from '../../../common';
|
||||
import shared from '../../../../common';
|
||||
import _ from 'lodash';
|
||||
import validator from 'validator';
|
||||
import moment from 'moment';
|
||||
import * as Tasks from './task';
|
||||
import Bluebird from 'bluebird';
|
||||
import { schema as TagSchema } from './tag';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import { schema as TagSchema } from '../tag';
|
||||
import {
|
||||
chatDefaults,
|
||||
TAVERN_ID,
|
||||
} from './group';
|
||||
import { defaults } from 'lodash';
|
||||
schema as UserNotificationSchema,
|
||||
} from '../userNotification';
|
||||
|
||||
let Schema = mongoose.Schema;
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
// User schema definition
|
||||
export let schema = new Schema({
|
||||
let schema = new Schema({
|
||||
apiToken: {
|
||||
type: String,
|
||||
default: shared.uuid,
|
||||
@@ -495,6 +489,7 @@ export let schema = new Schema({
|
||||
},
|
||||
},
|
||||
|
||||
notifications: [UserNotificationSchema],
|
||||
tags: [TagSchema],
|
||||
|
||||
inbox: {
|
||||
@@ -514,312 +509,13 @@ export let schema = new Schema({
|
||||
extra: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
pushDevices: {
|
||||
type: [{
|
||||
regId: {type: String},
|
||||
type: {type: String},
|
||||
}],
|
||||
default: () => [],
|
||||
},
|
||||
pushDevices: [{
|
||||
regId: {type: String},
|
||||
type: {type: String},
|
||||
}],
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
// noSet is not used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...)
|
||||
noSet: [],
|
||||
private: ['auth.local.hashed_password', 'auth.local.salt', '_cronSignature'],
|
||||
toJSONTransform: function userToJSON (plainObj, originalDoc) {
|
||||
// plainObj.filters = {}; // TODO Not saved, remove?
|
||||
plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs
|
||||
|
||||
return plainObj;
|
||||
},
|
||||
});
|
||||
|
||||
// A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private)
|
||||
export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt
|
||||
preferences.chair 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);
|
||||
});
|
||||
|
||||
function _populateDefaultTasks (user, taskTypes) {
|
||||
let tagsI = taskTypes.indexOf('tag');
|
||||
|
||||
if (tagsI !== -1) {
|
||||
user.tags = _.map(shared.content.userDefaults.tags, (tag) => {
|
||||
let newTag = _.cloneDeep(tag);
|
||||
|
||||
// tasks automatically get _id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here
|
||||
newTag.id = shared.uuid();
|
||||
// Render tag's name in user's language
|
||||
newTag.name = newTag.name(user.preferences.language);
|
||||
return newTag;
|
||||
});
|
||||
}
|
||||
|
||||
let tasksToCreate = [];
|
||||
|
||||
if (tagsI !== -1) {
|
||||
taskTypes = _.clone(taskTypes);
|
||||
taskTypes.splice(tagsI, 1);
|
||||
}
|
||||
|
||||
_.each(taskTypes, (taskType) => {
|
||||
let tasksOfType = _.map(shared.content.userDefaults[`${taskType}s`], (taskDefaults) => {
|
||||
let newTask = new Tasks[taskType](taskDefaults);
|
||||
|
||||
newTask.userId = user._id;
|
||||
newTask.text = taskDefaults.text(user.preferences.language);
|
||||
if (newTask.notes) newTask.notes = taskDefaults.notes(user.preferences.language);
|
||||
if (taskDefaults.checklist) {
|
||||
newTask.checklist = _.map(taskDefaults.checklist, (checklistItem) => {
|
||||
checklistItem.text = checklistItem.text(user.preferences.language);
|
||||
return checklistItem;
|
||||
});
|
||||
}
|
||||
|
||||
return newTask.save();
|
||||
});
|
||||
|
||||
tasksToCreate.push(...tasksOfType);
|
||||
});
|
||||
|
||||
return Bluebird.all(tasksToCreate)
|
||||
.then((tasksCreated) => {
|
||||
_.each(tasksCreated, (task) => {
|
||||
user.tasksOrder[`${task.type}s`].push(task._id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _populateDefaultsForNewUser (user) {
|
||||
let taskTypes;
|
||||
let iterableFlags = user.flags.toObject();
|
||||
|
||||
if (user.registeredThrough === 'habitica-web' || user.registeredThrough === 'habitica-android') {
|
||||
taskTypes = ['habit', 'daily', 'todo', 'reward', 'tag'];
|
||||
|
||||
_.each(iterableFlags.tutorial.common, (val, section) => {
|
||||
user.flags.tutorial.common[section] = true;
|
||||
});
|
||||
} else {
|
||||
taskTypes = ['todo', 'tag'];
|
||||
user.flags.showTour = false;
|
||||
|
||||
_.each(iterableFlags.tour, (val, section) => {
|
||||
user.flags.tour[section] = -2;
|
||||
});
|
||||
}
|
||||
|
||||
return _populateDefaultTasks(user, taskTypes);
|
||||
}
|
||||
|
||||
function _setProfileName (user) {
|
||||
let fb = user.auth.facebook;
|
||||
|
||||
let localUsername = user.auth.local && user.auth.local.username;
|
||||
let facebookUsername = fb && (fb.displayName || fb.name || fb.username || `${fb.first_name && fb.first_name} ${fb.last_name}`);
|
||||
let anonymous = 'Anonymous';
|
||||
|
||||
return localUsername || facebookUsername || anonymous;
|
||||
}
|
||||
|
||||
schema.pre('save', true, function preSaveUser (next, done) {
|
||||
next();
|
||||
|
||||
if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) {
|
||||
this.preferences.dayStart = 0;
|
||||
}
|
||||
|
||||
if (!this.profile.name) {
|
||||
this.profile.name = _setProfileName(this);
|
||||
}
|
||||
|
||||
// Determines if Beast Master should be awarded
|
||||
let beastMasterProgress = shared.count.beastMasterProgress(this.items.pets);
|
||||
|
||||
if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) {
|
||||
this.achievements.beastMaster = true;
|
||||
}
|
||||
|
||||
// Determines if Mount Master should be awarded
|
||||
let mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts);
|
||||
|
||||
if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) {
|
||||
this.achievements.mountMaster = true;
|
||||
}
|
||||
|
||||
// Determines if Triad Bingo should be awarded
|
||||
|
||||
let dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets);
|
||||
let qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90;
|
||||
|
||||
if (qualifiesForTriad || this.achievements.triadBingoCount > 0) {
|
||||
this.achievements.triadBingo = true;
|
||||
}
|
||||
|
||||
// Enable weekly recap emails for old users who sign in
|
||||
if (this.flags.lastWeeklyRecapDiscriminator) {
|
||||
// Enable weekly recap emails in 24 hours
|
||||
this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate();
|
||||
// Unset the field so this is run only once
|
||||
this.flags.lastWeeklyRecapDiscriminator = undefined;
|
||||
}
|
||||
|
||||
// EXAMPLE CODE for allowing all existing and new players to be
|
||||
// automatically granted an item during a certain time period:
|
||||
// if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01'))
|
||||
// this.items.pets['JackOLantern-Base'] = 5;
|
||||
|
||||
// our own version incrementer
|
||||
if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0;
|
||||
this._v++;
|
||||
|
||||
// Populate new users with default content
|
||||
if (this.isNew) {
|
||||
_populateDefaultsForNewUser(this)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
schema.pre('update', function preUpdateUser () {
|
||||
this.update({}, {$inc: {_v: 1}});
|
||||
});
|
||||
|
||||
schema.methods.isSubscribed = function isSubscribed () {
|
||||
return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion
|
||||
};
|
||||
|
||||
// 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(TAVERN_ID);
|
||||
return userGroups;
|
||||
};
|
||||
|
||||
schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, message) {
|
||||
let sender = this;
|
||||
|
||||
shared.refPush(userToReceiveMessage.inbox.messages, chatDefaults(message, sender));
|
||||
userToReceiveMessage.inbox.newMessages++;
|
||||
userToReceiveMessage._v++;
|
||||
userToReceiveMessage.markModified('inbox.messages');
|
||||
|
||||
shared.refPush(sender.inbox.messages, defaults({sent: true}, chatDefaults(message, userToReceiveMessage)));
|
||||
sender.markModified('inbox.messages');
|
||||
|
||||
let promises = [userToReceiveMessage.save(), sender.save()];
|
||||
await Bluebird.all(promises);
|
||||
};
|
||||
|
||||
// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model)
|
||||
// These will be removed once API v2 is discontinued
|
||||
|
||||
// Get all the tasks belonging to a user,
|
||||
schema.methods.getTasks = function getUserTasks () {
|
||||
let args = Array.from(arguments);
|
||||
let cb;
|
||||
let type;
|
||||
|
||||
if (args.length === 1) {
|
||||
cb = args[0];
|
||||
} else {
|
||||
type = args[0];
|
||||
cb = args[1];
|
||||
}
|
||||
|
||||
let query = {
|
||||
userId: this._id,
|
||||
};
|
||||
|
||||
if (type) query.type = type;
|
||||
|
||||
Tasks.Task.find(query, cb);
|
||||
};
|
||||
|
||||
// Given user and an array of tasks, return an API compatible user + tasks obj
|
||||
schema.methods.addTasksToUser = function addTasksToUser (tasks) {
|
||||
let obj = this.toJSON();
|
||||
|
||||
obj.id = obj._id;
|
||||
obj.filters = {};
|
||||
|
||||
obj.tags = obj.tags.map(tag => {
|
||||
return {
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
challenge: tag.challenge,
|
||||
};
|
||||
});
|
||||
|
||||
let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it
|
||||
|
||||
obj.habits = [];
|
||||
obj.dailys = [];
|
||||
obj.todos = [];
|
||||
obj.rewards = [];
|
||||
|
||||
obj.tasksOrder = undefined;
|
||||
let unordered = [];
|
||||
|
||||
tasks.forEach((task) => {
|
||||
// We want to push the task at the same position where it's stored in tasksOrder
|
||||
let pos = tasksOrder[`${task.type}s`].indexOf(task._id);
|
||||
if (pos === -1) { // Should never happen, it means the lists got out of sync
|
||||
unordered.push(task.toJSONV2());
|
||||
} else {
|
||||
obj[`${task.type}s`][pos] = task.toJSONV2();
|
||||
}
|
||||
});
|
||||
|
||||
// Reconcile unordered items
|
||||
unordered.forEach((task) => {
|
||||
obj[`${task.type}s`].push(task);
|
||||
});
|
||||
|
||||
// Remove null values that can be created when inserting tasks at an index > length
|
||||
['habits', 'dailys', 'rewards', 'todos'].forEach((type) => {
|
||||
obj[type] = _.compact(obj[type]);
|
||||
});
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
// Return the data maintaining backward compatibility
|
||||
schema.methods.getTransformedData = function getTransformedData (cb) {
|
||||
let self = this;
|
||||
this.getTasks((err, tasks) => {
|
||||
if (err) return cb(err);
|
||||
cb(null, self.addTasksToUser(tasks));
|
||||
});
|
||||
};
|
||||
|
||||
// END of API v2 methods
|
||||
export let model = mongoose.model('User', schema);
|
||||
|
||||
// Initially export an empty object so external requires will get
|
||||
// the right object by reference when it's defined later
|
||||
// Otherwise it would remain undefined if requested before the query executes
|
||||
export let mods = [];
|
||||
|
||||
mongoose.model('User')
|
||||
.find({'contributor.admin': true})
|
||||
.sort('-contributor.level -backer.npc profile.name')
|
||||
.select('profile contributor backer')
|
||||
.exec()
|
||||
.then((foundMods) => {
|
||||
// Using push to maintain the reference to mods
|
||||
mods.push(...foundMods);
|
||||
}); // In case of failure we don't want this to crash the whole server
|
||||
module.exports = schema;
|
||||
42
website/server/models/userNotification.js
Normal file
42
website/server/models/userNotification.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import mongoose from 'mongoose';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import validator from 'validator';
|
||||
|
||||
const NOTIFICATION_TYPES = [
|
||||
'DROPS_ENABLED',
|
||||
'REBIRTH_ENABLED',
|
||||
'WON_CHALLENGE',
|
||||
'STREAK_ACHIEVEMENT',
|
||||
'ULTIMATE_GEAR_ACHIEVEMENT',
|
||||
'REBIRTH_ACHIEVEMENT',
|
||||
'NEW_CONTRIBUTOR_LEVEL',
|
||||
'CRON',
|
||||
];
|
||||
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
export let schema = new Schema({
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid,
|
||||
validate: [validator.isUUID, 'Invalid uuid.'],
|
||||
},
|
||||
type: {type: String, required: true, enum: NOTIFICATION_TYPES},
|
||||
data: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
_id: false, // use id instead of _id
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
noSet: ['_id', 'id'],
|
||||
timestamps: true,
|
||||
private: ['updatedAt'],
|
||||
_id: false, // use id instead of _id
|
||||
});
|
||||
|
||||
export let model = mongoose.model('UserNotification', schema);
|
||||
Reference in New Issue
Block a user