mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-13 12:47:28 +01:00
1685 lines
54 KiB
JavaScript
1685 lines
54 KiB
JavaScript
import moment from 'moment';
|
|
import mongoose from 'mongoose';
|
|
import _ from 'lodash';
|
|
import validator from 'validator';
|
|
import nconf from 'nconf';
|
|
import { // eslint-disable-line import/no-cycle
|
|
model as User,
|
|
nameFields,
|
|
} from './user';
|
|
import shared from '../../common';
|
|
import { model as Challenge } from './challenge'; // eslint-disable-line import/no-cycle
|
|
import {
|
|
chatModel as Chat,
|
|
setUserStyles,
|
|
messageDefaults,
|
|
} from './message';
|
|
import * as Tasks from './task';
|
|
import { removeFromArray } from '../libs/collectionManipulators';
|
|
import payments from '../libs/payments/payments'; // eslint-disable-line import/no-cycle
|
|
import { // eslint-disable-line import/no-cycle
|
|
groupChatReceivedWebhook,
|
|
questActivityWebhook,
|
|
} from '../libs/webhook';
|
|
import {
|
|
InternalServerError,
|
|
BadRequest,
|
|
NotAuthorized,
|
|
} from '../libs/errors';
|
|
import baseModel from '../libs/baseModel';
|
|
import { sendTxn as sendTxnEmail } from '../libs/email'; // eslint-disable-line import/no-cycle
|
|
import { sendNotification as sendPushNotification } from '../libs/pushNotifications'; // eslint-disable-line import/no-cycle
|
|
import {
|
|
schema as SubscriptionPlanSchema,
|
|
} from './subscriptionPlan';
|
|
import logger from '../libs/logger';
|
|
import amazonPayments from '../libs/payments/amazon'; // eslint-disable-line import/no-cycle
|
|
import stripePayments from '../libs/payments/stripe'; // eslint-disable-line import/no-cycle
|
|
import { getGroupChat, translateMessage } from '../libs/chat/group-chat'; // eslint-disable-line import/no-cycle
|
|
import { model as UserNotification } from './userNotification';
|
|
import { sendChatPushNotifications } from '../libs/chat'; // eslint-disable-line import/no-cycle
|
|
import { model as UserHistory } from './userHistory'; // eslint-disable-line import/no-cycle
|
|
|
|
const questScrolls = shared.content.quests;
|
|
const { questSeriesAchievements } = shared.content;
|
|
const { Schema } = mongoose;
|
|
|
|
export const INVITES_LIMIT = 100; // must not be greater than MAX_EMAIL_INVITES_BY_USER
|
|
export const PARTY_PENDING_LIMIT = 10;
|
|
export const { TAVERN_ID } = shared;
|
|
|
|
const NO_CHAT_NOTIFICATIONS = [TAVERN_ID];
|
|
const { LARGE_GROUP_COUNT_MESSAGE_CUTOFF } = shared.constants;
|
|
const { MAX_SUMMARY_SIZE_FOR_GUILDS } = shared.constants;
|
|
|
|
const { CHAT_FLAG_LIMIT_FOR_HIDING } = shared.constants;
|
|
|
|
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
|
|
const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true';
|
|
const MAX_UPDATE_RETRIES = 5;
|
|
|
|
/*
|
|
# Spam constants to limit people from sending too many messages too quickly
|
|
# SPAM_MESSAGE_LIMIT - The amount of messages that can be sent in a time window
|
|
# SPAM_WINDOW_LENGTH - The window length for spam protection in milliseconds
|
|
# SPAM_MIN_EXEMPT_CONTRIB_LEVEL - Anyone at or above this level is exempt
|
|
*/
|
|
export const SPAM_MESSAGE_LIMIT = 2;
|
|
export const SPAM_WINDOW_LENGTH = 60000; // 1 minute
|
|
export const SPAM_MIN_EXEMPT_CONTRIB_LEVEL = 4;
|
|
|
|
export const MAX_CHAT_COUNT = 400;
|
|
export const MAX_SUBBED_GROUP_CHAT_COUNT = 400;
|
|
|
|
export const schema = new Schema({
|
|
name: { $type: String, required: true },
|
|
summary: { $type: String, maxlength: MAX_SUMMARY_SIZE_FOR_GUILDS },
|
|
description: String,
|
|
leader: {
|
|
$type: String, ref: 'User', validate: [v => validator.isUUID(v), 'Invalid uuid for group leader.'], required: true,
|
|
},
|
|
type: { $type: String, enum: ['guild', 'party'], required: true },
|
|
privacy: {
|
|
$type: String, enum: ['private', 'public'], default: 'private', required: true,
|
|
},
|
|
chat: Array, // Used for backward compatibility, but messages aren't stored here
|
|
bannedWordsAllowed: { $type: Boolean, required: false },
|
|
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},
|
|
// Some group plans prevent members from getting gems
|
|
getGems: { $type: Boolean, default: false },
|
|
},
|
|
memberCount: { $type: Number, default: 1 },
|
|
challengeCount: { $type: Number, default: 0 },
|
|
chatLimitCount: { $type: Number },
|
|
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: () => ({}),
|
|
}, // {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: () => ({}),
|
|
},
|
|
extra: {
|
|
$type: Schema.Types.Mixed,
|
|
default: () => ({}),
|
|
},
|
|
},
|
|
tasksOrder: {
|
|
habits: [{ $type: String, ref: 'Task' }],
|
|
dailys: [{ $type: String, ref: 'Task' }],
|
|
todos: [{ $type: String, ref: 'Task' }],
|
|
rewards: [{ $type: String, ref: 'Task' }],
|
|
},
|
|
purchased: {
|
|
plan: {
|
|
$type: SubscriptionPlanSchema,
|
|
default: () => ({}),
|
|
},
|
|
},
|
|
managers: {
|
|
$type: Schema.Types.Mixed,
|
|
default: () => ({}),
|
|
},
|
|
categories: [{
|
|
slug: { $type: String },
|
|
name: { $type: String },
|
|
}],
|
|
cron: {
|
|
lastProcessed: { $type: Date },
|
|
},
|
|
}, {
|
|
strict: true,
|
|
minimize: false, // So empty objects are returned
|
|
typeKey: '$type', // So that we can use fields named `type`
|
|
});
|
|
|
|
schema.plugin(baseModel, {
|
|
noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'bannedWordsAllowed', 'challengeCount', 'tasksOrder', 'purchased', 'managers'],
|
|
private: ['purchased.plan'],
|
|
toJSONTransform (plainObj, originalDoc) {
|
|
if (plainObj.purchased) plainObj.purchased.active = originalDoc.hasActiveGroupPlan();
|
|
},
|
|
});
|
|
|
|
schema.pre('init', group => {
|
|
// The Vue website makes the summary be mandatory for all new groups, but the
|
|
// Angular website did not, and the API does not yet for backwards-compatibility.
|
|
// When any guild without a summary is fetched from the database, this code
|
|
// supplies the name as the summary. This can be removed when all guilds have
|
|
// a summary and the API makes it mandatory (a breaking change!)
|
|
// NOTE: the Tavern and parties do NOT need summaries so ensure they don't break
|
|
// if we remove this code.
|
|
if (!group.summary) {
|
|
group.summary = group.name ? group.name.substring(0, MAX_SUMMARY_SIZE_FOR_GUILDS) : ' ';
|
|
}
|
|
});
|
|
|
|
// A list of additional fields that cannot be updated (but can be set on creation)
|
|
const noUpdate = ['privacy', 'type'];
|
|
schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
|
|
return this.sanitize(updateObj, noUpdate);
|
|
};
|
|
|
|
// Basic fields to fetch for populating a group info
|
|
export const basicFields = 'name type privacy leader summary categories';
|
|
|
|
schema.pre('deleteOne', { document: true }, async function preRemoveGroup (next, done) {
|
|
next();
|
|
try {
|
|
await this.removeGroupInvitations();
|
|
done();
|
|
} catch (err) {
|
|
done(err);
|
|
}
|
|
});
|
|
|
|
// return clean updates for each user in a party without resetting their progress
|
|
function _cleanQuestParty (merge) {
|
|
const updates = {
|
|
$set: {
|
|
'party.quest.key': null,
|
|
'party.quest.completed': null,
|
|
'party.quest.RSVPNeeded': false,
|
|
},
|
|
};
|
|
|
|
if (merge) _.merge(updates, merge);
|
|
|
|
return updates;
|
|
}
|
|
|
|
// return a clean user.quest of a particular user while keeping his progress
|
|
function _cleanQuestUser (userProgress) {
|
|
if (!userProgress) {
|
|
userProgress = { // eslint-disable-line no-param-reassign
|
|
up: 0,
|
|
down: 0,
|
|
collect: {},
|
|
collectedItems: 0,
|
|
};
|
|
} else {
|
|
userProgress = userProgress.toObject(); // eslint-disable-line no-param-reassign
|
|
}
|
|
|
|
const clean = {
|
|
key: null,
|
|
progress: userProgress,
|
|
completed: null,
|
|
RSVPNeeded: false,
|
|
};
|
|
|
|
return clean;
|
|
}
|
|
|
|
schema.statics.getGroup = async function getGroup (options = {}) {
|
|
const {
|
|
user, groupId, fields, optionalMembership = false,
|
|
populateLeader = false, requireMembership = false,
|
|
} = options;
|
|
let query;
|
|
|
|
const isUserParty = groupId === 'party' || user.party._id === groupId;
|
|
const isUserGuild = user.guilds.indexOf(groupId) !== -1;
|
|
const 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 };
|
|
}
|
|
|
|
const mQuery = this.findOne(query);
|
|
if (fields) mQuery.select(fields);
|
|
if (populateLeader === true) mQuery.populate('leader', nameFields);
|
|
const group = await mQuery.exec();
|
|
|
|
if (!group) {
|
|
if (groupId === user.party._id) {
|
|
// reset party object to default state
|
|
user.party = {};
|
|
await user.save();
|
|
} else {
|
|
const item = removeFromArray(user.guilds, groupId);
|
|
if (item) {
|
|
await user.save();
|
|
}
|
|
}
|
|
}
|
|
|
|
return group;
|
|
};
|
|
|
|
export const VALID_QUERY_TYPES = ['party', 'guilds', 'privateGuilds', 'tavern'];
|
|
|
|
schema.statics.getGroups = async function getGroups (options = {}) {
|
|
const {
|
|
user, types, groupFields = basicFields,
|
|
sort = '-memberCount', populateLeader = false,
|
|
filters = {},
|
|
} = options;
|
|
const queries = [];
|
|
|
|
// Throw error if an invalid type is supplied
|
|
const areValidTypes = types.every(type => VALID_QUERY_TYPES.indexOf(type) !== -1);
|
|
if (!areValidTypes) throw new BadRequest(shared.i18n.t('groupTypesRequired'));
|
|
|
|
types.forEach(type => {
|
|
switch (type) { // eslint-disable-line default-case
|
|
case 'party': {
|
|
queries.push(this.getGroup({
|
|
user, groupId: 'party', fields: groupFields, populateLeader,
|
|
}));
|
|
break;
|
|
}
|
|
case 'guilds':
|
|
case 'privateGuilds': {
|
|
const query = {
|
|
type: 'guild',
|
|
privacy: 'private',
|
|
_id: { $in: user.guilds },
|
|
'purchased.plan.customerId': { $exists: true },
|
|
$or: [
|
|
{ 'purchased.plan.dateTerminated': null },
|
|
{ 'purchased.plan.dateTerminated': { $exists: false } },
|
|
{ 'purchased.plan.dateTerminated': { $gt: new Date() } },
|
|
],
|
|
};
|
|
_.assign(query, filters);
|
|
const privateGuildsQuery = this.find(query).select(groupFields);
|
|
if (populateLeader === true) privateGuildsQuery.populate('leader', nameFields);
|
|
privateGuildsQuery.sort(sort);
|
|
queries.push(privateGuildsQuery);
|
|
break;
|
|
}
|
|
case 'tavern': {
|
|
if (types.indexOf('publicGuilds') === -1) {
|
|
queries.push(this.getGroup({ user, groupId: TAVERN_ID, fields: groupFields }));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
const groupsArray = _.reduce(await Promise.all(queries), (previousValue, currentValue) => {
|
|
// don't add anything to the results if the query returned null or an empty array
|
|
if (_.isEmpty(currentValue)) return previousValue;
|
|
// otherwise concat the new results to the previousValue
|
|
return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]);
|
|
}, []);
|
|
|
|
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 or said chat is posted by that user
|
|
// Not putting into toJSON because there we can't access user
|
|
// It also removes the _meta field that can be stored inside a chat message
|
|
schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, user) {
|
|
// @TODO: Adding this here for support the old chat,
|
|
// but we should depreciate accessing chat like this
|
|
// Also only return chat if requested, eventually we don't want to return chat here
|
|
if (group && group.chat) {
|
|
await getGroupChat(group);
|
|
}
|
|
|
|
const groupToJson = group.toJSON();
|
|
const userLang = user.preferences.language;
|
|
|
|
groupToJson.chat = groupToJson.chat
|
|
.map(chatMsg => {
|
|
// Translate system messages
|
|
if (!_.isEmpty(chatMsg.info)) {
|
|
chatMsg.unformattedText = translateMessage(userLang, chatMsg.info);
|
|
chatMsg.text = chatMsg.unformattedText;
|
|
if (!chatMsg.text.includes('`')) {
|
|
chatMsg.text = `\`${chatMsg.text}\``;
|
|
}
|
|
}
|
|
|
|
// Convert to timestamps because Android expects it
|
|
// old chats are saved with a numeric timestamp
|
|
// new chats use `Date` which then has to be converted to the numeric timestamp
|
|
if (chatMsg.timestamp && chatMsg.timestamp.getTime) {
|
|
chatMsg.timestamp = chatMsg.timestamp.getTime();
|
|
}
|
|
|
|
if (!user.hasPermission('moderator')) {
|
|
// Flags are hidden to non admins
|
|
chatMsg.flags = {};
|
|
if (chatMsg._meta) chatMsg._meta = undefined;
|
|
|
|
// Messages with too many flags are hidden to non-admins and non-authors
|
|
if (user._id !== chatMsg.uuid && chatMsg.flagCount >= CHAT_FLAG_LIMIT_FOR_HIDING) {
|
|
return undefined;
|
|
}
|
|
chatMsg.flagCount = 0;
|
|
}
|
|
|
|
return chatMsg;
|
|
})
|
|
// Used to filter for undefined chat messages that should not be shown to non-admins
|
|
.filter(chatMsg => chatMsg !== undefined);
|
|
|
|
return groupToJson;
|
|
};
|
|
|
|
function getInviteError (uuids, emails, usernames) {
|
|
const uuidsIsArray = Array.isArray(uuids);
|
|
const emailsIsArray = Array.isArray(emails);
|
|
const usernamesIsArray = Array.isArray(usernames);
|
|
const emptyEmails = emailsIsArray && emails.length < 1;
|
|
const emptyUuids = uuidsIsArray && uuids.length < 1;
|
|
const emptyUsernames = usernamesIsArray && usernames.length < 1;
|
|
|
|
let errorString;
|
|
|
|
if (!uuids && !emails && !usernames) {
|
|
errorString = 'canOnlyInviteEmailUuid';
|
|
} else if (uuids && !uuidsIsArray) {
|
|
errorString = 'uuidsMustBeAnArray';
|
|
} else if (emails && !emailsIsArray) {
|
|
errorString = 'emailsMustBeAnArray';
|
|
} else if (usernames && !usernamesIsArray) {
|
|
errorString = 'usernamesMustBeAnArray';
|
|
} else if ((!emails || emptyEmails) && (!uuids || emptyUuids) && (!usernames || emptyUsernames)) {
|
|
errorString = 'inviteMustNotBeEmpty';
|
|
}
|
|
|
|
return errorString;
|
|
}
|
|
|
|
function getInviteCount (uuids, emails, usernames) {
|
|
let totalInvites = 0;
|
|
|
|
if (uuids) {
|
|
totalInvites += uuids.length;
|
|
}
|
|
|
|
if (emails) {
|
|
totalInvites += emails.length;
|
|
}
|
|
|
|
if (usernames) {
|
|
totalInvites += usernames.length;
|
|
}
|
|
|
|
return totalInvites;
|
|
}
|
|
|
|
/**
|
|
* Checks invitation uuids and emails for possible errors.
|
|
*
|
|
* @param uuids An array of User IDs
|
|
* @param emails An array of emails
|
|
* @param res Express res object for use with translations
|
|
* @throws BadRequest An error describing the issue with the invitations
|
|
*/
|
|
schema.statics.validateInvitations = async function getInvitationErr (invites, res, group = null) {
|
|
const {
|
|
uuids,
|
|
emails,
|
|
usernames,
|
|
} = invites;
|
|
const errorString = getInviteError(uuids, emails, usernames);
|
|
if (errorString) throw new BadRequest(res.t(errorString));
|
|
|
|
const totalInvites = getInviteCount(uuids, emails, usernames);
|
|
if (totalInvites > INVITES_LIMIT) {
|
|
throw new BadRequest(res.t('canOnlyInviteMaxInvites', { maxInvites: INVITES_LIMIT }));
|
|
}
|
|
|
|
// If party, check the limit of members
|
|
if (group && group.type === 'party') {
|
|
let memberCount = 0;
|
|
|
|
// Counting the members that already joined the party
|
|
memberCount += group.memberCount;
|
|
|
|
// Count how many invitations currently exist in the party
|
|
const query = {};
|
|
query['invitations.party.id'] = group._id;
|
|
// @TODO invitations are now stored like this: `'invitations.parties': []`
|
|
const groupInvites = await User.countDocuments(query).exec();
|
|
if (groupInvites + totalInvites > PARTY_PENDING_LIMIT) {
|
|
throw new BadRequest(res.t('partyExceedsInvitesLimit', { maxInvites: PARTY_PENDING_LIMIT }));
|
|
}
|
|
memberCount += groupInvites;
|
|
|
|
// Counting the members that are going to be invited by email and uuids
|
|
memberCount += totalInvites;
|
|
|
|
if (memberCount > shared.constants.PARTY_LIMIT_MEMBERS) {
|
|
throw new BadRequest(res.t('partyExceedsMembersLimit', { maxMembersParty: shared.constants.PARTY_LIMIT_MEMBERS }));
|
|
}
|
|
}
|
|
};
|
|
|
|
schema.methods.getParticipatingQuestMembers = function getParticipatingQuestMembers () {
|
|
return Object.keys(this.quest.members).filter(member => this.quest.members[member]);
|
|
};
|
|
|
|
schema.methods.removeGroupInvitations = async function removeGroupInvitations () {
|
|
const group = this;
|
|
|
|
const usersToRemoveInvitationsFrom = await User.find({
|
|
[`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id,
|
|
}).exec();
|
|
|
|
const userUpdates = usersToRemoveInvitationsFrom.map(user => {
|
|
if (group.type === 'party') {
|
|
removeFromArray(user.invitations.parties, { id: group._id });
|
|
user.invitations.party = user.invitations.parties.length > 0
|
|
? user.invitations.parties[user.invitations.parties.length - 1] : {};
|
|
this.markModified('invitations.party');
|
|
} else {
|
|
removeFromArray(user.invitations.guilds, { id: group._id });
|
|
}
|
|
return user.save();
|
|
});
|
|
|
|
return Promise.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
|
|
} if (this.type === 'party') {
|
|
return user.party._id === this._id;
|
|
} // guilds
|
|
return user.guilds.indexOf(this._id) !== -1;
|
|
};
|
|
|
|
schema.methods.getMemberCount = async function getMemberCount (options) {
|
|
let excludeUserId = null;
|
|
if (options && options.excludeUserId) {
|
|
excludeUserId = options.excludeUserId;
|
|
}
|
|
let query = { guilds: this._id };
|
|
|
|
if (this.type === 'party') {
|
|
query = { 'party._id': this._id };
|
|
}
|
|
|
|
if (excludeUserId) {
|
|
query._id = { $ne: excludeUserId };
|
|
}
|
|
|
|
return User.countDocuments(query).exec();
|
|
};
|
|
|
|
schema.methods.sendChat = async function sendChat (options = {}) {
|
|
const {
|
|
message, user, metaData,
|
|
client, flagCount = 0, info = {},
|
|
translate, mentions, mentionedMembers,
|
|
} = options;
|
|
const newMessage = messageDefaults(message, user, client, flagCount, info);
|
|
|
|
let newChatMessage = new Chat();
|
|
newChatMessage = Object.assign(newChatMessage, newMessage);
|
|
newChatMessage.groupId = this._id;
|
|
|
|
if (user) setUserStyles(newChatMessage, user);
|
|
|
|
// Optional data stored in the chat message but not returned
|
|
// to the users that can be stored for debugging purposes
|
|
if (metaData) {
|
|
newChatMessage._meta = metaData;
|
|
}
|
|
|
|
// Activate the webhook for receiving group chat messages before
|
|
// newChatMessage is possibly returned
|
|
this.sendGroupChatReceivedWebhooks(newChatMessage);
|
|
|
|
// do not send notifications for:
|
|
// - groups that never send notifications (e.g., Tavern)
|
|
// - groups with very many users
|
|
// - messages that have already been flagged to hide them
|
|
if (
|
|
NO_CHAT_NOTIFICATIONS.indexOf(this._id) !== -1
|
|
|| this.memberCount > LARGE_GROUP_COUNT_MESSAGE_CUTOFF
|
|
|| newChatMessage.flagCount >= CHAT_FLAG_LIMIT_FOR_HIDING
|
|
) {
|
|
return newChatMessage;
|
|
}
|
|
|
|
// Kick off chat notifications in the background.
|
|
|
|
const query = {
|
|
_id: { $ne: user ? user._id : '' },
|
|
'notifications.data.group.id': { $ne: this._id },
|
|
};
|
|
|
|
if (this.type === 'party') {
|
|
query['party._id'] = this._id;
|
|
} else {
|
|
query.guilds = this._id;
|
|
}
|
|
|
|
// Add the new notification
|
|
const lastSeenUpdateAddNew = {
|
|
$set: { // old notification, supported until mobile is updated and we release api v4
|
|
[`newMessages.${this._id}`]: { name: this.name, value: true },
|
|
},
|
|
$push: {
|
|
notifications: new UserNotification({
|
|
type: 'NEW_CHAT_MESSAGE',
|
|
data: { group: { id: this._id, name: this.name } },
|
|
}).toObject(),
|
|
},
|
|
};
|
|
|
|
User
|
|
.updateMany(query, lastSeenUpdateAddNew).exec()
|
|
.catch(err => logger.error(err));
|
|
|
|
if (this.type === 'party' && user) {
|
|
sendChatPushNotifications(user, this, newChatMessage, mentions, translate);
|
|
}
|
|
if (mentionedMembers) {
|
|
await mentionedMembers.forEach(async member => {
|
|
if (member._id === user._id) return;
|
|
const pushNotifPrefs = member.preferences.pushNotifications;
|
|
if (this.type === 'party') {
|
|
if (pushNotifPrefs.mentionParty !== true || !this.isMember(member)) {
|
|
return;
|
|
}
|
|
} else if (this.isMember(member)) {
|
|
if (pushNotifPrefs.mentionJoinedGuild !== true) {
|
|
return;
|
|
}
|
|
} else {
|
|
if (this.privacy !== 'public') {
|
|
return;
|
|
}
|
|
if (pushNotifPrefs.mentionUnjoinedGuild !== true) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (newChatMessage.unformattedText) {
|
|
await sendPushNotification(member, {
|
|
identifier: 'chatMention',
|
|
title: `${user.profile.name} mentioned you in ${this.name}`,
|
|
message: newChatMessage.unformattedText,
|
|
payload: { type: this.type, groupID: this._id },
|
|
});
|
|
}
|
|
});
|
|
}
|
|
return newChatMessage;
|
|
};
|
|
|
|
schema.methods.handleQuestInvitation = async function handleQuestInvitation (user, accept) {
|
|
if (!user) throw new InternalServerError('Must provide user to handle quest invitation');
|
|
if (accept !== true && accept !== false) throw new InternalServerError('Must provide accept param handle quest invitation');
|
|
|
|
// Handle quest invitation atomically (update only current member when still undecided)
|
|
// to prevent multiple concurrent requests overriding updates
|
|
// see https://github.com/HabitRPG/habitica/issues/11398
|
|
const Group = this.constructor;
|
|
const result = await Group.updateOne(
|
|
{
|
|
_id: this._id,
|
|
[`quest.members.${user._id}`]: { $type: 10 }, // match BSON Type Null (type number 10)
|
|
},
|
|
{ $set: { [`quest.members.${user._id}`]: accept } },
|
|
).exec();
|
|
|
|
if (result.modifiedCount) {
|
|
// update also current instance so future operations will work correctly
|
|
this.quest.members[user._id] = accept;
|
|
}
|
|
|
|
return Boolean(result.modifiedCount);
|
|
};
|
|
|
|
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');
|
|
|
|
const userIsParticipating = this.quest.members[user._id];
|
|
const 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;
|
|
}
|
|
|
|
const nonMembers = Object.keys(_.pickBy(this.quest.members, member => !member));
|
|
const noResponseMembers = Object.keys(_.pickBy(this.quest.members, member => member === null));
|
|
// Changes quest.members to only include participating members
|
|
this.quest.members = _.pickBy(this.quest.members, _.identity);
|
|
|
|
// Persist quest.members early to avoid simultaneous handling of accept/reject
|
|
// while processing the rest of this script
|
|
await this.updateOne({ $set: { 'quest.members': this.quest.members } }).exec();
|
|
|
|
const nonUserQuestMembers = _.keys(this.quest.members);
|
|
removeFromArray(nonUserQuestMembers, user._id);
|
|
|
|
// remove any users from quest.members who aren't in the party
|
|
// and get the data necessary to send webhooks
|
|
const members = [];
|
|
|
|
await User.find({
|
|
_id: { $in: Object.keys(this.quest.members) },
|
|
})
|
|
.select('party.quest party._id items.quests auth preferences.emailNotifications preferences.pushNotifications preferences.language pushDevices profile.name webhooks')
|
|
.lean()
|
|
.exec()
|
|
.then(partyMembers => {
|
|
partyMembers.forEach(member => {
|
|
if (!member.party || member.party._id !== this._id) {
|
|
delete this.quest.members[member._id];
|
|
} else {
|
|
members.push(member);
|
|
}
|
|
});
|
|
});
|
|
|
|
if (userIsParticipating) {
|
|
user.party.quest.key = this.quest.key;
|
|
user.party.quest.progress.down = 0;
|
|
user.party.quest.completed = null;
|
|
user.markModified('party.quest');
|
|
}
|
|
|
|
const promises = [];
|
|
|
|
// 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');
|
|
promises.push(user.save());
|
|
} else { // another user is starting the quest, update the leader separately
|
|
promises.push(User.updateOne({ _id: this.quest.leader }, {
|
|
$inc: {
|
|
[`items.quests.${this.quest.key}`]: -1,
|
|
},
|
|
}).exec());
|
|
}
|
|
|
|
// update the remaining users
|
|
promises.push(User.updateMany({
|
|
_id: { $in: nonUserQuestMembers },
|
|
}, {
|
|
$set: {
|
|
'party.quest.key': this.quest.key,
|
|
'party.quest.progress.down': 0,
|
|
'party.quest.completed': null,
|
|
},
|
|
}).exec());
|
|
|
|
await Promise.all(promises);
|
|
|
|
// update the users who are not participating
|
|
// Do not block updates
|
|
User.updateMany({
|
|
_id: { $in: nonMembers },
|
|
}, _cleanQuestParty()).exec();
|
|
|
|
noResponseMembers.forEach(member => {
|
|
UserHistory.beginUserHistoryUpdate(member)
|
|
.withQuestInviteResponse(this.quest.key, 'no response')
|
|
.commit();
|
|
});
|
|
|
|
const newMessage = await this.sendChat({
|
|
message: `\`${shared.i18n.t('chatQuestStarted', { questName: quest.text('en') }, 'en')}\``,
|
|
metaData: {
|
|
participatingMembers: this.getParticipatingQuestMembers().join(', '),
|
|
},
|
|
info: {
|
|
type: 'quest_start',
|
|
quest: quest.key,
|
|
},
|
|
});
|
|
await newMessage.save();
|
|
|
|
const membersToEmail = [];
|
|
|
|
// send notifications and webhooks in the background without blocking
|
|
for (const member of members) {
|
|
if (member._id === user._id) {
|
|
// early "exit", saving one indention level
|
|
// eslint-disable-next-line no-continue
|
|
continue;
|
|
}
|
|
|
|
// add email to send if that user did not disabled this email
|
|
if (member.preferences.emailNotifications.questStarted !== false) {
|
|
membersToEmail.push(member);
|
|
}
|
|
|
|
// send push notifications if that user did not disabled this notifications
|
|
if (member.preferences.pushNotifications.questStarted !== false) {
|
|
const memberLang = member.preferences.language;
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await sendPushNotification(member, {
|
|
title: quest.text(memberLang),
|
|
message: shared.i18n.t('questStarted', memberLang),
|
|
identifier: 'questStarted',
|
|
});
|
|
}
|
|
|
|
// Send webhooks
|
|
questActivityWebhook.send(member, {
|
|
type: 'questStarted',
|
|
group: this,
|
|
quest,
|
|
});
|
|
}
|
|
|
|
// Send emails in bulk
|
|
sendTxnEmail(membersToEmail, 'quest-started', [
|
|
{ name: 'PARTY_URL', content: '/party' },
|
|
]);
|
|
};
|
|
|
|
schema.methods.sendGroupChatReceivedWebhooks = function sendGroupChatReceivedWebhooks (chat) {
|
|
const query = {
|
|
webhooks: {
|
|
$elemMatch: {
|
|
type: 'groupChatReceived',
|
|
'options.groupId': this._id,
|
|
},
|
|
},
|
|
};
|
|
|
|
if (this.type === 'party') {
|
|
query['party._id'] = this._id;
|
|
} else {
|
|
query.guilds = this._id;
|
|
}
|
|
|
|
User.find(query).select({ webhooks: 1 }).lean().exec()
|
|
.then(users => {
|
|
users.forEach(user => {
|
|
groupChatReceivedWebhook.send(user, {
|
|
group: this,
|
|
chat,
|
|
});
|
|
});
|
|
})
|
|
.catch(err => logger.error(err));
|
|
};
|
|
|
|
schema.statics.cleanQuestParty = _cleanQuestParty;
|
|
schema.statics.cleanQuestUser = _cleanQuestUser;
|
|
|
|
// returns a clean object for group.quest
|
|
schema.statics.cleanGroupQuest = function cleanGroupQuest () {
|
|
return {
|
|
key: null,
|
|
active: false,
|
|
leader: null,
|
|
progress: {
|
|
collect: {},
|
|
},
|
|
members: {},
|
|
};
|
|
};
|
|
|
|
function _getUserUpdateForQuestReward (itemToAward, allAwardedItems) {
|
|
let updates = {
|
|
$set: {},
|
|
$inc: {},
|
|
$pull: {},
|
|
};
|
|
const dropK = itemToAward.key;
|
|
|
|
switch (itemToAward.type) { // eslint-disable-line default-case
|
|
case 'gear': {
|
|
// TODO This means they can lose their new gear on death, is that what we want?
|
|
updates.$set[`items.gear.owned.${dropK}`] = true;
|
|
updates.$pull.pinnedItems = { path: `gear.flat.${dropK}` };
|
|
break;
|
|
}
|
|
case 'eggs':
|
|
case 'food':
|
|
case 'hatchingPotions':
|
|
case 'quests': {
|
|
updates.$inc[`items.${itemToAward.type}.${dropK}`] = _.filter(allAwardedItems, { type: itemToAward.type, key: itemToAward.key }).length;
|
|
break;
|
|
}
|
|
case 'pets': {
|
|
updates.$set[`items.pets.${dropK}`] = 5;
|
|
break;
|
|
}
|
|
case 'mounts': {
|
|
updates.$set[`items.mounts.${dropK}`] = true;
|
|
break;
|
|
}
|
|
}
|
|
updates = _.omitBy(updates, _.isEmpty);
|
|
return updates;
|
|
}
|
|
|
|
async function _updateUserWithRetries (userId, updates, numTry = 1, query = {}) {
|
|
query._id = userId;
|
|
try {
|
|
return await User.updateOne(query, updates).exec();
|
|
} catch (err) {
|
|
if (numTry < MAX_UPDATE_RETRIES) {
|
|
numTry += 1; // eslint-disable-line no-param-reassign
|
|
return _updateUserWithRetries(userId, updates, numTry, query);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Participants: Grant rewards & achievements, finish quest.
|
|
// Changes the group object update members
|
|
schema.methods.finishQuest = async function finishQuest (quest) {
|
|
const questK = quest.key;
|
|
const updates = {
|
|
$inc: {
|
|
[`achievements.quests.${questK}`]: 1,
|
|
'stats.gp': Number(quest.drop.gp),
|
|
'stats.exp': Number(quest.drop.exp),
|
|
},
|
|
$set: {},
|
|
};
|
|
|
|
if (this._id === TAVERN_ID) {
|
|
updates.$set['party.quest.completed'] = questK; // Just show the notif
|
|
} else {
|
|
_.merge(updates, _cleanQuestParty({ $set: { 'party.quest.completed': questK } })); // clear quest progress
|
|
}
|
|
|
|
_.each(_.reject(quest.drop.items, 'onlyOwner'), item => {
|
|
_.merge(updates, _getUserUpdateForQuestReward(item, quest.drop.items));
|
|
});
|
|
|
|
const questOwnerUpdates = {};
|
|
const questLeader = this.quest.leader;
|
|
|
|
_.each(_.filter(quest.drop.items, 'onlyOwner'), item => {
|
|
_.merge(questOwnerUpdates, _getUserUpdateForQuestReward(item, quest.drop.items));
|
|
});
|
|
_.merge(questOwnerUpdates, updates);
|
|
|
|
const participants = this._id === TAVERN_ID ? {} : this.getParticipatingQuestMembers();
|
|
this.quest = {};
|
|
this.markModified('quest');
|
|
|
|
if (this._id === TAVERN_ID) {
|
|
return User.updateMany({}, updates).exec();
|
|
}
|
|
|
|
const promises = participants.map(userId => {
|
|
if (userId === questLeader) {
|
|
return _updateUserWithRetries(userId, questOwnerUpdates);
|
|
}
|
|
return _updateUserWithRetries(userId, updates);
|
|
});
|
|
|
|
// Send webhooks in background
|
|
// @TODO move the find users part to a worker as well, not just the http request
|
|
User.find({
|
|
_id: { $in: participants },
|
|
webhooks: {
|
|
$elemMatch: {
|
|
type: 'questActivity',
|
|
'options.questFinished': true,
|
|
},
|
|
},
|
|
})
|
|
.select('_id webhooks')
|
|
.lean()
|
|
.exec()
|
|
.then(participantsWithWebhook => {
|
|
participantsWithWebhook.forEach(participantWithWebhook => {
|
|
// Send webhooks
|
|
questActivityWebhook.send(participantWithWebhook, {
|
|
type: 'questFinished',
|
|
group: this,
|
|
quest,
|
|
});
|
|
});
|
|
})
|
|
.catch(err => logger.error(err));
|
|
|
|
_.forEach(questSeriesAchievements, (questList, achievement) => {
|
|
if (questList.includes(questK)) {
|
|
const questAchievementQuery = {};
|
|
questAchievementQuery[`achievements.${achievement}`] = { $ne: true };
|
|
|
|
_.forEach(questList, questName => {
|
|
if (questName !== questK) {
|
|
questAchievementQuery[`achievements.quests.${questName}`] = { $gt: 0 };
|
|
}
|
|
});
|
|
|
|
const questAchievementUpdate = { $set: {}, $push: {} };
|
|
questAchievementUpdate.$set[`achievements.${achievement}`] = true;
|
|
const achievementTitleCase = `${achievement.slice(0, 1).toUpperCase()}${achievement.slice(1, achievement.length)}`;
|
|
questAchievementUpdate.$push = {
|
|
notifications: new UserNotification({
|
|
type: 'ACHIEVEMENT_QUESTS',
|
|
data: {
|
|
achievement,
|
|
message: `${shared.i18n.t('modalAchievement')} ${shared.i18n.t(`achievement${achievementTitleCase}`)}`,
|
|
modalText: shared.i18n.t(`achievement${achievementTitleCase}ModalText`),
|
|
},
|
|
}).toObject(),
|
|
};
|
|
|
|
promises.push(participants.map(userId => _updateUserWithRetries(
|
|
userId,
|
|
questAchievementUpdate,
|
|
null,
|
|
questAchievementQuery,
|
|
)));
|
|
}
|
|
});
|
|
|
|
return Promise.all(promises);
|
|
};
|
|
|
|
function _isOnQuest (user, progress, group) {
|
|
return group && progress && group.quest && group.quest.active
|
|
&& group.quest.members[user._id] === true;
|
|
}
|
|
|
|
schema.methods._processBossQuest = async function processBossQuest (options) {
|
|
const {
|
|
user,
|
|
progress,
|
|
} = options;
|
|
|
|
const group = this;
|
|
const quest = questScrolls[group.quest.key];
|
|
const down = progress.down * quest.boss.str; // multiply by boss strength
|
|
// Everyone takes damage
|
|
const updates = {
|
|
$inc: { 'stats.hp': down },
|
|
};
|
|
const promises = [];
|
|
|
|
group.quest.progress.hp -= progress.up;
|
|
if (CRON_SAFE_MODE || CRON_SEMI_SAFE_MODE) {
|
|
const groupMessage = await group.sendChat({
|
|
message: `\`${shared.i18n.t('chatBossDontAttack', { bossName: quest.boss.name('en') }, 'en')}\``,
|
|
info: {
|
|
type: 'boss_dont_attack',
|
|
user: user.profile.name,
|
|
quest: group.quest.key,
|
|
userDamage: progress.up.toFixed(1),
|
|
},
|
|
});
|
|
promises.push(groupMessage.save());
|
|
} else {
|
|
const groupMessage = await group.sendChat({
|
|
message: `\`${shared.i18n.t('chatBossDamage', {
|
|
username: user.profile.name, bossName: quest.boss.name('en'), userDamage: progress.up.toFixed(1), bossDamage: Math.abs(down).toFixed(1),
|
|
}, user.preferences.language)}\``,
|
|
info: {
|
|
type: 'boss_damage',
|
|
user: user.profile.name,
|
|
quest: group.quest.key,
|
|
userDamage: progress.up.toFixed(1),
|
|
bossDamage: Math.abs(down).toFixed(1),
|
|
},
|
|
});
|
|
promises.push(groupMessage.save());
|
|
}
|
|
|
|
// 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) {
|
|
const rageMessage = await group.sendChat({
|
|
message: quest.boss.rage.effect('en'),
|
|
info: {
|
|
type: 'boss_rage',
|
|
quest: quest.key,
|
|
},
|
|
});
|
|
promises.push(rageMessage.save());
|
|
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;
|
|
if (quest.boss.rage.mpDrain) {
|
|
updates.$mul = { 'stats.mp': 1 - quest.boss.rage.mpDrain };
|
|
}
|
|
if (quest.boss.rage.progressDrain) {
|
|
updates.$mul = { 'party.quest.progress.up': quest.boss.rage.progressDrain };
|
|
}
|
|
}
|
|
}
|
|
|
|
await User.updateMany(
|
|
{
|
|
_id:
|
|
{ $in: this.getParticipatingQuestMembers() },
|
|
},
|
|
updates,
|
|
).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) {
|
|
const questFinishChat = await group.sendChat({
|
|
message: `\`${shared.i18n.t('chatBossDefeated', { bossName: quest.boss.name('en') }, 'en')}\``,
|
|
info: {
|
|
type: 'boss_defeated',
|
|
quest: quest.key,
|
|
},
|
|
});
|
|
promises.push(questFinishChat.save());
|
|
|
|
// Participants: Grant rewards & achievements, finish quest
|
|
await group.finishQuest(shared.content.quests[group.quest.key]);
|
|
}
|
|
|
|
promises.unshift(group.save());
|
|
return Promise.all(promises);
|
|
};
|
|
|
|
schema.methods._processCollectionQuest = async function processCollectionQuest (options) {
|
|
const {
|
|
user,
|
|
progress,
|
|
} = options;
|
|
|
|
const group = this;
|
|
const quest = questScrolls[group.quest.key];
|
|
const itemsFound = {};
|
|
Object.keys(quest.collect).forEach(item => {
|
|
itemsFound[item] = 0;
|
|
});
|
|
|
|
// Create an array of item names, one item name per item that still needs to
|
|
// be collected so that items are found proportionally to how many are needed.
|
|
const remainingItems = [].concat(...Object.keys(quest.collect).map(item => {
|
|
let count = quest.collect[item].count - (group.quest.progress.collect[item] || 0);
|
|
if (count < 0) { // This could only happen if there's a bug, but just in case.
|
|
count = 0;
|
|
}
|
|
return Array(count).fill(item);
|
|
}));
|
|
|
|
// slice() will grab only what is available even if requested slice is larger
|
|
// than the array, so we don't need to worry about overfilling quest items.
|
|
const collectedItems = _.shuffle(remainingItems).slice(0, progress.collectedItems);
|
|
collectedItems.forEach(item => {
|
|
itemsFound[item] += 1;
|
|
group.quest.progress.collect[item] += 1;
|
|
});
|
|
|
|
let foundText = _.reduce(itemsFound, (m, v, k) => {
|
|
m.push(`${v} ${quest.collect[k].text('en')}`);
|
|
return m;
|
|
}, []);
|
|
|
|
foundText = foundText.join(', ');
|
|
const foundChat = await group.sendChat({
|
|
message: `\`${shared.i18n.t('chatFindItems', { username: user.profile.name, items: foundText }, 'en')}\``,
|
|
info: {
|
|
type: 'user_found_items',
|
|
user: user.profile.name,
|
|
quest: quest.key,
|
|
items: itemsFound,
|
|
},
|
|
});
|
|
group.markModified('quest.progress.collect');
|
|
|
|
const promises = [group.save(), foundChat.save()];
|
|
|
|
const questFinished = collectedItems.length === remainingItems.length;
|
|
if (questFinished) {
|
|
await group.finishQuest(quest);
|
|
const allItemsFoundChat = await group.sendChat({
|
|
message: `\`${shared.i18n.t('chatItemQuestFinish', 'en')}\``,
|
|
info: {
|
|
type: 'all_items_found',
|
|
},
|
|
});
|
|
|
|
promises.push(allItemsFoundChat.save());
|
|
}
|
|
|
|
return Promise.all(promises);
|
|
};
|
|
|
|
schema.statics.processQuestProgress = async function processQuestProgress (user, progress) {
|
|
if (user.preferences.sleep) return;
|
|
|
|
const group = await this.getGroup({ user, groupId: 'party' });
|
|
|
|
if (!_isOnQuest(user, progress, group)) return;
|
|
|
|
const quest = shared.content.quests[group.quest.key];
|
|
|
|
if (!quest) return; // TODO should this throw an error instead?
|
|
|
|
const questType = quest.boss ? 'Boss' : 'Collection';
|
|
|
|
await group[`_process${questType}Quest`]({ // _processBossQuest, _processCollectionQuest
|
|
user,
|
|
progress,
|
|
group,
|
|
});
|
|
};
|
|
|
|
// to set a boss:
|
|
// `db.groups.updateOne({_id:TAVERN_ID},
|
|
// {$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}}).exec()`
|
|
// we export an empty object that is then populated with the query-returned data
|
|
export const tavernQuest = {};
|
|
const 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 null;
|
|
if (user.preferences.sleep) return null;
|
|
|
|
// hack: prevent crazy damage to world boss
|
|
const dmg = Math.min(900, Math.abs(progress.up || 0));
|
|
const rage = -Math.min(900, Math.abs(progress.down || 0));
|
|
|
|
const tavern = await this.findOne(tavernQ).exec();
|
|
if (!(tavern && tavern.quest && tavern.quest.key)) return null;
|
|
|
|
const quest = shared.content.quests[tavern.quest.key];
|
|
|
|
const chatPromises = [];
|
|
|
|
if (tavern.quest.progress.hp <= 0) {
|
|
const completeChat = await tavern.sendChat({
|
|
message: quest.completionChat('en'),
|
|
info: {
|
|
type: 'tavern_quest_completed',
|
|
quest: quest.key,
|
|
},
|
|
});
|
|
chatPromises.push(completeChat.save());
|
|
await tavern.finishQuest(quest);
|
|
_.assign(tavernQuest, { extra: null });
|
|
return tavern.save();
|
|
}
|
|
|
|
// 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 = {};
|
|
|
|
const wd = tavern.quest.extra.worldDmg;
|
|
// Dysheartener attacks Seasonal Sorceress, Alex, Ian
|
|
let scene;
|
|
if (wd.quests) {
|
|
scene = false;
|
|
} else if (wd.market) {
|
|
scene = 'quests';
|
|
} else if (wd.seasonalShop) {
|
|
scene = 'market';
|
|
} else {
|
|
scene = 'seasonalShop';
|
|
}
|
|
|
|
if (!scene) {
|
|
const tiredChat = await tavern.sendChat({
|
|
message: `\`${shared.i18n.t('tavernBossTired', { rageName: quest.boss.rage.title('en'), bossName: quest.boss.name('en') }, 'en')}\``,
|
|
info: {
|
|
type: 'tavern_boss_rage_tired',
|
|
quest: quest.key,
|
|
},
|
|
});
|
|
chatPromises.push(tiredChat.save());
|
|
tavern.quest.progress.rage = 0; // quest.boss.rage.value;
|
|
} else {
|
|
const rageChat = await tavern.sendChat({
|
|
message: quest.boss.rage[scene]('en'),
|
|
info: {
|
|
type: 'tavern_boss_rage',
|
|
quest: quest.key,
|
|
scene,
|
|
},
|
|
});
|
|
chatPromises.push(rageChat.save());
|
|
tavern.quest.extra.worldDmg[scene] = true;
|
|
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
|
|
) {
|
|
const progressChat = await tavern.sendChat({
|
|
message: quest.boss.desperation.text('en'),
|
|
info: {
|
|
type: 'tavern_boss_desperation',
|
|
quest: quest.key,
|
|
},
|
|
});
|
|
chatPromises.push(progressChat.save());
|
|
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());
|
|
|
|
chatPromises.unshift(tavern.save());
|
|
return Promise.all(chatPromises);
|
|
};
|
|
|
|
schema.methods.leave = async function leaveGroup (user, keep = 'keep-all', keepChallenges = 'leave-challenges') {
|
|
const group = this;
|
|
const update = {};
|
|
|
|
if (group.memberCount <= 1 && group.privacy === 'private' && group.hasNotCancelled()) {
|
|
throw new NotAuthorized(shared.i18n.t('cannotDeleteActiveGroup'));
|
|
}
|
|
|
|
if (group.leader === user._id && group.hasNotCancelled()) {
|
|
throw new NotAuthorized(shared.i18n.t('leaderCannotLeaveGroupWithActiveGroup'));
|
|
}
|
|
|
|
if (group.purchased.plan.customerId) {
|
|
await payments.cancelGroupSubscriptionForUser(user, this);
|
|
}
|
|
|
|
// only remove user from challenges if it's set to leave-challenges
|
|
if (keepChallenges === 'leave-challenges') {
|
|
const challenges = await Challenge.find({
|
|
_id: { $in: user.challenges },
|
|
group: group._id,
|
|
}).exec();
|
|
|
|
const challengesToRemoveUserFrom = challenges.map(chal => chal.unlinkTasks(user, keep, false));
|
|
await Promise.all(challengesToRemoveUserFrom);
|
|
}
|
|
|
|
// Unlink group tasks
|
|
const assignedTasks = await Tasks.Task.find({
|
|
'group.id': group._id,
|
|
userId: { $exists: false },
|
|
'group.assignedUsers': user._id,
|
|
}).exec();
|
|
const assignedTasksToRemoveUserFrom = assignedTasks
|
|
.map(task => this.unlinkTask(task, user, keep, false));
|
|
await Promise.all(assignedTasksToRemoveUserFrom);
|
|
|
|
this.unlinkTags(user);
|
|
|
|
// the user could be modified by calls to `unlinkTask` for challenge and group tasks
|
|
// it has not been saved before to avoid multiple saves in parallel
|
|
const promises = user.isModified() ? [user.save()] : [];
|
|
|
|
// remove the group from the user's groups
|
|
const userUpdate = { $pull: { 'preferences.tasks.mirrorGroupTasks': group._id } };
|
|
if (group.type === 'guild') {
|
|
userUpdate.$pull.guilds = group._id;
|
|
promises.push(User.updateOne({ _id: user._id }, userUpdate).exec());
|
|
} else {
|
|
userUpdate.$set = { party: {} };
|
|
promises.push(User.updateOne({ _id: user._id }, userUpdate).exec());
|
|
|
|
update.$unset = { [`quest.members.${user._id}`]: 1 };
|
|
}
|
|
|
|
// If user is the last one in group and group is private, delete it
|
|
if (group.memberCount <= 1 && group.privacy === 'private') {
|
|
// double check the member count is correct
|
|
// so we don't accidentally delete a group that still has users in it
|
|
let members;
|
|
if (group.type === 'guild') {
|
|
members = await User.find({ guilds: group._id }).select('_id').exec();
|
|
} else {
|
|
members = await User.find({ 'party._id': group._id }).select('_id').exec();
|
|
}
|
|
_.remove(members, { _id: user._id });
|
|
|
|
if (members.length === 0) {
|
|
promises.push(group.deleteOne());
|
|
return Promise.all(promises);
|
|
}
|
|
}
|
|
// otherwise If the leader is leaving
|
|
// (or if the leader previously left, and this wasn't accounted for)
|
|
update.$inc = { memberCount: -1 };
|
|
if (group.leader === user._id) {
|
|
const query = group.type === 'party' ? { 'party._id': group._id } : { guilds: group._id };
|
|
query._id = { $ne: user._id };
|
|
const 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.updateOne(update).exec());
|
|
|
|
return Promise.all(promises);
|
|
};
|
|
|
|
schema.methods.unlinkTags = function unlinkTags (user) {
|
|
const group = this;
|
|
user.tags.forEach(tag => {
|
|
if (tag.group && tag.group === group._id) {
|
|
tag.group = undefined;
|
|
}
|
|
});
|
|
};
|
|
|
|
schema.methods.syncTask = async function groupSyncTask (taskToSync, users, assigningUser) {
|
|
const group = this;
|
|
const toSave = [];
|
|
for (const user of users) {
|
|
const assignmentData = {
|
|
assignedDate: new Date(),
|
|
assignedUsername: user.auth.local.username,
|
|
assigningUsername: assigningUser.auth.local.username,
|
|
completed: false,
|
|
};
|
|
|
|
if (!taskToSync.group.assignedUsersDetail) {
|
|
taskToSync.group.assignedUsersDetail = {};
|
|
}
|
|
if (!taskToSync.group.assignedUsersDetail[user._id]) {
|
|
taskToSync.group.assignedUsersDetail[user._id] = assignmentData;
|
|
}
|
|
taskToSync.markModified('group.assignedUsersDetail');
|
|
|
|
taskToSync.group.assignedUsers = _.keys(taskToSync.group.assignedUsersDetail);
|
|
|
|
// Sync tags
|
|
const userTags = user.tags;
|
|
const i = _.findIndex(userTags, { id: group._id });
|
|
|
|
if (i !== -1) {
|
|
if (userTags[i].name !== group.name) {
|
|
// update the name - it's been changed since
|
|
userTags[i].name = group.name;
|
|
userTags[i].group = group._id;
|
|
}
|
|
} else {
|
|
userTags.push({
|
|
id: group._id,
|
|
name: group.name,
|
|
group: group._id,
|
|
});
|
|
}
|
|
toSave.push(user.save());
|
|
}
|
|
toSave.push(taskToSync.save());
|
|
return Promise.all(toSave);
|
|
};
|
|
|
|
schema.methods.unlinkTask = async function groupUnlinkTask (
|
|
unlinkingTask,
|
|
user,
|
|
keep,
|
|
saveUser = true,
|
|
) {
|
|
const findQuery = {
|
|
'group.taskId': unlinkingTask._id,
|
|
'group.assignedUsers': user._id,
|
|
};
|
|
|
|
if (unlinkingTask.group.assignedUsersDetail) {
|
|
delete unlinkingTask.group.assignedUsersDetail[user._id];
|
|
unlinkingTask.group.assignedUsers = _.keys(unlinkingTask.group.assignedUsersDetail);
|
|
} else {
|
|
// Task was created before assignedUsersDetail was added
|
|
removeFromArray(unlinkingTask.group.assignedUsers, user._id);
|
|
}
|
|
unlinkingTask.markModified('group');
|
|
|
|
const promises = [unlinkingTask.save()];
|
|
|
|
if (keep === 'keep-all') {
|
|
await Tasks.Task.updateOne(findQuery, {
|
|
$set: { group: {} },
|
|
}).exec();
|
|
|
|
// When multiple tasks are being unlinked at the same time,
|
|
// save the user once outside of this function
|
|
if (saveUser) await user.save();
|
|
} else { // keep = 'remove-all'
|
|
const task = await Tasks.Task.findOne(findQuery).select('_id type completed').exec();
|
|
// Remove task from user.tasksOrder and delete them
|
|
if (task && (task.type !== 'todo' || !task.completed)) {
|
|
removeFromArray(user.tasksOrder[`${task.type}s`], task._id);
|
|
user.markModified('tasksOrder');
|
|
}
|
|
|
|
if (task) {
|
|
promises.push(task.deleteOne());
|
|
}
|
|
// When multiple tasks are being unlinked at the same time,
|
|
// save the user once outside of this function
|
|
if (saveUser) promises.push(user.save());
|
|
}
|
|
await Promise.all(promises);
|
|
};
|
|
|
|
schema.methods.removeTask = async function groupRemoveTask (task) {
|
|
const group = this;
|
|
const removalPromises = [];
|
|
|
|
// Delete individual task copies and related notifications
|
|
const userTasks = await Tasks.Task.find({
|
|
userId: { $exists: true },
|
|
'group.id': group.id,
|
|
'group.taskId': task._id,
|
|
}, { userId: 1, _id: 1 }).exec();
|
|
|
|
userTasks.forEach(async userTask => {
|
|
const assignedUser = await User.findOne({ _id: userTask.userId }, 'notifications tasksOrder').exec();
|
|
|
|
let notificationIndex = assignedUser.notifications.findIndex(notification => notification
|
|
&& notification.type === 'GROUP_TASK_ASSIGNED'
|
|
&& notification.data && notification.data.taskId === task._id);
|
|
|
|
if (notificationIndex !== -1) {
|
|
assignedUser.notifications.splice(notificationIndex, 1);
|
|
}
|
|
|
|
notificationIndex = assignedUser.notifications.findIndex(notification => notification
|
|
&& notification.type === 'GROUP_TASK_NEEDS_WORK'
|
|
&& notification.data && notification.data.task
|
|
&& notification.data.task.id === userTask._id);
|
|
|
|
if (notificationIndex !== -1) {
|
|
assignedUser.notifications.splice(notificationIndex, 1);
|
|
}
|
|
|
|
notificationIndex = assignedUser.notifications.findIndex(notification => notification
|
|
&& notification.type === 'GROUP_TASK_APPROVED'
|
|
&& notification.data && notification.data.task
|
|
&& notification.data.task._id === userTask._id);
|
|
|
|
if (notificationIndex !== -1) {
|
|
assignedUser.notifications.splice(notificationIndex, 1);
|
|
}
|
|
|
|
await Tasks.Task.deleteOne({ _id: userTask._id });
|
|
removeFromArray(assignedUser.tasksOrder[`${task.type}s`], userTask._id);
|
|
removalPromises.push(assignedUser.save());
|
|
});
|
|
|
|
// Get Managers
|
|
const managerIds = Object.keys(group.managers);
|
|
managerIds.push(group.leader);
|
|
const managers = await User.find({ _id: managerIds }, 'notifications').exec(); // Use this method so we can get access to notifications
|
|
|
|
// Remove old notifications
|
|
managers.forEach(manager => {
|
|
const notificationIndex = manager.notifications.findIndex(notification => notification
|
|
&& notification.data && notification.data.groupTaskId === task._id
|
|
&& notification.type === 'GROUP_TASK_APPROVAL');
|
|
|
|
if (notificationIndex !== -1) {
|
|
manager.notifications.splice(notificationIndex, 1);
|
|
}
|
|
removalPromises.push(manager.save());
|
|
});
|
|
|
|
removeFromArray(group.tasksOrder[`${task.type}s`], task._id);
|
|
group.markModified('tasksOrder');
|
|
removalPromises.push(group.save());
|
|
|
|
return Promise.all(removalPromises);
|
|
};
|
|
|
|
// Returns true if the user has reached the spam message limit
|
|
schema.methods.checkChatSpam = function groupCheckChatSpam (user) {
|
|
if (this._id !== TAVERN_ID) {
|
|
return false;
|
|
} if (user.contributor && user.contributor.level >= SPAM_MIN_EXEMPT_CONTRIB_LEVEL) {
|
|
return false;
|
|
}
|
|
|
|
const currentTime = Date.now();
|
|
let userMessages = 0;
|
|
for (let i = 0; i < this.chat.length; i += 1) {
|
|
const message = this.chat[i];
|
|
if (message.uuid === user._id && currentTime - message.timestamp <= SPAM_WINDOW_LENGTH) {
|
|
userMessages += 1;
|
|
if (userMessages >= SPAM_MESSAGE_LIMIT) {
|
|
return true;
|
|
}
|
|
} else if (currentTime - message.timestamp > SPAM_WINDOW_LENGTH) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
schema.methods.hasActiveGroupPlan = function hasActiveGroupPlan () {
|
|
const now = new Date();
|
|
const { plan } = this.purchased;
|
|
return plan && plan.customerId
|
|
&& (!plan.dateTerminated || moment(plan.dateTerminated).isAfter(now));
|
|
};
|
|
|
|
schema.methods.hasNotCancelled = function hasNotCancelled () {
|
|
const { plan } = this.purchased;
|
|
return Boolean(this.hasActiveGroupPlan() && !plan.dateTerminated);
|
|
};
|
|
|
|
schema.methods.hasCancelled = function hasCancelled () {
|
|
const { plan } = this.purchased;
|
|
return Boolean(this.hasActiveGroupPlan() && plan.dateTerminated);
|
|
};
|
|
|
|
schema.methods.updateGroupPlan = async function updateGroupPlan (removingMember) {
|
|
// Recheck the group plan count
|
|
this.memberCount = await this.getMemberCount();
|
|
|
|
if (this.purchased.plan.paymentMethod === stripePayments.constants.PAYMENT_METHOD) {
|
|
await stripePayments.chargeForAdditionalGroupMember(this);
|
|
} else if (
|
|
this.purchased.plan.paymentMethod === amazonPayments.constants.PAYMENT_METHOD
|
|
&& !removingMember
|
|
) {
|
|
await amazonPayments.chargeForAdditionalGroupMember(this);
|
|
}
|
|
};
|
|
|
|
export const 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.countDocuments({ _id: TAVERN_ID }).then(count => {
|
|
if (count === 0) {
|
|
new model({ // eslint-disable-line new-cap
|
|
_id: TAVERN_ID,
|
|
leader: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0', // Siena Leslie
|
|
name: 'Tavern',
|
|
type: 'guild',
|
|
privacy: 'public',
|
|
}).save();
|
|
}
|
|
});
|
|
}
|