diff --git a/habitica-images b/habitica-images index 98e9a400b8..aa72332019 160000 --- a/habitica-images +++ b/habitica-images @@ -1 +1 @@ -Subproject commit 98e9a400b840ee8673a636f3e3d3f19b560783a5 +Subproject commit aa723320199d7f03ce749d431b46e8d7f95cc8de diff --git a/website/server/controllers/api-v3/quests.js b/website/server/controllers/api-v3/quests.js index 1c2e94b457..a738df8238 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(); @@ -227,6 +228,10 @@ api.acceptQuest = { uuid: user._id, headers: req.headers, }); + + await UserHistory.beginUserHistoryUpdate(user._id) + .withQuestInviteResponse(group.quest.key, 'accept') + .commit(); }, }; @@ -288,6 +293,10 @@ api.rejectQuest = { uuid: user._id, headers: req.headers, }); + + await UserHistory.beginUserHistoryUpdate(user._id) + .withQuestInviteResponse(group.quest.key, 'reject') + .commit(); }, }; diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 1a0aa1d757..c4706b1979 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) + .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) + .withArmoire(buyArmoireResponse[1].data.armoire.dropKey) + .commit(); res.respond(200, ...buyArmoireResponse); }, }; diff --git a/website/server/libs/cron.js b/website/server/libs/cron.js index 39d548de2f..bf746e37a3 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'; @@ -523,5 +524,9 @@ export async function cron (options = {}) { user.flags.cronCount += 1; trackCronAnalytics(analytics, user, _progress, options); + await UserHistory.beginUserHistoryUpdate(user._id) + .withCron() + .commit(); + return _progress; } diff --git a/website/server/models/user/hooks.js b/website/server/models/user/hooks.js index 254a7d1575..237104b3f6 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,13 @@ 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(); + console.log('Initialized user history'); + } } // Enforce min/max values without displaying schema errors to end user @@ -396,12 +406,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..53a51a9279 --- /dev/null +++ b/website/server/models/userHistory.js @@ -0,0 +1,117 @@ +import mongoose from 'mongoose'; +import validator from 'validator'; +import baseModel from '../libs/baseModel'; + +const { Schema } = mongoose; + +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 }, + reward: { $type: String, required: true }, + }, + ], + questInviteResponses: [ + { + _id: false, + timestamp: { $type: Date, required: true }, + quest: { $type: String, required: true }, + response: { $type: String, required: true }, + }, + ], + cron: [ + { + _id: false, + timestamp: { $type: Date, required: true }, + }, + ], +}, { + 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: 10, + }; + } + if (update.data.questInviteResponses.length) { + data.$push.questInviteResponses = { + $each: update.data.questInviteResponses, + $sort: { timestamp: -1 }, + $slice: 10, + }; + } + if (update.data.cron.length > 0) { + data.$push.cron = { + $each: update.data.cron, + $sort: { timestamp: -1 }, + $slice: 10, + }; + } + return model.updateOne( + { userId: update.userId }, + data, + ).exec(); +}; + +model.beginUserHistoryUpdate = function beginUserHistoryUpdate (userID) { + return { + userId: userID, + data: { + armoire: [], + questInviteResponses: [], + cron: [], + }, + withArmoire: function withArmoire (reward) { + this.data.armoire.push({ + timestamp: new Date(), + reward, + }); + return this; + }, + withQuestInviteResponse: function withQuestInviteResponse (quest, response) { + this.data.questInviteResponses.push({ + timestamp: new Date(), + quest, + response, + }); + return this; + }, + withCron: function withCron () { + this.data.cron.push({ + timestamp: new Date(), + }); + return this; + }, + commit: function commit () { + commitUserHistoryUpdate(this); + }, + }; +};