Files
habitica/website/server/models/group.js
Phillip Thelen 38b39b600c Adminpanel and revamped permissions (#13843)
* create Admin Panel page with initial content from Hall's admin section

* reorganise Admin Panel form and add more accordians

* add lastCron to fields returned by api.getHeroes

* improve timestamps and authentication section

* add party and quest info to Admin Panel, add party to heroAdminFields

* move Admin Panel menu item to top of menu, make invisible to non-admins

* remove code used for displaying all Heroes

* add avatar appearance and drops section in Admin Panel

* allow logged-in user to be the default hero loaded

* add time zones to timestamp/authentication section

* rename Items to Update Items

This will allow a new Items section to be added.

* add read-only Items display with button to copy data to Update Items section

* remove never-used allItemsPaths code that had been copied from Hall

* update tests for the attributes added to heroAdminFields

* supply names for items and also set information for gear/equipment

* remove code that loads subsections of content

We use enough of the content that it's easier to load it all and
access it through the content object, especially when we're looping
through different item types.

* add gear names and set details to Avatar Costume/Battle Gear section

* make the wiki URLs clickable and make minor item format improvements

* add gear sets for Check-In Incentives and animal ears and tails

* add gear set for Gold-Purchasable Quest Lines

Also merges the existing Mystery of the Masterclassers quest set into it.

* fix error with Kickstarter gear set and include wiki link

* improve description of check-in incentive gear set

* fix description of Items section

* fix lint warnings

* update another test for the attributes added to heroAdminFields

* allow "@" to be included when specifying Username to load

* create GetHeroParty API v3 route to fetch a given user's party data

Only some data from the party will be loaded (e.g., not private
data such as name, description).

Includes tests for the route.

See the next commit for front-end changes that use this.

* display data from a given user's party in admin panel

Only some data from the party will be loaded (e.g., not private
data such as name, description).

Also adds support for finding and displaying errors from the
user's data.

* use new error handling method for other sections

- Time zone differences
- Cron bugs
- Privilege removal (mute/block) - not a bug but needs to be highlighted

* redirect non-admin users away from admin-only page (WIP)

This needs more work. Currently, admin users are also redirected
if they access the page by direct URL or after reload.

* clarify source of items from Check-In Incentives and Lunar Battle quests

* replace non-standard form fields with HTML forms

* add user's language, remove unused export blocks

* convert functions to filters: formatDate, formatTimeZone

* improve display of minutes portion of time zone in Admin Panel

* move basic details about user to a new component

* move Timestamp/Cron/Auth/etc details to a new component - WIP, has errors

The automatic expand and error warnings don't reset themselves when
you fetch data for a new user.

* replace non-standard form fields with HTML forms

Most of this was done in 26fdcbbee5

* move Timestamp/Cron/Auth/etc details to a new component (fixed)

* move Avatar and Drops section to a new component

* move Party and Quest section to a new component

* move Contributor Details to new component, add checkbox for admin, add preview

This adds a markdown-enabled preview of the Contributions textarea.

It also removes the code that automatically set contributor.admin
to true when the Tier was above 7.
That feature wasn't secure because the Tier can be accidentally
changed if you scroll while the cursor is over the Tier form field
(we accidentally demoted a Socialite once by doing that and if
we'd scrolled in the other direction we would have given her
admin privileges).

Instead there's now a checkbox for giving moderator-level privileges.
We'll want that anyway when we move to a system of selected
privileges for each admin instead of all admin privileges being
given to all mods/staff.

There's also a commented-out checkbox for giving Bailey CMS
privileges, for when we're ready to use that. The User model doesn't
yet have support for it.

* move Privileges and Gems section to a new component

* rename formatItems to getItemDescription; make other minor fixes

* remove an outdated test description

This "pended" explanation probably wasn't needed after "x" was
removed from "describe" in 2ab76db27c

* add newsPoster Bailey CMS permission to User model and Admin Panel

* move formatDate from mixins to filters

* make lint fixes

* remove development comments from hall.js

I'll be handling the TODO comment and I've left in my "XXX" marker
to remind me

* fix bug in Hall's castItemVal: mounts are null not false

* move Items section to a new component and delete Update Items section

The Update Items section is no longer needed because the new Items
component has in-place editing.

* remove unused imports

* add "secret" field to "Privileges, Gem Balance" section.

Also move the markdownPreview style from contributorDetails.vue to
index.vue since it's used in two components now.

* show non-Standard never-owned Pets and Mounts in Items section

* redirect non-admin users away from admin-only page

This completes the work started in commit a4f9c754ad

It now allows admins to access the page when coming from another
page on the site or from a direct link, including if the admin user
isn't logged in yet.

* display memberCount for party

* add secret.text field to Contributor Details

This is in addition to showing it in the Privileges section because
the secret text could be about either troublesome behaviour or
contributions.

* allow user to be loaded into Admin Panel via a URL

This includes:

- router config has a child route for the admin panel with a
Username/ID as a parameter
- loadHero code moved from top-level index page into a new
"user support" index page
- links in the Hall changed to point to admin panel route
- admin panel link added to admin section of user profile modal

* keep list of known titles on their own lines

* sort heroFields alphabetically

No actual changes.

* return all flags for use in Admin Panel and fix Hall tests for flags

Future Admin Panel changes will display more flags.

NB 'flags' wasn't in the tests before, even though two optional
flags were being fetched.
The tests weren't failing because the test users hadn't been given
data for those optional flags.

The primary reason for this change now is to fix the tests.

* show part of the API Token in the Admin Panel

* send full hero object into cronAndAuth.vue

This is a prelude to allowing this component to change the hero.

* split heroAdminFields string into two: one for fetching data and one for showing it

This is because apiToken must be fetched but not shown,
while apiTokenObscured is calculated (not fetched) and shown.

* let admin change a user's API Token

* restore sanity

* remove code to show obscured version of API Token

It will return with tighter permissions for viewing it.

* add Custom Day Start time (CDS) to Timestamps, Time Zone... section

* commit lint's automatic fixes - one for admin-panel changes in hall.js

The other fixes aren't related to this PR but I figured they may
as well go live.

* apply fixes from paglias's comments, excluding style/CSS changesd

The comments that this PR fixes start at
https://github.com/HabitRPG/habitica/pull/12035#pullrequestreview-500422316

Style fixes will be in a future commit.

* fix styles/CSS

* allow profile modal to close when using admin panel link

Also removes an empty components block.

* prevent Admin Panel being used without new userSupport privilege

Also adds initial support for other contributor.priv privileges
and changes Debug Menu to add userSupport privilege

* don't do this: this.hero = { ...hero };

* enhance quest error messages

* redirect to admin-panel home page when using "Save and Clear Data"

The user's ID / name is still in the form for easy refetching.

* create ensurePriv function, use in api.getHeroParty

* fix lint problems and integration tests

* add page title to top-level Admin Panel

Also add more details to a router comment (consistent with a similar
comment) in case it helps anyone.

* fix tests

* display Moderation Notes above Contributions

* lint fix

* remove placeholder code for new privileges

I had planned to have each of these implemented in stages, but
paglias wanted it all done at once. I'm afraid that's too big a
project for me to take on in a single PR so I'm cancelling
the plans for adjusting the privileges.

* Improve permission handling

* Don't report timezone error on first day

* fix lint error

* .

* Fix lint error

* fix failing tests

* Fix more tests

* .

* ..

* ...

* fix(admin): always include permissions when querying user
also remove unnecessary failing test case

* permission improvements

* show transactions in admin panel

* fix lint errors

* fix permission check

* fix(panel): missing mixin, handle empty perms object

Co-authored-by: Alys <alice.harris@oldgods.net>
Co-authored-by: SabreCat <sabe@habitica.com>
2022-05-03 14:40:56 -05:00

1756 lines
57 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 { // eslint-disable-line import/no-cycle
syncableAttrs,
} from '../libs/tasks/utils';
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
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 { 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 { GUILDS_PER_PAGE } = 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 = 200;
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 },
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 },
}],
}, {
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('remove', 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 = {};
} else {
removeFromArray(user.guilds, groupId);
}
await user.save();
}
return group;
};
export const VALID_QUERY_TYPES = ['party', 'guilds', 'privateGuilds', 'publicGuilds', 'tavern'];
schema.statics.getGroups = async function getGroups (options = {}) {
const {
user, types, groupFields = basicFields,
sort = '-memberCount', populateLeader = false,
paginate = false, page = 0, // optional pagination for public guilds
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': {
const query = {
type: 'guild',
_id: { $in: user.guilds, $ne: TAVERN_ID },
};
_.assign(query, filters);
const userGuildsQuery = this.find(query).select(groupFields);
if (populateLeader === true) userGuildsQuery.populate('leader', nameFields);
userGuildsQuery.sort(sort).exec();
queries.push(userGuildsQuery);
break;
}
case 'privateGuilds': {
const query = {
type: 'guild',
privacy: 'private',
_id: { $in: user.guilds },
};
_.assign(query, filters);
const privateGuildsQuery = this.find(query).select(groupFields);
if (populateLeader === true) privateGuildsQuery.populate('leader', nameFields);
privateGuildsQuery.sort(sort).exec();
queries.push(privateGuildsQuery);
break;
}
// NOTE: when returning publicGuilds we use `.lean()` so all
// mongoose methods won't be available.
// Docs are going to be plain javascript objects
case 'publicGuilds': {
const query = {
type: 'guild',
privacy: 'public',
_id: { $ne: TAVERN_ID },
};
_.assign(query, filters);
const publicGuildsQuery = this.find(query).select(groupFields);
if (populateLeader === true) publicGuildsQuery.populate('leader', nameFields);
if (paginate === true) {
publicGuildsQuery.limit(GUILDS_PER_PAGE).skip(page * GUILDS_PER_PAGE);
}
publicGuildsQuery.sort(sort).lean().exec();
queries.push(publicGuildsQuery);
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.text = translateMessage(userLang, chatMsg.info);
}
// 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;
}
}
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) {
let totalInvites = 0;
if (uuids) {
totalInvites += uuids.length;
}
if (emails) {
totalInvites += emails.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);
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();
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 + 1 }));
}
}
};
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 () {
let query = { guilds: this._id };
if (this.type === 'party') {
query = { 'party._id': this._id };
}
return User.countDocuments(query).exec();
};
schema.methods.sendChat = 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 = {};
if (this.type === 'party') {
query['party._id'] = this._id;
} else {
query.guilds = this._id;
}
query._id = { $ne: user ? user._id : '' };
// First remove the old notification (if it exists)
const lastSeenUpdateRemoveOld = {
$pull: {
notifications: { type: 'NEW_CHAT_MESSAGE', 'data.group.id': this._id },
},
};
// Then 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
.update(query, lastSeenUpdateRemoveOld, { multi: true })
.exec()
.then(() => User.update(query, lastSeenUpdateAddNew, { multi: true }).exec())
.catch(err => logger.error(err));
if (this.type === 'party' && user) {
sendChatPushNotifications(user, this, newChatMessage, mentions, translate);
}
if (mentionedMembers) {
mentionedMembers.forEach(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) {
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.update(
{
_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.nModified) {
// update also current instance so future operations will work correctly
this.quest.members[user._id] = accept;
}
return Boolean(result.nModified);
};
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));
// 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.update({ $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.update({ _id: this.quest.leader }, {
$inc: {
[`items.quests.${this.quest.key}`]: -1,
},
}).exec());
}
// update the remaining users
promises.push(User.update({
_id: { $in: nonUserQuestMembers },
}, {
$set: {
'party.quest.key': this.quest.key,
'party.quest.progress.down': 0,
'party.quest.completed': null,
},
}, { multi: true }).exec());
await Promise.all(promises);
// update the users who are not participating
// Do not block updates
User.update({
_id: { $in: nonMembers },
}, _cleanQuestParty(),
{ multi: true }).exec();
const newMessage = 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
members.forEach(member => {
if (member._id !== user._id) {
// send push notifications and filter users that disabled emails
if (member.preferences.emailNotifications.questStarted !== false) {
membersToEmail.push(member);
}
// send push notifications and filter users that disabled emails
if (member.preferences.pushNotifications.questStarted !== false) {
const memberLang = member.preferences.language;
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: {},
};
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;
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.update(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.update({}, updates, { multi: true }).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)}`;
const achievementSnakeCase = `ACHIEVEMENT_${_.snakeCase(achievement).toUpperCase()}`;
questAchievementUpdate.$push = {
notifications: new UserNotification({
type: achievementSnakeCase,
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 = 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 = 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 = 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.$set = { 'stats.mp': 0 };
}
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 = 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 = 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 = 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.update({_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 = 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 = 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 = 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 = 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'));
}
// 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
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());
update.$unset = { [`quest.members.${user._id}`]: 1 };
}
if (group.purchased.plan.customerId) {
promises.push(payments.cancelGroupSubscriptionForUser(user, this));
}
// 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.remove());
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.update(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;
}
});
};
/**
* Updates all linked tasks for a group task
*
* @param taskToSync The group task that will be synced
* @param options.newCheckListItem The new checklist item
* that needs to be synced to all assigned users
* @param options.removedCheckListItem The removed checklist item that
* needs to be removed from all assigned users
*
* @return The created tasks
*/
schema.methods.updateTask = async function updateTask (taskToSync, options = {}) {
const group = this;
const updateCmd = { $set: {} };
const syncableAttributes = syncableAttrs(taskToSync);
for (const key of Object.keys(syncableAttributes)) {
updateCmd.$set[key] = syncableAttributes[key];
}
updateCmd.$set['group.approval.required'] = taskToSync.group.approval.required;
updateCmd.$set['group.assignedUsers'] = taskToSync.group.assignedUsers;
updateCmd.$set['group.sharedCompletion'] = taskToSync.group.sharedCompletion;
updateCmd.$set['group.managerNotes'] = taskToSync.group.managerNotes;
const taskSchema = Tasks[taskToSync.type];
const updateQuery = {
userId: { $exists: true },
'group.id': group.id,
'group.taskId': taskToSync._id,
};
if (options.newCheckListItem) {
const newCheckList = { completed: false };
newCheckList.linkId = options.newCheckListItem.id;
newCheckList.text = options.newCheckListItem.text;
updateCmd.$push = { checklist: newCheckList };
}
if (options.removedCheckListItemId) {
updateCmd.$pull = { checklist: { linkId: { $in: [options.removedCheckListItemId] } } };
}
if (options.updateCheckListItems) {
updateCmd.$set.checklist = taskToSync.checklist;
}
// Updating instead of loading and saving for performances,
// risks becoming a problem if we introduce more complexity in tasks
await taskSchema.update(updateQuery, updateCmd, { multi: true }).exec();
};
schema.methods.syncTask = async function groupSyncTask (taskToSync, user, assigningUser) {
const group = this;
const toSave = [];
if (taskToSync.group.assignedUsers.indexOf(user._id) === -1) {
taskToSync.group.assignedUsers.push(user._id);
}
// 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,
});
}
const findQuery = {
'group.taskId': taskToSync._id,
userId: user._id,
'group.id': group._id,
};
let matchingTask = await Tasks.Task.findOne(findQuery).exec();
if (!matchingTask) { // If the task is new, create it
matchingTask = new Tasks[taskToSync.type](Tasks.Task.sanitize(syncableAttrs(taskToSync)));
matchingTask.group.id = taskToSync.group.id;
matchingTask.userId = user._id;
matchingTask.group.taskId = taskToSync._id;
matchingTask.group.assignedDate = new Date();
user.tasksOrder[`${taskToSync.type}s`].unshift(matchingTask._id);
} else {
_.merge(matchingTask, syncableAttrs(taskToSync));
// Make sure the task is in user.tasksOrder
const orderList = user.tasksOrder[`${taskToSync.type}s`];
if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id);
}
matchingTask.group.approval.required = taskToSync.group.approval.required;
matchingTask.group.assignedUsers = taskToSync.group.assignedUsers;
matchingTask.group.sharedCompletion = taskToSync.group.sharedCompletion;
matchingTask.group.managerNotes = taskToSync.group.managerNotes;
if (assigningUser && user._id !== assigningUser._id) {
matchingTask.group.assigningUsername = assigningUser.auth.local.username;
}
// sync checklist
if (taskToSync.checklist) {
taskToSync.checklist.forEach(element => {
const newCheckList = { completed: false };
newCheckList.linkId = element.id;
newCheckList.text = element.text;
matchingTask.checklist.push(newCheckList);
});
}
// don't override the notes, but provide it if not provided
if (!matchingTask.notes) matchingTask.notes = taskToSync.notes;
// add tag if missing
if (matchingTask.tags.indexOf(group._id) === -1) matchingTask.tags.push(group._id);
toSave.push(matchingTask.save(), taskToSync.save(), user.save());
return Promise.all(toSave);
};
schema.methods.unlinkTask = async function groupUnlinkTask (
unlinkingTask, user,
keep, saveUser = true,
) {
const findQuery = {
'group.taskId': unlinkingTask._id,
userId: user._id,
};
const assignedUserIndex = unlinkingTask.group.assignedUsers.indexOf(user._id);
unlinkingTask.group.assignedUsers.splice(assignedUserIndex, 1);
if (keep === 'keep-all') {
await Tasks.Task.update(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');
}
const promises = [unlinkingTask.save()];
if (task) {
promises.push(task.remove());
}
// 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.remove({ _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 }, (err, ct) => {
if (err) throw err;
if (ct > 0) return;
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();
});
}