chore(): rename website/src -> website/server and website/public -> website/client (#7199)
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
import mongoose from 'mongoose';
|
||||
import Bluebird from 'bluebird';
|
||||
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';
|
||||
import {
|
||||
model as Group,
|
||||
TAVERN_ID,
|
||||
} from './group';
|
||||
import { removeFromArray } from '../libs/api-v3/collectionManipulators';
|
||||
import shared from '../../../common';
|
||||
import { sendTxn as txnEmail } from '../libs/api-v3/email';
|
||||
import sendPushNotification from '../libs/api-v3/pushNotifications';
|
||||
|
||||
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},
|
||||
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},
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
noSet: ['_id', 'memberCount', 'tasksOrder'],
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
// Returns true if user is a member of the challenge
|
||||
schema.methods.isMember = function isChallengeMember (user) {
|
||||
return user.challenges.indexOf(this._id) !== -1;
|
||||
};
|
||||
|
||||
// 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
|
||||
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);
|
||||
}
|
||||
|
||||
// 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, challenge._id)) user.challenges.push(challenge._id);
|
||||
|
||||
// Sync tags
|
||||
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
|
||||
userTags[i].name = challenge.shortName;
|
||||
}
|
||||
} else {
|
||||
userTags.push({
|
||||
id: challenge._id,
|
||||
name: challenge.shortName,
|
||||
challenge: true,
|
||||
});
|
||||
}
|
||||
|
||||
let [challengeTasks, userTasks] = await Bluebird.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.sanitize(_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
|
||||
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"
|
||||
userTasks.forEach(userTask => {
|
||||
if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) {
|
||||
userTask.challenge.broken = 'TASK_DELETED';
|
||||
toSave.push(userTask.save());
|
||||
}
|
||||
});
|
||||
|
||||
toSave.push(user.save());
|
||||
return Bluebird.all(toSave);
|
||||
};
|
||||
|
||||
async function _fetchMembersIds (challengeId) {
|
||||
return (await User.find({challenges: {$in: [challengeId]}}).select('_id').lean().exec()).map(member => member._id);
|
||||
}
|
||||
|
||||
// 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
|
||||
// TODO are we sure it's the best solution? Use cwait
|
||||
// use bulk ops? http://stackoverflow.com/questions/16726330/mongoose-mongodb-batch-insert
|
||||
for (let memberId of membersIds) {
|
||||
let updateTasksOrderQ = {$push: {}};
|
||||
let toSave = [];
|
||||
|
||||
// TODO eslint complaints about having 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.sanitize(_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 Bluebird.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: {}};
|
||||
|
||||
let syncableAttrs = _syncableAttrs(task);
|
||||
for (let key in syncableAttrs) {
|
||||
updateCmd.$set[key] = syncableAttrs[key];
|
||||
}
|
||||
|
||||
// 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();
|
||||
};
|
||||
|
||||
// Unlink challenges tasks (and the challenge itself) from user
|
||||
schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) {
|
||||
let challengeId = this._id;
|
||||
let findQuery = {
|
||||
userId: user._id,
|
||||
'challenge.id': challengeId,
|
||||
};
|
||||
|
||||
removeFromArray(user.challenges, challengeId);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return task.remove();
|
||||
});
|
||||
user.markModified('tasksOrder');
|
||||
taskPromises.push(user.save());
|
||||
return Bluebird.all(taskPromises);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO everything here should be moved to a worker
|
||||
// actually even for a worker it's probably just too big and will kill mongo
|
||||
schema.methods.closeChal = async function closeChal (broken = {}) {
|
||||
let challenge = this;
|
||||
|
||||
let winner = broken.winner;
|
||||
let brokenReason = broken.broken;
|
||||
|
||||
// Delete the challenge
|
||||
await this.model('Challenge').remove({_id: challenge._id}).exec();
|
||||
|
||||
// Refund the leader if the challenge is closed and the group not the tavern
|
||||
if (challenge.group !== TAVERN_ID && 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},
|
||||
]);
|
||||
}
|
||||
|
||||
sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name);
|
||||
}
|
||||
|
||||
// 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(),
|
||||
];
|
||||
|
||||
Bluebird.all(backgroundTasks);
|
||||
};
|
||||
|
||||
// Methods to adapt the new schema to API v2 responses (mostly tasks inside the challenge model)
|
||||
// These will be removed once API v2 is discontinued
|
||||
|
||||
// Get all the tasks belonging to a challenge,
|
||||
schema.methods.getTasks = function getChallengeTasks () {
|
||||
let args = Array.from(arguments);
|
||||
let cb;
|
||||
let type;
|
||||
|
||||
if (args.length === 1) {
|
||||
cb = args[0];
|
||||
} else if (args.length > 1) {
|
||||
type = args[0];
|
||||
cb = args[1];
|
||||
} else {
|
||||
cb = function noop () {};
|
||||
}
|
||||
|
||||
let query = {
|
||||
userId: {
|
||||
$exists: false,
|
||||
},
|
||||
|
||||
'challenge.id': this._id,
|
||||
};
|
||||
|
||||
if (type) query.type = type;
|
||||
|
||||
return Tasks.Task.find(query, cb); // so we can use it as a promise
|
||||
};
|
||||
|
||||
// Given challenge and an array of tasks and one of members return an API compatible challenge + tasks obj + members
|
||||
schema.methods.addToChallenge = function addToChallenge (tasks, members) {
|
||||
let obj = this.toJSON();
|
||||
obj.members = members;
|
||||
|
||||
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 (options) {
|
||||
let self = this;
|
||||
|
||||
let cb = options.cb;
|
||||
let populateMembers = options.populateMembers;
|
||||
|
||||
let queryMembers = {
|
||||
challenges: self._id,
|
||||
};
|
||||
|
||||
let selectDataMembers = '_id';
|
||||
|
||||
if (populateMembers) {
|
||||
selectDataMembers += ` ${populateMembers}`;
|
||||
}
|
||||
|
||||
let membersQuery = User.find(queryMembers).select(selectDataMembers);
|
||||
if (options.limitPopulation) membersQuery.limit(15);
|
||||
|
||||
Bluebird.all([
|
||||
membersQuery.exec(),
|
||||
self.getTasks(),
|
||||
])
|
||||
.then((results) => {
|
||||
cb(null, self.addToChallenge(results[1], results[0]));
|
||||
})
|
||||
.catch(cb);
|
||||
};
|
||||
|
||||
// END of API v2 methods
|
||||
|
||||
export let model = mongoose.model('Challenge', schema);
|
||||
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
import mongoose from 'mongoose';
|
||||
import _ from 'lodash';
|
||||
import shared from '../../../common';
|
||||
import couponCode from 'coupon-code';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
} from '../libs/api-v3/errors';
|
||||
|
||||
export let schema = new mongoose.Schema({
|
||||
_id: {type: String, default: couponCode.generate},
|
||||
event: {type: String, enum: ['wondercon', 'google_6mo']},
|
||||
user: {type: String, ref: 'User'},
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
timestamps: true,
|
||||
_id: false,
|
||||
});
|
||||
|
||||
schema.statics.generate = async function generateCoupons (event, count = 1) {
|
||||
let coupons = _.times(count, () => {
|
||||
return {event};
|
||||
});
|
||||
|
||||
return await this.create(coupons);
|
||||
};
|
||||
|
||||
schema.statics.apply = async function applyCoupon (user, req, code) {
|
||||
let coupon = await this.findById(couponCode.validate(code)).exec();
|
||||
if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon', req.language));
|
||||
if (coupon.user) throw new NotAuthorized(shared.i18n.t('couponUsed', req.language));
|
||||
|
||||
if (coupon.event === 'wondercon') {
|
||||
user.items.gear.owned.eyewear_special_wondercon_red = true;
|
||||
user.items.gear.owned.eyewear_special_wondercon_black = true;
|
||||
user.items.gear.owned.back_special_wondercon_black = true;
|
||||
user.items.gear.owned.back_special_wondercon_red = true;
|
||||
user.items.gear.owned.body_special_wondercon_red = true;
|
||||
user.items.gear.owned.body_special_wondercon_black = true;
|
||||
user.items.gear.owned.body_special_wondercon_gold = true;
|
||||
user.extra = {signupEvent: 'wondercon'};
|
||||
}
|
||||
|
||||
await user.save();
|
||||
coupon.user = user._id;
|
||||
await coupon.save();
|
||||
};
|
||||
|
||||
module.exports.schema = schema;
|
||||
export let model = mongoose.model('Coupon', schema);
|
||||
@@ -0,0 +1,24 @@
|
||||
import mongoose from 'mongoose';
|
||||
import validator from 'validator';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
|
||||
// A collection used to store mailing list unsubscription for non registered email addresses
|
||||
export let schema = new mongoose.Schema({
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
validator: [validator.isEmail, 'Invalid email.'],
|
||||
},
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
noSet: ['_id'],
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export let model = mongoose.model('EmailUnsubscription', schema);
|
||||
@@ -0,0 +1,749 @@
|
||||
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 Bluebird from 'bluebird';
|
||||
import nconf from 'nconf';
|
||||
import sendPushNotification from '../libs/api-v3/pushNotifications';
|
||||
|
||||
const questScrolls = shared.content.quests;
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
export const INVITES_LIMIT = 100;
|
||||
export const TAVERN_ID = '00000000-0000-4000-A000-000000000000';
|
||||
|
||||
// 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
|
||||
export let schema = new Schema({
|
||||
name: {type: String, required: true},
|
||||
description: String,
|
||||
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,
|
||||
/*
|
||||
# [{
|
||||
# timestamp: Date
|
||||
# user: String
|
||||
# text: String
|
||||
# contributor: String
|
||||
# uuid: String
|
||||
# id: String
|
||||
# }]
|
||||
*/
|
||||
leaderOnly: { // restrict group actions to leader (members can't do them)
|
||||
challenges: {type: Boolean, default: false, required: true},
|
||||
// invites: {type: Boolean, default: false, required: true},
|
||||
},
|
||||
memberCount: {type: Number, default: 1},
|
||||
challengeCount: {type: Number, default: 0},
|
||||
balance: {type: Number, default: 0},
|
||||
logo: String,
|
||||
leaderMessage: String,
|
||||
quest: {
|
||||
key: String,
|
||||
active: {type: Boolean, default: false},
|
||||
leader: {type: String, ref: 'User'},
|
||||
progress: {
|
||||
hp: Number,
|
||||
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: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
extra: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
},
|
||||
}, {
|
||||
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 test
|
||||
schema.pre('remove', true, async function preRemoveGroup (next, done) {
|
||||
next();
|
||||
try {
|
||||
await this.removeGroupInvitations();
|
||||
done();
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
|
||||
schema.post('remove', function postRemoveGroup (group) {
|
||||
firebase.deleteGroup(group._id);
|
||||
});
|
||||
|
||||
schema.statics.getGroup = async function getGroup (options = {}) {
|
||||
let {user, groupId, fields, optionalMembership = false, populateLeader = false, requireMembership = false} = options;
|
||||
let query;
|
||||
|
||||
let isUserParty = groupId === 'party' || user.party._id === groupId;
|
||||
let isUserGuild = user.guilds.indexOf(groupId) !== -1;
|
||||
let isTavern = ['habitrpg', TAVERN_ID].indexOf(groupId) !== -1;
|
||||
|
||||
// When requireMembership is true check that user is member even in public guild
|
||||
if (requireMembership && !isUserParty && !isUserGuild && !isTavern) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// When optionalMembership is true it's not required for the user to be a member of the group
|
||||
if (isUserParty) {
|
||||
query = {type: 'party', _id: user.party._id};
|
||||
} else if (isTavern) {
|
||||
query = {_id: TAVERN_ID};
|
||||
} else if (optionalMembership === true) {
|
||||
query = {_id: groupId};
|
||||
} else if (isUserGuild) {
|
||||
query = {type: 'guild', _id: groupId};
|
||||
} else {
|
||||
query = {type: 'guild', privacy: 'public', _id: groupId};
|
||||
}
|
||||
|
||||
let mQuery = this.findOne(query);
|
||||
if (fields) mQuery.select(fields);
|
||||
if (populateLeader === true) mQuery.populate('leader', nameFields);
|
||||
let group = await mQuery.exec();
|
||||
return group;
|
||||
};
|
||||
|
||||
schema.statics.getGroups = async function getGroups (options = {}) {
|
||||
let {user, types, groupFields = basicFields, sort = '-memberCount', populateLeader = false} = options;
|
||||
let queries = [];
|
||||
|
||||
types.forEach(type => {
|
||||
switch (type) {
|
||||
case 'party': {
|
||||
queries.push(this.getGroup({user, groupId: 'party', fields: groupFields, populateLeader}));
|
||||
break;
|
||||
}
|
||||
case 'guilds': {
|
||||
let userGuildsQuery = this.find({
|
||||
type: 'guild',
|
||||
_id: {$in: user.guilds},
|
||||
}).select(groupFields);
|
||||
if (populateLeader === true) userGuildsQuery.populate('leader', nameFields);
|
||||
userGuildsQuery.sort(sort).exec();
|
||||
queries.push(userGuildsQuery);
|
||||
break;
|
||||
}
|
||||
case 'privateGuilds': {
|
||||
let privateGuildsQuery = this.find({
|
||||
type: 'guild',
|
||||
privacy: 'private',
|
||||
_id: {$in: user.guilds},
|
||||
}).select(groupFields);
|
||||
if (populateLeader === true) privateGuildsQuery.populate('leader', nameFields);
|
||||
privateGuildsQuery.sort(sort).exec();
|
||||
queries.push(privateGuildsQuery);
|
||||
break;
|
||||
}
|
||||
case 'publicGuilds': {
|
||||
let publicGuildsQuery = this.find({
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
}).select(groupFields);
|
||||
if (populateLeader === true) publicGuildsQuery.populate('leader', nameFields);
|
||||
publicGuildsQuery.sort(sort).exec();
|
||||
queries.push(publicGuildsQuery); // TODO use lean?
|
||||
break;
|
||||
}
|
||||
case 'tavern': {
|
||||
if (types.indexOf('publicGuilds') === -1) {
|
||||
queries.push(this.getGroup({user, groupId: TAVERN_ID, fields: groupFields}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let groupsArray = _.reduce(await Bluebird.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
|
||||
}, []);
|
||||
|
||||
return groupsArray;
|
||||
};
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
schema.methods.removeGroupInvitations = async function removeGroupInvitations () {
|
||||
let group = this;
|
||||
|
||||
let usersToRemoveInvitationsFrom = await User.find({
|
||||
[`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id,
|
||||
}).exec();
|
||||
|
||||
let userUpdates = usersToRemoveInvitationsFrom.map(user => {
|
||||
if (group.type === 'party') {
|
||||
user.invitations.party = {};
|
||||
this.markModified('invitations.party');
|
||||
} else {
|
||||
removeFromArray(user.invitations.guilds, { id: group._id });
|
||||
}
|
||||
return user.save();
|
||||
});
|
||||
|
||||
return Bluebird.all(userUpdates);
|
||||
};
|
||||
|
||||
// Return true if user is a member of the group
|
||||
schema.methods.isMember = function isGroupMember (user) {
|
||||
if (this._id === TAVERN_ID) {
|
||||
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: Number(new Date()),
|
||||
likes: {},
|
||||
flags: {},
|
||||
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,
|
||||
});
|
||||
} else {
|
||||
message.uuid = 'system';
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
const NO_CHAT_NOTIFICATIONS = [TAVERN_ID];
|
||||
schema.methods.sendChat = function sendChat (message, user) {
|
||||
this.chat.unshift(chatDefaults(message, user));
|
||||
this.chat.splice(200);
|
||||
|
||||
// Kick off chat notifications in the background.
|
||||
let lastSeenUpdate = {$set: {}, $inc: {_v: 1}};
|
||||
lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true};
|
||||
|
||||
// do not send notifications for guilds with more than 5000 users and for the tavern
|
||||
if (NO_CHAT_NOTIFICATIONS.indexOf(this._id) !== -1 || this.memberCount > 5000) {
|
||||
// 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 {
|
||||
let query = {};
|
||||
|
||||
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.progress.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.progress.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 pushDevices profile.name'
|
||||
).exec().then((membersToNotify) => {
|
||||
let membersToEmail = _.filter(membersToNotify, (member) => {
|
||||
// send push notifications and filter users that disabled emails
|
||||
sendPushNotification(member, 'HabitRPG', `${shared.i18n.t('questStarted')}: ${quest.text()}`);
|
||||
|
||||
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: {},
|
||||
},
|
||||
completed: null,
|
||||
RSVPNeeded: false,
|
||||
};
|
||||
|
||||
if (merge) {
|
||||
_.merge(clean, _.omit(merge, 'progress'));
|
||||
if (merge.progress) _.merge(clean.progress, merge.progress);
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
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);
|
||||
updates.$inc['stats.exp'] = Number(quest.drop.exp);
|
||||
updates.$inc._v = 1;
|
||||
|
||||
if (this._id === TAVERN_ID) {
|
||||
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;
|
||||
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;
|
||||
break;
|
||||
}
|
||||
case 'pets': {
|
||||
updates.$set[`items.pets.${dropK}`] = 5;
|
||||
break;
|
||||
}
|
||||
case 'mounts': {
|
||||
updates.$set[`items.mounts.${dropK}`] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let q = this._id === TAVERN_ID ? {} : {_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;
|
||||
}
|
||||
|
||||
// Returns a promise
|
||||
schema.statics.collectQuest = async function collectQuest (user, progress) {
|
||||
let group = await this.getGroup({user, groupId: 'party'});
|
||||
if (!_isOnQuest(user, progress, group)) return;
|
||||
let quest = shared.content.quests[group.quest.key];
|
||||
|
||||
_.each(progress.collect, (v, k) => {
|
||||
group.quest.progress.collect[k] += v;
|
||||
});
|
||||
|
||||
let foundText = _.reduce(progress.collect, (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');
|
||||
|
||||
// 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; // TODO 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 the user is saved it'll save
|
||||
// the modified user.stats.hp but that must not happen as the hp value has already been updated by the User.update above
|
||||
// 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:TAVERN_ID},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})`
|
||||
// we export an empty object that is then populated with the query-returned data
|
||||
export let tavernQuest = {};
|
||||
let tavernQ = {_id: TAVERN_ID, 'quest.key': {$ne: null}};
|
||||
|
||||
// we use process.nextTick because at this point the model is not yet available
|
||||
process.nextTick(() => {
|
||||
model // eslint-disable-line no-use-before-define
|
||||
.findOne(tavernQ).exec()
|
||||
.then(tavern => {
|
||||
if (!tavern) return; // No tavern quest
|
||||
|
||||
// Using _assign so we don't lose the reference to the exported tavernQuest
|
||||
_.assign(tavernQuest, tavern.quest.toObject());
|
||||
})
|
||||
.catch(err => {
|
||||
throw err;
|
||||
});
|
||||
});
|
||||
|
||||
// returns a promise
|
||||
schema.statics.tavernBoss = async function tavernBoss (user, progress) {
|
||||
if (!progress) return;
|
||||
|
||||
// hack: prevent crazy damage to world boss
|
||||
let dmg = Math.min(900, Math.abs(progress.up || 0));
|
||||
let rage = -Math.min(900, Math.abs(progress.down || 0));
|
||||
|
||||
let tavern = await this.findOne(tavernQ).exec();
|
||||
if (!(tavern && tavern.quest && tavern.quest.key)) return;
|
||||
|
||||
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
|
||||
// Be-Wilder attacks Alex, Matt, Bailey
|
||||
let scene = wd.market ? wd.stables ? wd.bailey ? false : 'bailey' : 'stables' : 'market'; // 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 {
|
||||
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(tavernQuest, tavern.quest.toObject());
|
||||
return tavern.save();
|
||||
}
|
||||
};
|
||||
|
||||
schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
|
||||
let group = this;
|
||||
|
||||
let challenges = await Challenge.find({
|
||||
_id: {$in: user.challenges},
|
||||
group: group._id,
|
||||
});
|
||||
|
||||
let challengesToRemoveUserFrom = challenges.map(chal => {
|
||||
return chal.unlinkTasks(user, keep);
|
||||
});
|
||||
await Bluebird.all(challengesToRemoveUserFrom);
|
||||
|
||||
let promises = [];
|
||||
|
||||
// remove the group from the user's groups
|
||||
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 user is the last one in group and group is private, delete it
|
||||
if (group.memberCount <= 1 && group.privacy === 'private') {
|
||||
return await group.remove();
|
||||
} else { // otherwise If the leader is leaving (or if the leader previously left, and this wasn't accounted for)
|
||||
let update = {
|
||||
$inc: {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 Bluebird.all(promises);
|
||||
};
|
||||
|
||||
// API v2 compatibility methods
|
||||
schema.methods.getTransformedData = function getTransformedData (options) {
|
||||
let cb = options.cb;
|
||||
let populateMembers = options.populateMembers;
|
||||
let populateInvites = options.populateInvites;
|
||||
let populateChallenges = options.populateChallenges;
|
||||
|
||||
let obj = this.toJSON();
|
||||
|
||||
let queryMembers = {};
|
||||
let queryInvites = {};
|
||||
|
||||
if (this.type === 'guild') {
|
||||
queryInvites['invitations.guilds.id'] = this._id;
|
||||
} else {
|
||||
queryInvites['invitations.party.id'] = this._id;
|
||||
}
|
||||
|
||||
if (this.type === 'guild') {
|
||||
queryMembers.guilds = this._id;
|
||||
} else {
|
||||
queryMembers['party._id'] = this._id;
|
||||
}
|
||||
|
||||
let selectDataMembers = '_id';
|
||||
let selectDataInvites = '_id';
|
||||
let selectDataChallenges = '_id';
|
||||
|
||||
if (populateMembers) {
|
||||
selectDataMembers += ` ${populateMembers}`;
|
||||
}
|
||||
if (populateInvites) {
|
||||
selectDataInvites += ` ${populateInvites}`;
|
||||
}
|
||||
if (populateChallenges) {
|
||||
selectDataChallenges += ` ${populateChallenges}`;
|
||||
}
|
||||
|
||||
let membersQuery = User.find(queryMembers).select(selectDataMembers);
|
||||
if (options.limitPopulation) membersQuery.limit(15);
|
||||
|
||||
Bluebird.all([
|
||||
membersQuery.exec(),
|
||||
User.find(queryInvites).select(populateInvites).exec(),
|
||||
Challenge.find({group: obj._id}).select(populateMembers).exec(),
|
||||
])
|
||||
.then((results) => {
|
||||
obj.members = results[0];
|
||||
obj.invites = results[1];
|
||||
obj.challenges = results[2];
|
||||
|
||||
cb(null, obj);
|
||||
})
|
||||
.catch(cb);
|
||||
};
|
||||
// END API v2 compatibility methods
|
||||
|
||||
export let model = mongoose.model('Group', schema);
|
||||
|
||||
// initialize tavern if !exists (fresh installs)
|
||||
// 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: TAVERN_ID}, (err, ct) => {
|
||||
if (err) throw err;
|
||||
if (ct > 0) return;
|
||||
new model({ // eslint-disable-line babel/new-cap
|
||||
_id: TAVERN_ID,
|
||||
leader: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0', // Siena Leslie
|
||||
name: 'Tavern',
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
}).save();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import mongoose from 'mongoose';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import validator from 'validator';
|
||||
|
||||
let Schema = mongoose.Schema;
|
||||
|
||||
export let schema = new Schema({
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid,
|
||||
validate: [validator.isUUID, 'Invalid uuid.'],
|
||||
},
|
||||
name: {type: String, required: true},
|
||||
challenge: {type: String},
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
_id: false, // use id instead of _id
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
noSet: ['_id', 'id', 'challenge'],
|
||||
_id: false, // use id instead of _id
|
||||
});
|
||||
|
||||
export let model = mongoose.model('Tag', schema);
|
||||
@@ -0,0 +1,206 @@
|
||||
import mongoose from 'mongoose';
|
||||
import shared from '../../../common';
|
||||
import validator from 'validator';
|
||||
import moment from 'moment';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import _ from 'lodash';
|
||||
import { preenHistory } from '../libs/api-v3/preening';
|
||||
|
||||
let Schema = mongoose.Schema;
|
||||
let discriminatorOptions = {
|
||||
discriminatorKey: 'type', // the key that distinguishes task types
|
||||
};
|
||||
let subDiscriminatorOptions = _.defaults(_.cloneDeep(discriminatorOptions), {_id: false});
|
||||
|
||||
export let tasksTypes = ['habit', 'daily', 'todo', 'reward'];
|
||||
|
||||
// Important
|
||||
// When something changes here remember to update the client side model at common/script/libs/taskDefaults
|
||||
export let TaskSchema = new Schema({
|
||||
type: {type: String, enum: tasksTypes, required: true, default: tasksTypes[0]},
|
||||
text: {type: String, required: true},
|
||||
notes: {type: String, default: ''},
|
||||
tags: [{
|
||||
type: String,
|
||||
validate: [validator.isUUID, 'Invalid uuid.'],
|
||||
}],
|
||||
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,
|
||||
validate: [
|
||||
(val) => [0.1, 1, 1.5, 2].indexOf(val) !== -1,
|
||||
'Valid priority values are 0.1, 1, 1.5, 2.',
|
||||
],
|
||||
},
|
||||
attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']},
|
||||
userId: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set it belongs to a challenge
|
||||
|
||||
challenge: {
|
||||
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
|
||||
broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED']},
|
||||
winner: String, // user.profile.name of the winner
|
||||
},
|
||||
|
||||
reminders: [{
|
||||
_id: false,
|
||||
id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], default: shared.uuid, required: true},
|
||||
startDate: {type: Date},
|
||||
time: {type: Date, required: true},
|
||||
}],
|
||||
}, _.defaults({
|
||||
minimize: true, // So empty objects are returned
|
||||
strict: true,
|
||||
}, discriminatorOptions));
|
||||
|
||||
TaskSchema.plugin(baseModel, {
|
||||
noSet: ['challenge', 'userId', 'completed', 'history', 'streak', 'dateCompleted', 'completed'],
|
||||
sanitizeTransform (taskObj) {
|
||||
if (taskObj.type && taskObj.type !== 'reward') { // value should be settable directly only for rewards
|
||||
delete taskObj.value;
|
||||
}
|
||||
|
||||
return taskObj;
|
||||
},
|
||||
private: [],
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
// Sanitize checklist objects (disallowing id)
|
||||
TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) {
|
||||
delete checklistObj.id;
|
||||
return checklistObj;
|
||||
};
|
||||
|
||||
// Sanitize reminder objects (disallowing id)
|
||||
TaskSchema.statics.sanitizeReminder = function sanitizeReminder (reminderObj) {
|
||||
delete reminderObj.id;
|
||||
return reminderObj;
|
||||
};
|
||||
|
||||
TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta) {
|
||||
let chalTask = this;
|
||||
|
||||
chalTask.value += delta;
|
||||
|
||||
if (chalTask.type === 'habit' || chalTask.type === 'daily') {
|
||||
// Add only one history entry per day
|
||||
let lastChallengHistoryIndex = chalTask.history.length - 1;
|
||||
|
||||
if (chalTask.history[lastChallengHistoryIndex] &&
|
||||
moment(chalTask.history[lastChallengHistoryIndex].date).isSame(new Date(), 'day')) {
|
||||
chalTask.history[lastChallengHistoryIndex] = {
|
||||
date: Number(new Date()),
|
||||
value: chalTask.value,
|
||||
};
|
||||
chalTask.markModified(`history.${lastChallengHistoryIndex}`);
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await chalTask.save();
|
||||
};
|
||||
|
||||
|
||||
// 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
|
||||
|
||||
// toJSON for API v2
|
||||
TaskSchema.methods.toJSONV2 = function toJSONV2 () {
|
||||
let toJSON = this.toJSON();
|
||||
toJSON.id = toJSON._id;
|
||||
|
||||
let v3Tags = this.tags;
|
||||
|
||||
toJSON.tags = {};
|
||||
v3Tags.forEach(tag => {
|
||||
toJSON.tags[tag] = true;
|
||||
});
|
||||
|
||||
return toJSON;
|
||||
};
|
||||
|
||||
TaskSchema.statics.fromJSONV2 = function fromJSONV2 (taskObj) {
|
||||
if (taskObj.id) taskObj._id = taskObj.id;
|
||||
|
||||
let v2Tags = taskObj.tags || {};
|
||||
|
||||
taskObj.tags = [];
|
||||
taskObj.tags = _.map(v2Tags, (tag, key) => key);
|
||||
|
||||
return taskObj;
|
||||
};
|
||||
|
||||
|
||||
// END of API v2 methods
|
||||
|
||||
export let Task = mongoose.model('Task', TaskSchema);
|
||||
|
||||
// habits and dailies shared fields
|
||||
let habitDailySchema = () => {
|
||||
return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems
|
||||
};
|
||||
|
||||
// dailys and todos shared fields
|
||||
let dailyTodoSchema = () => {
|
||||
return {
|
||||
completed: {type: Boolean, default: false},
|
||||
// Checklist fields (dailies and todos)
|
||||
collapseChecklist: {type: Boolean, default: false},
|
||||
checklist: [{
|
||||
completed: {type: Boolean, default: false},
|
||||
text: {type: String, required: false, default: ''}, // required:false because it can be empty on creation
|
||||
_id: false,
|
||||
id: {type: String, default: shared.uuid, validate: [validator.isUUID, 'Invalid uuid.']},
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
export let HabitSchema = new Schema(_.defaults({
|
||||
up: {type: Boolean, default: true},
|
||||
down: {type: Boolean, default: true},
|
||||
}, habitDailySchema()), subDiscriminatorOptions);
|
||||
export let habit = Task.discriminator('habit', HabitSchema);
|
||||
|
||||
export let DailySchema = new Schema(_.defaults({
|
||||
frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly']},
|
||||
everyX: {type: Number, default: 1}, // e.g. once every X weeks
|
||||
startDate: {
|
||||
type: Date,
|
||||
default () {
|
||||
return moment().startOf('day').toDate();
|
||||
},
|
||||
},
|
||||
repeat: { // used only for 'weekly' frequency,
|
||||
m: {type: Boolean, default: true},
|
||||
t: {type: Boolean, default: true},
|
||||
w: {type: Boolean, default: true},
|
||||
th: {type: Boolean, default: true},
|
||||
f: {type: Boolean, default: true},
|
||||
s: {type: Boolean, default: true},
|
||||
su: {type: Boolean, default: true},
|
||||
},
|
||||
streak: {type: Number, default: 0},
|
||||
}, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions);
|
||||
export let daily = Task.discriminator('daily', DailySchema);
|
||||
|
||||
export let TodoSchema = new Schema(_.defaults({
|
||||
dateCompleted: Date,
|
||||
// TODO we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date see http://stackoverflow.com/questions/1353684/detecting-an-invalid-date-date-instance-in-javascript
|
||||
date: String, // due date for todos
|
||||
}, dailyTodoSchema()), subDiscriminatorOptions);
|
||||
export let todo = Task.discriminator('todo', TodoSchema);
|
||||
|
||||
export let RewardSchema = new Schema({}, subDiscriminatorOptions);
|
||||
export let reward = Task.discriminator('reward', RewardSchema);
|
||||
@@ -0,0 +1,826 @@
|
||||
import mongoose from 'mongoose';
|
||||
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 {
|
||||
chatDefaults,
|
||||
TAVERN_ID,
|
||||
} from './group';
|
||||
import { defaults } from 'lodash';
|
||||
|
||||
let Schema = mongoose.Schema;
|
||||
|
||||
// User schema definition
|
||||
export let schema = new Schema({
|
||||
apiToken: {
|
||||
type: String,
|
||||
default: shared.uuid,
|
||||
},
|
||||
|
||||
auth: {
|
||||
blocked: Boolean,
|
||||
facebook: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
local: {
|
||||
email: {
|
||||
type: String,
|
||||
validate: [validator.isEmail, shared.i18n.t('invalidEmail')],
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
},
|
||||
// Store a lowercase version of username to check for duplicates
|
||||
lowerCaseUsername: String,
|
||||
hashed_password: String, // eslint-disable-line camelcase
|
||||
salt: String,
|
||||
},
|
||||
timestamps: {
|
||||
created: {type: Date, default: Date.now},
|
||||
loggedin: {type: Date, default: Date.now},
|
||||
},
|
||||
},
|
||||
// We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which
|
||||
// have been updated (http://goo.gl/gQLz41), but we want *every* update
|
||||
_v: { type: Number, default: 0 },
|
||||
achievements: {
|
||||
originalUser: Boolean,
|
||||
habitSurveys: Number,
|
||||
ultimateGearSets: {
|
||||
healer: {type: Boolean, default: false},
|
||||
wizard: {type: Boolean, default: false},
|
||||
rogue: {type: Boolean, default: false},
|
||||
warrior: {type: Boolean, default: false},
|
||||
},
|
||||
beastMaster: Boolean,
|
||||
beastMasterCount: Number,
|
||||
mountMaster: Boolean,
|
||||
mountMasterCount: Number,
|
||||
triadBingo: Boolean,
|
||||
triadBingoCount: Number,
|
||||
veteran: Boolean,
|
||||
snowball: Number,
|
||||
spookDust: Number,
|
||||
shinySeed: Number,
|
||||
seafoam: Number,
|
||||
streak: Number,
|
||||
challenges: Array,
|
||||
quests: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
rebirths: Number,
|
||||
rebirthLevel: Number,
|
||||
perfect: {type: Number, default: 0},
|
||||
habitBirthdays: Number,
|
||||
valentine: Number,
|
||||
costumeContest: Boolean, // Superseded by costumeContests
|
||||
nye: Number,
|
||||
habiticaDays: Number,
|
||||
greeting: Number,
|
||||
thankyou: Number,
|
||||
costumeContests: Number,
|
||||
birthday: Number,
|
||||
partyUp: Boolean,
|
||||
partyOn: Boolean,
|
||||
},
|
||||
|
||||
backer: {
|
||||
tier: Number,
|
||||
npc: String,
|
||||
tokensApplied: Boolean,
|
||||
},
|
||||
|
||||
contributor: {
|
||||
// 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801
|
||||
level: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 9,
|
||||
},
|
||||
admin: Boolean,
|
||||
sudo: Boolean,
|
||||
// Artisan, Friend, Blacksmith, etc
|
||||
text: String,
|
||||
// a markdown textarea to list their contributions + links
|
||||
contributions: String,
|
||||
critical: String,
|
||||
},
|
||||
|
||||
balance: {type: Number, default: 0},
|
||||
// Not saved on the user right now
|
||||
filters: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
|
||||
purchased: {
|
||||
ads: {type: Boolean, default: false},
|
||||
// eg, {skeleton: true, pumpkin: true, eb052b: true}
|
||||
skin: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
hair: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
shirt: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
background: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
txnCount: {type: Number, default: 0},
|
||||
mobileChat: Boolean,
|
||||
plan: {
|
||||
planId: String,
|
||||
paymentMethod: String, // enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']}
|
||||
customerId: String, // Billing Agreement Id in case of Amazon Payments
|
||||
dateCreated: Date,
|
||||
dateTerminated: Date,
|
||||
dateUpdated: Date,
|
||||
extraMonths: {type: Number, default: 0},
|
||||
gemsBought: {type: Number, default: 0},
|
||||
mysteryItems: {type: Array, default: () => []},
|
||||
lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date
|
||||
consecutive: {
|
||||
count: {type: Number, default: 0},
|
||||
offset: {type: Number, default: 0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0
|
||||
gemCapExtra: {type: Number, default: 0},
|
||||
trinkets: {type: Number, default: 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
flags: {
|
||||
customizationsNotification: {type: Boolean, default: false},
|
||||
showTour: {type: Boolean, default: true},
|
||||
tour: {
|
||||
// -1 indicates "uninitiated", -2 means "complete", any other number is the current tour step (0-index)
|
||||
intro: {type: Number, default: -1},
|
||||
classes: {type: Number, default: -1},
|
||||
stats: {type: Number, default: -1},
|
||||
tavern: {type: Number, default: -1},
|
||||
party: {type: Number, default: -1},
|
||||
guilds: {type: Number, default: -1},
|
||||
challenges: {type: Number, default: -1},
|
||||
market: {type: Number, default: -1},
|
||||
pets: {type: Number, default: -1},
|
||||
mounts: {type: Number, default: -1},
|
||||
hall: {type: Number, default: -1},
|
||||
equipment: {type: Number, default: -1},
|
||||
},
|
||||
tutorial: {
|
||||
common: {
|
||||
habits: {type: Boolean, default: false},
|
||||
dailies: {type: Boolean, default: false},
|
||||
todos: {type: Boolean, default: false},
|
||||
rewards: {type: Boolean, default: false},
|
||||
party: {type: Boolean, default: false},
|
||||
pets: {type: Boolean, default: false},
|
||||
gems: {type: Boolean, default: false},
|
||||
skills: {type: Boolean, default: false},
|
||||
classes: {type: Boolean, default: false},
|
||||
tavern: {type: Boolean, default: false},
|
||||
equipment: {type: Boolean, default: false},
|
||||
items: {type: Boolean, default: false},
|
||||
},
|
||||
ios: {
|
||||
addTask: {type: Boolean, default: false},
|
||||
editTask: {type: Boolean, default: false},
|
||||
deleteTask: {type: Boolean, default: false},
|
||||
filterTask: {type: Boolean, default: false},
|
||||
groupPets: {type: Boolean, default: false},
|
||||
inviteParty: {type: Boolean, default: false},
|
||||
},
|
||||
},
|
||||
dropsEnabled: {type: Boolean, default: false},
|
||||
itemsEnabled: {type: Boolean, default: false},
|
||||
newStuff: {type: Boolean, default: false},
|
||||
rewrite: {type: Boolean, default: true},
|
||||
contributor: Boolean,
|
||||
classSelected: {type: Boolean, default: false},
|
||||
mathUpdates: Boolean,
|
||||
rebirthEnabled: {type: Boolean, default: false},
|
||||
levelDrops: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
chatRevoked: Boolean,
|
||||
// Used to track the status of recapture emails sent to each user,
|
||||
// can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user
|
||||
recaptureEmailsPhase: {type: Number, default: 0},
|
||||
// Needed to track the tip to send inside the email
|
||||
weeklyRecapEmailsPhase: {type: Number, default: 0},
|
||||
// Used to track when the next weekly recap should be sent
|
||||
lastWeeklyRecap: {type: Date, default: Date.now},
|
||||
// Used to enable weekly recap emails as users login
|
||||
lastWeeklyRecapDiscriminator: Boolean,
|
||||
communityGuidelinesAccepted: {type: Boolean, default: false},
|
||||
cronCount: {type: Number, default: 0},
|
||||
welcomed: {type: Boolean, default: false},
|
||||
armoireEnabled: {type: Boolean, default: false},
|
||||
armoireOpened: {type: Boolean, default: false},
|
||||
armoireEmpty: {type: Boolean, default: false},
|
||||
cardReceived: {type: Boolean, default: false},
|
||||
warnedLowHealth: {type: Boolean, default: false},
|
||||
},
|
||||
|
||||
history: {
|
||||
exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined
|
||||
todos: Array, // [{data: Date, value: Number}] // big peformance issues if these are defined
|
||||
},
|
||||
|
||||
items: {
|
||||
gear: {
|
||||
owned: _.transform(shared.content.gear.flat, (m, v) => {
|
||||
m[v.key] = {type: Boolean};
|
||||
if (v.key.match(/[armor|head|shield]_warrior_0/) || v.gearSet === 'glasses') {
|
||||
m[v.key].default = true;
|
||||
}
|
||||
}),
|
||||
|
||||
equipped: {
|
||||
weapon: String,
|
||||
armor: {type: String, default: 'armor_base_0'},
|
||||
head: {type: String, default: 'head_base_0'},
|
||||
shield: {type: String, default: 'shield_base_0'},
|
||||
back: String,
|
||||
headAccessory: String,
|
||||
eyewear: String,
|
||||
body: String,
|
||||
},
|
||||
costume: {
|
||||
weapon: String,
|
||||
armor: {type: String, default: 'armor_base_0'},
|
||||
head: {type: String, default: 'head_base_0'},
|
||||
shield: {type: String, default: 'shield_base_0'},
|
||||
back: String,
|
||||
headAccessory: String,
|
||||
eyewear: String,
|
||||
body: String,
|
||||
},
|
||||
},
|
||||
|
||||
special: {
|
||||
snowball: {type: Number, default: 0},
|
||||
spookDust: {type: Number, default: 0},
|
||||
shinySeed: {type: Number, default: 0},
|
||||
seafoam: {type: Number, default: 0},
|
||||
valentine: {type: Number, default: 0},
|
||||
valentineReceived: Array, // array of strings, by sender name
|
||||
nye: {type: Number, default: 0},
|
||||
nyeReceived: Array,
|
||||
greeting: {type: Number, default: 0},
|
||||
greetingReceived: Array,
|
||||
thankyou: {type: Number, default: 0},
|
||||
thankyouReceived: Array,
|
||||
birthday: {type: Number, default: 0},
|
||||
birthdayReceived: Array,
|
||||
},
|
||||
|
||||
// -------------- Animals -------------------
|
||||
// Complex bit here. The result looks like:
|
||||
// pets: {
|
||||
// 'Wolf-Desert': 0, // 0 means does not own
|
||||
// 'PandaCub-Red': 10, // Number represents "Growth Points"
|
||||
// etc...
|
||||
// }
|
||||
pets: _.defaults(
|
||||
// First transform to a 1D eggs/potions mapping
|
||||
_.transform(shared.content.pets, (m, v, k) => m[k] = Number),
|
||||
// Then add additional pets (quest, backer, contributor, premium)
|
||||
_.transform(shared.content.questPets, (m, v, k) => m[k] = Number),
|
||||
_.transform(shared.content.specialPets, (m, v, k) => m[k] = Number),
|
||||
_.transform(shared.content.premiumPets, (m, v, k) => m[k] = Number)
|
||||
),
|
||||
currentPet: String, // Cactus-Desert
|
||||
|
||||
// eggs: {
|
||||
// 'PandaCub': 0, // 0 indicates "doesn't own"
|
||||
// 'Wolf': 5 // Number indicates "stacking"
|
||||
// }
|
||||
eggs: _.transform(shared.content.eggs, (m, v, k) => m[k] = Number),
|
||||
|
||||
// hatchingPotions: {
|
||||
// 'Desert': 0, // 0 indicates "doesn't own"
|
||||
// 'CottonCandyBlue': 5 // Number indicates "stacking"
|
||||
// }
|
||||
hatchingPotions: _.transform(shared.content.hatchingPotions, (m, v, k) => m[k] = Number),
|
||||
|
||||
// Food: {
|
||||
// 'Watermelon': 0, // 0 indicates "doesn't own"
|
||||
// 'RottenMeat': 5 // Number indicates "stacking"
|
||||
// }
|
||||
food: _.transform(shared.content.food, (m, v, k) => m[k] = Number),
|
||||
|
||||
// mounts: {
|
||||
// 'Wolf-Desert': true,
|
||||
// 'PandaCub-Red': false,
|
||||
// etc...
|
||||
// }
|
||||
mounts: _.defaults(
|
||||
// First transform to a 1D eggs/potions mapping
|
||||
_.transform(shared.content.pets, (m, v, k) => m[k] = Boolean),
|
||||
// Then add quest and premium pets
|
||||
_.transform(shared.content.questPets, (m, v, k) => m[k] = Boolean),
|
||||
_.transform(shared.content.premiumPets, (m, v, k) => m[k] = Boolean),
|
||||
// Then add additional mounts (backer, contributor)
|
||||
_.transform(shared.content.specialMounts, (m, v, k) => m[k] = Boolean)
|
||||
),
|
||||
currentMount: String,
|
||||
|
||||
// Quests: {
|
||||
// 'boss_0': 0, // 0 indicates "doesn't own"
|
||||
// 'collection_honey': 5 // Number indicates "stacking"
|
||||
// }
|
||||
quests: _.transform(shared.content.quests, (m, v, k) => m[k] = Number),
|
||||
|
||||
lastDrop: {
|
||||
date: {type: Date, default: Date.now},
|
||||
count: {type: Number, default: 0},
|
||||
},
|
||||
},
|
||||
|
||||
lastCron: {type: Date, default: Date.now},
|
||||
|
||||
// {GROUP_ID: Boolean}, represents whether they have unseen chat messages
|
||||
newMessages: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
|
||||
challenges: [{type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}],
|
||||
|
||||
invitations: {
|
||||
// Using an array without validation because otherwise mongoose treat this as a subdocument and applies _id by default
|
||||
// Schema is (id, name, inviter)
|
||||
// TODO one way to fix is http://mongoosejs.com/docs/guide.html#_id
|
||||
guilds: {type: Array, default: () => []},
|
||||
// Using a Mixed type because otherwise user.invitations.party = {} // to reset invitation, causes validation to fail TODO
|
||||
// schema is the same as for guild invitations (id, name, inviter)
|
||||
party: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
},
|
||||
|
||||
guilds: [{type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}],
|
||||
|
||||
party: {
|
||||
_id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Group'},
|
||||
order: {type: String, default: 'level'},
|
||||
orderAscending: {type: String, default: 'ascending'},
|
||||
quest: {
|
||||
key: String,
|
||||
progress: {
|
||||
up: {type: Number, default: 0},
|
||||
down: {type: Number, default: 0},
|
||||
collect: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}}, // {feather:1, ingot:2}
|
||||
},
|
||||
completed: String, // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser
|
||||
RSVPNeeded: {type: Boolean, default: false}, // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
dayStart: {type: Number, default: 0, min: 0, max: 23},
|
||||
size: {type: String, enum: ['broad', 'slim'], default: 'slim'},
|
||||
hair: {
|
||||
color: {type: String, default: 'red'},
|
||||
base: {type: Number, default: 3},
|
||||
bangs: {type: Number, default: 1},
|
||||
beard: {type: Number, default: 0},
|
||||
mustache: {type: Number, default: 0},
|
||||
flower: {type: Number, default: 1},
|
||||
},
|
||||
hideHeader: {type: Boolean, default: false},
|
||||
skin: {type: String, default: '915533'},
|
||||
shirt: {type: String, default: 'blue'},
|
||||
timezoneOffset: {type: Number, default: 0},
|
||||
sound: {type: String, default: 'off', enum: ['off', 'danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme']},
|
||||
chair: {type: String, default: 'none'},
|
||||
timezoneOffsetAtLastCron: Number,
|
||||
language: String,
|
||||
automaticAllocation: Boolean,
|
||||
allocationMode: {type: String, enum: ['flat', 'classbased', 'taskbased'], default: 'flat'},
|
||||
autoEquip: {type: Boolean, default: true},
|
||||
costume: Boolean,
|
||||
dateFormat: {type: String, enum: ['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'], default: 'MM/dd/yyyy'},
|
||||
sleep: {type: Boolean, default: false},
|
||||
stickyHeader: {type: Boolean, default: true},
|
||||
disableClasses: {type: Boolean, default: false},
|
||||
newTaskEdit: {type: Boolean, default: false},
|
||||
dailyDueDefaultView: {type: Boolean, default: false},
|
||||
tagsCollapsed: {type: Boolean, default: false},
|
||||
advancedCollapsed: {type: Boolean, default: false},
|
||||
toolbarCollapsed: {type: Boolean, default: false},
|
||||
reverseChatOrder: {type: Boolean, default: false},
|
||||
background: String,
|
||||
displayInviteToPartyWhenPartyIs1: {type: Boolean, default: true},
|
||||
webhooks: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
// For the following fields make sure to use strict comparison when searching for falsey values (=== false)
|
||||
// As users who didn't login after these were introduced may have them undefined/null
|
||||
emailNotifications: {
|
||||
unsubscribeFromAll: {type: Boolean, default: false},
|
||||
newPM: {type: Boolean, default: true},
|
||||
kickedGroup: {type: Boolean, default: true},
|
||||
wonChallenge: {type: Boolean, default: true},
|
||||
giftedGems: {type: Boolean, default: true},
|
||||
giftedSubscription: {type: Boolean, default: true},
|
||||
invitedParty: {type: Boolean, default: true},
|
||||
invitedGuild: {type: Boolean, default: true},
|
||||
questStarted: {type: Boolean, default: true},
|
||||
invitedQuest: {type: Boolean, default: true},
|
||||
// remindersToLogin: {type: Boolean, default: true},
|
||||
// importantAnnouncements are in fact the recapture emails
|
||||
importantAnnouncements: {type: Boolean, default: true},
|
||||
weeklyRecaps: {type: Boolean, default: true},
|
||||
},
|
||||
suppressModals: {
|
||||
levelUp: {type: Boolean, default: false},
|
||||
hatchPet: {type: Boolean, default: false},
|
||||
raisePet: {type: Boolean, default: false},
|
||||
streak: {type: Boolean, default: false},
|
||||
},
|
||||
improvementCategories: {
|
||||
type: Array,
|
||||
validate: (categories) => {
|
||||
const validCategories = ['work', 'exercise', 'healthWellness', 'school', 'teams', 'chores', 'creativity'];
|
||||
let isValidCategory = categories.every(category => validCategories.indexOf(category) !== -1);
|
||||
return isValidCategory;
|
||||
},
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
blurb: String,
|
||||
imageUrl: String,
|
||||
name: String,
|
||||
},
|
||||
stats: {
|
||||
hp: {type: Number, default: shared.maxHealth},
|
||||
mp: {type: Number, default: 10},
|
||||
exp: {type: Number, default: 0},
|
||||
gp: {type: Number, default: 0},
|
||||
lvl: {type: Number, default: 1},
|
||||
|
||||
// Class System
|
||||
class: {type: String, enum: ['warrior', 'rogue', 'wizard', 'healer'], default: 'warrior', required: true},
|
||||
points: {type: Number, default: 0},
|
||||
str: {type: Number, default: 0},
|
||||
con: {type: Number, default: 0},
|
||||
int: {type: Number, default: 0},
|
||||
per: {type: Number, default: 0},
|
||||
buffs: {
|
||||
str: {type: Number, default: 0},
|
||||
int: {type: Number, default: 0},
|
||||
per: {type: Number, default: 0},
|
||||
con: {type: Number, default: 0},
|
||||
stealth: {type: Number, default: 0},
|
||||
streaks: {type: Boolean, default: false},
|
||||
snowball: {type: Boolean, default: false},
|
||||
spookDust: {type: Boolean, default: false},
|
||||
shinySeed: {type: Boolean, default: false},
|
||||
seafoam: {type: Boolean, default: false},
|
||||
},
|
||||
training: {
|
||||
int: {type: Number, default: 0},
|
||||
per: {type: Number, default: 0},
|
||||
str: {type: Number, default: 0},
|
||||
con: {type: Number, default: 0},
|
||||
},
|
||||
},
|
||||
|
||||
tags: [TagSchema],
|
||||
|
||||
inbox: {
|
||||
newMessages: {type: Number, default: 0},
|
||||
blocks: {type: Array, default: () => []},
|
||||
messages: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
optOut: {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'}],
|
||||
},
|
||||
extra: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
pushDevices: {
|
||||
type: [{
|
||||
regId: {type: String},
|
||||
type: {type: String},
|
||||
}],
|
||||
default: () => [],
|
||||
},
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
// 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
|
||||
// This is not really used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...)
|
||||
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 userToJSON (plainObj, originalDoc) {
|
||||
plainObj.id = plainObj._id;
|
||||
|
||||
// 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.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();
|
||||
|
||||
// TODO remove all unnecessary checks
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// TODO unit test this?
|
||||
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 an 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
|
||||
Reference in New Issue
Block a user