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:
Keith Holliday
2018-01-30 08:23:20 -07:00
committed by GitHub
parent ccf8e0b320
commit 4fe6c8db64
5 changed files with 244 additions and 104 deletions

View File

@@ -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);
});
});

View File

@@ -1,6 +1,6 @@
<template lang="pug"> <template lang="pug">
.row .row
challenge-modal(:cloning='cloning' v-on:updatedChallenge='updatedChallenge') challenge-modal(v-on:updatedChallenge='updatedChallenge')
leave-challenge-modal(:challengeId='challenge._id') leave-challenge-modal(:challengeId='challenge._id')
close-challenge-modal(:members='members', :challengeId='challenge._id') close-challenge-modal(:members='members', :challengeId='challenge._id')
challenge-member-progress-modal(:memberId='progressMemberId', :challengeId='challenge._id') challenge-member-progress-modal(:memberId='progressMemberId', :challengeId='challenge._id')
@@ -220,7 +220,6 @@ export default {
memberIcon, memberIcon,
calendarIcon, calendarIcon,
}), }),
cloning: false,
challenge: {}, challenge: {},
members: [], members: [],
tasksByType: { tasksByType: {
@@ -261,10 +260,6 @@ export default {
async beforeRouteUpdate (to, from, next) { async beforeRouteUpdate (to, from, next) {
this.searchId = to.params.challengeId; this.searchId = to.params.challengeId;
await this.loadChallenge(); await this.loadChallenge();
if (this.$store.state.challengeOptions.cloning) {
this.cloneTasks(this.$store.state.challengeOptions.tasksToClone);
}
next(); next();
}, },
methods: { methods: {
@@ -284,28 +279,6 @@ export default {
return cleansedTask; 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 () { async loadChallenge () {
this.challenge = await this.$store.dispatch('challenges:getChallenge', {challengeId: this.searchId}); this.challenge = await this.$store.dispatch('challenges:getChallenge', {challengeId: this.searchId});
this.members = await this.$store.dispatch('members:getChallengeMembers', {challengeId: this.searchId}); this.members = await this.$store.dispatch('members:getChallengeMembers', {challengeId: this.searchId});
@@ -377,7 +350,6 @@ export default {
}, },
edit () { edit () {
// @TODO: set working challenge // @TODO: set working challenge
this.cloning = false;
this.$store.state.challengeOptions.workingChallenge = Object.assign({}, this.$store.state.challengeOptions.workingChallenge, this.challenge); 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('bv::show::modal', 'challenge-modal');
}, },
@@ -396,10 +368,9 @@ export default {
window.location = `/api/v3/challenges/${this.searchId}/export/csv`; window.location = `/api/v3/challenges/${this.searchId}/export/csv`;
}, },
cloneChallenge () { cloneChallenge () {
this.cloning = true; this.$root.$emit('habitica:clone-challenge', {
this.$store.state.challengeOptions.tasksToClone = this.tasksByType; challenge: this.challenge,
this.$store.state.challengeOptions.workingChallenge = Object.assign({}, this.$store.state.challengeOptions.workingChallenge, this.challenge); });
this.$root.$emit('bv::show::modal', 'challenge-modal');
}, },
}, },
}; };

View File

@@ -138,7 +138,7 @@ import { TAVERN_ID, MIN_SHORTNAME_SIZE_FOR_CHALLENGES, MAX_SUMMARY_SIZE_FOR_CHAL
import { mapState } from 'client/libs/store'; import { mapState } from 'client/libs/store';
export default { export default {
props: ['groupId', 'cloning'], props: ['groupId'],
directives: { directives: {
markdown: markdownDirective, markdown: markdownDirective,
}, },
@@ -224,6 +224,8 @@ export default {
shortName: '', shortName: '',
todos: [], todos: [],
}, },
cloning: false,
cloningChallengeId: '',
showCategorySelect: false, showCategorySelect: false,
categoryOptions, categoryOptions,
categoriesHashByKey, categoriesHashByKey,
@@ -231,7 +233,18 @@ export default {
groups: [], 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: { watch: {
user () { user () {
if (!this.challenge) this.workingChallenge.leader = this.user._id; if (!this.challenge) this.workingChallenge.leader = this.user._id;
@@ -252,7 +265,6 @@ export default {
if (this.creating) { if (this.creating) {
return this.$t('createChallenge'); return this.$t('createChallenge');
} }
return this.$t('editingChallenge'); return this.$t('editingChallenge');
}, },
charactersRemaining () { charactersRemaining () {
@@ -322,6 +334,8 @@ export default {
if (!this.challenge) return; if (!this.challenge) return;
this.workingChallenge = Object.assign({}, this.workingChallenge, this.challenge); 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 = []; this.workingChallenge.categories = [];
if (this.challenge.categories) { if (this.challenge.categories) {
@@ -388,15 +402,40 @@ export default {
let challengeDetails = clone(this.workingChallenge); let challengeDetails = clone(this.workingChallenge);
challengeDetails.categories = serverCategories; challengeDetails.categories = serverCategories;
let challenge = await this.$store.dispatch('challenges:createChallenge', {challenge: challengeDetails}); let challenge;
// @TODO: When to remove from guild instead? if (this.cloning) {
this.user.balance -= this.workingChallenge.prize / 4; 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.$emit('createChallenge', challenge);
this.resetWorkingChallenge(); this.resetWorkingChallenge();
if (this.cloning) this.$store.state.challengeOptions.cloning = true;
this.$root.$emit('bv::hide::modal', 'challenge-modal'); this.$root.$emit('bv::hide::modal', 'challenge-modal');
this.$router.push(`/challenges/${challenge._id}`); this.$router.push(`/challenges/${challenge._id}`);
}, },

View File

@@ -10,6 +10,13 @@ export async function createChallenge (store, payload) {
return newChallenge; 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) { export async function joinChallenge (store, payload) {
let response = await axios.post(`/api/v3/challenges/${payload.challengeId}/join`); let response = await axios.post(`/api/v3/challenges/${payload.challengeId}/join`);

View File

@@ -1,5 +1,8 @@
import { authWithHeaders, authWithSession } from '../../middlewares/auth'; import { authWithHeaders, authWithSession } from '../../middlewares/auth';
import _ from 'lodash'; 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 Challenge } from '../../models/challenge';
import { import {
model as Group, model as Group,
@@ -17,9 +20,81 @@ import {
import * as Tasks from '../../models/task'; import * as Tasks from '../../models/task';
import Bluebird from 'bluebird'; import Bluebird from 'bluebird';
import csvStringify from '../../libs/csvStringify'; 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 = {}; 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 * @apiDefine ChallengeLeader Challenge Leader
* The leader of the challenge can use this route. * The leader of the challenge can use this route.
@@ -179,71 +254,10 @@ api.createChallenge = {
req.checkBody('group', res.t('groupIdRequired')).notEmpty(); req.checkBody('group', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors(); const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
let groupId = req.body.group; const {savedChal, group} = await createChallenge(user, req, res);
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)
let response = savedChal.toJSON(); let response = savedChal.toJSON();
response.leader = { // the leader is the authenticated user 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; module.exports = api;