mirror of
https://github.com/sudoxnym/habitica-self-host.git
synced 2026-04-15 12:07:43 +00:00
Improve Adminpanel with local logs (#15404)
* log armoire, quoest response and cron events to history * show user history in admin panel * allow stats to be edited from admin panel * Improve admin panel stats input * improve setting client in history * fix tests * fix lint * fix armoire buying issue * Improve hero saving * Formatting fix * Improve user history logging * allow class to be changed from admin panel * make terminating subscriptions easier * support decimal extraMonths * Fix editing some achievements in admin panel * log if a user invites party to quest * Log more quest events into user history * make userhistory length configurable * fix some numbered achievements * fix extraMonths field * Automatically set up group plan subs with admin panel * show party info nicer in admin panel * improve admin panel sub handling * add missing brace * display when there are unsaved changes * fix setting group plan * fix showing group id * Display group plan info in admin panel * fix setting hourglass promo date * Improve termination handling in admin panel * reload data after certain save events in admin panel * remove console * fix plan.extraMonths not being reset if terminating a sub * add more options when cancelling subs * reload data after group plan change * Add a way to remove users from a party * fix issue with removing user from party * pass party id correctly * correctly call async function * Improve sub display in admin panel * fix line length * fix line * shorter * plaid * fix(lint): vue code style --------- Co-authored-by: Kalista Payne <sabrecat@gmail.com>
This commit is contained in:
parent
dbc23e89b8
commit
379afa9554
32 changed files with 1743 additions and 201 deletions
|
|
@ -10,6 +10,7 @@ describe('GET /heroes/:heroId', () => {
|
|||
const heroFields = [
|
||||
'_id', 'id', 'auth', 'balance', 'contributor', 'flags', 'items',
|
||||
'lastCron', 'party', 'preferences', 'profile', 'purchased', 'secret', 'achievements',
|
||||
'stats',
|
||||
];
|
||||
|
||||
before(async () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ describe('PUT /heroes/:heroId', () => {
|
|||
const heroFields = [
|
||||
'_id', 'auth', 'balance', 'contributor', 'flags', 'items', 'lastCron',
|
||||
'party', 'preferences', 'profile', 'purchased', 'secret', 'permissions', 'achievements',
|
||||
'stats',
|
||||
];
|
||||
|
||||
before(async () => {
|
||||
|
|
|
|||
|
|
@ -92,8 +92,6 @@ export default {
|
|||
params: { userIdentifier },
|
||||
}).catch(failure => {
|
||||
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
|
||||
// the admin has requested that the same user be displayed again so reload the page
|
||||
// (e.g., if they changed their mind about changes they were making)
|
||||
this.$router.go();
|
||||
}
|
||||
});
|
||||
|
|
@ -101,14 +99,16 @@ export default {
|
|||
|
||||
async loadUser (userIdentifier) {
|
||||
const id = userIdentifier || this.user._id;
|
||||
|
||||
this.$router.push({
|
||||
if (this.$router.currentRoute.name === 'adminPanelUser') {
|
||||
await this.$router.push({
|
||||
name: 'adminPanel',
|
||||
});
|
||||
}
|
||||
await this.$router.push({
|
||||
name: 'adminPanelUser',
|
||||
params: { userIdentifier: id },
|
||||
}).catch(failure => {
|
||||
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
|
||||
// the admin has requested that the same user be displayed again so reload the page
|
||||
// (e.g., if they changed their mind about changes they were making)
|
||||
this.$router.go();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import VueRouter from 'vue-router';
|
||||
|
||||
const { isNavigationFailure, NavigationFailureType } = VueRouter;
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
async saveHero ({ hero, msg = 'User', clearData }) {
|
||||
async saveHero ({
|
||||
hero,
|
||||
msg = 'User',
|
||||
clearData,
|
||||
reloadData,
|
||||
}) {
|
||||
await this.$store.dispatch('hall:updateHero', { heroDetails: hero });
|
||||
await this.$store.dispatch('snackbars:add', {
|
||||
title: '',
|
||||
|
|
@ -14,6 +23,20 @@ export default {
|
|||
// The admin should re-fetch the data if they need to keep working on that user.
|
||||
this.$emit('clear-data');
|
||||
this.$router.push({ name: 'adminPanel' });
|
||||
} else if (reloadData) {
|
||||
if (this.$router.currentRoute.name === 'adminPanelUser') {
|
||||
await this.$router.push({
|
||||
name: 'adminPanel',
|
||||
});
|
||||
}
|
||||
await this.$router.push({
|
||||
name: 'adminPanelUser',
|
||||
params: { userIdentifier: hero._id },
|
||||
}).catch(failure => {
|
||||
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
|
||||
this.$router.go();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
>
|
||||
Could not find any matching users.
|
||||
</div>
|
||||
<loading-spinner class="mx-auto mb-2" dark-color="true" v-if="isSearching" />
|
||||
<loading-spinner
|
||||
v-if="isSearching"
|
||||
class="mx-auto mb-2"
|
||||
dark-color="true"
|
||||
/>
|
||||
<div
|
||||
v-if="users.length > 0"
|
||||
class="list-group"
|
||||
|
|
@ -59,6 +63,10 @@ export default {
|
|||
components: {
|
||||
LoadingSpinner,
|
||||
},
|
||||
beforeRouteUpdate (to, from, next) {
|
||||
this.userIdentifier = to.params.userIdentifier;
|
||||
next();
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
userIdentifier: '',
|
||||
|
|
@ -70,10 +78,6 @@ export default {
|
|||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
beforeRouteUpdate (to, from, next) {
|
||||
this.userIdentifier = to.params.userIdentifier;
|
||||
next();
|
||||
},
|
||||
watch: {
|
||||
userIdentifier () {
|
||||
this.isSearching = true;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
<li
|
||||
v-for="item in achievements"
|
||||
:key="item.path"
|
||||
v-b-tooltip.hover="item.notes"
|
||||
>
|
||||
<form @submit.prevent="saveItem(item)">
|
||||
<span
|
||||
|
|
@ -27,7 +28,7 @@
|
|||
{{ item.value }}
|
||||
</span>
|
||||
:
|
||||
{{ itemText(item) }}
|
||||
{{ item.text || item.key }} - <i> {{ item.key }} </i>
|
||||
</span>
|
||||
|
||||
<div
|
||||
|
|
@ -68,6 +69,7 @@
|
|||
<li
|
||||
v-for="item in nestedAchievements[achievementType]"
|
||||
:key="item.path"
|
||||
v-b-tooltip.hover="item.notes"
|
||||
>
|
||||
<form @submit.prevent="saveItem(item)">
|
||||
<span
|
||||
|
|
@ -78,7 +80,7 @@
|
|||
{{ item.value }}
|
||||
</span>
|
||||
:
|
||||
{{ itemText(item) }}
|
||||
{{ item.text || item.key }} - <i> {{ item.key }} </i>
|
||||
</span>
|
||||
|
||||
<div
|
||||
|
|
@ -143,79 +145,28 @@ function getText (achievementItem) {
|
|||
}
|
||||
const { titleKey } = achievementItem;
|
||||
if (titleKey !== undefined) {
|
||||
return i18n.t(titleKey, 'en');
|
||||
return i18n.t(titleKey);
|
||||
}
|
||||
const { singularTitleKey } = achievementItem;
|
||||
if (singularTitleKey !== undefined) {
|
||||
return i18n.t(singularTitleKey, 'en');
|
||||
return i18n.t(singularTitleKey);
|
||||
}
|
||||
return achievementItem.key;
|
||||
}
|
||||
|
||||
function collateItemData (self) {
|
||||
const achievements = [];
|
||||
const nestedAchievements = {};
|
||||
const basePath = 'achievements';
|
||||
const ownedAchievements = self.hero.achievements;
|
||||
const allAchievements = content.achievements;
|
||||
|
||||
for (const key of Object.keys(ownedAchievements)) {
|
||||
const value = ownedAchievements[key];
|
||||
if (typeof value === 'object') {
|
||||
nestedAchievements[key] = [];
|
||||
for (const nestedKey of Object.keys(value)) {
|
||||
const valueIsInteger = self.integerTypes.includes(key);
|
||||
let text = nestedKey;
|
||||
if (allAchievements[key] && allAchievements[key][nestedKey]) {
|
||||
text = getText(allAchievements[key][nestedKey]);
|
||||
}
|
||||
nestedAchievements[key].push({
|
||||
key: nestedKey,
|
||||
text,
|
||||
achievementType: key,
|
||||
modified: false,
|
||||
path: `${basePath}.${key}.${nestedKey}`,
|
||||
value: value[nestedKey],
|
||||
valueIsInteger,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const valueIsInteger = self.integerTypes.includes(key);
|
||||
achievements.push({
|
||||
key,
|
||||
text: getText(allAchievements[key]),
|
||||
modified: false,
|
||||
path: `${basePath}.${key}`,
|
||||
value: ownedAchievements[key],
|
||||
valueIsInteger,
|
||||
});
|
||||
}
|
||||
function getNotes (achievementItem, count) {
|
||||
if (achievementItem === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (const key of Object.keys(allAchievements)) {
|
||||
if (key !== '' && !key.endsWith('UltimateGear') && !key.endsWith('Quest')) {
|
||||
if (ownedAchievements[key] === undefined) {
|
||||
const valueIsInteger = self.integerTypes.includes(key);
|
||||
achievements.push({
|
||||
key,
|
||||
text: getText(allAchievements[key]),
|
||||
modified: false,
|
||||
path: `${basePath}.${key}`,
|
||||
value: valueIsInteger ? 0 : false,
|
||||
valueIsInteger,
|
||||
neverOwned: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const { textKey } = achievementItem;
|
||||
if (textKey !== undefined) {
|
||||
return i18n.t(textKey, { count });
|
||||
}
|
||||
|
||||
self.achievements = achievements;
|
||||
self.nestedAchievements = nestedAchievements;
|
||||
}
|
||||
|
||||
function resetData (self) {
|
||||
collateItemData(self);
|
||||
self.nestedAchievementKeys.forEach(itemType => { self.expandItemType[itemType] = false; });
|
||||
const { singularTextKey } = achievementItem;
|
||||
if (singularTextKey !== undefined) {
|
||||
return i18n.t(singularTextKey, { count });
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
@ -241,26 +192,34 @@ export default {
|
|||
},
|
||||
nestedAchievementKeys: ['quests', 'ultimateGearSets'],
|
||||
integerTypes: ['streak', 'perfect', 'birthday', 'habiticaDays', 'habitSurveys', 'habitBirthdays',
|
||||
'valentine', 'congrats', 'shinySeed', 'goodluck', 'thankyou', 'seafoam', 'snowball', 'quests'],
|
||||
'valentine', 'congrats', 'shinySeed', 'goodluck', 'thankyou', 'seafoam', 'snowball', 'quests',
|
||||
'rebirths', 'rebirthLevel', 'greeting', 'spookySparkles', 'nye', 'costumeContests', 'congrats',
|
||||
'getwell', 'beastMasterCount', 'mountMasterCount', 'triadBingoCount',
|
||||
],
|
||||
cardTypes: ['greeting', 'birthday', 'valentine', 'goodluck', 'thankyou', 'greeting', 'nye',
|
||||
'congrats', 'getwell'],
|
||||
achievements: [],
|
||||
nestedAchievements: {},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
this.resetData();
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
this.resetData();
|
||||
},
|
||||
methods: {
|
||||
async saveItem (item) {
|
||||
// prepare the item's new value and path for being saved
|
||||
this.hero.achievementPath = item.path;
|
||||
this.hero.achievementVal = item.value;
|
||||
|
||||
await this.saveHero({ hero: this.hero, msg: item.path });
|
||||
await this.saveHero({
|
||||
hero: {
|
||||
_id: this.hero._id,
|
||||
achievementPath: item.path,
|
||||
achievementVal: item.value,
|
||||
},
|
||||
msg: item.path,
|
||||
});
|
||||
item.modified = false;
|
||||
},
|
||||
enableValueChange (item) {
|
||||
|
|
@ -270,14 +229,84 @@ export default {
|
|||
item.value = !item.value;
|
||||
}
|
||||
},
|
||||
itemText (item) {
|
||||
if (item.key === 'npc') {
|
||||
return this.$t('npcAchievementName', { key: this.hero.backer && this.hero.backer.npc });
|
||||
resetData () {
|
||||
this.collateItemData();
|
||||
this.nestedAchievementKeys.forEach(itemType => { this.expandItemType[itemType] = false; });
|
||||
},
|
||||
collateItemData () {
|
||||
const achievements = [];
|
||||
const nestedAchievements = {};
|
||||
const basePath = 'achievements';
|
||||
const ownedAchievements = this.hero.achievements;
|
||||
const allAchievements = content.achievements;
|
||||
|
||||
const ownedKeys = Object.keys(ownedAchievements).sort();
|
||||
for (const key of ownedKeys) {
|
||||
const value = ownedAchievements[key];
|
||||
let contentKey = key;
|
||||
if (this.cardTypes.indexOf(key) !== -1) {
|
||||
contentKey += 'Cards';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
nestedAchievements[key] = [];
|
||||
for (const nestedKey of Object.keys(value)) {
|
||||
const valueIsInteger = this.integerTypes.includes(key);
|
||||
let text = nestedKey;
|
||||
if (allAchievements[key] && allAchievements[key][contentKey]) {
|
||||
text = getText(allAchievements[key][contentKey]);
|
||||
}
|
||||
let notes = '';
|
||||
if (allAchievements[key] && allAchievements[key][contentKey]) {
|
||||
notes = getNotes(allAchievements[key][contentKey], ownedAchievements[key]);
|
||||
}
|
||||
nestedAchievements[key].push({
|
||||
key: nestedKey,
|
||||
text,
|
||||
notes,
|
||||
achievementType: key,
|
||||
modified: false,
|
||||
path: `${basePath}.${key}.${nestedKey}`,
|
||||
value: value[nestedKey],
|
||||
valueIsInteger,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const valueIsInteger = this.integerTypes.includes(key);
|
||||
achievements.push({
|
||||
key,
|
||||
text: getText(allAchievements[contentKey]),
|
||||
notes: getNotes(allAchievements[contentKey], ownedAchievements[key]),
|
||||
modified: false,
|
||||
path: `${basePath}.${key}`,
|
||||
value: ownedAchievements[key],
|
||||
valueIsInteger,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (item.key === 'kickstarter') {
|
||||
return this.$t('kickstartName', { key: this.hero.backer && this.hero.backer.tier });
|
||||
|
||||
const allKeys = Object.keys(allAchievements).sort();
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key !== '' && !key.endsWith('UltimateGear') && !key.endsWith('Quest')) {
|
||||
const ownedKey = key.replace('Cards', '');
|
||||
if (ownedAchievements[ownedKey] === undefined) {
|
||||
const valueIsInteger = this.integerTypes.includes(ownedKey);
|
||||
achievements.push({
|
||||
key: ownedKey,
|
||||
text: getText(allAchievements[key]),
|
||||
notes: getNotes(allAchievements[key], 0),
|
||||
modified: false,
|
||||
path: `${basePath}.${ownedKey}`,
|
||||
value: valueIsInteger ? 0 : false,
|
||||
valueIsInteger,
|
||||
neverOwned: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return item.text || item.key;
|
||||
|
||||
this.achievements = achievements;
|
||||
this.nestedAchievements = nestedAchievements;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
<template>
|
||||
<form @submit.prevent="saveHero({ hero, msg: 'Contributor details', clearData: true })">
|
||||
<form
|
||||
@submit.prevent="saveHero({ hero: {
|
||||
_id: hero._id,
|
||||
contributor: hero.contributor,
|
||||
secret: hero.secret,
|
||||
permissions: hero.permissions,
|
||||
}, msg: 'Contributor details', clearData: true })"
|
||||
>
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<h3
|
||||
|
|
@ -8,6 +15,12 @@
|
|||
@click="expand = !expand"
|
||||
>
|
||||
Contributor Details
|
||||
<b
|
||||
v-if="hasUnsavedChanges && !expand"
|
||||
class="text-warning float-right"
|
||||
>
|
||||
Unsaved changes
|
||||
</b>
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -104,13 +117,16 @@
|
|||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-footer"
|
||||
class="card-footer d-flex align-items-center justify-content-between"
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary mt-1"
|
||||
>
|
||||
<b v-if="hasUnsavedChanges" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -190,6 +206,10 @@ export default {
|
|||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hasUnsavedChanges: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<form @submit.prevent="saveHero({ hero, msg: 'Authentication' })">
|
||||
<form
|
||||
@submit.prevent="saveHero({ hero: {
|
||||
_id: hero._id,
|
||||
auth: hero.auth,
|
||||
preferences: hero.preferences,
|
||||
}, msg: 'Authentication' })"
|
||||
>
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<h3
|
||||
|
|
@ -38,7 +44,10 @@
|
|||
<strong v-else>No</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cronError" class="form-group row">
|
||||
<div
|
||||
v-if="cronError"
|
||||
class="form-group row"
|
||||
>
|
||||
<label class="col-sm-3 col-form-label">lastCron value:</label>
|
||||
<strong>{{ hero.lastCron | formatDate }}</strong>
|
||||
<br>
|
||||
|
|
@ -53,12 +62,12 @@
|
|||
<div class="col-sm-9 col-form-label">
|
||||
<strong>
|
||||
{{ hero.auth.timestamps.loggedin | formatDate }}</strong>
|
||||
<button
|
||||
<a
|
||||
class="btn btn-warning btn-sm ml-4"
|
||||
@click="resetCron()"
|
||||
>
|
||||
Reset Cron to Yesterday
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
|
|
@ -110,13 +119,14 @@
|
|||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">API Token</label>
|
||||
<div class="col-sm-9">
|
||||
<button
|
||||
<a
|
||||
href="#"
|
||||
value="Change API Token"
|
||||
class="btn btn-danger"
|
||||
@click="changeApiToken()"
|
||||
>
|
||||
Change API Token
|
||||
</button>
|
||||
</a>
|
||||
<div
|
||||
v-if="tokenModified"
|
||||
>
|
||||
|
|
@ -268,13 +278,24 @@ export default {
|
|||
return false;
|
||||
},
|
||||
async changeApiToken () {
|
||||
this.hero.changeApiToken = true;
|
||||
await this.saveHero({ hero: this.hero, msg: 'API Token' });
|
||||
await this.saveHero({
|
||||
hero: {
|
||||
_id: this.hero._id,
|
||||
changeApiToken: true,
|
||||
},
|
||||
msg: 'API Token',
|
||||
});
|
||||
this.tokenModified = true;
|
||||
},
|
||||
resetCron () {
|
||||
this.hero.resetCron = true;
|
||||
this.saveHero({ hero: this.hero, msg: 'Last Cron', clearData: true });
|
||||
this.saveHero({
|
||||
hero: {
|
||||
_id: this.hero._id,
|
||||
resetCron: true,
|
||||
},
|
||||
msg: 'Last Cron',
|
||||
clearData: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
:
|
||||
<span :class="{ ownedItem: !item.neverOwned }">{{ item.text }}</span>
|
||||
</span>
|
||||
{{ item.set }}
|
||||
- {{ itemType }}.{{item.key}} - <i> {{ item.set }}</i>
|
||||
|
||||
<div
|
||||
v-if="item.modified"
|
||||
|
|
@ -232,11 +232,14 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
async saveItem (item) {
|
||||
// prepare the item's new value and path for being saved
|
||||
this.hero.purchasedPath = item.path;
|
||||
this.hero.purchasedVal = item.value;
|
||||
|
||||
await this.saveHero({ hero: this.hero, msg: item.path });
|
||||
await this.saveHero({
|
||||
hero: {
|
||||
_id: this.hero._id,
|
||||
purchasedPath: item.path,
|
||||
purchasedVal: item.value,
|
||||
},
|
||||
msg: item.path,
|
||||
});
|
||||
item.modified = false;
|
||||
},
|
||||
enableValueChange (item) {
|
||||
|
|
|
|||
|
|
@ -15,10 +15,17 @@
|
|||
<privileges-and-gems
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
:has-unsaved-changes="hasUnsavedChanges([hero.flags, unModifiedHero.flags],
|
||||
[hero.auth, unModifiedHero.auth],
|
||||
[hero.balance, unModifiedHero.balance],
|
||||
[hero.secret, unModifiedHero.secret])"
|
||||
/>
|
||||
|
||||
<subscription-and-perks
|
||||
:hero="hero"
|
||||
:group-plans="groupPlans"
|
||||
:has-unsaved-changes="hasUnsavedChanges([hero.purchased.plan,
|
||||
unModifiedHero.purchased.plan])"
|
||||
/>
|
||||
|
||||
<cron-and-auth
|
||||
|
|
@ -29,6 +36,7 @@
|
|||
<user-profile
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
:has-unsaved-changes="hasUnsavedChanges([hero.profile, unModifiedHero.profile])"
|
||||
/>
|
||||
|
||||
<party-and-quest
|
||||
|
|
@ -47,6 +55,12 @@
|
|||
:preferences="hero.preferences"
|
||||
/>
|
||||
|
||||
<stats
|
||||
:hero="hero"
|
||||
:has-unsaved-changes="hasUnsavedChanges([hero.stats, unModifiedHero.stats])"
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<items-owned
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
|
|
@ -67,8 +81,18 @@
|
|||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<user-history
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<contributor-details
|
||||
:hero="hero"
|
||||
:hasUnsavedChanges="hasUnsavedChanges(
|
||||
[hero.contributor, unModifiedHero.contributor],
|
||||
[hero.permissions, unModifiedHero.permissions],
|
||||
[hero.secret, unModifiedHero.secret],
|
||||
)"
|
||||
:reset-counter="resetCounter"
|
||||
@clear-data="clearData"
|
||||
/>
|
||||
|
|
@ -109,6 +133,7 @@
|
|||
</style>
|
||||
|
||||
<script>
|
||||
import isEqualWith from 'lodash/isEqualWith';
|
||||
import BasicDetails from './basicDetails';
|
||||
import ItemsOwned from './itemsOwned';
|
||||
import CronAndAuth from './cronAndAuth';
|
||||
|
|
@ -121,6 +146,8 @@ import Transactions from './transactions';
|
|||
import SubscriptionAndPerks from './subscriptionAndPerks';
|
||||
import CustomizationsOwned from './customizationsOwned.vue';
|
||||
import Achievements from './achievements.vue';
|
||||
import UserHistory from './userHistory.vue';
|
||||
import Stats from './stats.vue';
|
||||
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
|
||||
|
|
@ -135,6 +162,8 @@ export default {
|
|||
PrivilegesAndGems,
|
||||
ContributorDetails,
|
||||
Transactions,
|
||||
UserHistory,
|
||||
Stats,
|
||||
SubscriptionAndPerks,
|
||||
UserProfile,
|
||||
Achievements,
|
||||
|
|
@ -148,8 +177,10 @@ export default {
|
|||
return {
|
||||
userIdentifier: '',
|
||||
resetCounter: 0,
|
||||
unModifiedHero: {},
|
||||
hero: {},
|
||||
party: {},
|
||||
groupPlans: [],
|
||||
hasParty: false,
|
||||
partyNotExistError: false,
|
||||
adminHasPrivForParty: true,
|
||||
|
|
@ -168,6 +199,7 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
clearData () {
|
||||
this.unModifiedHero = {};
|
||||
this.hero = {};
|
||||
},
|
||||
|
||||
|
|
@ -176,6 +208,7 @@ export default {
|
|||
this.$emit('changeUserIdentifier', id); // change user identifier in Admin Panel's form
|
||||
|
||||
this.hero = await this.$store.dispatch('hall:getHero', { uuid: id });
|
||||
this.unModifiedHero = JSON.parse(JSON.stringify(this.hero));
|
||||
|
||||
if (!this.hero.flags) {
|
||||
this.hero.flags = {
|
||||
|
|
@ -206,8 +239,38 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.hero.purchased.plan.planId === 'group_plan_auto') {
|
||||
try {
|
||||
this.groupPlans = await this.$store.dispatch('hall:getHeroGroupPlans', { heroId: this.hero._id });
|
||||
} catch (e) {
|
||||
this.groupPlans = [];
|
||||
}
|
||||
}
|
||||
|
||||
this.resetCounter += 1; // tell child components to reinstantiate from scratch
|
||||
},
|
||||
hasUnsavedChanges (...comparisons) {
|
||||
for (const index in comparisons) {
|
||||
if (index && comparisons[index]) {
|
||||
const objs = comparisons[index];
|
||||
const obj1 = objs[0];
|
||||
const obj2 = objs[1];
|
||||
if (!isEqualWith(obj1, obj2, (x, y) => {
|
||||
if (typeof x === 'object' && typeof y === 'object') {
|
||||
return undefined;
|
||||
}
|
||||
if (x === false && y === undefined) {
|
||||
// Special case for checkboxes
|
||||
return true;
|
||||
}
|
||||
return x == y; // eslint-disable-line eqeqeq
|
||||
})) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -269,16 +269,19 @@ export default {
|
|||
methods: {
|
||||
async saveItem (item) {
|
||||
// prepare the item's new value and path for being saved
|
||||
this.hero.itemPath = item.path;
|
||||
const toSave = {
|
||||
_id: this.hero._id,
|
||||
};
|
||||
toSave.itemPath = item.path;
|
||||
if (item.value === null) {
|
||||
this.hero.itemVal = 'null';
|
||||
toSave.itemVal = 'null';
|
||||
} else if (item.value === false) {
|
||||
this.hero.itemVal = 'false';
|
||||
toSave.itemVal = 'false';
|
||||
} else {
|
||||
this.hero.itemVal = item.value;
|
||||
toSave.itemVal = item.value;
|
||||
}
|
||||
|
||||
await this.saveHero({ hero: this.hero, msg: item.key });
|
||||
await this.saveHero({ hero: toSave, msg: item.key });
|
||||
item.neverOwned = false;
|
||||
item.modified = false;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -31,22 +31,41 @@
|
|||
v-html="questErrors"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Party:
|
||||
<span v-if="userHasParty">
|
||||
yes: party ID {{ groupPartyData._id }},
|
||||
member count {{ groupPartyData.memberCount }} (may be wrong)
|
||||
<br>
|
||||
<div v-if="userHasParty">
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Party ID
|
||||
</label>
|
||||
<strong class="col-sm-9 col-form-label">
|
||||
{{ groupPartyData._id }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Estimated Member Count
|
||||
</label>
|
||||
<strong class="col-sm-9 col-form-label">
|
||||
{{ groupPartyData.memberCount }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Leader
|
||||
</label>
|
||||
<strong class="col-sm-9 col-form-label">
|
||||
<span v-if="userIsPartyLeader">User is the party leader</span>
|
||||
<span v-else>Party leader is
|
||||
<router-link :to="{'name': 'userProfile', 'params': {'userId': groupPartyData.leader}}">
|
||||
{{ groupPartyData.leader }}
|
||||
</router-link>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>no</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
class="btn btn-danger"
|
||||
@click="removeFromParty()">Remove from Party</div>
|
||||
</div>
|
||||
<strong v-else>User is not in a party.</strong>
|
||||
<div class="subsection-start">
|
||||
<p v-html="questStatus"></p>
|
||||
</div>
|
||||
|
|
@ -56,6 +75,7 @@
|
|||
|
||||
<script>
|
||||
import * as quests from '@/../../common/script/content/quests';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
|
||||
function determineQuestStatus (self) {
|
||||
// Quest data is in the user doc and party doc. They can be out of sync.
|
||||
|
|
@ -271,6 +291,7 @@ function resetData (self) {
|
|||
}
|
||||
|
||||
export default {
|
||||
mixins: [saveHero],
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
|
|
@ -318,5 +339,14 @@ export default {
|
|||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
methods: {
|
||||
removeFromParty () {
|
||||
this.saveHero({
|
||||
hero: { _id: this.userId, removeFromParty: true },
|
||||
msg: 'Removed from party',
|
||||
reloadData: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<form @submit.prevent="saveHero({hero, msg: 'Privileges or Gems or Moderation Notes'})">
|
||||
<form @submit.prevent="saveHero({hero: {
|
||||
_id: hero._id,
|
||||
flags: hero.flags,
|
||||
balance: hero.balance,
|
||||
auth: hero.auth,
|
||||
secret: hero.secret,
|
||||
}, msg: 'Privileges or Gems or Moderation Notes'})">
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<h3
|
||||
|
|
@ -8,6 +14,9 @@
|
|||
@click="expand = !expand"
|
||||
>
|
||||
Privileges, Gem Balance
|
||||
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -117,13 +126,16 @@
|
|||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-footer"
|
||||
class="card-footer d-flex align-items-center justify-content-between"
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary mt-1"
|
||||
>
|
||||
<b v-if="hasUnsavedChanges" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -169,6 +181,10 @@ export default {
|
|||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hasUnsavedChanges: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
<template>
|
||||
<div class="form-group row">
|
||||
<label
|
||||
class="col-sm-3 col-form-label"
|
||||
:class="color"
|
||||
>{{ label }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input
|
||||
:value="value"
|
||||
class="form-control"
|
||||
type="number"
|
||||
:step="step"
|
||||
:max="max"
|
||||
:min="min"
|
||||
@input="$emit('input', parseInt($event.target.value, 10))"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.about-row {
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.red-label {
|
||||
color: $red_100;
|
||||
}
|
||||
.blue-label {
|
||||
color: $blue_100;
|
||||
}
|
||||
.purple-label {
|
||||
color: $purple_300;
|
||||
}
|
||||
.yellow-label {
|
||||
color: $yellow_50;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
model: {
|
||||
prop: 'value',
|
||||
event: 'input',
|
||||
},
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'text-label',
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
default: 'any',
|
||||
},
|
||||
min: {
|
||||
},
|
||||
max: {
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
286
website/client/src/components/admin-panel/user-support/stats.vue
Normal file
286
website/client/src/components/admin-panel/user-support/stats.vue
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
<template>
|
||||
<form @submit.prevent="submitClicked()">
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<h3
|
||||
class="mb-0 mt-0"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Stats
|
||||
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-body"
|
||||
>
|
||||
<stats-row
|
||||
label="Health"
|
||||
color="red-label"
|
||||
:max="maxHealth"
|
||||
v-model="hero.stats.hp" />
|
||||
<stats-row
|
||||
label="Experience"
|
||||
color="yellow-label"
|
||||
min="0"
|
||||
:max="maxFieldHardCap"
|
||||
v-model="hero.stats.exp" />
|
||||
<stats-row
|
||||
label="Mana"
|
||||
color="blue-label"
|
||||
min="0"
|
||||
:max="maxFieldHardCap"
|
||||
v-model="hero.stats.mp" />
|
||||
<stats-row
|
||||
label="Level"
|
||||
step="1"
|
||||
min="0"
|
||||
:max="maxLevelHardCap"
|
||||
v-model="hero.stats.lvl" />
|
||||
<stats-row
|
||||
label="Gold"
|
||||
min="0"
|
||||
:max="maxFieldHardCap"
|
||||
v-model="hero.stats.gp" />
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">Selected Class</label>
|
||||
<div class="col-sm-9">
|
||||
<select
|
||||
id="selectedClass"
|
||||
v-model="hero.stats.class"
|
||||
class="form-control"
|
||||
:disabled="hero.stats.lvl < 10"
|
||||
>
|
||||
<option value="warrior">Warrior</option>
|
||||
<option value="wizard">Mage</option>
|
||||
<option value="healer">Healer</option>
|
||||
<option value="rogue">Rogue</option>
|
||||
</select>
|
||||
<small>
|
||||
When changing class, players usually need stat points deallocated as well.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Stat Points</h3>
|
||||
<stats-row
|
||||
label="Unallocated"
|
||||
min="0"
|
||||
step="1"
|
||||
:max="maxStatPoints"
|
||||
v-model="hero.stats.points" />
|
||||
<stats-row
|
||||
label="Strength"
|
||||
color="red-label"
|
||||
min="0"
|
||||
:max="maxStatPoints"
|
||||
step="1"
|
||||
v-model="hero.stats.str" />
|
||||
<stats-row
|
||||
label="Intelligence"
|
||||
color="blue-label"
|
||||
min="0"
|
||||
:max="maxStatPoints"
|
||||
step="1"
|
||||
v-model="hero.stats.int" />
|
||||
<stats-row
|
||||
label="Perception"
|
||||
color="purple-label"
|
||||
min="0"
|
||||
:max="maxStatPoints"
|
||||
step="1"
|
||||
v-model="hero.stats.per" />
|
||||
<stats-row
|
||||
label="Constitution"
|
||||
color="yellow-label"
|
||||
min="0"
|
||||
:max="maxStatPoints"
|
||||
step="1"
|
||||
v-model="hero.stats.con" />
|
||||
<div class="form-group row">
|
||||
<div class="offset-sm-3 col-sm-9">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-warning btn-sm"
|
||||
@click="deallocateStatPoints">
|
||||
Deallocate all stat points
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row" v-if="statPointsIncorrect">
|
||||
<div class="offset-sm-3 col-sm-9 text-danger">
|
||||
Error: Sum of stat points should equal the users level
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Buffs</h3>
|
||||
<stats-row
|
||||
label="Strength"
|
||||
color="red-label"
|
||||
min="0"
|
||||
step="1"
|
||||
v-model="hero.stats.buffs.str" />
|
||||
<stats-row
|
||||
label="Intelligence"
|
||||
color="blue-label"
|
||||
min="0"
|
||||
step="1"
|
||||
v-model="hero.stats.buffs.int" />
|
||||
<stats-row
|
||||
label="Perception"
|
||||
color="purple-label"
|
||||
min="0"
|
||||
step="1"
|
||||
v-model="hero.stats.buffs.per" />
|
||||
<stats-row
|
||||
label="Constitution"
|
||||
color="yellow-label"
|
||||
min="0"
|
||||
step="1"
|
||||
v-model="hero.stats.buffs.con" />
|
||||
<div class="form-group row">
|
||||
<div class="offset-sm-3 col-sm-9">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-warning btn-sm"
|
||||
@click="resetBuffs">
|
||||
Reset Buffs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-footer d-flex align-items-center justify-content-between"
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary mt-1"
|
||||
>
|
||||
<b v-if="hasUnsavedChanges" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.about-row {
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import {
|
||||
MAX_HEALTH,
|
||||
MAX_STAT_POINTS,
|
||||
MAX_LEVEL_HARD_CAP,
|
||||
MAX_FIELD_HARD_CAP,
|
||||
} from '@/../../common/script/constants';
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
|
||||
import { mapState } from '@/libs/store';
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
|
||||
import StatsRow from './stats-row';
|
||||
|
||||
function resetData (self) {
|
||||
self.expand = false;
|
||||
}
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
components: {
|
||||
StatsRow,
|
||||
},
|
||||
mixins: [
|
||||
userStateMixin,
|
||||
saveHero,
|
||||
],
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
statPointsIncorrect () {
|
||||
if (this.hero.stats.lvl >= 10) {
|
||||
return (parseInt(this.hero.stats.points, 10)
|
||||
+ parseInt(this.hero.stats.str, 10)
|
||||
+ parseInt(this.hero.stats.int, 10)
|
||||
+ parseInt(this.hero.stats.per, 10)
|
||||
+ parseInt(this.hero.stats.con, 10)
|
||||
) !== this.hero.stats.lvl;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hasUnsavedChanges: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
maxHealth: MAX_HEALTH,
|
||||
maxStatPoints: MAX_STAT_POINTS,
|
||||
maxLevelHardCap: MAX_LEVEL_HARD_CAP,
|
||||
maxFieldHardCap: MAX_FIELD_HARD_CAP,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
methods: {
|
||||
submitClicked () {
|
||||
if (this.statPointsIncorrect) {
|
||||
return;
|
||||
}
|
||||
this.saveHero({
|
||||
hero: {
|
||||
_id: this.hero._id,
|
||||
stats: this.hero.stats,
|
||||
},
|
||||
msg: 'Stats',
|
||||
});
|
||||
},
|
||||
resetBuffs () {
|
||||
this.hero.stats.buffs = {
|
||||
str: 0,
|
||||
int: 0,
|
||||
per: 0,
|
||||
con: 0,
|
||||
};
|
||||
},
|
||||
deallocateStatPoints () {
|
||||
this.hero.stats.points = this.hero.stats.lvl;
|
||||
this.hero.stats.str = 0;
|
||||
this.hero.stats.int = 0;
|
||||
this.hero.stats.per = 0;
|
||||
this.hero.stats.con = 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,30 +1,135 @@
|
|||
<template>
|
||||
<form @submit.prevent="saveHero({ hero, msg: 'Subscription Perks' })">
|
||||
<form
|
||||
@submit.prevent="saveHero({ hero: {
|
||||
_id: hero._id,
|
||||
purchased: hero.purchased
|
||||
}, msg: 'Subscription Perks' })"
|
||||
>
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<div class="card-header"
|
||||
@click="expand = !expand">
|
||||
<h3
|
||||
class="mb-0 mt-0"
|
||||
:class="{ 'open': expand }"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Subscription, Monthly Perks
|
||||
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-body"
|
||||
>
|
||||
<div v-if="hero.purchased.plan.paymentMethod">
|
||||
Payment method:
|
||||
<strong>{{ hero.purchased.plan.paymentMethod }}</strong>
|
||||
<div
|
||||
class="form-group row"
|
||||
>
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Payment method:
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<input v-model="hero.purchased.plan.paymentMethod"
|
||||
class="form-control"
|
||||
type="text"
|
||||
v-if="!isRegularPaymentMethod"
|
||||
>
|
||||
<select
|
||||
v-else
|
||||
v-model="hero.purchased.plan.paymentMethod"
|
||||
class="form-control"
|
||||
type="text"
|
||||
>
|
||||
<option value="groupPlan">Group Plan</option>
|
||||
<option value="Stripe">Stripe</option>
|
||||
<option value="Apple">Apple</option>
|
||||
<option value="Google">Google</option>
|
||||
<option value="Amazon Payments">Amazon</option>
|
||||
<option value="PayPal">PayPal</option>
|
||||
<option value="Gift">Gift</option>
|
||||
<option value="">Clear out</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hero.purchased.plan.planId">
|
||||
Payment schedule ("basic-earned" is monthly):
|
||||
<strong>{{ hero.purchased.plan.planId }}</strong>
|
||||
<div
|
||||
class="form-group row"
|
||||
>
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Payment schedule:
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<input v-model="hero.purchased.plan.planId"
|
||||
class="form-control"
|
||||
type="text"
|
||||
v-if="!isRegularPlanId"
|
||||
>
|
||||
<select
|
||||
v-else
|
||||
v-model="hero.purchased.plan.planId"
|
||||
class="form-control"
|
||||
type="text"
|
||||
>
|
||||
<option value="basic_earned">Monthly recurring</option>
|
||||
<option value="basic_3mo">3 Months recurring</option>
|
||||
<option value="basic_6mo">6 Months recurring</option>
|
||||
<option value="basic_12mo">12 Months recurring</option>
|
||||
<option value="group_monthly">Group Plan (legacy)</option>
|
||||
<option value="group_plan_auto">Group Plan (auto)</option>
|
||||
<option value="">Clear out</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hero.purchased.plan.planId == 'group_plan_auto'">
|
||||
Group plan ID:
|
||||
<strong>{{ hero.purchased.plan.owner }}</strong>
|
||||
<div
|
||||
class="form-group row"
|
||||
>
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Customer ID:
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<input
|
||||
v-model="hero.purchased.plan.customerId"
|
||||
class="form-control"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row"
|
||||
v-if="hero.purchased.plan.planId === 'group_plan_auto'">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Group Plan Memberships:
|
||||
</label>
|
||||
<div class="col-sm-9 col-form-label">
|
||||
<loading-spinner
|
||||
v-if="!groupPlans"
|
||||
dark-color=true
|
||||
/>
|
||||
<b
|
||||
v-else-if="groupPlans.length === 0"
|
||||
class="text-danger col-form-label"
|
||||
>User is not part of an active group plan!</b>
|
||||
<div
|
||||
v-else
|
||||
v-for="group in groupPlans"
|
||||
:key="group._id"
|
||||
class="card mb-2">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">{{ group.name }}
|
||||
<small class="float-right">{{ group._id }}</small>
|
||||
</h6>
|
||||
<p class="card-text">
|
||||
<strong>Leader: </strong>
|
||||
<a
|
||||
v-if="group.leader !== hero._id"
|
||||
@click="switchUser(group.leader)"
|
||||
>{{ group.leader }}</a>
|
||||
<strong v-else class="text-success">This user</strong>
|
||||
</p>
|
||||
<p class="card-text">
|
||||
<strong>Members: </strong> {{ group.memberCount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="hero.purchased.plan.dateCreated"
|
||||
|
|
@ -85,8 +190,18 @@
|
|||
<strong class="input-group-text">
|
||||
{{ dateFormat(hero.purchased.plan.dateTerminated) }}
|
||||
</strong>
|
||||
<a class="btn btn-danger"
|
||||
href="#"
|
||||
v-b-modal.sub_termination_modal
|
||||
v-if="!hero.purchased.plan.dateTerminated && hero.purchased.plan.planId">
|
||||
Terminate
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="!hero.purchased.plan.dateTerminated
|
||||
&& hero.purchased.plan.planId" class="text-success">
|
||||
The subscription does not have a termination date and is active.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
|
|
@ -101,6 +216,35 @@
|
|||
min="0"
|
||||
step="1"
|
||||
>
|
||||
<small class="text-secondary">
|
||||
Cumulative subscribed months across subscription periods.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Extra months:
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<div class="input-group">
|
||||
<input
|
||||
v-model="hero.purchased.plan.extraMonths"
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
>
|
||||
<div class="input-group-append">
|
||||
<a class="btn btn-warning"
|
||||
@click="applyExtraMonths"
|
||||
v-if="hero.purchased.plan.dateTerminated && hero.purchased.plan.extraMonths > 0">
|
||||
Apply Credit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-secondary">
|
||||
Additional credit that is applied if a subscription is cancelled.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
|
|
@ -174,10 +318,6 @@
|
|||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hero.purchased.plan.extraMonths > 0">
|
||||
Additional credit (applied upon cancellation):
|
||||
<strong>{{ hero.purchased.plan.extraMonths }}</strong>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Mystery Items:
|
||||
|
|
@ -199,18 +339,64 @@
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row"
|
||||
v-if="!isConvertingToGroupPlan && hero.purchased.plan.planId !== 'group_plan_auto'">
|
||||
<div class="offset-sm-3 col-sm-9">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="beginGroupPlanConvert">
|
||||
Begin converting to group plan subscription
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row"
|
||||
v-if="isConvertingToGroupPlan">
|
||||
<label class="col-sm-3 col-form-label">
|
||||
Group Plan group ID:
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<input
|
||||
v-model="groupPlanID"
|
||||
class="form-control"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-footer"
|
||||
class="card-footer d-flex align-items-center justify-content-between"
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary mt-1"
|
||||
@click="saveClicked"
|
||||
>
|
||||
<b v-if="hasUnsavedChanges" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
<b-modal id="sub_termination_modal" title="Set Termination Date">
|
||||
<p>
|
||||
You can set the sub benefit termination date to today or to the last
|
||||
day of the current billing cycle. Any extra subscription credit will
|
||||
then be processed and automatically added onto the selected date.
|
||||
</p>
|
||||
<template #modal-footer>
|
||||
<div class="mt-3 btn btn-secondary" @click="$bvModal.hide('sub_termination_modal')">
|
||||
Close
|
||||
</div>
|
||||
<div class="mt-3 btn btn-danger" @click="terminateSubscription()">
|
||||
Set to Today
|
||||
</div>
|
||||
<div class="mt-3 btn btn-danger" @click="terminateSubscription(todayWithRemainingCycle)">
|
||||
Set to {{ todayWithRemainingCycle.utc().format('MM/DD/YYYY') }}
|
||||
</div>
|
||||
</template>
|
||||
</b-modal>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
|
@ -231,21 +417,38 @@
|
|||
</style>
|
||||
|
||||
<script>
|
||||
import isUUID from 'validator/es/lib/isUUID';
|
||||
import moment from 'moment';
|
||||
import { getPlanContext } from '@/../../common/script/cron';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
import subscriptionBlocks from '../../../../../common/script/content/subscriptionBlocks';
|
||||
import LoadingSpinner from '@/components/ui/loadingSpinner';
|
||||
|
||||
export default {
|
||||
mixins: [saveHero],
|
||||
components: {
|
||||
LoadingSpinner,
|
||||
},
|
||||
props: {
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hasUnsavedChanges: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
groupPlans: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
isConvertingToGroupPlan: false,
|
||||
groupPlanID: '',
|
||||
subscriptionBlocks,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -255,6 +458,30 @@ export default {
|
|||
if (!currentPlanContext.nextHourglassDate) return 'N/A';
|
||||
return currentPlanContext.nextHourglassDate.format('MMMM YYYY');
|
||||
},
|
||||
isRegularPlanId () {
|
||||
return this.subscriptionBlocks[this.hero.purchased.plan.planId] !== undefined;
|
||||
},
|
||||
isRegularPaymentMethod () {
|
||||
return [
|
||||
'groupPlan',
|
||||
'Group Plan',
|
||||
'Stripe',
|
||||
'Apple',
|
||||
'Google',
|
||||
'Amazon Payments',
|
||||
'PayPal',
|
||||
'Gift',
|
||||
].includes(this.hero.purchased.plan.paymentMethod);
|
||||
},
|
||||
todayWithRemainingCycle () {
|
||||
const now = moment();
|
||||
const monthCount = subscriptionBlocks[this.hero.purchased.plan.planId].months;
|
||||
const terminationDate = moment(this.hero.purchased.plan.dateCurrentTypeCreated || new Date());
|
||||
while (terminationDate.isBefore(now)) {
|
||||
terminationDate.add(monthCount, 'months');
|
||||
}
|
||||
return terminationDate;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
dateFormat (date) {
|
||||
|
|
@ -263,6 +490,46 @@ export default {
|
|||
}
|
||||
return moment(date).format('YYYY/MM/DD');
|
||||
},
|
||||
terminateSubscription (terminationDate) {
|
||||
if (terminationDate) {
|
||||
this.hero.purchased.plan.dateTerminated = terminationDate.utc().format();
|
||||
} else {
|
||||
this.hero.purchased.plan.dateTerminated = moment(new Date()).utc().format();
|
||||
}
|
||||
this.applyExtraMonths();
|
||||
this.saveHero({ hero: this.hero, msg: 'Subscription Termination', reloadData: true });
|
||||
},
|
||||
applyExtraMonths () {
|
||||
if (this.hero.purchased.plan.extraMonths > 0 || this.hero.purchased.plan.extraMonths !== '0') {
|
||||
const date = moment(this.hero.purchased.plan.dateTerminated || new Date());
|
||||
const extraMonths = Math.max(this.hero.purchased.plan.extraMonths, 0);
|
||||
const extraDays = Math.ceil(30.5 * extraMonths);
|
||||
this.hero.purchased.plan.dateTerminated = date.add(extraDays, 'days').utc().format();
|
||||
this.hero.purchased.plan.extraMonths = 0;
|
||||
}
|
||||
},
|
||||
beginGroupPlanConvert () {
|
||||
this.isConvertingToGroupPlan = true;
|
||||
this.hero.purchased.plan.owner = '';
|
||||
},
|
||||
saveClicked (e) {
|
||||
e.preventDefault();
|
||||
if (this.isConvertingToGroupPlan) {
|
||||
if (!isUUID(this.groupPlanID)) {
|
||||
alert('Invalid group ID');
|
||||
return;
|
||||
}
|
||||
this.hero.purchased.plan.convertToGroupPlan = this.groupPlanID;
|
||||
this.saveHero({ hero: this.hero, msg: 'Group Plan Subscription', reloadData: true });
|
||||
} else {
|
||||
this.saveHero({ hero: this.hero, msg: 'Subscription Perks', reloadData: true });
|
||||
}
|
||||
},
|
||||
switchUser (id) {
|
||||
if (window.confirm('Switch to this user?')) {
|
||||
this.$emit('changeUserIdentifier', id);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
<template>
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<h3
|
||||
class="mb-0 mt-0"
|
||||
:class="{'open': expand}"
|
||||
@click="toggleHistoryOpen"
|
||||
>
|
||||
User History
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-body"
|
||||
>
|
||||
<div>
|
||||
<div class="clearfix">
|
||||
<div class="mb-4 float-left">
|
||||
<button
|
||||
class="page-header btn-flat tab-button textCondensed"
|
||||
:class="{'active': selectedTab === 'armoire'}"
|
||||
@click="selectTab('armoire')"
|
||||
>
|
||||
Armoire
|
||||
</button>
|
||||
<button
|
||||
class="page-header btn-flat tab-button textCondensed"
|
||||
:class="{'active': selectedTab === 'questInvites'}"
|
||||
@click="selectTab('questInvites')"
|
||||
>
|
||||
Quest Invitations
|
||||
</button>
|
||||
<button
|
||||
class="page-header btn-flat tab-button textCondensed"
|
||||
:class="{'active': selectedTab === 'cron'}"
|
||||
@click="selectTab('cron')"
|
||||
>
|
||||
Cron
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div
|
||||
v-if="selectedTab === 'armoire'"
|
||||
class="col-12"
|
||||
>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th
|
||||
v-once
|
||||
>
|
||||
{{ $t('timestamp') }}
|
||||
</th>
|
||||
<th v-once>
|
||||
Client
|
||||
</th>
|
||||
<th
|
||||
v-once
|
||||
>
|
||||
Received
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="entry in armoire"
|
||||
:key="entry.timestamp"
|
||||
>
|
||||
<td>
|
||||
<span
|
||||
v-b-tooltip.hover="entry.timestamp"
|
||||
>{{ entry.timestamp | timeAgo }}</span>
|
||||
</td>
|
||||
<td>{{ entry.client }}</td>
|
||||
<td>{{ entry.reward }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTab === 'questInvites'"
|
||||
class="col-12"
|
||||
>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th
|
||||
v-once
|
||||
>
|
||||
{{ $t('timestamp') }}
|
||||
</th>
|
||||
<th v-once>
|
||||
Client
|
||||
</th>
|
||||
<th v-once>
|
||||
Quest Key
|
||||
</th>
|
||||
<th v-once>
|
||||
Response
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="entry in questInviteResponses"
|
||||
:key="entry.timestamp"
|
||||
>
|
||||
<td>
|
||||
<span
|
||||
v-b-tooltip.hover="entry.timestamp"
|
||||
>{{ entry.timestamp | timeAgo }}</span>
|
||||
</td>
|
||||
<td>{{ entry.client }}</td>
|
||||
<td>{{ entry.quest }}</td>
|
||||
<td>{{ questInviteResponseText(entry.response) }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTab === 'cron'"
|
||||
class="col-12"
|
||||
>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th
|
||||
v-once
|
||||
>
|
||||
{{ $t('timestamp') }}
|
||||
</th>
|
||||
<th v-once>
|
||||
Client
|
||||
</th>
|
||||
<th v-once>
|
||||
Checkin Count
|
||||
</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="entry in cron"
|
||||
:key="entry.timestamp"
|
||||
>
|
||||
<td>
|
||||
<span
|
||||
v-b-tooltip.hover="entry.timestamp"
|
||||
>{{ entry.timestamp | timeAgo }}</span>
|
||||
</td>
|
||||
<td>{{ entry.client }}</td>
|
||||
<td>{{ entry.checkinCount }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
.page-header.btn-flat {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
height: 2rem;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
font-stretch: condensed;
|
||||
line-height: 1.33;
|
||||
letter-spacing: normal;
|
||||
color: $gray-10;
|
||||
|
||||
margin-right: 1.125rem;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-bottom: 2.5rem;
|
||||
|
||||
&.active, &:hover {
|
||||
color: $purple-300;
|
||||
box-shadow: 0px -0.25rem 0px $purple-300 inset;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
|
||||
export default {
|
||||
filters: {
|
||||
timeAgo (value) {
|
||||
return moment(value).fromNow();
|
||||
},
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
props: {
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
selectedTab: 'armoire',
|
||||
armoire: [],
|
||||
questInviteResponses: [],
|
||||
cron: [],
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
if (this.expand) {
|
||||
this.retrieveUserHistory();
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selectTab (type) {
|
||||
this.selectedTab = type;
|
||||
},
|
||||
async toggleHistoryOpen () {
|
||||
this.expand = !this.expand;
|
||||
if (this.expand) {
|
||||
this.retrieveUserHistory();
|
||||
}
|
||||
},
|
||||
async retrieveUserHistory () {
|
||||
const history = await this.$store.dispatch('adminPanel:getUserHistory', { userIdentifier: this.hero._id });
|
||||
this.armoire = history.armoire;
|
||||
this.questInviteResponses = history.questInviteResponses;
|
||||
this.cron = history.cron;
|
||||
},
|
||||
questInviteResponseText (response) {
|
||||
if (response === 'accept') {
|
||||
return 'Accepted';
|
||||
}
|
||||
if (response === 'reject') {
|
||||
return 'Rejected';
|
||||
}
|
||||
if (response === 'leave') {
|
||||
return 'Left active quest';
|
||||
}
|
||||
if (response === 'invite') {
|
||||
return 'Accepted as owner';
|
||||
}
|
||||
if (response === 'abort') {
|
||||
return 'Aborted by owner';
|
||||
}
|
||||
if (response === 'abortByLeader') {
|
||||
return 'Aborted by party leader';
|
||||
}
|
||||
if (response === 'cancel') {
|
||||
return 'Cancelled before start';
|
||||
}
|
||||
if (response === 'cancelByLeader') {
|
||||
return 'Cancelled before start by party leader';
|
||||
}
|
||||
return response;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
<template>
|
||||
<form @submit.prevent="saveHero({hero, msg: 'Users Profile'})">
|
||||
<form
|
||||
@submit.prevent="saveHero({hero: {
|
||||
_id: hero._id,
|
||||
profile: hero.profile
|
||||
}, msg: 'Users Profile'})"
|
||||
>
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
<h3
|
||||
|
|
@ -8,6 +13,9 @@
|
|||
@click="expand = !expand"
|
||||
>
|
||||
User Profile
|
||||
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -51,13 +59,16 @@
|
|||
</div>
|
||||
<div
|
||||
v-if="expand"
|
||||
class="card-footer"
|
||||
class="card-footer d-flex align-items-center justify-content-between"
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary mt-1"
|
||||
>
|
||||
<b v-if="hasUnsavedChanges" class="text-warning float-right">
|
||||
Unsaved changes
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -101,6 +112,10 @@ export default {
|
|||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hasUnsavedChanges: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -843,7 +843,6 @@ export default {
|
|||
purchasedPlanIdInfo () {
|
||||
if (!this.subscriptionBlocks[this.user.purchased.plan.planId]) {
|
||||
// @TODO: find which subs are in the common
|
||||
// console.log(this.subscriptionBlocks
|
||||
// [this.user.purchased.plan.planId]); // eslint-disable-line
|
||||
return {
|
||||
price: 0,
|
||||
|
|
|
|||
|
|
@ -5,3 +5,9 @@ export async function searchUsers (store, payload) {
|
|||
const response = await axios.get(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getUserHistory (store, payload) {
|
||||
const url = `/api/v4/admin/user/${payload.userIdentifier}/history`;
|
||||
const response = await axios.get(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,3 +32,9 @@ export async function getHeroParty (store, payload) {
|
|||
const response = await axios.get(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getHeroGroupPlans (store, payload) {
|
||||
const url = `/api/v4/hall/heroes/${payload.heroId}/group-plans`;
|
||||
const response = await axios.get(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ import stripePayments from '../../libs/payments/stripe';
|
|||
import amzLib from '../../libs/payments/amazon';
|
||||
import { apiError } from '../../libs/apiError';
|
||||
import { model as UserNotification } from '../../models/userNotification';
|
||||
import {
|
||||
leaveGroup,
|
||||
removeMessagesFromMember,
|
||||
} from '../../libs/groups';
|
||||
|
||||
const { MAX_SUMMARY_SIZE_FOR_GUILDS } = common.constants;
|
||||
const MAX_EMAIL_INVITES_BY_USER = 200;
|
||||
|
|
@ -776,21 +780,6 @@ api.rejectGroupInvite = {
|
|||
},
|
||||
};
|
||||
|
||||
function _removeMessagesFromMember (member, groupId) {
|
||||
if (member.newMessages[groupId]) {
|
||||
delete member.newMessages[groupId];
|
||||
member.markModified('newMessages');
|
||||
}
|
||||
|
||||
member.notifications = member.notifications.filter(n => {
|
||||
if (n && n.type === 'NEW_CHAT_MESSAGE' && n.data && n.data.group && n.data.group.id === groupId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/leave Leave a group
|
||||
* @apiName LeaveGroup
|
||||
|
|
@ -840,32 +829,13 @@ api.leaveGroup = {
|
|||
if (validationErrors) throw validationErrors;
|
||||
|
||||
const { groupId } = req.params;
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, fields: '-chat', requireMembership: true,
|
||||
await leaveGroup({
|
||||
res,
|
||||
user,
|
||||
groupId,
|
||||
keep: req.query.keep,
|
||||
keepChallenges: req.body.keepChallenges,
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFound(res.t('groupNotFound'));
|
||||
}
|
||||
|
||||
// During quests, check if user can leave
|
||||
if (group.type === 'party') {
|
||||
if (group.quest && group.quest.leader === user._id) {
|
||||
throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup'));
|
||||
}
|
||||
|
||||
if (
|
||||
group.quest && group.quest.active
|
||||
&& group.quest.members && group.quest.members[user._id]
|
||||
) {
|
||||
throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest'));
|
||||
}
|
||||
}
|
||||
|
||||
await group.leave(user, req.query.keep, req.body.keepChallenges);
|
||||
_removeMessagesFromMember(user, group._id);
|
||||
await user.save();
|
||||
|
||||
if (group.hasNotCancelled()) await group.updateGroupPlan(true);
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
|
@ -981,7 +951,7 @@ api.removeGroupMember = {
|
|||
member.party._id = undefined; // TODO remove quest information too? Use group.leave()?
|
||||
}
|
||||
|
||||
_removeMessagesFromMember(member, group._id);
|
||||
removeMessagesFromMember(member, group._id);
|
||||
|
||||
if (group.quest && group.quest.active && group.quest.leader === member._id) {
|
||||
member.items.quests[group.quest.key] += 1;
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ import { model as Group } from '../../models/group';
|
|||
import common from '../../../common';
|
||||
import {
|
||||
NotFound,
|
||||
BadRequest,
|
||||
} from '../../libs/errors';
|
||||
import { apiError } from '../../libs/apiError';
|
||||
import {
|
||||
validateItemPath,
|
||||
castItemVal,
|
||||
} from '../../libs/items/utils';
|
||||
import { addSubToGroupUser } from '../../libs/payments/groupPayments';
|
||||
import { leaveGroup } from '../../libs/groups';
|
||||
|
||||
const api = {};
|
||||
|
||||
|
|
@ -146,7 +149,7 @@ api.getHeroes = {
|
|||
// Note, while the following routes are called getHero / updateHero
|
||||
// they can be used by admins to get/update any user
|
||||
|
||||
const heroAdminFields = 'auth balance contributor flags items lastCron party preferences profile purchased secret permissions achievements';
|
||||
const heroAdminFields = 'auth balance contributor flags items lastCron party preferences profile purchased secret permissions achievements stats';
|
||||
const heroAdminFieldsToFetch = heroAdminFields; // these variables will make more sense when...
|
||||
const heroAdminFieldsToShow = heroAdminFields; // ... apiTokenObscured is added
|
||||
|
||||
|
|
@ -314,6 +317,77 @@ api.updateHero = {
|
|||
if (plan.cumulativeCount) {
|
||||
hero.purchased.plan.cumulativeCount = plan.cumulativeCount;
|
||||
}
|
||||
if (plan.extraMonths || plan.extraMonths === 0) {
|
||||
hero.purchased.plan.extraMonths = plan.extraMonths;
|
||||
}
|
||||
if (plan.customerId) {
|
||||
hero.purchased.plan.customerId = plan.customerId;
|
||||
}
|
||||
if (plan.paymentMethod) {
|
||||
hero.purchased.plan.paymentMethod = plan.paymentMethod;
|
||||
}
|
||||
if (plan.planId) {
|
||||
hero.purchased.plan.planId = plan.planId;
|
||||
}
|
||||
if (plan.owner) {
|
||||
hero.purchased.plan.owner = plan.owner;
|
||||
}
|
||||
if (plan.hourglassPromoReceived) {
|
||||
hero.purchased.plan.hourglassPromoReceived = plan.hourglassPromoReceived;
|
||||
}
|
||||
|
||||
if (plan.convertToGroupPlan) {
|
||||
const groupID = plan.convertToGroupPlan;
|
||||
const group = await Group.getGroup({ user: hero, groupId: groupID });
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (group.hasNotCancelled()) {
|
||||
hero.purchased.plan.customerId = null;
|
||||
hero.purchased.plan.paymentMethod = null;
|
||||
await addSubToGroupUser(hero, group);
|
||||
await group.updateGroupPlan();
|
||||
} else {
|
||||
throw new BadRequest('Group does not have a plan');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updateData.stats) {
|
||||
if (updateData.stats.hp) {
|
||||
hero.stats.hp = updateData.stats.hp;
|
||||
}
|
||||
if (updateData.stats.mp) {
|
||||
hero.stats.mp = updateData.stats.mp;
|
||||
}
|
||||
if (updateData.stats.exp) {
|
||||
hero.stats.exp = updateData.stats.exp;
|
||||
}
|
||||
if (updateData.stats.gp) {
|
||||
hero.stats.gp = updateData.stats.gp;
|
||||
}
|
||||
if (updateData.stats.lvl) {
|
||||
hero.stats.lvl = updateData.stats.lvl;
|
||||
}
|
||||
if (updateData.stats.points) {
|
||||
hero.stats.points = updateData.stats.points;
|
||||
}
|
||||
if (updateData.stats.str) {
|
||||
hero.stats.str = updateData.stats.str;
|
||||
}
|
||||
if (updateData.stats.int) {
|
||||
hero.stats.int = updateData.stats.int;
|
||||
}
|
||||
if (updateData.stats.per) {
|
||||
hero.stats.per = updateData.stats.per;
|
||||
}
|
||||
if (updateData.stats.con) {
|
||||
hero.stats.con = updateData.stats.con;
|
||||
}
|
||||
if (updateData.stats.buffs) {
|
||||
hero.stats.buffs = updateData.stats.buffs;
|
||||
}
|
||||
if (updateData.stats.class) {
|
||||
hero.stats.class = updateData.stats.class;
|
||||
}
|
||||
}
|
||||
|
||||
// give them gems if they got an higher level
|
||||
|
|
@ -435,6 +509,17 @@ api.updateHero = {
|
|||
}
|
||||
|
||||
const savedHero = await hero.save();
|
||||
|
||||
if (updateData.removeFromParty) {
|
||||
await leaveGroup({
|
||||
user: savedHero,
|
||||
groupId: savedHero.party._id,
|
||||
res,
|
||||
keep: false,
|
||||
keepChallenges: false,
|
||||
});
|
||||
}
|
||||
|
||||
const heroJSON = savedHero.toJSON();
|
||||
heroJSON.secret = savedHero.getSecretData();
|
||||
const responseHero = { _id: heroJSON._id }; // only respond with important fields
|
||||
|
|
@ -491,4 +576,66 @@ api.getHeroParty = { // @TODO XXX add tests
|
|||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/hall/heroes/:heroId Get Group Plans for a user
|
||||
* @apiParam (Path) {UUID} groupId party's group ID
|
||||
* @apiName GetHeroGroupPlans
|
||||
* @apiGroup Hall
|
||||
* @apiPermission userSupport
|
||||
*
|
||||
* @apiDescription Returns some basic information about group plans,
|
||||
* to assist admins with user support.
|
||||
*
|
||||
* @apiSuccess {Object} data The active group plans
|
||||
*
|
||||
* @apiUse NoAuthHeaders
|
||||
* @apiUse NoAccount
|
||||
* @apiUse NoUser
|
||||
* @apiUse NoPrivs
|
||||
*/
|
||||
api.getHeroGroupPlans = {
|
||||
method: 'GET',
|
||||
url: '/hall/heroes/:heroId/group-plans',
|
||||
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
|
||||
async handler (req, res) {
|
||||
req.checkParams('heroId', res.t('heroIdRequired')).notEmpty();
|
||||
|
||||
const validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
const { heroId } = req.params;
|
||||
|
||||
let query;
|
||||
if (validator.isUUID(heroId)) {
|
||||
query = { _id: heroId };
|
||||
} else {
|
||||
query = { 'auth.local.lowerCaseUsername': heroId.toLowerCase() };
|
||||
}
|
||||
|
||||
const hero = await User
|
||||
.findOne(query)
|
||||
.select('guilds party')
|
||||
.exec();
|
||||
|
||||
if (!hero) throw new NotFound(res.t('userWithIDNotFound', { userId: heroId }));
|
||||
const heroGroups = hero.getGroups();
|
||||
|
||||
if (heroGroups.length === 0) {
|
||||
res.respond(200, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = await Group
|
||||
.find({
|
||||
_id: { $in: heroGroups },
|
||||
})
|
||||
.select('leaderOnly leader purchased name managers memberCount')
|
||||
.exec();
|
||||
|
||||
const groupPlans = groups.filter(group => group.hasActiveGroupPlan());
|
||||
|
||||
res.respond(200, groupPlans);
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import common from '../../../common';
|
|||
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
|
||||
import { apiError } from '../../libs/apiError';
|
||||
import { questActivityWebhook } from '../../libs/webhook';
|
||||
import { model as UserHistory } from '../../models/userHistory';
|
||||
|
||||
const analytics = getAnalyticsServiceByEnvironment();
|
||||
|
||||
|
|
@ -172,6 +173,10 @@ api.inviteToQuest = {
|
|||
uuid: user._id,
|
||||
headers: req.headers,
|
||||
});
|
||||
|
||||
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
|
||||
.withQuestInviteResponse(group.quest.key, 'invite')
|
||||
.commit();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -233,6 +238,10 @@ api.acceptQuest = {
|
|||
uuid: user._id,
|
||||
headers: req.headers,
|
||||
});
|
||||
|
||||
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
|
||||
.withQuestInviteResponse(group.quest.key, 'accept')
|
||||
.commit();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -294,6 +303,10 @@ api.rejectQuest = {
|
|||
uuid: user._id,
|
||||
headers: req.headers,
|
||||
});
|
||||
|
||||
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
|
||||
.withQuestInviteResponse(group.quest.key, 'reject')
|
||||
.commit();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -399,13 +412,14 @@ api.cancelQuest = {
|
|||
}
|
||||
if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest'));
|
||||
|
||||
const questName = questScrolls[group.quest.key].text('en');
|
||||
const questKey = group.quest.key;
|
||||
const questName = questScrolls[questKey].text('en');
|
||||
const newChatMessage = await group.sendChat({
|
||||
message: `\`${user.profile.name} cancelled the party quest ${questName}.\``,
|
||||
info: {
|
||||
type: 'quest_cancel',
|
||||
user: user.profile.name,
|
||||
quest: group.quest.key,
|
||||
quest: questKey,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -422,6 +436,15 @@ api.cancelQuest = {
|
|||
]);
|
||||
|
||||
res.respond(200, savedGroup.quest);
|
||||
|
||||
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
|
||||
.withQuestInviteResponse(questKey, 'cancel')
|
||||
.commit();
|
||||
if (group.quest.leader !== user._id) {
|
||||
await UserHistory.beginUserHistoryUpdate(group.quest.leader, req.headers)
|
||||
.withQuestInviteResponse(questKey, 'cancelByLeader')
|
||||
.commit();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -461,13 +484,14 @@ api.abortQuest = {
|
|||
if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToAbort'));
|
||||
if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest'));
|
||||
|
||||
const questName = questScrolls[group.quest.key].text('en');
|
||||
const questKey = group.quest.key;
|
||||
const questName = questScrolls[questKey].text('en');
|
||||
const newChatMessage = await group.sendChat({
|
||||
message: `\`${common.i18n.t('chatQuestAborted', { username: user.profile.name, questName }, 'en')}\``,
|
||||
info: {
|
||||
type: 'quest_abort',
|
||||
user: user.profile.name,
|
||||
quest: group.quest.key,
|
||||
quest: questKey,
|
||||
},
|
||||
});
|
||||
await newChatMessage.save();
|
||||
|
|
@ -480,7 +504,7 @@ api.abortQuest = {
|
|||
_id: group.quest.leader,
|
||||
}, {
|
||||
$inc: {
|
||||
[`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader
|
||||
[`items.quests.${questKey}`]: 1, // give back the quest to the quest leader
|
||||
},
|
||||
}).exec();
|
||||
|
||||
|
|
@ -490,6 +514,15 @@ api.abortQuest = {
|
|||
const [groupSaved] = await Promise.all([group.save(), memberUpdates, questLeaderUpdate]);
|
||||
|
||||
res.respond(200, groupSaved.quest);
|
||||
|
||||
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
|
||||
.withQuestInviteResponse(questKey, 'abort')
|
||||
.commit();
|
||||
if (group.quest.leader !== user._id) {
|
||||
await UserHistory.beginUserHistoryUpdate(group.quest.leader, req.headers)
|
||||
.withQuestInviteResponse(questKey, 'abortByLeader')
|
||||
.commit();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -537,6 +570,10 @@ api.leaveQuest = {
|
|||
]);
|
||||
|
||||
res.respond(200, savedGroup.quest);
|
||||
|
||||
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
|
||||
.withQuestInviteResponse(group.quest.key, 'leave')
|
||||
.commit();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
} from '../../libs/email';
|
||||
import * as inboxLib from '../../libs/inbox';
|
||||
import * as userLib from '../../libs/user';
|
||||
import { model as UserHistory } from '../../models/userHistory';
|
||||
|
||||
const OFFICIAL_PLATFORMS = ['habitica-web', 'habitica-ios', 'habitica-android'];
|
||||
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS_TECH_ASSISTANCE_EMAIL');
|
||||
|
|
@ -501,6 +502,13 @@ api.buy = {
|
|||
const buyRes = await common.ops.buy(user, req, res.analytics);
|
||||
|
||||
await user.save();
|
||||
|
||||
if (type === 'armoire') {
|
||||
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
|
||||
.withArmoire(buyRes[0].armoire.dropKey || 'experience')
|
||||
.commit();
|
||||
}
|
||||
|
||||
res.respond(200, ...buyRes);
|
||||
},
|
||||
};
|
||||
|
|
@ -593,6 +601,9 @@ api.buyArmoire = {
|
|||
}
|
||||
const buyArmoireResponse = await common.ops.buy(user, req, res.analytics);
|
||||
await user.save();
|
||||
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
|
||||
.withArmoire(buyArmoireResponse[0].armoire.dropKey || 'experience')
|
||||
.commit();
|
||||
res.respond(200, ...buyArmoireResponse);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import validator from 'validator';
|
|||
import { authWithHeaders } from '../../middlewares/auth';
|
||||
import { ensurePermission } from '../../middlewares/ensureAccessRight';
|
||||
import { model as User } from '../../models/user';
|
||||
import { model as UserHistory } from '../../models/userHistory';
|
||||
import {
|
||||
NotFound,
|
||||
} from '../../libs/errors';
|
||||
|
||||
const api = {};
|
||||
|
||||
|
|
@ -21,7 +25,7 @@ const api = {};
|
|||
* @apiUse NoUser
|
||||
* @apiUse NotAdmin
|
||||
*/
|
||||
api.getHero = {
|
||||
api.searchHero = {
|
||||
method: 'GET',
|
||||
url: '/admin/search/:userIdentifier',
|
||||
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
|
||||
|
|
@ -73,4 +77,43 @@ api.getHero = {
|
|||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v4/admin/user/:userId/history Get the history of a user
|
||||
* @apiParam (Path) {String} userIdentifier The username or email of the user
|
||||
* @apiName GetUserHistory
|
||||
* @apiGroup Admin
|
||||
* @apiPermission Admin
|
||||
*
|
||||
* @apiDescription Returns the history of a user
|
||||
*
|
||||
* @apiSuccess {Object} data The User history
|
||||
*
|
||||
* @apiUse NoAuthHeaders
|
||||
* @apiUse NoAccount
|
||||
* @apiUse NoUser
|
||||
* @apiUse NotAdmin
|
||||
*/
|
||||
api.getUserHistory = {
|
||||
method: 'GET',
|
||||
url: '/admin/user/:userId/history',
|
||||
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
|
||||
async handler (req, res) {
|
||||
req.checkParams('userId', res.t('heroIdRequired')).notEmpty().isUUID();
|
||||
|
||||
const validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
const { userId } = req.params;
|
||||
|
||||
const history = await UserHistory
|
||||
.findOne({ userId })
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
if (!history) throw new NotFound(res.t('userWithIDNotFound', { userId }));
|
||||
|
||||
res.respond(200, history);
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import common from '../../common';
|
|||
import { preenUserHistory } from './preening';
|
||||
import { sleep } from './sleep';
|
||||
import { revealMysteryItems } from './payments/subscriptions';
|
||||
import { model as UserHistory } from '../models/userHistory';
|
||||
|
||||
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
|
||||
const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true';
|
||||
|
|
@ -471,5 +472,9 @@ export async function cron (options = {}) {
|
|||
user.flags.cronCount += 1;
|
||||
trackCronAnalytics(analytics, user, _progress, options);
|
||||
|
||||
await UserHistory.beginUserHistoryUpdate(user._id, options.headers)
|
||||
.withCron(user.flags.cronCount)
|
||||
.commit();
|
||||
|
||||
return _progress;
|
||||
}
|
||||
|
|
|
|||
58
website/server/libs/groups.js
Normal file
58
website/server/libs/groups.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import {
|
||||
NotFound,
|
||||
NotAuthorized,
|
||||
} from './errors';
|
||||
import {
|
||||
model as Group,
|
||||
} from '../models/group';
|
||||
|
||||
export function removeMessagesFromMember (member, groupId) {
|
||||
if (member.newMessages[groupId]) {
|
||||
delete member.newMessages[groupId];
|
||||
member.markModified('newMessages');
|
||||
}
|
||||
|
||||
member.notifications = member.notifications.filter(n => {
|
||||
if (n && n.type === 'NEW_CHAT_MESSAGE' && n.data && n.data.group && n.data.group.id === groupId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function leaveGroup (data) {
|
||||
const {
|
||||
groupId,
|
||||
user,
|
||||
res,
|
||||
keep,
|
||||
keepChallenges,
|
||||
} = data;
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, fields: '-chat', requireMembership: true,
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFound(res.t('groupNotFound'));
|
||||
}
|
||||
|
||||
// During quests, check if user can leave
|
||||
if (group.type === 'party') {
|
||||
if (group.quest && group.quest.leader === user._id) {
|
||||
throw new NotAuthorized(res.t('questLeaderCannotLeaveGroup'));
|
||||
}
|
||||
|
||||
if (
|
||||
group.quest && group.quest.active
|
||||
&& group.quest.members && group.quest.members[user._id]
|
||||
) {
|
||||
throw new NotAuthorized(res.t('cannotLeaveWhileActiveQuest'));
|
||||
}
|
||||
}
|
||||
|
||||
await group.leave(user, keep, keepChallenges);
|
||||
removeMessagesFromMember(user, group._id);
|
||||
await user.save();
|
||||
|
||||
if (group.hasNotCancelled()) await group.updateGroupPlan(true);
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ import stripePayments from '../libs/payments/stripe'; // eslint-disable-line imp
|
|||
import { getGroupChat, translateMessage } from '../libs/chat/group-chat'; // eslint-disable-line import/no-cycle
|
||||
import { model as UserNotification } from './userNotification';
|
||||
import { sendChatPushNotifications } from '../libs/chat'; // eslint-disable-line import/no-cycle
|
||||
import { model as UserHistory } from './userHistory'; // eslint-disable-line import/no-cycle
|
||||
|
||||
const questScrolls = shared.content.quests;
|
||||
const { questSeriesAchievements } = shared.content;
|
||||
|
|
@ -683,7 +684,7 @@ schema.methods.startQuest = async function startQuest (user) {
|
|||
}
|
||||
|
||||
const nonMembers = Object.keys(_.pickBy(this.quest.members, member => !member));
|
||||
|
||||
const noResponseMembers = Object.keys(_.pickBy(this.quest.members, member => member === null));
|
||||
// Changes quest.members to only include participating members
|
||||
this.quest.members = _.pickBy(this.quest.members, _.identity);
|
||||
|
||||
|
|
@ -755,6 +756,12 @@ schema.methods.startQuest = async function startQuest (user) {
|
|||
_id: { $in: nonMembers },
|
||||
}, _cleanQuestParty()).exec();
|
||||
|
||||
noResponseMembers.forEach(member => {
|
||||
UserHistory.beginUserHistoryUpdate(member)
|
||||
.withQuestInviteResponse(this.quest.key, 'no response')
|
||||
.commit();
|
||||
});
|
||||
|
||||
const newMessage = await this.sendChat({
|
||||
message: `\`${shared.i18n.t('chatQuestStarted', { questName: quest.text('en') }, 'en')}\``,
|
||||
metaData: {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import {
|
|||
import {
|
||||
model as NewsPost,
|
||||
} from '../newsPost';
|
||||
import {
|
||||
model as UserHistory,
|
||||
} from '../userHistory';
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
userActivityWebhook,
|
||||
} from '../../libs/webhook';
|
||||
|
|
@ -237,7 +240,7 @@ schema.pre('validate', function preValidateUser (next) {
|
|||
next();
|
||||
});
|
||||
|
||||
schema.pre('save', true, function preSaveUser (next, done) {
|
||||
schema.pre('save', true, async function preSaveUser (next, done) {
|
||||
next();
|
||||
|
||||
// VERY IMPORTANT NOTE: when only some fields from an user document are selected
|
||||
|
|
@ -360,6 +363,12 @@ schema.pre('save', true, function preSaveUser (next, done) {
|
|||
// Unset the field so this is run only once
|
||||
this.flags.lastWeeklyRecapDiscriminator = undefined;
|
||||
}
|
||||
if (!this.flags.initializedUserHistory) {
|
||||
this.flags.initializedUserHistory = true;
|
||||
const history = UserHistory();
|
||||
history.userId = this._id;
|
||||
await history.save();
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce min/max values without displaying schema errors to end user
|
||||
|
|
@ -396,12 +405,9 @@ schema.pre('save', true, function preSaveUser (next, done) {
|
|||
|
||||
// Populate new users with default content
|
||||
if (this.isNew) {
|
||||
_setUpNewUser(this)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
} else {
|
||||
done();
|
||||
await _setUpNewUser(this);
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
schema.pre('updateOne', function preUpdateUser () {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
129
website/server/models/userHistory.js
Normal file
129
website/server/models/userHistory.js
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import nconf from 'nconf';
|
||||
import mongoose from 'mongoose';
|
||||
import validator from 'validator';
|
||||
import baseModel from '../libs/baseModel';
|
||||
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const userHistoryLength = nconf.get('USER_HISTORY_LENGTH') || 20;
|
||||
|
||||
export const schema = new Schema({
|
||||
userId: {
|
||||
$type: String,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
validate: [v => validator.isUUID(v), 'Invalid uuid for userhistory.'],
|
||||
index: true,
|
||||
unique: true,
|
||||
},
|
||||
armoire: [
|
||||
{
|
||||
_id: false,
|
||||
timestamp: { $type: Date, required: true },
|
||||
client: { $type: String, required: false },
|
||||
reward: { $type: String, required: true },
|
||||
},
|
||||
],
|
||||
questInviteResponses: [
|
||||
{
|
||||
_id: false,
|
||||
timestamp: { $type: Date, required: true },
|
||||
client: { $type: String, required: false },
|
||||
quest: { $type: String, required: true },
|
||||
response: { $type: String, required: true },
|
||||
},
|
||||
],
|
||||
cron: [
|
||||
{
|
||||
_id: false,
|
||||
timestamp: { $type: Date, required: true },
|
||||
checkinCount: { $type: Number, required: true },
|
||||
client: { $type: String, required: false },
|
||||
},
|
||||
],
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
typeKey: '$type', // So that we can use fields named `type`
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
noSet: ['id', '_id', 'userId'],
|
||||
timestamps: true,
|
||||
_id: false, // using custom _id
|
||||
});
|
||||
|
||||
export const model = mongoose.model('UserHistory', schema);
|
||||
|
||||
const commitUserHistoryUpdate = function commitUserHistoryUpdate (update) {
|
||||
const data = {
|
||||
$push: {
|
||||
|
||||
},
|
||||
};
|
||||
if (update.data.armoire.length) {
|
||||
data.$push.armoire = {
|
||||
$each: update.data.armoire,
|
||||
$sort: { timestamp: -1 },
|
||||
$slice: userHistoryLength,
|
||||
};
|
||||
}
|
||||
if (update.data.questInviteResponses.length) {
|
||||
data.$push.questInviteResponses = {
|
||||
$each: update.data.questInviteResponses,
|
||||
$sort: { timestamp: -1 },
|
||||
$slice: userHistoryLength,
|
||||
};
|
||||
}
|
||||
if (update.data.cron.length > 0) {
|
||||
data.$push.cron = {
|
||||
$each: update.data.cron,
|
||||
$sort: { timestamp: -1 },
|
||||
$slice: userHistoryLength,
|
||||
};
|
||||
}
|
||||
return model.updateOne(
|
||||
{ userId: update.userId },
|
||||
data,
|
||||
).exec();
|
||||
};
|
||||
|
||||
model.beginUserHistoryUpdate = function beginUserHistoryUpdate (userID, headers = null) {
|
||||
return {
|
||||
userId: userID,
|
||||
data: {
|
||||
headers: headers || {},
|
||||
armoire: [],
|
||||
questInviteResponses: [],
|
||||
cron: [],
|
||||
},
|
||||
withArmoire: function withArmoire (reward) {
|
||||
this.data.armoire.push({
|
||||
timestamp: new Date(),
|
||||
client: this.data.headers['x-client'],
|
||||
reward,
|
||||
});
|
||||
return this;
|
||||
},
|
||||
withQuestInviteResponse: function withQuestInviteResponse (quest, response) {
|
||||
this.data.questInviteResponses.push({
|
||||
timestamp: new Date(),
|
||||
client: this.data.headers['x-client'],
|
||||
quest,
|
||||
response,
|
||||
});
|
||||
return this;
|
||||
},
|
||||
withCron: function withCron (checkinCount) {
|
||||
this.data.cron.push({
|
||||
timestamp: new Date(),
|
||||
checkinCount,
|
||||
client: this.data.headers['x-client'],
|
||||
});
|
||||
return this;
|
||||
},
|
||||
commit: function commit () {
|
||||
commitUserHistoryUpdate(this);
|
||||
},
|
||||
};
|
||||
};
|
||||
Loading…
Reference in a new issue