Files
habitica/website/server/models/user/schema.js
Kip Raske fa044ffb44 Feature/sortable reward area (#9930)
* Client POC

We need to wrap each draggable region it its own div or else the
"draggable" element will conflict with each other. This screws up the
styling but that is totally fixable

* Ah that ref was being used after all, changing back

* Scaffold out a new callback for when we drag these things

Next is going to be the hard part: I need to save the sort order for
these to the database. I don't even know if there is a schema but hey
this is the best place to start

* Firefox caching is the problem: don't actually need the wrapper div

So I guess I should try this in chrome and see how it works then come
back to firefox and figure out what the heck is going on

* Scaffolding out our API call to save the sort order

The endpoint doesn't exist yet so we will need to add that

* Ok we are now calling our API endpoint to reorder these things

Of course it doesn't exist yet so you get a 404 when you try, but that
is ok

* Defining api endpoint, a work in progress

In particular I really had ought to use _id for these too, it appears
that the primary way we detect order doesn't even use "key" at all.

* Switching to using the pinned item UUID

This has much better results, but of course the server and client logic
don't match now. Will have to keep working on my splice to make sure
that they are the same

* I thought this would fix our server/client mismatch but it is not it

Something is really wrong with my logic somewhere, maybe I need to
update the db step?

* Moving this logic to the "user" rather than "tasks" and key off path

Path is unique and is less finiky than dealing with string comparisons
with ids. Unfortunately everything is still not working... I suppose
user.update() doesn't care about the position?

* This client code caused quite a lot of problems if you dragged fast

We don't really need it it seems, so off it goes

* Updating markup and CSS so it actually looks good.

Everything is working horray!!

I did just notice the following bug: the popover text sometimes makes it
very annoying to drag because you can't drop over it@

* Cleaning up my comments in the API section user.js

I had a lot of TODOS that are mostly done now

* Fixing a spacing code standards thing

* Turns out we never use type, so we should remove this from the API call

* Adding pinnedItemsOrder into the user schema

And disabling my call in the frontend before I do any more damage

* Halfway to using pinnedItemsOrder

This isn't working yet but it is not going to break it horribly like it
was before.

* Hooking up inAppRewards to always produce sorted information

It is suspicially working right now even though I have not added the
seasonal stuff logic yet...

* Updating the comments in user.js in movedPinnedItem

It turns out that my bandaid fix to just get the ball rolling perfectly
does what I need it to do when we have a length discrepancy. So we are
getting much closer to the final product, just need lots of testing

* Cleaning up code standards kinds of things

* Yay, this fixes the popover issue

I hope this is the right "vue" way to do things, because I tried a bunch
of other things that definately were not the right way to do it. And
this appears to work too

* ** Partial Work ** Starting tests on api call for draggable items

Doesn't work, doesn't compile so don't include in PR!

* Test failing still...

This is worth a save. The api call grabs the seasonal items too, so we
can't get away from using the common functions and calls here to get the
actual list of items

* Okay have the first test passing

Need to clean up my linter problems though

* Planning out the next two tests and fixing my format problems

* 2nd Test case written, this time with the "more" odd case

* Making sure that we didn't mess with pinned items

* Huh... this test doesn't give me the expected result

Drat, I guess I found a bug

* Throw an error when we put garbage in our api call.

Well, before we got user.pinnedItemsOrder filled with a bunch of "null"
entries which is not ideal. it still worked, but isn't this confusing
enough already?

* Cleaning up the multitude of linting problems thanks gulp :)

* Writing tests for inAppRewards.js, but something is wrong

* Fixing my linting errors in inAppRewards tests

These tests still do not run though, so they may fail and I would not
know

* Applying Negue's fixes to inAppRewards.js test

It never occured to me that we shouldn't try to reach the database while
in the common tests. Well, we shouldn't do that, we should use the
common.helpers instead. Thanks!
2018-04-13 15:22:06 +02:00

605 lines
21 KiB
JavaScript

import mongoose from 'mongoose';
import shared from '../../../common';
import _ from 'lodash';
import validator from 'validator';
import { schema as TagSchema } from '../tag';
import { schema as PushDeviceSchema } from '../pushDevice';
import { schema as WebhookSchema } from '../webhook';
import {
schema as UserNotificationSchema,
} from '../userNotification';
import {
schema as SubscriptionPlanSchema,
} from '../subscriptionPlan';
const Schema = mongoose.Schema;
const RESTRICTED_EMAIL_DOMAINS = Object.freeze(['habitica.com', 'habitrpg.com']);
// User schema definition
let schema = new Schema({
apiToken: {
type: String,
default: shared.uuid,
},
auth: {
blocked: Boolean,
facebook: {type: Schema.Types.Mixed, default: () => {
return {};
}},
google: {type: Schema.Types.Mixed, default: () => {
return {};
}},
local: {
email: {
type: String,
validate: [{
validator: validator.isEmail,
message: shared.i18n.t('invalidEmail'),
}, {
validator (email) {
let lowercaseEmail = email.toLowerCase();
return RESTRICTED_EMAIL_DOMAINS.every((domain) => {
return !lowercaseEmail.endsWith(`@${domain}`);
});
},
message: shared.i18n.t('invalidEmailDomain', { domains: RESTRICTED_EMAIL_DOMAINS.join(', ')}),
}],
},
username: {
type: String,
},
// Store a lowercase version of username to check for duplicates
lowerCaseUsername: String,
hashed_password: String, // eslint-disable-line camelcase
// Legacy password are hashed with SHA1, new ones with bcrypt
passwordHashMethod: {
type: String,
enum: ['bcrypt', 'sha1'],
},
salt: String, // Salt for SHA1 encrypted passwords, not stored for bcrypt,
// Used to validate password reset codes and make sure only the most recent one can be used
passwordResetCode: String,
},
timestamps: {
created: {type: Date, default: Date.now},
loggedin: {type: Date, default: Date.now},
},
},
// 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 },
migration: String,
achievements: {
originalUser: Boolean,
habitSurveys: Number,
ultimateGearSets: {
healer: {type: Boolean, default: false},
wizard: {type: Boolean, default: false},
rogue: {type: Boolean, default: false},
warrior: {type: Boolean, default: false},
},
beastMaster: Boolean,
beastMasterCount: Number,
mountMaster: Boolean,
mountMasterCount: Number,
triadBingo: Boolean,
triadBingoCount: Number,
veteran: Boolean,
snowball: Number,
spookySparkles: Number,
shinySeed: Number,
seafoam: Number,
streak: Number,
challenges: Array,
quests: {type: Schema.Types.Mixed, default: () => {
return {};
}},
rebirths: Number,
rebirthLevel: Number,
perfect: {type: Number, default: 0},
habitBirthdays: Number,
valentine: Number,
nye: Number,
habiticaDays: Number,
greeting: Number,
thankyou: Number,
costumeContests: Number,
birthday: Number,
partyUp: Boolean,
partyOn: Boolean,
congrats: Number,
getwell: Number,
goodluck: Number,
royallyLoyal: Boolean,
joinedGuild: Boolean,
joinedChallenge: Boolean,
invitedFriend: Boolean,
lostMasterclasser: Boolean,
},
backer: {
tier: Number,
npc: String,
tokensApplied: Boolean,
},
contributor: {
// 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitica/issues/3801
level: {
type: Number,
min: 0,
max: 9,
},
admin: Boolean,
sudo: Boolean,
// Artisan, Friend, Blacksmith, etc
text: String,
// a markdown textarea to list their contributions + links
contributions: String,
critical: String,
},
balance: {type: Number, default: 0},
purchased: {
ads: {type: Boolean, default: false},
// eg, {skeleton: true, pumpkin: true, eb052b: true}
skin: {type: Schema.Types.Mixed, default: () => {
return {};
}},
hair: {type: Schema.Types.Mixed, default: () => {
return {};
}},
shirt: {type: Schema.Types.Mixed, default: () => {
return {};
}},
background: {type: Schema.Types.Mixed, default: () => {
return {};
}},
txnCount: {type: Number, default: 0},
mobileChat: Boolean,
plan: {type: SubscriptionPlanSchema, default: () => {
return {};
}},
},
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},
mounts: {type: Boolean, default: false},
inbox: {type: Boolean, default: false},
stats: {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},
reorderTask: {type: Boolean, default: false},
},
},
dropsEnabled: {type: Boolean, default: false},
itemsEnabled: {type: Boolean, default: false},
newStuff: {type: Boolean, default: false},
rewrite: {type: Boolean, default: true},
classSelected: {type: Boolean, default: false},
mathUpdates: Boolean,
rebirthEnabled: {type: Boolean, default: false},
levelDrops: {type: Schema.Types.Mixed, default: () => {
return {};
}},
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,
onboardingEmailsPhase: String, // Keep track of the latest onboarding email sent
communityGuidelinesAccepted: {type: Boolean, default: false},
cronCount: {type: Number, default: 0},
welcomed: {type: Boolean, default: false},
armoireEnabled: {type: Boolean, default: true},
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
},
items: {
gear: {
owned: _.transform(shared.content.gear.flat, (m, v) => {
m[v.key] = {type: Boolean};
if (v.key.match(/(armor|head|shield)_warrior_0/) || v.gearSet === 'glasses') {
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},
spookySparkles: {type: Number, default: 0},
shinySeed: {type: Number, default: 0},
seafoam: {type: Number, default: 0},
valentine: {type: Number, default: 0},
valentineReceived: Array, // array of strings, by sender name
nye: {type: Number, default: 0},
nyeReceived: Array,
greeting: {type: Number, default: 0},
greetingReceived: Array,
thankyou: {type: Number, default: 0},
thankyouReceived: Array,
birthday: {type: Number, default: 0},
birthdayReceived: Array,
congrats: {type: Number, default: 0},
congratsReceived: Array,
getwell: {type: Number, default: 0},
getwellReceived: Array,
goodluck: {type: Number, default: 0},
goodluckReceived: 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, (m, v, k) => m[k] = Number),
// Then add additional pets (quest, backer, contributor, premium)
_.transform(shared.content.questPets, (m, v, k) => m[k] = Number),
_.transform(shared.content.specialPets, (m, v, k) => m[k] = Number),
_.transform(shared.content.premiumPets, (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, (m, v, k) => m[k] = Number),
// hatchingPotions: {
// 'Desert': 0, // 0 indicates "doesn't own"
// 'CottonCandyBlue': 5 // Number indicates "stacking"
// }
hatchingPotions: _.transform(shared.content.hatchingPotions, (m, v, k) => m[k] = Number),
// Food: {
// 'Watermelon': 0, // 0 indicates "doesn't own"
// 'RottenMeat': 5 // Number indicates "stacking"
// }
food: _.transform(shared.content.food, (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, (m, v, k) => m[k] = Boolean),
// Then add quest and premium pets
_.transform(shared.content.questPets, (m, v, k) => m[k] = Boolean),
_.transform(shared.content.premiumPets, (m, v, k) => m[k] = Boolean),
// Then add additional mounts (backer, contributor)
_.transform(shared.content.specialMounts, (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, (m, v, k) => m[k] = Number),
lastDrop: {
date: {type: Date, default: Date.now},
count: {type: Number, default: 0},
},
},
lastCron: {type: Date, default: Date.now},
_cronSignature: {type: String, default: 'NOT_RUNNING'}, // Private property used to avoid double cron
// {GROUP_ID: Boolean}, represents whether they have unseen chat messages
newMessages: {type: Schema.Types.Mixed, default: () => {
return {};
}},
challenges: [{type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}],
invitations: {
// Using an array without validation because otherwise mongoose treat this as a subdocument and applies _id by default
// Schema is (id, name, inviter, publicGuild)
// TODO one way to fix is http://mongoosejs.com/docs/guide.html#_id
guilds: {type: Array, default: () => []},
// Using a Mixed type because otherwise user.invitations.party = {} // to reset invitation, causes validation to fail TODO
// schema is the same as for guild invitations (id, name, inviter)
party: {type: Schema.Types.Mixed, default: () => {
return {};
}},
parties: [{
id: {
type: String,
ref: 'Group',
required: true,
validate: [validator.isUUID, 'Invalid uuid.'],
},
name: {
type: String,
required: true,
},
inviter: {
type: String,
ref: 'User',
required: true,
validate: [validator.isUUID, 'Invalid uuid.'],
},
}],
},
guilds: [{type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}],
party: {
_id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Group'},
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: () => {
return {};
}},
collectedItems: {type: Number, default: 0},
},
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},
},
hideHeader: {type: Boolean, default: false},
skin: {type: String, default: '915533'},
shirt: {type: String, default: 'blue'},
timezoneOffset: {type: Number, default: 0},
sound: {type: String, default: 'rosstavoTheme', enum: ['off', ...shared.content.audioThemes]},
chair: {type: String, default: 'none'},
timezoneOffsetAtLastCron: Number,
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},
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: () => {
return {};
}},
// For the following 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},
// importantAnnouncements are in fact the recapture emails
importantAnnouncements: {type: Boolean, default: true},
weeklyRecaps: {type: Boolean, default: true},
onboarding: {type: Boolean, default: true},
},
pushNotifications: {
unsubscribeFromAll: {type: Boolean, default: false},
newPM: {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},
},
suppressModals: {
levelUp: {type: Boolean, default: false},
hatchPet: {type: Boolean, default: false},
raisePet: {type: Boolean, default: false},
streak: {type: Boolean, default: false},
},
tasks: {
groupByChallenge: {type: Boolean, default: false},
confirmScoreNotes: {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: {
type: String,
required: true,
trim: true,
},
},
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, min: 1},
// Class System
class: {type: String, enum: ['warrior', 'rogue', 'wizard', 'healer'], default: 'warrior', required: true},
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},
spookySparkles: {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},
},
},
notifications: [UserNotificationSchema],
tags: [TagSchema],
inbox: {
newMessages: {type: Number, default: 0},
blocks: {type: Array, default: () => []},
messages: {type: Schema.Types.Mixed, default: () => {
return {};
}},
optOut: {type: Boolean, default: false},
},
tasksOrder: {
habits: [{type: String, ref: 'Task'}],
dailys: [{type: String, ref: 'Task'}],
todos: [{type: String, ref: 'Task'}],
rewards: [{type: String, ref: 'Task'}],
},
extra: {type: Schema.Types.Mixed, default: () => {
return {};
}},
pushDevices: [PushDeviceSchema],
_ABtests: {type: Schema.Types.Mixed, default: () => {
return {};
}},
webhooks: [WebhookSchema],
loginIncentives: {type: Number, default: 0},
invitesSent: {type: Number, default: 0},
// Items manually pinned by the user
pinnedItems: [{
_id: false,
path: {type: String},
type: {type: String},
}],
// Ordered array of shown pinned items, necessary for sorting because seasonal items are not stored in pinnedItems
pinnedItemsOrder: [{type: String}],
// Items the user manually unpinned from the ones suggested by Habitica
unpinnedItems: [{
_id: false,
path: {type: String},
type: {type: String},
}],
}, {
strict: true,
minimize: false, // So empty objects are returned
});
module.exports = schema;