Files
habitica/website/server/models/user/schema.js
Phillip Thelen 12773d539e Add interface to block ip-addresses or clients due to abuse (#15484)
* Read IP blocks from database

* begin building general blocking solution

* add new frontend files

* Add UI for managing blockers

* correctly reset local data after creating blocker

* Tweak wording

* Add UI for managing blockers

* restructure admin pages

* improve test coverage

* Improve blocker UI

* add blocker to block emails from registration

* lint fix

* fix

* lint fixes

* fix import

* add new permission for managing blockers

* improve permission check

* fix managing permissions from admin

* improve navbar display for non fullAccess admin

* update block error strings

* lint fix

* add option to errorHandler to skip logging

* validate blocker value during input

* improve blocker form display

* chore(subproj): reconcile habitica-images

* fix(scripts): use same Mongo version for dev/test

* fix(whitespace): eof

* documentation improvements

* remove nconf import

* remove old test

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
2025-08-06 15:08:07 -05:00

766 lines
26 KiB
JavaScript

import { Schema } from 'mongoose';
import validator from 'validator';
import shared from '../../../common';
import { // eslint-disable-line import/no-cycle
getDefaultOwnedGear,
} from '../../libs/items/utils';
import { schema as PushDeviceSchema } from '../pushDevice';
import { schema as SubscriptionPlanSchema } from '../subscriptionPlan';
import { schema as TagSchema } from '../tag';
import { schema as UserNotificationSchema } from '../userNotification';
import { schema as WebhookSchema } from '../webhook';
import { model as Blocker } from '../blocker';
const RESTRICTED_EMAIL_DOMAINS = Object.freeze(['habitica.com', 'habitrpg.com']);
const BLOCKED_EMAILS = [];
Blocker.watchBlockers({
type: 'email',
area: 'full',
}, {
initial: true,
}).on('change', async change => {
const { operation, blocker: { value } } = change;
if (operation === 'add') {
if (value && !BLOCKED_EMAILS.includes(value)) {
BLOCKED_EMAILS.push(value);
}
} else if (operation === 'delete') {
const index = BLOCKED_EMAILS.indexOf(value);
if (index !== -1) {
BLOCKED_EMAILS.splice(index, 1);
}
}
});
// User schema definition
export const UserSchema = new Schema({
apiToken: {
$type: String,
default: shared.uuid,
},
auth: {
blocked: Boolean,
facebook: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
google: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
apple: { $type: Schema.Types.Mixed, default: () => ({}) },
local: {
email: {
$type: String,
validate: [{
validator: v => validator.isEmail(v),
message: shared.i18n.t('invalidEmail'),
}, {
validator (email) {
const lowercaseEmail = email.toLowerCase();
return RESTRICTED_EMAIL_DOMAINS.every(domain => !lowercaseEmail.endsWith(`@${domain}`));
},
message: shared.i18n.t('invalidEmailDomain', { domains: RESTRICTED_EMAIL_DOMAINS.join(', ') }),
}, {
validator (email) {
const lowercaseEmail = email.toLowerCase();
return BLOCKED_EMAILS.every(block => lowercaseEmail.indexOf(block) === -1);
},
message: shared.i18n.t('emailBlockedRegistration'),
}],
},
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 },
updated: { $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, 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: { $type: Number, default: 0 },
challenges: Array,
quests: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
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,
mindOverMatter: Boolean,
justAddWater: Boolean,
backToBasics: Boolean,
allYourBase: Boolean,
dustDevil: Boolean,
aridAuthority: Boolean,
kickstarter2019: Boolean,
monsterMagus: Boolean,
undeadUndertaker: Boolean,
primedForPainting: Boolean,
pearlyPro: Boolean,
tickledPink: Boolean,
rosyOutlook: Boolean,
bugBonanza: Boolean,
bareNecessities: Boolean,
freshwaterFriends: Boolean,
goodAsGold: Boolean,
allThatGlitters: Boolean,
boneCollector: Boolean,
skeletonCrew: Boolean,
seeingRed: Boolean,
redLetterDay: Boolean,
legendaryBestiary: Boolean,
seasonalSpecialist: Boolean,
violetsAreBlue: Boolean,
wildBlueYonder: Boolean,
domesticated: Boolean,
shadyCustomer: Boolean,
shadeOfItAll: Boolean,
zodiacZookeeper: Boolean,
birdsOfAFeather: Boolean,
reptacularRumble: Boolean,
woodlandWizard: Boolean,
boneToPick: Boolean,
polarPro: Boolean,
plantParent: Boolean,
dinosaurDynasty: Boolean,
bonelessBoss: Boolean,
duneBuddy: Boolean,
roughRider: Boolean,
rodentRuler: Boolean,
cats: Boolean,
// Onboarding Guide
createdTask: Boolean,
completedTask: Boolean,
hatchedPet: Boolean,
fedPet: Boolean,
purchasedEquipment: Boolean,
groupsBeta2022: Boolean,
},
backer: {
tier: Number,
npc: String,
tokensApplied: Boolean,
},
contributor: {
// 1-9, see https://habitica.fandom.com/wiki/Contributor_Rewards
level: {
$type: Number,
min: 0,
max: 9,
},
admin: Boolean,
// Artisan, Friend, Blacksmith, etc
text: String,
// a markdown textarea to list their contributions + links
contributions: String,
// user can own Critical Hammer of Bug-Crushing if this has a truthy value
critical: String,
},
permissions: {
fullAccess: Boolean, // esentially what was previously contributor.admin. Can do everything
news: Boolean,
userSupport: Boolean, // access User Support feature in Admin Panel
challengeAdmin: Boolean, // Can manage and administrate challenges
moderator: Boolean, // Can ban, flag users and manage social spaces
accessControl: Boolean, // Can manage IP and client blockers
coupons: Boolean, // Can generate and request coupons
},
balance: { $type: Number, default: 0 },
purchased: {
ads: { $type: Boolean, default: false },
// eg, {skeleton: true, pumpkin: true, eb052b: true}
skin: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
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: {
$type: SubscriptionPlanSchema,
default: () => ({}),
},
},
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 },
groupPlans: { $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 }, // unused
itemsEnabled: { $type: Boolean, default: false },
lastNewStuffRead: { $type: String, default: '' },
// The newStuff field was changed to be a computed property when returning the user in json,
// so that it doesn't have to be updated for each bailey post.
// See models/user/hooks#toJSONTransform
// newStuff: { $type: Boolean, default: false },
rewrite: { $type: Boolean, default: true },
classSelected: { $type: Boolean, default: false },
mathUpdates: Boolean,
rebirthEnabled: { $type: Boolean, default: false },
lastFreeRebirth: Date,
levelDrops: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
chatRevoked: Boolean,
chatShadowMuted: 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 },
verifiedUsername: { $type: Boolean, default: false },
thirdPartyTools: { $type: Date },
initializedUserHistory: { $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: {
$type: Schema.Types.Mixed,
default: () => getDefaultOwnedGear(),
},
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: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
currentPet: { $type: String, default: '' }, // Cactus-Desert
// eggs: {
// 'PandaCub': 0, // 0 indicates "doesn't own"
// 'Wolf': 5 // Number indicates "stacking"
// }
eggs: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
// hatchingPotions: {
// 'Desert': 0, // 0 indicates "doesn't own"
// 'CottonCandyBlue': 5 // Number indicates "stacking"
// }
hatchingPotions: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
// Food: {
// 'Watermelon': 0, // 0 indicates "doesn't own"
// 'RottenMeat': 5 // Number indicates "stacking"
// }
food: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
// mounts: {
// 'Wolf-Desert': true,
// 'PandaCub-Red': false,
// etc...
// }
mounts: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
currentMount: { $type: String, default: '' }, // Cactus-Desert
// Quests: {
// 'boss_0': 0, // 0 indicates "doesn't own"
// 'collection_honey': 5 // Number indicates "stacking"
// }
quests: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
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
// Lock property to avoid double subscription. Not strictly private because we query on it
_subSignature: { $type: String, default: 'NOT_RUNNING' },
// {GROUP_ID: Boolean}, represents whether they have unseen chat messages
newMessages: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
challenges: [{ $type: String, ref: 'Challenge', validate: [v => validator.isUUID(v), 'Invalid uuid for user challenges.'] }],
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 https://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: () => ({}),
},
parties: [{
id: {
$type: String,
ref: 'Group',
required: true,
validate: [v => validator.isUUID(v), 'Invalid uuid for user invitation party id.'],
},
name: {
$type: String,
required: true,
},
inviter: {
$type: String,
ref: 'User',
required: true,
validate: [v => validator.isUUID(v), 'Invalid uuid for user invitation inviter id.'],
},
cancelledPlan: {
$type: Boolean,
},
}],
},
guilds: [{ $type: String, ref: 'Group', validate: [v => validator.isUUID(v), 'Invalid uuid for user guild.'] }],
party: {
_id: { $type: String, validate: [v => validator.isUUID(v), 'Invalid uuid for user party.'], 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: () => ({}),
},
collectedItems: { $type: Number, default: 0 },
},
// 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
completed: String,
// Set to true when invite is pending, set to false when quest
// invite is accepted or rejected, quest starts, or quest is cancelled
RSVPNeeded: { $type: Boolean, default: false },
},
seeking: Date,
},
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: { $type: Boolean, default: false },
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 },
// not used anymore, now the current filter is saved in preferences.activeFilter
dailyDueDefaultView: { $type: Boolean, default: false },
// deprecated, unused
advancedCollapsed: { $type: Boolean, default: false },
toolbarCollapsed: { $type: Boolean, default: false },
reverseChatOrder: { $type: Boolean, default: false },
developerMode: { $type: Boolean, default: false },
background: String,
// deprecated, unused
displayInviteToPartyWhenPartyIs1: { $type: Boolean, default: true },
webhooks: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
// 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 },
majorUpdates: { $type: Boolean, default: true },
subscriptionReminders: { $type: Boolean, default: true },
contentRelease: { $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 },
majorUpdates: { $type: Boolean, default: true },
mentionParty: { $type: Boolean, default: true },
mentionJoinedGuild: { $type: Boolean, default: true },
mentionUnjoinedGuild: { $type: Boolean, default: true },
partyActivity: { $type: Boolean, default: true },
contentRelease: { $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 }, // @TODO remove? not used
confirmScoreNotes: { $type: Boolean, default: false }, // @TODO remove? not used
mirrorGroupTasks: [
{ $type: String, validate: [v => validator.isUUID(v), 'Invalid group UUID.'], ref: 'Group' },
],
activeFilter: {
habit: { $type: String, default: 'all' },
daily: { $type: String, default: 'all' },
todo: { $type: String, default: 'remaining' },
reward: { $type: String, default: 'all' },
},
},
improvementCategories: {
$type: Array,
validate: categories => {
const validCategories = ['work', 'exercise', 'healthWellness', 'school', 'teams', 'chores', 'creativity'];
const isValidCategory = categories
.every(category => validCategories.indexOf(category) !== -1);
return isValidCategory;
},
},
},
profile: {
blurb: String,
imageUrl: String,
name: {
$type: String,
required: true,
trim: true,
},
flags: { $type: Schema.Types.Mixed },
},
stats: {
hp: { $type: Number, default: shared.maxHealth },
mp: { $type: Number, default: 10, min: 0 },
exp: { $type: Number, default: 0 },
gp: { $type: Number, default: 0, min: 0 },
lvl: {
$type: Number,
default: 1,
min: 1,
max: shared.constants.MAX_LEVEL_HARD_CAP,
},
// Class System
class: {
$type: String, enum: shared.content.classes, default: 'warrior', required: true,
},
points: { $type: Number, default: 0, min: 0 },
str: { $type: Number, default: 0, min: 0 },
con: { $type: Number, default: 0, min: 0 },
int: { $type: Number, default: 0, min: 0 },
per: { $type: Number, default: 0, min: 0 },
buffs: {
str: { $type: Number, default: 0, min: 0 },
int: { $type: Number, default: 0, min: 0 },
per: { $type: Number, default: 0, min: 0 },
con: { $type: Number, default: 0, min: 0 },
stealth: { $type: Number, default: 0, min: 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: {
// messages are stored in the Inbox collection
newMessages: { $type: Number, default: 0 },
blocks: { $type: Array, default: () => [] },
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: () => ({}),
},
pushDevices: [PushDeviceSchema],
_ABtests: {
$type: Schema.Types.Mixed,
default: () => ({}),
},
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 },
}],
// only visible to staff and moderators
secret: {
text: String,
},
}, {
skipVersioning: { notifications: true },
strict: true,
minimize: false, // So empty objects are returned
typeKey: '$type', // So that we can use fields named `type`
});
export default UserSchema; // fallback export until all imports using the Named one