mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 06:37:23 +01:00
Clone challenges api (#9684)
* Added clone api * Added new clone UI * Fixed challenge clone * Fixed lint and added mongo toObject * Removed clone field, fixed type, fixed challenge task query * Auto selected group * Accounted for group balance when creating challenge * Added check for if user is leader of guild * Added leader existence check * Added fix for leader and prizecost equal to
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
generateUser,
|
||||
generateGroup,
|
||||
} from '../../../../helpers/api-v3-integration.helper';
|
||||
|
||||
describe('POST /challenges/:challengeId/clone', () => {
|
||||
it('clones a challenge', async () => {
|
||||
const user = await generateUser({balance: 10});
|
||||
const group = await generateGroup(user);
|
||||
|
||||
const name = 'Test Challenge';
|
||||
const shortName = 'TC Label';
|
||||
const description = 'Test Description';
|
||||
const prize = 1;
|
||||
|
||||
const challenge = await user.post('/challenges', {
|
||||
group: group._id,
|
||||
name,
|
||||
shortName,
|
||||
description,
|
||||
prize,
|
||||
});
|
||||
const challengeTask = await user.post(`/tasks/challenge/${challenge._id}`, {
|
||||
text: 'test habit',
|
||||
type: 'habit',
|
||||
up: false,
|
||||
down: true,
|
||||
notes: 1976,
|
||||
});
|
||||
|
||||
const cloneChallengeResponse = await user.post(`/challenges/${challenge._id}/clone`, {
|
||||
group: group._id,
|
||||
name: `${name} cloned`,
|
||||
shortName,
|
||||
description,
|
||||
prize,
|
||||
});
|
||||
|
||||
expect(cloneChallengeResponse.clonedTasks[0].text).to.eql(challengeTask.text);
|
||||
expect(cloneChallengeResponse.clonedTasks[0]._id).to.not.eql(challengeTask._id);
|
||||
expect(cloneChallengeResponse.clonedTasks[0].challenge.id).to.eql(cloneChallengeResponse.clonedChallenge._id);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
<template lang="pug">
|
||||
.row
|
||||
challenge-modal(:cloning='cloning' v-on:updatedChallenge='updatedChallenge')
|
||||
challenge-modal(v-on:updatedChallenge='updatedChallenge')
|
||||
leave-challenge-modal(:challengeId='challenge._id')
|
||||
close-challenge-modal(:members='members', :challengeId='challenge._id')
|
||||
challenge-member-progress-modal(:memberId='progressMemberId', :challengeId='challenge._id')
|
||||
@@ -220,7 +220,6 @@ export default {
|
||||
memberIcon,
|
||||
calendarIcon,
|
||||
}),
|
||||
cloning: false,
|
||||
challenge: {},
|
||||
members: [],
|
||||
tasksByType: {
|
||||
@@ -261,10 +260,6 @@ export default {
|
||||
async beforeRouteUpdate (to, from, next) {
|
||||
this.searchId = to.params.challengeId;
|
||||
await this.loadChallenge();
|
||||
|
||||
if (this.$store.state.challengeOptions.cloning) {
|
||||
this.cloneTasks(this.$store.state.challengeOptions.tasksToClone);
|
||||
}
|
||||
next();
|
||||
},
|
||||
methods: {
|
||||
@@ -284,28 +279,6 @@ export default {
|
||||
|
||||
return cleansedTask;
|
||||
},
|
||||
cloneTasks (tasksToClone) {
|
||||
let clonedTasks = [];
|
||||
|
||||
for (let key in tasksToClone) {
|
||||
let tasksSection = tasksToClone[key];
|
||||
tasksSection.forEach(task => {
|
||||
let clonedTask = cloneDeep(task);
|
||||
clonedTask = this.cleanUpTask(clonedTask);
|
||||
clonedTask = taskDefaults(clonedTask);
|
||||
this.tasksByType[task.type].push(clonedTask);
|
||||
clonedTasks.push(clonedTask);
|
||||
});
|
||||
}
|
||||
|
||||
this.$store.dispatch('tasks:createChallengeTasks', {
|
||||
challengeId: this.searchId,
|
||||
tasks: clonedTasks,
|
||||
});
|
||||
|
||||
this.$store.state.challengeOptions.cloning = false;
|
||||
this.$store.state.challengeOptions.tasksToClone = [];
|
||||
},
|
||||
async loadChallenge () {
|
||||
this.challenge = await this.$store.dispatch('challenges:getChallenge', {challengeId: this.searchId});
|
||||
this.members = await this.$store.dispatch('members:getChallengeMembers', {challengeId: this.searchId});
|
||||
@@ -377,7 +350,6 @@ export default {
|
||||
},
|
||||
edit () {
|
||||
// @TODO: set working challenge
|
||||
this.cloning = false;
|
||||
this.$store.state.challengeOptions.workingChallenge = Object.assign({}, this.$store.state.challengeOptions.workingChallenge, this.challenge);
|
||||
this.$root.$emit('bv::show::modal', 'challenge-modal');
|
||||
},
|
||||
@@ -396,10 +368,9 @@ export default {
|
||||
window.location = `/api/v3/challenges/${this.searchId}/export/csv`;
|
||||
},
|
||||
cloneChallenge () {
|
||||
this.cloning = true;
|
||||
this.$store.state.challengeOptions.tasksToClone = this.tasksByType;
|
||||
this.$store.state.challengeOptions.workingChallenge = Object.assign({}, this.$store.state.challengeOptions.workingChallenge, this.challenge);
|
||||
this.$root.$emit('bv::show::modal', 'challenge-modal');
|
||||
this.$root.$emit('habitica:clone-challenge', {
|
||||
challenge: this.challenge,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -138,7 +138,7 @@ import { TAVERN_ID, MIN_SHORTNAME_SIZE_FOR_CHALLENGES, MAX_SUMMARY_SIZE_FOR_CHAL
|
||||
import { mapState } from 'client/libs/store';
|
||||
|
||||
export default {
|
||||
props: ['groupId', 'cloning'],
|
||||
props: ['groupId'],
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
@@ -224,6 +224,8 @@ export default {
|
||||
shortName: '',
|
||||
todos: [],
|
||||
},
|
||||
cloning: false,
|
||||
cloningChallengeId: '',
|
||||
showCategorySelect: false,
|
||||
categoryOptions,
|
||||
categoriesHashByKey,
|
||||
@@ -231,7 +233,18 @@ export default {
|
||||
groups: [],
|
||||
};
|
||||
},
|
||||
async mounted () {},
|
||||
mounted () {
|
||||
this.$root.$on('habitica:clone-challenge', (data) => {
|
||||
if (!data.challenge) return;
|
||||
this.cloning = true;
|
||||
this.cloningChallengeId = data.challenge._id;
|
||||
this.$store.state.challengeOptions.workingChallenge = Object.assign({}, this.$store.state.challengeOptions.workingChallenge, data.challenge);
|
||||
this.$root.$emit('bv::show::modal', 'challenge-modal');
|
||||
});
|
||||
},
|
||||
destroyed () {
|
||||
this.$root.$off('habitica:clone-challenge');
|
||||
},
|
||||
watch: {
|
||||
user () {
|
||||
if (!this.challenge) this.workingChallenge.leader = this.user._id;
|
||||
@@ -252,7 +265,6 @@ export default {
|
||||
if (this.creating) {
|
||||
return this.$t('createChallenge');
|
||||
}
|
||||
|
||||
return this.$t('editingChallenge');
|
||||
},
|
||||
charactersRemaining () {
|
||||
@@ -322,6 +334,8 @@ export default {
|
||||
if (!this.challenge) return;
|
||||
|
||||
this.workingChallenge = Object.assign({}, this.workingChallenge, this.challenge);
|
||||
// @TODO: Should we use a separate field? I think the API expects `group` but it is confusing
|
||||
this.workingChallenge.group = this.workingChallenge.group._id;
|
||||
this.workingChallenge.categories = [];
|
||||
|
||||
if (this.challenge.categories) {
|
||||
@@ -388,15 +402,40 @@ export default {
|
||||
let challengeDetails = clone(this.workingChallenge);
|
||||
challengeDetails.categories = serverCategories;
|
||||
|
||||
let challenge = await this.$store.dispatch('challenges:createChallenge', {challenge: challengeDetails});
|
||||
// @TODO: When to remove from guild instead?
|
||||
this.user.balance -= this.workingChallenge.prize / 4;
|
||||
let challenge;
|
||||
if (this.cloning) {
|
||||
challenge = await this.$store.dispatch('challenges:cloneChallenge', {
|
||||
challenge: challengeDetails,
|
||||
cloningChallengeId: this.cloningChallengeId,
|
||||
});
|
||||
this.cloningChallengeId = '';
|
||||
} else {
|
||||
challenge = await this.$store.dispatch('challenges:createChallenge', {challenge: challengeDetails});
|
||||
}
|
||||
|
||||
// Update Group Prize
|
||||
let challengeGroup = this.groups.find(group => {
|
||||
return group._id === this.workingChallenge.group;
|
||||
});
|
||||
|
||||
// @TODO: Share with server
|
||||
const prizeCost = this.workingChallenge.prize / 4;
|
||||
const challengeGroupLeader = challengeGroup.leader && challengeGroup.leader._id ? challengeGroup.leader._id : challengeGroup.leader;
|
||||
const userIsLeader = challengeGroupLeader === this.user._id;
|
||||
if (challengeGroup && userIsLeader && challengeGroup.balance > 0 && challengeGroup.balance >= prizeCost) {
|
||||
// Group pays for all of prize
|
||||
} else if (challengeGroup && userIsLeader && challengeGroup.balance > 0) {
|
||||
// User pays remainder of prize cost after group
|
||||
let remainder = prizeCost - challengeGroup.balance;
|
||||
this.user.balance -= remainder;
|
||||
} else {
|
||||
// User pays for all of prize
|
||||
this.user.balance -= prizeCost;
|
||||
}
|
||||
|
||||
this.$emit('createChallenge', challenge);
|
||||
this.resetWorkingChallenge();
|
||||
|
||||
if (this.cloning) this.$store.state.challengeOptions.cloning = true;
|
||||
|
||||
this.$root.$emit('bv::hide::modal', 'challenge-modal');
|
||||
this.$router.push(`/challenges/${challenge._id}`);
|
||||
},
|
||||
|
||||
@@ -10,6 +10,13 @@ export async function createChallenge (store, payload) {
|
||||
return newChallenge;
|
||||
}
|
||||
|
||||
export async function cloneChallenge (store, payload) {
|
||||
const response = await axios.post(`/api/v3/challenges/${payload.cloningChallengeId}/clone`, payload.challenge);
|
||||
const newChallenge = response.data.data.clonedChallenge;
|
||||
store.state.user.data.challenges.push(newChallenge._id);
|
||||
return newChallenge;
|
||||
}
|
||||
|
||||
export async function joinChallenge (store, payload) {
|
||||
let response = await axios.post(`/api/v3/challenges/${payload.challengeId}/join`);
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { authWithHeaders, authWithSession } from '../../middlewares/auth';
|
||||
import _ from 'lodash';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import omit from 'lodash/omit';
|
||||
import uuid from 'uuid';
|
||||
import { model as Challenge } from '../../models/challenge';
|
||||
import {
|
||||
model as Group,
|
||||
@@ -17,9 +20,81 @@ import {
|
||||
import * as Tasks from '../../models/task';
|
||||
import Bluebird from 'bluebird';
|
||||
import csvStringify from '../../libs/csvStringify';
|
||||
import {
|
||||
createTasks,
|
||||
} from '../../libs/taskManager';
|
||||
|
||||
const TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'dateCompleted', 'history', 'id', 'streak', 'createdAt', 'challenge'];
|
||||
|
||||
let api = {};
|
||||
|
||||
async function createChallenge (user, req, res) {
|
||||
let groupId = req.body.group;
|
||||
let prize = req.body.prize;
|
||||
|
||||
let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (!group.isMember(user)) throw new NotAuthorized(res.t('mustBeGroupMember'));
|
||||
|
||||
if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) {
|
||||
throw new NotAuthorized(res.t('onlyGroupLeaderChal'));
|
||||
}
|
||||
|
||||
if (group._id === TAVERN_ID && prize < 1) {
|
||||
throw new NotAuthorized(res.t('tavChalsMinPrize'));
|
||||
}
|
||||
|
||||
if (prize > 0) {
|
||||
let groupBalance = group.balance && group.leader === user._id ? group.balance : 0;
|
||||
let prizeCost = prize / 4;
|
||||
|
||||
if (prizeCost > user.balance + groupBalance) {
|
||||
throw new NotAuthorized(res.t('cantAfford'));
|
||||
}
|
||||
|
||||
if (groupBalance >= prizeCost) {
|
||||
// Group pays for all of prize
|
||||
group.balance -= prizeCost;
|
||||
} else if (groupBalance > 0) {
|
||||
// User pays remainder of prize cost after group
|
||||
let remainder = prizeCost - group.balance;
|
||||
group.balance = 0;
|
||||
user.balance -= remainder;
|
||||
} else {
|
||||
// User pays for all of prize
|
||||
user.balance -= prizeCost;
|
||||
}
|
||||
}
|
||||
|
||||
group.challengeCount += 1;
|
||||
|
||||
if (!req.body.summary) {
|
||||
req.body.summary = req.body.name;
|
||||
}
|
||||
req.body.leader = user._id;
|
||||
req.body.official = user.contributor.admin && req.body.official ? true : false;
|
||||
let challenge = new Challenge(Challenge.sanitize(req.body));
|
||||
|
||||
// First validate challenge so we don't save group if it's invalid (only runs sync validators)
|
||||
let challengeValidationErrors = challenge.validateSync();
|
||||
if (challengeValidationErrors) throw challengeValidationErrors;
|
||||
|
||||
// Add achievement if user's first challenge
|
||||
if (!user.achievements.joinedChallenge) {
|
||||
user.achievements.joinedChallenge = true;
|
||||
user.addNotification('CHALLENGE_JOINED_ACHIEVEMENT');
|
||||
}
|
||||
|
||||
let results = await Bluebird.all([challenge.save({
|
||||
validateBeforeSave: false, // already validate
|
||||
}), group.save()]);
|
||||
let savedChal = results[0];
|
||||
|
||||
await savedChal.syncToUser(user); // (it also saves the user)
|
||||
|
||||
return {savedChal, group};
|
||||
}
|
||||
|
||||
/**
|
||||
* @apiDefine ChallengeLeader Challenge Leader
|
||||
* The leader of the challenge can use this route.
|
||||
@@ -179,71 +254,10 @@ api.createChallenge = {
|
||||
|
||||
req.checkBody('group', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
const validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let groupId = req.body.group;
|
||||
let prize = req.body.prize;
|
||||
|
||||
let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (!group.isMember(user)) throw new NotAuthorized(res.t('mustBeGroupMember'));
|
||||
|
||||
if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) {
|
||||
throw new NotAuthorized(res.t('onlyGroupLeaderChal'));
|
||||
}
|
||||
|
||||
if (group._id === TAVERN_ID && prize < 1) {
|
||||
throw new NotAuthorized(res.t('tavChalsMinPrize'));
|
||||
}
|
||||
|
||||
if (prize > 0) {
|
||||
let groupBalance = group.balance && group.leader === user._id ? group.balance : 0;
|
||||
let prizeCost = prize / 4;
|
||||
|
||||
if (prizeCost > user.balance + groupBalance) {
|
||||
throw new NotAuthorized(res.t('cantAfford'));
|
||||
}
|
||||
|
||||
if (groupBalance >= prizeCost) {
|
||||
// Group pays for all of prize
|
||||
group.balance -= prizeCost;
|
||||
} else if (groupBalance > 0) {
|
||||
// User pays remainder of prize cost after group
|
||||
let remainder = prizeCost - group.balance;
|
||||
group.balance = 0;
|
||||
user.balance -= remainder;
|
||||
} else {
|
||||
// User pays for all of prize
|
||||
user.balance -= prizeCost;
|
||||
}
|
||||
}
|
||||
|
||||
group.challengeCount += 1;
|
||||
|
||||
if (!req.body.summary) {
|
||||
req.body.summary = req.body.name;
|
||||
}
|
||||
req.body.leader = user._id;
|
||||
req.body.official = user.contributor.admin && req.body.official ? true : false;
|
||||
let challenge = new Challenge(Challenge.sanitize(req.body));
|
||||
|
||||
// First validate challenge so we don't save group if it's invalid (only runs sync validators)
|
||||
let challengeValidationErrors = challenge.validateSync();
|
||||
if (challengeValidationErrors) throw challengeValidationErrors;
|
||||
|
||||
// Add achievement if user's first challenge
|
||||
if (!user.achievements.joinedChallenge) {
|
||||
user.achievements.joinedChallenge = true;
|
||||
user.addNotification('CHALLENGE_JOINED_ACHIEVEMENT');
|
||||
}
|
||||
|
||||
let results = await Bluebird.all([challenge.save({
|
||||
validateBeforeSave: false, // already validate
|
||||
}), group.save()]);
|
||||
let savedChal = results[0];
|
||||
|
||||
await savedChal.syncToUser(user); // (it also saves the user)
|
||||
const {savedChal, group} = await createChallenge(user, req, res);
|
||||
|
||||
let response = savedChal.toJSON();
|
||||
response.leader = { // the leader is the authenticated user
|
||||
@@ -784,4 +798,70 @@ api.selectChallengeWinner = {
|
||||
},
|
||||
};
|
||||
|
||||
function cleanUpTask (task) {
|
||||
let cleansedTask = omit(task, TASK_KEYS_TO_REMOVE);
|
||||
|
||||
// Copy checklists but reset to uncomplete and assign new id
|
||||
if (!cleansedTask.checklist) cleansedTask.checklist = [];
|
||||
cleansedTask.checklist.forEach((item) => {
|
||||
item.completed = false;
|
||||
item.id = uuid();
|
||||
});
|
||||
|
||||
if (cleansedTask.type !== 'reward') {
|
||||
delete cleansedTask.value;
|
||||
}
|
||||
|
||||
return cleansedTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/challenges/:challengeId/clone Clone a challenge
|
||||
* @apiName CloneChallenge
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiParam (Path) {UUID} challengeId The _id for the challenge to clone
|
||||
*
|
||||
* @apiSuccess {Object} challenge The cloned challenge
|
||||
*
|
||||
* @apiUse ChallengeNotFound
|
||||
*/
|
||||
api.cloneChallenge = {
|
||||
method: 'POST',
|
||||
url: '/challenges/:challengeId/clone',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
const challengeToClone = await Challenge.findOne({_id: req.params.challengeId}).exec();
|
||||
if (!challengeToClone) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
const {savedChal} = await createChallenge(user, req, res);
|
||||
|
||||
const challengeTasks = await Tasks.Task.find({
|
||||
'challenge.id': challengeToClone._id,
|
||||
userId: {$exists: false},
|
||||
}).exec();
|
||||
|
||||
const tasksToClone = challengeTasks.map(task => {
|
||||
let clonedTask = cloneDeep(task.toObject());
|
||||
let omittedTask = cleanUpTask(clonedTask);
|
||||
return omittedTask;
|
||||
});
|
||||
|
||||
const taskRequest = {
|
||||
body: tasksToClone,
|
||||
};
|
||||
|
||||
const clonedTasks = await createTasks(taskRequest, res, {user, challenge: savedChal});
|
||||
|
||||
res.respond(200, {clonedTasks, clonedChallenge: savedChal});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
|
||||
Reference in New Issue
Block a user