diff --git a/.eslintignore b/.eslintignore index 93a77392f1..a623e23548 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,10 +3,15 @@ common/dist/ common/transpiled-babel/ coverage/ database_reports/ -migrations/ website/build/ website/transpiled-babel/ +migrations/* +!migrations/api_v3 +!migrations/api_v3 +!migrations/api_v3 +migrations/api_v3/old_models + # The files in website/public/js should be moved out and browserified website/public/ diff --git a/migrations/api_v3/old_models/challenge.js b/migrations/api_v3/old_models/challenge.js new file mode 100644 index 0000000000..7a014375c2 --- /dev/null +++ b/migrations/api_v3/old_models/challenge.js @@ -0,0 +1,122 @@ +// OLD (v2) CHALLENGE MODEL + +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); +var TaskSchemas = require('./task'); + +var ChallengeSchema = new Schema({ + _id: {type: String, 'default': shared.uuid}, + name: String, + shortName: String, + description: String, + official: {type: Boolean,'default':false}, + habits: [TaskSchemas.HabitSchema], + dailys: [TaskSchemas.DailySchema], + todos: [TaskSchemas.TodoSchema], + rewards: [TaskSchemas.RewardSchema], + leader: {type: String, ref: 'User'}, + group: {type: String, ref: 'Group'}, + timestamp: {type: Date, 'default': Date.now}, + members: [{type: String, ref: 'User'}], + memberCount: {type: Number, 'default': 0}, + prize: {type: Number, 'default': 0} +}, {collection: 'challenges'}); + +ChallengeSchema.virtual('tasks').get(function () { + var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); + var tasks = _.object(_.pluck(tasks,'id'), tasks); + return tasks; +}); + +ChallengeSchema.methods.toJSON = function(){ + var doc = this.toObject(); + doc._isMember = this._isMember; + return doc; +} + +// -------------- +// Syncing logic +// -------------- + +function syncableAttrs(task) { + var t = (task.toObject) ? task.toObject() : task; // lodash doesn't seem to like _.omit on EmbeddedDocument + // only sync/compare important attrs + var omitAttrs = 'challenge history tags completed streak notes'.split(' '); + if (t.type != 'reward') omitAttrs.push('value'); + return _.omit(t, omitAttrs); +} + +/** + * Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers + */ +function comparableData(obj) { + return JSON.stringify( + _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) + .sortBy('id') // we don't want to update if they're sort-order is different + .transform(function(result, task){ + result.push(syncableAttrs(task)); + }) + .value()) +} + +ChallengeSchema.methods.isOutdated = function(newData) { + return comparableData(this) !== comparableData(newData); +} + +/** + * Syncs all new tasks, deleted tasks, etc to the user object + * @param user + * @return nothing, user is modified directly. REMEMBER to save the user! + */ +ChallengeSchema.methods.syncToUser = function(user, cb) { + if (!user) return; + var self = this; + self.shortName = self.shortName || self.name; + + // Add challenge to user.challenges + if (!_.contains(user.challenges, self._id)) { + user.challenges.push(self._id); + } + + // Sync tags + var tags = user.tags || []; + var i = _.findIndex(tags, {id: self._id}) + if (~i) { + if (tags[i].name !== self.shortName) { + // update the name - it's been changed since + user.tags[i].name = self.shortName; + } + } else { + user.tags.push({ + id: self._id, + name: self.shortName, + challenge: true + }); + } + + // Sync new tasks and updated tasks + _.each(self.tasks, function(task){ + var list = user[task.type+'s']; + var userTask = user.tasks[task.id] || (list.push(syncableAttrs(task)), list[list.length-1]); + if (!userTask.notes) userTask.notes = task.notes; // don't override the notes, but provide it if not provided + userTask.challenge = {id:self._id}; + userTask.tags = userTask.tags || {}; + userTask.tags[self._id] = true; + _.merge(userTask, syncableAttrs(task)); + }) + + // Flag deleted tasks as "broken" + _.each(user.tasks, function(task){ + if (task.challenge && task.challenge.id==self._id && !self.tasks[task.id]) { + task.challenge.broken = 'TASK_DELETED'; + } + }) + + user.save(cb); +}; + + +module.exports.schema = ChallengeSchema; +module.exports.model = mongoose.model("ChallengeOld", ChallengeSchema); diff --git a/migrations/api_v3/old_models/task.js b/migrations/api_v3/old_models/task.js new file mode 100644 index 0000000000..63d24cc743 --- /dev/null +++ b/migrations/api_v3/old_models/task.js @@ -0,0 +1,115 @@ +// OLD (v2) TASK MODEL + +// User.js +// ======= +// Defines the user data model (schema) for use via the API. + +// Dependencies +// ------------ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); +var moment = require('moment'); + +// Task Schema +// ----------- + +var TaskSchema = { + //_id:{type: String,'default': helpers.uuid}, + id: {type: String,'default': shared.uuid}, + dateCreated: {type:Date, 'default':Date.now}, + text: String, + notes: {type: String, 'default': ''}, + tags: {type: Schema.Types.Mixed, 'default': {}}, //{ "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, + value: {type: Number, 'default': 0}, // redness + priority: {type: Number, 'default': '1'}, + attribute: {type: String, 'default': "str", enum: ['str','con','int','per']}, + challenge: { + id: {type: 'String', ref:'Challenge'}, + broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED + winner: String // user.profile.name + // group: {type: 'Strign', ref: 'Group'} // if we restore this, rename `id` above to `challenge` + }, + reminders: [{ + id: {type:String,'default':shared.uuid}, + startDate: Date, + time: Date + }] +}; + +var HabitSchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'habit'}, + history: Array, // [{date:Date, value:Number}], // this causes major performance problems + up: {type: Boolean, 'default': true}, + down: {type: Boolean, 'default': true} + }, TaskSchema) + , { _id: false, minimize:false } +); + +var collapseChecklist = {type:Boolean, 'default':false}; +var checklist = [{ + completed:{type:Boolean,'default':false}, + text: String, + _id:false, + id: {type:String,'default':shared.uuid} +}]; + +var DailySchema = new Schema( + _.defaults({ + type: {type: String, 'default': 'daily'}, + frequency: {type: String, 'default': 'weekly', enum: ['daily', 'weekly']}, + everyX: {type: Number, 'default': 1}, // e.g. once every X weeks + startDate: {type: Date, 'default': moment().startOf('day').toDate()}, + history: Array, + completed: {type: Boolean, 'default': false}, + repeat: { // used only for 'weekly' frequency, + m: {type: Boolean, 'default': true}, + t: {type: Boolean, 'default': true}, + w: {type: Boolean, 'default': true}, + th: {type: Boolean, 'default': true}, + f: {type: Boolean, 'default': true}, + s: {type: Boolean, 'default': true}, + su: {type: Boolean, 'default': true} + }, + collapseChecklist:collapseChecklist, + checklist:checklist, + streak: {type: Number, 'default': 0} + }, TaskSchema) + , { _id: false, minimize:false } +) + +var TodoSchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'todo'}, + completed: {type: Boolean, 'default': false}, + dateCompleted: Date, + date: String, // due date for todos // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date + collapseChecklist:collapseChecklist, + checklist:checklist + }, TaskSchema) + , { _id: false, minimize:false } +); + +var RewardSchema = new Schema( + _.defaults({ + type: {type:String, 'default': 'reward'} + }, TaskSchema) + , { _id: false, minimize:false } +); + +/** + * Workaround for bug when _id & id were out of sync, we can remove this after challenges has been running for a while + */ +//_.each([HabitSchema, DailySchema, TodoSchema, RewardSchema], function(schema){ +// schema.post('init', function(doc){ +// if (!doc.id && doc._id) doc.id = doc._id; +// }) +//}) + +module.exports.TaskSchema = TaskSchema; +module.exports.HabitSchema = HabitSchema; +module.exports.DailySchema = DailySchema; +module.exports.TodoSchema = TodoSchema; +module.exports.RewardSchema = RewardSchema; diff --git a/migrations/api_v3/old_models/user.js b/migrations/api_v3/old_models/user.js new file mode 100644 index 0000000000..b8fb7a7ed6 --- /dev/null +++ b/migrations/api_v3/old_models/user.js @@ -0,0 +1,700 @@ +// OLD (v2) USER MODEL + +// User.js +// ======= +// Defines the user data model (schema) for use via the API. + +// Dependencies +// ------------ +var mongoose = require("mongoose"); +var Schema = mongoose.Schema; +var shared = require('../../../common'); +var _ = require('lodash'); +var TaskSchemas = require('./task'); +var Challenge = require('./challenge').model; +var moment = require('moment'); + +// User Schema +// ----------- + +var UserSchema = new Schema({ + // ### UUID and API Token + _id: { + type: String, + 'default': shared.uuid + }, + apiToken: { + type: String, + 'default': shared.uuid + }, + + // ### Mongoose Update Object + // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which + // have been updated (http://goo.gl/gQLz41), but we want *every* update + _v: { type: Number, 'default': 0 }, + achievements: { + originalUser: Boolean, + habitSurveys: Number, + ultimateGearSets: Schema.Types.Mixed, + beastMaster: Boolean, + beastMasterCount: Number, + mountMaster: Boolean, + mountMasterCount: Number, + triadBingo: Boolean, + triadBingoCount: Number, + veteran: Boolean, + snowball: Number, + spookDust: Number, + shinySeed: Number, + seafoam: Number, + streak: Number, + challenges: Array, + quests: Schema.Types.Mixed, + rebirths: Number, + rebirthLevel: Number, + perfect: Number, + habitBirthdays: Number, + valentine: Number, + costumeContest: Boolean, // Superseded by costumeContests + nye: Number, + habiticaDays: Number, + greeting: Number, + thankyou: Number, + costumeContests: Number, + birthday: Number, + partyUp: Boolean, + partyOn: Boolean + }, + auth: { + blocked: Boolean, + facebook: Schema.Types.Mixed, + local: { + email: String, + hashed_password: String, + salt: String, + username: String, + lowerCaseUsername: String // Store a lowercase version of username to check for duplicates + }, + timestamps: { + created: {type: Date,'default': Date.now}, + loggedin: {type: Date,'default': Date.now} + } + }, + + backer: { + tier: Number, + npc: String, + tokensApplied: Boolean + }, + + contributor: { + level: Number, // 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801 + admin: Boolean, + sudo: Boolean, + text: String, // Artisan, Friend, Blacksmith, etc + contributions: String, // a markdown textarea to list their contributions + links + critical: String + }, + + balance: {type: Number, 'default':0}, + filters: {type: Schema.Types.Mixed, 'default': {}}, + + purchased: { + ads: {type: Boolean, 'default': false}, + skin: {type: Schema.Types.Mixed, 'default': {}}, // eg, {skeleton: true, pumpkin: true, eb052b: true} + hair: {type: Schema.Types.Mixed, 'default': {}}, + shirt: {type: Schema.Types.Mixed, 'default': {}}, + background: {type: Schema.Types.Mixed, 'default': {}}, + txnCount: {type: Number, 'default':0}, + mobileChat: Boolean, + plan: { + planId: String, + paymentMethod: String, //enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']} + customerId: String, // Billing Agreement Id in case of Amazon Payments + dateCreated: Date, + dateTerminated: Date, + dateUpdated: Date, + extraMonths: {type:Number, 'default':0}, + gemsBought: {type: Number, 'default': 0}, + mysteryItems: {type: Array, 'default': []}, + lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date + consecutive: { + count: {type:Number, 'default':0}, + offset: {type:Number, 'default':0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 + gemCapExtra: {type:Number, 'default':0}, + trinkets: {type:Number, 'default':0} + } + } + }, + + flags: { + customizationsNotification: {type: Boolean, 'default': false}, + showTour: {type: Boolean, 'default': true}, + tour: { + // -1 indicates "uninitiated", -2 means "complete", any other number is the current tour step (0-index) + intro: {type: Number, 'default': -1}, + classes: {type: Number, 'default': -1}, + stats: {type: Number, 'default': -1}, + tavern: {type: Number, 'default': -1}, + party: {type: Number, 'default': -1}, + guilds: {type: Number, 'default': -1}, + challenges: {type: Number, 'default': -1}, + market: {type: Number, 'default': -1}, + pets: {type: Number, 'default': -1}, + mounts: {type: Number, 'default': -1}, + hall: {type: Number, 'default': -1}, + equipment: {type: Number, 'default': -1} + }, + tutorial: { + common: { + habits: {type: Boolean, 'default': false}, + dailies: {type: Boolean, 'default': false}, + todos: {type: Boolean, 'default': false}, + rewards: {type: Boolean, 'default': false}, + party: {type: Boolean, 'default': false}, + pets: {type: Boolean, 'default': false}, + gems: {type: Boolean, 'default': false}, + skills: {type: Boolean, 'default': false}, + classes: {type: Boolean, 'default': false}, + tavern: {type: Boolean, 'default': false}, + equipment: {type: Boolean, 'default': false}, + items: {type: Boolean, 'default': false}, + }, + ios: { + addTask: {type: Boolean, 'default': false}, + editTask: {type: Boolean, 'default': false}, + deleteTask: {type: Boolean, 'default': false}, + filterTask: {type: Boolean, 'default': false}, + groupPets: {type: Boolean, 'default': false}, + inviteParty: {type: Boolean, 'default': false}, + } + }, + dropsEnabled: {type: Boolean, 'default': false}, + itemsEnabled: {type: Boolean, 'default': false}, + newStuff: {type: Boolean, 'default': false}, + rewrite: {type: Boolean, 'default': true}, + contributor: Boolean, + classSelected: {type: Boolean, 'default': false}, + mathUpdates: Boolean, + rebirthEnabled: {type: Boolean, 'default': false}, + levelDrops: {type:Schema.Types.Mixed, 'default':{}}, + chatRevoked: Boolean, + // Used to track the status of recapture emails sent to each user, + // can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user + recaptureEmailsPhase: {type: Number, 'default': 0}, + // Needed to track the tip to send inside the email + weeklyRecapEmailsPhase: {type: Number, 'default': 0}, + // Used to track when the next weekly recap should be sent + lastWeeklyRecap: {type: Date, 'default': Date.now}, + // Used to enable weekly recap emails as users login + lastWeeklyRecapDiscriminator: Boolean, + communityGuidelinesAccepted: {type: Boolean, 'default': false}, + cronCount: {type:Number, 'default':0}, + welcomed: {type: Boolean, 'default': false}, + armoireEnabled: {type: Boolean, 'default': false}, + armoireOpened: {type: Boolean, 'default': false}, + armoireEmpty: {type: Boolean, 'default': false}, + cardReceived: {type: Boolean, 'default': false}, + warnedLowHealth: {type: Boolean, 'default': false} + }, + history: { + exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined + todos: Array //[{data: Date, value: Number}] // big peformance issues if these are defined + }, + + invitations: { + guilds: {type: Array, 'default': []}, + party: Schema.Types.Mixed + }, + items: { + gear: { + owned: _.transform(shared.content.gear.flat, function(m,v,k){ + m[v.key] = {type: Boolean}; + if (v.key.match(/[armor|head|shield]_warrior_0/)) + m[v.key]['default'] = true; + }), + + equipped: { + weapon: String, + armor: {type: String, 'default': 'armor_base_0'}, + head: {type: String, 'default': 'head_base_0'}, + shield: {type: String, 'default': 'shield_base_0'}, + back: String, + headAccessory: String, + eyewear: String, + body: String + }, + costume: { + weapon: String, + armor: {type: String, 'default': 'armor_base_0'}, + head: {type: String, 'default': 'head_base_0'}, + shield: {type: String, 'default': 'shield_base_0'}, + back: String, + headAccessory: String, + eyewear: String, + body: String + } + }, + + special:{ + snowball: {type: Number, 'default': 0}, + spookDust: {type: Number, 'default': 0}, + shinySeed: {type: Number, 'default': 0}, + seafoam: {type: Number, 'default': 0}, + valentine: Number, + valentineReceived: Array, // array of strings, by sender name + nye: Number, + nyeReceived: Array, + greeting: Number, + greetingReceived: Array, + thankyou: Number, + thankyouReceived: Array, + birthday: Number, + birthdayReceived: Array + }, + + // -------------- Animals ------------------- + // Complex bit here. The result looks like: + // pets: { + // 'Wolf-Desert': 0, // 0 means does not own + // 'PandaCub-Red': 10, // Number represents "Growth Points" + // etc... + // } + pets: + _.defaults( + // First transform to a 1D eggs/potions mapping + _.transform(shared.content.pets, function(m,v,k){ m[k] = Number; }), + // Then add additional pets (quest, backer, contributor, premium) + _.transform(shared.content.questPets, function(m,v,k){ m[k] = Number; }), + _.transform(shared.content.specialPets, function(m,v,k){ m[k] = Number; }), + _.transform(shared.content.premiumPets, function(m,v,k){ m[k] = Number; }) + ), + currentPet: String, // Cactus-Desert + + // eggs: { + // 'PandaCub': 0, // 0 indicates "doesn't own" + // 'Wolf': 5 // Number indicates "stacking" + // } + eggs: _.transform(shared.content.eggs, function(m,v,k){ m[k] = Number; }), + + // hatchingPotions: { + // 'Desert': 0, // 0 indicates "doesn't own" + // 'CottonCandyBlue': 5 // Number indicates "stacking" + // } + hatchingPotions: _.transform(shared.content.hatchingPotions, function(m,v,k){ m[k] = Number; }), + + // Food: { + // 'Watermelon': 0, // 0 indicates "doesn't own" + // 'RottenMeat': 5 // Number indicates "stacking" + // } + food: _.transform(shared.content.food, function(m,v,k){ m[k] = Number; }), + + // mounts: { + // 'Wolf-Desert': true, + // 'PandaCub-Red': false, + // etc... + // } + mounts: _.defaults( + // First transform to a 1D eggs/potions mapping + _.transform(shared.content.pets, function(m,v,k){ m[k] = Boolean; }), + // Then add quest and premium pets + _.transform(shared.content.questPets, function(m,v,k){ m[k] = Boolean; }), + _.transform(shared.content.premiumPets, function(m,v,k){ m[k] = Boolean; }), + // Then add additional mounts (backer, contributor) + _.transform(shared.content.specialMounts, function(m,v,k){ m[k] = Boolean; }) + ), + currentMount: String, + + // Quests: { + // 'boss_0': 0, // 0 indicates "doesn't own" + // 'collection_honey': 5 // Number indicates "stacking" + // } + quests: _.transform(shared.content.quests, function(m,v,k){ m[k] = Number; }), + + lastDrop: { + date: {type: Date, 'default': Date.now}, + count: {type: Number, 'default': 0} + } + }, + + lastCron: {type: Date, 'default': Date.now}, + + // {GROUP_ID: Boolean}, represents whether they have unseen chat messages + newMessages: {type: Schema.Types.Mixed, 'default': {}}, + + party: { + // id // FIXME can we use a populated doc instead of fetching party separate from user? + order: {type:String, 'default':'level'}, + orderAscending: {type:String, 'default':'ascending'}, + quest: { + key: String, + progress: { + up: {type: Number, 'default': 0}, + down: {type: Number, 'default': 0}, + collect: {type: Schema.Types.Mixed, 'default': {}} // {feather:1, ingot:2} + }, + completed: String, // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser + RSVPNeeded: {type: Boolean, 'default': false} // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled + } + }, + preferences: { + dayStart: {type:Number, 'default': 0, min: 0, max: 23}, + size: {type:String, enum: ['broad','slim'], 'default': 'slim'}, + hair: { + color: {type: String, 'default': 'red'}, + base: {type: Number, 'default': 3}, + bangs: {type: Number, 'default': 1}, + beard: {type: Number, 'default': 0}, + mustache: {type: Number, 'default': 0}, + flower: {type: Number, 'default': 1} + }, + chair: {type: String, 'default': 'none'}, + hideHeader: {type:Boolean, 'default':false}, + skin: {type:String, 'default':'915533'}, + shirt: {type: String, 'default': 'blue'}, + timezoneOffset: {type: Number, 'default': 0}, + timezoneOffsetAtLastCron: Number, + sound: {type:String, 'default':'off', enum: ['off', 'danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme']}, + language: String, + automaticAllocation: Boolean, + allocationMode: {type:String, enum: ['flat','classbased','taskbased'], 'default': 'flat'}, + autoEquip: {type: Boolean, 'default': true}, + costume: Boolean, + dateFormat: {type: String, enum:['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'], 'default': 'MM/dd/yyyy'}, + sleep: {type: Boolean, 'default': false}, + stickyHeader: {type: Boolean, 'default': true}, + disableClasses: {type: Boolean, 'default': false}, + newTaskEdit: {type: Boolean, 'default': false}, + dailyDueDefaultView: {type: Boolean, 'default': false}, + tagsCollapsed: {type: Boolean, 'default': false}, + advancedCollapsed: {type: Boolean, 'default': false}, + toolbarCollapsed: {type:Boolean, 'default':false}, + reverseChatOrder: {type:Boolean, 'default':false}, + background: String, + displayInviteToPartyWhenPartyIs1: { type:Boolean, 'default':true}, + webhooks: {type: Schema.Types.Mixed, 'default': {}}, + // For this fields make sure to use strict comparison when searching for falsey values (=== false) + // As users who didn't login after these were introduced may have them undefined/null + emailNotifications: { + unsubscribeFromAll: {type: Boolean, 'default': false}, + newPM: {type: Boolean, 'default': true}, + kickedGroup: {type: Boolean, 'default': true}, + wonChallenge: {type: Boolean, 'default': true}, + giftedGems: {type: Boolean, 'default': true}, + giftedSubscription: {type: Boolean, 'default': true}, + invitedParty: {type: Boolean, 'default': true}, + invitedGuild: {type: Boolean, 'default': true}, + questStarted: {type: Boolean, 'default': true}, + invitedQuest: {type: Boolean, 'default': true}, + //remindersToLogin: {type: Boolean, 'default': true}, + // Those importantAnnouncements are in fact the recapture emails + importantAnnouncements: {type: Boolean, 'default': true}, + weeklyRecaps: {type: Boolean, 'default': true} + }, + suppressModals: { + levelUp: {type: Boolean, 'default': false}, + hatchPet: {type: Boolean, 'default': false}, + raisePet: {type: Boolean, 'default': false}, + streak: {type: Boolean, 'default': false} + }, + improvementCategories: { + type: Array, + validate: (categories) => { + const validCategories = ['work', 'exercise', 'healthWellness', 'school', 'teams', 'chores', 'creativity']; + let isValidCategory = categories.every(category => validCategories.indexOf(category) !== -1); + return isValidCategory; + }} + }, + profile: { + blurb: String, + imageUrl: String, + name: String + }, + stats: { + hp: {type: Number, 'default': shared.maxHealth}, + mp: {type: Number, 'default': 10}, + exp: {type: Number, 'default': 0}, + gp: {type: Number, 'default': 0}, + lvl: {type: Number, 'default': 1}, + + // Class System + 'class': {type: String, enum: ['warrior','rogue','wizard','healer'], 'default': 'warrior'}, + points: {type: Number, 'default': 0}, + str: {type: Number, 'default': 0}, + con: {type: Number, 'default': 0}, + int: {type: Number, 'default': 0}, + per: {type: Number, 'default': 0}, + buffs: { + str: {type: Number, 'default': 0}, + int: {type: Number, 'default': 0}, + per: {type: Number, 'default': 0}, + con: {type: Number, 'default': 0}, + stealth: {type: Number, 'default': 0}, + streaks: {type: Boolean, 'default': false}, + snowball: {type: Boolean, 'default': false}, + spookDust: {type: Boolean, 'default': false}, + shinySeed: {type: Boolean, 'default': false}, + seafoam: {type: Boolean, 'default': false} + }, + training: { + int: {type: Number, 'default': 0}, + per: {type: Number, 'default': 0}, + str: {type: Number, 'default': 0}, + con: {type: Number, 'default': 0} + } + }, + + tags: {type: [{ + _id: false, + id: { type: String, 'default': shared.uuid }, + name: String, + challenge: String + }]}, + + challenges: [{type: 'String', ref:'Challenge'}], + + inbox: { + newMessages: {type:Number, 'default':0}, + blocks: {type:Array, 'default':[]}, + messages: {type:Schema.Types.Mixed, 'default':{}}, //reflist + optOut: {type:Boolean, 'default':false} + }, + + habits: {type:[TaskSchemas.HabitSchema]}, + dailys: {type:[TaskSchemas.DailySchema]}, + todos: {type:[TaskSchemas.TodoSchema]}, + rewards: {type:[TaskSchemas.RewardSchema]}, + + extra: Schema.Types.Mixed, + + pushDevices: {type: [{ + regId: {type: String}, + type: {type: String} + }],'default': []} + +}, { + collection: 'users', + strict: true, + minimize: false // So empty objects are returned +}); + +UserSchema.methods.deleteTask = function(tid) { + this.ops.deleteTask({params:{id:tid}},function(){}); // TODO remove this whole method, since it just proxies, and change all references to this method +} + +UserSchema.methods.toJSON = function() { + var doc = this.toObject(); + doc.id = doc._id; + + // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? + doc.filters = {}; + doc._tmp = this._tmp; // be sure to send down drop notifs + + return doc; +}; + +//UserSchema.virtual('tasks').get(function () { +// var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); +// var tasks = _.object(_.pluck(tasks,'id'), tasks); +// return tasks; +//}); + +UserSchema.post('init', function(doc){ + shared.wrap(doc); +}) + +UserSchema.pre('save', function(next) { + + // Populate new users with default content + if (this.isNew){ + _populateDefaultsForNewUser(this); + } + + //this.markModified('tasks'); + if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { + this.preferences.dayStart = 0; + } + + if (!this.profile.name) { + var fb = this.auth.facebook; + this.profile.name = + (this.auth.local && this.auth.local.username) || + (fb && (fb.displayName || fb.name || fb.username || (fb.first_name && fb.first_name + ' ' + fb.last_name))) || + 'Anonymous'; + } + + // Determines if Beast Master should be awarded + var beastMasterProgress = shared.count.beastMasterProgress(this.items.pets); + if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) { + this.achievements.beastMaster = true; + } + + // Determines if Mount Master should be awarded + var mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts); + + if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) { + this.achievements.mountMaster = true; + } + + // Determines if Triad Bingo should be awarded + + var dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets); + var qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90; + + if (qualifiesForTriad || this.achievements.triadBingoCount > 0) { + this.achievements.triadBingo = true; + } + + // Enable weekly recap emails for old users who sign in + if(this.flags.lastWeeklyRecapDiscriminator){ + // Enable weekly recap emails in 24 hours + this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate(); + // Unset the field so this is run only once + this.flags.lastWeeklyRecapDiscriminator = undefined; + } + + // EXAMPLE CODE for allowing all existing and new players to be + // automatically granted an item during a certain time period: + // if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01')) + // this.items.pets['JackOLantern-Base'] = 5; + + //our own version incrementer + if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0; + this._v++; + + next(); +}); + +UserSchema.methods.unlink = function(options, cb) { + var cid = options.cid, keep = options.keep, tid = options.tid; + if (!cid) { + return cb("Could not remove challenge tasks. Please delete them manually."); + } + var self = this; + switch (keep) { + case 'keep': + self.tasks[tid].challenge = {}; + break; + case 'remove': + self.deleteTask(tid); + break; + case 'keep-all': + _.each(self.tasks, function(t){ + if (t.challenge && t.challenge.id == cid) { + t.challenge = {}; + } + }); + break; + case 'remove-all': + _.each(self.tasks, function(t){ + if (t.challenge && t.challenge.id == cid) { + self.deleteTask(t.id); + } + }) + break; + } + self.markModified('habits'); + self.markModified('dailys'); + self.markModified('todos'); + self.markModified('rewards'); + self.save(cb); +} + +function _populateDefaultsForNewUser(user) { + var taskTypes; + + if (user.registeredThrough === "habitica-web" || user.registeredThrough === "habitica-android") { + taskTypes = ['habits', 'dailys', 'todos', 'rewards', 'tags']; + + var tutorialCommonSections = [ + 'habits', + 'dailies', + 'todos', + 'rewards', + 'party', + 'pets', + 'gems', + 'skills', + 'classes', + 'tavern', + 'equipment', + 'items', + 'inviteParty', + ]; + + _.each(tutorialCommonSections, function(section) { + user.flags.tutorial.common[section] = true; + }); + } else { + taskTypes = ['todos', 'tags'] + + user.flags.showTour = false; + + var tourSections = [ + 'showTour', + 'intro', + 'classes', + 'stats', + 'tavern', + 'party', + 'guilds', + 'challenges', + 'market', + 'pets', + 'mounts', + 'hall', + 'equipment', + ]; + + _.each(tourSections, function(section) { + user.flags.tour[section] = -2; + }); + } + + _populateDefaultTasks(user, taskTypes); +} + +function _populateDefaultTasks (user, taskTypes) { + _.each(taskTypes, function(taskType){ + user[taskType] = _.map(shared.content.userDefaults[taskType], function(task){ + var newTask = _.cloneDeep(task); + + // Render task's text and notes in user's language + if(taskType === 'tags'){ + // tasks automatically get id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here + newTask.id = shared.uuid(); + newTask.name = newTask.name(user.preferences.language); + }else{ + newTask.text = newTask.text(user.preferences.language); + if(newTask.notes) { + newTask.notes = newTask.notes(user.preferences.language); + } + + if(newTask.checklist){ + newTask.checklist = _.map(newTask.checklist, function(checklistItem){ + checklistItem.text = checklistItem.text(user.preferences.language); + return checklistItem; + }); + } + } + + return newTask; + }); + }); +} + +module.exports.schema = UserSchema; +module.exports.model = mongoose.model("UserOld", UserSchema); +// Initially export an empty object so external requires will get +// the right object by reference when it's defined later +// Otherwise it would remain undefined if requested before the query executes +module.exports.mods = []; + +mongoose.model("User") + .find({'contributor.admin':true}) + .sort('-contributor.level -backer.npc profile.name') + .select('profile contributor backer') + .exec(function(err,mods){ + // Using push to maintain the reference to mods + module.exports.mods.push.apply(module.exports.mods, mods); +}); diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js new file mode 100644 index 0000000000..782c79bf89 --- /dev/null +++ b/migrations/api_v3/users.js @@ -0,0 +1,185 @@ +/* eslint-disable no-console, no-unused-vars */ + +// Migrate users collection to new schema +// This should run AFTER challenges migration + +// This code makes heavy use of ES6 / 7 features and should be compiled / run with BabelJS. + +// It requires two environment variables: MONGODB_OLD and MONGODB_NEW + +console.log('Starting migrations/api_v3/users.js.'); + +import nconf from 'nconf'; +import mongoose from 'mongoose'; +import MongoDB from 'mongodb'; +import Q from 'q'; + +const MongoClient = MongoDB.MongoClient; + +// Initialize configuration +import setupNconf from '../../website/src/libs/api-v3/setupNconf'; +setupNconf(); + +const MONGODB_OLD = nconf.get('MONGODB_OLD'); +const MONGODB_NEW = nconf.get('MONGODB_NEW'); + +// Initialize mongoose and connect to the database containing the old data +mongoose.Promise = Q.Promise; + +const mongooseDbInstance = mongoose.connect(MONGODB_OLD, { + replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, + server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, +}, (err) => { + if (err) throw err; + console.log(`Connected with Mongoose to ${MONGODB_OLD}.`); +}); + +// Load old and new models +const OldUserModel = require('./old_models/user').model; +import { model as NewUser } from '../../website/src/models/user'; +import * as Tasks from '../../website/src/models/task'; + +// To be defined later when MongoClient connects +let mongoDbInstance; + +async function processUser (_id) { + let oldUser = await OldUserModel + .findById(_id) + .lean() + .exec(); + + console.log(`Processing ${oldUser._id}.`); + + let oldTasks = oldUser.habits.concat(oldUser.dailys).concat(oldUser.rewards).concat(oldUser.todos); + oldUser.habits = oldUser.dailys = oldUser.rewards = oldUser.todos = undefined; + + console.log(oldUser, oldTasks); +}; + +/* + +TODO var challengeTasksChangedId = {}; +... given a user + +let processed = 0; +let batchSize = 1000; + +var db; // defined later by MongoClient +var dbNewUsers; +var dbTasks; + +var processUser = function(gt) { + var query = { + _id: {} + }; + if(gt) query._id.$gt = gt; + + console.log('Launching query', query); + + // take batchsize docs from users and process them + OldUserModel + .find(query) + .lean() // Use plain JS objects as old user data won't match the new model + .limit(batchSize) + .sort({_id: 1}) + .exec(function(err, users) { + if(err) throw err; + + console.log('Processing ' + users.length + ' users.', 'Already processed: ' + processed); + + var lastUser = null; + if(users.length === batchSize){ + lastUser = users[users.length - 1]; + } + + var tasksToSave = 0; + + // Initialize batch operation for later + var batchInsertUsers = dbNewUsers.initializeUnorderedBulkOp(); + var batchInsertTasks = dbTasks.initializeUnorderedBulkOp(); + + users.forEach(function(user){ + // user obj is a plain js object because we used .lean() + + // add tasks order arrays + user.tasksOrder = { + habits: [], + rewards: [], + todos: [], + dailys: [] + }; + + // ... convert tasks to individual models + + var tasksArr = user.dailys + .concat(user.habits) + .concat(user.todos) + .concat(user.rewards); + + // free memory? + user.dailys = user.habits = user.todos = user.rewards = undefined; + + tasksArr.forEach(function(task){ + task.userId = user._id; + + task._id = shared.uuid(); // we rely on these to be unique... hopefully! + task.legacyId = task.id; + task.id = undefined; + + task.challenge = task.challenge || {}; + if(task.challenge.id) { + // If challengeTasksChangedId[task._id] then we got on of the duplicates from the challenges migration + if (challengeTasksChangedId[task.legacyId]) { + var res = _.find(challengeTasksChangedId[task.legacyId], function(arr){ + return arr[1] === task.challenge.id; + }); + + // If res, id changed, otherwise matches the original one + task.challenge.taskId = res ? res[0] : task.legacyId; + } else { + task.challenge.taskId = task.legacyId; + } + } + + if(!task.type) console.log('Task without type ', task._id, ' user ', user._id); + + task = new TaskModel(task); // this should also fix dailies that wen to the habits array or vice-versa + user.tasksOrder[task.type + 's'].push(task._id); + tasksToSave++; + batchInsertTasks.insert(task.toObject()); + }); + + batchInsertUsers.insert((new NewUserModel(user)).toObject()); + }); + + console.log('Saving', users.length, 'users and', tasksToSave, 'tasks'); + + // Save in the background and dispatch another processUser(); + + batchInsertUsers.execute(function(err, result){ + if(err) throw err // we can't simply accept errors + console.log('Saved', result.nInserted, 'users') + }); + + batchInsertTasks.execute(function(err, result){ + if(err) throw err // we can't simply accept errors + console.log('Saved', result.nInserted, 'tasks') + }); + + processed = processed + users.length; + if(lastUser && lastUser._id){ + processUser(lastUser._id); + } else { + console.log('Done!'); + } + }); +}; +*/ + +// Connect to the database for new data +MongoClient.connect(MONGODB_NEW, (err, dbInstance) => { + if (err) throw err; + + mongoDbInstance = dbInstance; + console.log(`Connected with MongoClient to ${MONGODB_NEW}.`); +});