diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index df92fe6a07..2ace1f7773 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -15,7 +15,7 @@ "cantDetachFb": "Account lacks another authentication method, can't detach Facebook.", "onlySocialAttachLocal": "Local auth can only be added to a social account.", "invalidReqParams": "Invalid request parameters.", - "taskIdRequired": "\"taskId\" must be a valid UUID", + "taskIdRequired": "\"taskId\" must be a valid UUID.", "taskNotFound": "Task not found.", "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", "cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.", @@ -41,5 +41,10 @@ "inviteMissingEmail": "Missing email address in invite.", "onlyGroupLeaderChal": "Only the group leader can create challenges", "pubChalsMinPrize": "Prize must be at least 1 Gem for public challenges.", - "cantAfford": "You can't afford this prize. Purchase more gems or lower the prize amount." + "cantAfford": "You can't afford this prize. Purchase more gems or lower the prize amount.", + "challengeIdRequired": "\"challengeId\" must be a valid UUID.", + "winnerIdRequired": "\"winnerId\" must be a valid UUID.", + "challengeNotFound": "Challenge not found.", + "onlyLeaderDeleteChal": "Only the challenge leader can delete it.", + "winnerNotFound": "Winner with id \"<%= userId %>\" not found or not part of the challenge." } diff --git a/website/src/controllers/api-v3/challenges.js b/website/src/controllers/api-v3/challenges.js index 6f17b8ce74..1d950446fa 100644 --- a/website/src/controllers/api-v3/challenges.js +++ b/website/src/controllers/api-v3/challenges.js @@ -2,11 +2,15 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; import cron from '../../middlewares/api-v3/cron'; import { model as Challenge } from '../../models/challenge'; import { model as Group } from '../../models/group'; +import { model as User } from '../../models/user'; import { NotFound, NotAuthorized, } from '../../libs/api-v3/errors'; +import shared from '../../../../common'; import * as Tasks from '../../models/task'; +import { txnEmail } from '../../libs/api-v3/email'; +import pushNotify from '../../libs/api-v3/pushNotifications'; import Q from 'q'; let api = {}; @@ -68,6 +72,8 @@ api.createChallenge = { } } + group.challengeCount += 1; + let tasks = req.body.tasks || []; // TODO validate req.body.leader = user._id; req.body.official = user.contributor.admin && req.body.official; @@ -81,14 +87,11 @@ api.createChallenge = { return task.save(); }); - toSave.unshift(challenge, group); return Q.all(toSave); }) .then(results => { let savedChal = results[0]; - - user.challenges.push(savedChal._id); // TODO save user only after group created, so that we can account for failed validation. Revisit in other places return savedChal.syncToUser(user) // (it also saves the user) .then(() => res.respond(201, savedChal)); }) @@ -135,4 +138,137 @@ api.getChallenges = { }, }; +// TODO everything here should be moved to a worker +// actually even for a worker it's probably just to big and will kill mongo +function _closeChal (challenge, broken = {}) { + let winner = broken.winner; + let brokenReason = broken.broken; + + let tasks = [ + // Delete the challenge + Challenge.remove({_id: challenge._id}).exec(), + // And it's tasks + Tasks.Task.remove({'challenge.id': challenge._id, userId: {$exists: false}}).exec(), + // Set the challenge tag to non-challenge status and remove the challenge from the user's challenges + User.update({ + challenges: {$in: [challenge._id]}, + 'tags._id': challenge._id, + }, { + $set: {'tags.$.challenge': false}, + $pull: {challenges: challenge._id}, + }, {multi: true}).exec(), + // Break users' tasks + Tasks.Task.update({ + 'challenge.id': challenge._id, + }, { + $set: { + 'challenge.broken': brokenReason, + 'challenge.winner': winner && winner.profile.name, + }, + }, {multi: true}).exec(), + // Update the challengeCount on the group + Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec(), + ]; + + // Refund the leader if the challenge is closed and the group not the tavern + if (challenge.group !== 'habitrpg' && brokenReason === 'CHALLENGE_DELETED') { + tasks.push(User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec()); + } + + // Award prize to winner and notify + if (winner) { + winner.achievements.challenges.push(challenge.name); + winner.balance += challenge.prize / 4; + tasks.push(winner.save().then(savedWinner => { + if (savedWinner.preferences.emailNotifications.wonChallenge !== false) { + txnEmail(savedWinner, 'won-challenge', [ + {name: 'CHALLENGE_NAME', content: challenge.name}, + ]); + } + + pushNotify.sendNotify(savedWinner, shared.i18n.t('wonChallenge'), challenge.name); // TODO translate + })); + } + + return Q.allSettled(tasks); // TODO look if allSettle could be useful somewhere else + // TODO catch and handle +} + +/** + * @api {delete} /challenges/:challengeId Delete a challenge + * @apiVersion 3.0.0 + * @apiName DeleteChallenge + * @apiGroup Challenge + * + * @apiSuccess {object} empty An empty object + */ +api.deleteChallenge = { + method: 'DELETE', + url: '/challenges/:challengeId', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + + req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Challenge.findOne({_id: req.params.challengeId}) + .exec() + .then(challenge => { + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + + res.respond(200, {}); + // Close channel in background + _closeChal(challenge, {broken: 'CHALLENGE_DELETED'}); + }) + .catch(next); + }, +}; + +/** + * @api {delete} /challenges/:challengeId Delete a challenge + * @apiVersion 3.0.0 + * @apiName DeleteChallenge + * @apiGroup Challenge + * + * @apiSuccess {object} empty An empty object + */ +api.selectChallengeWinner = { + method: 'POST', + url: '/challenges/:challengeId/selectWinner/:winnerId', + middlewares: [authWithHeaders(), cron], + handler (req, res, next) { + let user = res.locals.user; + let challenge; + + req.checkParams('challenge', res.t('challengeIdRequired')).notEmpty().isUUID(); + req.checkParams('winnerId', res.t('winnerIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) return next(validationErrors); + + Challenge.findOne({_id: req.params.challengeId}) + .exec() + .then(challengeFound => { + if (!challenge) throw new NotFound(res.t('challengeNotFound')); + if (challenge.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderDeleteChal')); + + challenge = challengeFound; + + return User.findOne({_id: req.params.winnerId}).exec(); + }) + .then(winner => { + if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.parama.winnerId})); + + res.respond(200, {}); + // Close channel in background + _closeChal(challenge, {broken: 'CHALLENGE_DELETED', winner}); + }) + .catch(next); + }, +}; + export default api; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index be77c97d70..99e217e401 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -7,7 +7,6 @@ import { NotAuthorized, BadRequest, } from '../../libs/api-v3/errors'; -import { model as Challenge } from '../../models/challenge'; import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; @@ -297,11 +296,11 @@ api.scoreTask = { // TODO test? if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') { Tasks.Task.findOne({ - _id: task.challenge.taskId + _id: task.challenge.taskId, }).exec() .then(chalTask => { chalTask.value += delta; - if (t.type == 'habit' || t.type == 'daily') { + if (chalTask.type === 'habit' || chalTask.type === 'daily') { chalTask.history.push({value: chalTask.value, date: Number(new Date())}); // TODO 1. treat challenges as subscribed users for preening 2. it's expensive to do it at every score - how to have it happen once like for cron? chalTask.history = preenHistory(user, chalTask.history); @@ -310,7 +309,7 @@ api.scoreTask = { return chalTask.save(); }); - //.catch(next) TODO what to do here + // .catch(next) TODO what to do here } }); }) diff --git a/website/src/controllers/pushNotifications.js b/website/src/controllers/pushNotifications.js index d1c728f365..860cfbf56a 100644 --- a/website/src/controllers/pushNotifications.js +++ b/website/src/controllers/pushNotifications.js @@ -1,3 +1,4 @@ +// TODO move to /api-v2 var api = module.exports; var _ = require('lodash'); var nconf = require('nconf');