@@ -101,6 +112,10 @@ export default {
type: Object,
required: true,
},
+ hasUnsavedChanges: {
+ type: Boolean,
+ required: true,
+ },
},
data () {
return {
diff --git a/website/client/src/components/settings/subscription.vue b/website/client/src/components/settings/subscription.vue
index 4c89a083c6..25fa5eb36b 100644
--- a/website/client/src/components/settings/subscription.vue
+++ b/website/client/src/components/settings/subscription.vue
@@ -843,7 +843,6 @@ export default {
purchasedPlanIdInfo () {
if (!this.subscriptionBlocks[this.user.purchased.plan.planId]) {
// @TODO: find which subs are in the common
- // console.log(this.subscriptionBlocks
// [this.user.purchased.plan.planId]); // eslint-disable-line
return {
price: 0,
diff --git a/website/client/src/store/actions/adminPanel.js b/website/client/src/store/actions/adminPanel.js
index 43e1805429..5084295db1 100644
--- a/website/client/src/store/actions/adminPanel.js
+++ b/website/client/src/store/actions/adminPanel.js
@@ -5,3 +5,9 @@ export async function searchUsers (store, payload) {
const response = await axios.get(url);
return response.data.data;
}
+
+export async function getUserHistory (store, payload) {
+ const url = `/api/v4/admin/user/${payload.userIdentifier}/history`;
+ const response = await axios.get(url);
+ return response.data.data;
+}
diff --git a/website/client/src/store/actions/hall.js b/website/client/src/store/actions/hall.js
index 52fde94b26..8aa3a277ea 100644
--- a/website/client/src/store/actions/hall.js
+++ b/website/client/src/store/actions/hall.js
@@ -32,3 +32,9 @@ export async function getHeroParty (store, payload) {
const response = await axios.get(url);
return response.data.data;
}
+
+export async function getHeroGroupPlans (store, payload) {
+ const url = `/api/v4/hall/heroes/${payload.heroId}/group-plans`;
+ const response = await axios.get(url);
+ return response.data.data;
+}
diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js
index f8dfbe86d1..0c68778090 100644
--- a/website/server/controllers/api-v3/groups.js
+++ b/website/server/controllers/api-v3/groups.js
@@ -28,6 +28,10 @@ import stripePayments from '../../libs/payments/stripe';
import amzLib from '../../libs/payments/amazon';
import { apiError } from '../../libs/apiError';
import { model as UserNotification } from '../../models/userNotification';
+import {
+ leaveGroup,
+ removeMessagesFromMember,
+} from '../../libs/groups';
const { MAX_SUMMARY_SIZE_FOR_GUILDS } = common.constants;
const MAX_EMAIL_INVITES_BY_USER = 200;
@@ -776,21 +780,6 @@ api.rejectGroupInvite = {
},
};
-function _removeMessagesFromMember (member, groupId) {
- if (member.newMessages[groupId]) {
- delete member.newMessages[groupId];
- member.markModified('newMessages');
- }
-
- member.notifications = member.notifications.filter(n => {
- if (n && n.type === 'NEW_CHAT_MESSAGE' && n.data && n.data.group && n.data.group.id === groupId) {
- return false;
- }
-
- return true;
- });
-}
-
/**
* @api {post} /api/v3/groups/:groupId/leave Leave a group
* @apiName LeaveGroup
@@ -840,32 +829,13 @@ api.leaveGroup = {
if (validationErrors) throw validationErrors;
const { groupId } = req.params;
- const group = await Group.getGroup({
- user, groupId, fields: '-chat', requireMembership: true,
+ await leaveGroup({
+ res,
+ user,
+ groupId,
+ keep: req.query.keep,
+ keepChallenges: req.body.keepChallenges,
});
- if (!group) {
- throw new NotFound(res.t('groupNotFound'));
- }
-
- // During quests, check if user can leave
- if (group.type === 'party') {
- if (group.quest && group.quest.leader === user._id) {
- throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup'));
- }
-
- if (
- group.quest && group.quest.active
- && group.quest.members && group.quest.members[user._id]
- ) {
- throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest'));
- }
- }
-
- await group.leave(user, req.query.keep, req.body.keepChallenges);
- _removeMessagesFromMember(user, group._id);
- await user.save();
-
- if (group.hasNotCancelled()) await group.updateGroupPlan(true);
res.respond(200, {});
},
};
@@ -981,7 +951,7 @@ api.removeGroupMember = {
member.party._id = undefined; // TODO remove quest information too? Use group.leave()?
}
- _removeMessagesFromMember(member, group._id);
+ removeMessagesFromMember(member, group._id);
if (group.quest && group.quest.active && group.quest.leader === member._id) {
member.items.quests[group.quest.key] += 1;
diff --git a/website/server/controllers/api-v3/hall.js b/website/server/controllers/api-v3/hall.js
index 0303764d0c..6948a0c133 100644
--- a/website/server/controllers/api-v3/hall.js
+++ b/website/server/controllers/api-v3/hall.js
@@ -7,12 +7,15 @@ import { model as Group } from '../../models/group';
import common from '../../../common';
import {
NotFound,
+ BadRequest,
} from '../../libs/errors';
import { apiError } from '../../libs/apiError';
import {
validateItemPath,
castItemVal,
} from '../../libs/items/utils';
+import { addSubToGroupUser } from '../../libs/payments/groupPayments';
+import { leaveGroup } from '../../libs/groups';
const api = {};
@@ -146,7 +149,7 @@ api.getHeroes = {
// Note, while the following routes are called getHero / updateHero
// they can be used by admins to get/update any user
-const heroAdminFields = 'auth balance contributor flags items lastCron party preferences profile purchased secret permissions achievements';
+const heroAdminFields = 'auth balance contributor flags items lastCron party preferences profile purchased secret permissions achievements stats';
const heroAdminFieldsToFetch = heroAdminFields; // these variables will make more sense when...
const heroAdminFieldsToShow = heroAdminFields; // ... apiTokenObscured is added
@@ -314,6 +317,77 @@ api.updateHero = {
if (plan.cumulativeCount) {
hero.purchased.plan.cumulativeCount = plan.cumulativeCount;
}
+ if (plan.extraMonths || plan.extraMonths === 0) {
+ hero.purchased.plan.extraMonths = plan.extraMonths;
+ }
+ if (plan.customerId) {
+ hero.purchased.plan.customerId = plan.customerId;
+ }
+ if (plan.paymentMethod) {
+ hero.purchased.plan.paymentMethod = plan.paymentMethod;
+ }
+ if (plan.planId) {
+ hero.purchased.plan.planId = plan.planId;
+ }
+ if (plan.owner) {
+ hero.purchased.plan.owner = plan.owner;
+ }
+ if (plan.hourglassPromoReceived) {
+ hero.purchased.plan.hourglassPromoReceived = plan.hourglassPromoReceived;
+ }
+
+ if (plan.convertToGroupPlan) {
+ const groupID = plan.convertToGroupPlan;
+ const group = await Group.getGroup({ user: hero, groupId: groupID });
+ if (!group) throw new NotFound(res.t('groupNotFound'));
+ if (group.hasNotCancelled()) {
+ hero.purchased.plan.customerId = null;
+ hero.purchased.plan.paymentMethod = null;
+ await addSubToGroupUser(hero, group);
+ await group.updateGroupPlan();
+ } else {
+ throw new BadRequest('Group does not have a plan');
+ }
+ }
+ }
+
+ if (updateData.stats) {
+ if (updateData.stats.hp) {
+ hero.stats.hp = updateData.stats.hp;
+ }
+ if (updateData.stats.mp) {
+ hero.stats.mp = updateData.stats.mp;
+ }
+ if (updateData.stats.exp) {
+ hero.stats.exp = updateData.stats.exp;
+ }
+ if (updateData.stats.gp) {
+ hero.stats.gp = updateData.stats.gp;
+ }
+ if (updateData.stats.lvl) {
+ hero.stats.lvl = updateData.stats.lvl;
+ }
+ if (updateData.stats.points) {
+ hero.stats.points = updateData.stats.points;
+ }
+ if (updateData.stats.str) {
+ hero.stats.str = updateData.stats.str;
+ }
+ if (updateData.stats.int) {
+ hero.stats.int = updateData.stats.int;
+ }
+ if (updateData.stats.per) {
+ hero.stats.per = updateData.stats.per;
+ }
+ if (updateData.stats.con) {
+ hero.stats.con = updateData.stats.con;
+ }
+ if (updateData.stats.buffs) {
+ hero.stats.buffs = updateData.stats.buffs;
+ }
+ if (updateData.stats.class) {
+ hero.stats.class = updateData.stats.class;
+ }
}
// give them gems if they got an higher level
@@ -435,6 +509,17 @@ api.updateHero = {
}
const savedHero = await hero.save();
+
+ if (updateData.removeFromParty) {
+ await leaveGroup({
+ user: savedHero,
+ groupId: savedHero.party._id,
+ res,
+ keep: false,
+ keepChallenges: false,
+ });
+ }
+
const heroJSON = savedHero.toJSON();
heroJSON.secret = savedHero.getSecretData();
const responseHero = { _id: heroJSON._id }; // only respond with important fields
@@ -491,4 +576,66 @@ api.getHeroParty = { // @TODO XXX add tests
},
};
+/**
+ * @api {get} /api/v3/hall/heroes/:heroId Get Group Plans for a user
+ * @apiParam (Path) {UUID} groupId party's group ID
+ * @apiName GetHeroGroupPlans
+ * @apiGroup Hall
+ * @apiPermission userSupport
+ *
+ * @apiDescription Returns some basic information about group plans,
+ * to assist admins with user support.
+ *
+ * @apiSuccess {Object} data The active group plans
+ *
+ * @apiUse NoAuthHeaders
+ * @apiUse NoAccount
+ * @apiUse NoUser
+ * @apiUse NoPrivs
+ */
+api.getHeroGroupPlans = {
+ method: 'GET',
+ url: '/hall/heroes/:heroId/group-plans',
+ middlewares: [authWithHeaders(), ensurePermission('userSupport')],
+ async handler (req, res) {
+ req.checkParams('heroId', res.t('heroIdRequired')).notEmpty();
+
+ const validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ const { heroId } = req.params;
+
+ let query;
+ if (validator.isUUID(heroId)) {
+ query = { _id: heroId };
+ } else {
+ query = { 'auth.local.lowerCaseUsername': heroId.toLowerCase() };
+ }
+
+ const hero = await User
+ .findOne(query)
+ .select('guilds party')
+ .exec();
+
+ if (!hero) throw new NotFound(res.t('userWithIDNotFound', { userId: heroId }));
+ const heroGroups = hero.getGroups();
+
+ if (heroGroups.length === 0) {
+ res.respond(200, []);
+ return;
+ }
+
+ const groups = await Group
+ .find({
+ _id: { $in: heroGroups },
+ })
+ .select('leaderOnly leader purchased name managers memberCount')
+ .exec();
+
+ const groupPlans = groups.filter(group => group.hasActiveGroupPlan());
+
+ res.respond(200, groupPlans);
+ },
+};
+
export default api;
diff --git a/website/server/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js
index f9152bc5bc..adf47923b5 100644
--- a/website/server/controllers/api-v3/quests.js
+++ b/website/server/controllers/api-v3/quests.js
@@ -19,6 +19,7 @@ import common from '../../../common';
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
import { apiError } from '../../libs/apiError';
import { questActivityWebhook } from '../../libs/webhook';
+import { model as UserHistory } from '../../models/userHistory';
const analytics = getAnalyticsServiceByEnvironment();
@@ -172,6 +173,10 @@ api.inviteToQuest = {
uuid: user._id,
headers: req.headers,
});
+
+ await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
+ .withQuestInviteResponse(group.quest.key, 'invite')
+ .commit();
},
};
@@ -233,6 +238,10 @@ api.acceptQuest = {
uuid: user._id,
headers: req.headers,
});
+
+ await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
+ .withQuestInviteResponse(group.quest.key, 'accept')
+ .commit();
},
};
@@ -294,6 +303,10 @@ api.rejectQuest = {
uuid: user._id,
headers: req.headers,
});
+
+ await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
+ .withQuestInviteResponse(group.quest.key, 'reject')
+ .commit();
},
};
@@ -399,13 +412,14 @@ api.cancelQuest = {
}
if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest'));
- const questName = questScrolls[group.quest.key].text('en');
+ const questKey = group.quest.key;
+ const questName = questScrolls[questKey].text('en');
const newChatMessage = await group.sendChat({
message: `\`${user.profile.name} cancelled the party quest ${questName}.\``,
info: {
type: 'quest_cancel',
user: user.profile.name,
- quest: group.quest.key,
+ quest: questKey,
},
});
@@ -422,6 +436,15 @@ api.cancelQuest = {
]);
res.respond(200, savedGroup.quest);
+
+ await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
+ .withQuestInviteResponse(questKey, 'cancel')
+ .commit();
+ if (group.quest.leader !== user._id) {
+ await UserHistory.beginUserHistoryUpdate(group.quest.leader, req.headers)
+ .withQuestInviteResponse(questKey, 'cancelByLeader')
+ .commit();
+ }
},
};
@@ -461,13 +484,14 @@ api.abortQuest = {
if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToAbort'));
if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest'));
- const questName = questScrolls[group.quest.key].text('en');
+ const questKey = group.quest.key;
+ const questName = questScrolls[questKey].text('en');
const newChatMessage = await group.sendChat({
message: `\`${common.i18n.t('chatQuestAborted', { username: user.profile.name, questName }, 'en')}\``,
info: {
type: 'quest_abort',
user: user.profile.name,
- quest: group.quest.key,
+ quest: questKey,
},
});
await newChatMessage.save();
@@ -480,7 +504,7 @@ api.abortQuest = {
_id: group.quest.leader,
}, {
$inc: {
- [`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader
+ [`items.quests.${questKey}`]: 1, // give back the quest to the quest leader
},
}).exec();
@@ -490,6 +514,15 @@ api.abortQuest = {
const [groupSaved] = await Promise.all([group.save(), memberUpdates, questLeaderUpdate]);
res.respond(200, groupSaved.quest);
+
+ await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
+ .withQuestInviteResponse(questKey, 'abort')
+ .commit();
+ if (group.quest.leader !== user._id) {
+ await UserHistory.beginUserHistoryUpdate(group.quest.leader, req.headers)
+ .withQuestInviteResponse(questKey, 'abortByLeader')
+ .commit();
+ }
},
};
@@ -537,6 +570,10 @@ api.leaveQuest = {
]);
res.respond(200, savedGroup.quest);
+
+ await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
+ .withQuestInviteResponse(group.quest.key, 'leave')
+ .commit();
},
};
diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js
index f96247ff11..4e2c31c169 100644
--- a/website/server/controllers/api-v3/user.js
+++ b/website/server/controllers/api-v3/user.js
@@ -22,6 +22,7 @@ import {
} from '../../libs/email';
import * as inboxLib from '../../libs/inbox';
import * as userLib from '../../libs/user';
+import { model as UserHistory } from '../../models/userHistory';
const OFFICIAL_PLATFORMS = ['habitica-web', 'habitica-ios', 'habitica-android'];
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS_TECH_ASSISTANCE_EMAIL');
@@ -501,6 +502,13 @@ api.buy = {
const buyRes = await common.ops.buy(user, req, res.analytics);
await user.save();
+
+ if (type === 'armoire') {
+ await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
+ .withArmoire(buyRes[0].armoire.dropKey || 'experience')
+ .commit();
+ }
+
res.respond(200, ...buyRes);
},
};
@@ -593,6 +601,9 @@ api.buyArmoire = {
}
const buyArmoireResponse = await common.ops.buy(user, req, res.analytics);
await user.save();
+ await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
+ .withArmoire(buyArmoireResponse[0].armoire.dropKey || 'experience')
+ .commit();
res.respond(200, ...buyArmoireResponse);
},
};
diff --git a/website/server/controllers/api-v4/admin.js b/website/server/controllers/api-v4/admin.js
index 01b8e4b4f6..bf1decf363 100644
--- a/website/server/controllers/api-v4/admin.js
+++ b/website/server/controllers/api-v4/admin.js
@@ -2,6 +2,10 @@ import validator from 'validator';
import { authWithHeaders } from '../../middlewares/auth';
import { ensurePermission } from '../../middlewares/ensureAccessRight';
import { model as User } from '../../models/user';
+import { model as UserHistory } from '../../models/userHistory';
+import {
+ NotFound,
+} from '../../libs/errors';
const api = {};
@@ -21,7 +25,7 @@ const api = {};
* @apiUse NoUser
* @apiUse NotAdmin
*/
-api.getHero = {
+api.searchHero = {
method: 'GET',
url: '/admin/search/:userIdentifier',
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
@@ -73,4 +77,43 @@ api.getHero = {
},
};
+/**
+ * @api {get} /api/v4/admin/user/:userId/history Get the history of a user
+ * @apiParam (Path) {String} userIdentifier The username or email of the user
+ * @apiName GetUserHistory
+ * @apiGroup Admin
+ * @apiPermission Admin
+ *
+ * @apiDescription Returns the history of a user
+ *
+ * @apiSuccess {Object} data The User history
+ *
+ * @apiUse NoAuthHeaders
+ * @apiUse NoAccount
+ * @apiUse NoUser
+ * @apiUse NotAdmin
+ */
+api.getUserHistory = {
+ method: 'GET',
+ url: '/admin/user/:userId/history',
+ middlewares: [authWithHeaders(), ensurePermission('userSupport')],
+ async handler (req, res) {
+ req.checkParams('userId', res.t('heroIdRequired')).notEmpty().isUUID();
+
+ const validationErrors = req.validationErrors();
+ if (validationErrors) throw validationErrors;
+
+ const { userId } = req.params;
+
+ const history = await UserHistory
+ .findOne({ userId })
+ .lean()
+ .exec();
+
+ if (!history) throw new NotFound(res.t('userWithIDNotFound', { userId }));
+
+ res.respond(200, history);
+ },
+};
+
export default api;
diff --git a/website/server/libs/cron.js b/website/server/libs/cron.js
index 1af449af4b..61e79b8e0b 100644
--- a/website/server/libs/cron.js
+++ b/website/server/libs/cron.js
@@ -7,6 +7,7 @@ import common from '../../common';
import { preenUserHistory } from './preening';
import { sleep } from './sleep';
import { revealMysteryItems } from './payments/subscriptions';
+import { model as UserHistory } from '../models/userHistory';
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true';
@@ -471,5 +472,9 @@ export async function cron (options = {}) {
user.flags.cronCount += 1;
trackCronAnalytics(analytics, user, _progress, options);
+ await UserHistory.beginUserHistoryUpdate(user._id, options.headers)
+ .withCron(user.flags.cronCount)
+ .commit();
+
return _progress;
}
diff --git a/website/server/libs/groups.js b/website/server/libs/groups.js
new file mode 100644
index 0000000000..c2a15a76e0
--- /dev/null
+++ b/website/server/libs/groups.js
@@ -0,0 +1,58 @@
+import {
+ NotFound,
+ NotAuthorized,
+} from './errors';
+import {
+ model as Group,
+} from '../models/group';
+
+export function removeMessagesFromMember (member, groupId) {
+ if (member.newMessages[groupId]) {
+ delete member.newMessages[groupId];
+ member.markModified('newMessages');
+ }
+
+ member.notifications = member.notifications.filter(n => {
+ if (n && n.type === 'NEW_CHAT_MESSAGE' && n.data && n.data.group && n.data.group.id === groupId) {
+ return false;
+ }
+
+ return true;
+ });
+}
+
+export async function leaveGroup (data) {
+ const {
+ groupId,
+ user,
+ res,
+ keep,
+ keepChallenges,
+ } = data;
+ const group = await Group.getGroup({
+ user, groupId, fields: '-chat', requireMembership: true,
+ });
+ if (!group) {
+ throw new NotFound(res.t('groupNotFound'));
+ }
+
+ // During quests, check if user can leave
+ if (group.type === 'party') {
+ if (group.quest && group.quest.leader === user._id) {
+ throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup'));
+ }
+
+ if (
+ group.quest && group.quest.active
+ && group.quest.members && group.quest.members[user._id]
+ ) {
+ throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest'));
+ }
+ }
+
+ await group.leave(user, keep, keepChallenges);
+ removeMessagesFromMember(user, group._id);
+ await user.save();
+
+ if (group.hasNotCancelled()) await group.updateGroupPlan(true);
+}
diff --git a/website/server/models/group.js b/website/server/models/group.js
index 5bbd296714..63a7a45bbf 100644
--- a/website/server/models/group.js
+++ b/website/server/models/group.js
@@ -38,6 +38,7 @@ import stripePayments from '../libs/payments/stripe'; // eslint-disable-line imp
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;
@@ -683,7 +684,7 @@ schema.methods.startQuest = async function startQuest (user) {
}
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);
@@ -755,6 +756,12 @@ schema.methods.startQuest = async function startQuest (user) {
_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: {
diff --git a/website/server/models/user/hooks.js b/website/server/models/user/hooks.js
index 254a7d1575..3fd58fd492 100644
--- a/website/server/models/user/hooks.js
+++ b/website/server/models/user/hooks.js
@@ -15,6 +15,9 @@ import {
import {
model as NewsPost,
} from '../newsPost';
+import {
+ model as UserHistory,
+} from '../userHistory';
import { // eslint-disable-line import/no-cycle
userActivityWebhook,
} from '../../libs/webhook';
@@ -237,7 +240,7 @@ schema.pre('validate', function preValidateUser (next) {
next();
});
-schema.pre('save', true, function preSaveUser (next, done) {
+schema.pre('save', true, async function preSaveUser (next, done) {
next();
// VERY IMPORTANT NOTE: when only some fields from an user document are selected
@@ -360,6 +363,12 @@ schema.pre('save', true, function preSaveUser (next, done) {
// Unset the field so this is run only once
this.flags.lastWeeklyRecapDiscriminator = undefined;
}
+ if (!this.flags.initializedUserHistory) {
+ this.flags.initializedUserHistory = true;
+ const history = UserHistory();
+ history.userId = this._id;
+ await history.save();
+ }
}
// Enforce min/max values without displaying schema errors to end user
@@ -396,12 +405,9 @@ schema.pre('save', true, function preSaveUser (next, done) {
// Populate new users with default content
if (this.isNew) {
- _setUpNewUser(this)
- .then(() => done())
- .catch(done);
- } else {
- done();
+ await _setUpNewUser(this);
}
+ done();
});
schema.pre('updateOne', function preUpdateUser () {
diff --git a/website/server/models/user/schema.js b/website/server/models/user/schema.js
index 9f508284cf..efffc176c0 100644
--- a/website/server/models/user/schema.js
+++ b/website/server/models/user/schema.js
@@ -313,6 +313,7 @@ export const UserSchema = new Schema({
warnedLowHealth: { $type: Boolean, default: false },
verifiedUsername: { $type: Boolean, default: false },
thirdPartyTools: { $type: Date },
+ initializedUserHistory: { $type: Boolean, default: false },
},
history: {
diff --git a/website/server/models/userHistory.js b/website/server/models/userHistory.js
new file mode 100644
index 0000000000..b4a9678d2a
--- /dev/null
+++ b/website/server/models/userHistory.js
@@ -0,0 +1,129 @@
+import nconf from 'nconf';
+import mongoose from 'mongoose';
+import validator from 'validator';
+import baseModel from '../libs/baseModel';
+
+const { Schema } = mongoose;
+
+const userHistoryLength = nconf.get('USER_HISTORY_LENGTH') || 20;
+
+export const schema = new Schema({
+ userId: {
+ $type: String,
+ ref: 'User',
+ required: true,
+ validate: [v => validator.isUUID(v), 'Invalid uuid for userhistory.'],
+ index: true,
+ unique: true,
+ },
+ armoire: [
+ {
+ _id: false,
+ timestamp: { $type: Date, required: true },
+ client: { $type: String, required: false },
+ reward: { $type: String, required: true },
+ },
+ ],
+ questInviteResponses: [
+ {
+ _id: false,
+ timestamp: { $type: Date, required: true },
+ client: { $type: String, required: false },
+ quest: { $type: String, required: true },
+ response: { $type: String, required: true },
+ },
+ ],
+ cron: [
+ {
+ _id: false,
+ timestamp: { $type: Date, required: true },
+ checkinCount: { $type: Number, required: true },
+ client: { $type: String, required: false },
+ },
+ ],
+}, {
+ strict: true,
+ minimize: false, // So empty objects are returned
+ typeKey: '$type', // So that we can use fields named `type`
+});
+
+schema.plugin(baseModel, {
+ noSet: ['id', '_id', 'userId'],
+ timestamps: true,
+ _id: false, // using custom _id
+});
+
+export const model = mongoose.model('UserHistory', schema);
+
+const commitUserHistoryUpdate = function commitUserHistoryUpdate (update) {
+ const data = {
+ $push: {
+
+ },
+ };
+ if (update.data.armoire.length) {
+ data.$push.armoire = {
+ $each: update.data.armoire,
+ $sort: { timestamp: -1 },
+ $slice: userHistoryLength,
+ };
+ }
+ if (update.data.questInviteResponses.length) {
+ data.$push.questInviteResponses = {
+ $each: update.data.questInviteResponses,
+ $sort: { timestamp: -1 },
+ $slice: userHistoryLength,
+ };
+ }
+ if (update.data.cron.length > 0) {
+ data.$push.cron = {
+ $each: update.data.cron,
+ $sort: { timestamp: -1 },
+ $slice: userHistoryLength,
+ };
+ }
+ return model.updateOne(
+ { userId: update.userId },
+ data,
+ ).exec();
+};
+
+model.beginUserHistoryUpdate = function beginUserHistoryUpdate (userID, headers = null) {
+ return {
+ userId: userID,
+ data: {
+ headers: headers || {},
+ armoire: [],
+ questInviteResponses: [],
+ cron: [],
+ },
+ withArmoire: function withArmoire (reward) {
+ this.data.armoire.push({
+ timestamp: new Date(),
+ client: this.data.headers['x-client'],
+ reward,
+ });
+ return this;
+ },
+ withQuestInviteResponse: function withQuestInviteResponse (quest, response) {
+ this.data.questInviteResponses.push({
+ timestamp: new Date(),
+ client: this.data.headers['x-client'],
+ quest,
+ response,
+ });
+ return this;
+ },
+ withCron: function withCron (checkinCount) {
+ this.data.cron.push({
+ timestamp: new Date(),
+ checkinCount,
+ client: this.data.headers['x-client'],
+ });
+ return this;
+ },
+ commit: function commit () {
+ commitUserHistoryUpdate(this);
+ },
+ };
+};