API v3 [WIP] (#6144)
* Fixed more tests * Added tags into user service * Added api-v3 auth urls * v3: fix package.json * v3: fix package.json * Fixed auth tests. Updated Authctrl response * v3: remove newrelic config file in favour of env variables * v3: upgrade some deps * switch from Q to Bluebird * v3 fix tests with deferred * Removed extra consoles.log. Changed data.data to res.data * v3 fix tests and use coroutines instead of regenerator * v3: fix tests * v3: do not await a non promise * v3: q -> bluebird * Changed id param for registration response * Updated party query and create * Ensured login callback happens after user sync * Add challenges to groups. Fixed isMemberOfGuild check * Updated party and group tests * Fixed cron test * return user.id and send analytics event before changing page * fix trailing spaces * disable redirects * Api v3 party tavern fixes (#7191) * Added check if user is in party before query * Cached party query. Prevented party request when user is not in party. Updated Party create with no invites * Update tavern ctrl to use new promise * v3: misc fixes * Api v3 task fixes (#7193) * Update task view to use _id * Added try catch to user service ops calls * v3 client: saving after syncing is complete * Fixed test broken by part sync change (#7195) * v3: fix todo scoring and try to fix production testing problem * revert changes to mongoose config * mongoose: increase keepAlive * test mongoose fix * fix: Only apply captureStackTrace if it exists on the error object * v3: fix reminders with no startDate * mongoose: use options * chore(): rename website/src -> website/server and website/public -> website/client (#7199) * v3 fix GET /groups: return an error only if an invalid type is supplied not when there are 0 results (#7203) * [API v3] Fix calls to user.ops and deleting tags (#7204) * v3: fixes calls to user.ops from views and deleting tags * v3: fix tests that use user._statsComputed * Api v3 fixes continued (#7205) * Added timzeone offset back * Added APIToken back to settings page * Fixed fetch recent messages for party * Fixed returning group description * Fixed check if user is member of challenge * Fixed party members appearing in header * Updated get myGroups param to include public groups. Fixed isMemberOf group * Fixed hourglass purchase * Fixed challenge addding tasks on first creating * Updated tests to accomidate new changes * fix: Correct checklist on client Closes #7207 * fix: Pin eslint to 2.9 * minor improvements to cron code for clarity; fix inaccurate comments; add TODOs for rest-in-inn actions * fix: Add missing type param to equip call closes #7212 * rename and reword pubChalsMinPrize to reflect that it's only for Tavern challenges * allows players to send gems to each other; other minor related changes - fixes https://github.com/HabitRPG/habitrpg/issues/7227 * fix tests for /members/transfer-gems * fix: Set gems sent notification as translatable string * chore: Remove unusued variable * fix: Remove requirement on message paramter in transfer-gems * add a missing variable declaration * chore: clarify comments on cron code * fix: Correct client request from habitrpg -> tavern * update apidoc URL in package.json Closes #7222 * Fixed start party by invites * Updated spell casting to v3 * Fixed adding and removing tags on tasks * Fixed page reload on settings change * Fixed battle monsters with friends button * Loaded completed todos when done is clicked * chore: Reinstate floating version number for eslint babel-eslint regression fixed * Fixed reload tests * change "an user" to "a user" in comments and text (no code changes) (#7257) * fix: Alert user that drops were recieved * remove userServices.js from karma.conf - it's been moved to website/client/js/services * feat: Create debug update user route * fix: Correct set cron debug function * feat: Add make admin button to debug menu * lint: Add missing semicolons in test * fix: Temporarilly comment out udpate user debug route * v3: fix _tmp for crit and streakBonus * v3: execute all actions when leaving a solo party * v3 client: fix group not found when leaving party * v3 migration: fix challenge prize * v3 cron: only save modified tasks * v3: add CHALLENGE_TASK_NOT_FOUND to valid broken reasons * v3: fix tasks chart * v3 client: fix ability to leave challenge * v3 client: fix filtering by tag and correctly show tag tooltip * v3 common: fix tags tests * v3 client: support unlinking not found challenges tasks * v3: disable Bluebird warning for missing return, fixes #7269 * feat: Separate out update-user into set-cron and make-admin debug routes * chore: Disable make admin debug route for v3 prod testing * v3: misc fixes * v3: misc fixes * v3: fix adding multiple tasks * Fixed join/leave button updates * Queried only user groups to be available when creating challenges * Fixed bulk add tasks to challenge * Synced challenge tasks after leave and join. * Fixed default selected group * Fixed challenge member info. Fixed challenge winner selection * Fixed deleting challenge tasks * Fixed particiapting filter * v3 client: fix casting spells * v3: do not log sensitive data * v3: always save user when casting spell * v3: always save user when casting spell * v3: more fixes for spells * fix typos and missing information in apidocs - fixes https://github.com/HabitRPG/habitrpg/issues/7277 (#7282) * v3: add TODO for client side spells * feat: Add modify inventory debug menu * Fixed viewing user progress on challenge * Updated tests * fix: Fix quest progress button * fix incorrect Armoire test; remove unneeded param details from apidocs; disambiguate health potion * v3: fix stealth casting * v3: fix tasks saving and selection for rebirth reroll and reset (server-only) * v3: fix auto allocation * v3 client: misc fixes * rename buyPotion and buy-potion to buyHealthPotion and buy-health-potion; fix apidoc param error * Added delete for saved challenge task * Fixed member modal on front page * adjust text in apidocs for errors / clarity / consistency / standard terminology (no code changes) (#7298) * fix bug in Rebirth test, add new tests, adjust apidocs (#7293) * Updated task model to allow setting streak (#7306) * fix: Correct missing * in apidoc comments * Api v3 challenge fixes (#7287) * Fixed join/leave button updates * Queried only user groups to be available when creating challenges * Fixed bulk add tasks to challenge * Synced challenge tasks after leave and join. * Fixed default selected group * Fixed challenge member info. Fixed challenge winner selection * Fixed deleting challenge tasks * Fixed particiapting filter * Fixed viewing user progress on challenge * Updated tests * Added delete for saved challenge task * v3: fix sorting * [API v3] add CRON_SAFE_MODE (#7286) * add CRON_SAFE_MODE to example config file, fix some bugs, add an unrelated low-priority TODO * create CRON_SAFE_MODE to disable parts of cron for use after extended outage - fixes https://github.com/HabitRPG/habitrpg/issues/7161 * fix a bug with CRON_SAFE_MODE, remove duplicated code, remove completed TODO comment * fix check for CRON_SAFE_MODE * v3 client: fix typo * adjust debug menu Modify Inventory: hungrier pets, fewer Special items, "Hide" buttons * completed To-Dos: return the 30 most recent instead of 30 oldest (#7318) * v3 migration: fix createdAt date * adjust locales text, key names, and files for Rebirth, Reset, and Fortify / ReRoll for consistency with existing strings (#7321) * v3: fix unlinking multiple tasks * v3 fix releasing pets * v3: fix authenticating with apiUrl * v3: fix typo * v3 fix client tests for unlinking * v3 client: do not show start quest button when quest is active * v3 client: fix ability to send cards * v3 client: fix misc challenge issues * v3: fix notifications * v3 client: more user friendly errors * v3 client: only load completed todos once * v3 client: fix tests * v3: move TAVERN_ID to common code * fix: Provide default type and text for new task creation in score route * fix: Provide default history [] for habit in score route * fix: Add _legacyId prop to tasks to support non-uuid identifiers * chore: Change v3 migration to use _legacyId instead of legacyId * fix: check for _legacyId in tasks if id does not exist * refactor: Extract out finding task by id or _legacyId into a function * Api v3 party quest fixes (#7341) * Fix display of add challenge message when group challenges are empty * Fixed forced quest start to update quest without reload * Fixed needing to reload when accepting party invite * Fix group leave and join reload * Fixed leave current party and join another * Updated party tests * v3 client: remove console.log statement * v3: misc fixes * v3 client: fix predicatbale random * v3: info about API v3 * v3: update footer with links to developer resources * v3: support party invitation from email * v3 client: fix chat flagging * fix: Correct get tasks route to properly get todos (#7349) * move locales strings from api-v3.json to other locales files (#7347) * move locales strings from api-v3.json: authentication strings -> front.json * move locales strings from api-v3.json: authentication strings -> tasks.json * move locales strings from api-v3.json: authentication strings -> groups.json * move locales strings from api-v3.json: authentication strings -> challenge.json * move locales strings from api-v3.json: authentication strings -> groups.json (again) * move locales strings from api-v3.json: authentication strings -> quests.json * move locales strings from api-v3.json: authentication strings -> subscriber.json * move locales strings from api-v3.json: authentication strings -> spells.json * move locales strings from api-v3.json: authentication strings -> character.json * move locales strings from api-v3.json: authentication strings -> groups.json (PMs) * move locales strings from api-v3.json: authentication strings -> npc.json * move locales strings from api-v3.json: authentication strings -> pets.json * move locales strings from api-v3.json: authentication strings -> miscellaneous * move locales strings from api-v3.json: authentication strings -> contrib.json and settings.json * move locales strings from api-v3.json: delete unused string (invalidTasksOwner), delete api-v3.json, whitespace cleanup * v3 client: fix sticky header * v3: remove unused code * v3 client: correctly redirect after inviting * Removed v2 calls from views (#7351) * v3: fix tests for challenge export * v3: fallbackto authWithHeaders if wuthWithSession or authWithUrl fails * Added force cache update when fetching new messages (#7360) * v3: fetch whole user when booting from group tto avoid issues with pre save hook expecting all data * v3: misc fixes for payments * v3: limit fields of challenge tasks that can be updated * fix(tests): never connect to NODE_DB_URI for tests * Added new route for setting last cron and updated front end * v3: fix iap url * v3: fix build and ios IAP * Changed route to user set custom day start * v3: iap accessible under /api/v3, fixes to spells and groups invitations * v3: correctly use v3 routes in client * remove XP, GP when unticking a Daily with a completed checklist - fixes https://github.com/HabitRPG/habitrpg/issues/7246 * use natural language for error message about skills on challenge tasks (#7336), fix other gramatical error * Updated ui when user rejects a guild invite (#7368) * feat: complete custom day start route Closes #7363 * fix: Correct spelling of healAll skill fix: Correct sprite name of healAll skill * fix: Change all instances of spookDust -> spookySparkles * add dateCreated to all tasks; add empty challenge object to tasks that don't have one (#7386) * add plumilla to artists for Tangle Tree in Bailey message * Fixed quest drop modal (#7377) * Fixed quest drop modal * Fixed broken party test * [API v3] Maintenance Mode (#7367) * WIP(maintenance): maintenance * WIP(maintenance): working locale features * fix(maintenance): don't translate info page target * WIP(maintenance): start adding info page * fix(maintenance): linting * feat: Add container to maintenance info page * fix(maintenance): add config.json edits Also DRY variables for main vs info pages * fix(maintenance): linting * refactor(maintenance): further slim down variables * refactor: Remove unnecessary variables * fix: Correct string interpolation in maintenace view * feat: Dynamically add time to maintenance pages * maintenance mode: do not connect to mongodb * fix(maintenance): clean up timezones etc. * fix(maintenance): remove unneeded sprite * Tavern party challenges invites fix (#7394) * Added challenges and invitations to party * Loaded tavern challenges * Updated group and quest services tests * v3: implement automatic syncing if user is not up to date * Removed unnecessary fields when updating groups and challenges (#7395) * v3: do not saved populated user * v3: correctly return user subset * Chained party promises together (#7396) * v3: $w -> splitWhitespace * use bluebird * use babel polyfill * migration: fix items * update links for v3 * Updated shortname validation to support multiple browsers * Docs changes (#7401) * chore: Clarify transfer-gems documentation * chore: Clarify api status route documentation * chore: Mark webhooks as BETA * Added tags update route. Added sort to user service (#7381) * Added tags update route. Added sort to user service * Change update tasks route to reorder tasks * Fixed linting issue * Changed params for reorder tags route * Fixed not found tag and added test * Added password confirmation when deleteing account (#7402) * fix production logging * feat(commit): push * empty commit * feat(maintenance): post-downtime news & awards (#7406) * fix exporting avatar * second attempt at fixing exporting avatar * fix production logging * s3: convert moment to date instance * fix avatar sharing and caching (30 minutes) * fix: Correct missing parameter Closes #7433 * fix: Validate challenge shortname on server * adjust text strings - fixes https://github.com/HabitRPG/habitrpg/issues/5631 and also Short Name -> Tag Name
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
var _ = require('lodash');
|
||||
var validator = require('validator');
|
||||
var passport = require('passport');
|
||||
var shared = require('../../../../common');
|
||||
var async = require('async');
|
||||
var utils = require('../../libs/api-v2/utils');
|
||||
var nconf = require('nconf');
|
||||
var request = require('request');
|
||||
var FirebaseTokenGenerator = require('firebase-token-generator');
|
||||
import {
|
||||
model as User,
|
||||
} from '../../models/user';
|
||||
import {
|
||||
model as EmailUnsubscription,
|
||||
} from '../../models/emailUnsubscription';
|
||||
|
||||
var analytics = utils.analytics;
|
||||
var i18n = require('./../../libs/api-v2/i18n');
|
||||
|
||||
var isProd = nconf.get('NODE_ENV') === 'production';
|
||||
|
||||
var api = module.exports;
|
||||
|
||||
var NO_TOKEN_OR_UID = { err: shared.i18n.t('messageAuthMustIncludeTokens') };
|
||||
var NO_USER_FOUND = {err: shared.i18n.t('messageAuthNoUserFound') };
|
||||
var NO_SESSION_FOUND = { err: shared.i18n.t('messageAuthMustBeLoggedIn') };
|
||||
var accountSuspended = function(uuid){
|
||||
return {
|
||||
err: 'Account has been suspended, please contact leslie@habitica.com with your UUID ('+uuid+') for assistance.',
|
||||
code: 'ACCOUNT_SUSPENDED'
|
||||
};
|
||||
}
|
||||
|
||||
api.auth = function(req, res, next) {
|
||||
var uid = req.headers['x-api-user'];
|
||||
var token = req.headers['x-api-key'];
|
||||
if (!(uid && token)) return res.status(401).json(NO_TOKEN_OR_UID);
|
||||
User.findOne({_id: uid, apiToken: token}, function(err, user) {
|
||||
if (err) return next(err);
|
||||
if (_.isEmpty(user)) return res.status(401).json(NO_USER_FOUND);
|
||||
if (user.auth.blocked) return res.status(401).json(accountSuspended(user._id));
|
||||
|
||||
res.locals.wasModified = req.query._v ? +user._v !== +req.query._v : true;
|
||||
res.locals.user = user;
|
||||
req.session.userId = user._id;
|
||||
return next();
|
||||
});
|
||||
};
|
||||
|
||||
api.authWithSession = function(req, res, next) { //[todo] there is probably a more elegant way of doing this...
|
||||
if (!(req.session && req.session.userId))
|
||||
return res.status(401).json(NO_SESSION_FOUND);
|
||||
User.findOne({_id: req.session.userId}, function(err, user) {
|
||||
if (err) return next(err);
|
||||
if (_.isEmpty(user)) return res.status(401).json(NO_USER_FOUND);
|
||||
res.locals.user = user;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
// TODO passing auth params as query params is not safe as they are logged by browser history, ...
|
||||
api.authWithUrl = function(req, res, next) {
|
||||
User.findOne({_id:req.query._id, apiToken:req.query.apiToken}, function(err,user){
|
||||
if (err) return next(err);
|
||||
if (_.isEmpty(user)) return res.status(401).json(NO_USER_FOUND);
|
||||
res.locals.user = user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
api.registerUser = function(req, res, next) {
|
||||
var email = req.body.email && req.body.email.toLowerCase();
|
||||
var username = req.body.username;
|
||||
// Get the lowercase version of username to check that we do not have duplicates
|
||||
// So we can search for it in the database and then reject the choosen username if 1 or more results are found
|
||||
var lowerCaseUsername = username && username.toLowerCase();
|
||||
|
||||
async.auto({
|
||||
validate: function(cb) {
|
||||
if (!(username && req.body.password && email))
|
||||
return cb({code:401, err: shared.i18n.t('messageAuthCredentialsRequired')});
|
||||
if (req.body.password !== req.body.confirmPassword)
|
||||
return cb({code:401, err: shared.i18n.t('messageAuthPasswordMustMatch')});
|
||||
if (!validator.isEmail(email))
|
||||
return cb({code:401, err: ":email invalid"});
|
||||
cb();
|
||||
},
|
||||
findReg: function(cb) {
|
||||
// Search for duplicates using lowercase version of username
|
||||
User.findOne({$or:[{'auth.local.email': email}, {'auth.local.lowerCaseUsername': lowerCaseUsername}]}, {'auth.local':1}, cb);
|
||||
},
|
||||
findFacebook: function(cb){
|
||||
User.findOne({_id: req.headers['x-api-user'], apiToken: req.headers['x-api-key']}, {auth:1}, cb);
|
||||
},
|
||||
register: ['validate', 'findReg', 'findFacebook', function(cb, data) {
|
||||
if (data.findReg) {
|
||||
if (email === data.findReg.auth.local.email) return cb({code:401, err:"Email already taken"});
|
||||
// Check that the lowercase username isn't already used
|
||||
if (lowerCaseUsername === data.findReg.auth.local.lowerCaseUsername) return cb({code:401, err: shared.i18n.t('messageAuthUsernameTaken')});
|
||||
}
|
||||
var salt = utils.makeSalt();
|
||||
var newUser = {
|
||||
auth: {
|
||||
local: {
|
||||
username: username,
|
||||
lowerCaseUsername: lowerCaseUsername, // Store the lowercase version of the username
|
||||
email: email, // Store email as lowercase
|
||||
salt: salt,
|
||||
hashed_password: utils.encryptPassword(req.body.password, salt)
|
||||
},
|
||||
timestamps: {created: +new Date(), loggedIn: +new Date()}
|
||||
}
|
||||
};
|
||||
// existing user, allow them to add local authentication
|
||||
if (data.findFacebook) {
|
||||
data.findFacebook.auth.local = newUser.auth.local;
|
||||
data.findFacebook.registeredThrough = newUser.registeredThrough;
|
||||
data.findFacebook.save(cb);
|
||||
// new user, register them
|
||||
} else {
|
||||
newUser.preferences = newUser.preferences || {};
|
||||
newUser.preferences.language = req.language; // User language detected from browser, not saved
|
||||
var user = new User(newUser);
|
||||
|
||||
user.registeredThrough = req.headers['x-client'];
|
||||
var analyticsData = {
|
||||
category: 'acquisition',
|
||||
type: 'local',
|
||||
gaLabel: 'local',
|
||||
uuid: user._id,
|
||||
};
|
||||
analytics.track('register', analyticsData)
|
||||
|
||||
user.save(function(err, savedUser){
|
||||
if (err) return cb(err);
|
||||
// Clean previous email preferences
|
||||
EmailUnsubscription.remove({email: savedUser.auth.local.email}, function(){
|
||||
utils.txnEmail(savedUser, 'welcome');
|
||||
});
|
||||
cb.apply(cb, arguments);
|
||||
});
|
||||
}
|
||||
}]
|
||||
}, function(err, data) {
|
||||
if (err) return err.code ? res.status(err.code).json(err) : next(err);
|
||||
data.register[0].getTransformedData(function(err, userTransformed){
|
||||
if(err) return next(err);
|
||||
res.status(200).json(userTransformed);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
api.loginLocal = function(req, res, next) {
|
||||
var username = req.body.username;
|
||||
var password = req.body.password;
|
||||
if (!(username && password)) return res.status(401).json({err:'Missing :username or :password in request body, please provide both'});
|
||||
var login = validator.isEmail(username) ?
|
||||
{'auth.local.email':username.toLowerCase()} : // Emails are all lowercase
|
||||
{'auth.local.username':username}; // Use the username as the user typed it
|
||||
|
||||
User.findOne(login, {auth:1}, function(err, user){
|
||||
if (err) return next(err);
|
||||
if (!user) return res.status(401).json({err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\" on the habitica.com website's login form."});
|
||||
if (user.auth.blocked) return res.status(401).json(accountSuspended(user._id));
|
||||
// We needed the whole user object first so we can get his salt to encrypt password comparison
|
||||
User.findOne(
|
||||
{$and: [login, {'auth.local.hashed_password': utils.encryptPassword(password, user.auth.local.salt)}]}
|
||||
, {_id:1, apiToken:1}
|
||||
, function(err, user){
|
||||
if (err) return next(err);
|
||||
if (!user) return res.status(401).json({err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\" on the habitica.com website's login form."});
|
||||
res.json({id: user._id,token: user.apiToken});
|
||||
password = null;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
POST /user/auth/social
|
||||
*/
|
||||
api.loginSocial = function(req, res, next) {
|
||||
var access_token = req.body.authResponse.access_token,
|
||||
network = req.body.network;
|
||||
if (network!=='facebook')
|
||||
return res.status(401).json({err:"Only Facebook supported currently."});
|
||||
async.auto({
|
||||
profile: function (cb) {
|
||||
passport._strategies[network].userProfile(access_token, cb);
|
||||
},
|
||||
user: ['profile', function (cb, results) {
|
||||
var q = {};
|
||||
q['auth.' + network + '.id'] = results.profile.id;
|
||||
User.findOne(q, {_id: 1, apiToken: 1, auth: 1}, cb);
|
||||
}],
|
||||
register: ['profile', 'user', function (cb, results) {
|
||||
if (results.user) return cb(null, results.user);
|
||||
// Create new user
|
||||
var prof = results.profile;
|
||||
var user = {
|
||||
preferences: {
|
||||
language: req.language // User language detected from browser, not saved
|
||||
},
|
||||
auth: {
|
||||
timestamps: {created: +new Date(), loggedIn: +new Date()}
|
||||
}
|
||||
};
|
||||
user.auth[network] = prof;
|
||||
user = new User(user);
|
||||
user.registeredThrough = req.headers['x-client'];
|
||||
|
||||
user.save(function(err, savedUser){
|
||||
// Clean previous email preferences
|
||||
if(savedUser.auth.facebook.emails && savedUser.auth.facebook.emails[0] && savedUser.auth.facebook.emails[0].value){
|
||||
EmailUnsubscription.remove({email: savedUser.auth.facebook.emails[0].value}, function(){
|
||||
utils.txnEmail(savedUser, 'welcome');
|
||||
});
|
||||
}
|
||||
cb.apply(cb, arguments);
|
||||
});
|
||||
|
||||
var analyticsData = {
|
||||
category: 'acquisition',
|
||||
type: network,
|
||||
gaLabel: network,
|
||||
uuid: user._id,
|
||||
};
|
||||
analytics.track('register', analyticsData)
|
||||
}]
|
||||
}, function(err, results){
|
||||
if (err) return res.status(401).json({err: err.toString ? err.toString() : err});
|
||||
var acct = results.register[0] ? results.register[0] : results.register;
|
||||
if (acct.auth.blocked) return res.status(401).json(accountSuspended(acct._id));
|
||||
return res.status(200).json({id:acct._id, token:acct.apiToken});
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /user/auth/social
|
||||
*/
|
||||
api.deleteSocial = function(req,res,next){
|
||||
if (!res.locals.user.auth.local.username)
|
||||
return res.status(401).json({err:"Account lacks another authentication method, can't detach Facebook"});
|
||||
//TODO for some reason, the following gives https://gist.github.com/lefnire/f93eb306069b9089d123
|
||||
//res.locals.user.auth.facebook = null;
|
||||
//res.locals.user.auth.save(function(err, saved){
|
||||
User.update({_id:res.locals.user._id}, {$unset:{'auth.facebook':1}}, function(err){
|
||||
if (err) return next(err);
|
||||
res.sendStatus(200);
|
||||
})
|
||||
}
|
||||
|
||||
api.resetPassword = function(req, res, next){
|
||||
var email = req.body.email && req.body.email.toLowerCase(), // Emails are all lowercase
|
||||
salt = utils.makeSalt(),
|
||||
newPassword = utils.makeSalt(), // use a salt as the new password too (they'll change it later)
|
||||
hashed_password = utils.encryptPassword(newPassword, salt);
|
||||
|
||||
if(!email) return res.status(400).json({err: "Email not provided"});
|
||||
|
||||
User.findOne({'auth.local.email': email}, function(err, user){
|
||||
if (err) return next(err);
|
||||
if (!user) return res.status(401).json({err:"Sorry, we can't find a user registered with email " + email + "\n- Make sure your email address is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login."});
|
||||
user.auth.local.salt = salt;
|
||||
user.auth.local.hashed_password = hashed_password;
|
||||
utils.sendEmail({
|
||||
from: "Habitica <admin@habitica.com>",
|
||||
to: email,
|
||||
subject: "Password Reset for Habitica",
|
||||
text: "Password for " + user.auth.local.username + " has been reset to " + newPassword + " Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them. Log in at " + nconf.get('BASE_URL') + ". After you've logged in, head to " + nconf.get('BASE_URL') + "/#/options/settings/settings and change your password.",
|
||||
html: "Password for <strong>" + user.auth.local.username + "</strong> has been reset to <strong>" + newPassword + "</strong><br /><br />Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them.<br /><br />Log in at " + nconf.get('BASE_URL') + ". After you've logged in, head to " + nconf.get('BASE_URL') + "/#/options/settings/settings and change your password."
|
||||
});
|
||||
user.save(function(err){
|
||||
if(err) return next(err);
|
||||
res.send('New password sent to '+ email);
|
||||
email = salt = newPassword = hashed_password = null;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var invalidPassword = function(user, password){
|
||||
var hashed_password = utils.encryptPassword(password, user.auth.local.salt);
|
||||
if (hashed_password !== user.auth.local.hashed_password)
|
||||
return {code:401, err:"Incorrect password"};
|
||||
return false;
|
||||
}
|
||||
|
||||
api.changeUsername = function(req, res, next) {
|
||||
var user = res.locals.user;
|
||||
var username = req.body.username;
|
||||
var lowerCaseUsername = username && username.toLowerCase(); // we search for the lowercased version to intercept duplicates
|
||||
|
||||
if(!username) return res.status(400).json({err: "Username not provided"});
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
User.findOne({'auth.local.lowerCaseUsername': lowerCaseUsername}, {auth:1}, cb);
|
||||
},
|
||||
function(found, cb){
|
||||
if (found) return cb({code:401, err: "Username already taken"});
|
||||
if (invalidPassword(user, req.body.password)) return cb(invalidPassword(user, req.body.password));
|
||||
user.auth.local.username = username;
|
||||
user.auth.local.lowerCaseUsername = lowerCaseUsername;
|
||||
|
||||
user.save(cb);
|
||||
}
|
||||
], function(err){
|
||||
if (err) return err.code ? res.status(err.code).json(err) : next(err);
|
||||
res.sendStatus(200);
|
||||
})
|
||||
}
|
||||
|
||||
api.changeEmail = function(req, res, next){
|
||||
var email = req.body.email && req.body.email.toLowerCase(); // emails are all lowercase
|
||||
if(!email) return res.status(400).json({err: "Email not provided"});
|
||||
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
User.findOne({'auth.local.email': email}, {auth:1}, cb);
|
||||
},
|
||||
function(found, cb){
|
||||
if(found) return cb({code:401, err: shared.i18n.t('messageAuthEmailTaken')});
|
||||
if (invalidPassword(res.locals.user, req.body.password)) return cb(invalidPassword(res.locals.user, req.body.password));
|
||||
res.locals.user.auth.local.email = email;
|
||||
res.locals.user.save(cb);
|
||||
}
|
||||
], function(err){
|
||||
if (err) return err.code ? res.status(err.code).json(err) : next(err);
|
||||
res.sendStatus(200);
|
||||
})
|
||||
}
|
||||
|
||||
api.changePassword = function(req, res, next) {
|
||||
var user = res.locals.user,
|
||||
oldPassword = req.body.oldPassword,
|
||||
newPassword = req.body.newPassword,
|
||||
confirmNewPassword = req.body.confirmNewPassword;
|
||||
|
||||
if (newPassword != confirmNewPassword)
|
||||
return res.status(401).json({err: "Password & Confirm don't match"});
|
||||
|
||||
var salt = user.auth.local.salt,
|
||||
hashed_old_password = utils.encryptPassword(oldPassword, salt),
|
||||
hashed_new_password = utils.encryptPassword(newPassword, salt);
|
||||
|
||||
if (hashed_old_password !== user.auth.local.hashed_password)
|
||||
return res.status(401).json({err:"Old password doesn't match"});
|
||||
|
||||
user.auth.local.hashed_password = hashed_new_password;
|
||||
user.save(function(err, saved){
|
||||
if (err) next(err);
|
||||
res.sendStatus(200);
|
||||
})
|
||||
};
|
||||
|
||||
// DISABLED FOR API v2
|
||||
/*var firebaseTokenGeneratorInstance = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET'));
|
||||
api.getFirebaseToken = function(req, res, next) {
|
||||
var user = res.locals.user;
|
||||
// Expires 24 hours after now (60*60*24*1000) (in milliseconds)
|
||||
var expires = new Date();
|
||||
expires.setTime(expires.getTime() + 86400000);
|
||||
|
||||
var token = firebaseTokenGeneratorInstance
|
||||
.createToken({
|
||||
uid: user._id,
|
||||
isHabiticaUser: true
|
||||
}, {
|
||||
expires: expires
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
token: token,
|
||||
expires: expires
|
||||
});
|
||||
};*/
|
||||
|
||||
// DISABLED FOR API v2
|
||||
/*api.setupPassport = function(router) {
|
||||
|
||||
router.get('/logout', i18n.getUserLanguage, function(req, res) {
|
||||
req.logout();
|
||||
delete req.session.userId;
|
||||
res.redirect('/');
|
||||
})
|
||||
|
||||
};*/
|
||||
@@ -0,0 +1,428 @@
|
||||
// @see ../routes for routing
|
||||
|
||||
var _ = require('lodash');
|
||||
var nconf = require('nconf');
|
||||
var async = require('async');
|
||||
var shared = require('../../../../common');
|
||||
import {
|
||||
model as User,
|
||||
} from '../../models/user';
|
||||
import {
|
||||
model as Group,
|
||||
basicFields as basicGroupFields,
|
||||
TAVERN_ID,
|
||||
} from '../../models/group';
|
||||
import {
|
||||
model as Challenge,
|
||||
} from '../../models/challenge';
|
||||
import * as Tasks from '../../models/task';
|
||||
var logging = require('./../../libs/api-v2/logging');
|
||||
var csvStringify = require('csv-stringify');
|
||||
var utils = require('../../libs/api-v2/utils');
|
||||
var api = module.exports;
|
||||
var pushNotify = require('./pushNotifications');
|
||||
import Bluebird from 'bluebird';
|
||||
import v3MembersController from '../api-v3/members';
|
||||
/*
|
||||
------------------------------------------------------------------------
|
||||
Challenges
|
||||
------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
var nameFields = 'profile.name';
|
||||
|
||||
api.list = async function(req, res, next) {
|
||||
try {
|
||||
var user = res.locals.user;
|
||||
|
||||
let challenges = await Challenge.find({
|
||||
$or: [
|
||||
{_id: {$in: user.challenges}}, // Challenges where the user is participating
|
||||
{group: {$in: user.getGroups()}}, // Challenges in groups where I'm a member
|
||||
{leader: user._id}, // Challenges where I'm the leader
|
||||
],
|
||||
_id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit
|
||||
})
|
||||
.sort('-official -timestamp')
|
||||
// .populate('group', basicGroupFields)
|
||||
// .populate('leader', nameFields)
|
||||
.exec();
|
||||
|
||||
let resChals = challenges.map(challenge => {
|
||||
let obj = challenge.toJSON();
|
||||
|
||||
obj._isMember = user.challenges.indexOf(challenge._id) !== -1;
|
||||
return obj;
|
||||
});
|
||||
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
await Bluebird.all(resChals.map((chal, index) => {
|
||||
return Bluebird.all([
|
||||
User.findById(chal.leader).select(nameFields).exec(),
|
||||
Group.findById(chal.group).select(basicGroupFields).exec(),
|
||||
]).then(populatedData => {
|
||||
resChals[index].leader = populatedData[0] ? populatedData[0].toJSON({minimize: true}) : null;
|
||||
resChals[index].group = populatedData[1] ? populatedData[1].toJSON({minimize: true}) : null;
|
||||
});
|
||||
}));
|
||||
|
||||
res.json(resChals);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// GET
|
||||
api.get = async function(req, res, next) {
|
||||
try {
|
||||
let user = res.locals.user;
|
||||
let challengeId = req.params.cid;
|
||||
|
||||
let challenge = await Challenge.findById(challengeId)
|
||||
// Don't populate the group as we'll fetch it manually later
|
||||
// .populate('leader', nameFields)
|
||||
.exec();
|
||||
if (!challenge) return res.status(404).json({err: 'Challenge ' + req.params.cid + ' not found'});
|
||||
|
||||
// Fetching basic group data
|
||||
let group = await Group.getGroup({user, groupId: challenge.group, optionalMembership: true});
|
||||
if (!group || !challenge.canView(user, group)) return res.status(404).json({err: 'Challenge ' + req.params.cid + ' not found'});
|
||||
|
||||
let leaderRes = await User.findById(challenge.leader).select('profile.name').exec();
|
||||
leaderRes = leaderRes ? leaderRes.toJSON({minimize: true}) : null;
|
||||
|
||||
challenge.getTransformedData({
|
||||
populateMembers: 'profile.name',
|
||||
cb (err, transformedChal) {
|
||||
transformedChal.group = group.toJSON({minimize: true});
|
||||
transformedChal.leader = leaderRes;
|
||||
transformedChal._isMember = user.challenges.indexOf(transformedChal._id) !== -1;
|
||||
res.json(transformedChal);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
api.csv = function(req, res, next) {
|
||||
var cid = req.params.cid;
|
||||
req.params.challengeId = cid;
|
||||
v3MembersController.exportChallengeCsv.handler(req, res, next).catch(next);
|
||||
}
|
||||
|
||||
api.getMember = function(req, res, next) {
|
||||
var cid = req.params.cid;
|
||||
var uid = req.params.uid;
|
||||
|
||||
req.params.memberId = uid;
|
||||
req.params.challengeId = cid;
|
||||
v3MembersController.getChallengeMemberProgress.handler(req, res, next)
|
||||
.then(result => {
|
||||
let newResult = {
|
||||
profile: {
|
||||
name: result.profile.name,
|
||||
},
|
||||
habits: [],
|
||||
dailys: [],
|
||||
todos: [],
|
||||
rewards: [],
|
||||
};
|
||||
|
||||
let tasks = result.tasks;
|
||||
tasks.forEach(task => {
|
||||
let taskObj = task.toJSONV2();
|
||||
newResult[taskObj.type + 's'].push(taskObj);
|
||||
});
|
||||
|
||||
res.json(newResult);
|
||||
})
|
||||
.catch(next);
|
||||
}
|
||||
|
||||
// CREATE
|
||||
api.create = async function(req, res, next){
|
||||
try {
|
||||
var user = res.locals.user;
|
||||
|
||||
let groupId = req.body.group;
|
||||
let prize = req.body.prize;
|
||||
|
||||
let group = await Group.getGroup({user, groupId, fields: '-chat', mustBeMember: true});
|
||||
if (!group) return res.status(404).json({err:"Group." + req.body.group + " not found"});
|
||||
if (!group.isMember(user)) return res.status(404).json({err:"Group." + req.body.group + " not found"});
|
||||
|
||||
if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) {
|
||||
return res.status(401).json({err:"Only the group leader can create challenges"});
|
||||
}
|
||||
|
||||
if (group._id === TAVERN_ID && prize < 1) {
|
||||
return res.status(401).json({err: 'Prize must be at least 1 Gem for public challenges.'})
|
||||
}
|
||||
|
||||
if (prize > 0) {
|
||||
let groupBalance = group.balance && group.leader === user._id ? group.balance : 0;
|
||||
let prizeCost = prize / 4;
|
||||
|
||||
if (prizeCost > user.balance + groupBalance) {
|
||||
return res.status(401).json({err: 'You can\'t afford this prize. Purchase more gems or lower the prize amount.'});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
req.body.leader = user._id;
|
||||
req.body.official = user.contributor.admin && req.body.official;
|
||||
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;
|
||||
|
||||
req.body.habits = req.body.habits || [];
|
||||
req.body.todos = req.body.todos || [];
|
||||
req.body.dailys = req.body.dailys || [];
|
||||
req.body.rewards = req.body.rewards || [];
|
||||
|
||||
var chalTasks = req.body.habits.concat(req.body.rewards)
|
||||
.concat(req.body.dailys).concat(req.body.todos)
|
||||
.map(v2Task => Tasks.Task.fromJSONV2(v2Task));
|
||||
|
||||
chalTasks = chalTasks.map(function(task) {
|
||||
var newTask = new Tasks[task.type](Tasks.Task.sanitize(task));
|
||||
newTask.challenge.id = challenge._id;
|
||||
return newTask.save();
|
||||
});
|
||||
|
||||
let results = await Bluebird.all([challenge.save({
|
||||
validateBeforeSave: false, // already validated
|
||||
}), group.save()].concat(chalTasks));
|
||||
let savedChal = results[0];
|
||||
|
||||
await savedChal.syncToUser(user); // (it also saves the user)
|
||||
|
||||
savedChal.getTransformedData({
|
||||
cb (err, transformedChal) {
|
||||
res.status(201).json(transformedChal);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// UPDATE
|
||||
api.update = function(req, res, next){
|
||||
var cid = req.params.cid;
|
||||
var user = res.locals.user;
|
||||
var before;
|
||||
var updatedTasks;
|
||||
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
// We first need the original challenge data, since we're going to compare against new & decide to sync users
|
||||
Challenge.findById(cid, cb);
|
||||
},
|
||||
function(chal, cb){
|
||||
if(!chal) return cb({chal: null});
|
||||
|
||||
chal.getTasks(function(err, tasks){
|
||||
cb(err, {
|
||||
chal: chal,
|
||||
tasks: tasks
|
||||
});
|
||||
});
|
||||
},
|
||||
function(_before, cb) {
|
||||
if (!_before.chal) return cb('Challenge ' + cid + ' not found');
|
||||
if (_before.chal.leader != user._id && !user.contributor.admin) return cb({code: 401, err: shared.i18n.t('noPermissionEditChallenge', req.language)});
|
||||
// Update the challenge, since syncing will need the updated challenge. But store `before` we're going to do some
|
||||
// before-save / after-save comparison to determine if we need to sync to users
|
||||
before = {chal: _before.chal, tasks: _before.tasks};
|
||||
var chalAttrs = _.pick(req.body, 'name shortName description date'.split(' '));
|
||||
async.parallel({
|
||||
chal: function(cb1){
|
||||
Challenge.findByIdAndUpdate(cid, {$set:chalAttrs}, {new: true}, cb1);
|
||||
},
|
||||
tasks: function(cb1) {
|
||||
// Convert to map of {id: task} so we can easily match them
|
||||
var _beforeClonedTasks = _before.tasks;
|
||||
updatedTasks = _.object(_.pluck(_beforeClonedTasks, '_id'), _beforeClonedTasks);
|
||||
var newTasks = req.body.habits.concat(req.body.dailys)
|
||||
.concat(req.body.todos).concat(req.body.rewards);
|
||||
|
||||
var newTasksObj = _.object(_.pluck(newTasks, '_id'), newTasks);
|
||||
async.forEachOf(newTasksObj, function(newTask, taskId, cb2){
|
||||
// some properties can't be changed
|
||||
newTask = Tasks.Task.sanitize(newTask);
|
||||
// we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances?
|
||||
_.assign(updatedTasks[taskId], shared.ops.updateTask(updatedTasks[taskId].toObject(), {body: newTask}));
|
||||
_before.chal.updateTask(updatedTasks[taskId]).then(cb2).catch(cb2);
|
||||
}, cb1);
|
||||
}
|
||||
}, cb);
|
||||
},
|
||||
], function(err, saved){
|
||||
if(err) {
|
||||
return err.code ? res.json(err.code, err) : next(err);
|
||||
}
|
||||
|
||||
saved.chal.getTransformedData({cb: function(err, newChal){
|
||||
if(err) return next(err);
|
||||
res.json(newChal);
|
||||
}})
|
||||
cid = user = before = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete & close
|
||||
*/
|
||||
api.delete = async function(req, res, next){
|
||||
try {
|
||||
var user = res.locals.user;
|
||||
var cid = req.params.cid;
|
||||
|
||||
let challenge = await Challenge.findOne({_id: req.params.cid}).exec();
|
||||
if (!challenge) return next('Challenge ' + cid + ' not found');
|
||||
if (!challenge.canModify(user)) return next(shared.i18n.t('noPermissionCloseChallenge'));
|
||||
|
||||
// Close channel in background, some ops are run in the background without `await`ing
|
||||
await challenge.closeChal({broken: 'CHALLENGE_DELETED'});
|
||||
res.sendStatus(200);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select Winner & Close
|
||||
*/
|
||||
api.selectWinner = async function(req, res, next) {
|
||||
try {
|
||||
if (!req.query.uid) return res.status(401).json({err: 'Must select a winner'});
|
||||
|
||||
let challenge = await Challenge.findOne({_id: req.params.cid}).exec();
|
||||
if (!challenge) return next('Challenge ' + req.params.cid + ' not found');
|
||||
if (!challenge.canModify(res.locals.user)) return next(shared.i18n.t('noPermissionCloseChallenge'));
|
||||
|
||||
let winner = await User.findOne({_id: req.query.uid}).exec();
|
||||
if (!winner || winner.challenges.indexOf(challenge._id) === -1) return next('Winner ' + req.query.uid + ' not found.');
|
||||
|
||||
// Close channel in background, some ops are run in the background without `await`ing
|
||||
await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner});
|
||||
res.respond(200, {});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
api.join = async function(req, res, next){
|
||||
try {
|
||||
var user = res.locals.user;
|
||||
var cid = req.params.cid;
|
||||
|
||||
let challenge = await Challenge.findOne({ _id: cid });
|
||||
if (!challenge) return next(shared.i18n.t('challengeNotFound'));
|
||||
if (challenge.isMember(user)) return next(shared.i18n.t('userAlreadyInChallenge'));
|
||||
|
||||
let group = await Group.getGroup({user, groupId: challenge.group, optionalMembership: true});
|
||||
if (!group || !challenge.hasAccess(user, group)) return next(shared.i18n.t('challengeNotFound'));
|
||||
|
||||
challenge.memberCount += 1;
|
||||
|
||||
// Add all challenge's tasks to user's tasks and save the challenge
|
||||
await Bluebird.all([challenge.syncToUser(user), challenge.save()]);
|
||||
|
||||
challenge.getTransformedData({
|
||||
cb (err, transformedChal) {
|
||||
transformedChal._isMember = true;
|
||||
res.json(transformedChal);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
}
|
||||
|
||||
api.leave = async function(req, res, next){
|
||||
try {
|
||||
var user = res.locals.user;
|
||||
var cid = req.params.cid;
|
||||
// whether or not to keep challenge's tasks. strictly default to true if "keep-all" isn't provided
|
||||
var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all';
|
||||
|
||||
let challenge = await Challenge.findOne({ _id: cid });
|
||||
if (!challenge) return next(shared.i18n.t('challengeNotFound'));
|
||||
|
||||
let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy'});
|
||||
if (!group || !challenge.canView(user, group)) return next(shared.i18n.t('challengeNotFound'));
|
||||
|
||||
if (!challenge.isMember(user)) return next(shared.i18n.t('challengeMemberNotFound'));
|
||||
|
||||
challenge.memberCount -= 1;
|
||||
|
||||
// Unlink challenge's tasks from user's tasks and save the challenge
|
||||
await Bluebird.all([challenge.unlinkTasks(user, keep), challenge.save()]);
|
||||
|
||||
challenge.getTransformedData({
|
||||
cb (err, transformedChal) {
|
||||
transformedChal._isMember = false;
|
||||
res.json(transformedChal);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
}
|
||||
|
||||
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
|
||||
|
||||
api.unlink = async function(req, res, next) {
|
||||
try {
|
||||
var user = res.locals.user;
|
||||
var tid = req.params.id;
|
||||
var cid;
|
||||
if (!req.query.keep)
|
||||
return res.status(400).json({err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'});
|
||||
|
||||
let keep = req.query.keep;
|
||||
let task = await Tasks.Task.findOne({
|
||||
_id: tid,
|
||||
userId: user._id,
|
||||
}).exec();
|
||||
|
||||
if (!task) return next(shared.i18n.t('taskNotFound'));
|
||||
if (!task.challenge.id) return next(shared.i18n.t('cantOnlyUnlinkChalTask'));
|
||||
|
||||
cid = task.challenge.id;
|
||||
if (keep === 'keep') {
|
||||
task.challenge = {};
|
||||
await task.save();
|
||||
} else { // remove
|
||||
if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if
|
||||
removeFromArray(user.tasksOrder[`${task.type}s`], tid);
|
||||
await Bluebird.all([user.save(), task.remove()]);
|
||||
} else {
|
||||
await task.remove();
|
||||
}
|
||||
}
|
||||
|
||||
res.sendStatus(200);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
var _ = require('lodash');
|
||||
import {
|
||||
model as Coupon,
|
||||
} from '../../models/coupon';
|
||||
var api = module.exports;
|
||||
var csvStringify = require('csv-stringify');
|
||||
var async = require('async');
|
||||
|
||||
api.ensureAdmin = function(req, res, next) {
|
||||
if (!res.locals.user.contributor.sudo) return res.status(401).json({err:"You don't have admin access"});
|
||||
next();
|
||||
}
|
||||
|
||||
api.generateCoupons = function(req,res,next) {
|
||||
let count = Number(req.query.count);
|
||||
Coupon.generate(req.params.event, count, function(err){
|
||||
if(err) return next(err);
|
||||
res.sendStatus(200);
|
||||
});
|
||||
}
|
||||
|
||||
api.getCoupons = function(req,res,next) {
|
||||
var options = {sort:'seq'};
|
||||
if (req.query.limit) options.limit = req.query.limit;
|
||||
if (req.query.skip) options.skip = req.query.skip;
|
||||
Coupon.find({},{}, options, function(err,coupons){
|
||||
let output = [['code']].concat(_.map(coupons, function(c){
|
||||
return [c._id];
|
||||
}))
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-disposition': 'attachment; filename=habitica-coupons.csv',
|
||||
});
|
||||
csvStringify(output, (err, csv) => {
|
||||
if (err) return next(err);
|
||||
res.status(200).send(csv);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
api.enterCode = function(req,res,next) {
|
||||
Coupon.apply(res.locals.user,req.params.code,function(err,user){
|
||||
if (err) return res.status(400).json({err:err});
|
||||
res.json(user);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
var _ = require('lodash');
|
||||
var express = require('express');
|
||||
var csvStringify = require('csv-stringify');
|
||||
var nconf = require('nconf');
|
||||
var moment = require('moment');
|
||||
var js2xmlparser = require("js2xmlparser");
|
||||
var pd = require('pretty-data').pd;
|
||||
import {
|
||||
model as User,
|
||||
} from '../../models/user';
|
||||
|
||||
// Avatar screenshot/static-page includes
|
||||
//var Pageres = require('pageres'); //https://github.com/sindresorhus/pageres
|
||||
//var AWS = require('aws-sdk');
|
||||
//AWS.config.update({accessKeyId: nconf.get("S3:accessKeyId"), secretAccessKey: nconf.get("S3:secretAccessKey")});
|
||||
//var s3Stream = require('s3-upload-stream')(new AWS.S3()); //https://github.com/nathanpeck/s3-upload-stream
|
||||
//var bucket = nconf.get("S3:bucket");
|
||||
//var request = require('request');
|
||||
|
||||
/*
|
||||
------------------------------------------------------------------------
|
||||
Data export
|
||||
------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
var dataexport = module.exports;
|
||||
|
||||
dataexport.history = function(req, res) {
|
||||
var user = res.locals.user;
|
||||
var output = [
|
||||
["Task Name", "Task ID", "Task Type", "Date", "Value"]
|
||||
];
|
||||
_.each(user.tasks, function(task) {
|
||||
_.each(task.history, function(history) {
|
||||
output.push([
|
||||
task.text,
|
||||
task.id,
|
||||
task.type,
|
||||
moment(history.date).format("MM-DD-YYYY HH:mm:ss"),
|
||||
history.value
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-disposition': 'attachment; filename=habitica-tasks-history.csv',
|
||||
});
|
||||
|
||||
csvStringify(output, (err, csv) => {
|
||||
if (err) return next(err);
|
||||
res.status(200).send(csv);
|
||||
});
|
||||
};
|
||||
|
||||
var userdata = function(user) {
|
||||
if(user.auth && user.auth.local) {
|
||||
delete user.auth.local.salt;
|
||||
delete user.auth.local.hashed_password;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
dataexport.leanuser = function(req, res, next) {
|
||||
User.findOne({_id: res.locals.user._id}).lean().exec(function(err, user) {
|
||||
if (err) return res.status(500).json({err: err});
|
||||
if (_.isEmpty(user)) return res.status(401).json(NO_USER_FOUND);
|
||||
res.locals.user = user;
|
||||
return next();
|
||||
});
|
||||
};
|
||||
|
||||
dataexport.userdata = {
|
||||
xml: function(req, res) {
|
||||
var user = userdata(res.locals.user);
|
||||
return res.xml({data: JSON.stringify(user), rootname: 'user'});
|
||||
},
|
||||
json: function(req, res) {
|
||||
var user = userdata(res.locals.user);
|
||||
return res.jsonstring(user);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
------------------------------------------------------------------------
|
||||
Express Extensions (should be refactored into a module)
|
||||
------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
var expressres = express.response || http.ServerResponse.prototype;
|
||||
|
||||
expressres.xml = function(obj, headers, status) {
|
||||
var body = '';
|
||||
this.charset = this.charset || 'utf-8';
|
||||
this.header('Content-Type', 'text/xml');
|
||||
this.header('Content-Disposition', 'attachment');
|
||||
body = pd.xml(js2xmlparser(obj.rootname,obj.data));
|
||||
return this.send(body, headers, status);
|
||||
};
|
||||
|
||||
expressres.jsonstring = function(obj, headers, status) {
|
||||
var body = '';
|
||||
this.charset = this.charset || 'utf-8';
|
||||
this.header('Content-Type', 'application/json');
|
||||
this.header('Content-Disposition', 'attachment');
|
||||
body = pd.json(JSON.stringify(obj));
|
||||
return this.send(body, headers, status);
|
||||
};
|
||||
|
||||
/*
|
||||
------------------------------------------------------------------------
|
||||
Static page and image screenshot of avatar
|
||||
------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
|
||||
dataexport.avatarPage = function(req, res) {
|
||||
User.findById(req.params.uuid).select('stats profile items achievements preferences backer contributor').exec(function(err, user){
|
||||
res.render('avatar-static', {
|
||||
title: user.profile.name,
|
||||
env: _.defaults({user:user}, res.locals.habitrpg)
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
dataexport.avatarImage = function(req, res, next) {
|
||||
var filename = 'avatars/'+req.params.uuid+'.png';
|
||||
request.head('https://'+bucket+'.s3.amazonaws.com/'+filename, function(err,response,body) {
|
||||
// cache images for 10 minutes on aws, else upload a new one
|
||||
if (response.statusCode==200 && moment().diff(response.headers['last-modified'], 'minutes') < 10)
|
||||
return res.redirect(301, 'https://' + bucket + '.s3.amazonaws.com/' + filename);
|
||||
new Pageres()//{delay:1}
|
||||
.src(nconf.get('BASE_URL') + '/export/avatar-' + req.params.uuid + '.html', ['140x147'], {crop: true, filename: filename.replace('.png', '')})
|
||||
.run()
|
||||
.then(function (file) {
|
||||
var upload = s3Stream.upload({
|
||||
Bucket: bucket,
|
||||
Key: filename,
|
||||
ACL: "public-read",
|
||||
StorageClass: "REDUCED_REDUNDANCY",
|
||||
ContentType: "image/png",
|
||||
Expires: +moment().add({minutes: 3})
|
||||
});
|
||||
upload.on('error', function (err) {
|
||||
next(err);
|
||||
});
|
||||
upload.on('uploaded', function (details) {
|
||||
res.redirect(details.Location);
|
||||
});
|
||||
file[0].pipe(upload);
|
||||
}).catch(next);
|
||||
})
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
var _ = require('lodash');
|
||||
var nconf = require('nconf');
|
||||
var async = require('async');
|
||||
var shared = require('../../../../common');
|
||||
import {
|
||||
model as User,
|
||||
} from '../../models/user';
|
||||
import {
|
||||
model as Group,
|
||||
} from '../../models/group';
|
||||
var api = module.exports;
|
||||
|
||||
api.ensureAdmin = function(req, res, next) {
|
||||
var user = res.locals.user;
|
||||
if (!(user.contributor && user.contributor.admin)) return res.status(401).json({err:"You don't have admin access"});
|
||||
next();
|
||||
}
|
||||
|
||||
api.getHeroes = function(req,res,next) {
|
||||
User.find({'contributor.level':{$gt:0}})
|
||||
.select('contributor backer balance profile.name')
|
||||
.sort('-contributor.level')
|
||||
.exec(function(err, users){
|
||||
if (err) return next(err);
|
||||
res.json(users);
|
||||
});
|
||||
}
|
||||
|
||||
api.getPatrons = function(req,res,next){
|
||||
var page = req.query.page || 0,
|
||||
perPage = 50;
|
||||
User.find({'backer.tier':{$gt:0}})
|
||||
.select('contributor backer profile.name')
|
||||
.sort('-backer.tier')
|
||||
.skip(page*perPage)
|
||||
.limit(perPage)
|
||||
.exec(function(err, users){
|
||||
if (err) return next(err);
|
||||
res.json(users);
|
||||
});
|
||||
}
|
||||
|
||||
api.getHero = function(req,res,next) {
|
||||
User.findById(req.params.uid)
|
||||
.select('contributor balance profile.name purchased items')
|
||||
.select('auth.local.username auth.local.email auth.facebook auth.blocked')
|
||||
.exec(function(err, user){
|
||||
if (err) return next(err)
|
||||
if (!user) return res.status(400).json({err:'User not found'});
|
||||
res.json(user);
|
||||
});
|
||||
}
|
||||
|
||||
api.updateHero = function(req,res,next) {
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
User.findById(req.params.uid, cb);
|
||||
},
|
||||
function(member, cb){
|
||||
if (!member) return res.status(404).json({err: "User not found"});
|
||||
member.balance = req.body.balance || 0;
|
||||
var newTier = req.body.contributor.level; // tier = level in this context
|
||||
var oldTier = member.contributor && member.contributor.level || 0;
|
||||
if (newTier > oldTier) {
|
||||
member.flags.contributor = true;
|
||||
var gemsPerTier = {1:3, 2:3, 3:3, 4:4, 5:4, 6:4, 7:4, 8:0, 9:0}; // e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff
|
||||
var tierDiff = newTier - oldTier; // can be 2+ tier increases at once
|
||||
while (tierDiff) {
|
||||
member.balance += gemsPerTier[newTier] / 4; // balance is in $
|
||||
tierDiff--;
|
||||
newTier--; // give them gems for the next tier down if they weren't aready that tier
|
||||
}
|
||||
}
|
||||
member.contributor = req.body.contributor;
|
||||
member.purchased.ads = req.body.purchased.ads;
|
||||
if (member.contributor.level >= 6) member.items.pets['Dragon-Hydra'] = 5;
|
||||
if (req.body.itemPath && req.body.itemVal
|
||||
&& req.body.itemPath.indexOf('items.') === 0
|
||||
&& User.schema.paths[req.body.itemPath]) {
|
||||
shared.dotSet(member, req.body.itemPath, req.body.itemVal); // Sanitization at 5c30944 (deemed unnecessary)
|
||||
}
|
||||
if (_.isBoolean(req.body.auth.blocked)) member.auth.blocked = req.body.auth.blocked;
|
||||
member.save(cb);
|
||||
}
|
||||
], function(err, saved){
|
||||
if (err) return next(err);
|
||||
res.status(204).json({});
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
model as groups,
|
||||
chatDefaults,
|
||||
} from '../../models/group';
|
||||
import {
|
||||
model as User,
|
||||
} from '../../models/user';
|
||||
let partyFields = require('./groups').partyFields;
|
||||
var api = module.exports;
|
||||
var async = require('async');
|
||||
var _ = require('lodash');
|
||||
var shared = require('../../../../common');
|
||||
var utils = require('../../libs/api-v2/utils');
|
||||
var nconf = require('nconf');
|
||||
var pushNotify = require('./pushNotifications');
|
||||
|
||||
var fetchMember = function(uuid, restrict){
|
||||
return function(cb){
|
||||
var q = User.findById(uuid);
|
||||
if (restrict) q.select(partyFields);
|
||||
q.exec(function(err, member){
|
||||
if (err) return cb(err);
|
||||
if (!member) return cb({code:404, err: 'User not found'});
|
||||
return cb(null, member);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var sendErr = function(err, res, next){
|
||||
err.code ? res.status(err.code).json({err: err.err}) : next(err);
|
||||
}
|
||||
|
||||
api.getMember = function(req, res, next) {
|
||||
fetchMember(req.params.uuid, true)(function(err, member){
|
||||
if (err) return sendErr(err, res, next);
|
||||
res.json(member);
|
||||
})
|
||||
}
|
||||
|
||||
api.sendMessage = function(user, member, data){
|
||||
var msg;
|
||||
if (!data.type) {
|
||||
msg = data.message
|
||||
} else {
|
||||
msg = "`Hello " + member.profile.name + ", " + user.profile.name + " has sent you ";
|
||||
if (data.type == 'gems') {
|
||||
var gemAmount = data.gems.amount;
|
||||
var gemLabel = gemAmount > 1 ? "gems" : "gem";
|
||||
msg += gemAmount + " " + gemLabel + "!`";
|
||||
} else {
|
||||
var monthAmount = shared.content.subscriptionBlocks[data.subscription.key].months;
|
||||
var monthLabel = monthAmount > 1 ? "months" : "month";
|
||||
msg += monthAmount + " " + monthLabel + " of subscription!`";
|
||||
}
|
||||
msg += data.message ? data.message : '';
|
||||
}
|
||||
shared.refPush(member.inbox.messages, chatDefaults(msg, user));
|
||||
member.inbox.newMessages++;
|
||||
member._v++;
|
||||
member.markModified('inbox.messages');
|
||||
|
||||
shared.refPush(user.inbox.messages, _.defaults({sent:true}, chatDefaults(msg, member)));
|
||||
user.markModified('inbox.messages');
|
||||
}
|
||||
|
||||
api.sendPrivateMessage = function(req, res, next){
|
||||
var fetchedMember;
|
||||
async.waterfall([
|
||||
fetchMember(req.params.uuid),
|
||||
function(member, cb) {
|
||||
fetchedMember = member;
|
||||
if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me
|
||||
|| ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them
|
||||
|| member.inbox.optOut) { // or if they've opted out of messaging
|
||||
return cb({code: 401, err: "Can't send message to this user."});
|
||||
}
|
||||
api.sendMessage(res.locals.user, member, {message:req.body.message});
|
||||
async.parallel([
|
||||
function (cb2) { member.save(cb2) },
|
||||
function (cb2) { res.locals.user.save(cb2) }
|
||||
], cb);
|
||||
}
|
||||
], function(err){
|
||||
if (err) return sendErr(err, res, next);
|
||||
|
||||
if(fetchedMember.preferences.emailNotifications.newPM !== false){
|
||||
utils.txnEmail(fetchedMember, 'new-pm', [
|
||||
{name: 'SENDER', content: utils.getUserInfo(res.locals.user, ['name']).name},
|
||||
{name: 'PMS_INBOX_URL', content: '/#/options/groups/inbox'}
|
||||
]);
|
||||
}
|
||||
|
||||
res.sendStatus(200);
|
||||
})
|
||||
}
|
||||
|
||||
api.sendGift = function(req, res, next){
|
||||
async.waterfall([
|
||||
fetchMember(req.params.uuid),
|
||||
function(member, cb) {
|
||||
// Gems
|
||||
switch (req.body.type) {
|
||||
case "gems":
|
||||
var amt = req.body.gems.amount / 4,
|
||||
user = res.locals.user;
|
||||
if (member.id == user.id)
|
||||
return cb({code: 401, err: "Cannot send gems to yourself. Try a subscription instead."});
|
||||
if (!amt || amt <=0 || user.balance < amt)
|
||||
return cb({code: 401, err: "Amount must be within 0 and your current number of gems."});
|
||||
member.balance += amt;
|
||||
user.balance -= amt;
|
||||
api.sendMessage(user, member, req.body);
|
||||
|
||||
var byUsername = utils.getUserInfo(user, ['name']).name;
|
||||
|
||||
if(member.preferences.emailNotifications.giftedGems !== false){
|
||||
utils.txnEmail(member, 'gifted-gems', [
|
||||
{name: 'GIFTER', content: byUsername},
|
||||
{name: 'X_GEMS_GIFTED', content: req.body.gems.amount}
|
||||
]);
|
||||
}
|
||||
|
||||
pushNotify.sendNotify(member, shared.i18n.t('giftedGems'), shared.i18n.t('giftedGemsInfo', { amount: req.body.gems.amount, name: byUsername }));
|
||||
|
||||
return async.parallel([
|
||||
function (cb2) { member.save(cb2) },
|
||||
function (cb2) { user.save(cb2) }
|
||||
], cb);
|
||||
case "subscription":
|
||||
return cb();
|
||||
default:
|
||||
return cb({code:400, err:"Body must contain a gems:{amount,fromBalance} or subscription:{months} object"});
|
||||
}
|
||||
}
|
||||
], function(err) {
|
||||
if (err) return sendErr(err, res, next);
|
||||
res.sendStatus(200);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// TODO move to /api-v2
|
||||
var api = module.exports;
|
||||
var _ = require('lodash');
|
||||
var nconf = require('nconf');
|
||||
|
||||
var pushNotify = require('push-notify');
|
||||
|
||||
var gcmApiKey = nconf.get("PUSH_CONFIGS:GCM_SERVER_API_KEY");
|
||||
|
||||
var gcm = gcmApiKey ? pushNotify.gcm({
|
||||
apiKey: gcmApiKey,
|
||||
retries: 3
|
||||
}) : undefined;
|
||||
|
||||
if(gcm){
|
||||
gcm.on('transmitted', function (result, message, registrationId) {
|
||||
//console.info("transmitted", result, message, registrationId);
|
||||
});
|
||||
|
||||
gcm.on('transmissionError', function (error, message, registrationId) {
|
||||
//console.info("transmissionError", error, message, registrationId);
|
||||
});
|
||||
gcm.on('updated', function (result, registrationId) {
|
||||
//console.info("updated", result, registrationId);
|
||||
});
|
||||
}
|
||||
|
||||
api.sendNotify = function(user, title, msg, timeToLive){
|
||||
timeToLive = timeToLive || 15;
|
||||
|
||||
// need investigation:
|
||||
// https://github.com/HabitRPG/habitrpg/issues/5252
|
||||
if(!user)
|
||||
return;
|
||||
|
||||
_.forEach(user.pushDevices, function(pushDevice){
|
||||
switch(pushDevice.type){
|
||||
case "android":
|
||||
if(gcm){
|
||||
gcm.send({
|
||||
registrationId: pushDevice.regId,
|
||||
//collapseKey: 'COLLAPSE_KEY',
|
||||
delayWhileIdle: true,
|
||||
timeToLive: timeToLive,
|
||||
data: {
|
||||
title: title,
|
||||
message: msg
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "ios":
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
model as User,
|
||||
} from '../../models/user';
|
||||
import {
|
||||
model as EmailUnsubscription,
|
||||
} from '../../models/emailUnsubscription';
|
||||
var utils = require('../../libs/api-v2/utils');
|
||||
var i18n = require('../../../../common').i18n;
|
||||
|
||||
var api = module.exports = {};
|
||||
|
||||
api.unsubscribe = function(req, res, next){
|
||||
if(!req.query.code) return res.status(500).json({err: 'Missing unsubscription code.'});
|
||||
|
||||
var data = JSON.parse(utils.decrypt(req.query.code));
|
||||
|
||||
if(data._id){
|
||||
User.update({_id: data._id}, {
|
||||
$set: {'preferences.emailNotifications.unsubscribeFromAll': true}
|
||||
}, {multi: false}, function(err, updateRes){
|
||||
if(err) return next(err);
|
||||
if(updateRes.n !== 1) return res.json(404, {err: 'User not found'});
|
||||
|
||||
res.send('<h1>' + i18n.t('unsubscribedSuccessfully', null, req.language) + '</h1>' + i18n.t('unsubscribedTextUsers', null, req.language));
|
||||
});
|
||||
}else{
|
||||
EmailUnsubscription.findOne({email: data.email}, function(err, doc){
|
||||
if(err) return next(err);
|
||||
var okRes = '<h1>' + i18n.t('unsubscribedSuccessfully', null, req.language) + '</h1>' + i18n.t('unsubscribedTextOthers', null, req.language);
|
||||
|
||||
if(doc) return res.send(okRes);
|
||||
|
||||
EmailUnsubscription.create({email: data.email}, function(err, doc){
|
||||
if(err) return next(err);
|
||||
|
||||
res.send(okRes);
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,512 @@
|
||||
import validator from 'validator';
|
||||
import moment from 'moment';
|
||||
import passport from 'passport';
|
||||
import nconf from 'nconf';
|
||||
import {
|
||||
authWithHeaders,
|
||||
} from '../../middlewares/api-v3/auth';
|
||||
import {
|
||||
NotAuthorized,
|
||||
BadRequest,
|
||||
NotFound,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import Bluebird from 'bluebird';
|
||||
import * as passwordUtils from '../../libs/api-v3/password';
|
||||
import logger from '../../libs/api-v3/logger';
|
||||
import { model as User } from '../../models/user';
|
||||
import { model as Group } from '../../models/group';
|
||||
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
|
||||
import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email';
|
||||
import { decrypt } from '../../libs/api-v3/encryption';
|
||||
import FirebaseTokenGenerator from 'firebase-token-generator';
|
||||
import { send as sendEmail } from '../../libs/api-v3/email';
|
||||
|
||||
let api = {};
|
||||
|
||||
// When the user signed up after having been invited to a group, invite them automatically to the group
|
||||
async function _handleGroupInvitation (user, invite) {
|
||||
// wrapping the code in a try because we don't want it to prevent the user from signing up
|
||||
// that's why errors are not translated
|
||||
try {
|
||||
let {sentAt, id: groupId, inviter} = JSON.parse(decrypt(invite));
|
||||
|
||||
// check that the invite has not expired (after 7 days)
|
||||
if (sentAt && moment().subtract(7, 'days').isAfter(sentAt)) {
|
||||
let err = new Error('Invite expired.');
|
||||
err.privateData = invite;
|
||||
throw err;
|
||||
}
|
||||
|
||||
let group = await Group.getGroup({user, optionalMembership: true, groupId, fields: 'name type'});
|
||||
if (!group) throw new NotFound('Group not found.');
|
||||
|
||||
if (group.type === 'party') {
|
||||
user.invitations.party = {id: group._id, name: group.name, inviter};
|
||||
} else {
|
||||
user.invitations.guilds.push({id: group._id, name: group.name, inviter});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/user/auth/local/register Register
|
||||
* @apiDescription Register a new user with email, username and password or attach local auth to a social user
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UserRegisterLocal
|
||||
* @apiGroup User
|
||||
*
|
||||
* @apiParam {String} username Body parameter - Username of the new user
|
||||
* @apiParam {String} email Body parameter - Email address of the new user
|
||||
* @apiParam {String} password Body parameter - Password for the new user
|
||||
* @apiParam {String} confirmPassword Body parameter - Password confirmation
|
||||
*
|
||||
* @apiSuccess {Object} data The user object, if local auth was just attached to a social user then only user.auth.local
|
||||
*/
|
||||
api.registerLocal = {
|
||||
method: 'POST',
|
||||
middlewares: [authWithHeaders(true)],
|
||||
url: '/user/auth/local/register',
|
||||
async handler (req, res) {
|
||||
let fbUser = res.locals.user; // If adding local auth to social user
|
||||
|
||||
req.checkBody({
|
||||
email: {
|
||||
notEmpty: {errorMessage: res.t('missingEmail')},
|
||||
isEmail: {errorMessage: res.t('notAnEmail')},
|
||||
},
|
||||
username: {notEmpty: {errorMessage: res.t('missingUsername')}},
|
||||
password: {
|
||||
notEmpty: {errorMessage: res.t('missingPassword')},
|
||||
equals: {options: [req.body.confirmPassword], errorMessage: res.t('passwordConfirmationMatch')},
|
||||
},
|
||||
});
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let { email, username, password } = req.body;
|
||||
|
||||
// Get the lowercase version of username to check that we do not have duplicates
|
||||
// So we can search for it in the database and then reject the choosen username if 1 or more results are found
|
||||
email = email.toLowerCase();
|
||||
let lowerCaseUsername = username.toLowerCase();
|
||||
|
||||
// Search for duplicates using lowercase version of username
|
||||
let user = await User.findOne({$or: [
|
||||
{'auth.local.email': email},
|
||||
{'auth.local.lowerCaseUsername': lowerCaseUsername},
|
||||
]}, {'auth.local': 1}).exec();
|
||||
|
||||
if (user) {
|
||||
if (email === user.auth.local.email) throw new NotAuthorized(res.t('emailTaken'));
|
||||
// Check that the lowercase username isn't already used
|
||||
if (lowerCaseUsername === user.auth.local.lowerCaseUsername) throw new NotAuthorized(res.t('usernameTaken'));
|
||||
}
|
||||
|
||||
let salt = passwordUtils.makeSalt();
|
||||
let hashed_password = passwordUtils.encrypt(password, salt); // eslint-disable-line camelcase
|
||||
let newUser = {
|
||||
auth: {
|
||||
local: {
|
||||
username,
|
||||
lowerCaseUsername,
|
||||
email,
|
||||
salt,
|
||||
hashed_password, // eslint-disable-line camelcase
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
language: req.language,
|
||||
},
|
||||
};
|
||||
|
||||
if (fbUser) {
|
||||
if (!fbUser.auth.facebook.id) throw new NotAuthorized(res.t('onlySocialAttachLocal'));
|
||||
fbUser.auth.local = newUser.auth.local;
|
||||
newUser = fbUser;
|
||||
} else {
|
||||
newUser = new User(newUser);
|
||||
newUser.registeredThrough = req.headers['x-client']; // Not saved, used to create the correct tasks based on the device used
|
||||
}
|
||||
|
||||
// we check for partyInvite for backward compatibility
|
||||
if (req.query.groupInvite || req.query.partyInvite) {
|
||||
await _handleGroupInvitation(newUser, req.query.groupInvite || req.query.partyInvite);
|
||||
}
|
||||
|
||||
let savedUser = await newUser.save();
|
||||
|
||||
if (savedUser.auth.facebook.id) {
|
||||
res.respond(200, savedUser.toJSON().auth.local); // We convert to toJSON to hide private fields
|
||||
} else {
|
||||
res.respond(201, savedUser);
|
||||
}
|
||||
|
||||
// Clean previous email preferences and send welcome email
|
||||
EmailUnsubscription
|
||||
.remove({email: savedUser.auth.local.email})
|
||||
.then(() => sendTxnEmail(savedUser, 'welcome'));
|
||||
|
||||
if (!savedUser.auth.facebook.id) {
|
||||
res.analytics.track('register', {
|
||||
category: 'acquisition',
|
||||
type: 'local',
|
||||
gaLabel: 'local',
|
||||
uuid: savedUser._id,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
function _loginRes (user, req, res) {
|
||||
if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {userId: user._id}));
|
||||
return res.respond(200, {id: user._id, apiToken: user.apiToken});
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/user/auth/local/login Login
|
||||
* @apiDescription Login a user with email / username and password
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UserLoginLocal
|
||||
* @apiGroup User
|
||||
*
|
||||
* @apiParam {String} username Body parameter - Username or email of the user
|
||||
* @apiParam {String} password Body parameter - The user's password
|
||||
*
|
||||
* @apiSuccess {String} data._id The user's unique identifier
|
||||
* @apiSuccess {String} data.apiToken The user's api token that must be used to authenticate requests.
|
||||
*/
|
||||
api.loginLocal = {
|
||||
method: 'POST',
|
||||
url: '/user/auth/local/login',
|
||||
middlewares: [],
|
||||
async handler (req, res) {
|
||||
req.checkBody({
|
||||
username: {
|
||||
notEmpty: true,
|
||||
errorMessage: res.t('missingUsernameEmail'),
|
||||
},
|
||||
password: {
|
||||
notEmpty: true,
|
||||
errorMessage: res.t('missingPassword'),
|
||||
},
|
||||
});
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
req.sanitizeBody('username').trim();
|
||||
req.sanitizeBody('password').trim();
|
||||
|
||||
let login;
|
||||
let username = req.body.username;
|
||||
|
||||
if (validator.isEmail(username)) {
|
||||
login = {'auth.local.email': username.toLowerCase()}; // Emails are stored lowercase
|
||||
} else {
|
||||
login = {'auth.local.username': username};
|
||||
}
|
||||
|
||||
let user = await User.findOne(login, {auth: 1, apiToken: 1}).exec();
|
||||
let isValidPassword = user && user.auth.local.hashed_password === passwordUtils.encrypt(req.body.password, user.auth.local.salt);
|
||||
if (!isValidPassword) throw new NotAuthorized(res.t('invalidLoginCredentialsLong'));
|
||||
return _loginRes(user, ...arguments);
|
||||
},
|
||||
};
|
||||
|
||||
function _passportFbProfile (accessToken) {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
passport._strategies.facebook.userProfile(accessToken, (err, profile) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(profile);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Called as a callback by Facebook (or other social providers). Internal route
|
||||
api.loginSocial = {
|
||||
method: 'POST',
|
||||
url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2
|
||||
async handler (req, res) {
|
||||
let accessToken = req.body.authResponse.access_token;
|
||||
let network = req.body.network;
|
||||
|
||||
if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported'));
|
||||
|
||||
let profile = await _passportFbProfile(accessToken);
|
||||
|
||||
let user = await User.findOne({
|
||||
[`auth.${network}.id`]: profile.id,
|
||||
}, {_id: 1, apiToken: 1, auth: 1}).exec();
|
||||
|
||||
// User already signed up
|
||||
if (user) {
|
||||
_loginRes(user, ...arguments);
|
||||
} else { // Create new user
|
||||
user = new User({
|
||||
auth: {
|
||||
[network]: profile,
|
||||
},
|
||||
preferences: {
|
||||
language: req.language,
|
||||
},
|
||||
});
|
||||
user.registeredThrough = req.headers['x-client'];
|
||||
|
||||
let savedUser = await user.save();
|
||||
|
||||
_loginRes(user, ...arguments);
|
||||
|
||||
// Clean previous email preferences
|
||||
if (savedUser.auth[network].emails && savedUser.auth.facebook.emails[0] && savedUser.auth[network].emails[0].value) {
|
||||
EmailUnsubscription
|
||||
.remove({email: savedUser.auth[network].emails[0].value.toLowerCase()})
|
||||
.exec()
|
||||
.then(() => sendTxnEmail(savedUser, 'welcome')); // eslint-disable-line max-nested-callbacks
|
||||
}
|
||||
|
||||
res.analytics.track('register', {
|
||||
category: 'acquisition',
|
||||
type: network,
|
||||
gaLabel: network,
|
||||
uuid: savedUser._id,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/user/auth/update-username Update username
|
||||
* @apiDescription Update the username of a local user
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UpdateUsername
|
||||
* @apiGroup User
|
||||
*
|
||||
* @apiParam {string} password Body parameter - The current user password
|
||||
* @apiParam {string} username Body parameter - The new username
|
||||
|
||||
* @apiSuccess {String} data.username The new username
|
||||
**/
|
||||
api.updateUsername = {
|
||||
method: 'PUT',
|
||||
middlewares: [authWithHeaders()],
|
||||
url: '/user/auth/update-username',
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkBody({
|
||||
password: {
|
||||
notEmpty: {errorMessage: res.t('missingPassword')},
|
||||
},
|
||||
username: {
|
||||
notEmpty: { errorMessage: res.t('missingUsername') },
|
||||
},
|
||||
});
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
if (!user.auth.local.username) throw new BadRequest(res.t('userHasNoLocalRegistration'));
|
||||
|
||||
let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt);
|
||||
if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword'));
|
||||
|
||||
let count = await User.count({ 'auth.local.lowerCaseUsername': req.body.username.toLowerCase() });
|
||||
if (count > 0) throw new BadRequest(res.t('usernameTaken'));
|
||||
|
||||
// save username
|
||||
user.auth.local.lowerCaseUsername = req.body.username.toLowerCase();
|
||||
user.auth.local.username = req.body.username;
|
||||
await user.save();
|
||||
|
||||
res.respond(200, { username: req.body.username });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/user/auth/update-password
|
||||
* @apiDescription Update the password of a local user
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UpdatePassword
|
||||
* @apiGroup User
|
||||
*
|
||||
* @apiParam {string} password Body parameter - The old password
|
||||
* @apiParam {string} newPassword Body parameter - The new password
|
||||
* @apiParam {string} confirmPassword Body parameter - New password confirmation
|
||||
*
|
||||
* @apiSuccess {Object} data An empty object
|
||||
**/
|
||||
api.updatePassword = {
|
||||
method: 'PUT',
|
||||
middlewares: [authWithHeaders()],
|
||||
url: '/user/auth/update-password',
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
if (!user.auth.local.hashed_password) throw new BadRequest(res.t('userHasNoLocalRegistration'));
|
||||
|
||||
let oldPassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt);
|
||||
if (oldPassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword'));
|
||||
|
||||
req.checkBody({
|
||||
password: {
|
||||
notEmpty: {errorMessage: res.t('missingNewPassword')},
|
||||
},
|
||||
newPassword: {
|
||||
notEmpty: {errorMessage: res.t('missingPassword')},
|
||||
},
|
||||
});
|
||||
|
||||
if (req.body.newPassword !== req.body.confirmPassword) throw new NotAuthorized(res.t('passwordConfirmationMatch'));
|
||||
|
||||
user.auth.local.hashed_password = passwordUtils.encrypt(req.body.newPassword, user.auth.local.salt); // eslint-disable-line camelcase
|
||||
await user.save();
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/user/reset-password Reset password
|
||||
* @apiDescription Reset the user password
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ResetPassword
|
||||
* @apiGroup User
|
||||
*
|
||||
* @apiParam {string} email Body parameter - The email address of the user
|
||||
*
|
||||
* @apiSuccess {string} message The localized success message
|
||||
**/
|
||||
api.resetPassword = {
|
||||
method: 'POST',
|
||||
middlewares: [],
|
||||
url: '/user/reset-password',
|
||||
async handler (req, res) {
|
||||
req.checkBody({
|
||||
email: {
|
||||
notEmpty: {errorMessage: res.t('missingEmail')},
|
||||
},
|
||||
});
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let email = req.body.email.toLowerCase();
|
||||
let salt = passwordUtils.makeSalt();
|
||||
let newPassword = passwordUtils.makeSalt(); // use a salt as the new password too (they'll change it later)
|
||||
let hashedPassword = passwordUtils.encrypt(newPassword, salt);
|
||||
|
||||
let user = await User.findOne({ 'auth.local.email': email }, { 'auth.local': 1 });
|
||||
|
||||
if (user) {
|
||||
user.auth.local.salt = salt;
|
||||
user.auth.local.hashed_password = hashedPassword; // eslint-disable-line camelcase
|
||||
sendEmail({
|
||||
from: 'Habitica <admin@habitica.com>',
|
||||
to: email,
|
||||
subject: res.t('passwordResetEmailSubject'),
|
||||
text: res.t('passwordResetEmailText', { username: user.auth.local.username,
|
||||
newPassword,
|
||||
baseUrl: nconf.get('BASE_URL'),
|
||||
}),
|
||||
html: res.t('passwordResetEmailHtml', { username: user.auth.local.username,
|
||||
newPassword,
|
||||
baseUrl: nconf.get('BASE_URL'),
|
||||
}),
|
||||
});
|
||||
await user.save();
|
||||
}
|
||||
res.respond(200, {}, res.t('passwordReset'));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/user/auth/update-email Update email
|
||||
* @apiDescription Change the user email address
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UpdateEmail
|
||||
* @apiGroup User
|
||||
*
|
||||
* @apiParam {string} Body parameter - newEmail The new email address.
|
||||
* @apiParam {string} Body parameter - password The user password.
|
||||
*
|
||||
* @apiSuccess {string} data.email The updated email address
|
||||
*/
|
||||
api.updateEmail = {
|
||||
method: 'PUT',
|
||||
middlewares: [authWithHeaders()],
|
||||
url: '/user/auth/update-email',
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
if (!user.auth.local.email) throw new BadRequest(res.t('userHasNoLocalRegistration'));
|
||||
|
||||
req.checkBody('newEmail', res.t('newEmailRequired')).notEmpty().isEmail();
|
||||
req.checkBody('password', res.t('missingPassword')).notEmpty();
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let candidatePassword = passwordUtils.encrypt(req.body.password, user.auth.local.salt);
|
||||
if (candidatePassword !== user.auth.local.hashed_password) throw new NotAuthorized(res.t('wrongPassword'));
|
||||
|
||||
user.auth.local.email = req.body.newEmail;
|
||||
await user.save();
|
||||
|
||||
return res.respond(200, { email: user.auth.local.email });
|
||||
},
|
||||
};
|
||||
|
||||
const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET'));
|
||||
|
||||
// Internal route
|
||||
api.getFirebaseToken = {
|
||||
method: 'POST',
|
||||
url: '/user/auth/firebase',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
// Expires 24 hours from now (60*60*24*1000) (in milliseconds)
|
||||
let expires = new Date();
|
||||
expires.setTime(expires.getTime() + 86400000);
|
||||
|
||||
let token = firebaseTokenGenerator.createToken({
|
||||
uid: user._id,
|
||||
isHabiticaUser: true,
|
||||
}, { expires });
|
||||
|
||||
res.respond(200, {token, expires});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {delete} /api/v3/user/auth/social/:network Delete social authentication method
|
||||
* @apiDescription Remove a social authentication method (only facebook supported) from a user profile. The user must have local authentication enabled
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UserDeleteSocial
|
||||
* @apiGroup User
|
||||
*
|
||||
* @apiSuccess {Object} data Empty object
|
||||
*/
|
||||
api.deleteSocial = {
|
||||
method: 'DELETE',
|
||||
url: '/user/auth/social/:network',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let network = req.params.network;
|
||||
|
||||
if (network !== 'facebook') throw new NotAuthorized(res.t('onlyFbSupported'));
|
||||
if (!user.auth.local.username) throw new NotAuthorized(res.t('cantDetachFb'));
|
||||
|
||||
await User.update({_id: user._id}, {$unset: {'auth.facebook': 1}}).exec();
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,518 @@
|
||||
import { authWithHeaders, authWithSession } from '../../middlewares/api-v3/auth';
|
||||
import _ from 'lodash';
|
||||
import { model as Challenge } from '../../models/challenge';
|
||||
import {
|
||||
model as Group,
|
||||
basicFields as basicGroupFields,
|
||||
TAVERN_ID,
|
||||
} from '../../models/group';
|
||||
import {
|
||||
model as User,
|
||||
nameFields,
|
||||
} from '../../models/user';
|
||||
import {
|
||||
NotFound,
|
||||
NotAuthorized,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import * as Tasks from '../../models/task';
|
||||
import Bluebird from 'bluebird';
|
||||
import csvStringify from '../../libs/api-v3/csvStringify';
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/challenges Create a new challenge
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName CreateChallenge
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiSuccess {object} data The newly created challenge
|
||||
*/
|
||||
api.createChallenge = {
|
||||
method: 'POST',
|
||||
url: '/challenges',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkBody('group', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let 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;
|
||||
|
||||
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;
|
||||
|
||||
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();
|
||||
response.leader = { // the leader is the authenticated user
|
||||
_id: user._id,
|
||||
profile: {name: user.profile.name},
|
||||
};
|
||||
response.group = { // we already have the group data
|
||||
_id: group._id,
|
||||
name: group.name,
|
||||
type: group.type,
|
||||
privacy: group.privacy,
|
||||
};
|
||||
|
||||
res.respond(201, response);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/challenges/:challengeId/join Joins a challenge
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName JoinChallenge
|
||||
* @apiGroup Challenge
|
||||
* @apiParam {UUID} challengeId The challenge _id
|
||||
*
|
||||
* @apiSuccess {object} data The challenge the user joined
|
||||
*/
|
||||
api.joinChallenge = {
|
||||
method: 'POST',
|
||||
url: '/challenges/:challengeId/join',
|
||||
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;
|
||||
|
||||
let challenge = await Challenge.findOne({ _id: req.params.challengeId });
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.isMember(user)) throw new NotAuthorized(res.t('userAlreadyInChallenge'));
|
||||
|
||||
let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true});
|
||||
if (!group || !challenge.hasAccess(user, group)) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
challenge.memberCount += 1;
|
||||
|
||||
// Add all challenge's tasks to user's tasks and save the challenge
|
||||
let results = await Bluebird.all([challenge.syncToUser(user), challenge.save()]);
|
||||
|
||||
let response = results[1].toJSON();
|
||||
response.group = { // we already have the group data
|
||||
_id: group._id,
|
||||
name: group.name,
|
||||
type: group.type,
|
||||
privacy: group.privacy,
|
||||
};
|
||||
let chalLeader = await User.findById(response.leader).select(nameFields).exec();
|
||||
response.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null;
|
||||
|
||||
res.respond(200, response);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/challenges/:challengeId/leave Leaves a challenge
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName LeaveChallenge
|
||||
* @apiGroup Challenge
|
||||
* @apiParam {UUID} challengeId The challenge _id
|
||||
*
|
||||
* @apiSuccess {object} data An empty object
|
||||
*/
|
||||
api.leaveChallenge = {
|
||||
method: 'POST',
|
||||
url: '/challenges/:challengeId/leave',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let keep = req.body.keep === 'remove-all' ? 'remove-all' : 'keep-all';
|
||||
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let challenge = await Challenge.findOne({ _id: req.params.challengeId });
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy'});
|
||||
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
if (!challenge.isMember(user)) throw new NotAuthorized(res.t('challengeMemberNotFound'));
|
||||
|
||||
challenge.memberCount -= 1;
|
||||
|
||||
// Unlink challenge's tasks from user's tasks and save the challenge
|
||||
await Bluebird.all([challenge.unlinkTasks(user, keep), challenge.save()]);
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/challenges/user Get challenges for a user
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetUserChallenges
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiSuccess {Array} data An array of challenges
|
||||
*/
|
||||
api.getUserChallenges = {
|
||||
method: 'GET',
|
||||
url: '/challenges/user',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
let challenges = await Challenge.find({
|
||||
$or: [
|
||||
{_id: {$in: user.challenges}}, // Challenges where the user is participating
|
||||
{group: {$in: user.getGroups()}}, // Challenges in groups where I'm a member
|
||||
{leader: user._id}, // Challenges where I'm the leader
|
||||
],
|
||||
_id: {$ne: '95533e05-1ff9-4e46-970b-d77219f199e9'}, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug TODO revisit
|
||||
})
|
||||
.sort('-official -timestamp')
|
||||
// see below why we're not using populate
|
||||
// .populate('group', basicGroupFields)
|
||||
// .populate('leader', nameFields)
|
||||
.exec();
|
||||
|
||||
let resChals = challenges.map(challenge => challenge.toJSON());
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
await Bluebird.all(resChals.map((chal, index) => {
|
||||
return Bluebird.all([
|
||||
User.findById(chal.leader).select(nameFields).exec(),
|
||||
Group.findById(chal.group).select(basicGroupFields).exec(),
|
||||
]).then(populatedData => {
|
||||
resChals[index].leader = populatedData[0] ? populatedData[0].toJSON({minimize: true}) : null;
|
||||
resChals[index].group = populatedData[1] ? populatedData[1].toJSON({minimize: true}) : null;
|
||||
});
|
||||
}));
|
||||
|
||||
res.respond(200, resChals);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/challenges/group/group:Id Get challenges for a group
|
||||
* @apiDescription Get challenges that the user is a member, public challenges and the ones from the user's groups.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetGroupChallenges
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiParam {groupId} groupId The group _id
|
||||
*
|
||||
* @apiSuccess {Array} data An array of challenges
|
||||
*/
|
||||
api.getGroupChallenges = {
|
||||
method: 'GET',
|
||||
url: '/challenges/groups/:groupId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let groupId = req.params.groupId;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
let challenges = await Challenge.find({group: groupId})
|
||||
.sort('-official -timestamp')
|
||||
// .populate('leader', nameFields) // Only populate the leader as the group is implicit
|
||||
.exec();
|
||||
|
||||
let resChals = challenges.map(challenge => challenge.toJSON());
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
await Bluebird.all(resChals.map((chal, index) => {
|
||||
return User.findById(chal.leader).select(nameFields).exec().then(populatedLeader => {
|
||||
resChals[index].leader = populatedLeader ? populatedLeader.toJSON({minimize: true}) : null;
|
||||
});
|
||||
}));
|
||||
|
||||
res.respond(200, resChals);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/challenges/:challengeId Get a challenge given its id
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetChallenge
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiParam {UUID} challengeId The challenge _id
|
||||
*
|
||||
* @apiSuccess {object} data The challenge object
|
||||
*/
|
||||
api.getChallenge = {
|
||||
method: 'GET',
|
||||
url: '/challenges/:challengeId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let user = res.locals.user;
|
||||
let challengeId = req.params.challengeId;
|
||||
|
||||
let challenge = await Challenge.findById(challengeId)
|
||||
// Don't populate the group as we'll fetch it manually later
|
||||
// .populate('leader', nameFields)
|
||||
.exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
// Fetching basic group data
|
||||
let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true});
|
||||
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
let chalRes = challenge.toJSON();
|
||||
chalRes.group = group.toJSON({minimize: true});
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
let chalLeader = await User.findById(chalRes.leader).select(nameFields).exec();
|
||||
chalRes.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null;
|
||||
|
||||
res.respond(200, chalRes);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/challenges/:challengeId/export/csv Export a challenge in CSV
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ExportChallengeCsv
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiParam {UUID} challengeId The challenge _id
|
||||
*
|
||||
* @apiSuccess {string} challenge A csv file
|
||||
*/
|
||||
api.exportChallengeCsv = {
|
||||
method: 'GET',
|
||||
url: '/challenges/:challengeId/export/csv',
|
||||
middlewares: [authWithSession],
|
||||
async handler (req, res) {
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let user = res.locals.user;
|
||||
let challengeId = req.params.challengeId;
|
||||
|
||||
let challenge = await Challenge.findById(challengeId).select('_id group leader tasksOrder').exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
|
||||
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
// In v2 this used the aggregation framework to run some computation on MongoDB but then iterated through all
|
||||
// results on the server so the perf difference isn't that big (hopefully)
|
||||
|
||||
let [members, tasks] = await Bluebird.all([
|
||||
User.find({challenges: challengeId})
|
||||
.select(nameFields)
|
||||
.sort({_id: 1})
|
||||
.lean() // so we don't involve mongoose
|
||||
.exec(),
|
||||
|
||||
Tasks.Task.find({'challenge.id': challengeId, userId: {$exists: true}})
|
||||
.sort({userId: 1, text: 1}).select('userId type text value notes').lean().exec(),
|
||||
]);
|
||||
|
||||
let resArray = members.map(member => [member._id, member.profile.name]);
|
||||
|
||||
// We assume every user in the challenge as at least some data so we can say that members[0] tasks will be at tasks [0]
|
||||
let lastUserId;
|
||||
let index = -1;
|
||||
tasks.forEach(task => {
|
||||
if (task.userId !== lastUserId) {
|
||||
lastUserId = task.userId;
|
||||
index++;
|
||||
}
|
||||
|
||||
resArray[index].push(`${task.type}:${task.text}`, task.value, task.notes);
|
||||
});
|
||||
|
||||
// The first row is going to be UUID name Task Value Notes repeated n times for the n challenge tasks
|
||||
let challengeTasks = _.reduce(challenge.tasksOrder.toObject(), (result, array) => {
|
||||
return result.concat(array);
|
||||
}, []).sort();
|
||||
resArray.unshift(['UUID', 'name']);
|
||||
_.times(challengeTasks.length, () => resArray[0].push('Task', 'Value', 'Notes'));
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-disposition': `attachment; filename=${challengeId}.csv`,
|
||||
});
|
||||
|
||||
let csvRes = await csvStringify(resArray);
|
||||
res.status(200).send(csvRes);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/challenges/:challengeId Update a challenge
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UpdateChallenge
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiParam {UUID} challengeId The challenge _id
|
||||
*
|
||||
* @apiSuccess {object} data The updated challenge
|
||||
*/
|
||||
api.updateChallenge = {
|
||||
method: 'PUT',
|
||||
url: '/challenges/:challengeId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let user = res.locals.user;
|
||||
let challengeId = req.params.challengeId;
|
||||
|
||||
let challenge = await Challenge.findById(challengeId).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
let group = await Group.getGroup({user, groupId: challenge.group, fields: basicGroupFields, optionalMembership: true});
|
||||
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderUpdateChal'));
|
||||
|
||||
_.merge(challenge, Challenge.sanitizeUpdate(req.body));
|
||||
|
||||
let savedChal = await challenge.save();
|
||||
let response = savedChal.toJSON();
|
||||
response.group = { // we already have the group data
|
||||
_id: group._id,
|
||||
name: group.name,
|
||||
type: group.type,
|
||||
privacy: group.privacy,
|
||||
};
|
||||
let chalLeader = await User.findById(response.leader).select(nameFields).exec();
|
||||
response.leader = chalLeader ? chalLeader.toJSON({minimize: true}) : null;
|
||||
res.respond(200, response);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {delete} /api/v3/challenges/:challengeId Delete a challenge
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName DeleteChallenge
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiParam {UUID} challengeId The _id for the challenge to delete
|
||||
*
|
||||
* @apiSuccess {object} data An empty object
|
||||
*/
|
||||
api.deleteChallenge = {
|
||||
method: 'DELETE',
|
||||
url: '/challenges/:challengeId',
|
||||
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;
|
||||
|
||||
let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
|
||||
|
||||
// Close channel in background, some ops are run in the background without `await`ing
|
||||
await challenge.closeChal({broken: 'CHALLENGE_DELETED'});
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/challenges/:challengeId/selectWinner/:winnerId Select winner for challenge
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName SelectChallengeWinner
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiParam {UUID} challengeId The _id for the challenge to close with a winner
|
||||
* @apiParam {UUID} winnerId The _id of the winning user
|
||||
*
|
||||
* @apiSuccess {object} data An empty object
|
||||
*/
|
||||
api.selectChallengeWinner = {
|
||||
method: 'POST',
|
||||
url: '/challenges/:challengeId/selectWinner/:winnerId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
req.checkParams('winnerId', res.t('winnerIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let challenge = await Challenge.findOne({_id: req.params.challengeId}).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderDeleteChal'));
|
||||
|
||||
let winner = await User.findOne({_id: req.params.winnerId}).exec();
|
||||
if (!winner || winner.challenges.indexOf(challenge._id) === -1) throw new NotFound(res.t('winnerNotFound', {userId: req.params.winnerId}));
|
||||
|
||||
// Close channel in background, some ops are run in the background without `await`ing
|
||||
await challenge.closeChal({broken: 'CHALLENGE_CLOSED', winner});
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,398 @@
|
||||
import { authWithHeaders } from '../../middlewares/api-v3/auth';
|
||||
import {
|
||||
model as Group,
|
||||
TAVERN_ID,
|
||||
} from '../../models/group';
|
||||
import { model as User } from '../../models/user';
|
||||
import {
|
||||
NotFound,
|
||||
NotAuthorized,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import _ from 'lodash';
|
||||
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
|
||||
import { sendTxn } from '../../libs/api-v3/email';
|
||||
import nconf from 'nconf';
|
||||
import Bluebird from 'bluebird';
|
||||
|
||||
const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => {
|
||||
return { email, canSend: true };
|
||||
});
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/groups/:groupId/chat Get chat messages from a group
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetChat
|
||||
* @apiGroup Chat
|
||||
*
|
||||
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
*
|
||||
* @apiSuccess {Array} data An array of chat messages
|
||||
*/
|
||||
api.getChat = {
|
||||
method: 'GET',
|
||||
url: '/groups/:groupId/chat',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'chat'});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
res.respond(200, Group.toJSONCleanChat(group, user).chat);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/chat Post chat message to a group
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PostCat
|
||||
* @apiGroup Chat
|
||||
*
|
||||
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {message} Body parameter - message The message to post
|
||||
* @apiParam {previousMsg} previousMsg Query parameter - The previous chat message which will force a return of the full group chat
|
||||
*
|
||||
* @apiSuccess data An array of chat messages if a new message was posted after previousMsg, otherwise the posted message
|
||||
*/
|
||||
api.postChat = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/chat',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let groupId = req.params.groupId;
|
||||
let chatUpdated;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
req.checkBody('message', res.t('messageGroupChatBlankMessage')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId});
|
||||
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (group.type !== 'party' && user.flags.chatRevoked) {
|
||||
throw new NotFound('Your chat privileges have been revoked.');
|
||||
}
|
||||
|
||||
let lastClientMsg = req.query.previousMsg;
|
||||
chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false;
|
||||
|
||||
group.sendChat(req.body.message, user);
|
||||
|
||||
let toSave = [group.save()];
|
||||
|
||||
if (group.type === 'party') {
|
||||
user.party.lastMessageSeen = group.chat[0].id;
|
||||
toSave.push(user.save());
|
||||
}
|
||||
|
||||
let [savedGroup] = await Bluebird.all(toSave);
|
||||
if (chatUpdated) {
|
||||
res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat});
|
||||
} else {
|
||||
res.respond(200, {message: savedGroup.chat[0]});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/chat/:chatId/like Like a group chat message
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName LikeChat
|
||||
* @apiGroup Chat
|
||||
*
|
||||
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {chatId} chatId The chat message _id
|
||||
*
|
||||
* @apiSuccess {Object} data The liked chat message
|
||||
*/
|
||||
api.likeChat = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/chat/:chatId/like',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let groupId = req.params.groupId;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
let message = _.find(group.chat, {id: req.params.chatId});
|
||||
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
|
||||
if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage'));
|
||||
|
||||
let update = {$set: {}};
|
||||
|
||||
if (!message.likes) message.likes = {};
|
||||
|
||||
message.likes[user._id] = !message.likes[user._id];
|
||||
update.$set[`chat.$.likes.${user._id}`] = message.likes[user._id];
|
||||
|
||||
await Group.update(
|
||||
{_id: group._id, 'chat.id': message.id},
|
||||
update
|
||||
);
|
||||
res.respond(200, message); // TODO what if the message is flagged and shouldn't be returned?
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/chat/:chatId/like Like a group chat message
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName LikeChat
|
||||
* @apiGroup Chat
|
||||
*
|
||||
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {chatId} chatId The chat message id
|
||||
*
|
||||
* @apiSuccess {object} data The flagged chat message
|
||||
*/
|
||||
api.flagChat = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/chat/:chatId/flag',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let groupId = req.params.groupId;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
let message = _.find(group.chat, {id: req.params.chatId});
|
||||
|
||||
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
|
||||
|
||||
if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatFlagOwnMessage'));
|
||||
|
||||
let author = await User.findOne({_id: message.uuid}, {auth: 1});
|
||||
|
||||
let update = {$set: {}};
|
||||
|
||||
// Log user ids that have flagged the message
|
||||
if (!message.flags) message.flags = {};
|
||||
if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported'));
|
||||
message.flags[user._id] = true;
|
||||
update.$set[`chat.$.flags.${user._id}`] = true;
|
||||
|
||||
// Log total number of flags (publicly viewable)
|
||||
if (!message.flagCount) message.flagCount = 0;
|
||||
if (user.contributor.admin) {
|
||||
// Arbitraty amount, higher than 2
|
||||
message.flagCount = 5;
|
||||
} else {
|
||||
message.flagCount++;
|
||||
}
|
||||
update.$set['chat.$.flagCount'] = message.flagCount;
|
||||
|
||||
await Group.update(
|
||||
{_id: group._id, 'chat.id': message.id},
|
||||
update
|
||||
);
|
||||
|
||||
let reporterEmailContent;
|
||||
if (user.auth.local) {
|
||||
reporterEmailContent = user.auth.local.email;
|
||||
} else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) {
|
||||
reporterEmailContent = user.auth.facebook.emails[0].value;
|
||||
}
|
||||
|
||||
let authorEmailContent;
|
||||
if (author.auth.local) {
|
||||
authorEmailContent = author.auth.local.email;
|
||||
} else if (author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) {
|
||||
authorEmailContent = author.auth.facebook.emails[0].value;
|
||||
}
|
||||
|
||||
let groupUrl;
|
||||
if (group._id === TAVERN_ID) {
|
||||
groupUrl = '/#/options/groups/tavern';
|
||||
} else if (group.type === 'guild') {
|
||||
groupUrl = `/#/options/groups/guilds/${group._id}`;
|
||||
} else {
|
||||
groupUrl = 'party';
|
||||
}
|
||||
|
||||
sendTxn(FLAG_REPORT_EMAILS, 'flag-report-to-mods', [
|
||||
{name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()},
|
||||
{name: 'MESSAGE_TEXT', content: message.text},
|
||||
|
||||
{name: 'REPORTER_USERNAME', content: user.profile.name},
|
||||
{name: 'REPORTER_UUID', content: user._id},
|
||||
{name: 'REPORTER_EMAIL', content: reporterEmailContent},
|
||||
{name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId=${user._id}`},
|
||||
|
||||
{name: 'AUTHOR_USERNAME', content: message.user},
|
||||
{name: 'AUTHOR_UUID', content: message.uuid},
|
||||
{name: 'AUTHOR_EMAIL', content: authorEmailContent},
|
||||
{name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${message.uuid}`},
|
||||
|
||||
{name: 'GROUP_NAME', content: group.name},
|
||||
{name: 'GROUP_TYPE', content: group.type},
|
||||
{name: 'GROUP_ID', content: group._id},
|
||||
{name: 'GROUP_URL', content: groupUrl},
|
||||
]);
|
||||
|
||||
res.respond(200, message);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/chat/:chatId/clear-flags Clear a group chat message's flags
|
||||
* @apiDescription Admin-only
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ClearFlags
|
||||
* @apiGroup Chat
|
||||
*
|
||||
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {chatId} chatId The chat message id
|
||||
*
|
||||
* @apiSuccess {Object} data An empty object
|
||||
*/
|
||||
api.clearChatFlags = {
|
||||
method: 'Post',
|
||||
url: '/groups/:groupId/chat/:chatId/clearflags',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let groupId = req.params.groupId;
|
||||
let chatId = req.params.chatId;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
if (!user.contributor.admin) {
|
||||
throw new NotAuthorized(res.t('messageGroupChatAdminClearFlagCount'));
|
||||
}
|
||||
|
||||
let group = await Group.getGroup({user, groupId});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
let message = _.find(group.chat, {id: chatId});
|
||||
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
|
||||
|
||||
message.flagCount = 0;
|
||||
|
||||
await Group.update(
|
||||
{_id: group._id, 'chat.id': message.id},
|
||||
{$set: {'chat.$.flagCount': message.flagCount}}
|
||||
);
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/chat/:chatId/seen Seen a group chat message
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName SeenChat
|
||||
* @apiGroup Chat
|
||||
*
|
||||
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
*
|
||||
* @apiSuccess {Object} data An empty object
|
||||
*/
|
||||
api.seenChat = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/chat/seen',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let groupId = req.params.groupId;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
// Do not validate group existence, it doesn't really matter and make it works if the group gets deleted
|
||||
// let group = await Group.getGroup({user, groupId});
|
||||
// if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
let update = {$unset: {}};
|
||||
update.$unset[`newMessages.${groupId}`] = true;
|
||||
|
||||
await User.update({_id: user._id}, update).exec();
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {delete} /api/v3/groups/:groupId/chat/:chatId Delete chat message from a group
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName DeleteChat
|
||||
* @apiGroup Chat
|
||||
*
|
||||
* @apiParam {string} previousMsg Query parameter - The last message fetched by the client so that the whole chat will be returned only if new messages have been posted in the meantime
|
||||
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {string} chatId The chat message id
|
||||
*
|
||||
* @apiSuccess data The updated chat array or an empty object if no message was posted after previousMsg
|
||||
* @apiSuccess {Object} data An empty object when the previous message was deleted
|
||||
*/
|
||||
api.deleteChat = {
|
||||
method: 'DELETE',
|
||||
url: '/groups/:groupId/chat/:chatId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let groupId = req.params.groupId;
|
||||
let chatId = req.params.chatId;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId, fields: 'chat'});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
let message = _.find(group.chat, {id: chatId});
|
||||
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
|
||||
|
||||
if (user._id !== message.uuid && !user.contributor.admin) {
|
||||
throw new NotAuthorized(res.t('onlyCreatorOrAdminCanDeleteChat'));
|
||||
}
|
||||
|
||||
let lastClientMsg = req.query.previousMsg;
|
||||
let chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false;
|
||||
|
||||
await Group.update(
|
||||
{_id: group._id},
|
||||
{$pull: {chat: {id: chatId}}}
|
||||
);
|
||||
|
||||
if (chatUpdated) {
|
||||
let chatRes = Group.toJSONCleanChat(group, user).chat;
|
||||
removeFromArray(chatRes, {id: chatId});
|
||||
res.respond(200, chatRes);
|
||||
} else {
|
||||
res.respond(200, {});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,110 @@
|
||||
import common from '../../../../common';
|
||||
import _ from 'lodash';
|
||||
import { langCodes } from '../../libs/api-v3/i18n';
|
||||
import Bluebird from 'bluebird';
|
||||
import fsCallback from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../../libs/api-v3/logger';
|
||||
|
||||
// Transform fs methods that accept callbacks in ones that return promises
|
||||
const fs = {
|
||||
readFile: Bluebird.promisify(fsCallback.readFile, {context: fsCallback}),
|
||||
writeFile: Bluebird.promisify(fsCallback.writeFile, {context: fsCallback}),
|
||||
stat: Bluebird.promisify(fsCallback.stat, {context: fsCallback}),
|
||||
mkdir: Bluebird.promisify(fsCallback.mkdir, {context: fsCallback}),
|
||||
};
|
||||
|
||||
let api = {};
|
||||
|
||||
function walkContent (obj, lang) {
|
||||
_.each(obj, (item, key, source) => {
|
||||
if (_.isPlainObject(item) || _.isArray(item)) return walkContent(item, lang);
|
||||
if (_.isFunction(item) && item.i18nLangFunc) source[key] = item(lang);
|
||||
});
|
||||
}
|
||||
|
||||
// After the getContent route is called the first time for a certain language
|
||||
// the response is saved on disk and subsequentially served directly from there to reduce computation.
|
||||
// Example: if `cachedContentResponses.en` is true it means that the response is cached
|
||||
let cachedContentResponses = {};
|
||||
|
||||
// Language key set to true while the cache file is being written
|
||||
let cacheBeingWritten = {};
|
||||
|
||||
_.each(langCodes, code => {
|
||||
cachedContentResponses[code] = false;
|
||||
cacheBeingWritten[code] = false;
|
||||
});
|
||||
|
||||
|
||||
const CONTENT_CACHE_PATH = path.join(__dirname, '/../../../build/content_cache/');
|
||||
|
||||
async function saveContentToDisk (language, content) {
|
||||
try {
|
||||
cacheBeingWritten[language] = true;
|
||||
|
||||
await fs.stat(CONTENT_CACHE_PATH); // check if the directory exists, if it doesn't an error is thrown
|
||||
await fs.writeFile(`${CONTENT_CACHE_PATH}${language}.json`, content, 'utf8');
|
||||
|
||||
cacheBeingWritten[language] = false;
|
||||
cachedContentResponses[language] = true;
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT' && err.syscall === 'stat') { // the directory doesn't exists, create it and retry
|
||||
await fs.mkdir(CONTENT_CACHE_PATH);
|
||||
return saveContentToDisk(language, content);
|
||||
} else {
|
||||
cacheBeingWritten[language] = false;
|
||||
logger.error(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/content Get all available content objects
|
||||
* @apiDescription Does not require authentication.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ContentGet
|
||||
* @apiGroup Content
|
||||
*
|
||||
* @apiParam {string} language Query parameter, the language code used for the items' strings. Defaulting to english
|
||||
*
|
||||
* @apiSuccess {Object} data All the content available on Habitica
|
||||
*/
|
||||
api.getContent = {
|
||||
method: 'GET',
|
||||
url: '/content',
|
||||
async handler (req, res) {
|
||||
let language = 'en';
|
||||
let proposedLang = req.query.language && req.query.language.toString();
|
||||
|
||||
if (proposedLang in cachedContentResponses) {
|
||||
language = proposedLang;
|
||||
}
|
||||
|
||||
let content;
|
||||
|
||||
// is the content response for this language cached?
|
||||
if (cachedContentResponses[language] === true) {
|
||||
content = await fs.readFile(`${CONTENT_CACHE_PATH}${language}.json`, 'utf8');
|
||||
} else { // generate the response
|
||||
content = _.cloneDeep(common.content);
|
||||
walkContent(content, language);
|
||||
content = JSON.stringify(content);
|
||||
}
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
|
||||
let jsonResString = `{"success": true, "data": ${content}}`;
|
||||
res.status(200).send(jsonResString);
|
||||
|
||||
// save the file in background unless it's already cached or being written right now
|
||||
if (cachedContentResponses[language] !== true && cacheBeingWritten[language] !== true) {
|
||||
saveContentToDisk(language, content);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,126 @@
|
||||
import csvStringify from '../../libs/api-v3/csvStringify';
|
||||
import {
|
||||
authWithHeaders,
|
||||
authWithSession,
|
||||
} from '../../middlewares/api-v3/auth';
|
||||
import { ensureSudo } from '../../middlewares/api-v3/ensureAccessRight';
|
||||
import { model as Coupon } from '../../models/coupon';
|
||||
import _ from 'lodash';
|
||||
import couponCode from 'coupon-code';
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/coupons Get coupons
|
||||
* @apiDescription Sudo users only
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetCoupons
|
||||
* @apiGroup Coupon
|
||||
*
|
||||
* @apiSuccess {string} Coupons in CSV format
|
||||
*/
|
||||
api.getCoupons = {
|
||||
method: 'GET',
|
||||
url: '/coupons',
|
||||
middlewares: [authWithSession, ensureSudo],
|
||||
async handler (req, res) {
|
||||
let coupons = await Coupon.find().sort('createdAt').lean().exec();
|
||||
|
||||
let output = [['code', 'event', 'date', 'user']].concat(_.map(coupons, coupon => {
|
||||
return [coupon._id, coupon.event, coupon.createdAt, coupon.user];
|
||||
}));
|
||||
let csv = await csvStringify(output);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-disposition': 'attachment; filename=habitica-coupons.csv',
|
||||
});
|
||||
res.status(200).send(csv);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/coupons/generate/:event Generate coupons for an event
|
||||
* @apiDescription Sudo users only
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GenerateCoupons
|
||||
* @apiGroup Coupon
|
||||
*
|
||||
* @apiParam {string} event The event for which the coupon should be generated
|
||||
* @apiParam {number} count Query parameter to specify the number of coupon codes to generate
|
||||
*
|
||||
* @apiSuccess {array} data Generated coupons
|
||||
*/
|
||||
api.generateCoupons = {
|
||||
method: 'POST',
|
||||
url: '/coupons/generate/:event',
|
||||
middlewares: [authWithHeaders(), ensureSudo],
|
||||
async handler (req, res) {
|
||||
req.checkParams('event', res.t('eventRequired')).notEmpty();
|
||||
req.checkQuery('count', res.t('countRequired')).notEmpty().isNumeric();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let coupons = await Coupon.generate(req.params.event, req.query.count);
|
||||
res.respond(200, coupons);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/user/coupon/:code Enter coupon code
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName EnterCouponCode
|
||||
* @apiGroup Coupon
|
||||
*
|
||||
* @apiParam {string} code The coupon code to apply
|
||||
*
|
||||
* @apiSuccess {object} data User object
|
||||
*/
|
||||
api.enterCouponCode = {
|
||||
method: 'POST',
|
||||
url: '/coupons/enter/:code',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('code', res.t('couponCodeRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
await Coupon.apply(user, req, req.params.code);
|
||||
res.respond(200, user);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/coupons/validate/:code Validate a coupon code
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ValidateCoupon
|
||||
* @apiGroup Coupon
|
||||
*
|
||||
* @apiSuccess {boolean} data.valid True or false
|
||||
*/
|
||||
api.validateCoupon = {
|
||||
method: 'POST',
|
||||
url: '/coupons/validate/:code',
|
||||
middlewares: [authWithHeaders(true)],
|
||||
async handler (req, res) {
|
||||
req.checkParams('code', res.t('couponCodeRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let valid = false;
|
||||
let code = couponCode.validate(req.params.code);
|
||||
if (code) {
|
||||
let coupon = await Coupon.findOne({_id: code}).exec();
|
||||
valid = coupon ? true : false;
|
||||
}
|
||||
|
||||
res.respond(200, {valid});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,190 @@
|
||||
import { authWithHeaders } from '../../middlewares/api-v3/auth';
|
||||
import ensureDevelpmentMode from '../../middlewares/api-v3/ensureDevelpmentMode';
|
||||
import { BadRequest } from '../../libs/api-v3/errors';
|
||||
import { content } from '../../../../common';
|
||||
import _ from 'lodash';
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/debug/add-ten-gems Add ten gems to the current user
|
||||
* @apiDescription Only available in development mode.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AddTenGems
|
||||
* @apiGroup Development
|
||||
*
|
||||
* @apiSuccess {Object} data An empty Object
|
||||
*/
|
||||
api.addTenGems = {
|
||||
method: 'POST',
|
||||
url: '/debug/add-ten-gems',
|
||||
middlewares: [ensureDevelpmentMode, authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
user.balance += 2.5;
|
||||
|
||||
await user.save();
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/debug/add-hourglass Add Hourglass to the current user
|
||||
* @apiDescription Only available in development mode.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AddHourglass
|
||||
* @apiGroup Development
|
||||
*
|
||||
* @apiSuccess {Object} data An empty Object
|
||||
*/
|
||||
api.addHourglass = {
|
||||
method: 'POST',
|
||||
url: '/debug/add-hourglass',
|
||||
middlewares: [ensureDevelpmentMode, authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
user.purchased.plan.consecutive.trinkets += 1;
|
||||
|
||||
await user.save();
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/debug/set-cron Sets lastCron for user
|
||||
* @apiDescription Only available in development mode.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName setCron
|
||||
* @apiGroup Development
|
||||
*
|
||||
* @apiSuccess {Object} data An empty Object
|
||||
*/
|
||||
api.setCron = {
|
||||
method: 'POST',
|
||||
url: '/debug/set-cron',
|
||||
middlewares: [ensureDevelpmentMode, authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let cron = req.body.lastCron;
|
||||
|
||||
user.lastCron = cron;
|
||||
|
||||
await user.save();
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/debug/make-admin Sets contributor.admin to true
|
||||
* @apiDescription Only available in development mode.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName setCron
|
||||
* @apiGroup Development
|
||||
*
|
||||
* @apiSuccess {Object} data An empty Object
|
||||
*/
|
||||
// TODO: Re-enable after v3 prod testing is done
|
||||
// api.makeAdmin = {
|
||||
// method: 'POST',
|
||||
// url: '/debug/make-admin',
|
||||
// middlewares: [ensureDevelpmentMode, authWithHeaders()],
|
||||
// async handler (req, res) {
|
||||
// let user = res.locals.user;
|
||||
//
|
||||
// user.contributor.admin = true;
|
||||
//
|
||||
// await user.save();
|
||||
//
|
||||
// res.respond(200, {});
|
||||
// },
|
||||
// };
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/debug/modify-inventory Manipulate user's inventory
|
||||
* @apiDescription Only available in development mode.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName modifyInventory
|
||||
* @apiGroup Development
|
||||
*
|
||||
* @apiSuccess {Object} data An empty Object
|
||||
*/
|
||||
api.modifyInventory = {
|
||||
method: 'POST',
|
||||
url: '/debug/modify-inventory',
|
||||
middlewares: [ensureDevelpmentMode, authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let { gear } = req.body;
|
||||
|
||||
if (gear) {
|
||||
user.items.gear.owned = gear;
|
||||
}
|
||||
|
||||
[
|
||||
'special',
|
||||
'pets',
|
||||
'mounts',
|
||||
'eggs',
|
||||
'hatchingPotions',
|
||||
'food',
|
||||
'quests',
|
||||
].forEach((type) => {
|
||||
if (req.body[type]) {
|
||||
user.items[type] = req.body[type];
|
||||
}
|
||||
});
|
||||
|
||||
await user.save();
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/debug/quest-progress Artificially accelerate quest progress
|
||||
* @apiDescription Only available in development mode.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName questProgress
|
||||
* @apiGroup Development
|
||||
*
|
||||
* @apiSuccess {Object} data An empty Object
|
||||
*/
|
||||
api.questProgress = {
|
||||
method: 'POST',
|
||||
url: '/debug/quest-progress',
|
||||
middlewares: [ensureDevelpmentMode, authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let key = _.get(user, 'party.quest.key');
|
||||
let quest = content.quests[key];
|
||||
|
||||
if (!quest) {
|
||||
throw new BadRequest('User is not on a valid quest.');
|
||||
}
|
||||
|
||||
if (quest.boss) {
|
||||
user.party.quest.progress.up += 1000;
|
||||
}
|
||||
|
||||
if (quest.collect) {
|
||||
let collect = user.party.quest.progress.collect;
|
||||
_.each(quest.collect, (details, item) => {
|
||||
collect[item] = collect[item] || 0;
|
||||
collect[item] += 300;
|
||||
});
|
||||
}
|
||||
|
||||
user.markModified('party.quest.progress');
|
||||
|
||||
await user.save();
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,670 @@
|
||||
import { authWithHeaders } from '../../middlewares/api-v3/auth';
|
||||
import Bluebird from 'bluebird';
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
INVITES_LIMIT,
|
||||
model as Group,
|
||||
basicFields as basicGroupFields,
|
||||
} from '../../models/group';
|
||||
import {
|
||||
model as User,
|
||||
nameFields,
|
||||
} from '../../models/user';
|
||||
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
|
||||
import {
|
||||
NotFound,
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
|
||||
import * as firebase from '../../libs/api-v3/firebase';
|
||||
import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email';
|
||||
import { encrypt } from '../../libs/api-v3/encryption';
|
||||
import common from '../../../../common';
|
||||
import sendPushNotification from '../../libs/api-v3/pushNotifications';
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups Create group
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName CreateGroup
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiSuccess {Object} data The create group
|
||||
*/
|
||||
api.createGroup = {
|
||||
method: 'POST',
|
||||
url: '/groups',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let group = new Group(Group.sanitize(req.body));
|
||||
group.leader = user._id;
|
||||
|
||||
if (group.type === 'guild') {
|
||||
if (user.balance < 1) throw new NotAuthorized(res.t('messageInsufficientGems'));
|
||||
|
||||
group.balance = 1;
|
||||
|
||||
user.balance--;
|
||||
user.guilds.push(group._id);
|
||||
} else {
|
||||
if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate'));
|
||||
if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty'));
|
||||
|
||||
user.party._id = group._id;
|
||||
}
|
||||
|
||||
let results = await Bluebird.all([user.save(), group.save()]);
|
||||
let savedGroup = results[1];
|
||||
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
// await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise
|
||||
let response = savedGroup.toJSON();
|
||||
// the leader is the authenticated user
|
||||
response.leader = {
|
||||
_id: user._id,
|
||||
profile: {name: user.profile.name},
|
||||
};
|
||||
res.respond(201, response); // do not remove chat flags data as we've just created the group
|
||||
|
||||
firebase.updateGroupData(savedGroup);
|
||||
firebase.addUserToGroup(savedGroup._id, user._id);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/groups Get groups for a user
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetGroups
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} type The type of groups to retrieve. Must be a query string representing a list of values like 'tavern,party'. Possible values are party, guilds, privateGuilds, publicGuilds, tavern
|
||||
*
|
||||
* @apiSuccess {Array} data An array of the requested groups
|
||||
*/
|
||||
api.getGroups = {
|
||||
method: 'GET',
|
||||
url: '/groups',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkQuery('type', res.t('groupTypesRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let types = req.query.type.split(',');
|
||||
let groupFields = basicGroupFields.concat(' description memberCount balance');
|
||||
let sort = '-memberCount';
|
||||
|
||||
let results = await Group.getGroups({user, types, groupFields, sort});
|
||||
res.respond(200, results);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/groups/:groupId Get group
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetGroup
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
*
|
||||
* @apiSuccess {Object} data The group object
|
||||
*/
|
||||
api.getGroup = {
|
||||
method: 'GET',
|
||||
url: '/groups/:groupId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId: req.params.groupId, populateLeader: false});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
group = Group.toJSONCleanChat(group, user);
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
let leader = await User.findById(group.leader).select(nameFields).exec();
|
||||
if (leader) group.leader = leader.toJSON({minimize: true});
|
||||
|
||||
res.respond(200, group);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/groups/:groupId Update group
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UpdateGroup
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
*
|
||||
* @apiSuccess {Object} data The updated group
|
||||
*/
|
||||
api.updateGroup = {
|
||||
method: 'PUT',
|
||||
url: '/groups/:groupId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId: req.params.groupId});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
|
||||
|
||||
_.assign(group, _.merge(group.toObject(), Group.sanitizeUpdate(req.body)));
|
||||
|
||||
let savedGroup = await group.save();
|
||||
let response = Group.toJSONCleanChat(savedGroup, user);
|
||||
// If the leader changed fetch new data, otherwise use authenticated user
|
||||
if (response.leader !== user._id) {
|
||||
response.leader = (await User.findById(response.leader).select(nameFields).exec()).toJSON({minimize: true});
|
||||
} else {
|
||||
response.leader = {
|
||||
_id: user._id,
|
||||
profile: {name: user.profile.name},
|
||||
};
|
||||
}
|
||||
res.respond(200, response);
|
||||
|
||||
firebase.updateGroupData(savedGroup);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/join Join a group
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName JoinGroup
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
*
|
||||
* @apiSuccess {Object} data The joined group
|
||||
*/
|
||||
api.joinGroup = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/join',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let inviter;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party'
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
// Works even if the user is not yet a member of the group
|
||||
let group = await Group.getGroup({user, groupId: req.params.groupId, optionalMembership: true}); // Do not fetch chat and work even if the user is not yet a member of the group
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
let isUserInvited = false;
|
||||
|
||||
if (group.type === 'party' && group._id === user.invitations.party.id) {
|
||||
inviter = user.invitations.party.inviter;
|
||||
user.invitations.party = {}; // Clear invite
|
||||
user.markModified('invitations.party');
|
||||
|
||||
// invite new user to pending quest
|
||||
if (group.quest.key && !group.quest.active) {
|
||||
user.party.quest.RSVPNeeded = true;
|
||||
user.party.quest.key = group.quest.key;
|
||||
group.quest.members[user._id] = null;
|
||||
group.markModified('quest.members');
|
||||
}
|
||||
|
||||
// If user was in a different party (when partying solo you can be invited to a new party)
|
||||
// make him leave that party before doing anything
|
||||
if (user.party._id) {
|
||||
let userPreviousParty = await Group.getGroup({user, groupId: user.party._id});
|
||||
if (userPreviousParty) await userPreviousParty.leave(user);
|
||||
}
|
||||
|
||||
user.party._id = group._id; // Set group as user's party
|
||||
|
||||
isUserInvited = true;
|
||||
} else if (group.type === 'guild') {
|
||||
let hasInvitation = removeFromArray(user.invitations.guilds, { id: group._id });
|
||||
|
||||
if (hasInvitation) {
|
||||
isUserInvited = true;
|
||||
} else {
|
||||
isUserInvited = group.privacy === 'private' ? false : true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isUserInvited && group.type === 'guild') {
|
||||
if (user.guilds.indexOf(group._id) !== -1) { // if user is already a member (party is checked previously)
|
||||
throw new NotAuthorized(res.t('userAlreadyInGroup'));
|
||||
}
|
||||
user.guilds.push(group._id); // Add group to user's guilds
|
||||
}
|
||||
if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite'));
|
||||
|
||||
if (group.memberCount === 0) group.leader = user._id; // If new user is only member -> set as leader
|
||||
|
||||
group.memberCount += 1;
|
||||
|
||||
let promises = [group.save(), user.save()];
|
||||
|
||||
if (group.type === 'party' && inviter) {
|
||||
promises.push(User.update({_id: inviter}, {$inc: {'items.quests.basilist': 1}}).exec()); // Reward inviter
|
||||
if (group.memberCount > 1) {
|
||||
promises.push(User.update({$or: [{'party._id': group._id}, {_id: user._id}], 'achievements.partyUp': {$ne: true}}, {$set: {'achievements.partyUp': true}}, {multi: true}).exec());
|
||||
}
|
||||
if (group.memberCount > 3) {
|
||||
promises.push(User.update({$or: [{'party._id': group._id}, {_id: user._id}], 'achievements.partyOn': {$ne: true}}, {$set: {'achievements.partyOn': true}}, {multi: true}).exec());
|
||||
}
|
||||
}
|
||||
|
||||
promises = await Bluebird.all(promises);
|
||||
|
||||
let response = Group.toJSONCleanChat(promises[0], user);
|
||||
let leader = await User.findById(response.leader).select(nameFields).exec();
|
||||
if (leader) {
|
||||
response.leader = leader.toJSON({minimize: true});
|
||||
}
|
||||
res.respond(200, response);
|
||||
|
||||
firebase.addUserToGroup(group._id, user._id);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/reject Reject a group invitation
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName RejectGroupInvite
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
*
|
||||
* @apiSuccess {Object} data An empty object
|
||||
*/
|
||||
api.rejectGroupInvite = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/reject-invite',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party'
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let groupId = req.params.groupId;
|
||||
let isUserInvited = false;
|
||||
|
||||
if (groupId === user.invitations.party.id) {
|
||||
user.invitations.party = {};
|
||||
user.markModified('invitations.party');
|
||||
isUserInvited = true;
|
||||
} else {
|
||||
let hasInvitation = removeFromArray(user.invitations.guilds, { id: groupId });
|
||||
|
||||
if (hasInvitation) {
|
||||
isUserInvited = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite'));
|
||||
|
||||
await user.save();
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/leave Leave a group
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName LeaveGroup
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {string="remove-all","keep-all"} keep Query parameter - Whether to keep or not challenges' tasks. Defaults to keep-all
|
||||
*
|
||||
* @apiSuccess {Object} data An empty object
|
||||
*/
|
||||
api.leaveGroup = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/leave',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
// When removing the user from challenges, should we keep the tasks?
|
||||
req.checkQuery('keep', res.t('keepOrRemoveAll')).optional().isIn(['keep-all', 'remove-all']);
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat', requireMembership: true});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
// During quests, checke wheter 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);
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
// Send an email to the removed user with an optional message from the leader
|
||||
function _sendMessageToRemoved (group, removedUser, message) {
|
||||
if (removedUser.preferences.emailNotifications.kickedGroup !== false) {
|
||||
sendTxnEmail(removedUser, `kicked-from-${group.type}`, [
|
||||
{name: 'GROUP_NAME', content: group.name},
|
||||
{name: 'MESSAGE', content: message},
|
||||
{name: 'GUILDS_LINK', content: '/#/options/groups/guilds/public'},
|
||||
{name: 'PARTY_WANTED_GUILD', content: '/#/options/groups/guilds/f2db2a7f-13c5-454d-b3ee-ea1f5089e601'},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/removeMember/:memberId Remove a member from a group
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName RemoveGroupMember
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {UUID} memberId The _id of the member to remove
|
||||
* @apiParam {string} message Query parameter - The message to send to the removed members
|
||||
*
|
||||
* @apiSuccess {Object} data An empty object
|
||||
*/
|
||||
api.removeGroupMember = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/removeMember/:memberId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
req.checkParams('memberId', res.t('userIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); // Do not fetch chat
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
let uuid = req.params.memberId;
|
||||
|
||||
if (group.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember'));
|
||||
if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself'));
|
||||
|
||||
let member = await User.findOne({_id: uuid}).exec();
|
||||
|
||||
// We're removing the user from a guild or a party? is the user invited only?
|
||||
let isInGroup;
|
||||
if (member.party._id === group._id) {
|
||||
isInGroup = 'party';
|
||||
} else if (member.guilds.indexOf(group._id) !== -1) {
|
||||
isInGroup = 'guild';
|
||||
}
|
||||
|
||||
let isInvited;
|
||||
if (member.invitations.party && member.invitations.party.id === group._id) {
|
||||
isInvited = 'party';
|
||||
} else if (_.findIndex(member.invitations.guilds, {id: group._id}) !== -1) {
|
||||
isInvited = 'guild';
|
||||
}
|
||||
|
||||
if (isInGroup) {
|
||||
group.memberCount -= 1;
|
||||
|
||||
if (group.quest && group.quest.leader === member._id) {
|
||||
group.quest.key = undefined;
|
||||
group.quest.leader = undefined;
|
||||
} else if (group.quest && group.quest.members) {
|
||||
// remove member from quest
|
||||
group.quest.members[member._id] = undefined;
|
||||
group.markModified('quest.members');
|
||||
}
|
||||
|
||||
if (isInGroup === 'guild') {
|
||||
removeFromArray(member.guilds, group._id);
|
||||
}
|
||||
if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? Use group.leave()?
|
||||
|
||||
if (member.newMessages[group._id]) {
|
||||
member.newMessages[group._id] = undefined;
|
||||
member.markModified('newMessages');
|
||||
}
|
||||
|
||||
if (group.quest && group.quest.active && group.quest.leader === member._id) {
|
||||
member.items.quests[group.quest.key] += 1;
|
||||
}
|
||||
} else if (isInvited) {
|
||||
if (isInvited === 'guild') {
|
||||
removeFromArray(member.invitations.guilds, { id: group._id });
|
||||
}
|
||||
if (isInvited === 'party') {
|
||||
user.invitations.party = {};
|
||||
user.markModified('invitations.party');
|
||||
}
|
||||
} else {
|
||||
throw new NotFound(res.t('groupMemberNotFound'));
|
||||
}
|
||||
|
||||
let message = req.query.message;
|
||||
if (message) _sendMessageToRemoved(group, member, message);
|
||||
|
||||
await Bluebird.all([
|
||||
member.save(),
|
||||
group.save(),
|
||||
]);
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
async function _inviteByUUID (uuid, group, inviter, req, res) {
|
||||
let userToInvite = await User.findById(uuid).exec();
|
||||
|
||||
if (!userToInvite) {
|
||||
throw new NotFound(res.t('userWithIDNotFound', {userId: uuid}));
|
||||
}
|
||||
|
||||
if (group.type === 'guild') {
|
||||
if (_.contains(userToInvite.guilds, group._id)) {
|
||||
throw new NotAuthorized(res.t('userAlreadyInGroup'));
|
||||
}
|
||||
if (_.find(userToInvite.invitations.guilds, {id: group._id})) {
|
||||
throw new NotAuthorized(res.t('userAlreadyInvitedToGroup'));
|
||||
}
|
||||
userToInvite.invitations.guilds.push({id: group._id, name: group.name, inviter: inviter._id});
|
||||
} else if (group.type === 'party') {
|
||||
if (userToInvite.invitations.party.id) {
|
||||
throw new NotAuthorized(res.t('userAlreadyPendingInvitation'));
|
||||
}
|
||||
|
||||
if (userToInvite.party._id) {
|
||||
let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'});
|
||||
|
||||
// Allow user to be invited to a new party when they're partying solo
|
||||
if (userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty'));
|
||||
}
|
||||
|
||||
userToInvite.invitations.party = {id: group._id, name: group.name, inviter: inviter._id};
|
||||
}
|
||||
|
||||
let groupLabel = group.type === 'guild' ? 'Guild' : 'Party';
|
||||
let groupTemplate = group.type === 'guild' ? 'guild' : 'party';
|
||||
if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) {
|
||||
let emailVars = [
|
||||
{name: 'INVITER', content: inviter.profile.name},
|
||||
];
|
||||
|
||||
if (group.type === 'guild') {
|
||||
emailVars.push(
|
||||
{name: 'GUILD_NAME', content: group.name},
|
||||
{name: 'GUILD_URL', content: '/#/options/groups/guilds/public'}
|
||||
);
|
||||
} else {
|
||||
emailVars.push(
|
||||
{name: 'PARTY_NAME', content: group.name},
|
||||
{name: 'PARTY_URL', content: '/#/options/groups/party'}
|
||||
);
|
||||
}
|
||||
|
||||
sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars);
|
||||
}
|
||||
|
||||
sendPushNotification(
|
||||
userToInvite,
|
||||
common.i18n.t(group.type === 'guild' ? 'invitedGuild' : 'invitedParty'),
|
||||
group.name
|
||||
);
|
||||
|
||||
let userInvited = await userToInvite.save();
|
||||
if (group.type === 'guild') {
|
||||
return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1];
|
||||
} else if (group.type === 'party') {
|
||||
return userInvited.invitations.party;
|
||||
}
|
||||
}
|
||||
|
||||
async function _inviteByEmail (invite, group, inviter, req, res) {
|
||||
let userReturnInfo;
|
||||
|
||||
if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail'));
|
||||
|
||||
let userToContact = await User.findOne({$or: [
|
||||
{'auth.local.email': invite.email},
|
||||
{'auth.facebook.emails.value': invite.email},
|
||||
]})
|
||||
.select({_id: true, 'preferences.emailNotifications': true})
|
||||
.exec();
|
||||
|
||||
if (userToContact) {
|
||||
userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res);
|
||||
} else {
|
||||
userReturnInfo = invite.email;
|
||||
const groupQueryString = JSON.stringify({
|
||||
id: group._id,
|
||||
inviter: inviter._id,
|
||||
sentAt: Date.now(), // so we can let it expire
|
||||
});
|
||||
let link = `/static/front?groupInvite=${encrypt(groupQueryString)}`;
|
||||
|
||||
let variables = [
|
||||
{name: 'LINK', content: link},
|
||||
{name: 'INVITER', content: req.body.inviter || inviter.profile.name},
|
||||
];
|
||||
|
||||
if (group.type === 'guild') {
|
||||
variables.push({name: 'GUILD_NAME', content: group.name});
|
||||
}
|
||||
|
||||
// Check for the email address not to be unsubscribed
|
||||
let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec();
|
||||
let groupLabel = group.type === 'guild' ? '-guild' : '';
|
||||
if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables);
|
||||
}
|
||||
|
||||
return userReturnInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/invite Invite users to a group using their UUIDs or email addresses
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName InviteToGroup
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
*
|
||||
* @apiParam {array} emails Body parameter - An array of emails addresses to invite (optional)
|
||||
* @apiParam {array} uuids Body parameter - An array of uuids to invite (optional)
|
||||
* @apiParam {string} inviter Body parameter - The inviters' name (optional)
|
||||
*
|
||||
* @apiSuccess {array} data The invites
|
||||
*/
|
||||
api.inviteToGroup = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/invite',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
let uuids = req.body.uuids;
|
||||
let emails = req.body.emails;
|
||||
|
||||
let uuidsIsArray = Array.isArray(uuids);
|
||||
let emailsIsArray = Array.isArray(emails);
|
||||
|
||||
if (!uuids && !emails) {
|
||||
throw new BadRequest(res.t('canOnlyInviteEmailUuid'));
|
||||
}
|
||||
|
||||
let results = [];
|
||||
let totalInvites = 0;
|
||||
|
||||
if (uuids) {
|
||||
if (!uuidsIsArray) {
|
||||
throw new BadRequest(res.t('uuidsMustBeAnArray'));
|
||||
} else {
|
||||
totalInvites += uuids.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (emails) {
|
||||
if (!emailsIsArray) {
|
||||
throw new BadRequest(res.t('emailsMustBeAnArray'));
|
||||
} else {
|
||||
totalInvites += emails.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalInvites > INVITES_LIMIT) {
|
||||
throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}));
|
||||
}
|
||||
|
||||
if (uuids) {
|
||||
let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res));
|
||||
let uuidResults = await Bluebird.all(uuidInvites);
|
||||
results.push(...uuidResults);
|
||||
}
|
||||
|
||||
if (emails) {
|
||||
let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res));
|
||||
let emailResults = await Bluebird.all(emailInvites);
|
||||
results.push(...emailResults);
|
||||
}
|
||||
|
||||
res.respond(200, results);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,183 @@
|
||||
import { authWithHeaders } from '../../middlewares/api-v3/auth';
|
||||
import { ensureAdmin } from '../../middlewares/api-v3/ensureAccessRight';
|
||||
import { model as User } from '../../models/user';
|
||||
import {
|
||||
NotFound,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import _ from 'lodash';
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/hall/patrons Get all patrons
|
||||
* @apiDescription Only the first 50 patrons are returned. More can be accessed passing ?page=n
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetPatrons
|
||||
* @apiGroup Hall
|
||||
*
|
||||
* @apiParam {Number} page Query Parameter - The result page. Default is 0
|
||||
*
|
||||
* @apiSuccess {Array} data An array of patrons
|
||||
*/
|
||||
api.getPatrons = {
|
||||
method: 'GET',
|
||||
url: '/hall/patrons',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkQuery('page', res.t('pageMustBeNumber')).optional().isNumeric();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let page = req.query.page ? Number(req.query.page) : 0;
|
||||
const perPage = 50;
|
||||
|
||||
let patrons = await User
|
||||
.find({
|
||||
'backer.tier': {$gt: 0},
|
||||
})
|
||||
.select('contributor backer profile.name')
|
||||
.sort('-backer.tier')
|
||||
.skip(page * perPage)
|
||||
.limit(perPage)
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
res.respond(200, patrons);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/hall/heroes Get all Heroes
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetHeroes
|
||||
* @apiGroup Hall
|
||||
*
|
||||
* @apiSuccess {Array} data An array of heroes
|
||||
*/
|
||||
api.getHeroes = {
|
||||
method: 'GET',
|
||||
url: '/hall/heroes',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let heroes = await User
|
||||
.find({
|
||||
'contributor.level': {$gt: 0},
|
||||
})
|
||||
.select('contributor backer profile.name')
|
||||
.sort('-contributor.level')
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
res.respond(200, heroes);
|
||||
},
|
||||
};
|
||||
|
||||
// Note, while the following routes are called getHero / updateHero
|
||||
// they can be used by admins to get/update any user
|
||||
|
||||
const heroAdminFields = 'contributor balance profile.name purchased items auth';
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID
|
||||
* @apiDescription Must be an admin to make this request.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetHero
|
||||
* @apiGroup Hall
|
||||
*
|
||||
* @apiSuccess {Object} data The user object
|
||||
*/
|
||||
api.getHero = {
|
||||
method: 'GET',
|
||||
url: '/hall/heroes/:heroId',
|
||||
middlewares: [authWithHeaders(), ensureAdmin],
|
||||
async handler (req, res) {
|
||||
let heroId = req.params.heroId;
|
||||
|
||||
req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let hero = await User
|
||||
.findById(heroId)
|
||||
.select(heroAdminFields)
|
||||
.exec();
|
||||
|
||||
if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId}));
|
||||
let heroRes = hero.toJSON({minimize: true});
|
||||
// supply to the possible absence of hero.contributor
|
||||
// if we didn't pass minimize: true it would have returned all fields as empty
|
||||
if (!heroRes.contributor) heroRes.contributor = {};
|
||||
res.respond(200, heroRes);
|
||||
},
|
||||
};
|
||||
|
||||
// e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff
|
||||
const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0};
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/hall/heroes/:heroId Update any user ("hero")
|
||||
* @apiDescription Must be an admin to make this request.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UpdateHero
|
||||
* @apiGroup Hall
|
||||
*
|
||||
* @apiSuccess {Object} data The updated user object
|
||||
*/
|
||||
api.updateHero = {
|
||||
method: 'PUT',
|
||||
url: '/hall/heroes/:heroId',
|
||||
middlewares: [authWithHeaders(), ensureAdmin],
|
||||
async handler (req, res) {
|
||||
let heroId = req.params.heroId;
|
||||
let updateData = req.body;
|
||||
|
||||
req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let hero = await User.findById(heroId).exec();
|
||||
if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId}));
|
||||
|
||||
if (updateData.balance) hero.balance = updateData.balance;
|
||||
|
||||
// give them gems if they got an higher level
|
||||
let newTier = updateData.contributor && updateData.contributor.level; // tier = level in this context
|
||||
let oldTier = hero.contributor && hero.contributor.level || 0;
|
||||
if (newTier > oldTier) {
|
||||
hero.flags.contributor = true;
|
||||
let tierDiff = newTier - oldTier; // can be 2+ tier increases at once
|
||||
while (tierDiff) {
|
||||
hero.balance += gemsPerTier[newTier] / 4; // balance is in $
|
||||
tierDiff--;
|
||||
newTier--; // give them gems for the next tier down if they weren't aready that tier
|
||||
}
|
||||
}
|
||||
|
||||
if (updateData.contributor) _.assign(hero.contributor, updateData.contributor);
|
||||
if (updateData.purchased && updateData.purchased.ads) hero.purchased.ads = updateData.purchased.ads;
|
||||
|
||||
// give them the Dragon Hydra pet if they're above level 6
|
||||
if (hero.contributor.level >= 6) hero.items.pets['Dragon-Hydra'] = 5;
|
||||
if (updateData.itemPath && updateData.itemVal &&
|
||||
updateData.itemPath.indexOf('items.') === 0 &&
|
||||
User.schema.paths[updateData.itemPath]) {
|
||||
_.set(hero, updateData.itemPath, updateData.itemVal); // Sanitization at 5c30944 (deemed unnecessary)
|
||||
}
|
||||
|
||||
if (updateData.auth && _.isBoolean(updateData.auth.blocked)) hero.auth.blocked = updateData.auth.blocked;
|
||||
|
||||
let savedHero = await hero.save();
|
||||
let heroJSON = savedHero.toJSON();
|
||||
let responseHero = {_id: heroJSON._id}; // only respond with important fields
|
||||
heroAdminFields.split(' ').forEach(field => {
|
||||
_.set(responseHero, field, _.get(heroJSON, field));
|
||||
});
|
||||
|
||||
res.respond(200, responseHero);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,4 @@
|
||||
// NOTE: this file is only used because the mobile apps expect IAP routes
|
||||
// to be found at /api/v3/iap instead of /iap.
|
||||
|
||||
module.exports = require('../top-level/payments/iap');
|
||||
@@ -0,0 +1,364 @@
|
||||
import { authWithHeaders } from '../../middlewares/api-v3/auth';
|
||||
import {
|
||||
model as User,
|
||||
publicFields as memberFields,
|
||||
nameFields,
|
||||
} from '../../models/user';
|
||||
import { model as Group } from '../../models/group';
|
||||
import { model as Challenge } from '../../models/challenge';
|
||||
import {
|
||||
NotFound,
|
||||
NotAuthorized,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import * as Tasks from '../../models/task';
|
||||
import {
|
||||
getUserInfo,
|
||||
sendTxn as sendTxnEmail,
|
||||
} from '../../libs/api-v3/email';
|
||||
import Bluebird from 'bluebird';
|
||||
import sendPushNotification from '../../libs/api-v3/pushNotifications';
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/members/:memberId Get a member profile
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetMember
|
||||
* @apiGroup Member
|
||||
*
|
||||
* @apiParam {UUID} memberId The member's id
|
||||
*
|
||||
* @apiSuccess {object} data The member object
|
||||
*/
|
||||
api.getMember = {
|
||||
method: 'GET',
|
||||
url: '/members/:memberId',
|
||||
middlewares: [],
|
||||
async handler (req, res) {
|
||||
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let memberId = req.params.memberId;
|
||||
|
||||
let member = await User
|
||||
.findById(memberId)
|
||||
.select(memberFields)
|
||||
.exec();
|
||||
|
||||
if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
|
||||
|
||||
// manually call toJSON with minimize: true so empty paths aren't returned
|
||||
res.respond(200, member.toJSON({minimize: true}));
|
||||
},
|
||||
};
|
||||
|
||||
// Return a request handler for getMembersForGroup / getInvitesForGroup / getMembersForChallenge
|
||||
// type is `invites` or `members`
|
||||
function _getMembersForItem (type) {
|
||||
if (['group-members', 'group-invites', 'challenge-members'].indexOf(type) === -1) {
|
||||
throw new Error('Type must be one of "group-members", "group-invites", "challenge-members"');
|
||||
}
|
||||
|
||||
return async function handleGetMembersForItem (req, res) {
|
||||
if (type === 'challenge-members') {
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
} else {
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
}
|
||||
req.checkQuery('lastId').optional().notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let groupId = req.params.groupId;
|
||||
let challengeId = req.params.challengeId;
|
||||
let lastId = req.query.lastId;
|
||||
let user = res.locals.user;
|
||||
let challenge;
|
||||
let group;
|
||||
|
||||
if (type === 'challenge-members') {
|
||||
challenge = await Challenge.findById(challengeId).select('_id type leader group').exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
// optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge
|
||||
// for example if you've been booted from it, are the leader or a site admin
|
||||
group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
|
||||
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
|
||||
} else {
|
||||
group = await Group.getGroup({user, groupId, fields: '_id type'});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
}
|
||||
|
||||
let query = {};
|
||||
let fields = nameFields;
|
||||
|
||||
if (type === 'challenge-members') {
|
||||
query.challenges = challenge._id;
|
||||
} else if (type === 'group-members') {
|
||||
if (group.type === 'guild') {
|
||||
query.guilds = group._id;
|
||||
} else {
|
||||
query['party._id'] = group._id; // group._id and not groupId because groupId could be === 'party'
|
||||
|
||||
if (req.query.includeAllPublicFields === 'true') {
|
||||
fields = memberFields;
|
||||
}
|
||||
}
|
||||
} else if (type === 'group-invites') {
|
||||
if (group.type === 'guild') { // eslint-disable-line no-lonely-if
|
||||
query['invitations.guilds.id'] = group._id;
|
||||
} else {
|
||||
query['invitations.party.id'] = group._id; // group._id and not groupId because groupId could be === 'party'
|
||||
}
|
||||
}
|
||||
|
||||
if (lastId) query._id = {$gt: lastId};
|
||||
|
||||
let members = await User
|
||||
.find(query)
|
||||
.sort({_id: 1})
|
||||
.limit(30)
|
||||
.select(fields)
|
||||
.exec();
|
||||
|
||||
// manually call toJSON with minimize: true so empty paths aren't returned
|
||||
res.respond(200, members.map(member => member.toJSON({minimize: true})));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/groups/:groupId/members Get members for a group
|
||||
* @apiDescription With a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetMembersForGroup
|
||||
* @apiGroup Member
|
||||
*
|
||||
* @apiParam {UUID} groupId The group id
|
||||
* @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results
|
||||
* @apiParam {boolean} includeAllPublicFields Query parameter available only when fetching a party. If === `true` then all public fields for members will be returned (liek when making a request for a single member)
|
||||
*
|
||||
* @apiSuccess {array} data An array of members, sorted by _id
|
||||
*/
|
||||
api.getMembersForGroup = {
|
||||
method: 'GET',
|
||||
url: '/groups/:groupId/members',
|
||||
middlewares: [authWithHeaders()],
|
||||
handler: _getMembersForItem('group-members'),
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/groups/:groupId/invites Get invites for a group
|
||||
* @apiDescription With a limit of 30 member per request. To get all invites run requests against this routes (updating the lastId query parameter) until you get less than 30 results.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetInvitesForGroup
|
||||
* @apiGroup Member
|
||||
*
|
||||
* @apiParam {UUID} groupId The group id
|
||||
* @apiParam {UUID} lastId Query parameter to specify the last invite returned in a previous request to this route and get the next batch of results
|
||||
*
|
||||
* @apiSuccess {array} data An array of invites, sorted by _id
|
||||
*/
|
||||
api.getInvitesForGroup = {
|
||||
method: 'GET',
|
||||
url: '/groups/:groupId/invites',
|
||||
middlewares: [authWithHeaders()],
|
||||
handler: _getMembersForItem('group-invites'),
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/challenges/:challengeId/members Get members for a challenge
|
||||
* @apiDescription With a limit of 30 member per request. To get all members run requests against this routes (updating the lastId query parameter) until you get less than 30 results.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetMembersForChallenge
|
||||
* @apiGroup Member
|
||||
*
|
||||
* @apiParam {UUID} challengeId The challenge id
|
||||
* @apiParam {UUID} lastId Query parameter to specify the last member returned in a previous request to this route and get the next batch of results
|
||||
*
|
||||
* @apiSuccess {array} data An array of members, sorted by _id
|
||||
*/
|
||||
api.getMembersForChallenge = {
|
||||
method: 'GET',
|
||||
url: '/challenges/:challengeId/members',
|
||||
middlewares: [authWithHeaders()],
|
||||
handler: _getMembersForItem('challenge-members'),
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/challenges/:challengeId/members/:memberId Get a challenge member progress
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetChallenge
|
||||
* @apiGroup Challenge
|
||||
*
|
||||
* @apiParam {UUID} challengeId The challenge _id
|
||||
* @apiParam {UUID} member The member _id
|
||||
*
|
||||
* @apiSuccess {object} data Return an object with member _id, profile.name and a tasks object with the challenge tasks for the member
|
||||
*/
|
||||
api.getChallengeMemberProgress = {
|
||||
method: 'GET',
|
||||
url: '/challenges/:challengeId/members/:memberId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let user = res.locals.user;
|
||||
let challengeId = req.params.challengeId;
|
||||
let memberId = req.params.memberId;
|
||||
|
||||
let member = await User.findById(memberId).select(`${nameFields} challenges`).exec();
|
||||
if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
|
||||
|
||||
let challenge = await Challenge.findById(challengeId).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
// optionalMembership is set to true because even if you're not member of the group you may be able to access the challenge
|
||||
// for example if you've been booted from it, are the leader or a site admin
|
||||
let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
|
||||
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (!challenge.isMember(member)) throw new NotFound(res.t('challengeMemberNotFound'));
|
||||
|
||||
let chalTasks = await Tasks.Task.find({
|
||||
userId: memberId,
|
||||
'challenge.id': challengeId,
|
||||
})
|
||||
.select('-tags') // We don't want to return the tags publicly TODO same for other data?
|
||||
.exec();
|
||||
|
||||
// manually call toJSON with minimize: true so empty paths aren't returned
|
||||
let response = member.toJSON({minimize: true});
|
||||
delete response.challenges;
|
||||
response.tasks = chalTasks.map(chalTask => chalTask.toJSON({minimize: true}));
|
||||
res.respond(200, response);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {posts} /members/send-private-message Send a private message to a member
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName SendPrivateMessage
|
||||
* @apiGroup Members
|
||||
*
|
||||
* @apiParam {String} message Body parameter - The message
|
||||
* @apiParam {UUID} toUserId Body parameter - The user to contact
|
||||
*
|
||||
* @apiSuccess {Object} data An empty Object
|
||||
*/
|
||||
api.sendPrivateMessage = {
|
||||
method: 'POST',
|
||||
url: '/members/send-private-message',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkBody('message', res.t('messageRequired')).notEmpty();
|
||||
req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let sender = res.locals.user;
|
||||
let message = req.body.message;
|
||||
|
||||
let receiver = await User.findById(req.body.toUserId).exec();
|
||||
if (!receiver) throw new NotFound(res.t('userNotFound'));
|
||||
|
||||
let userBlockedSender = receiver.inbox.blocks.indexOf(sender._id) !== -1;
|
||||
let userIsBlockBySender = sender.inbox.blocks.indexOf(receiver._id) !== -1;
|
||||
let userOptedOutOfMessaging = receiver.inbox.optOut;
|
||||
|
||||
if (userBlockedSender || userIsBlockBySender || userOptedOutOfMessaging) {
|
||||
throw new NotAuthorized(res.t('notAuthorizedToSendMessageToThisUser'));
|
||||
}
|
||||
|
||||
await sender.sendMessage(receiver, message);
|
||||
|
||||
if (receiver.preferences.emailNotifications.newPM !== false) {
|
||||
sendTxnEmail(receiver, 'new-pm', [
|
||||
{name: 'SENDER', content: getUserInfo(sender, ['name']).name},
|
||||
{name: 'PMS_INBOX_URL', content: '/#/options/groups/inbox'},
|
||||
]);
|
||||
}
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {posts} /members/transfer-gems Send a gem gift to a member
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName TransferGems
|
||||
* @apiGroup Members
|
||||
*
|
||||
* @apiParam {String} message Body parameter The message
|
||||
* @apiParam {UUID} toUserId Body parameter The toUser _id
|
||||
* @apiParam {Integer} gemAmount Body parameter The number of gems to send
|
||||
*
|
||||
* @apiSuccess {Object} data An empty Object
|
||||
*/
|
||||
api.transferGems = {
|
||||
method: 'POST',
|
||||
url: '/members/transfer-gems',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkBody('toUserId', res.t('toUserIDRequired')).notEmpty().isUUID();
|
||||
req.checkBody('gemAmount', res.t('gemAmountRequired')).notEmpty().isInt();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let sender = res.locals.user;
|
||||
|
||||
let receiver = await User.findById(req.body.toUserId).exec();
|
||||
if (!receiver) throw new NotFound(res.t('userNotFound'));
|
||||
|
||||
if (receiver._id === sender._id) {
|
||||
throw new NotAuthorized(res.t('cannotSendGemsToYourself'));
|
||||
}
|
||||
|
||||
let gemAmount = req.body.gemAmount;
|
||||
let amount = gemAmount / 4;
|
||||
|
||||
if (amount <= 0 || sender.balance < amount) {
|
||||
throw new NotAuthorized(res.t('badAmountOfGemsToSend'));
|
||||
}
|
||||
|
||||
receiver.balance += amount;
|
||||
sender.balance -= amount;
|
||||
let promises = [receiver.save(), sender.save()];
|
||||
await Bluebird.all(promises);
|
||||
|
||||
let message = res.t('privateMessageGiftIntro', {
|
||||
receiverName: receiver.profile.name,
|
||||
senderName: sender.profile.name,
|
||||
});
|
||||
message += res.t('privateMessageGiftGemsMessage', {gemAmount});
|
||||
|
||||
if (req.body.message) {
|
||||
message += req.body.message;
|
||||
}
|
||||
|
||||
await sender.sendMessage(receiver, message);
|
||||
|
||||
let byUsername = getUserInfo(sender, ['name']).name;
|
||||
|
||||
if (receiver.preferences.emailNotifications.giftedGems !== false) {
|
||||
sendTxnEmail(receiver, 'gifted-gems', [
|
||||
{name: 'GIFTER', content: byUsername},
|
||||
{name: 'X_GEMS_GIFTED', content: gemAmount},
|
||||
]);
|
||||
}
|
||||
|
||||
sendPushNotification(sender, res.t('giftedGems'), res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername }));
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,40 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
let api = {};
|
||||
|
||||
let tasksModels = ['habit', 'daily', 'todo', 'reward'];
|
||||
let allModels = ['user', 'tag', 'challenge', 'group'].concat(tasksModels);
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/models/:model/paths Get all paths for the specified model
|
||||
* @apiDescription Doesn't require authentication
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetUserModelPaths
|
||||
* @apiGroup Meta
|
||||
*
|
||||
* @apiParam {string="user","group","challenge","tag","habit","daily","todo","reward"} model The name of the model
|
||||
*
|
||||
* @apiSuccess {object} data A key-value object made of fieldPath: fieldType (like {'field.nested': Boolean})
|
||||
*/
|
||||
api.getModelPaths = {
|
||||
method: 'GET',
|
||||
url: '/models/:model/paths',
|
||||
async handler (req, res) {
|
||||
req.checkParams('model', res.t('modelNotFound')).notEmpty().isIn(allModels);
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let model = req.params.model;
|
||||
// tasks models are lowercase, the others have the first letter uppercase (User, Group)
|
||||
if (tasksModels.indexOf(model) === -1) {
|
||||
model = model.charAt(0).toUpperCase() + model.slice(1);
|
||||
}
|
||||
|
||||
model = mongoose.model(model);
|
||||
|
||||
res.respond(200, model.getModelPaths());
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,451 @@
|
||||
import _ from 'lodash';
|
||||
import Bluebird from 'bluebird';
|
||||
import { authWithHeaders } from '../../middlewares/api-v3/auth';
|
||||
import analytics from '../../libs/api-v3/analyticsService';
|
||||
import {
|
||||
model as Group,
|
||||
} from '../../models/group';
|
||||
import { model as User } from '../../models/user';
|
||||
import {
|
||||
NotFound,
|
||||
NotAuthorized,
|
||||
BadRequest,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import {
|
||||
getUserInfo,
|
||||
sendTxn as sendTxnEmail,
|
||||
} from '../../libs/api-v3/email';
|
||||
import common from '../../../../common';
|
||||
import sendPushNotification from '../../libs/api-v3/pushNotifications';
|
||||
|
||||
const questScrolls = common.content.quests;
|
||||
|
||||
function canStartQuestAutomatically (group) {
|
||||
// If all members are either true (accepted) or false (rejected) return true
|
||||
// If any member is null/undefined (undecided) return false
|
||||
return _.every(group.quest.members, _.isBoolean);
|
||||
}
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/quests/invite Invite users to a quest
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName InviteToQuest
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} groupId The group _id (or 'party')
|
||||
* @apiParam {string} questKey
|
||||
*
|
||||
* @apiSuccess {Object} data Quest object
|
||||
*/
|
||||
api.inviteToQuest = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/quests/invite/:questKey',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let questKey = req.params.questKey;
|
||||
let quest = questScrolls[questKey];
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'});
|
||||
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
|
||||
if (!quest) throw new NotFound(res.t('questNotFound', { key: questKey }));
|
||||
if (!user.items.quests[questKey]) throw new NotAuthorized(res.t('questNotOwned'));
|
||||
if (user.stats.lvl < quest.lvl) throw new NotAuthorized(res.t('questLevelTooHigh', { level: quest.lvl }));
|
||||
if (group.quest.key) throw new NotAuthorized(res.t('questAlreadyUnderway'));
|
||||
|
||||
let members = await User.find({
|
||||
'party._id': group._id,
|
||||
_id: {$ne: user._id},
|
||||
}).select('auth.facebook auth.local preferences.emailNotifications profile.name pushDevices')
|
||||
.exec();
|
||||
|
||||
group.markModified('quest');
|
||||
group.quest.key = questKey;
|
||||
group.quest.leader = user._id;
|
||||
group.quest.members = {};
|
||||
group.quest.members[user._id] = true;
|
||||
|
||||
user.party.quest.RSVPNeeded = false;
|
||||
user.party.quest.key = questKey;
|
||||
|
||||
await User.update({
|
||||
'party._id': group._id,
|
||||
_id: {$ne: user._id},
|
||||
}, {
|
||||
$set: {
|
||||
'party.quest.RSVPNeeded': true,
|
||||
'party.quest.key': questKey,
|
||||
},
|
||||
}, {multi: true}).exec();
|
||||
|
||||
_.each(members, (member) => {
|
||||
group.quest.members[member._id] = null;
|
||||
});
|
||||
|
||||
if (canStartQuestAutomatically(group)) {
|
||||
await group.startQuest(user);
|
||||
}
|
||||
|
||||
let [savedGroup] = await Bluebird.all([
|
||||
group.save(),
|
||||
user.save(),
|
||||
]);
|
||||
|
||||
res.respond(200, savedGroup.quest);
|
||||
|
||||
// send out invites
|
||||
let inviterVars = getUserInfo(user, ['name', 'email']);
|
||||
let membersToEmail = members.filter(member => {
|
||||
// send push notifications while filtering members before sending emails
|
||||
sendPushNotification(
|
||||
member,
|
||||
common.i18n.t('questInvitationTitle'),
|
||||
common.i18n.t('questInvitationInfo', { quest: quest.text() })
|
||||
);
|
||||
|
||||
return member.preferences.emailNotifications.invitedQuest !== false;
|
||||
});
|
||||
sendTxnEmail(membersToEmail, `invite-${quest.boss ? 'boss' : 'collection'}-quest`, [
|
||||
{name: 'QUEST_NAME', content: quest.text()},
|
||||
{name: 'INVITER', content: inviterVars.name},
|
||||
{name: 'PARTY_URL', content: '/#/options/groups/party'},
|
||||
]);
|
||||
|
||||
// track that the inviting user has accepted the quest
|
||||
analytics.track('quest', {
|
||||
category: 'behavior',
|
||||
owner: true,
|
||||
response: 'accept',
|
||||
gaLabel: 'accept',
|
||||
questName: questKey,
|
||||
uuid: user._id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/quests/accept Accept a pending quest
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AcceptQuest
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} groupId The group _id (or 'party')
|
||||
*
|
||||
* @apiSuccess {Object} data Quest Object
|
||||
*/
|
||||
api.acceptQuest = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/quests/accept',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'});
|
||||
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
|
||||
if (!group.quest.key) throw new NotFound(res.t('questInviteNotFound'));
|
||||
if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway'));
|
||||
if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted'));
|
||||
|
||||
group.markModified('quest');
|
||||
group.quest.members[user._id] = true;
|
||||
user.party.quest.RSVPNeeded = false;
|
||||
|
||||
if (canStartQuestAutomatically(group)) {
|
||||
await group.startQuest(user);
|
||||
}
|
||||
|
||||
let [savedGroup] = await Bluebird.all([
|
||||
group.save(),
|
||||
user.save(),
|
||||
]);
|
||||
|
||||
res.respond(200, savedGroup.quest);
|
||||
|
||||
// track that a user has accepted the quest
|
||||
analytics.track('quest', {
|
||||
category: 'behavior',
|
||||
owner: false,
|
||||
response: 'accept',
|
||||
gaLabel: 'accept',
|
||||
questName: group.quest.key,
|
||||
uuid: user._id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/quests/reject Reject a quest
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName RejectQuest
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} groupId The group _id (or 'party')
|
||||
*
|
||||
* @apiSuccess {Object} data Quest Object
|
||||
*/
|
||||
api.rejectQuest = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/quests/reject',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest'});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
|
||||
if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist'));
|
||||
if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway'));
|
||||
if (group.quest.members[user._id]) throw new BadRequest(res.t('questAlreadyAccepted'));
|
||||
if (group.quest.members[user._id] === false) throw new BadRequest(res.t('questAlreadyRejected'));
|
||||
|
||||
group.quest.members[user._id] = false;
|
||||
group.markModified('quest.members');
|
||||
|
||||
user.party.quest = Group.cleanQuestProgress();
|
||||
user.markModified('party.quest');
|
||||
|
||||
if (canStartQuestAutomatically(group)) {
|
||||
await group.startQuest(user);
|
||||
}
|
||||
|
||||
let [savedGroup] = await Bluebird.all([
|
||||
group.save(),
|
||||
user.save(),
|
||||
]);
|
||||
|
||||
res.respond(200, savedGroup.quest);
|
||||
|
||||
analytics.track('quest', {
|
||||
category: 'behavior',
|
||||
owner: false,
|
||||
response: 'reject',
|
||||
gaLabel: 'reject',
|
||||
questName: group.quest.key,
|
||||
uuid: user._id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/quests/force-start Force-start a pending quest
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ForceQuestStart
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} groupId The group _id (or 'party')
|
||||
*
|
||||
* @apiSuccess {Object} data Quest Object
|
||||
*/
|
||||
api.forceStart = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/quests/force-start',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'type quest leader'});
|
||||
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
|
||||
if (!group.quest.key) throw new NotFound(res.t('questNotPending'));
|
||||
if (group.quest.active) throw new NotAuthorized(res.t('questAlreadyUnderway'));
|
||||
if (!(user._id === group.quest.leader || user._id === group.leader)) throw new NotAuthorized(res.t('questOrGroupLeaderOnlyStartQuest'));
|
||||
|
||||
group.markModified('quest');
|
||||
|
||||
await group.startQuest(user);
|
||||
|
||||
let [savedGroup] = await Bluebird.all([
|
||||
group.save(),
|
||||
user.save(),
|
||||
]);
|
||||
|
||||
res.respond(200, savedGroup.quest);
|
||||
|
||||
analytics.track('quest', {
|
||||
category: 'behavior',
|
||||
owner: user._id === group.quest.leader,
|
||||
response: 'force-start',
|
||||
gaLabel: 'force-start',
|
||||
questName: group.quest.key,
|
||||
uuid: user._id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/quests/cancel Cancels a quest
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName CancelQuest
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} groupId The group _id (or 'party')
|
||||
*
|
||||
* @apiSuccess {Object} data Quest Object
|
||||
*/
|
||||
api.cancelQuest = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/quests/cancel',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
// Cancel a quest BEFORE it has begun (i.e., in the invitation stage)
|
||||
// Quest scroll has not yet left quest owner's inventory so no need to return it.
|
||||
// Do not wipe quest progress for members because they'll want it to be applied to the next quest that's started.
|
||||
let user = res.locals.user;
|
||||
let groupId = req.params.groupId;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId, fields: 'type leader quest'});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
|
||||
if (!group.quest.key) throw new NotFound(res.t('questInvitationDoesNotExist'));
|
||||
if (user._id !== group.leader && group.quest.leader !== user._id) throw new NotAuthorized(res.t('onlyLeaderCancelQuest'));
|
||||
if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest'));
|
||||
|
||||
group.quest = Group.cleanGroupQuest();
|
||||
group.markModified('quest');
|
||||
|
||||
let [savedGroup] = await Bluebird.all([
|
||||
group.save(),
|
||||
User.update(
|
||||
{'party._id': groupId},
|
||||
{$set: {'party.quest': Group.cleanQuestProgress()}},
|
||||
{multi: true}
|
||||
),
|
||||
]);
|
||||
|
||||
res.respond(200, savedGroup.quest);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/quests/abort Abort the current quest
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AbortQuest
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} groupId The group _id (or 'party')
|
||||
*
|
||||
* @apiSuccess {Object} data Quest Object
|
||||
*/
|
||||
api.abortQuest = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/quests/abort',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
// Abort a quest AFTER it has begun (see questCancel for BEFORE)
|
||||
let user = res.locals.user;
|
||||
let groupId = req.params.groupId;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId, fields: 'type quest leader'});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
|
||||
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'));
|
||||
|
||||
let memberUpdates = User.update({
|
||||
'party._id': groupId,
|
||||
}, {
|
||||
$set: {'party.quest': Group.cleanQuestProgress()},
|
||||
}, {multi: true}).exec();
|
||||
|
||||
let questLeaderUpdate = User.update({
|
||||
_id: group.quest.leader,
|
||||
}, {
|
||||
$inc: {
|
||||
[`items.quests.${group.quest.key}`]: 1, // give back the quest to the quest leader
|
||||
},
|
||||
}).exec();
|
||||
|
||||
group.quest = Group.cleanGroupQuest();
|
||||
group.markModified('quest');
|
||||
|
||||
let [groupSaved] = await Bluebird.all([group.save(), memberUpdates, questLeaderUpdate]);
|
||||
|
||||
res.respond(200, groupSaved.quest);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/groups/:groupId/quests/leave Leaves the active quest
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName LeaveQuest
|
||||
* @apiGroup Group
|
||||
*
|
||||
* @apiParam {string} groupId The group _id (or 'party')
|
||||
*
|
||||
* @apiSuccess {Object} data Quest Object
|
||||
*/
|
||||
api.leaveQuest = {
|
||||
method: 'POST',
|
||||
url: '/groups/:groupId/quests/leave',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let groupId = req.params.groupId;
|
||||
|
||||
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let group = await Group.getGroup({user, groupId, fields: 'type quest'});
|
||||
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
if (group.type !== 'party') throw new NotAuthorized(res.t('guildQuestsNotSupported'));
|
||||
if (!group.quest.active) throw new NotFound(res.t('noActiveQuestToLeave'));
|
||||
if (group.quest.leader === user._id) throw new NotAuthorized(res.t('questLeaderCannotLeaveQuest'));
|
||||
if (!group.quest.members[user._id]) throw new NotAuthorized(res.t('notPartOfQuest'));
|
||||
|
||||
group.quest.members[user._id] = false;
|
||||
group.markModified('quest.members');
|
||||
|
||||
user.party.quest = Group.cleanQuestProgress();
|
||||
user.markModified('party.quest');
|
||||
|
||||
let [savedGroup] = await Bluebird.all([
|
||||
group.save(),
|
||||
user.save(),
|
||||
]);
|
||||
|
||||
res.respond(200, savedGroup.quest);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,21 @@
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/status Get Habitica's API status
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetStatus
|
||||
* @apiGroup Status
|
||||
*
|
||||
* @apiSuccess {status} data.status 'up' if everything is ok
|
||||
*/
|
||||
api.getStatus = {
|
||||
method: 'GET',
|
||||
url: '/status',
|
||||
async handler (req, res) {
|
||||
res.respond(200, {
|
||||
status: 'up',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,190 @@
|
||||
import { authWithHeaders } from '../../middlewares/api-v3/auth';
|
||||
import { model as Tag } from '../../models/tag';
|
||||
import * as Tasks from '../../models/task';
|
||||
import {
|
||||
NotFound,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import _ from 'lodash';
|
||||
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/tags Create a new tag
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName CreateTag
|
||||
* @apiGroup Tag
|
||||
*
|
||||
* @apiSuccess {Object} data The newly created tag
|
||||
*/
|
||||
api.createTag = {
|
||||
method: 'POST',
|
||||
url: '/tags',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
user.tags.push(Tag.sanitize(req.body));
|
||||
let savedUser = await user.save();
|
||||
|
||||
let l = savedUser.tags.length;
|
||||
let tag = savedUser.tags[l - 1];
|
||||
res.respond(201, tag);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/tag Get a user's tags
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetTags
|
||||
* @apiGroup Tag
|
||||
*
|
||||
* @apiSuccess {Array} data An array of tags
|
||||
*/
|
||||
api.getTags = {
|
||||
method: 'GET',
|
||||
url: '/tags',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
res.respond(200, user.tags);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/tags/:tagId Get a tag given its id
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetTag
|
||||
* @apiGroup Tag
|
||||
*
|
||||
* @apiParam {UUID} tagId The tag _id
|
||||
*
|
||||
* @apiSuccess {object} data The tag object
|
||||
*/
|
||||
api.getTag = {
|
||||
method: 'GET',
|
||||
url: '/tags/:tagId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let tag = _.find(user.tags, {id: req.params.tagId});
|
||||
if (!tag) throw new NotFound(res.t('tagNotFound'));
|
||||
res.respond(200, tag);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/tag/:tagId Update a tag
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UpdateTag
|
||||
* @apiGroup Tag
|
||||
*
|
||||
* @apiParam {UUID} tagId The tag _id
|
||||
*
|
||||
* @apiSuccess {object} data The updated tag
|
||||
*/
|
||||
api.updateTag = {
|
||||
method: 'PUT',
|
||||
url: '/tags/:tagId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let tagId = req.params.tagId;
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let tag = _.find(user.tags, {id: tagId});
|
||||
if (!tag) throw new NotFound(res.t('tagNotFound'));
|
||||
|
||||
_.merge(tag, Tag.sanitize(req.body));
|
||||
|
||||
let savedUser = await user.save();
|
||||
res.respond(200, _.find(savedUser.tags, {id: tagId}));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/reorder-tags Reorder a tag
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ReorderTags
|
||||
* @apiGroup Tag
|
||||
*
|
||||
* @apiParam {tagId} UUID Id of the tag to move
|
||||
* @apiParam {to} number Position the tag is moving to
|
||||
*
|
||||
* @apiSuccess {object} data An empty object
|
||||
*/
|
||||
api.reorderTags = {
|
||||
method: 'POST',
|
||||
url: '/reorder-tags',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkBody('to', res.t('toRequired')).notEmpty();
|
||||
req.checkBody('tagId', res.t('tagIdRequired')).notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let tagIndex = _.findIndex(user.tags, function findTag (tag) {
|
||||
return tag.id === req.body.tagId;
|
||||
});
|
||||
if (tagIndex === -1) throw new NotFound(res.t('tagNotFound'));
|
||||
user.tags.splice(req.body.to, 0, user.tags.splice(tagIndex, 1)[0]);
|
||||
|
||||
await user.save();
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {delete} /api/v3/tag/:tagId Delete a user tag given its id
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName DeleteTag
|
||||
* @apiGroup Tag
|
||||
*
|
||||
* @apiParam {UUID} tagId The tag _id
|
||||
*
|
||||
* @apiSuccess {object} data An empty object
|
||||
*/
|
||||
api.deleteTag = {
|
||||
method: 'DELETE',
|
||||
url: '/tags/:tagId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let tag = removeFromArray(user.tags, { id: req.params.tagId });
|
||||
if (!tag) throw new NotFound(res.t('tagNotFound'));
|
||||
|
||||
// Remove from all the tasks TODO test
|
||||
await Tasks.Task.update({
|
||||
userId: user._id,
|
||||
}, {
|
||||
$pull: {
|
||||
tags: tag.id,
|
||||
},
|
||||
}, {multi: true}).exec();
|
||||
|
||||
await user.save();
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,971 @@
|
||||
import { authWithHeaders } from '../../middlewares/api-v3/auth';
|
||||
import { sendTaskWebhook } from '../../libs/api-v3/webhook';
|
||||
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
|
||||
import * as Tasks from '../../models/task';
|
||||
import { model as Challenge } from '../../models/challenge';
|
||||
import { model as Group } from '../../models/group';
|
||||
import {
|
||||
NotFound,
|
||||
NotAuthorized,
|
||||
BadRequest,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import common from '../../../../common';
|
||||
import Bluebird from 'bluebird';
|
||||
import _ from 'lodash';
|
||||
import logger from '../../libs/api-v3/logger';
|
||||
|
||||
let api = {};
|
||||
|
||||
// challenge must be passed only when a challenge task is being created
|
||||
async function _createTasks (req, res, user, challenge) {
|
||||
let toSave = Array.isArray(req.body) ? req.body : [req.body];
|
||||
|
||||
toSave = toSave.map(taskData => {
|
||||
// Validate that task.type is valid
|
||||
if (!taskData || Tasks.tasksTypes.indexOf(taskData.type) === -1) throw new BadRequest(res.t('invalidTaskType'));
|
||||
|
||||
let taskType = taskData.type;
|
||||
let newTask = new Tasks[taskType](Tasks.Task.sanitize(taskData));
|
||||
|
||||
if (challenge) {
|
||||
newTask.challenge.id = challenge.id;
|
||||
} else {
|
||||
newTask.userId = user._id;
|
||||
}
|
||||
|
||||
// Validate that the task is valid and throw if it isn't
|
||||
// otherwise since we're saving user/challenge and task in parallel it could save the user/challenge with a tasksOrder that doens't match reality
|
||||
let validationErrors = newTask.validateSync();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
// Otherwise update the user/challenge
|
||||
(challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id);
|
||||
|
||||
return newTask;
|
||||
}).map(task => task.save({ // If all tasks are valid (this is why it's not in the previous .map()), save everything, withough running validation again
|
||||
validateBeforeSave: false,
|
||||
}));
|
||||
|
||||
toSave.unshift((challenge || user).save());
|
||||
|
||||
let tasks = await Bluebird.all(toSave);
|
||||
tasks.splice(0, 1); // Remove user or challenge
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/tasks/user Create a new task belonging to the user
|
||||
* @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName CreateUserTasks
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiSuccess data An object if a single task was created, otherwise an array of tasks
|
||||
*/
|
||||
api.createUserTasks = {
|
||||
method: 'POST',
|
||||
url: '/tasks/user',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let tasks = await _createTasks(req, res, res.locals.user);
|
||||
res.respond(201, tasks.length === 1 ? tasks[0] : tasks);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/tasks/challenge/:challengeId Create a new task belonging to a challenge
|
||||
* @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName CreateChallengeTasks
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} challengeId The id of the challenge the new task(s) will belong to
|
||||
*
|
||||
* @apiSuccess data An object if a single task was created, otherwise an array of tasks
|
||||
*/
|
||||
api.createChallengeTasks = {
|
||||
method: 'POST',
|
||||
url: '/tasks/challenge/:challengeId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let reqValidationErrors = req.validationErrors();
|
||||
if (reqValidationErrors) throw reqValidationErrors;
|
||||
|
||||
let user = res.locals.user;
|
||||
let challengeId = req.params.challengeId;
|
||||
|
||||
let challenge = await Challenge.findOne({_id: challengeId}).exec();
|
||||
|
||||
// If the challenge does not exist, or if it exists but user is not the leader -> throw error
|
||||
if (!challenge || user.challenges.indexOf(challengeId) === -1) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
|
||||
let tasks = await _createTasks(req, res, user, challenge);
|
||||
|
||||
res.respond(201, tasks.length === 1 ? tasks[0] : tasks);
|
||||
|
||||
// If adding tasks to a challenge -> sync users
|
||||
if (challenge) challenge.addTasks(tasks);
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
// challenge must be passed only when a challenge task is being created
|
||||
async function _getTasks (req, res, user, challenge) {
|
||||
let query = challenge ? {'challenge.id': challenge.id, userId: {$exists: false}} : {userId: user._id};
|
||||
let type = req.query.type;
|
||||
|
||||
if (type) {
|
||||
if (type === 'todos') {
|
||||
query.completed = false; // Exclude completed todos
|
||||
query.type = 'todo';
|
||||
} else if (type === 'completedTodos') {
|
||||
query = Tasks.Task.find({
|
||||
userId: user._id,
|
||||
type: 'todo',
|
||||
completed: true,
|
||||
}).limit(30).sort({ // TODO add ability to pick more than 30 completed todos
|
||||
dateCompleted: -1,
|
||||
});
|
||||
} else {
|
||||
query.type = type.slice(0, -1); // removing the final "s"
|
||||
}
|
||||
} else {
|
||||
query.$or = [ // Exclude completed todos
|
||||
{type: 'todo', completed: false},
|
||||
{type: {$in: ['habit', 'daily', 'reward']}},
|
||||
];
|
||||
}
|
||||
|
||||
let tasks = await Tasks.Task.find(query).exec();
|
||||
|
||||
// Order tasks based on tasksOrder
|
||||
if (type && type !== 'completedTodos') {
|
||||
let order = (challenge || user).tasksOrder[type];
|
||||
let orderedTasks = new Array(tasks.length);
|
||||
let unorderedTasks = []; // what we want to add later
|
||||
|
||||
tasks.forEach((task, index) => {
|
||||
let taskId = task._id;
|
||||
let i = order[index] === taskId ? index : order.indexOf(taskId);
|
||||
if (i === -1) {
|
||||
unorderedTasks.push(task);
|
||||
} else {
|
||||
orderedTasks[i] = task;
|
||||
}
|
||||
});
|
||||
|
||||
// Remove empty values from the array and add any unordered task
|
||||
orderedTasks = _.compact(orderedTasks).concat(unorderedTasks);
|
||||
res.respond(200, orderedTasks);
|
||||
} else {
|
||||
res.respond(200, tasks);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/tasks/user Get a user's tasks
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetUserTasks
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {string="habits","dailys","todos","rewards","completedTodos"} type Optional query parameter to return just a type of tasks. By default all types will be returned except completed todos that must be requested separately.
|
||||
*
|
||||
* @apiSuccess {Array} data An array of tasks
|
||||
*/
|
||||
api.getUserTasks = {
|
||||
method: 'GET',
|
||||
url: '/tasks/user',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let types = Tasks.tasksTypes.map(type => `${type}s`);
|
||||
types.push('completedTodos');
|
||||
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
return await _getTasks(req, res, res.locals.user);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/tasks/challenge/:challengeId Get a challenge's tasks
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetChallengeTasks
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} challengeId The id of the challenge from which to retrieve the tasks
|
||||
* @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks
|
||||
*
|
||||
* @apiSuccess {Array} data An array of tasks
|
||||
*/
|
||||
api.getChallengeTasks = {
|
||||
method: 'GET',
|
||||
url: '/tasks/challenge/:challengeId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
let types = Tasks.tasksTypes.map(type => `${type}s`);
|
||||
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let user = res.locals.user;
|
||||
let challengeId = req.params.challengeId;
|
||||
|
||||
let challenge = await Challenge.findOne({_id: challengeId}).select('group leader tasksOrder').exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
let group = await Group.getGroup({user, groupId: challenge.group, fields: '_id type privacy', optionalMembership: true});
|
||||
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
|
||||
|
||||
return await _getTasks(req, res, res.locals.user, challenge);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/task/:taskId Get a task
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName GetTask
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} taskId The task _id
|
||||
*
|
||||
* @apiSuccess {object} data The task object
|
||||
*/
|
||||
api.getTask = {
|
||||
method: 'GET',
|
||||
url: '/tasks/:taskId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let task = await Tasks.Task.findOne({
|
||||
_id: req.params.taskId,
|
||||
}).exec();
|
||||
|
||||
if (!task) {
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
|
||||
let challenge = await Challenge.find({_id: task.challenge.id}).select('leader').exec();
|
||||
if (!challenge || (user.challenges.indexOf(task.challenge.id) === -1 && challenge.leader !== user._id && !user.contributor.admin)) { // eslint-disable-line no-extra-parens
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
}
|
||||
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
}
|
||||
|
||||
res.respond(200, task);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/task/:taskId Update a task
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UpdateTask
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} taskId The task _id
|
||||
*
|
||||
* @apiSuccess {object} data The updated task
|
||||
*/
|
||||
api.updateTask = {
|
||||
method: 'PUT',
|
||||
url: '/tasks/:taskId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let challenge;
|
||||
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let task = await Tasks.Task.findOne({
|
||||
_id: req.params.taskId,
|
||||
}).exec();
|
||||
|
||||
if (!task) {
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
|
||||
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
}
|
||||
|
||||
// we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances?
|
||||
let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req);
|
||||
|
||||
|
||||
// Sanitize differently user tasks linked to a challenge
|
||||
let sanitizedObj;
|
||||
|
||||
if (!challenge && task.userId && task.challenge && task.challenge.id) {
|
||||
sanitizedObj = Tasks.Task.sanitizeUserChallengeTask(updatedTaskObj);
|
||||
} else {
|
||||
sanitizedObj = Tasks.Task.sanitize(updatedTaskObj);
|
||||
}
|
||||
|
||||
_.assign(task, sanitizedObj);
|
||||
// console.log(task.modifiedPaths(), task.toObject().repeat === tep)
|
||||
// repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject()
|
||||
// see https://github.com/Automattic/mongoose/issues/2749
|
||||
|
||||
let savedTask = await task.save();
|
||||
res.respond(200, savedTask);
|
||||
if (challenge) challenge.updateTask(savedTask);
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
function _generateWebhookTaskData (task, direction, delta, stats, user) {
|
||||
let extendedStats = _.extend(stats, {
|
||||
toNextLevel: common.tnl(user.stats.lvl),
|
||||
maxHealth: common.maxHealth,
|
||||
maxMP: common.statsComputed(user).maxMP,
|
||||
});
|
||||
|
||||
let userData = {
|
||||
_id: user._id,
|
||||
_tmp: user._tmp,
|
||||
stats: extendedStats,
|
||||
};
|
||||
|
||||
let taskData = {
|
||||
details: task,
|
||||
direction,
|
||||
delta,
|
||||
};
|
||||
|
||||
return {
|
||||
task: taskData,
|
||||
user: userData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/tasks/:taskId/score/:direction Score a task
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ScoreTask
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} taskId The task _id
|
||||
* @apiParam {string="up","down"} direction The direction for scoring the task
|
||||
*
|
||||
* @apiSuccess {object} data._tmp If an item was dropped it'll be returned in te _tmp object
|
||||
* @apiSuccess {number} data.delta
|
||||
* @apiSuccess {object} data The user stats
|
||||
*/
|
||||
api.scoreTask = {
|
||||
method: 'POST',
|
||||
url: '/tasks/:taskId/score/:direction',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']);
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let user = res.locals.user;
|
||||
let direction = req.params.direction;
|
||||
|
||||
let task = await Tasks.Task.findOne({
|
||||
_id: req.params.taskId,
|
||||
userId: user._id,
|
||||
}).exec();
|
||||
|
||||
if (!task) throw new NotFound(res.t('taskNotFound'));
|
||||
|
||||
let wasCompleted = task.completed;
|
||||
|
||||
let [delta] = common.ops.scoreTask({task, user, direction}, req);
|
||||
// Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results)
|
||||
if (direction === 'up') user.fns.randomDrop({task, delta}, req);
|
||||
|
||||
// If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list
|
||||
// TODO move to common code?
|
||||
if (task.type === 'todo') {
|
||||
if (!wasCompleted && task.completed) {
|
||||
removeFromArray(user.tasksOrder.todos, task._id);
|
||||
} else if (wasCompleted && !task.completed) {
|
||||
let hasTask = removeFromArray(user.tasksOrder.todos, task._id);
|
||||
if (!hasTask) {
|
||||
user.tasksOrder.todos.push(task._id);
|
||||
} // If for some reason it hadn't been removed previously don't do anything
|
||||
}
|
||||
}
|
||||
|
||||
let results = await Bluebird.all([
|
||||
user.save(),
|
||||
task.save(),
|
||||
]);
|
||||
|
||||
let savedUser = results[0];
|
||||
|
||||
let userStats = savedUser.stats.toJSON();
|
||||
let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats);
|
||||
res.respond(200, resJsonData);
|
||||
|
||||
sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user));
|
||||
|
||||
if (task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') {
|
||||
// Wrapping everything in a try/catch block because if an error occurs using `await` it MUST NOT bubble up because the request has already been handled
|
||||
try {
|
||||
let chalTask = await Tasks.Task.findOne({
|
||||
_id: task.challenge.taskId,
|
||||
}).exec();
|
||||
|
||||
await chalTask.scoreChallengeTask(delta);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/tasks/:taskId/move/to/:position Move a task to a new position
|
||||
* @apiDescription Note: completed To-Dos are not sortable, do not appear in user.tasksOrder.todos, and are ordered by date of completion.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName MoveTask
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} taskId The task _id
|
||||
* @apiParam {Number} position Query parameter - Where to move the task (-1 means push to bottom). First position is 0
|
||||
*
|
||||
* @apiSuccess {array} data The new tasks order (user.tasksOrder.{task.type}s)
|
||||
*/
|
||||
api.moveTask = {
|
||||
method: 'POST',
|
||||
url: '/tasks/:taskId/move/to/:position',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let user = res.locals.user;
|
||||
let to = Number(req.params.position);
|
||||
|
||||
let task = await Tasks.Task.findOne({
|
||||
_id: req.params.taskId,
|
||||
userId: user._id,
|
||||
}).exec();
|
||||
|
||||
if (!task) throw new NotFound(res.t('taskNotFound'));
|
||||
if (task.type === 'todo' && task.completed) throw new BadRequest(res.t('cantMoveCompletedTodo'));
|
||||
let order = user.tasksOrder[`${task.type}s`];
|
||||
let currentIndex = order.indexOf(task._id);
|
||||
|
||||
// If for some reason the task isn't ordered (should never happen), push it in the new position
|
||||
// if the task is moved to a non existing position
|
||||
// or if the task is moved to position -1 (push to bottom)
|
||||
// -> push task at end of list
|
||||
if (!order[to] && to !== -1) {
|
||||
order.push(task._id);
|
||||
} else {
|
||||
if (currentIndex !== -1) order.splice(currentIndex, 1);
|
||||
if (to === -1) {
|
||||
order.push(task._id);
|
||||
} else {
|
||||
order.splice(to, 0, task._id);
|
||||
}
|
||||
}
|
||||
|
||||
await user.save();
|
||||
res.respond(200, order);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/tasks/:taskId/checklist Add an item to the task's checklist
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AddChecklistItem
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} taskId The task _id
|
||||
*
|
||||
* @apiSuccess {object} data The updated task
|
||||
*/
|
||||
api.addChecklistItem = {
|
||||
method: 'POST',
|
||||
url: '/tasks/:taskId/checklist',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let challenge;
|
||||
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let task = await Tasks.Task.findOne({
|
||||
_id: req.params.taskId,
|
||||
}).exec();
|
||||
|
||||
if (!task) {
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
|
||||
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
}
|
||||
|
||||
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
|
||||
|
||||
task.checklist.push(Tasks.Task.sanitizeChecklist(req.body));
|
||||
let savedTask = await task.save();
|
||||
|
||||
res.respond(200, savedTask);
|
||||
if (challenge) challenge.updateTask(savedTask);
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/tasks/:taskId/checklist/:itemId/score Score a checklist item
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ScoreChecklistItem
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} taskId The task _id
|
||||
* @apiParam {UUID} itemId The checklist item _id
|
||||
*
|
||||
* @apiSuccess {object} data The updated task
|
||||
*/
|
||||
api.scoreCheckListItem = {
|
||||
method: 'POST',
|
||||
url: '/tasks/:taskId/checklist/:itemId/score',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let task = await Tasks.Task.findOne({
|
||||
_id: req.params.taskId,
|
||||
userId: user._id,
|
||||
}).exec();
|
||||
|
||||
if (!task) throw new NotFound(res.t('taskNotFound'));
|
||||
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
|
||||
|
||||
let item = _.find(task.checklist, {id: req.params.itemId});
|
||||
|
||||
if (!item) throw new NotFound(res.t('checklistItemNotFound'));
|
||||
item.completed = !item.completed;
|
||||
let savedTask = await task.save();
|
||||
|
||||
res.respond(200, savedTask);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/tasks/:taskId/checklist/:itemId Update a checklist item
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UpdateChecklistItem
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} taskId The task _id
|
||||
* @apiParam {UUID} itemId The checklist item _id
|
||||
*
|
||||
* @apiSuccess {object} data The updated task
|
||||
*/
|
||||
api.updateChecklistItem = {
|
||||
method: 'PUT',
|
||||
url: '/tasks/:taskId/checklist/:itemId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let challenge;
|
||||
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let task = await Tasks.Task.findOne({
|
||||
_id: req.params.taskId,
|
||||
}).exec();
|
||||
|
||||
if (!task) {
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
|
||||
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
}
|
||||
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
|
||||
|
||||
let item = _.find(task.checklist, {id: req.params.itemId});
|
||||
if (!item) throw new NotFound(res.t('checklistItemNotFound'));
|
||||
|
||||
_.merge(item, Tasks.Task.sanitizeChecklist(req.body));
|
||||
let savedTask = await task.save();
|
||||
|
||||
res.respond(200, savedTask);
|
||||
if (challenge) challenge.updateTask(savedTask);
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {delete} /api/v3/tasks/:taskId/checklist/:itemId Remove a checklist item
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName RemoveChecklistItem
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} taskId The task _id
|
||||
* @apiParam {UUID} itemId The checklist item _id
|
||||
*
|
||||
* @apiSuccess {object} data The updated task
|
||||
*/
|
||||
api.removeChecklistItem = {
|
||||
method: 'DELETE',
|
||||
url: '/tasks/:taskId/checklist/:itemId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let challenge;
|
||||
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let task = await Tasks.Task.findOne({
|
||||
_id: req.params.taskId,
|
||||
}).exec();
|
||||
|
||||
if (!task) {
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
|
||||
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
}
|
||||
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
|
||||
|
||||
let hasItem = removeFromArray(task.checklist, { id: req.params.itemId });
|
||||
if (!hasItem) throw new NotFound(res.t('checklistItemNotFound'));
|
||||
|
||||
let savedTask = await task.save();
|
||||
res.respond(200, savedTask);
|
||||
if (challenge) challenge.updateTask(savedTask);
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/tasks/:taskId/tags/:tagId Add a tag to a task
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AddTagToTask
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} taskId The task _id
|
||||
* @apiParam {UUID} tagId The tag id
|
||||
*
|
||||
* @apiSuccess {object} data The updated task
|
||||
*/
|
||||
api.addTagToTask = {
|
||||
method: 'POST',
|
||||
url: '/tasks/:taskId/tags/:tagId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
let userTags = user.tags.map(tag => tag.id);
|
||||
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID().isIn(userTags);
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let task = await Tasks.Task.findOne({
|
||||
_id: req.params.taskId,
|
||||
userId: user._id,
|
||||
}).exec();
|
||||
|
||||
if (!task) throw new NotFound(res.t('taskNotFound'));
|
||||
let tagId = req.params.tagId;
|
||||
|
||||
let alreadyTagged = task.tags.indexOf(tagId) !== -1;
|
||||
if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged'));
|
||||
|
||||
task.tags.push(tagId);
|
||||
|
||||
let savedTask = await task.save();
|
||||
res.respond(200, savedTask);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {delete} /api/v3/tasks/:taskId/tags/:tagId Remove a tag from atask
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName RemoveTagFromTask
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} taskId The task _id
|
||||
* @apiParam {UUID} tagId The tag id
|
||||
*
|
||||
* @apiSuccess {object} data The updated task
|
||||
*/
|
||||
api.removeTagFromTask = {
|
||||
method: 'DELETE',
|
||||
url: '/tasks/:taskId/tags/:tagId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let task = await Tasks.Task.findOne({
|
||||
_id: req.params.taskId,
|
||||
userId: user._id,
|
||||
}).exec();
|
||||
|
||||
if (!task) throw new NotFound(res.t('taskNotFound'));
|
||||
|
||||
let hasTag = removeFromArray(task.tags, req.params.tagId);
|
||||
if (!hasTag) throw new NotFound(res.t('tagNotFound'));
|
||||
|
||||
let savedTask = await task.save();
|
||||
res.respond(200, savedTask);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/tasks/unlink-all/:challengeId Unlink all tasks from a challenge
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UnlinkAllTasks
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} challengeId The challenge _id
|
||||
* @apiParam {string} keep Query parameter - keep-all or remove-all
|
||||
*
|
||||
* @apiSuccess {object} data An empty object
|
||||
*/
|
||||
api.unlinkAllTasks = {
|
||||
method: 'POST',
|
||||
url: '/tasks/unlink-all/:challengeId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkParams('challengeId', res.t('challengeIdRequired')).notEmpty().isUUID();
|
||||
req.checkQuery('keep', res.t('keepOrRemoveAll')).notEmpty().isIn(['keep-all', 'remove-all']);
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let user = res.locals.user;
|
||||
let keep = req.query.keep;
|
||||
let challengeId = req.params.challengeId;
|
||||
|
||||
let tasks = await Tasks.Task.find({
|
||||
'challenge.id': challengeId,
|
||||
userId: user._id,
|
||||
}).exec();
|
||||
|
||||
let validTasks = tasks.every(task => {
|
||||
return task.challenge.broken;
|
||||
});
|
||||
|
||||
if (!validTasks) throw new BadRequest(res.t('cantOnlyUnlinkChalTask'));
|
||||
|
||||
if (keep === 'keep-all') {
|
||||
await Bluebird.all(tasks.map(task => {
|
||||
task.challenge = {};
|
||||
return task.save();
|
||||
}));
|
||||
} else { // remove
|
||||
let toSave = [];
|
||||
|
||||
tasks.forEach(task => {
|
||||
if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if
|
||||
removeFromArray(user.tasksOrder[`${task.type}s`], task._id);
|
||||
}
|
||||
|
||||
toSave.push(task.remove());
|
||||
});
|
||||
|
||||
toSave.push(user.save());
|
||||
|
||||
await Bluebird.all(toSave);
|
||||
}
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/tasks/unlink-one/:taskId Unlink a challenge task
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UnlinkOneTask
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} taskId The task _id
|
||||
* @apiParam {string} keep Query parameter - keep or remove
|
||||
*
|
||||
* @apiSuccess {object} data An empty object
|
||||
*/
|
||||
api.unlinkOneTask = {
|
||||
method: 'POST',
|
||||
url: '/tasks/unlink-one/:taskId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
req.checkQuery('keep', res.t('keepOrRemove')).notEmpty().isIn(['keep', 'remove']);
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let user = res.locals.user;
|
||||
let keep = req.query.keep;
|
||||
let taskId = req.params.taskId;
|
||||
|
||||
let task = await Tasks.Task.findOne({
|
||||
_id: taskId,
|
||||
userId: user._id,
|
||||
}).exec();
|
||||
|
||||
if (!task) throw new NotFound(res.t('taskNotFound'));
|
||||
if (!task.challenge.id) throw new BadRequest(res.t('cantOnlyUnlinkChalTask'));
|
||||
if (!task.challenge.broken) throw new BadRequest(res.t('cantOnlyUnlinkChalTask'));
|
||||
|
||||
if (keep === 'keep') {
|
||||
task.challenge = {};
|
||||
await task.save();
|
||||
} else { // remove
|
||||
if (task.type !== 'todo' || !task.completed) { // eslint-disable-line no-lonely-if
|
||||
removeFromArray(user.tasksOrder[`${task.type}s`], taskId);
|
||||
await Bluebird.all([user.save(), task.remove()]);
|
||||
} else {
|
||||
await task.remove();
|
||||
}
|
||||
}
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/tasks/clearCompletedTodos Delete user's completed todos
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ClearCompletedTodos
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiSuccess {object} data An empty object
|
||||
*/
|
||||
api.clearCompletedTodos = {
|
||||
method: 'POST',
|
||||
url: '/tasks/clearCompletedTodos',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
// Clear completed todos
|
||||
// Do not delete challenges completed todos unless the task is broken
|
||||
await Tasks.Task.remove({
|
||||
userId: user._id,
|
||||
type: 'todo',
|
||||
completed: true,
|
||||
$or: [
|
||||
{'challenge.id': {$exists: false}},
|
||||
{'challenge.broken': {$exists: true}},
|
||||
],
|
||||
}).exec();
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {delete} /api/v3/tasks/:taskId Delete a task given its id
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName DeleteTask
|
||||
* @apiGroup Task
|
||||
*
|
||||
* @apiParam {UUID} taskId The task _id
|
||||
*
|
||||
* @apiSuccess {object} data An empty object
|
||||
*/
|
||||
api.deleteTask = {
|
||||
method: 'DELETE',
|
||||
url: '/tasks/:taskId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let challenge;
|
||||
|
||||
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let taskId = req.params.taskId;
|
||||
let task = await Tasks.Task.findById(taskId).exec();
|
||||
|
||||
if (!task) {
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
} else if (!task.userId) { // If the task belongs to a challenge make sure the user has rights
|
||||
challenge = await Challenge.findOne({_id: task.challenge.id}).exec();
|
||||
if (!challenge) throw new NotFound(res.t('challengeNotFound'));
|
||||
if (challenge.leader !== user._id) throw new NotAuthorized(res.t('onlyChalLeaderEditTasks'));
|
||||
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
|
||||
throw new NotFound(res.t('taskNotFound'));
|
||||
} else if (task.userId && task.challenge.id && !task.challenge.broken) {
|
||||
throw new NotAuthorized(res.t('cantDeleteChallengeTasks'));
|
||||
}
|
||||
|
||||
if (task.type !== 'todo' || !task.completed) {
|
||||
removeFromArray((challenge || user).tasksOrder[`${task.type}s`], taskId);
|
||||
await Bluebird.all([(challenge || user).save(), task.remove()]);
|
||||
} else {
|
||||
await task.remove();
|
||||
}
|
||||
|
||||
res.respond(200, {});
|
||||
if (challenge) challenge.removeTask(task);
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
let api = {};
|
||||
|
||||
// Internal authentication routes
|
||||
|
||||
// Logout the user from the website.
|
||||
api.logout = {
|
||||
method: 'GET',
|
||||
url: '/logout',
|
||||
async handler (req, res) {
|
||||
if (req.logout) req.logout(); // passportjs method
|
||||
req.session = null;
|
||||
res.redirect('/');
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,247 @@
|
||||
import { authWithSession } from '../../middlewares/api-v3/auth';
|
||||
import { model as User } from '../../models/user';
|
||||
import * as Tasks from '../../models/task';
|
||||
import {
|
||||
NotFound,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import _ from 'lodash';
|
||||
import csvStringify from '../../libs/api-v3/csvStringify';
|
||||
import moment from 'moment';
|
||||
import js2xml from 'js2xmlparser';
|
||||
import Pageres from 'pageres';
|
||||
import AWS from 'aws-sdk';
|
||||
import nconf from 'nconf';
|
||||
import got from 'got';
|
||||
import Bluebird from 'bluebird';
|
||||
import locals from '../../middlewares/api-v3/locals';
|
||||
|
||||
let S3 = new AWS.S3({
|
||||
accessKeyId: nconf.get('S3:accessKeyId'),
|
||||
secretAccessKey: nconf.get('S3:secretAccessKey'),
|
||||
});
|
||||
const S3_BUCKET = nconf.get('S3:bucket');
|
||||
|
||||
const BASE_URL = nconf.get('BASE_URL');
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {get} /export/history.csv Export user tasks history in CSV format
|
||||
* @apiDescription History is only available for habits and dailys so todos and rewards won't be included. NOTE: Part of the private API that may change at any time.
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ExportUserHistory
|
||||
* @apiGroup DataExport
|
||||
*
|
||||
* @apiSuccess {string} A cvs file
|
||||
*/
|
||||
api.exportUserHistory = {
|
||||
method: 'GET',
|
||||
url: '/export/history.csv',
|
||||
middlewares: [authWithSession],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
let tasks = await Tasks.Task.find({
|
||||
userId: user._id,
|
||||
type: {$in: ['habit', 'daily']},
|
||||
}).exec();
|
||||
|
||||
let output = [
|
||||
['Task Name', 'Task ID', 'Task Type', 'Date', 'Value'],
|
||||
];
|
||||
|
||||
tasks.forEach(task => {
|
||||
task.history.forEach(history => {
|
||||
output.push([
|
||||
task.text,
|
||||
task._id,
|
||||
task.type,
|
||||
moment(history.date).format('YYYY-MM-DD HH:mm:ss'),
|
||||
history.value,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-disposition': 'attachment; filename=habitica-tasks-history.csv',
|
||||
});
|
||||
|
||||
let csvRes = await csvStringify(output);
|
||||
res.status(200).send(csvRes);
|
||||
},
|
||||
};
|
||||
|
||||
// Convert user to json and attach tasks divided by type
|
||||
// at user.tasks[`${taskType}s`] (user.tasks.{dailys/habits/...})
|
||||
async function _getUserDataForExport (user) {
|
||||
let userData = user.toJSON();
|
||||
userData.tasks = {};
|
||||
|
||||
let tasks = await Tasks.Task.find({
|
||||
userId: user._id,
|
||||
}).exec();
|
||||
|
||||
tasks = _.chain(tasks)
|
||||
.map(task => task.toJSON())
|
||||
.groupBy(task => task.type)
|
||||
.each((tasksPerType, taskType) => {
|
||||
userData.tasks[`${taskType}s`] = tasksPerType;
|
||||
})
|
||||
.value();
|
||||
|
||||
return userData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /export/userdata.json Export user data in JSON format
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ExportUserDataJson
|
||||
* @apiGroup DataExport
|
||||
* @apiDescription NOTE: Part of the private API that may change at any time.
|
||||
*
|
||||
* @apiSuccess {string} A json file
|
||||
*/
|
||||
api.exportUserDataJson = {
|
||||
method: 'GET',
|
||||
url: '/export/userdata.json',
|
||||
middlewares: [authWithSession],
|
||||
async handler (req, res) {
|
||||
let userData = await _getUserDataForExport(res.locals.user);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'application/json',
|
||||
'Content-disposition': 'attachment; filename=habitica-user-data.json',
|
||||
});
|
||||
let jsonRes = JSON.stringify(userData);
|
||||
|
||||
res.status(200).send(jsonRes);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /export/userdata.xml Export user data in XML format
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ExportUserDataXml
|
||||
* @apiGroup DataExport
|
||||
* @apiDescription NOTE: Part of the private API that may change at any time.
|
||||
*
|
||||
* @apiSuccess {string} A xml file
|
||||
*/
|
||||
api.exportUserDataXml = {
|
||||
method: 'GET',
|
||||
url: '/export/userdata.xml',
|
||||
middlewares: [authWithSession],
|
||||
async handler (req, res) {
|
||||
let userData = await _getUserDataForExport(res.locals.user);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/xml',
|
||||
'Content-disposition': 'attachment; filename=habitica-user-data.xml',
|
||||
});
|
||||
res.status(200).send(js2xml('user', userData));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /export/avatar-:uuid.html Render a user avatar as an HTML page
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ExportUserAvatarHtml
|
||||
* @apiGroup DataExport
|
||||
* @apiDescription NOTE: Part of the private API that may change at any time.
|
||||
*
|
||||
* @apiSuccess {string} An html page
|
||||
*/
|
||||
api.exportUserAvatarHtml = {
|
||||
method: 'GET',
|
||||
url: '/export/avatar-:memberId.html',
|
||||
middlewares: [locals],
|
||||
async handler (req, res) {
|
||||
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let memberId = req.params.memberId;
|
||||
let member = await User
|
||||
.findById(memberId)
|
||||
.select('stats profile items achievements preferences backer contributor')
|
||||
.exec();
|
||||
|
||||
if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId}));
|
||||
res.render('avatar-static', {
|
||||
title: member.profile.name,
|
||||
env: _.defaults({user: member}, res.locals.habitrpg),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /export/avatar-:uuid.html Export a user avatar as a PNG file
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName ExportUserAvatarPng
|
||||
* @apiGroup DataExport
|
||||
* @apiDescription NOTE: Part of the private API that may change at any time.
|
||||
*
|
||||
* @apiSuccess {string} A png file
|
||||
*/
|
||||
api.exportUserAvatarPng = {
|
||||
method: 'GET',
|
||||
url: '/export/avatar-:memberId.png',
|
||||
async handler (req, res) {
|
||||
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let memberId = req.params.memberId;
|
||||
|
||||
let filename = `avatars/${memberId}.png`;
|
||||
let s3url = `https://${S3_BUCKET}.s3.amazonaws.com/${filename}`;
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await got.head(s3url);
|
||||
} catch (gotError) {
|
||||
if (gotError.code !== 'ENOTFOUND' && gotError.statusCode !== 404) {
|
||||
throw gotError;
|
||||
}
|
||||
}
|
||||
|
||||
// cache images for 30 minutes on aws, else upload a new one
|
||||
if (response && response.statusCode === 200 && moment().diff(response.headers['last-modified'], 'minutes') < 30) {
|
||||
return res.redirect(s3url);
|
||||
}
|
||||
|
||||
let [stream] = await new Pageres()
|
||||
.src(`${BASE_URL}/export/avatar-${memberId}.html`, ['140x147'], {
|
||||
crop: true,
|
||||
filename: filename.replace('.png', ''),
|
||||
})
|
||||
.run();
|
||||
|
||||
let s3upload = S3.upload({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: filename,
|
||||
ACL: 'public-read',
|
||||
StorageClass: 'REDUCED_REDUNDANCY',
|
||||
ContentType: 'image/png',
|
||||
Expires: moment().add({minutes: 5}).toDate(),
|
||||
Body: stream,
|
||||
});
|
||||
|
||||
let s3res = await new Bluebird((resolve, reject) => {
|
||||
s3upload.send((err, s3uploadRes) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(s3uploadRes);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
res.redirect(s3res.Location);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { model as User } from '../../models/user';
|
||||
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
|
||||
import { decrypt } from '../../libs/api-v3/encryption';
|
||||
import {
|
||||
NotFound,
|
||||
} from '../../libs/api-v3/errors';
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/email/unsubscribe Unsubscribe an email or user from email notifications
|
||||
* @apiDescription Does not require authentication
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UnsubscribeEmail
|
||||
* @apiGroup Unsubscribe
|
||||
* @apiDescription This is a GET method so that you can put the unsubscribe link in emails.
|
||||
*
|
||||
* @apiParam {String} code Query parameter - An unsubscription code
|
||||
*
|
||||
* @apiSuccess {String} An html success message
|
||||
*/
|
||||
api.unsubscribe = {
|
||||
method: 'GET',
|
||||
url: '/email/unsubscribe',
|
||||
async handler (req, res) {
|
||||
req.checkQuery({
|
||||
code: {
|
||||
notEmpty: {errorMessage: res.t('missingUnsubscriptionCode')},
|
||||
},
|
||||
});
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let data = JSON.parse(decrypt(req.query.code));
|
||||
|
||||
if (data._id) {
|
||||
let userUpdated = await User.update(
|
||||
{_id: data._id},
|
||||
{ $set: {'preferences.emailNotifications.unsubscribeFromAll': true}}
|
||||
);
|
||||
|
||||
if (userUpdated.nModified !== 1) throw new NotFound(res.t('userNotFound'));
|
||||
|
||||
res.send(`<h1>${res.t('unsubscribedSuccessfully')}</h1> ${res.t('unsubscribedTextUsers')}`);
|
||||
} else {
|
||||
let unsubscribedEmail = await EmailUnsubscription.findOne({email: data.email.toLowerCase()});
|
||||
let okResponse = `<h1>${res.t('unsubscribedSuccessfully')}</h1> ${res.t('unsubscribedTextOthers')}`;
|
||||
if (!unsubscribedEmail) await EmailUnsubscription.create({email: data.email.toLowerCase()});
|
||||
res.send(okResponse);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,79 @@
|
||||
import locals from '../../middlewares/api-v3/locals';
|
||||
import _ from 'lodash';
|
||||
import markdownIt from 'markdown-it';
|
||||
|
||||
const md = markdownIt({
|
||||
html: true,
|
||||
});
|
||||
|
||||
let api = {};
|
||||
|
||||
const TOTAL_USER_COUNT = '1,100,000';
|
||||
|
||||
api.getFrontPage = {
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
middlewares: [locals],
|
||||
runCron: false,
|
||||
async handler (req, res) {
|
||||
if (!req.header('x-api-user') && !req.header('x-api-key') && !(req.session && req.session.userId)) {
|
||||
return res.redirect('/static/front');
|
||||
}
|
||||
|
||||
return res.render('index.jade', {
|
||||
title: 'Habitica | Your Life The Role Playing Game',
|
||||
env: res.locals.habitrpg,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
let staticPages = ['front', 'privacy', 'terms', 'api-v2', 'features',
|
||||
'videos', 'contact', 'plans', 'new-stuff', 'community-guidelines',
|
||||
'old-news', 'press-kit', 'faq', 'overview', 'apps',
|
||||
'clear-browser-data', 'merch', 'maintenance-info'];
|
||||
|
||||
_.each(staticPages, (name) => {
|
||||
api[`get${name}Page`] = {
|
||||
method: 'GET',
|
||||
url: `/static/${name}`,
|
||||
middlewares: [locals],
|
||||
runCron: false,
|
||||
async handler (req, res) {
|
||||
return res.render(`static/${name}.jade`, {
|
||||
env: res.locals.habitrpg,
|
||||
md,
|
||||
userCount: TOTAL_USER_COUNT,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let shareables = ['level-up', 'hatch-pet', 'raise-pet', 'unlock-quest', 'won-challenge', 'achievement'];
|
||||
|
||||
_.each(shareables, (name) => {
|
||||
api[`get${name}ShareablePage`] = {
|
||||
method: 'GET',
|
||||
url: `/social/${name}`,
|
||||
middlewares: [locals],
|
||||
runCron: false,
|
||||
async handler (req, res) {
|
||||
return res.render(`social/${name}`, {
|
||||
env: res.locals.habitrpg,
|
||||
md,
|
||||
userCount: TOTAL_USER_COUNT,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
api.redirectExtensionsPage = {
|
||||
method: 'GET',
|
||||
url: '/static/extensions',
|
||||
runCron: false,
|
||||
async handler (req, res) {
|
||||
return res.redirect('http://habitica.wikia.com/wiki/App_and_Extension_Integrations');
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
} from '../../../libs/api-v3/errors';
|
||||
import amzLib from '../../../libs/api-v3/amazonPayments';
|
||||
import {
|
||||
authWithHeaders,
|
||||
authWithUrl,
|
||||
} from '../../../middlewares/api-v3/auth';
|
||||
import shared from '../../../../../common';
|
||||
import payments from '../../../libs/api-v3/payments';
|
||||
import moment from 'moment';
|
||||
import { model as Coupon } from '../../../models/coupon';
|
||||
import { model as User } from '../../../models/user';
|
||||
import cc from 'coupon-code';
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /amazon/verifyAccessToken Amazon Payments: verify access token
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AmazonVerifyAccessToken
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiSuccess {Object} data Empty object
|
||||
**/
|
||||
api.verifyAccessToken = {
|
||||
method: 'POST',
|
||||
url: '/amazon/verifyAccessToken',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let accessToken = req.body.access_token;
|
||||
|
||||
if (!accessToken) throw new BadRequest('Missing req.body.access_token');
|
||||
|
||||
await amzLib.getTokenInfo(accessToken);
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /amazon/createOrderReferenceId Amazon Payments: create order reference id
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AmazonCreateOrderReferenceId
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiSuccess {string} data.orderReferenceId The order reference id.
|
||||
**/
|
||||
api.createOrderReferenceId = {
|
||||
method: 'POST',
|
||||
url: '/amazon/createOrderReferenceId',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let billingAgreementId = req.body.billingAgreementId;
|
||||
|
||||
if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId');
|
||||
|
||||
let response = await amzLib.createOrderReferenceId({
|
||||
Id: billingAgreementId,
|
||||
IdType: 'BillingAgreement',
|
||||
ConfirmNow: false,
|
||||
});
|
||||
|
||||
res.respond(200, {
|
||||
orderReferenceId: response.OrderReferenceDetails.AmazonOrderReferenceId,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /amazon/checkout Amazon Payments: checkout
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AmazonCheckout
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiSuccess {object} data Empty object
|
||||
**/
|
||||
api.checkout = {
|
||||
method: 'POST',
|
||||
url: '/amazon/checkout',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let gift = req.body.gift;
|
||||
let user = res.locals.user;
|
||||
let orderReferenceId = req.body.orderReferenceId;
|
||||
let amount = 5;
|
||||
|
||||
if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId');
|
||||
|
||||
if (gift) {
|
||||
if (gift.type === 'gems') {
|
||||
amount = gift.gems.amount / 4;
|
||||
} else if (gift.type === 'subscription') {
|
||||
amount = shared.content.subscriptionBlocks[gift.subscription.key].price;
|
||||
}
|
||||
}
|
||||
|
||||
await amzLib.setOrderReferenceDetails({
|
||||
AmazonOrderReferenceId: orderReferenceId,
|
||||
OrderReferenceAttributes: {
|
||||
OrderTotal: {
|
||||
CurrencyCode: 'USD',
|
||||
Amount: amount,
|
||||
},
|
||||
SellerNote: 'HabitRPG Payment',
|
||||
SellerOrderAttributes: {
|
||||
SellerOrderId: shared.uuid(),
|
||||
StoreName: 'HabitRPG',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await amzLib.confirmOrderReference({ AmazonOrderReferenceId: orderReferenceId });
|
||||
|
||||
await amzLib.authorize({
|
||||
AmazonOrderReferenceId: orderReferenceId,
|
||||
AuthorizationReferenceId: shared.uuid().substring(0, 32),
|
||||
AuthorizationAmount: {
|
||||
CurrencyCode: 'USD',
|
||||
Amount: amount,
|
||||
},
|
||||
SellerAuthorizationNote: 'HabitRPG Payment',
|
||||
TransactionTimeout: 0,
|
||||
CaptureNow: true,
|
||||
});
|
||||
|
||||
await amzLib.closeOrderReference({ AmazonOrderReferenceId: orderReferenceId });
|
||||
|
||||
// execute payment
|
||||
let method = 'buyGems';
|
||||
let data = { user, paymentMethod: 'Amazon Payments' };
|
||||
|
||||
if (gift) {
|
||||
if (gift.type === 'subscription') method = 'createSubscription';
|
||||
gift.member = await User.findById(gift ? gift.uuid : undefined);
|
||||
data.gift = gift;
|
||||
data.paymentMethod = 'Gift';
|
||||
}
|
||||
|
||||
await payments[method](data);
|
||||
|
||||
res.respond(200);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /amazon/subscribe Amazon Payments: subscribe
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AmazonSubscribe
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiSuccess {object} data Empty object
|
||||
**/
|
||||
api.subscribe = {
|
||||
method: 'POST',
|
||||
url: '/amazon/subscribe',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let billingAgreementId = req.body.billingAgreementId;
|
||||
let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false;
|
||||
let coupon = req.body.coupon;
|
||||
let user = res.locals.user;
|
||||
|
||||
if (!sub) throw new BadRequest(res.t('missingSubscriptionCode'));
|
||||
if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId');
|
||||
|
||||
if (sub.discount) { // apply discount
|
||||
if (!coupon) throw new BadRequest(res.t('couponCodeRequired'));
|
||||
let result = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key});
|
||||
if (!result) throw new NotAuthorized(res.t('invalidCoupon'));
|
||||
}
|
||||
|
||||
await amzLib.setBillingAgreementDetails({
|
||||
AmazonBillingAgreementId: billingAgreementId,
|
||||
BillingAgreementAttributes: {
|
||||
SellerNote: 'HabitRPG Subscription',
|
||||
SellerBillingAgreementAttributes: {
|
||||
SellerBillingAgreementId: shared.uuid(),
|
||||
StoreName: 'HabitRPG',
|
||||
CustomInformation: 'HabitRPG Subscription',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await amzLib.confirmBillingAgreement({
|
||||
AmazonBillingAgreementId: billingAgreementId,
|
||||
});
|
||||
|
||||
await amzLib.authorizeOnBillingAgreement({
|
||||
AmazonBillingAgreementId: billingAgreementId,
|
||||
AuthorizationReferenceId: shared.uuid().substring(0, 32),
|
||||
AuthorizationAmount: {
|
||||
CurrencyCode: 'USD',
|
||||
Amount: sub.price,
|
||||
},
|
||||
SellerAuthorizationNote: 'HabitRPG Subscription Payment',
|
||||
TransactionTimeout: 0,
|
||||
CaptureNow: true,
|
||||
SellerNote: 'HabitRPG Subscription Payment',
|
||||
SellerOrderAttributes: {
|
||||
SellerOrderId: shared.uuid(),
|
||||
StoreName: 'HabitRPG',
|
||||
},
|
||||
});
|
||||
|
||||
await payments.createSubscription({
|
||||
user,
|
||||
customerId: billingAgreementId,
|
||||
paymentMethod: 'Amazon Payments',
|
||||
sub,
|
||||
});
|
||||
|
||||
res.respond(200);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /amazon/subscribe/cancel Amazon Payments: subscribe cancel
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName AmazonSubscribe
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.subscribeCancel = {
|
||||
method: 'GET',
|
||||
url: '/amazon/subscribe/cancel',
|
||||
middlewares: [authWithUrl],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let billingAgreementId = user.purchased.plan.customerId;
|
||||
|
||||
if (!billingAgreementId) throw new NotAuthorized(res.t('missingSubscription'));
|
||||
|
||||
await amzLib.closeBillingAgreement({
|
||||
AmazonBillingAgreementId: billingAgreementId,
|
||||
});
|
||||
|
||||
await payments.cancelSubscription({
|
||||
user,
|
||||
nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: 30 }),
|
||||
paymentMethod: 'Amazon Payments',
|
||||
});
|
||||
|
||||
if (req.query.noRedirect) {
|
||||
res.respond(200);
|
||||
} else {
|
||||
res.redirect('/');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,191 @@
|
||||
import iap from 'in-app-purchase';
|
||||
import nconf from 'nconf';
|
||||
import {
|
||||
authWithHeaders,
|
||||
authWithUrl,
|
||||
} from '../../../middlewares/api-v3/auth';
|
||||
import payments from '../../../libs/api-v3/payments';
|
||||
|
||||
// NOT PORTED TO v3
|
||||
|
||||
iap.config({
|
||||
// this is the path to the directory containing iap-sanbox/iap-live files
|
||||
googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'),
|
||||
});
|
||||
|
||||
// Validation ERROR Codes
|
||||
const INVALID_PAYLOAD = 6778001;
|
||||
// const CONNECTION_FAILED = 6778002;
|
||||
// const PURCHASE_EXPIRED = 6778003;
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /iap/android/verify Android Verify IAP
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName IapAndroidVerify
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.iapAndroidVerify = {
|
||||
method: 'POST',
|
||||
url: '/iap/android/verify',
|
||||
middlewares: [authWithUrl],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let iapBody = req.body;
|
||||
|
||||
iap.setup((error) => {
|
||||
if (error) {
|
||||
let resObj = {
|
||||
ok: false,
|
||||
data: 'IAP Error',
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
|
||||
// google receipt must be provided as an object
|
||||
// {
|
||||
// "data": "{stringified data object}",
|
||||
// "signature": "signature from google"
|
||||
// }
|
||||
let testObj = {
|
||||
data: iapBody.transaction.receipt,
|
||||
signature: iapBody.transaction.signature,
|
||||
};
|
||||
|
||||
// iap is ready
|
||||
iap.validate(iap.GOOGLE, testObj, (err, googleRes) => {
|
||||
if (err) {
|
||||
let resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: err.toString(),
|
||||
},
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
|
||||
if (iap.isValidated(googleRes)) {
|
||||
let resObj = {
|
||||
ok: true,
|
||||
data: googleRes,
|
||||
};
|
||||
|
||||
payments.buyGems({
|
||||
user,
|
||||
paymentMethod: 'IAP GooglePlay',
|
||||
amount: 5.25,
|
||||
}).then(() => res.json(resObj));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /iap/ios/verify iOS Verify IAP
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName IapiOSVerify
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.iapiOSVerify = {
|
||||
method: 'POST',
|
||||
url: '/iap/ios/verify',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let iapBody = req.body;
|
||||
let user = res.locals.user;
|
||||
|
||||
iap.setup(function iosSetupResult (error) {
|
||||
if (error) {
|
||||
let resObj = {
|
||||
ok: false,
|
||||
data: 'IAP Error',
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
|
||||
// iap is ready
|
||||
iap.validate(iap.APPLE, iapBody.transaction.receipt, (err, appleRes) => {
|
||||
if (err) {
|
||||
let resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: err.toString(),
|
||||
},
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
|
||||
if (iap.isValidated(appleRes)) {
|
||||
let purchaseDataList = iap.getPurchaseData(appleRes);
|
||||
if (purchaseDataList.length > 0) {
|
||||
let correctReceipt = true;
|
||||
|
||||
for (let index in purchaseDataList) {
|
||||
switch (purchaseDataList[index].productId) {
|
||||
case 'com.habitrpg.ios.Habitica.4gems':
|
||||
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 1});
|
||||
break;
|
||||
case 'com.habitrpg.ios.Habitica.8gems':
|
||||
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 2});
|
||||
break;
|
||||
case 'com.habitrpg.ios.Habitica.20gems':
|
||||
case 'com.habitrpg.ios.Habitica.21gems':
|
||||
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 5.25});
|
||||
break;
|
||||
case 'com.habitrpg.ios.Habitica.42gems':
|
||||
payments.buyGems({user, paymentMethod: 'IAP AppleStore', amount: 10.5});
|
||||
break;
|
||||
default:
|
||||
correctReceipt = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (correctReceipt) {
|
||||
let resObj = {
|
||||
ok: true,
|
||||
data: appleRes,
|
||||
};
|
||||
|
||||
// yay good!
|
||||
return res.json(resObj);
|
||||
}
|
||||
}
|
||||
|
||||
// wrong receipt content
|
||||
let resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: 'Incorrect receipt content',
|
||||
},
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
}
|
||||
|
||||
// invalid receipt
|
||||
let resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: 'Invalid receipt',
|
||||
},
|
||||
};
|
||||
|
||||
return res.json(resObj);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,278 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
import nconf from 'nconf';
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
import payments from '../../../libs/api-v3/payments';
|
||||
import ipn from 'paypal-ipn';
|
||||
import paypal from 'paypal-rest-sdk';
|
||||
import shared from '../../../../../common';
|
||||
import cc from 'coupon-code';
|
||||
import Bluebird from 'bluebird';
|
||||
import { model as Coupon } from '../../../models/coupon';
|
||||
import { model as User } from '../../../models/user';
|
||||
import {
|
||||
authWithUrl,
|
||||
authWithSession,
|
||||
} from '../../../middlewares/api-v3/auth';
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
} from '../../../libs/api-v3/errors';
|
||||
|
||||
const BASE_URL = nconf.get('BASE_URL');
|
||||
|
||||
// This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have
|
||||
// a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created
|
||||
// there, get it's plan.id and store it in config.json
|
||||
_.each(shared.content.subscriptionBlocks, (block) => {
|
||||
block.paypalKey = nconf.get(`PAYPAL:billing_plans:${block.key}`);
|
||||
});
|
||||
|
||||
paypal.configure({
|
||||
mode: nconf.get('PAYPAL:mode'), // sandbox or live
|
||||
client_id: nconf.get('PAYPAL:client_id'),
|
||||
client_secret: nconf.get('PAYPAL:client_secret'),
|
||||
});
|
||||
|
||||
// TODO better handling of errors
|
||||
const paypalPaymentCreate = Bluebird.promisify(paypal.payment.create, {context: paypal.payment});
|
||||
const paypalPaymentExecute = Bluebird.promisify(paypal.payment.execute, {context: paypal.payment});
|
||||
const paypalBillingAgreementCreate = Bluebird.promisify(paypal.billingAgreement.create, {context: paypal.billingAgreement});
|
||||
const paypalBillingAgreementExecute = Bluebird.promisify(paypal.billingAgreement.execute, {context: paypal.billingAgreement});
|
||||
const paypalBillingAgreementGet = Bluebird.promisify(paypal.billingAgreement.get, {context: paypal.billingAgreement});
|
||||
const paypalBillingAgreementCancel = Bluebird.promisify(paypal.billingAgreement.cancel, {context: paypal.billingAgreement});
|
||||
|
||||
const ipnVerifyAsync = Bluebird.promisify(ipn.verify, {context: ipn});
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /paypal/checkout Paypal: checkout
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PaypalCheckout
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.checkout = {
|
||||
method: 'GET',
|
||||
url: '/paypal/checkout',
|
||||
middlewares: [authWithUrl],
|
||||
async handler (req, res) {
|
||||
let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
||||
req.session.gift = req.query.gift;
|
||||
|
||||
let amount = 5.00;
|
||||
let description = 'HabitRPG gems';
|
||||
if (gift) {
|
||||
if (gift.type === 'gems') {
|
||||
amount = Number(gift.gems.amount / 4).toFixed(2);
|
||||
description = `${description} (Gift)`;
|
||||
} else {
|
||||
amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2);
|
||||
description = 'mo. HabitRPG Subscription (Gift)';
|
||||
}
|
||||
}
|
||||
|
||||
let createPayment = {
|
||||
intent: 'sale',
|
||||
payer: { payment_method: 'Paypal' },
|
||||
redirect_urls: {
|
||||
return_url: `${BASE_URL}/paypal/checkout/success`,
|
||||
cancel_url: `${BASE_URL}`,
|
||||
},
|
||||
transactions: [{
|
||||
item_list: {
|
||||
items: [{
|
||||
name: description,
|
||||
// sku: 1,
|
||||
price: amount,
|
||||
currency: 'USD',
|
||||
quantity: 1,
|
||||
}],
|
||||
},
|
||||
amount: {
|
||||
currency: 'USD',
|
||||
total: amount,
|
||||
},
|
||||
description,
|
||||
}],
|
||||
};
|
||||
|
||||
let result = await paypalPaymentCreate(createPayment);
|
||||
let link = _.find(result.links, { rel: 'approval_url' }).href;
|
||||
res.redirect(link);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /paypal/checkout/success Paypal: checkout success
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PaypalCheckoutSuccess
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.checkoutSuccess = {
|
||||
method: 'GET',
|
||||
url: '/paypal/checkout/success',
|
||||
middlewares: [authWithSession],
|
||||
async handler (req, res) {
|
||||
let paymentId = req.query.paymentId;
|
||||
let customerId = req.query.payerID;
|
||||
|
||||
let method = 'buyGems';
|
||||
let data = {
|
||||
user: res.locals.user,
|
||||
customerId,
|
||||
paymentMethod: 'Paypal',
|
||||
};
|
||||
|
||||
let gift = req.session.gift ? JSON.parse(req.session.gift) : undefined;
|
||||
delete req.session.gift;
|
||||
|
||||
if (gift) {
|
||||
gift.member = await User.findById(gift.uuid);
|
||||
if (gift.type === 'subscription') {
|
||||
method = 'createSubscription';
|
||||
}
|
||||
|
||||
data.paymentMethod = 'Gift';
|
||||
data.gift = gift;
|
||||
}
|
||||
|
||||
await paypalPaymentExecute(paymentId, { payer_id: customerId });
|
||||
await payments[method](data);
|
||||
res.redirect('/');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /paypal/subscribe Paypal: subscribe
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PaypalSubscribe
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.subscribe = {
|
||||
method: 'GET',
|
||||
url: '/paypal/subscribe',
|
||||
middlewares: [authWithUrl],
|
||||
async handler (req, res) {
|
||||
let sub = shared.content.subscriptionBlocks[req.query.sub];
|
||||
|
||||
if (sub.discount) {
|
||||
if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired'));
|
||||
let coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key});
|
||||
if (!coupon) throw new NotAuthorized(res.t('invalidCoupon'));
|
||||
}
|
||||
|
||||
let billingPlanTitle = `HabitRPG Subscription ($${sub.price} every ${sub.months} months, recurring)`;
|
||||
let billingAgreementAttributes = {
|
||||
name: billingPlanTitle,
|
||||
description: billingPlanTitle,
|
||||
start_date: moment().add({ minutes: 5 }).format(),
|
||||
plan: {
|
||||
id: sub.paypalKey,
|
||||
},
|
||||
payer: {
|
||||
payment_method: 'Paypal',
|
||||
},
|
||||
};
|
||||
let billingAgreement = await paypalBillingAgreementCreate(billingAgreementAttributes);
|
||||
|
||||
req.session.paypalBlock = req.query.sub;
|
||||
let link = _.find(billingAgreement.links, { rel: 'approval_url' }).href;
|
||||
res.redirect(link);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /paypal/subscribe/success Paypal: subscribe success
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PaypalSubscribeSuccess
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.subscribeSuccess = {
|
||||
method: 'GET',
|
||||
url: '/paypal/subscribe/success',
|
||||
middlewares: [authWithSession],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let block = shared.content.subscriptionBlocks[req.session.paypalBlock];
|
||||
delete req.session.paypalBlock;
|
||||
|
||||
let result = await paypalBillingAgreementExecute(req.query.token, {});
|
||||
await payments.createSubscription({
|
||||
user,
|
||||
customerId: result.id,
|
||||
paymentMethod: 'Paypal',
|
||||
sub: block,
|
||||
});
|
||||
|
||||
res.redirect('/');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /paypal/subscribe/cancel Paypal: subscribe cancel
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PaypalSubscribeCancel
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.subscribeCancel = {
|
||||
method: 'GET',
|
||||
url: '/paypal/subscribe/cancel',
|
||||
middlewares: [authWithUrl],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
let customerId = user.purchased.plan.customerId;
|
||||
if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription'));
|
||||
|
||||
let customer = await paypalBillingAgreementGet(customerId);
|
||||
|
||||
let nextBillingDate = customer.agreement_details.next_billing_date;
|
||||
if (customer.agreement_details.cycles_completed === '0') { // hasn't billed yet
|
||||
throw new BadRequest(res.t('planNotActive', { nextBillingDate }));
|
||||
}
|
||||
|
||||
await paypalBillingAgreementCancel(customerId, { note: res.t('cancelingSubscription') });
|
||||
await payments.cancelSubscription({
|
||||
user,
|
||||
paymentMethod: 'Paypal',
|
||||
nextBill: nextBillingDate,
|
||||
});
|
||||
|
||||
res.redirect('/');
|
||||
},
|
||||
};
|
||||
|
||||
// General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their
|
||||
// recurring paypal payments in their paypal dashboard. TODO ? Remove this when we can move to webhooks or some other solution
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /paypal/ipn Paypal IPN
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName PaypalIpn
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.ipn = {
|
||||
method: 'POST',
|
||||
url: '/paypal/ipn',
|
||||
async handler (req, res) {
|
||||
res.sendStatus(200);
|
||||
|
||||
await ipnVerifyAsync(req.body);
|
||||
|
||||
if (req.body.txn_type === 'recurring_payment_profile_cancel' || req.body.txn_type === 'subscr_cancel') {
|
||||
let user = await User.findOne({ 'purchased.plan.customerId': req.body.recurring_payment_id });
|
||||
if (user) {
|
||||
await payments.cancelSubscription({ user, paymentMethod: 'Paypal' });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,169 @@
|
||||
import stripeModule from 'stripe';
|
||||
import shared from '../../../../../common';
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
} from '../../../libs/api-v3/errors';
|
||||
import { model as Coupon } from '../../../models/coupon';
|
||||
import payments from '../../../libs/api-v3/payments';
|
||||
import nconf from 'nconf';
|
||||
import { model as User } from '../../../models/user';
|
||||
import cc from 'coupon-code';
|
||||
import {
|
||||
authWithHeaders,
|
||||
authWithUrl,
|
||||
} from '../../../middlewares/api-v3/auth';
|
||||
|
||||
const stripe = stripeModule(nconf.get('STRIPE_API_KEY'));
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /stripe/checkout Stripe checkout
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName StripeCheckout
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiParam {string} id Body parameter - The token
|
||||
* @apiParam {string} email Body parameter - the customer email
|
||||
* @apiParam {string} gift Query parameter - stringified json object, gift
|
||||
* @apiParam {string} sub Query parameter - subscription, possible values are: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo
|
||||
* @apiParam {string} coupon Query parameter - coupon for the matching subscription, required only for certain subscriptions
|
||||
*
|
||||
* @apiSuccess {Object} data Empty object
|
||||
**/
|
||||
api.checkout = {
|
||||
method: 'POST',
|
||||
url: '/stripe/checkout',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let token = req.body.id;
|
||||
let user = res.locals.user;
|
||||
let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
||||
let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false;
|
||||
let coupon;
|
||||
let response;
|
||||
|
||||
if (!token) throw new BadRequest('Missing req.body.id');
|
||||
|
||||
if (sub) {
|
||||
if (sub.discount) {
|
||||
if (!req.query.coupon) throw new BadRequest(res.t('couponCodeRequired'));
|
||||
coupon = await Coupon.findOne({_id: cc.validate(req.query.coupon), event: sub.key});
|
||||
if (!coupon) throw new BadRequest(res.t('invalidCoupon'));
|
||||
}
|
||||
|
||||
response = await stripe.customers.create({
|
||||
email: req.body.email,
|
||||
metadata: { uuid: user._id },
|
||||
card: token,
|
||||
plan: sub.key,
|
||||
});
|
||||
} else {
|
||||
let amount = 500; // $5
|
||||
|
||||
if (gift) {
|
||||
if (gift.type === 'subscription') {
|
||||
amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`;
|
||||
} else {
|
||||
amount = `${gift.gems.amount / 4 * 100}`;
|
||||
}
|
||||
}
|
||||
|
||||
response = await stripe.charges.create({
|
||||
amount,
|
||||
currency: 'usd',
|
||||
card: token,
|
||||
});
|
||||
}
|
||||
|
||||
if (sub) {
|
||||
await payments.createSubscription({
|
||||
user,
|
||||
customerId: response.id,
|
||||
paymentMethod: 'Stripe',
|
||||
sub,
|
||||
});
|
||||
} else {
|
||||
let method = 'buyGems';
|
||||
let data = {
|
||||
user,
|
||||
customerId: response.id,
|
||||
paymentMethod: 'Stripe',
|
||||
gift,
|
||||
};
|
||||
|
||||
if (gift) {
|
||||
let member = await User.findById(gift.uuid);
|
||||
gift.member = member;
|
||||
if (gift.type === 'subscription') method = 'createSubscription';
|
||||
data.paymentMethod = 'Gift';
|
||||
}
|
||||
|
||||
await payments[method](data);
|
||||
}
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /stripe/subscribe/edit Edit Stripe subscription
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName StripeSubscribeEdit
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiParam {string} id Body parameter - The token
|
||||
*
|
||||
* @apiSuccess {Object} data Empty object
|
||||
**/
|
||||
api.subscribeEdit = {
|
||||
method: 'POST',
|
||||
url: '/stripe/subscribe/edit',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let token = req.body.id;
|
||||
let user = res.locals.user;
|
||||
let customerId = user.purchased.plan.customerId;
|
||||
|
||||
if (!customerId) throw new NotAuthorized(res.t('missingSubscription'));
|
||||
if (!token) throw new BadRequest('Missing req.body.id');
|
||||
|
||||
let subscriptions = await stripe.customers.listSubscriptions(customerId);
|
||||
let subscriptionId = subscriptions.data[0].id;
|
||||
await stripe.customers.updateSubscription(customerId, subscriptionId, { card: token });
|
||||
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {get} /stripe/subscribe/cancel Cancel Stripe subscription
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName StripeSubscribeCancel
|
||||
* @apiGroup Payments
|
||||
**/
|
||||
api.subscribeCancel = {
|
||||
method: 'GET',
|
||||
url: '/stripe/subscribe/cancel',
|
||||
middlewares: [authWithUrl],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
if (!user.purchased.plan.customerId) throw new NotAuthorized(res.t('missingSubscription'));
|
||||
|
||||
let customer = await stripe.customers.retrieve(user.purchased.plan.customeerId);
|
||||
await stripe.customers.del(user.purchased.plan.customerId);
|
||||
await payments.cancelSubscriptoin({
|
||||
user,
|
||||
nextBill: customer.subscription.current_period_end * 1000, // timestamp in seconds
|
||||
paymentMethod: 'Stripe',
|
||||
});
|
||||
|
||||
res.redirect('/');
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
/* eslint-disable global-require, no-process-env */
|
||||
|
||||
// Register babel hook so we can write the real entry file (server.js) in ES6
|
||||
// In production, the es6 code is pre-transpiled so it doesn't need it
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
require('babel-register');
|
||||
}
|
||||
|
||||
// The BabelJS polyfill is needed in production too
|
||||
require('babel-polyfill');
|
||||
|
||||
// Setup Bluebird as the global promise library
|
||||
global.Promise = require('bluebird');
|
||||
|
||||
// Initialize configuration BEFORE anything
|
||||
const setupNconf = require('./libs/api-v3/setupNconf');
|
||||
setupNconf();
|
||||
|
||||
const nconf = require('nconf');
|
||||
|
||||
const cluster = require('cluster');
|
||||
const logger = require('./libs/api-v3/logger');
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
const IS_DEV = nconf.get('IS_DEV');
|
||||
const CORES = Number(nconf.get('WEB_CONCURRENCY')) || 0;
|
||||
|
||||
// Initialize New Relic
|
||||
if (IS_PROD && nconf.get('NEW_RELIC_ENABLED') === 'true') require('newrelic');
|
||||
|
||||
// Setup the cluster module
|
||||
if (CORES !== 0 && cluster.isMaster && (IS_DEV || IS_PROD)) {
|
||||
// Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production)
|
||||
for (let i = 0; i < CORES; i += 1) {
|
||||
cluster.fork();
|
||||
}
|
||||
|
||||
cluster.on('disconnect', function onWorkerDisconnect (worker) {
|
||||
let w = cluster.fork(); // replace the dead worker
|
||||
|
||||
logger.info('[%s] [master:%s] worker:%s disconnect! new worker:%s fork', new Date(), process.pid, worker.process.pid, w.process.pid);
|
||||
});
|
||||
} else {
|
||||
module.exports = require('./server.js');
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
require('./i18n');
|
||||
|
||||
var _ = require('lodash');
|
||||
var Content = require('../../../../common').content;
|
||||
var Amplitude = require('amplitude');
|
||||
var googleAnalytics = require('universal-analytics');
|
||||
|
||||
var ga;
|
||||
var amplitude;
|
||||
|
||||
var analytics = {
|
||||
trackPurchase: trackPurchase,
|
||||
track: track
|
||||
}
|
||||
|
||||
function init(options) {
|
||||
if(!options) { throw 'No options provided' }
|
||||
|
||||
amplitude = new Amplitude(options.amplitudeToken);
|
||||
ga = googleAnalytics(options.googleAnalytics);
|
||||
|
||||
return analytics;
|
||||
}
|
||||
|
||||
function track(eventType, data) {
|
||||
_sendDataToAmplitude(eventType, data);
|
||||
_sendDataToGoogle(eventType, data);
|
||||
}
|
||||
|
||||
function _sendDataToAmplitude(eventType, data) {
|
||||
var amplitudeData = _formatDataForAmplitude(data);
|
||||
amplitudeData.event_type = eventType;
|
||||
amplitude.track(amplitudeData).catch(function(error) {
|
||||
// @TODO log error with new relic
|
||||
});
|
||||
}
|
||||
|
||||
function _sendDataToGoogle(eventType, data) {
|
||||
var eventData = {
|
||||
ec: data.category,
|
||||
ea: eventType
|
||||
}
|
||||
|
||||
var label = _generateLabelForGoogleAnalytics(data);
|
||||
if(label) { eventData.el = label; }
|
||||
|
||||
var value = _generateValueForGoogleAnalytics(data);
|
||||
if(value) { eventData.ev = value; }
|
||||
|
||||
ga.event(eventData).send();
|
||||
}
|
||||
|
||||
function _generateLabelForGoogleAnalytics(data) {
|
||||
var label;
|
||||
var POSSIBLE_LABELS = ['gaLabel', 'itemKey'];
|
||||
|
||||
_(POSSIBLE_LABELS).each(function(key) {
|
||||
if(data[key]) {
|
||||
label = data[key];
|
||||
return false; // exit _.each early
|
||||
}
|
||||
}).value();
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
function _generateValueForGoogleAnalytics(data) {
|
||||
var value;
|
||||
var POSSIBLE_VALUES = ['gaValue', 'gemCost', 'goldCost'];
|
||||
|
||||
_(POSSIBLE_VALUES).each(function(key) {
|
||||
if(data[key]) {
|
||||
value = data[key];
|
||||
return false; // exit _.each early
|
||||
}
|
||||
}).value();
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function trackPurchase(data) {
|
||||
_sendPurchaseDataToAmplitude(data);
|
||||
_sendPurchaseDataToGoogle(data);
|
||||
}
|
||||
|
||||
function _sendPurchaseDataToAmplitude(data) {
|
||||
var amplitudeData = _formatDataForAmplitude(data);
|
||||
amplitudeData.event_type = 'purchase';
|
||||
amplitudeData.revenue = data.purchaseValue;
|
||||
|
||||
amplitude.track(amplitudeData).catch(function(error) {
|
||||
// @TODO log error with new relic
|
||||
});
|
||||
}
|
||||
|
||||
function _formatDataForAmplitude(data) {
|
||||
var PROPERTIES_TO_SCRUB = ['uuid', 'user', 'purchaseValue', 'gaLabel', 'gaValue'];
|
||||
var event_properties = _.omit(data, PROPERTIES_TO_SCRUB);
|
||||
|
||||
var ampData = {
|
||||
user_id: data.uuid || 'no-user-id-was-provided',
|
||||
platform: 'server',
|
||||
event_properties: event_properties
|
||||
}
|
||||
|
||||
if(data.user) {
|
||||
ampData.user_properties = _formatUserData(data.user);
|
||||
}
|
||||
|
||||
var itemName = _lookUpItemName(data.itemKey);
|
||||
if(itemName) {
|
||||
event_properties.itemName = itemName;
|
||||
}
|
||||
|
||||
return ampData;
|
||||
}
|
||||
|
||||
function _lookUpItemName(itemKey) {
|
||||
if (!itemKey) return;
|
||||
|
||||
var gear = Content.gear.flat[itemKey];
|
||||
var egg = Content.eggs[itemKey];
|
||||
var food = Content.food[itemKey];
|
||||
var hatchingPotion = Content.hatchingPotions[itemKey];
|
||||
var quest = Content.quests[itemKey];
|
||||
var spell = Content.special[itemKey];
|
||||
|
||||
var itemName;
|
||||
|
||||
if (gear) {
|
||||
itemName = gear.text();
|
||||
} else if (egg) {
|
||||
itemName = egg.text() + ' Egg';
|
||||
} else if (food) {
|
||||
itemName = food.text();
|
||||
} else if (hatchingPotion) {
|
||||
itemName = hatchingPotion.text() + " Hatching Potion";
|
||||
} else if (quest) {
|
||||
itemName = quest.text();
|
||||
} else if (spell) {
|
||||
itemName = spell.text();
|
||||
}
|
||||
|
||||
return itemName;
|
||||
}
|
||||
|
||||
function _formatUserData(user) {
|
||||
var properties = {};
|
||||
|
||||
if (user.stats) {
|
||||
properties.Class = user.stats.class;
|
||||
properties.Experience = Math.floor(user.stats.exp);
|
||||
properties.Gold = Math.floor(user.stats.gp);
|
||||
properties.Health = Math.ceil(user.stats.hp);
|
||||
properties.Level = user.stats.lvl;
|
||||
properties.Mana = Math.floor(user.stats.mp);
|
||||
}
|
||||
|
||||
properties.tutorialComplete = user.flags && user.flags.tour && user.flags.tour.intro === -2;
|
||||
|
||||
if (user.habits && user.dailys && user.todos && user.rewards) {
|
||||
properties["Number Of Tasks"] = {
|
||||
habits: user.habits.length,
|
||||
dailys: user.dailys.length,
|
||||
todos: user.todos.length,
|
||||
rewards: user.rewards.length
|
||||
};
|
||||
}
|
||||
|
||||
if (user.contributor && user.contributor.level) {
|
||||
properties.contributorLevel = user.contributor.level;
|
||||
}
|
||||
|
||||
if (user.purchased && user.purchased.plan.planId) {
|
||||
properties.subscription = user.purchased.plan.planId;
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
function _sendPurchaseDataToGoogle(data) {
|
||||
var label = data.paymentMethod;
|
||||
var type = data.purchaseType;
|
||||
var price = data.purchaseValue;
|
||||
var qty = data.quantity;
|
||||
var sku = data.sku;
|
||||
var itemKey = data.itemPurchased;
|
||||
var variation = type;
|
||||
if(data.gift) variation += ' - Gift';
|
||||
|
||||
var eventData = {
|
||||
ec: 'commerce',
|
||||
ea: type,
|
||||
el: label,
|
||||
ev: price
|
||||
};
|
||||
|
||||
ga.event(eventData).send();
|
||||
|
||||
ga.transaction(data.uuid, price)
|
||||
.item(price, qty, sku, itemKey, variation)
|
||||
.send();
|
||||
}
|
||||
|
||||
module.exports = init;
|
||||
@@ -0,0 +1,59 @@
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var nconf = require('nconf');
|
||||
var _ = require('lodash');
|
||||
var manifestFiles = require("../../../client/manifest.json");
|
||||
|
||||
var IS_PROD = nconf.get('NODE_ENV') === 'production';
|
||||
var buildFiles = [];
|
||||
|
||||
var walk = function(folder){
|
||||
var res = fs.readdirSync(folder);
|
||||
|
||||
res.forEach(function(fileName){
|
||||
var file = folder + '/' + fileName;
|
||||
if(fs.statSync(file).isDirectory()){
|
||||
walk(file);
|
||||
}else{
|
||||
var relFolder = path.relative(path.join(__dirname, "/../../../build"), folder);
|
||||
var old = fileName.replace(/-.{8}(\.[\d\w]+)$/, '$1');
|
||||
|
||||
if(relFolder){
|
||||
old = relFolder + '/' + old;
|
||||
fileName = relFolder + '/' + fileName;
|
||||
}
|
||||
|
||||
buildFiles[old] = fileName;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walk(path.join(__dirname, "/../../../build"));
|
||||
|
||||
var getBuildUrl = module.exports.getBuildUrl = function(url){
|
||||
if(buildFiles[url]) return '/' + buildFiles[url];
|
||||
|
||||
return '/' + url;
|
||||
};
|
||||
|
||||
module.exports.getManifestFiles = function(page){
|
||||
var files = manifestFiles[page];
|
||||
|
||||
if(!files) throw new Error("Page not found!");
|
||||
|
||||
var code = '';
|
||||
|
||||
if(IS_PROD){
|
||||
code += '<link rel="stylesheet" type="text/css" href="' + getBuildUrl(page + '.css') + '">';
|
||||
code += '<script type="text/javascript" src="' + getBuildUrl(page + '.js') + '"></script>';
|
||||
}else{
|
||||
_.each(files.css, function(file){
|
||||
code += '<link rel="stylesheet" type="text/css" href="' + getBuildUrl(file) + '">';
|
||||
});
|
||||
_.each(files.js, function(file){
|
||||
code += '<script type="text/javascript" src="' + getBuildUrl(file) + '"></script>';
|
||||
});
|
||||
}
|
||||
|
||||
return code;
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
var Firebase = require('firebase');
|
||||
var nconf = require('nconf');
|
||||
var isProd = nconf.get('NODE_ENV') === 'production';
|
||||
var firebaseConfig = nconf.get('FIREBASE');
|
||||
|
||||
var firebaseRef;
|
||||
var isFirebaseEnabled = (nconf.get('NODE_ENV') === 'production') && (firebaseConfig.ENABLED === 'true');
|
||||
|
||||
import { TAVERN_ID } from '../../models/group';
|
||||
|
||||
// Setup
|
||||
if(isFirebaseEnabled){
|
||||
firebaseRef = new Firebase('https://' + firebaseConfig.APP + '.firebaseio.com');
|
||||
|
||||
// TODO what happens if an op is sent before client is authenticated?
|
||||
firebaseRef.authWithCustomToken(firebaseConfig.SECRET, function(err, authData){
|
||||
// TODO it's ok to kill the server here? what if FB is offline?
|
||||
if(err) throw new Error('Impossible to authenticate Firebase');
|
||||
});
|
||||
}
|
||||
|
||||
var api = module.exports = {};
|
||||
|
||||
api.updateGroupData = function(group){
|
||||
if(!isFirebaseEnabled) return;
|
||||
// TODO is throw ok? we don't have callbacks
|
||||
if(!group) throw new Error('group is required.');
|
||||
// Return in case of tavern (comparison working because we use string for _id)
|
||||
if(group._id === TAVERN_ID) return;
|
||||
|
||||
firebaseRef.child('rooms/' + group._id)
|
||||
.set({
|
||||
name: group.name
|
||||
});
|
||||
};
|
||||
|
||||
api.addUserToGroup = function(groupId, userId){
|
||||
if(!isFirebaseEnabled) return;
|
||||
if(!userId || !groupId) throw new Error('groupId, userId are required.');
|
||||
if(groupId === TAVERN_ID) return;
|
||||
|
||||
firebaseRef.child('members/' + groupId + '/' + userId)
|
||||
.set(true);
|
||||
|
||||
firebaseRef.child('users/' + userId + '/rooms/' + groupId)
|
||||
.set(true);
|
||||
};
|
||||
|
||||
api.removeUserFromGroup = function(groupId, userId){
|
||||
if(!isFirebaseEnabled) return;
|
||||
if(!userId || !groupId) throw new Error('groupId, userId are required.');
|
||||
if(groupId === TAVERN_ID) return;
|
||||
|
||||
firebaseRef.child('members/' + groupId + '/' + userId)
|
||||
.remove();
|
||||
|
||||
firebaseRef.child('users/' + userId + '/rooms/' + groupId)
|
||||
.remove();
|
||||
};
|
||||
|
||||
api.deleteGroup = function(groupId){
|
||||
if(!isFirebaseEnabled) return;
|
||||
if(!groupId) throw new Error('groupId is required.');
|
||||
if(groupId === TAVERN_ID) return;
|
||||
|
||||
firebaseRef.child('rooms/' + groupId)
|
||||
.remove();
|
||||
|
||||
// TODO not really necessary as long as we only store room data,
|
||||
// as empty objects are automatically deleted (/members/... in future...)
|
||||
firebaseRef.child('members/' + groupId)
|
||||
.remove();
|
||||
};
|
||||
|
||||
// TODO not really necessary as long as we only store room data,
|
||||
// as empty objects are automatically deleted
|
||||
api.deleteUser = function(userId){
|
||||
if(!isFirebaseEnabled) return;
|
||||
if(!userId) throw new Error('userId is required.');
|
||||
|
||||
firebaseRef.child('users/' + userId)
|
||||
.remove();
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
var fs = require('fs'),
|
||||
path = require('path'),
|
||||
_ = require('lodash'),
|
||||
User = require('../../models/user').model,
|
||||
accepts = require('accepts'),
|
||||
shared = require('../../../../common'),
|
||||
translations = {};
|
||||
|
||||
var localePath = path.join(__dirname, "/../../../../common/locales/")
|
||||
|
||||
var loadTranslations = function(locale){
|
||||
var files = fs.readdirSync(path.join(localePath, locale));
|
||||
translations[locale] = {};
|
||||
_.each(files, function(file){
|
||||
if(path.extname(file) !== '.json') return;
|
||||
_.merge(translations[locale], require(path.join(localePath, locale, file)));
|
||||
});
|
||||
};
|
||||
|
||||
// First fetch english so we can merge with missing strings in other languages
|
||||
loadTranslations('en');
|
||||
|
||||
fs.readdirSync(localePath).forEach(function(file) {
|
||||
if(file === 'en' || fs.statSync(path.join(localePath, file)).isDirectory() === false) return;
|
||||
loadTranslations(file);
|
||||
// Merge missing strings from english
|
||||
_.defaults(translations[file], translations.en);
|
||||
});
|
||||
|
||||
var langCodes = Object.keys(translations);
|
||||
|
||||
var avalaibleLanguages = _.map(langCodes, function(langCode){
|
||||
return {
|
||||
code: langCode,
|
||||
name: translations[langCode].languageName
|
||||
}
|
||||
});
|
||||
|
||||
// Load MomentJS localization files
|
||||
var momentLangs = {};
|
||||
|
||||
// Handle different language codes from MomentJS and /locales
|
||||
var momentLangsMapping = {
|
||||
'en': 'en-gb',
|
||||
'en_GB': 'en-gb',
|
||||
'no': 'nn',
|
||||
'zh': 'zh-cn',
|
||||
'es_419': 'es'
|
||||
};
|
||||
|
||||
var momentLangs = {};
|
||||
|
||||
_.each(langCodes, function(code){
|
||||
var lang = _.find(avalaibleLanguages, {code: code});
|
||||
lang.momentLangCode = (momentLangsMapping[code] || code);
|
||||
try{
|
||||
// MomentJS lang files are JS files that has to be executed in the browser so we load them as plain text files
|
||||
var f = fs.readFileSync(path.join(__dirname, '/../../node_modules/moment/locale/' + lang.momentLangCode + '.js'), 'utf8');
|
||||
momentLangs[code] = f;
|
||||
}catch (e){}
|
||||
});
|
||||
|
||||
// Remove en_GB from langCodes checked by browser to avaoi it being
|
||||
// used in place of plain original 'en'
|
||||
var defaultLangCodes = _.without(langCodes, 'en_GB');
|
||||
|
||||
// A list of languages that have different versions
|
||||
var multipleVersionsLanguages = ['es', 'zh'];
|
||||
|
||||
var latinAmericanSpanishes = {
|
||||
'es-419': 'es_419',
|
||||
'es-mx': 'es_419',
|
||||
'es-gt': 'es_419',
|
||||
'es-cr': 'es_419',
|
||||
'es-pa': 'es_419',
|
||||
'es-do': 'es_419',
|
||||
'es-ve': 'es_419',
|
||||
'es-co': 'es_419',
|
||||
'es-pe': 'es_419',
|
||||
'es-ar': 'es_419',
|
||||
'es-ec': 'es_419',
|
||||
'es-cl': 'es_419',
|
||||
'es-uy': 'es_419',
|
||||
'es-py': 'es_419',
|
||||
'es-bo': 'es_419',
|
||||
'es-sv': 'es_419',
|
||||
'es-hn': 'es_419',
|
||||
'es-ni': 'es_419',
|
||||
'es-pr': 'es_419',
|
||||
};
|
||||
|
||||
var chineseVersions = {
|
||||
'zh-tw': 'zh_TW',
|
||||
};
|
||||
|
||||
var getUserLanguage = function(req, res, next){
|
||||
var getFromBrowser = function(){
|
||||
var acceptedLanguages = accepts(req).languages();
|
||||
|
||||
var acceptable = _(acceptedLanguages).map(function(lang){
|
||||
return lang.slice(0, 2);
|
||||
}).uniq().value();
|
||||
|
||||
var matches = _.intersection(acceptable, defaultLangCodes);
|
||||
|
||||
var iAcceptedCompleteLang = (matches.length > 0) ? multipleVersionsLanguages.indexOf(matches[0].toLowerCase()) : -1;
|
||||
|
||||
if(iAcceptedCompleteLang !== -1){
|
||||
var acceptedCompleteLang = _.find(acceptedLanguages, function(accepted){
|
||||
return accepted.slice(0, 2) == multipleVersionsLanguages[iAcceptedCompleteLang];
|
||||
});
|
||||
|
||||
if(acceptedCompleteLang){
|
||||
acceptedCompleteLang = acceptedCompleteLang.toLowerCase();
|
||||
}else{
|
||||
return 'en';
|
||||
}
|
||||
|
||||
if(matches[0] === 'es'){
|
||||
return latinAmericanSpanishes[acceptedCompleteLang] || 'es';
|
||||
}else if(matches[0] === 'zh'){
|
||||
return chineseVersions[acceptedCompleteLang] || 'zh';
|
||||
}else{
|
||||
return en;
|
||||
}
|
||||
|
||||
}else if(matches.length > 0){
|
||||
return matches[0].toLowerCase();
|
||||
}else{
|
||||
return 'en';
|
||||
}
|
||||
};
|
||||
|
||||
var getFromUser = function(user){
|
||||
var lang;
|
||||
if(user && user.preferences.language && translations[user.preferences.language]){
|
||||
lang = user.preferences.language;
|
||||
}else{
|
||||
var preferred = getFromBrowser();
|
||||
lang = translations[preferred] ? preferred : 'en';
|
||||
}
|
||||
req.language = lang;
|
||||
next();
|
||||
};
|
||||
|
||||
if(req.query.lang){
|
||||
req.language = translations[req.query.lang] ? (req.query.lang) : 'en';
|
||||
next();
|
||||
}else if(req.locals && req.locals.user){
|
||||
getFromUser(req.locals.user);
|
||||
}else if(req.session && req.session.userId){
|
||||
User.findOne({_id: req.session.userId}, 'preferences.language', function(err, user){
|
||||
if(err) return next(err);
|
||||
getFromUser(user);
|
||||
});
|
||||
}else{
|
||||
getFromUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
shared.i18n.translations = translations;
|
||||
|
||||
module.exports = {
|
||||
translations: translations,
|
||||
avalaibleLanguages: avalaibleLanguages,
|
||||
langCodes: langCodes,
|
||||
getUserLanguage: getUserLanguage,
|
||||
momentLangs: momentLangs
|
||||
};
|
||||
|
||||
|
||||
// Export en strings only, temporary solution for mobile
|
||||
// This is copied from middlewares/locals#t()
|
||||
module.exports.enTranslations = function(){ // stringName and vars are the allowed parameters
|
||||
var language = _.find(avalaibleLanguages, {code: 'en'});
|
||||
//language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined);
|
||||
var args = Array.prototype.slice.call(arguments, 0);
|
||||
args.push(language.code);
|
||||
return shared.i18n.t.apply(null, args);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
var nconf = require('nconf');
|
||||
var winston = require('winston');
|
||||
|
||||
var logger, loggly;
|
||||
|
||||
// Currently disabled
|
||||
if (nconf.get('LOGGLY:enabled')){
|
||||
loggly = require('loggly').createClient({
|
||||
token: nconf.get('LOGGLY:token'),
|
||||
subdomain: nconf.get('LOGGLY:subdomain'),
|
||||
auth: {
|
||||
username: nconf.get('LOGGLY:username'),
|
||||
password: nconf.get('LOGGLY:password')
|
||||
},
|
||||
//
|
||||
// Optional: Tag to send with EVERY log message
|
||||
//
|
||||
tags: [('heroku-'+nconf.get('BASE_URL'))],
|
||||
json: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!logger) {
|
||||
logger = new (winston.Logger)({});
|
||||
logger.add(winston.transports.Console, {colorize:true}); // TODO remove
|
||||
|
||||
if (nconf.get('NODE_ENV') !== 'production') {
|
||||
logger.add(winston.transports.File, {filename: 'habitrpg.log'});
|
||||
}
|
||||
}
|
||||
|
||||
// A custom log function that wraps Winston. Makes it easy to instrument code
|
||||
// and still possible to replace Winston in the future.
|
||||
module.exports.log = function(/* variable args */) {
|
||||
if (logger)
|
||||
logger.log.apply(logger, arguments);
|
||||
};
|
||||
|
||||
module.exports.info = function(/* variable args */) {
|
||||
if (logger)
|
||||
logger.info.apply(logger, arguments);
|
||||
};
|
||||
|
||||
module.exports.warn = function(/* variable args */) {
|
||||
if (logger)
|
||||
logger.warn.apply(logger, arguments);
|
||||
};
|
||||
|
||||
module.exports.error = function(/* variable args */) {
|
||||
if (logger)
|
||||
logger.error.apply(logger, arguments);
|
||||
};
|
||||
|
||||
module.exports.loggly = function(/* variable args */){
|
||||
if (loggly)
|
||||
loggly.log.apply(loggly, arguments);
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
var nodemailer = require('nodemailer');
|
||||
var nconf = require('nconf');
|
||||
var crypto = require('crypto');
|
||||
var path = require("path");
|
||||
var request = require('request');
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
const BASE_URL = nconf.get('BASE_URL');
|
||||
|
||||
module.exports.sendEmail = function(mailData) {
|
||||
var smtpTransport = nodemailer.createTransport({
|
||||
service: nconf.get('SMTP_SERVICE'),
|
||||
auth: {
|
||||
user: nconf.get('SMTP_USER'),
|
||||
pass: nconf.get('SMTP_PASS')
|
||||
}
|
||||
});
|
||||
|
||||
smtpTransport.sendMail(mailData, function(error, response){
|
||||
var logging = require('./api-v2/logging');
|
||||
if(error) logging.error(error);
|
||||
else logging.info("Message sent: " + response.message);
|
||||
smtpTransport.close(); // shut down the connection pool, no more messages
|
||||
});
|
||||
}
|
||||
|
||||
function getUserInfo(user, fields) {
|
||||
var info = {};
|
||||
|
||||
if(fields.indexOf('name') != -1){
|
||||
if(user.auth.local){
|
||||
info.name = user.profile.name || user.auth.local.username;
|
||||
}else if(user.auth.facebook){
|
||||
info.name = user.profile.name || user.auth.facebook.displayName || user.auth.facebook.username;
|
||||
}
|
||||
}
|
||||
|
||||
if(fields.indexOf('email') != -1){
|
||||
if(user.auth.local && user.auth.local.email){
|
||||
info.email = user.auth.local.email;
|
||||
}else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){
|
||||
info.email = user.auth.facebook.emails[0].value;
|
||||
}
|
||||
}
|
||||
|
||||
if(fields.indexOf('_id') != -1){
|
||||
info._id = user._id;
|
||||
}
|
||||
|
||||
if(fields.indexOf('canSend') != -1){
|
||||
info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
module.exports.getUserInfo = getUserInfo;
|
||||
|
||||
module.exports.txnEmail = function(mailingInfoArray, emailType, variables, personalVariables){
|
||||
var mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray];
|
||||
|
||||
var variables = [
|
||||
{name: 'BASE_URL', content: BASE_URL}
|
||||
].concat(variables || []);
|
||||
|
||||
// It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed
|
||||
mailingInfoArray = mailingInfoArray.map(function(mailingInfo){
|
||||
return mailingInfo._id ? getUserInfo(mailingInfo, ['_id', 'email', 'name', 'canSend']) : mailingInfo;
|
||||
}).filter(function(mailingInfo){
|
||||
// Always send reset-password emails
|
||||
// Don't check canSend for non registered users as already checked before
|
||||
return (mailingInfo.email && ((!mailingInfo._id || mailingInfo.canSend) || emailType === 'reset-password'));
|
||||
});
|
||||
|
||||
// Personal variables are personal to each email recipient, if they are missing
|
||||
// we manually create a structure for them with RECIPIENT_NAME and RECIPIENT_UNSUB_URL
|
||||
// otherwise we just add RECIPIENT_NAME and RECIPIENT_UNSUB_URL to the existing personal variables
|
||||
if(!personalVariables || personalVariables.length === 0){
|
||||
personalVariables = mailingInfoArray.map(function(mailingInfo){
|
||||
return {
|
||||
rcpt: mailingInfo.email,
|
||||
vars: [
|
||||
{
|
||||
name: 'RECIPIENT_NAME',
|
||||
content: mailingInfo.name
|
||||
},
|
||||
{
|
||||
name: 'RECIPIENT_UNSUB_URL',
|
||||
content: '/unsubscribe?code=' + module.exports.encrypt(JSON.stringify({
|
||||
_id: mailingInfo._id,
|
||||
email: mailingInfo.email
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}else{
|
||||
var temporaryPersonalVariables = {};
|
||||
|
||||
mailingInfoArray.forEach(function(mailingInfo){
|
||||
temporaryPersonalVariables[mailingInfo.email] = {
|
||||
name: mailingInfo.name,
|
||||
_id: mailingInfo._id
|
||||
}
|
||||
});
|
||||
|
||||
personalVariables.forEach(function(singlePersonalVariables){
|
||||
singlePersonalVariables.vars.push(
|
||||
{
|
||||
name: 'RECIPIENT_NAME',
|
||||
content: temporaryPersonalVariables[singlePersonalVariables.rcpt].name
|
||||
},
|
||||
{
|
||||
name: 'RECIPIENT_UNSUB_URL',
|
||||
content: '/unsubscribe?code=' + module.exports.encrypt(JSON.stringify({
|
||||
_id: temporaryPersonalVariables[singlePersonalVariables.rcpt]._id,
|
||||
email: singlePersonalVariables.rcpt
|
||||
}))
|
||||
}
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
if(IS_PROD && mailingInfoArray.length > 0){
|
||||
request({
|
||||
url: nconf.get('EMAIL_SERVER:url') + '/job',
|
||||
method: 'POST',
|
||||
auth: {
|
||||
user: nconf.get('EMAIL_SERVER:authUser'),
|
||||
pass: nconf.get('EMAIL_SERVER:authPassword')
|
||||
},
|
||||
json: {
|
||||
type: 'email',
|
||||
data: {
|
||||
emailType: emailType,
|
||||
to: mailingInfoArray,
|
||||
variables: variables,
|
||||
personalVariables: personalVariables
|
||||
},
|
||||
options: {
|
||||
priority: 'high',
|
||||
attempts: 5,
|
||||
backoff: {delay: 10*60*1000, type: 'fixed'}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Encryption using http://dailyjs.com/2010/12/06/node-tutorial-5/
|
||||
// Note: would use [password-hash](https://github.com/davidwood/node-password-hash), but we need to run
|
||||
// model.query().equals(), so it's a PITA to work in their verify() function
|
||||
|
||||
module.exports.encryptPassword = function(password, salt) {
|
||||
return crypto.createHmac('sha1', salt).update(password).digest('hex');
|
||||
}
|
||||
|
||||
module.exports.makeSalt = function() {
|
||||
var len = 10;
|
||||
return crypto.randomBytes(Math.ceil(len / 2)).toString('hex').substring(0, len);
|
||||
}
|
||||
|
||||
// Prepare to export analytics object
|
||||
// Export emoty methods until the right ones are ready
|
||||
module.exports.analytics = { track: function() { }, trackPurchase: function() { } };
|
||||
|
||||
/**
|
||||
* Load nconf and define default configuration values if config.json or ENV vars are not found
|
||||
*/
|
||||
module.exports.setupConfig = function(){
|
||||
if (nconf.get('IS_DEV'))
|
||||
Error.stackTraceLimit = Infinity;
|
||||
if (IS_PROD && nconf.get('NEW_RELIC_ENABLED') === 'true')
|
||||
require('newrelic');
|
||||
|
||||
var analytics = IS_PROD && require('./api-v2/analytics');
|
||||
var analyticsTokens = {
|
||||
amplitudeToken: nconf.get('AMPLITUDE_KEY'),
|
||||
googleAnalytics: nconf.get('GA_ID')
|
||||
}
|
||||
|
||||
if(analytics){
|
||||
analytics = analytics(analyticsTokens);
|
||||
// Use the right analytics methods, don't substitute the entire object
|
||||
// or all the require() across the code will keep the empty methods
|
||||
module.exports.analytics.track = analytics.track;
|
||||
module.exports.analytics.trackPurchase = analytics.trackPurchase;
|
||||
}
|
||||
};
|
||||
|
||||
var algorithm = 'aes-256-ctr';
|
||||
module.exports.encrypt = function(text){
|
||||
var cipher = crypto.createCipher(algorithm,nconf.get('SESSION_SECRET'))
|
||||
var crypted = cipher.update(text,'utf8','hex')
|
||||
crypted += cipher.final('hex');
|
||||
return crypted;
|
||||
}
|
||||
|
||||
module.exports.decrypt = function(text){
|
||||
var decipher = crypto.createDecipher(algorithm,nconf.get('SESSION_SECRET'))
|
||||
var dec = decipher.update(text,'hex','utf8')
|
||||
dec += decipher.final('utf8');
|
||||
return dec;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
var _ = require('lodash');
|
||||
var request = require('request');
|
||||
var validator = require('validator');
|
||||
|
||||
function sendTaskWebhook(webhooks, data) {
|
||||
_.each(webhooks, function(hook){
|
||||
if (!hook.enabled || !validator.isURL(hook.url)) return;
|
||||
|
||||
request.post({
|
||||
url: hook.url,
|
||||
body: {
|
||||
direction: data.task.direction,
|
||||
task: data.task.details,
|
||||
delta: data.task.delta,
|
||||
user: data.user
|
||||
},
|
||||
json: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendTaskWebhook: sendTaskWebhook
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import amazonPayments from 'amazon-payments';
|
||||
import nconf from 'nconf';
|
||||
import common from '../../../../common';
|
||||
import Bluebird from 'bluebird';
|
||||
import {
|
||||
BadRequest,
|
||||
} from './errors';
|
||||
|
||||
// TODO better handling of errors
|
||||
|
||||
const i18n = common.i18n;
|
||||
const IS_PROD = nconf.get('NODE_ENV') === 'production';
|
||||
|
||||
let amzPayment = amazonPayments.connect({
|
||||
environment: amazonPayments.Environment[IS_PROD ? 'Production' : 'Sandbox'],
|
||||
sellerId: nconf.get('AMAZON_PAYMENTS:SELLER_ID'),
|
||||
mwsAccessKey: nconf.get('AMAZON_PAYMENTS:MWS_KEY'),
|
||||
mwsSecretKey: nconf.get('AMAZON_PAYMENTS:MWS_SECRET'),
|
||||
clientId: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'),
|
||||
});
|
||||
|
||||
let getTokenInfo = Bluebird.promisify(amzPayment.api.getTokenInfo, {context: amzPayment.api});
|
||||
let createOrderReferenceId = Bluebird.promisify(amzPayment.offAmazonPayments.createOrderReferenceForId, {context: amzPayment.offAmazonPayments});
|
||||
let setOrderReferenceDetails = Bluebird.promisify(amzPayment.offAmazonPayments.setOrderReferenceDetails, {context: amzPayment.offAmazonPayments});
|
||||
let confirmOrderReference = Bluebird.promisify(amzPayment.offAmazonPayments.confirmOrderReference, {context: amzPayment.offAmazonPayments});
|
||||
let closeOrderReference = Bluebird.promisify(amzPayment.offAmazonPayments.closeOrderReference, {context: amzPayment.offAmazonPayments});
|
||||
let setBillingAgreementDetails = Bluebird.promisify(amzPayment.offAmazonPayments.setBillingAgreementDetails, {context: amzPayment.offAmazonPayments});
|
||||
let confirmBillingAgreement = Bluebird.promisify(amzPayment.offAmazonPayments.confirmBillingAgreement, {context: amzPayment.offAmazonPayments});
|
||||
let closeBillingAgreement = Bluebird.promisify(amzPayment.offAmazonPayments.closeBillingAgreement, {context: amzPayment.offAmazonPayments});
|
||||
|
||||
let authorizeOnBillingAgreement = (inputSet) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
amzPayment.offAmazonPayments.authorizeOnBillingAgreement(inputSet, (err, response) => {
|
||||
if (err) return reject(err);
|
||||
if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful')));
|
||||
return resolve(response);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
let authorize = (inputSet) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
amzPayment.offAmazonPayments.authorize(inputSet, (err, response) => {
|
||||
if (err) return reject(err);
|
||||
if (response.AuthorizationDetails.AuthorizationStatus.State === 'Declined') return reject(new BadRequest(i18n.t('paymentNotSuccessful')));
|
||||
return resolve(response);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getTokenInfo,
|
||||
createOrderReferenceId,
|
||||
setOrderReferenceDetails,
|
||||
confirmOrderReference,
|
||||
closeOrderReference,
|
||||
confirmBillingAgreement,
|
||||
setBillingAgreementDetails,
|
||||
closeBillingAgreement,
|
||||
authorizeOnBillingAgreement,
|
||||
authorize,
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
/* eslint-disable camelcase */
|
||||
import nconf from 'nconf';
|
||||
import Amplitude from 'amplitude';
|
||||
import Bluebird from 'bluebird';
|
||||
import googleAnalytics from 'universal-analytics';
|
||||
import {
|
||||
each,
|
||||
omit,
|
||||
} from 'lodash';
|
||||
import { content as Content } from '../../../../common';
|
||||
|
||||
const AMPLIUDE_TOKEN = nconf.get('AMPLITUDE_KEY');
|
||||
const GA_TOKEN = nconf.get('GA_ID');
|
||||
const GA_POSSIBLE_LABELS = ['gaLabel', 'itemKey'];
|
||||
const GA_POSSIBLE_VALUES = ['gaValue', 'gemCost', 'goldCost'];
|
||||
const AMPLITUDE_PROPERTIES_TO_SCRUB = ['uuid', 'user', 'purchaseValue', 'gaLabel', 'gaValue'];
|
||||
|
||||
let amplitude = new Amplitude(AMPLIUDE_TOKEN);
|
||||
let ga = googleAnalytics(GA_TOKEN);
|
||||
|
||||
let _lookUpItemName = (itemKey) => {
|
||||
if (!itemKey) return;
|
||||
|
||||
let gear = Content.gear.flat[itemKey];
|
||||
let egg = Content.eggs[itemKey];
|
||||
let food = Content.food[itemKey];
|
||||
let hatchingPotion = Content.hatchingPotions[itemKey];
|
||||
let quest = Content.quests[itemKey];
|
||||
let spell = Content.special[itemKey];
|
||||
|
||||
let itemName;
|
||||
|
||||
if (gear) {
|
||||
itemName = gear.text();
|
||||
} else if (egg) {
|
||||
itemName = `${egg.text()} Egg`;
|
||||
} else if (food) {
|
||||
itemName = food.text();
|
||||
} else if (hatchingPotion) {
|
||||
itemName = `${hatchingPotion.text()} Hatching Potion`;
|
||||
} else if (quest) {
|
||||
itemName = quest.text();
|
||||
} else if (spell) {
|
||||
itemName = spell.text();
|
||||
}
|
||||
|
||||
return itemName;
|
||||
};
|
||||
|
||||
let _formatUserData = (user) => {
|
||||
let properties = {};
|
||||
|
||||
if (user.stats) {
|
||||
properties.Class = user.stats.class;
|
||||
properties.Experience = Math.floor(user.stats.exp);
|
||||
properties.Gold = Math.floor(user.stats.gp);
|
||||
properties.Health = Math.ceil(user.stats.hp);
|
||||
properties.Level = user.stats.lvl;
|
||||
properties.Mana = Math.floor(user.stats.mp);
|
||||
}
|
||||
|
||||
properties.tutorialComplete = user.flags && user.flags.tour && user.flags.tour.intro === -2;
|
||||
|
||||
if (user.habits && user.dailys && user.todos && user.rewards) {
|
||||
properties['Number Of Tasks'] = {
|
||||
habits: user.habits.length,
|
||||
dailys: user.dailys.length,
|
||||
todos: user.todos.length,
|
||||
rewards: user.rewards.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (user.contributor && user.contributor.level) {
|
||||
properties.contributorLevel = user.contributor.level;
|
||||
}
|
||||
|
||||
if (user.purchased && user.purchased.plan.planId) {
|
||||
properties.subscription = user.purchased.plan.planId;
|
||||
}
|
||||
|
||||
return properties;
|
||||
};
|
||||
|
||||
|
||||
let _formatDataForAmplitude = (data) => {
|
||||
let event_properties = omit(data, AMPLITUDE_PROPERTIES_TO_SCRUB);
|
||||
|
||||
let ampData = {
|
||||
user_id: data.uuid || 'no-user-id-was-provided',
|
||||
platform: 'server',
|
||||
event_properties,
|
||||
};
|
||||
|
||||
if (data.user) {
|
||||
ampData.user_properties = _formatUserData(data.user);
|
||||
}
|
||||
|
||||
let itemName = _lookUpItemName(data.itemKey);
|
||||
|
||||
if (itemName) {
|
||||
event_properties.itemName = itemName;
|
||||
}
|
||||
|
||||
return ampData;
|
||||
};
|
||||
|
||||
let _sendDataToAmplitude = (eventType, data) => {
|
||||
let amplitudeData = _formatDataForAmplitude(data);
|
||||
|
||||
amplitudeData.event_type = eventType;
|
||||
|
||||
return new Bluebird((resolve, reject) => {
|
||||
amplitude.track(amplitudeData)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
let _generateLabelForGoogleAnalytics = (data) => {
|
||||
let label;
|
||||
|
||||
each(GA_POSSIBLE_LABELS, (key) => {
|
||||
if (data[key]) {
|
||||
label = data[key];
|
||||
return false; // exit each early
|
||||
}
|
||||
});
|
||||
|
||||
return label;
|
||||
};
|
||||
|
||||
let _generateValueForGoogleAnalytics = (data) => {
|
||||
let value;
|
||||
|
||||
each(GA_POSSIBLE_VALUES, (key) => {
|
||||
if (data[key]) {
|
||||
value = data[key];
|
||||
return false; // exit each early
|
||||
}
|
||||
});
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
let _sendDataToGoogle = (eventType, data) => {
|
||||
let eventData = {
|
||||
ec: data.category,
|
||||
ea: eventType,
|
||||
};
|
||||
|
||||
let label = _generateLabelForGoogleAnalytics(data);
|
||||
|
||||
if (label) {
|
||||
eventData.el = label;
|
||||
}
|
||||
|
||||
let value = _generateValueForGoogleAnalytics(data);
|
||||
|
||||
if (value) {
|
||||
eventData.ev = value;
|
||||
}
|
||||
|
||||
return new Bluebird((resolve, reject) => {
|
||||
ga.event(eventData, (err) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
let _sendPurchaseDataToAmplitude = (data) => {
|
||||
let amplitudeData = _formatDataForAmplitude(data);
|
||||
|
||||
amplitudeData.event_type = 'purchase';
|
||||
amplitudeData.revenue = data.purchaseValue;
|
||||
|
||||
return new Bluebird((resolve, reject) => {
|
||||
amplitude.track(amplitudeData)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
let _sendPurchaseDataToGoogle = (data) => {
|
||||
let label = data.paymentMethod;
|
||||
let type = data.purchaseType;
|
||||
let price = data.purchaseValue;
|
||||
let qty = data.quantity;
|
||||
let sku = data.sku;
|
||||
let itemKey = data.itemPurchased;
|
||||
let variation = type;
|
||||
|
||||
if (data.gift) variation += ' - Gift';
|
||||
|
||||
let eventData = {
|
||||
ec: 'commerce',
|
||||
ea: type,
|
||||
el: label,
|
||||
ev: price,
|
||||
};
|
||||
|
||||
return new Bluebird((resolve) => {
|
||||
ga.event(eventData).send();
|
||||
|
||||
ga.transaction(data.uuid, price)
|
||||
.item(price, qty, sku, itemKey, variation)
|
||||
.send();
|
||||
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
|
||||
function track (eventType, data) {
|
||||
return Bluebird.all([
|
||||
_sendDataToAmplitude(eventType, data),
|
||||
_sendDataToGoogle(eventType, data),
|
||||
]);
|
||||
}
|
||||
|
||||
function trackPurchase (data) {
|
||||
return Bluebird.all([
|
||||
_sendPurchaseDataToAmplitude(data),
|
||||
_sendPurchaseDataToGoogle(data),
|
||||
]);
|
||||
}
|
||||
|
||||
// Stub for non-prod environments
|
||||
let mockAnalyticsService = {
|
||||
track: () => { },
|
||||
trackPurchase: () => { },
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
track,
|
||||
trackPurchase,
|
||||
mockAnalyticsService,
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import validator from 'validator';
|
||||
import objectPath from 'object-path'; // TODO use lodash's unset once v4 is out
|
||||
import _ from 'lodash';
|
||||
|
||||
module.exports = function baseModel (schema, options = {}) {
|
||||
if (options._id !== false) {
|
||||
schema.add({
|
||||
_id: {
|
||||
type: String,
|
||||
default: uuid,
|
||||
validate: [validator.isUUID, 'Invalid uuid.'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.timestamps) {
|
||||
schema.add({
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.timestamps) {
|
||||
schema.pre('save', function updateUpdatedAt (next) {
|
||||
if (!this.isNew) this.updatedAt = Date.now();
|
||||
next();
|
||||
});
|
||||
|
||||
schema.pre('update', function preUpdateModel () {
|
||||
this.update({}, { $set: { updatedAt: new Date() } });
|
||||
});
|
||||
}
|
||||
|
||||
let noSetFields = ['createdAt', 'updatedAt'];
|
||||
let privateFields = ['__v'];
|
||||
|
||||
if (Array.isArray(options.noSet)) noSetFields.push(...options.noSet);
|
||||
// This method accepts an additional array of fields to be sanitized that can be passed at runtime
|
||||
schema.statics.sanitize = function sanitize (objToSanitize = {}, additionalFields = []) {
|
||||
noSetFields.concat(additionalFields).forEach((fieldPath) => {
|
||||
objectPath.del(objToSanitize, fieldPath);
|
||||
});
|
||||
|
||||
// Allow a sanitize transform function to be used
|
||||
return options.sanitizeTransform ? options.sanitizeTransform(objToSanitize) : objToSanitize;
|
||||
};
|
||||
|
||||
if (Array.isArray(options.private)) privateFields.push(...options.private);
|
||||
|
||||
if (!schema.options.toJSON) schema.options.toJSON = {};
|
||||
schema.options.toJSON.transform = function transformToObject (doc, plainObj) {
|
||||
privateFields.forEach((fieldPath) => {
|
||||
objectPath.del(plainObj, fieldPath);
|
||||
});
|
||||
|
||||
// Always return `id`
|
||||
if (!plainObj.id && plainObj._id) plainObj.id = plainObj._id;
|
||||
|
||||
// Allow an additional toJSON transform function to be used
|
||||
return options.toJSONTransform ? options.toJSONTransform(plainObj, doc) : plainObj;
|
||||
};
|
||||
|
||||
schema.statics.getModelPaths = function getModelPaths () {
|
||||
return _.reduce(this.schema.paths, (result, field, path) => {
|
||||
if (privateFields.indexOf(path) === -1) {
|
||||
result[path] = field.instance || 'Boolean';
|
||||
}
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import nconf from 'nconf';
|
||||
|
||||
const MANIFEST_FILE_PATH = path.join(__dirname, '/../../../client/manifest.json');
|
||||
const BUILD_FOLDER_PATH = path.join(__dirname, '/../../../build');
|
||||
let manifestFiles = require(MANIFEST_FILE_PATH);
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
let buildFiles = [];
|
||||
|
||||
function _walk (folder) {
|
||||
let files = fs.readdirSync(folder);
|
||||
|
||||
files.forEach((fileName) => {
|
||||
let file = `${folder}/${fileName}`;
|
||||
|
||||
if (fs.statSync(file).isDirectory()) {
|
||||
_walk(file);
|
||||
} else {
|
||||
let relFolder = path.relative(BUILD_FOLDER_PATH, folder);
|
||||
let original = fileName.replace(/-.{8}(\.[\d\w]+)$/, '$1'); // Match the hash part of the filename
|
||||
|
||||
if (relFolder) {
|
||||
original = `${relFolder}/${original}`;
|
||||
fileName = `${relFolder}/${fileName}`;
|
||||
}
|
||||
|
||||
buildFiles[original] = fileName;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Walks through all the files in the build directory
|
||||
// and creates a map of original files names and hashed files names
|
||||
_walk(BUILD_FOLDER_PATH);
|
||||
|
||||
export function getBuildUrl (url) {
|
||||
return `/${buildFiles[url] || url}`;
|
||||
}
|
||||
|
||||
export function getManifestFiles (page) {
|
||||
let files = manifestFiles[page];
|
||||
|
||||
if (!files) throw new Error(`Page "${page}" not found!`);
|
||||
|
||||
let htmlCode = '';
|
||||
|
||||
if (IS_PROD) {
|
||||
htmlCode += `<link rel="stylesheet" type="text/css" href="${getBuildUrl(page + '.css')}">`; // eslint-disable-line prefer-template
|
||||
htmlCode += `<script type="text/javascript" src="${getBuildUrl(page + '.js')}"></script>`; // eslint-disable-line prefer-template
|
||||
} else {
|
||||
files.css.forEach((file) => {
|
||||
htmlCode += `<link rel="stylesheet" type="text/css" href="${getBuildUrl(file)}">`;
|
||||
});
|
||||
files.js.forEach((file) => {
|
||||
htmlCode += `<script type="text/javascript" src="${getBuildUrl(file)}"></script>`;
|
||||
});
|
||||
}
|
||||
|
||||
return htmlCode;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
findIndex,
|
||||
isPlainObject,
|
||||
} from 'lodash';
|
||||
|
||||
export function removeFromArray (array, element) {
|
||||
let elementIndex;
|
||||
|
||||
if (isPlainObject(element)) {
|
||||
elementIndex = findIndex(array, element);
|
||||
} else {
|
||||
elementIndex = array.indexOf(element);
|
||||
}
|
||||
|
||||
if (elementIndex !== -1) {
|
||||
let removedElement = array[elementIndex];
|
||||
array.splice(elementIndex, 1);
|
||||
return removedElement;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import moment from 'moment';
|
||||
import common from '../../../../common/';
|
||||
import { preenUserHistory } from '../../libs/api-v3/preening';
|
||||
import _ from 'lodash';
|
||||
import nconf from 'nconf';
|
||||
|
||||
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
|
||||
const shouldDo = common.shouldDo;
|
||||
const scoreTask = common.ops.scoreTask;
|
||||
// const maxPMs = 200;
|
||||
|
||||
let CLEAR_BUFFS = {
|
||||
str: 0,
|
||||
int: 0,
|
||||
per: 0,
|
||||
con: 0,
|
||||
stealth: 0,
|
||||
streaks: false,
|
||||
};
|
||||
|
||||
function grantEndOfTheMonthPerks (user, now) {
|
||||
let plan = user.purchased.plan;
|
||||
|
||||
if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) {
|
||||
plan.gemsBought = 0; // reset gem-cap
|
||||
plan.dateUpdated = now;
|
||||
// For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks
|
||||
// If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0
|
||||
// TODO use month diff instead of ++ / --? see https://github.com/HabitRPG/habitrpg/issues/4317
|
||||
_.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0});
|
||||
|
||||
plan.consecutive.count++;
|
||||
|
||||
if (plan.consecutive.offset > 0) {
|
||||
plan.consecutive.offset--;
|
||||
} else if (plan.consecutive.count % 3 === 0) { // every 3 months
|
||||
plan.consecutive.trinkets++;
|
||||
plan.consecutive.gemCapExtra += 5;
|
||||
if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeTerminatedSubscription (user) {
|
||||
// If subscription's termination date has arrived
|
||||
let plan = user.purchased.plan;
|
||||
|
||||
if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) {
|
||||
_.merge(plan, {
|
||||
planId: null,
|
||||
customerId: null,
|
||||
paymentMethod: null,
|
||||
});
|
||||
|
||||
_.merge(plan.consecutive, {
|
||||
count: 0,
|
||||
offset: 0,
|
||||
gemCapExtra: 0,
|
||||
});
|
||||
|
||||
user.markModified('purchased.plan');
|
||||
}
|
||||
}
|
||||
|
||||
function performSleepTasks (user, tasksByType, now) {
|
||||
user.stats.buffs = _.cloneDeep(CLEAR_BUFFS);
|
||||
|
||||
tasksByType.dailys.forEach((daily) => {
|
||||
let completed = daily.completed;
|
||||
let thatDay = moment(now).subtract({days: 1});
|
||||
|
||||
if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) {
|
||||
// TODO also untick checklists if the Daily was due on previous missed days, if two or more days were missed at once -- https://github.com/HabitRPG/habitrpg/pull/7218#issuecomment-219256016
|
||||
daily.checklist.forEach(box => box.completed = false);
|
||||
}
|
||||
|
||||
daily.completed = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Perform various beginning-of-day reset actions.
|
||||
export function cron (options = {}) {
|
||||
let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options;
|
||||
|
||||
user.auth.timestamps.loggedin = now;
|
||||
user.lastCron = now;
|
||||
user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs;
|
||||
// User is only allowed a certain number of drops a day. This resets the count.
|
||||
if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0;
|
||||
|
||||
// "Perfect Day" achievement for perfect-days
|
||||
let perfect = true;
|
||||
|
||||
if (user.isSubscribed()) {
|
||||
grantEndOfTheMonthPerks(user, now);
|
||||
if (!CRON_SAFE_MODE) removeTerminatedSubscription(user);
|
||||
}
|
||||
|
||||
// User is resting at the inn.
|
||||
// On cron, buffs are cleared and all dailies are reset without performing damage
|
||||
if (user.preferences.sleep === true) {
|
||||
performSleepTasks(user, tasksByType, now);
|
||||
return;
|
||||
}
|
||||
|
||||
let multiDaysCountAsOneDay = true;
|
||||
// If the user does not log in for two or more days, cron (mostly) acts as if it were only one day.
|
||||
// When site-wide difficulty settings are introduced, this can be a user preference option.
|
||||
|
||||
// Tally each task
|
||||
let todoTally = 0;
|
||||
|
||||
tasksByType.todos.forEach(task => { // make uncompleted To-Dos redder (further incentive to complete them)
|
||||
scoreTask({
|
||||
task,
|
||||
user,
|
||||
direction: 'down',
|
||||
cron: true,
|
||||
times: multiDaysCountAsOneDay ? 1 : daysMissed,
|
||||
});
|
||||
|
||||
todoTally += task.value;
|
||||
});
|
||||
|
||||
// For incomplete Dailys, add value (further incentive), deduct health, keep records for later decreasing the nightly mana gain
|
||||
let dailyChecked = 0; // how many dailies were checked?
|
||||
let dailyDueUnchecked = 0; // how many dailies were un-checked?
|
||||
if (!user.party.quest.progress.down) user.party.quest.progress.down = 0;
|
||||
|
||||
tasksByType.dailys.forEach((task) => {
|
||||
let completed = task.completed;
|
||||
// Deduct points for missed Daily tasks
|
||||
let EvadeTask = 0;
|
||||
let scheduleMisses = daysMissed;
|
||||
|
||||
if (completed) {
|
||||
dailyChecked += 1;
|
||||
} else {
|
||||
// dailys repeat, so need to calculate how many they've missed according to their own schedule
|
||||
scheduleMisses = 0;
|
||||
|
||||
for (let i = 0; i < daysMissed; i++) {
|
||||
let thatDay = moment(now).subtract({days: i + 1});
|
||||
|
||||
if (shouldDo(thatDay.toDate(), task, user.preferences)) {
|
||||
scheduleMisses++;
|
||||
if (user.stats.buffs.stealth) {
|
||||
user.stats.buffs.stealth--;
|
||||
EvadeTask++;
|
||||
}
|
||||
if (multiDaysCountAsOneDay) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduleMisses > EvadeTask) {
|
||||
// The user did not complete this due Daily (but no penalty if cron is running in safe mode).
|
||||
if (CRON_SAFE_MODE) {
|
||||
dailyChecked += 1; // allows full allotment of mp to be gained
|
||||
} else {
|
||||
perfect = false;
|
||||
|
||||
if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points
|
||||
let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length;
|
||||
dailyDueUnchecked += 1 - fractionChecked;
|
||||
dailyChecked += fractionChecked;
|
||||
} else {
|
||||
dailyDueUnchecked += 1;
|
||||
}
|
||||
|
||||
let delta = scoreTask({
|
||||
user,
|
||||
task,
|
||||
direction: 'down',
|
||||
times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask,
|
||||
cron: true,
|
||||
});
|
||||
|
||||
// Apply damage from a boss, less damage for Trivial priority (difficulty)
|
||||
user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1);
|
||||
// NB: Medium and Hard priorities do not increase damage from boss. This was by accident
|
||||
// initially, and when we realised, we could not fix it because users are used to
|
||||
// their Medium and Hard Dailies doing an Easy amount of damage from boss.
|
||||
// Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future
|
||||
// setting between Trivial and Easy.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task.history.push({
|
||||
date: Number(new Date()),
|
||||
value: task.value,
|
||||
});
|
||||
task.completed = false;
|
||||
|
||||
if (completed || scheduleMisses > 0) {
|
||||
task.checklist.forEach(i => i.completed = false);
|
||||
}
|
||||
});
|
||||
|
||||
// move singleton Habits towards yellow.
|
||||
tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0
|
||||
if (task.up === false || task.down === false) {
|
||||
task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2;
|
||||
}
|
||||
});
|
||||
|
||||
// Finished tallying
|
||||
user.history.todos.push({date: now, value: todoTally});
|
||||
|
||||
// tally experience
|
||||
let expTally = user.stats.exp;
|
||||
let lvl = 0; // iterator
|
||||
while (lvl < user.stats.lvl - 1) {
|
||||
lvl++;
|
||||
expTally += common.tnl(lvl);
|
||||
}
|
||||
|
||||
user.history.exp.push({date: now, value: expTally});
|
||||
|
||||
// preen user history so that it doesn't become a performance problem
|
||||
// also for subscribed users but differently
|
||||
// TODO also do while resting in the inn. Note that later we'll be allowing the value/color of tasks to change while sleeping (https://github.com/HabitRPG/habitrpg/issues/5232), so the code in performSleepTasks() might be best merged back into here for that. Perhaps wait until then to do preen history for sleeping users.
|
||||
preenUserHistory(user, tasksByType, user.preferences.timezoneOffset);
|
||||
|
||||
if (perfect) {
|
||||
user.achievements.perfect++;
|
||||
let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2);
|
||||
user.stats.buffs = {
|
||||
str: lvlDiv2,
|
||||
int: lvlDiv2,
|
||||
per: lvlDiv2,
|
||||
con: lvlDiv2,
|
||||
stealth: 0,
|
||||
streaks: false,
|
||||
};
|
||||
} else {
|
||||
user.stats.buffs = _.cloneDeep(CLEAR_BUFFS);
|
||||
}
|
||||
|
||||
// Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit
|
||||
// Adjust for fraction of dailies completed
|
||||
if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1;
|
||||
user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked);
|
||||
if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP;
|
||||
|
||||
// After all is said and done, progress up user's effect on quest, return those values & reset the user's
|
||||
let progress = user.party.quest.progress;
|
||||
let _progress = _.cloneDeep(progress);
|
||||
_.merge(progress, {down: 0, up: 0});
|
||||
progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0);
|
||||
|
||||
// TODO: Clean PMs - keep 200 for subscribers and 50 for free users. Should also be done while resting in the inn
|
||||
// let numberOfPMs = Object.keys(user.inbox.messages).length;
|
||||
// if (numberOfPMs > maxPMs) {
|
||||
// _(user.inbox.messages)
|
||||
// .sortBy('timestamp')
|
||||
// .takeRight(numberOfPMs - maxPMs)
|
||||
// .each(pm => {
|
||||
// delete user.inbox.messages[pm.id];
|
||||
// }).value();
|
||||
//
|
||||
// user.markModified('inbox.messages');
|
||||
// }
|
||||
|
||||
// Analytics
|
||||
user.flags.cronCount++;
|
||||
analytics.track('Cron', { // TODO also do while resting in the inn. https://github.com/HabitRPG/habitrpg/issues/7161#issuecomment-218214191
|
||||
category: 'behavior',
|
||||
gaLabel: 'Cron Count',
|
||||
gaValue: user.flags.cronCount,
|
||||
uuid: user._id,
|
||||
user,
|
||||
resting: user.preferences.sleep,
|
||||
cronCount: user.flags.cronCount,
|
||||
progressUp: _.min([_progress.up, 900]),
|
||||
progressDown: _progress.down,
|
||||
});
|
||||
|
||||
return _progress;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import csvStringify from 'csv-stringify';
|
||||
import Bluebird from 'bluebird';
|
||||
|
||||
module.exports = (input) => {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
csvStringify(input, (err, output) => {
|
||||
if (err) return reject(err);
|
||||
return resolve(output);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createTransport } from 'nodemailer';
|
||||
import nconf from 'nconf';
|
||||
import { encrypt } from './encryption';
|
||||
import request from 'request';
|
||||
import logger from './logger';
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
const EMAIL_SERVER = {
|
||||
url: nconf.get('EMAIL_SERVER:url'),
|
||||
auth: {
|
||||
user: nconf.get('EMAIL_SERVER:authUser'),
|
||||
password: nconf.get('EMAIL_SERVER:authPassword'),
|
||||
},
|
||||
};
|
||||
const BASE_URL = nconf.get('BASE_URL');
|
||||
|
||||
let smtpTransporter = createTransport({
|
||||
service: nconf.get('SMTP_SERVICE'),
|
||||
auth: {
|
||||
user: nconf.get('SMTP_USER'),
|
||||
pass: nconf.get('SMTP_PASS'),
|
||||
},
|
||||
});
|
||||
|
||||
// Send email directly from the server using the smtpTransporter,
|
||||
// used only to send password reset emails because users unsubscribed on Mandrill wouldn't get them
|
||||
export function send (mailData) {
|
||||
return smtpTransporter.sendMail(mailData); // promise
|
||||
}
|
||||
|
||||
export function getUserInfo (user, fields = []) {
|
||||
let info = {};
|
||||
|
||||
if (fields.indexOf('name') !== -1) {
|
||||
info.name = user.profile && user.profile.name;
|
||||
|
||||
if (!info.name) {
|
||||
if (user.auth.local && user.auth.local.username) {
|
||||
info.name = user.auth.local.username;
|
||||
} else if (user.auth.facebook) {
|
||||
info.name = user.auth.facebook.displayName || user.auth.facebook.username;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.indexOf('email') !== -1) {
|
||||
if (user.auth.local && user.auth.local.email) {
|
||||
info.email = user.auth.local.email;
|
||||
} else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value) {
|
||||
info.email = user.auth.facebook.emails[0].value;
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.indexOf('_id') !== -1) {
|
||||
info._id = user._id;
|
||||
}
|
||||
|
||||
if (fields.indexOf('canSend') !== -1) {
|
||||
if (user.preferences && user.preferences.emailNotifications) {
|
||||
info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true;
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
// Send a transactional email using Mandrill through the external email server
|
||||
export function sendTxn (mailingInfoArray, emailType, variables, personalVariables) {
|
||||
mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray];
|
||||
|
||||
variables = [
|
||||
{name: 'BASE_URL', content: BASE_URL},
|
||||
].concat(variables || []);
|
||||
|
||||
// It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed
|
||||
mailingInfoArray = mailingInfoArray.map((mailingInfo) => {
|
||||
return mailingInfo._id ? getUserInfo(mailingInfo, ['_id', 'email', 'name', 'canSend']) : mailingInfo;
|
||||
}).filter((mailingInfo) => {
|
||||
// Always send reset-password emails
|
||||
// Don't check canSend for non registered users as already checked before
|
||||
return mailingInfo.email && (!mailingInfo._id || mailingInfo.canSend || emailType === 'reset-password');
|
||||
});
|
||||
|
||||
// Personal variables are personal to each email recipient, if they are missing
|
||||
// we manually create a structure for them with RECIPIENT_NAME and RECIPIENT_UNSUB_URL
|
||||
// otherwise we just add RECIPIENT_NAME and RECIPIENT_UNSUB_URL to the existing personal variables
|
||||
if (!personalVariables || personalVariables.length === 0) {
|
||||
personalVariables = mailingInfoArray.map((mailingInfo) => {
|
||||
return {
|
||||
rcpt: mailingInfo.email,
|
||||
vars: [
|
||||
{
|
||||
name: 'RECIPIENT_NAME',
|
||||
content: mailingInfo.name,
|
||||
},
|
||||
{
|
||||
name: 'RECIPIENT_UNSUB_URL',
|
||||
content: `/email/unsubscribe?code=${encrypt(JSON.stringify({
|
||||
_id: mailingInfo._id,
|
||||
email: mailingInfo.email,
|
||||
}))}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
} else {
|
||||
let temporaryPersonalVariables = {};
|
||||
|
||||
mailingInfoArray.forEach((mailingInfo) => {
|
||||
temporaryPersonalVariables[mailingInfo.email] = {
|
||||
name: mailingInfo.name,
|
||||
_id: mailingInfo._id,
|
||||
};
|
||||
});
|
||||
|
||||
personalVariables.forEach((singlePersonalVariables) => {
|
||||
singlePersonalVariables.vars.push(
|
||||
{
|
||||
name: 'RECIPIENT_NAME',
|
||||
content: temporaryPersonalVariables[singlePersonalVariables.rcpt].name,
|
||||
},
|
||||
{
|
||||
name: 'RECIPIENT_UNSUB_URL',
|
||||
content: `/email/unsubscribe?code=${encrypt(JSON.stringify({
|
||||
_id: temporaryPersonalVariables[singlePersonalVariables.rcpt]._id,
|
||||
email: singlePersonalVariables.rcpt,
|
||||
}))}`,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_PROD && mailingInfoArray.length > 0) {
|
||||
request.post({
|
||||
url: `${EMAIL_SERVER.url}/job`,
|
||||
auth: {
|
||||
user: EMAIL_SERVER.auth.user,
|
||||
pass: EMAIL_SERVER.auth.password,
|
||||
},
|
||||
json: {
|
||||
type: 'email',
|
||||
data: {
|
||||
emailType,
|
||||
to: mailingInfoArray,
|
||||
variables,
|
||||
personalVariables,
|
||||
},
|
||||
options: {
|
||||
priority: 'high',
|
||||
attempts: 5,
|
||||
backoff: {delay: 10 * 60 * 1000, type: 'fixed'},
|
||||
},
|
||||
},
|
||||
}, (err) => logger.error(err));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
createCipher,
|
||||
createDecipher,
|
||||
} from 'crypto';
|
||||
import nconf from 'nconf';
|
||||
|
||||
const algorithm = 'aes-256-ctr';
|
||||
const SESSION_SECRET = nconf.get('SESSION_SECRET');
|
||||
|
||||
export function encrypt (text) {
|
||||
let cipher = createCipher(algorithm, SESSION_SECRET);
|
||||
let crypted = cipher.update(text, 'utf8', 'hex');
|
||||
|
||||
crypted += cipher.final('hex');
|
||||
return crypted;
|
||||
}
|
||||
|
||||
export function decrypt (text) {
|
||||
let decipher = createDecipher(algorithm, SESSION_SECRET);
|
||||
let dec = decipher.update(text, 'hex', 'utf8');
|
||||
|
||||
dec += decipher.final('utf8');
|
||||
return dec;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import common from '../../../../common';
|
||||
|
||||
export const CustomError = common.errors.CustomError;
|
||||
|
||||
/**
|
||||
* @apiDefine NotAuthorized
|
||||
* @apiError NotAuthorized The client is not authorized to make this request.
|
||||
*
|
||||
* @apiErrorExample Error-Response:
|
||||
* HTTP/1.1 401 Unauthorized
|
||||
* {
|
||||
* "error": "NotAuthorized",
|
||||
* "message": "Not authorized."
|
||||
* }
|
||||
*/
|
||||
export const NotAuthorized = common.errors.NotAuthorized;
|
||||
|
||||
/**
|
||||
* @apiDefine BadRequest
|
||||
* @apiError BadRequest The request wasn't formatted correctly.
|
||||
*
|
||||
* @apiErrorExample Error-Response:
|
||||
* HTTP/1.1 400 Bad Request
|
||||
* {
|
||||
* "error": "BadRequest",
|
||||
* "message": "Bad request."
|
||||
* }
|
||||
*/
|
||||
export const BadRequest = common.errors.BadRequest;
|
||||
|
||||
/**
|
||||
* @apiDefine NotFound
|
||||
* @apiError NotFound The requested resource was not found.
|
||||
*
|
||||
* @apiErrorExample Error-Response:
|
||||
* HTTP/1.1 404 Not Found
|
||||
* {
|
||||
* "error": "NotFound",
|
||||
* "message": "Not found."
|
||||
* }
|
||||
*/
|
||||
export const NotFound = common.errors.NotFound;
|
||||
|
||||
/**
|
||||
* @apiDefine InternalServerError
|
||||
* @apiError InternalServerError An unexpected error occurred.
|
||||
*
|
||||
* @apiErrorExample Error-Response:
|
||||
* HTTP/1.1 500 Internal Server Error
|
||||
* {
|
||||
* "error": "InternalServerError",
|
||||
* "message": "An unexpected error occurred."
|
||||
* }
|
||||
*/
|
||||
export class InternalServerError extends CustomError {
|
||||
constructor (customMessage) {
|
||||
super();
|
||||
this.name = this.constructor.name;
|
||||
this.httpCode = 500;
|
||||
this.message = customMessage || 'An unexpected error occurred.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Firebase from 'firebase';
|
||||
import nconf from 'nconf';
|
||||
import { TAVERN_ID } from '../../models/group';
|
||||
|
||||
const FIREBASE_CONFIG = nconf.get('FIREBASE');
|
||||
const FIREBASE_ENABLED = FIREBASE_CONFIG.ENABLED === 'true';
|
||||
|
||||
let firebaseRef;
|
||||
|
||||
if (FIREBASE_ENABLED) {
|
||||
firebaseRef = new Firebase(`https://${FIREBASE_CONFIG.APP}.firebaseio.com`);
|
||||
|
||||
// TODO what happens if an op is sent before client is authenticated?
|
||||
firebaseRef.authWithCustomToken(FIREBASE_CONFIG.SECRET, (err) => {
|
||||
// TODO it's ok to kill the server here? what if FB is offline?
|
||||
if (err) throw new Error('Impossible to authenticate Firebase');
|
||||
});
|
||||
}
|
||||
|
||||
export function updateGroupData (group) {
|
||||
if (!FIREBASE_ENABLED) return;
|
||||
// TODO is throw ok? we don't have callbacks
|
||||
if (!group) throw new Error('group obj is required.');
|
||||
// Return in case of tavern (comparison working because we use string for _id)
|
||||
if (group._id === TAVERN_ID) return;
|
||||
|
||||
firebaseRef.child(`rooms/${group._id}`)
|
||||
.set({
|
||||
name: group.name,
|
||||
});
|
||||
}
|
||||
|
||||
export function addUserToGroup (groupId, userId) {
|
||||
if (!FIREBASE_ENABLED) return;
|
||||
if (!userId || !groupId) throw new Error('groupId, userId are required.');
|
||||
if (groupId === TAVERN_ID) return;
|
||||
|
||||
firebaseRef.child(`members/${groupId}/${userId}`).set(true);
|
||||
firebaseRef.child(`users/${userId}/rooms/${groupId}`).set(true);
|
||||
}
|
||||
|
||||
export function removeUserFromGroup (groupId, userId) {
|
||||
if (!FIREBASE_ENABLED) return;
|
||||
if (!userId || !groupId) throw new Error('groupId, userId are required.');
|
||||
if (groupId === TAVERN_ID) return;
|
||||
|
||||
firebaseRef.child(`members/${groupId}/${userId}`).remove();
|
||||
firebaseRef.child(`users/${userId}/rooms/${groupId}`).remove();
|
||||
}
|
||||
|
||||
export function deleteGroup (groupId) {
|
||||
if (!FIREBASE_ENABLED) return;
|
||||
if (!groupId) throw new Error('groupId is required.');
|
||||
if (groupId === TAVERN_ID) return;
|
||||
|
||||
firebaseRef.child(`members/${groupId}`).remove();
|
||||
// TODO not really necessary as long as we only store room data,
|
||||
// as empty objects are automatically deleted (/members/... in future...)
|
||||
firebaseRef.child(`rooms/${groupId}`).remove();
|
||||
}
|
||||
|
||||
// TODO not really necessary as long as we only store room data,
|
||||
// as empty objects are automatically deleted
|
||||
export function deleteUser (userId) {
|
||||
if (!FIREBASE_ENABLED) return;
|
||||
if (!userId) throw new Error('userId is required.');
|
||||
|
||||
firebaseRef.child(`users/${userId}`).remove();
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import _ from 'lodash';
|
||||
import shared from '../../../../common';
|
||||
|
||||
export const localePath = path.join(__dirname, '/../../../../common/locales/');
|
||||
|
||||
// Store translations
|
||||
export let translations = {};
|
||||
// Store MomentJS localization files
|
||||
export let momentLangs = {};
|
||||
|
||||
// Handle differencies in language codes between MomentJS and /locales
|
||||
let momentLangsMapping = {
|
||||
en: 'en-gb',
|
||||
en_GB: 'en-gb', // eslint-disable-line camelcase
|
||||
no: 'nn',
|
||||
zh: 'zh-cn',
|
||||
es_419: 'es', // eslint-disable-line camelcase
|
||||
};
|
||||
|
||||
function _loadTranslations (locale) {
|
||||
let files = fs.readdirSync(path.join(localePath, locale));
|
||||
|
||||
translations[locale] = {};
|
||||
|
||||
files.forEach((file) => {
|
||||
if (path.extname(file) !== '.json') return;
|
||||
|
||||
// We use require to load and parse a JSON file
|
||||
_.merge(translations[locale], require(path.join(localePath, locale, file))); // eslint-disable-line global-require
|
||||
});
|
||||
}
|
||||
|
||||
// First fetch English strings so we can merge them with missing strings in other languages
|
||||
_loadTranslations('en');
|
||||
|
||||
// Then load all other languages
|
||||
fs.readdirSync(localePath).forEach((file) => {
|
||||
if (file === 'en' || fs.statSync(path.join(localePath, file)).isDirectory() === false) return;
|
||||
_loadTranslations(file);
|
||||
|
||||
// Merge missing strings from english
|
||||
_.defaults(translations[file], translations.en);
|
||||
});
|
||||
|
||||
// Add translations to shared
|
||||
shared.i18n.translations = translations;
|
||||
|
||||
export let langCodes = Object.keys(translations);
|
||||
|
||||
export let availableLanguages = langCodes.map((langCode) => {
|
||||
return {
|
||||
code: langCode,
|
||||
name: translations[langCode].languageName,
|
||||
};
|
||||
});
|
||||
|
||||
langCodes.forEach((code) => {
|
||||
let lang = _.find(availableLanguages, {code});
|
||||
|
||||
lang.momentLangCode = momentLangsMapping[code] || code;
|
||||
|
||||
try {
|
||||
// MomentJS lang files are JS files that has to be executed in the browser so we load them as plain text files
|
||||
// We wrap everything in a try catch because the file might not exist
|
||||
let f = fs.readFileSync(path.join(__dirname, `/../../../node_modules/moment/locale/${lang.momentLangCode}.js`), 'utf8');
|
||||
|
||||
momentLangs[code] = f;
|
||||
} catch (e) { // eslint-disable-lint no-empty
|
||||
// The catch block is mandatory so it won't crash the server
|
||||
}
|
||||
});
|
||||
|
||||
// Remove en_GB from langCodes checked by browser to avoid it being
|
||||
// used in place of plain original 'en' (it's an optional language that can be enabled only in setting)
|
||||
export let defaultLangCodes = _.without(langCodes, 'en_GB');
|
||||
|
||||
// A map of languages that have different versions and the relative versions
|
||||
export let multipleVersionsLanguages = {
|
||||
es: {
|
||||
'es-419': 'es_419',
|
||||
'es-mx': 'es_419',
|
||||
'es-gt': 'es_419',
|
||||
'es-cr': 'es_419',
|
||||
'es-pa': 'es_419',
|
||||
'es-do': 'es_419',
|
||||
'es-ve': 'es_419',
|
||||
'es-co': 'es_419',
|
||||
'es-pe': 'es_419',
|
||||
'es-ar': 'es_419',
|
||||
'es-ec': 'es_419',
|
||||
'es-cl': 'es_419',
|
||||
'es-uy': 'es_419',
|
||||
'es-py': 'es_419',
|
||||
'es-bo': 'es_419',
|
||||
'es-sv': 'es_419',
|
||||
'es-hn': 'es_419',
|
||||
'es-ni': 'es_419',
|
||||
'es-pr': 'es_419',
|
||||
},
|
||||
zh: {
|
||||
'zh-tw': 'zh_TW',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
// Logger utility
|
||||
import winston from 'winston';
|
||||
import nconf from 'nconf';
|
||||
import _ from 'lodash';
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
const IS_TEST = nconf.get('IS_TEST');
|
||||
const ENABLE_CONSOLE_LOGS_IN_PROD = nconf.get('ENABLE_CONSOLE_LOGS_IN_PROD') === 'true';
|
||||
|
||||
const logger = new winston.Logger();
|
||||
|
||||
if (IS_PROD) {
|
||||
if (ENABLE_CONSOLE_LOGS_IN_PROD) {
|
||||
logger.add(winston.transports.Console, {
|
||||
colorize: true,
|
||||
prettyPrint: true,
|
||||
});
|
||||
}
|
||||
} else if (IS_TEST) {
|
||||
// Do not log anything when testing
|
||||
} else {
|
||||
logger
|
||||
.add(winston.transports.Console, {
|
||||
colorize: true,
|
||||
prettyPrint: true,
|
||||
});
|
||||
}
|
||||
|
||||
// exports a public interface insteaf of accessing directly the logger module
|
||||
let loggerInterface = {
|
||||
info (...args) {
|
||||
logger.info(...args);
|
||||
},
|
||||
|
||||
// Accepts two argument,
|
||||
// an Error object (required)
|
||||
// and an object of additional data to log alongside the error
|
||||
// If the first argument isn't an Error, it'll call logger.error with all the arguments supplied
|
||||
error (...args) {
|
||||
let [err, errorData = {}, ...otherArgs] = args;
|
||||
|
||||
if (err instanceof Error) {
|
||||
// pass the error stack as the first parameter to logger.error
|
||||
let stack = err.stack || err.message || err;
|
||||
|
||||
if (_.isPlainObject(errorData) && !errorData.fullError) errorData.fullError = err;
|
||||
logger.error(stack, errorData, ...otherArgs);
|
||||
} else {
|
||||
logger.error(...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Logs unhandled promises errors
|
||||
// when no catch is attached to a promise a unhandledRejection event will be triggered
|
||||
process.on('unhandledRejection', function handlePromiseRejection (reason) {
|
||||
loggerInterface.error(reason);
|
||||
});
|
||||
|
||||
module.exports = loggerInterface;
|
||||
@@ -0,0 +1,18 @@
|
||||
// Utilities for working with passwords
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Return the encrypted version of a password (using sha1) given a salt
|
||||
export function encrypt (password, salt) {
|
||||
return crypto
|
||||
.createHmac('sha1', salt)
|
||||
.update(password)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
// Create a salt, default length is 10
|
||||
export function makeSalt (len = 10) {
|
||||
return crypto
|
||||
.randomBytes(Math.ceil(len / 2))
|
||||
.toString('hex')
|
||||
.substring(0, len);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import _ from 'lodash' ;
|
||||
import analytics from './analyticsService';
|
||||
import {
|
||||
getUserInfo,
|
||||
sendTxn as txnEmail,
|
||||
} from './email';
|
||||
import members from '../../controllers/api-v3/members';
|
||||
import moment from 'moment';
|
||||
import nconf from 'nconf';
|
||||
import pushNotify from './pushNotifications';
|
||||
import shared from '../../../../common' ;
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
|
||||
let api = {};
|
||||
|
||||
function revealMysteryItems (user) {
|
||||
_.each(shared.content.gear.flat, function findMysteryItems (item) {
|
||||
if (
|
||||
item.klass === 'mystery' &&
|
||||
moment().isAfter(shared.content.mystery[item.mystery].start) &&
|
||||
moment().isBefore(shared.content.mystery[item.mystery].end) &&
|
||||
!user.items.gear.owned[item.key] &&
|
||||
user.purchased.plan.mysteryItems.indexOf(item.key) !== -1
|
||||
) {
|
||||
user.purchased.plan.mysteryItems.push(item.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
api.createSubscription = async function createSubscription (data) {
|
||||
let recipient = data.gift ? data.gift.member : data.user;
|
||||
let plan = recipient.purchased.plan;
|
||||
let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key];
|
||||
let months = Number(block.months);
|
||||
|
||||
if (data.gift) {
|
||||
if (plan.customerId && !plan.dateTerminated) { // User has active plan
|
||||
plan.extraMonths += months;
|
||||
} else {
|
||||
plan.dateTerminated = moment(plan.dateTerminated).add({months}).toDate();
|
||||
if (!plan.dateUpdated) plan.dateUpdated = new Date();
|
||||
}
|
||||
|
||||
if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId
|
||||
} else {
|
||||
_(plan).merge({ // override with these values
|
||||
planId: block.key,
|
||||
customerId: data.customerId,
|
||||
dateUpdated: new Date(),
|
||||
gemsBought: 0,
|
||||
paymentMethod: data.paymentMethod,
|
||||
extraMonths: Number(plan.extraMonths) +
|
||||
Number(plan.dateTerminated ? moment(plan.dateTerminated).diff(new Date(), 'months', true) : 0),
|
||||
dateTerminated: null,
|
||||
// Specify a lastBillingDate just for Amazon Payments
|
||||
// Resetted every time the subscription restarts
|
||||
lastBillingDate: data.paymentMethod === 'Amazon Payments' ? new Date() : undefined,
|
||||
}).defaults({ // allow non-override if a plan was previously used
|
||||
dateCreated: new Date(),
|
||||
mysteryItems: [],
|
||||
}).value();
|
||||
}
|
||||
|
||||
// Block sub perks
|
||||
let perks = Math.floor(months / 3);
|
||||
if (perks) {
|
||||
plan.consecutive.offset += months;
|
||||
plan.consecutive.gemCapExtra += perks * 5;
|
||||
if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25;
|
||||
plan.consecutive.trinkets += perks;
|
||||
}
|
||||
|
||||
revealMysteryItems(recipient);
|
||||
|
||||
if (IS_PROD) {
|
||||
if (!data.gift) txnEmail(data.user, 'subscription-begins');
|
||||
|
||||
analytics.trackPurchase({
|
||||
uuid: data.user._id,
|
||||
itemPurchased: 'Subscription',
|
||||
sku: `${data.paymentMethod.toLowerCase()}-subscription`,
|
||||
purchaseType: 'subscribe',
|
||||
paymentMethod: data.paymentMethod,
|
||||
quantity: 1,
|
||||
gift: Boolean(data.gift),
|
||||
purchaseValue: block.price,
|
||||
});
|
||||
}
|
||||
|
||||
data.user.purchased.txnCount++;
|
||||
|
||||
if (data.gift) {
|
||||
members.sendMessage(data.user, data.gift.member, data.gift);
|
||||
|
||||
let byUserName = getUserInfo(data.user, ['name']).name;
|
||||
|
||||
if (data.gift.member.preferences.emailNotifications.giftedSubscription !== false) {
|
||||
txnEmail(data.gift.member, 'gifted-subscription', [
|
||||
{name: 'GIFTER', content: byUserName},
|
||||
{name: 'X_MONTHS_SUBSCRIPTION', content: months},
|
||||
]);
|
||||
}
|
||||
|
||||
if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself
|
||||
pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`);
|
||||
}
|
||||
}
|
||||
|
||||
await data.user.save();
|
||||
if (data.gift) await data.gift.member.save();
|
||||
};
|
||||
|
||||
// Sets their subscription to be cancelled later
|
||||
api.cancelSubscription = async function cancelSubscription (data) {
|
||||
let plan = data.user.purchased.plan;
|
||||
let now = moment();
|
||||
let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days') : 30;
|
||||
let nowStr = `${now.format('MM')}/${moment(plan.dateUpdated).format('DD')}/${now.format('YYYY')}`;
|
||||
let nowStrFormat = 'MM/DD/YYYY';
|
||||
|
||||
plan.dateTerminated =
|
||||
moment(nowStr, nowStrFormat)
|
||||
.add({days: remaining}) // end their subscription 1mo from their last payment
|
||||
.add({days: Math.ceil(30 * plan.extraMonths)}) // plus any extra time (carry-over, gifted subscription, etc) they have.
|
||||
.toDate();
|
||||
plan.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated
|
||||
|
||||
await data.user.save();
|
||||
|
||||
txnEmail(data.user, 'cancel-subscription');
|
||||
|
||||
analytics.track('unsubscribe', {
|
||||
uuid: data.user._id,
|
||||
gaCategory: 'commerce',
|
||||
gaLabel: data.paymentMethod,
|
||||
paymentMethod: data.paymentMethod,
|
||||
});
|
||||
};
|
||||
|
||||
api.buyGems = async function buyGems (data) {
|
||||
let amt = data.amount || 5;
|
||||
amt = data.gift ? data.gift.gems.amount / 4 : amt;
|
||||
|
||||
(data.gift ? data.gift.member : data.user).balance += amt;
|
||||
data.user.purchased.txnCount++;
|
||||
|
||||
if (IS_PROD) {
|
||||
if (!data.gift) txnEmail(data.user, 'donation');
|
||||
|
||||
analytics.trackPurchase({
|
||||
uuid: data.user._id,
|
||||
itemPurchased: 'Gems',
|
||||
sku: `${data.paymentMethod.toLowerCase()}-checkout`,
|
||||
purchaseType: 'checkout',
|
||||
paymentMethod: data.paymentMethod,
|
||||
quantity: 1,
|
||||
gift: Boolean(data.gift),
|
||||
purchaseValue: amt,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.gift) {
|
||||
let byUsername = getUserInfo(data.user, ['name']).name;
|
||||
let gemAmount = data.gift.gems.amount || 20;
|
||||
|
||||
members.sendMessage(data.user, data.gift.member, data.gift);
|
||||
if (data.gift.member.preferences.emailNotifications.giftedGems !== false) {
|
||||
txnEmail(data.gift.member, 'gifted-gems', [
|
||||
{name: 'GIFTER', content: byUsername},
|
||||
{name: 'X_GEMS_GIFTED', content: gemAmount},
|
||||
]);
|
||||
}
|
||||
|
||||
if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself
|
||||
pushNotify.sendNotify(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`);
|
||||
}
|
||||
|
||||
await data.gift.member.save();
|
||||
}
|
||||
|
||||
await data.user.save();
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
@@ -0,0 +1,82 @@
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
// Aggregate entries
|
||||
function _aggregate (history, aggregateBy) {
|
||||
return _.chain(history)
|
||||
.groupBy(entry => { // group entries by aggregateBy
|
||||
return moment(entry.date).format(aggregateBy);
|
||||
})
|
||||
.sortBy((entry, key) => key) // sort by date
|
||||
.map(entries => {
|
||||
return {
|
||||
date: Number(entries[0].date),
|
||||
value: _.reduce(entries, (previousValue, entry) => {
|
||||
return previousValue + entry.value;
|
||||
}, 0) / entries.length,
|
||||
};
|
||||
})
|
||||
.value();
|
||||
}
|
||||
|
||||
/* Preen an array of history entries
|
||||
Free users:
|
||||
- 1 value for each day of the past 60 days (no compression)
|
||||
- 1 value each month for the previous 10 months
|
||||
- 1 value each year for the previous years
|
||||
Subscribers and challenges:
|
||||
- 1 value for each day of the past 365 days (no compression)
|
||||
- 1 value each month for the previous 12 months
|
||||
- 1 value each year for the previous years
|
||||
*/
|
||||
export function preenHistory (history, isSubscribed, timezoneOffset) {
|
||||
// history = _.filter(history, historyEntry => Boolean(historyEntry)); // Filter missing entries
|
||||
let now = timezoneOffset ? moment().zone(timezoneOffset) : moment();
|
||||
// Date after which to begin compressing data
|
||||
let cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day');
|
||||
|
||||
// Keep uncompressed entries (modifies history and returns removed items)
|
||||
let newHistory = _.remove(history, entry => {
|
||||
let date = moment(entry.date);
|
||||
return date.isSame(cutOff) || date.isAfter(cutOff);
|
||||
});
|
||||
|
||||
// Date after which to begin compressing data by year
|
||||
let monthsCutOff = cutOff.subtract(isSubscribed ? 12 : 10, 'months').startOf('day');
|
||||
let aggregateByMonth = _.remove(history, entry => {
|
||||
let date = moment(entry.date);
|
||||
return date.isSame(monthsCutOff) || date.isAfter(monthsCutOff);
|
||||
});
|
||||
// Aggregate remaining entries by month and year
|
||||
if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM'));
|
||||
if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY'));
|
||||
|
||||
return newHistory;
|
||||
}
|
||||
|
||||
// Preen history for users and tasks.
|
||||
export function preenUserHistory (user, tasksByType) {
|
||||
let isSubscribed = user.isSubscribed();
|
||||
let timezoneOffset = user.preferences.timezoneOffset;
|
||||
let minHistoryLength = isSubscribed ? 365 : 60;
|
||||
|
||||
function _processTask (task) {
|
||||
if (task.history && task.history.length > minHistoryLength) {
|
||||
task.history = preenHistory(task.history, isSubscribed, timezoneOffset);
|
||||
task.markModified('history');
|
||||
}
|
||||
}
|
||||
|
||||
tasksByType.habits.forEach(_processTask);
|
||||
tasksByType.dailys.forEach(_processTask);
|
||||
|
||||
if (user.history.exp.length > minHistoryLength) {
|
||||
user.history.exp = preenHistory(user.history.exp, isSubscribed, timezoneOffset);
|
||||
user.markModified('history.exp');
|
||||
}
|
||||
|
||||
if (user.history.todos.length > minHistoryLength) {
|
||||
user.history.todos = preenHistory(user.history.todos, isSubscribed, timezoneOffset);
|
||||
user.markModified('history.todos');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import _ from 'lodash';
|
||||
import nconf from 'nconf';
|
||||
import pushNotify from 'push-notify';
|
||||
|
||||
const GCM_API_KEY = nconf.get('PUSH_CONFIGS:GCM_SERVER_API_KEY');
|
||||
|
||||
let gcm = GCM_API_KEY ? pushNotify.gcm({
|
||||
apiKey: GCM_API_KEY,
|
||||
retries: 3,
|
||||
}) : undefined;
|
||||
|
||||
// TODO review and test this file when push notifications are added back
|
||||
|
||||
if (gcm) {
|
||||
gcm.on('transmitted', (/* result, message, registrationId */) => {
|
||||
// console.info("transmitted", result, message, registrationId);
|
||||
});
|
||||
|
||||
gcm.on('transmissionError', (/* error, message, registrationId */) => {
|
||||
// console.info("transmissionError", error, message, registrationId);
|
||||
});
|
||||
|
||||
gcm.on('updated', (/* result, registrationId */) => {
|
||||
// console.info("updated", result, registrationId);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function sendNotification (user, title, message, timeToLive = 15) {
|
||||
if (!user) return;
|
||||
|
||||
_.each(user.pushDevices, pushDevice => {
|
||||
switch (pushDevice.type) {
|
||||
case 'android':
|
||||
if (gcm) {
|
||||
gcm.send({
|
||||
registrationId: pushDevice.regId,
|
||||
// collapseKey: 'COLLAPSE_KEY',
|
||||
delayWhileIdle: true,
|
||||
timeToLive,
|
||||
data: {
|
||||
title,
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'ios':
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import fs from 'fs';
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
getUserLanguage,
|
||||
} from '../../middlewares/api-v3/language';
|
||||
import cron from '../../middlewares/api-v3/cron';
|
||||
|
||||
// Wrapper function to handler `async` route handlers that return promises
|
||||
// It takes the async function, execute it and pass any error to next (args[2])
|
||||
let _wrapAsyncFn = fn => (...args) => fn(...args).catch(args[2]);
|
||||
let noop = (req, res, next) => next();
|
||||
|
||||
module.exports.readController = function readController (router, controller) {
|
||||
_.each(controller, (action) => {
|
||||
let {method, url, middlewares = [], handler, runCron} = action;
|
||||
|
||||
// If an authentication middleware is used run getUserLanguage after it, otherwise before
|
||||
// for cron instead use it only if an authentication middleware is present
|
||||
let authMiddlewareIndex = _.findIndex(middlewares, middleware => {
|
||||
if (middleware.name.indexOf('authWith') === 0) { // authWith{Headers|Session|Url|...}
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
let middlewaresToAdd = [getUserLanguage];
|
||||
|
||||
if (authMiddlewareIndex !== -1) { // the user will be authenticated, getUserLanguage and cron after authentication
|
||||
if (!(runCron === false)) { // eslint-disable-line no-extra-parens
|
||||
middlewaresToAdd.push(cron);
|
||||
}
|
||||
|
||||
if (authMiddlewareIndex === middlewares.length - 1) {
|
||||
middlewares.push(...middlewaresToAdd);
|
||||
} else {
|
||||
middlewares.splice(authMiddlewareIndex + 1, 0, ...middlewaresToAdd);
|
||||
}
|
||||
} else { // no auth, getUserLanguage as the first middleware
|
||||
middlewares.unshift(...middlewaresToAdd);
|
||||
}
|
||||
|
||||
method = method.toLowerCase();
|
||||
let fn = handler ? _wrapAsyncFn(handler) : noop;
|
||||
|
||||
router[method](url, ...middlewares, fn);
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.walkControllers = function walkControllers (router, filePath) {
|
||||
fs
|
||||
.readdirSync(filePath)
|
||||
.forEach(fileName => {
|
||||
if (!fs.statSync(filePath + fileName).isFile()) {
|
||||
walkControllers(router, `${filePath}${fileName}/`);
|
||||
} else if (fileName.match(/\.js$/)) {
|
||||
let controller = require(filePath + fileName); // eslint-disable-line global-require
|
||||
module.exports.readController(router, controller);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import nconf from 'nconf';
|
||||
import logger from './logger';
|
||||
import autoinc from 'mongoose-id-autoinc';
|
||||
import mongoose from 'mongoose';
|
||||
import Bluebird from 'bluebird';
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
const MAINTENANCE_MODE = nconf.get('MAINTENANCE_MODE');
|
||||
|
||||
// Use Q promises instead of mpromise in mongoose
|
||||
mongoose.Promise = Bluebird;
|
||||
|
||||
// Do not connect to MongoDB when in maintenance mode
|
||||
if (MAINTENANCE_MODE !== 'true') {
|
||||
let mongooseOptions = !IS_PROD ? {} : {
|
||||
replset: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } },
|
||||
server: { socketOptions: { keepAlive: 120, connectTimeoutMS: 30000 } },
|
||||
};
|
||||
|
||||
const NODE_DB_URI = nconf.get('IS_TEST') ? nconf.get('TEST_DB_URI') : nconf.get('NODE_DB_URI');
|
||||
|
||||
let db = mongoose.connect(NODE_DB_URI, mongooseOptions, (err) => {
|
||||
if (err) throw err;
|
||||
logger.info('Connected with Mongoose.');
|
||||
});
|
||||
|
||||
autoinc.init(db);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import nconf from 'nconf';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
const PATH_TO_CONFIG = join(resolve(__dirname, '../../../../config.json'));
|
||||
|
||||
module.exports = function setupNconf (file) {
|
||||
let configFile = file || PATH_TO_CONFIG;
|
||||
|
||||
nconf
|
||||
.argv()
|
||||
.env()
|
||||
.file('user', configFile);
|
||||
|
||||
nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production');
|
||||
nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development');
|
||||
nconf.set('IS_TEST', nconf.get('NODE_ENV') === 'test');
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import passport from 'passport';
|
||||
import nconf from 'nconf';
|
||||
import passportFacebook from 'passport-facebook';
|
||||
|
||||
const FacebookStrategy = passportFacebook.Strategy;
|
||||
|
||||
// Passport session setup.
|
||||
// To support persistent login sessions, Passport needs to be able to
|
||||
// serialize users into and deserialize users out of the session. Typically,
|
||||
// this will be as simple as storing the user ID when serializing, and finding
|
||||
// the user by ID when deserializing. However, since this example does not
|
||||
// have a database of user records, the complete Facebook profile is serialized
|
||||
// and deserialized.
|
||||
passport.serializeUser((user, done) => done(null, user));
|
||||
passport.deserializeUser((obj, done) => done(null, obj));
|
||||
|
||||
// TODO remove?
|
||||
// This auth strategy is no longer used. It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile)
|
||||
// The proper fix would be to move to a general OAuth module simply to verify accessTokens
|
||||
passport.use(new FacebookStrategy({
|
||||
clientID: nconf.get('FACEBOOK_KEY'),
|
||||
clientSecret: nconf.get('FACEBOOK_SECRET'),
|
||||
// callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback"
|
||||
}, (accessToken, refreshToken, profile, done) => done(null, profile)));
|
||||
@@ -0,0 +1,31 @@
|
||||
import { each } from 'lodash';
|
||||
import { post } from 'request';
|
||||
import { isURL } from 'validator';
|
||||
import logger from './logger';
|
||||
|
||||
let _sendWebhook = (url, body) => {
|
||||
post({
|
||||
url,
|
||||
body,
|
||||
json: true,
|
||||
}, (err) => logger.error(err));
|
||||
};
|
||||
|
||||
let _isInvalidWebhook = (hook) => {
|
||||
return !hook.enabled || !isURL(hook.url);
|
||||
};
|
||||
|
||||
export function sendTaskWebhook (webhooks, data) {
|
||||
each(webhooks, (hook) => {
|
||||
if (_isInvalidWebhook(hook)) return;
|
||||
|
||||
let body = {
|
||||
direction: data.task.direction,
|
||||
task: data.task.details,
|
||||
delta: data.task.delta,
|
||||
user: data.user,
|
||||
};
|
||||
|
||||
_sendWebhook(hook.url, body);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
var nconf = require('nconf');
|
||||
var moment = require('moment');
|
||||
var domainMiddleware = require('domain-middleware');
|
||||
var os = require('os');
|
||||
var request = require('request');
|
||||
|
||||
var IS_PROD = nconf.get('NODE_ENV') === 'production';
|
||||
|
||||
module.exports = function(server,mongoose) {
|
||||
/* if (IS_PROD) {
|
||||
var mins = 3, // how often to run this check
|
||||
useAvg = false, // use average over 3 minutes, or simply the last minute's report
|
||||
url = 'https://api.newrelic.com/v2/applications/'+nconf.get('NEW_RELIC_APPLICATION_ID')+'/metrics/data.json?names[]=Apdex&values[]=score';
|
||||
setInterval(function(){
|
||||
// see https://docs.newrelic.com/docs/apm/apis/api-v2-examples/average-response-time-examples-api-v2, https://rpm.newrelic.com/api/explore/applications/data
|
||||
request({
|
||||
url: useAvg ? url+'&from='+moment().subtract({minutes:mins}).utc().format()+'&to='+moment().utc().format()+'&summarize=true' : url,
|
||||
headers: {'X-Api-Key': nconf.get('NEW_RELIC_API_KEY')}
|
||||
}, function(err, response, body){
|
||||
var ts = JSON.parse(body).metric_data.metrics[0].timeslices,
|
||||
score = ts[ts.length-1].values.score,
|
||||
memory = os.freemem() / os.totalmem(),
|
||||
memoryHigh = memory < 0.1;
|
||||
|
||||
if (memoryHigh) {
|
||||
var newRelicMemoryLeakMessage = '[Memory Leak] Apdex='+score+' Memory='+parseFloat(memory).toFixed(3)+' Time='+moment().format();
|
||||
throw newRelicMemoryLeakMessage;
|
||||
}
|
||||
});
|
||||
|
||||
var memory = os.freemem() / os.totalmem(),
|
||||
memoryHigh = memory < 0.1;
|
||||
if (memoryHigh) {
|
||||
var memoryLeakMessage = '[Memory Leak] Memory='+parseFloat(memory).toFixed(3)+' Time='+moment().format();
|
||||
throw memoryLeakMessage;
|
||||
}
|
||||
}, mins*60*1000);
|
||||
} */
|
||||
|
||||
return domainMiddleware({
|
||||
server: {
|
||||
close:function(){
|
||||
server.close();
|
||||
mongoose.connection.close();
|
||||
}
|
||||
},
|
||||
killTimeout: 10000
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
var logging = require('../../libs/api-v2/logging');
|
||||
|
||||
module.exports = function(err, req, res, next) {
|
||||
//res.locals.domain.emit('error', err);
|
||||
// when we hit an error, send it to admin as an email. If no ADMIN_EMAIL is present, just send it to yourself (SMTP_USER)
|
||||
var stack = (err.stack ? err.stack : err.message ? err.message : err) +
|
||||
"\n ----------------------------\n" +
|
||||
"\n\noriginalUrl: " + req.originalUrl +
|
||||
"\n\nauth: " + req.headers['x-api-user'] + ' | ' + req.headers['x-api-key'] +
|
||||
"\n\nheaders: " + JSON.stringify(req.headers) +
|
||||
"\n\nbody: " + JSON.stringify(req.body) +
|
||||
(res.locals.ops ? "\n\ncompleted ops: " + JSON.stringify(res.locals.ops) : "");
|
||||
logging.error(stack);
|
||||
/*logging.loggly({
|
||||
error: "Uncaught error",
|
||||
stack: (err.stack || err.message || err),
|
||||
body: req.body, headers: req.header,
|
||||
auth: req.headers['x-api-user'],
|
||||
originalUrl: req.originalUrl
|
||||
});*/
|
||||
var message = err.message ? err.message : err;
|
||||
message = (message.length < 200) ? message : message.substring(0,100) + message.substring(message.length-100,message.length);
|
||||
res.status(500).json({err:message}); //res.end(err.message);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
var nconf = require('nconf');
|
||||
var _ = require('lodash');
|
||||
var utils = require('../libs/api-v2/utils');
|
||||
var shared = require('../../../common');
|
||||
var i18n = require('../libs/api-v2/i18n');
|
||||
var buildManifest = require('../libs/api-v2/buildManifest');
|
||||
var shared = require('../../../common');
|
||||
var forceRefresh = require('./forceRefresh');
|
||||
var tavernQuest = require('../models/group').tavernQuest;
|
||||
var mods = require('../models/user').mods;
|
||||
|
||||
// To avoid stringifying more data then we need,
|
||||
// items from `env` used on the client will have to be specified in this array
|
||||
var clientVars = ['language', 'isStaticPage', 'avalaibleLanguages', 'translations',
|
||||
'FACEBOOK_KEY', 'NODE_ENV', 'BASE_URL', 'GA_ID',
|
||||
'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY',
|
||||
'worldDmg', 'mods', 'IS_MOBILE'];
|
||||
|
||||
var env = {
|
||||
getManifestFiles: buildManifest.getManifestFiles,
|
||||
getBuildUrl: buildManifest.getBuildUrl,
|
||||
_: _,
|
||||
clientVars: clientVars,
|
||||
mods: mods,
|
||||
Content: shared.content,
|
||||
siteVersion: forceRefresh.siteVersion,
|
||||
avalaibleLanguages: i18n.avalaibleLanguages,
|
||||
AMAZON_PAYMENTS: {
|
||||
SELLER_ID: nconf.get('AMAZON_PAYMENTS:SELLER_ID'),
|
||||
CLIENT_ID: nconf.get('AMAZON_PAYMENTS:CLIENT_ID')
|
||||
}
|
||||
};
|
||||
|
||||
'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY AMPLITUDE_KEY'.split(' ').forEach(function(key){
|
||||
env[key] = nconf.get(key);
|
||||
});
|
||||
|
||||
module.exports = function(req, res, next) {
|
||||
var language = _.find(i18n.avalaibleLanguages, {code: req.language});
|
||||
var isStaticPage = req.url.split('/')[1] === 'static'; // If url contains '/static/'
|
||||
|
||||
// Load moment.js language file only when not on static pages
|
||||
language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined);
|
||||
|
||||
res.locals.habitrpg = _.assign(env, {
|
||||
IS_MOBILE: /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header('User-Agent')),
|
||||
language: language,
|
||||
isStaticPage: isStaticPage,
|
||||
translations: i18n.translations[language.code],
|
||||
t: function(){ // stringName and vars are the allowed parameters
|
||||
var args = Array.prototype.slice.call(arguments, 0);
|
||||
args.push(language.code);
|
||||
return shared.i18n.t.apply(null, args);
|
||||
},
|
||||
// Defined here and not outside of the middleware because tavernQuest might be an
|
||||
// empty object until the query to fetch it finishes
|
||||
worldDmg: (tavernQuest && tavernQuest.extra && tavernQuest.extra.worldDmg) || {},
|
||||
});
|
||||
|
||||
// Put query-string party (& guild but use partyInvite for backward compatibility)
|
||||
// invitations into session to be handled later
|
||||
// TODO once we have proper logging in place, log errors here
|
||||
if(req.query.partyInvite){
|
||||
try{
|
||||
req.session.partyInvite = JSON.parse(utils.decrypt(req.query.partyInvite));
|
||||
} catch(e){}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import nconf from 'nconf';
|
||||
import {
|
||||
track,
|
||||
trackPurchase,
|
||||
mockAnalyticsService,
|
||||
} from '../../libs/api-v3/analyticsService';
|
||||
|
||||
let service;
|
||||
|
||||
if (nconf.get('IS_PROD')) {
|
||||
service = {
|
||||
track,
|
||||
trackPurchase,
|
||||
};
|
||||
} else {
|
||||
service = mockAnalyticsService;
|
||||
}
|
||||
|
||||
module.exports = function attachAnalytics (req, res, next) {
|
||||
res.analytics = service;
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
NotAuthorized,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import {
|
||||
model as User,
|
||||
} from '../../models/user';
|
||||
|
||||
// Strins won't be translated here because getUserLanguage has not run yet
|
||||
|
||||
// Authenticate a request through the x-api-user and x-api key header
|
||||
// If optional is true, don't error on missing authentication
|
||||
export function authWithHeaders (optional = false) {
|
||||
return function authWithHeadersHandler (req, res, next) {
|
||||
let userId = req.header('x-api-user');
|
||||
let apiToken = req.header('x-api-key');
|
||||
|
||||
if (!userId || !apiToken) {
|
||||
if (optional) return next();
|
||||
return next(new NotAuthorized(res.t('missingAuthHeaders')));
|
||||
}
|
||||
|
||||
return User.findOne({
|
||||
_id: userId,
|
||||
apiToken,
|
||||
})
|
||||
.exec()
|
||||
.then((user) => {
|
||||
if (!user) throw new NotAuthorized(res.t('invalidCredentials'));
|
||||
if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', {userId: user._id}));
|
||||
|
||||
res.locals.user = user;
|
||||
|
||||
req.session.userId = user._id;
|
||||
return next();
|
||||
})
|
||||
.catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
// Authenticate a request through a valid session
|
||||
export function authWithSession (req, res, next) {
|
||||
let userId = req.session.userId;
|
||||
|
||||
// Always allow authentication with headers
|
||||
if (!userId) {
|
||||
if (!req.header('x-api-user') || !req.header('x-api-key')) {
|
||||
return next(new NotAuthorized(res.t('invalidCredentials')));
|
||||
} else {
|
||||
return authWithHeaders()(req, res, next);
|
||||
}
|
||||
}
|
||||
|
||||
return User.findOne({
|
||||
_id: userId,
|
||||
})
|
||||
.exec()
|
||||
.then((user) => {
|
||||
if (!user) throw new NotAuthorized(res.t('invalidCredentials'));
|
||||
|
||||
res.locals.user = user;
|
||||
return next();
|
||||
})
|
||||
.catch(next);
|
||||
}
|
||||
|
||||
export function authWithUrl (req, res, next) {
|
||||
let userId = req.query._id;
|
||||
let apiToken = req.query.apiToken;
|
||||
|
||||
// Always allow authentication with headers
|
||||
if (!userId || !apiToken) {
|
||||
if (!req.header('x-api-user') || !req.header('x-api-key')) {
|
||||
return next(new NotAuthorized(res.t('missingAuthParams')));
|
||||
} else {
|
||||
return authWithHeaders()(req, res, next);
|
||||
}
|
||||
}
|
||||
|
||||
return User.findOne({ _id: userId, apiToken }).exec()
|
||||
.then((user) => {
|
||||
if (!user) throw new NotAuthorized(res.t('invalidCredentials'));
|
||||
|
||||
res.locals.user = user;
|
||||
return next();
|
||||
})
|
||||
.catch(next);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = function corsMiddleware (req, res, next) {
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': req.header('origin') || '*',
|
||||
'Access-Control-Allow-Methods': 'OPTIONS,GET,POST,PUT,HEAD,DELETE',
|
||||
'Access-Control-Allow-Headers': 'Content-Type,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key',
|
||||
});
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import common from '../../../../common';
|
||||
import * as Tasks from '../../models/task';
|
||||
import Bluebird from 'bluebird';
|
||||
import { model as Group } from '../../models/group';
|
||||
import { model as User } from '../../models/user';
|
||||
import { cron } from '../../libs/api-v3/cron';
|
||||
|
||||
const daysSince = common.daysSince;
|
||||
|
||||
module.exports = function cronMiddleware (req, res, next) {
|
||||
let user = res.locals.user;
|
||||
|
||||
if (!user) return next(); // User might not be available when authentication is not mandatory
|
||||
|
||||
let analytics = res.analytics;
|
||||
|
||||
let now = new Date();
|
||||
|
||||
// If the user's timezone has changed (due to travel or daylight savings),
|
||||
// cron can be triggered twice in one day, so we check for that and use
|
||||
// both timezones to work out if cron should run.
|
||||
// CDS = Custom Day Start time.
|
||||
let timezoneOffsetFromUserPrefs = user.preferences.timezoneOffset || 0;
|
||||
let timezoneOffsetAtLastCron = _.isFinite(user.preferences.timezoneOffsetAtLastCron) ? user.preferences.timezoneOffsetAtLastCron : timezoneOffsetFromUserPrefs;
|
||||
let timezoneOffsetFromBrowser = Number(req.header('x-user-timezoneoffset'));
|
||||
timezoneOffsetFromBrowser = _.isFinite(timezoneOffsetFromBrowser) ? timezoneOffsetFromBrowser : timezoneOffsetFromUserPrefs;
|
||||
// NB: All timezone offsets can be 0, so can't use `... || ...` to apply non-zero defaults
|
||||
|
||||
if (timezoneOffsetFromBrowser !== timezoneOffsetFromUserPrefs) {
|
||||
// The user's browser has just told Habitica that the user's timezone has
|
||||
// changed so store and use the new zone.
|
||||
user.preferences.timezoneOffset = timezoneOffsetFromBrowser;
|
||||
timezoneOffsetFromUserPrefs = timezoneOffsetFromBrowser;
|
||||
}
|
||||
|
||||
// How many days have we missed using the user's current timezone:
|
||||
let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences));
|
||||
|
||||
if (timezoneOffsetAtLastCron !== timezoneOffsetFromUserPrefs) {
|
||||
// Since cron last ran, the user's timezone has changed.
|
||||
// How many days have we missed using the old timezone:
|
||||
let daysMissedNewZone = daysMissed;
|
||||
let daysMissedOldZone = daysSince(user.lastCron, _.defaults({
|
||||
now,
|
||||
timezoneOffsetOverride: timezoneOffsetAtLastCron,
|
||||
}, user.preferences));
|
||||
|
||||
if (timezoneOffsetAtLastCron < timezoneOffsetFromUserPrefs) {
|
||||
// The timezone change was in the unsafe direction.
|
||||
// E.g., timezone changes from UTC+1 (offset -60) to UTC+0 (offset 0).
|
||||
// or timezone changes from UTC-4 (offset 240) to UTC-5 (offset 300).
|
||||
// Local time changed from, for example, 03:00 to 02:00.
|
||||
|
||||
if (daysMissedOldZone > 0 && daysMissedNewZone > 0) {
|
||||
// Both old and new timezones indicate that we SHOULD run cron, so
|
||||
// it is safe to do so immediately.
|
||||
daysMissed = Math.min(daysMissedOldZone, daysMissedNewZone);
|
||||
// use minimum value to be nice to user
|
||||
} else if (daysMissedOldZone > 0) {
|
||||
// The old timezone says that cron should run; the new timezone does not.
|
||||
// This should be impossible for this direction of timezone change, but
|
||||
// just in case I'm wrong...
|
||||
// TODO
|
||||
// console.log("zone has changed - old zone says run cron, NEW zone says no - stop cron now only -- SHOULD NOT HAVE GOT TO HERE", timezoneOffsetAtLastCron, timezoneOffsetFromUserPrefs, now); // used in production for confirming this never happens
|
||||
} else if (daysMissedNewZone > 0) {
|
||||
// The old timezone says that cron should NOT run -- i.e., cron has
|
||||
// already run today, from the old timezone's point of view.
|
||||
// The new timezone says that cron SHOULD run, but this is almost
|
||||
// certainly incorrect.
|
||||
// This happens when cron occurred at a time soon after the CDS. When
|
||||
// you reinterpret that time in the new timezone, it looks like it
|
||||
// was before the CDS, because local time has stepped backwards.
|
||||
// To fix this, rewrite the cron time to a time that the new
|
||||
// timezone interprets as being in today.
|
||||
|
||||
daysMissed = 0; // prevent cron running now
|
||||
let timezoneOffsetDiff = timezoneOffsetAtLastCron - timezoneOffsetFromUserPrefs;
|
||||
// e.g., for dangerous zone change: 240 - 300 = -60 or -660 - -600 = -60
|
||||
|
||||
user.lastCron = moment(user.lastCron).subtract(timezoneOffsetDiff, 'minutes');
|
||||
// NB: We don't change user.auth.timestamps.loggedin so that will still record the time that the previous cron actually ran.
|
||||
// From now on we can ignore the old timezone:
|
||||
user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs;
|
||||
} else {
|
||||
// Both old and new timezones indicate that cron should
|
||||
// NOT run.
|
||||
daysMissed = 0; // prevent cron running now
|
||||
}
|
||||
} else if (timezoneOffsetAtLastCron > timezoneOffsetFromUserPrefs) {
|
||||
daysMissed = daysMissedNewZone;
|
||||
// TODO: Either confirm that there is nothing that could possibly go wrong here and remove the need for this else branch, or fix stuff.
|
||||
// There are probably situations where the Dailies do not reset early enough for a user who was expecting the zone change and wants to use all their Dailies immediately in the new zone;
|
||||
// if so, we should provide an option for easy reset of Dailies (can't be automatic because there will be other situations where the user was not prepared).
|
||||
}
|
||||
}
|
||||
|
||||
if (daysMissed <= 0) return next();
|
||||
|
||||
// Fetch active tasks (no completed todos)
|
||||
Tasks.Task.find({
|
||||
userId: user._id,
|
||||
$or: [ // Exclude completed todos
|
||||
{type: 'todo', completed: false},
|
||||
{type: {$in: ['habit', 'daily', 'reward']}},
|
||||
],
|
||||
}).exec()
|
||||
.then(tasks => {
|
||||
let tasksByType = {habits: [], dailys: [], todos: [], rewards: []};
|
||||
tasks.forEach(task => tasksByType[`${task.type}s`].push(task));
|
||||
|
||||
// Run cron
|
||||
let progress = cron({user, tasksByType, now, daysMissed, analytics, timezoneOffsetFromUserPrefs});
|
||||
|
||||
// Clear old completed todos - 30 days for free users, 90 for subscribers
|
||||
// Do not delete challenges completed todos TODO unless the task is broken?
|
||||
Tasks.Task.remove({
|
||||
userId: user._id,
|
||||
type: 'todo',
|
||||
completed: true,
|
||||
dateCompleted: {
|
||||
$lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(),
|
||||
},
|
||||
'challenge.id': {$exists: false},
|
||||
}).exec();
|
||||
|
||||
let ranCron = user.isModified();
|
||||
let quest = common.content.quests[user.party.quest.key];
|
||||
|
||||
if (ranCron) res.locals.wasModified = true; // TODO remove after v2 is retired
|
||||
if (!ranCron) return next();
|
||||
|
||||
// Group.tavernBoss(user, progress);
|
||||
|
||||
// Save user and tasks
|
||||
let toSave = [user.save()];
|
||||
tasks.forEach(task => {
|
||||
if (task.isModified()) toSave.push(task.save());
|
||||
});
|
||||
|
||||
return Bluebird.all(toSave)
|
||||
.then(saved => {
|
||||
user = res.locals.user = saved[0];
|
||||
if (!quest) return;
|
||||
// If user is on a quest, roll for boss & player, or handle collections
|
||||
let questType = quest.boss ? 'boss' : 'collect';
|
||||
// TODO this saves user, runs db updates, loads user. Is there a better way to handle this?
|
||||
return Group[`${questType}Quest`](user, progress)
|
||||
.then(() => User.findById(user._id).exec()) // fetch the updated user...
|
||||
.then(updatedUser => {
|
||||
res.locals.user = updatedUser;
|
||||
|
||||
return null;
|
||||
});
|
||||
})
|
||||
.then(() => next())
|
||||
.catch(next);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import domainMiddleware from 'domain-middleware';
|
||||
|
||||
module.exports = function implementDomainMiddleware (server, mongoose) {
|
||||
return domainMiddleware({
|
||||
server: {
|
||||
close () {
|
||||
server.close();
|
||||
mongoose.connection.close();
|
||||
},
|
||||
},
|
||||
killTimeout: 10000,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
NotAuthorized,
|
||||
} from '../../libs/api-v3/errors';
|
||||
|
||||
export function ensureAdmin (req, res, next) {
|
||||
let user = res.locals.user;
|
||||
|
||||
if (!user.contributor.admin) {
|
||||
return next(new NotAuthorized(res.t('noAdminAccess')));
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export function ensureSudo (req, res, next) {
|
||||
let user = res.locals.user;
|
||||
|
||||
if (!user.contributor.sudo) {
|
||||
return next(new NotAuthorized(res.t('noSudoAccess')));
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import nconf from 'nconf';
|
||||
import {
|
||||
NotFound,
|
||||
} from '../../libs/api-v3/errors';
|
||||
|
||||
module.exports = function ensureDevelpmentMode (req, res, next) {
|
||||
if (nconf.get('IS_PROD')) {
|
||||
next(new NotFound());
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
// The error handler middleware that handles all errors
|
||||
// and respond to the client
|
||||
import logger from '../../libs/api-v3/logger';
|
||||
import {
|
||||
CustomError,
|
||||
BadRequest,
|
||||
InternalServerError,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import {
|
||||
map,
|
||||
omit,
|
||||
} from 'lodash';
|
||||
|
||||
module.exports = function errorHandler (err, req, res, next) { // eslint-disable-line no-unused-vars
|
||||
logger.error(err, {
|
||||
originalUrl: req.originalUrl,
|
||||
headers: omit(req.headers, ['x-api-key']),
|
||||
body: req.body,
|
||||
});
|
||||
|
||||
// In case of a CustomError class, use it's data
|
||||
// Otherwise try to identify the type of error (mongoose validation, mongodb unique, ...)
|
||||
// If we can't identify it, respond with a generic 500 error
|
||||
let responseErr = err instanceof CustomError ? err : null;
|
||||
|
||||
// Handle errors created with 'http-errors' or similar that have a status/statusCode property
|
||||
if (err.statusCode && typeof err.statusCode === 'number') {
|
||||
responseErr = new CustomError();
|
||||
responseErr.httpCode = err.statusCode;
|
||||
responseErr.name = err.name;
|
||||
responseErr.message = err.message;
|
||||
}
|
||||
|
||||
// Handle errors by express-validator
|
||||
if (Array.isArray(err) && err[0].param && err[0].msg) {
|
||||
responseErr = new BadRequest(res.t('invalidReqParams'));
|
||||
responseErr.errors = err.map((paramErr) => {
|
||||
return {
|
||||
message: paramErr.msg,
|
||||
param: paramErr.param,
|
||||
value: paramErr.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Handle mongoose validation errors
|
||||
if (err.name === 'ValidationError') {
|
||||
responseErr = new BadRequest(err.message); // TODO standard message? translate?
|
||||
responseErr.errors = map(err.errors, (mongooseErr) => {
|
||||
return {
|
||||
message: mongooseErr.message,
|
||||
path: mongooseErr.path,
|
||||
value: mongooseErr.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Stripe Card errors errors (can be safely shown to the users)
|
||||
// https://stripe.com/docs/api/node#errors
|
||||
if (err.type === 'StripeCardError') {
|
||||
responseErr = new BadRequest(err.message);
|
||||
}
|
||||
|
||||
if (!responseErr || responseErr.httpCode >= 500) {
|
||||
// Try to identify the error...
|
||||
// ...
|
||||
// Otherwise create an InternalServerError and use it
|
||||
// we don't want to leak anything, just a generic error message
|
||||
// Use it also in case of identified errors but with httpCode === 500
|
||||
responseErr = new InternalServerError();
|
||||
}
|
||||
|
||||
let jsonRes = {
|
||||
success: false,
|
||||
error: responseErr.name,
|
||||
message: responseErr.message,
|
||||
};
|
||||
|
||||
if (responseErr.errors) {
|
||||
jsonRes.errors = responseErr.errors;
|
||||
}
|
||||
|
||||
// In some occasions like when invalid JSON is supplied `res.respond` might be not yet avalaible,
|
||||
// in this case we use the standard res.status(...).json(...)
|
||||
return res.status(responseErr.httpCode).json(jsonRes);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
// This module is only used to attach middlewares to the express app
|
||||
import errorHandler from './errorHandler';
|
||||
import bodyParser from 'body-parser';
|
||||
import notFoundHandler from './notFound';
|
||||
import nconf from 'nconf';
|
||||
import morgan from 'morgan';
|
||||
import cookieSession from 'cookie-session';
|
||||
import cors from './cors';
|
||||
import staticMiddleware from './static';
|
||||
import domainMiddleware from './domain';
|
||||
import mongoose from 'mongoose';
|
||||
import compression from 'compression';
|
||||
import favicon from 'serve-favicon';
|
||||
import methodOverride from 'method-override';
|
||||
import passport from 'passport';
|
||||
import path from 'path';
|
||||
import maintenanceMode from './maintenanceMode';
|
||||
import {
|
||||
forceSSL,
|
||||
forceHabitica,
|
||||
} from './redirects';
|
||||
import v1 from './v1';
|
||||
import v2 from './v2';
|
||||
import v3 from './v3';
|
||||
import responseHandler from './response';
|
||||
import {
|
||||
attachTranslateFunction,
|
||||
} from './language';
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
const DISABLE_LOGGING = nconf.get('DISABLE_REQUEST_LOGGING');
|
||||
const PUBLIC_DIR = path.join(__dirname, '/../../../client');
|
||||
|
||||
const SESSION_SECRET = nconf.get('SESSION_SECRET');
|
||||
const TWO_WEEKS = 1000 * 60 * 60 * 24 * 14;
|
||||
|
||||
module.exports = function attachMiddlewares (app, server) {
|
||||
app.set('view engine', 'jade');
|
||||
app.set('views', `${__dirname}/../views`);
|
||||
|
||||
app.use(domainMiddleware(server, mongoose));
|
||||
|
||||
if (!IS_PROD && !DISABLE_LOGGING) app.use(morgan('dev'));
|
||||
|
||||
// add res.respond and res.t
|
||||
app.use(responseHandler);
|
||||
app.use(attachTranslateFunction);
|
||||
|
||||
app.use(compression());
|
||||
app.use(favicon(`${PUBLIC_DIR}/favicon.ico`));
|
||||
|
||||
app.use(maintenanceMode);
|
||||
|
||||
app.use(cors);
|
||||
app.use(forceSSL);
|
||||
app.use(forceHabitica);
|
||||
|
||||
app.use(bodyParser.urlencoded({
|
||||
extended: true, // Uses 'qs' library as old connect middleware
|
||||
}));
|
||||
app.use(bodyParser.json());
|
||||
app.use(methodOverride());
|
||||
|
||||
app.use(cookieSession({
|
||||
name: 'connect:sess', // Used to keep backward compatibility with Express 3 cookies
|
||||
secret: SESSION_SECRET,
|
||||
httpOnly: true, // so cookies are not accessible with browser JS
|
||||
// TODO what about https only (secure) ?
|
||||
maxAge: TWO_WEEKS,
|
||||
}));
|
||||
|
||||
// Initialize Passport! Also use passport.session() middleware, to support
|
||||
// persistent login sessions (recommended).
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
app.use('/api/v2', v2);
|
||||
app.use('/api/v1', v1);
|
||||
app.use(v3); // the main app, also setup top-level routes
|
||||
staticMiddleware(app);
|
||||
|
||||
app.use(notFoundHandler);
|
||||
|
||||
// Error handler middleware, define as the last one.
|
||||
// Used for v3 and v1, v2 will keep using its own error handler
|
||||
app.use(errorHandler);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { model as User } from '../../models/user';
|
||||
import accepts from 'accepts';
|
||||
import common from '../../../../common';
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
translations,
|
||||
defaultLangCodes,
|
||||
multipleVersionsLanguages,
|
||||
} from '../../libs/api-v3/i18n';
|
||||
|
||||
const i18n = common.i18n;
|
||||
|
||||
function _getUniqueListOfLanguages (languages) {
|
||||
let acceptableLanguages = _(languages).map((lang) => {
|
||||
return lang.slice(0, 2);
|
||||
}).uniq().value();
|
||||
|
||||
let uniqueListOfLanguages = _.intersection(acceptableLanguages, defaultLangCodes);
|
||||
|
||||
return uniqueListOfLanguages;
|
||||
}
|
||||
|
||||
function _checkForApplicableLanguageVariant (originalLanguageOptions) {
|
||||
let languageVariant = _.find(originalLanguageOptions, (accepted) => {
|
||||
let trimmedAccepted = accepted.slice(0, 2);
|
||||
|
||||
return multipleVersionsLanguages[trimmedAccepted];
|
||||
});
|
||||
|
||||
return languageVariant;
|
||||
}
|
||||
|
||||
function _getFromBrowser (req) {
|
||||
let originalLanguageOptions = accepts(req).languages();
|
||||
let uniqueListOfLanguages = _getUniqueListOfLanguages(originalLanguageOptions);
|
||||
let baseLanguage = (uniqueListOfLanguages[0] || '').toLowerCase();
|
||||
let languageMapping = multipleVersionsLanguages[baseLanguage];
|
||||
|
||||
if (languageMapping) {
|
||||
let languageVariant = _checkForApplicableLanguageVariant(originalLanguageOptions);
|
||||
|
||||
if (languageVariant) {
|
||||
languageVariant = languageVariant.toLowerCase();
|
||||
} else {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
return languageMapping[languageVariant] || baseLanguage;
|
||||
} else {
|
||||
return baseLanguage || 'en';
|
||||
}
|
||||
}
|
||||
|
||||
function _getFromUser (user, req) {
|
||||
let preferredLang = user && user.preferences && user.preferences.language;
|
||||
let lang = translations[preferredLang] ? preferredLang : _getFromBrowser(req);
|
||||
|
||||
return lang;
|
||||
}
|
||||
|
||||
export function attachTranslateFunction (req, res, next) {
|
||||
res.t = function reqTranslation () {
|
||||
return i18n.t(...arguments, req.language);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export function getUserLanguage (req, res, next) {
|
||||
if (req.query.lang) { // In case the language is specified in the request url, use it
|
||||
req.language = translations[req.query.lang] ? req.query.lang : 'en';
|
||||
return next();
|
||||
} else if (req.locals && req.locals.user) { // If the request is authenticated, use the user's preferred language
|
||||
req.language = _getFromUser(req.locals.user, req);
|
||||
return next();
|
||||
} else if (req.session && req.session.userId) { // Same thing if the user has a valid session
|
||||
return User.findOne({
|
||||
_id: req.session.userId,
|
||||
}, 'preferences.language')
|
||||
.lean()
|
||||
.exec()
|
||||
.then((user) => {
|
||||
req.language = _getFromUser(user, req);
|
||||
return next();
|
||||
})
|
||||
.catch(next);
|
||||
} else { // Otherwise get from browser
|
||||
req.language = _getFromUser(null, req);
|
||||
return next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import nconf from 'nconf';
|
||||
import _ from 'lodash';
|
||||
import shared from '../../../../common';
|
||||
import * as i18n from '../../libs/api-v3/i18n';
|
||||
import {
|
||||
getBuildUrl,
|
||||
getManifestFiles,
|
||||
} from '../../libs/api-v3/buildManifest';
|
||||
import forceRefresh from './../forceRefresh';
|
||||
import { tavernQuest } from '../../models/group';
|
||||
import { mods } from '../../models/user';
|
||||
|
||||
// To avoid stringifying more data then we need,
|
||||
// items from `env` used on the client will have to be specified in this array
|
||||
const CLIENT_VARS = ['language', 'isStaticPage', 'availableLanguages', 'translations',
|
||||
'FACEBOOK_KEY', 'NODE_ENV', 'BASE_URL', 'GA_ID',
|
||||
'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY',
|
||||
'worldDmg', 'mods', 'IS_MOBILE'];
|
||||
|
||||
let env = {
|
||||
getManifestFiles,
|
||||
getBuildUrl,
|
||||
_,
|
||||
clientVars: CLIENT_VARS,
|
||||
mods,
|
||||
Content: shared.content,
|
||||
siteVersion: forceRefresh.siteVersion,
|
||||
availableLanguages: i18n.availableLanguages,
|
||||
AMAZON_PAYMENTS: {
|
||||
SELLER_ID: nconf.get('AMAZON_PAYMENTS:SELLER_ID'),
|
||||
CLIENT_ID: nconf.get('AMAZON_PAYMENTS:CLIENT_ID'),
|
||||
},
|
||||
};
|
||||
|
||||
'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY AMPLITUDE_KEY'.split(' ').forEach(key => {
|
||||
env[key] = nconf.get(key);
|
||||
});
|
||||
|
||||
module.exports = function locals (req, res, next) {
|
||||
let language = _.find(i18n.availableLanguages, {code: req.language});
|
||||
let isStaticPage = req.url.split('/')[1] === 'static'; // If url contains '/static/'
|
||||
|
||||
// Load moment.js language file only when not on static pages
|
||||
language.momentLang = !isStaticPage && i18n.momentLangs[language.code] || undefined;
|
||||
|
||||
res.locals.habitrpg = _.assign(env, {
|
||||
IS_MOBILE: /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header('User-Agent')),
|
||||
language,
|
||||
isStaticPage,
|
||||
translations: i18n.translations[language.code],
|
||||
t (...args) { // stringName and vars are the allowed parameters
|
||||
args.push(language.code);
|
||||
return shared.i18n.t(...args);
|
||||
},
|
||||
// Defined here and not outside of the middleware because tavernQuest might be an
|
||||
// empty object until the query to fetch it finishes
|
||||
worldDmg: tavernQuest && tavernQuest.extra && tavernQuest.extra.worldDmg || {},
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getUserLanguage } from './language';
|
||||
import nconf from 'nconf';
|
||||
|
||||
const MAINTENANCE_MODE = nconf.get('MAINTENANCE_MODE');
|
||||
|
||||
module.exports = function maintenanceMode (req, res, next) {
|
||||
if (MAINTENANCE_MODE !== 'true') return next();
|
||||
|
||||
getUserLanguage(req, res, (err) => {
|
||||
if (err) return next(err);
|
||||
|
||||
let pageVariables = {
|
||||
maintenanceStart: nconf.get('MAINTENANCE_START'),
|
||||
maintenanceEnd: nconf.get('MAINTENANCE_END'),
|
||||
translation: res.t,
|
||||
};
|
||||
|
||||
if (req.headers && req.headers.accept && req.headers.accept.indexOf('text/html') !== -1) {
|
||||
if (req.path === '/views/static/maintenance-info') {
|
||||
return res.status(503).render('../../../views/static/maintenance-info', pageVariables);
|
||||
} else {
|
||||
return res.status(503).render('../../../views/static/maintenance', pageVariables);
|
||||
}
|
||||
} else {
|
||||
return res.status(503).send({
|
||||
error: 'Maintenance',
|
||||
message: 'Server offline for maintenance.',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import {
|
||||
NotFound,
|
||||
} from '../../libs/api-v3/errors';
|
||||
|
||||
module.exports = function NotFoundMiddleware (req, res, next) {
|
||||
next(new NotFound());
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import nconf from 'nconf';
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
const IGNORE_REDIRECT = nconf.get('IGNORE_REDIRECT');
|
||||
const BASE_URL = nconf.get('BASE_URL');
|
||||
|
||||
function isHTTP (req) {
|
||||
return ( // eslint-disable-line no-extra-parens
|
||||
req.header('x-forwarded-proto') &&
|
||||
req.header('x-forwarded-proto') !== 'https' &&
|
||||
IS_PROD &&
|
||||
BASE_URL.indexOf('https') === 0
|
||||
);
|
||||
}
|
||||
|
||||
function isProxied (req) {
|
||||
return ( // eslint-disable-line no-extra-parens
|
||||
req.header('x-habitica-lb') &&
|
||||
req.header('x-habitica-lb') === 'Yes'
|
||||
);
|
||||
}
|
||||
|
||||
export function forceSSL (req, res, next) {
|
||||
if (isHTTP(req) && !isProxied(req)) {
|
||||
return res.redirect(BASE_URL + req.originalUrl);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Redirect to habitica for non-api urls
|
||||
|
||||
function nonApiUrl (req) {
|
||||
return req.originalUrl.search(/\/api\//) === -1;
|
||||
}
|
||||
|
||||
export function forceHabitica (req, res, next) {
|
||||
if (IS_PROD && !IGNORE_REDIRECT && !isProxied(req) && nonApiUrl(req)) {
|
||||
return res.redirect(301, BASE_URL + req.url);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
module.exports = function responseHandler (req, res, next) {
|
||||
// Only used for successful responses
|
||||
res.respond = function respond (status = 200, data = {}, message) {
|
||||
let user = res.locals && res.locals.user;
|
||||
|
||||
let response = {
|
||||
success: status < 400,
|
||||
data,
|
||||
};
|
||||
|
||||
if (message) response.message = message;
|
||||
|
||||
// When userV=Number (user version) query parameter is passed and a user is logged in,
|
||||
// sends back the current user._v in the response so that the client
|
||||
// can verify if it's the most up to date data.
|
||||
// Considered part of the private API for now and not officially supported
|
||||
if (user && req.query.userV) {
|
||||
response.userV = user._v;
|
||||
}
|
||||
|
||||
res.status(status).json(response);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
// TODO test this middleware
|
||||
module.exports = function setupBodyMiddleware (req, res, next) {
|
||||
req.body = req.body || {};
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import express from 'express';
|
||||
import nconf from 'nconf';
|
||||
import path from 'path';
|
||||
|
||||
const IS_PROD = nconf.get('IS_PROD');
|
||||
const MAX_AGE = IS_PROD ? 31536000000 : 0;
|
||||
const PUBLIC_DIR = path.join(__dirname, '/../../../client');
|
||||
const BUILD_DIR = path.join(__dirname, '/../../../build');
|
||||
|
||||
module.exports = function staticMiddleware (expressApp) {
|
||||
// TODO move all static files to a single location (one for public and one for build)
|
||||
expressApp.use(express.static(BUILD_DIR, { maxAge: MAX_AGE }));
|
||||
expressApp.use('/common/dist', express.static(`${PUBLIC_DIR}/../../common/dist`, { maxAge: MAX_AGE }));
|
||||
expressApp.use('/common/audio', express.static(`${PUBLIC_DIR}/../../common/audio`, { maxAge: MAX_AGE }));
|
||||
expressApp.use('/common/script/public', express.static(`${PUBLIC_DIR}/../../common/script/public`, { maxAge: MAX_AGE }));
|
||||
expressApp.use('/common/img', express.static(`${PUBLIC_DIR}/../../common/img`, { maxAge: MAX_AGE }));
|
||||
expressApp.use(express.static(PUBLIC_DIR));
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
// API v1 middlewares and routes
|
||||
// DEPRECATED AND INACTIVE
|
||||
|
||||
import express from 'express';
|
||||
import nconf from 'nconf';
|
||||
import {
|
||||
NotFound,
|
||||
} from '../../libs/api-v3/errors';
|
||||
|
||||
const router = express.Router(); // eslint-disable-line babel/new-cap
|
||||
|
||||
const BASE_URL = nconf.get('BASE_URL');
|
||||
|
||||
router.all('*', function deprecatedV1 (req, res, next) {
|
||||
let error = new NotFound(`API v1 is no longer supported, please use API v3 instead (${BASE_URL}/static/api).`);
|
||||
return next(error);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,27 @@
|
||||
// DEPRECATED BUT STILL ACTIVE
|
||||
|
||||
// import path from 'path';
|
||||
import swagger from 'swagger-node-express';
|
||||
// import shared from '../../../../common';
|
||||
import express from 'express';
|
||||
import analytics from './analytics';
|
||||
import responseHandler from './response';
|
||||
|
||||
const v2app = express();
|
||||
|
||||
// re-set the view options because they are not inherited from the top level app
|
||||
v2app.set('view engine', 'jade');
|
||||
v2app.set('views', `${__dirname}/../../../views`);
|
||||
|
||||
v2app.use(analytics);
|
||||
v2app.use(responseHandler);
|
||||
|
||||
|
||||
// Custom Directives
|
||||
v2app.use('/', require('../../routes/api-v2/auth'));
|
||||
|
||||
require('../../routes/api-v2/swagger')(swagger, v2app);
|
||||
|
||||
v2app.use(require('../api-v2/errorHandler'));
|
||||
|
||||
module.exports = v2app;
|
||||
@@ -0,0 +1,30 @@
|
||||
import express from 'express';
|
||||
import expressValidator from 'express-validator';
|
||||
import analytics from './analytics';
|
||||
import setupBody from './setupBody';
|
||||
import routes from '../../libs/api-v3/routes';
|
||||
import path from 'path';
|
||||
|
||||
const API_CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/api-v3/');
|
||||
const TOP_LEVEL_CONTROLLERS_PATH = path.join(__dirname, '/../../controllers/top-level/');
|
||||
|
||||
const v3app = express();
|
||||
|
||||
// re-set the view options because they are not inherited from the top level app
|
||||
v3app.set('view engine', 'jade');
|
||||
v3app.set('views', `${__dirname}/../../../views`);
|
||||
|
||||
v3app.use(expressValidator());
|
||||
v3app.use(analytics);
|
||||
v3app.use(setupBody);
|
||||
|
||||
const topLevelRouter = express.Router(); // eslint-disable-line babel/new-cap
|
||||
|
||||
routes.walkControllers(topLevelRouter, TOP_LEVEL_CONTROLLERS_PATH);
|
||||
v3app.use('/', topLevelRouter);
|
||||
|
||||
const v3Router = express.Router(); // eslint-disable-line babel/new-cap
|
||||
routes.walkControllers(v3Router, API_CONTROLLERS_PATH);
|
||||
v3app.use('/api/v3', v3Router);
|
||||
|
||||
module.exports = v3app;
|
||||
@@ -0,0 +1,26 @@
|
||||
var nconf = require('nconf');
|
||||
var limiter = require('connect-ratelimit');
|
||||
|
||||
var IS_PROD = nconf.get('NODE_ENV') === 'production';
|
||||
|
||||
// TODO since Habitica runs on many different servers this module is pretty useless
|
||||
// as it will only block requests that go to the same server but anyway we should probably have a rate limiter in place
|
||||
|
||||
module.exports = function(app) {
|
||||
// disable the rate limiter middleware
|
||||
if (/*!IS_PROD || */true) return;
|
||||
app.use(limiter({
|
||||
end:false,
|
||||
categories:{
|
||||
normal: {
|
||||
// 2 req/s, but split as minutes
|
||||
totalRequests: 80,
|
||||
every: 60000
|
||||
}
|
||||
}
|
||||
})).use(function(req,res,next){
|
||||
//logging.info(res.ratelimit);
|
||||
if (res.ratelimit.exceeded) return res.status(429).json({err:'Rate limit exceeded'});
|
||||
next();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
// TODO do we need this module anymore in v3? No
|
||||
|
||||
module.exports.siteVersion = 1;
|
||||
|
||||
module.exports.middleware = function(req, res, next){
|
||||
if(req.query.siteVersion && req.query.siteVersion != module.exports.siteVersion){
|
||||
return res.status(400).json({needRefresh: true});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,426 @@
|
||||
import mongoose from 'mongoose';
|
||||
import Bluebird from 'bluebird';
|
||||
import validator from 'validator';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import _ from 'lodash';
|
||||
import * as Tasks from './task';
|
||||
import { model as User } from './user';
|
||||
import {
|
||||
model as Group,
|
||||
TAVERN_ID,
|
||||
} from './group';
|
||||
import { removeFromArray } from '../libs/api-v3/collectionManipulators';
|
||||
import shared from '../../../common';
|
||||
import { sendTxn as txnEmail } from '../libs/api-v3/email';
|
||||
import sendPushNotification from '../libs/api-v3/pushNotifications';
|
||||
import cwait from 'cwait';
|
||||
|
||||
let Schema = mongoose.Schema;
|
||||
|
||||
let schema = new Schema({
|
||||
name: {type: String, required: true},
|
||||
shortName: {type: String, required: true, minlength: 3},
|
||||
description: String,
|
||||
official: {type: Boolean, default: false},
|
||||
tasksOrder: {
|
||||
habits: [{type: String, ref: 'Task'}],
|
||||
dailys: [{type: String, ref: 'Task'}],
|
||||
todos: [{type: String, ref: 'Task'}],
|
||||
rewards: [{type: String, ref: 'Task'}],
|
||||
},
|
||||
leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
|
||||
group: {type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
|
||||
memberCount: {type: Number, default: 1},
|
||||
prize: {type: Number, default: 0, min: 0},
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
noSet: ['_id', 'memberCount', 'tasksOrder'],
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
// A list of additional fields that cannot be updated (but can be set on creation)
|
||||
let noUpdate = ['group', 'official', 'shortName', 'prize'];
|
||||
schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
|
||||
return this.sanitize(updateObj, noUpdate);
|
||||
};
|
||||
|
||||
// Returns true if user is a member of the challenge
|
||||
schema.methods.isMember = function isChallengeMember (user) {
|
||||
return user.challenges.indexOf(this._id) !== -1;
|
||||
};
|
||||
|
||||
// Returns true if the user can modify (close, selectWinner, ...) the challenge
|
||||
schema.methods.canModify = function canModifyChallenge (user) {
|
||||
return user.contributor.admin || this.leader === user._id;
|
||||
};
|
||||
|
||||
// Returns true if user has access to the challenge (can join)
|
||||
schema.methods.hasAccess = function hasAccessToChallenge (user, group) {
|
||||
if (group.type === 'guild' && group.privacy === 'public') return true;
|
||||
return user.getGroups().indexOf(this.group) !== -1;
|
||||
};
|
||||
|
||||
// Returns true if user can view the challenge
|
||||
// Different from hasAccess because you can see challenges of groups you've been removed from if you're partecipating in them
|
||||
schema.methods.canView = function canViewChallenge (user, group) {
|
||||
if (this.isMember(user)) return true;
|
||||
return this.hasAccess(user, group);
|
||||
};
|
||||
|
||||
// Takes a Task document and return a plain object of attributes that can be synced to the user
|
||||
function _syncableAttrs (task) {
|
||||
let t = task.toObject(); // lodash doesn't seem to like _.omit on Document
|
||||
// only sync/compare important attrs
|
||||
let omitAttrs = ['_id', 'userId', 'challenge', 'history', 'tags', 'completed', 'streak', 'notes', 'updatedAt'];
|
||||
if (t.type !== 'reward') omitAttrs.push('value');
|
||||
return _.omit(t, omitAttrs);
|
||||
}
|
||||
|
||||
// Sync challenge to user, including tasks and tags.
|
||||
// Used when user joins the challenge or to force sync.
|
||||
schema.methods.syncToUser = async function syncChallengeToUser (user) {
|
||||
let challenge = this;
|
||||
challenge.shortName = challenge.shortName || challenge.name;
|
||||
|
||||
// Add challenge to user.challenges
|
||||
if (!_.contains(user.challenges, challenge._id)) user.challenges.push(challenge._id);
|
||||
|
||||
// Sync tags
|
||||
let userTags = user.tags;
|
||||
let i = _.findIndex(userTags, {id: challenge._id});
|
||||
|
||||
if (i !== -1) {
|
||||
if (userTags[i].name !== challenge.shortName) {
|
||||
// update the name - it's been changed since
|
||||
userTags[i].name = challenge.shortName;
|
||||
}
|
||||
} else {
|
||||
userTags.push({
|
||||
id: challenge._id,
|
||||
name: challenge.shortName,
|
||||
challenge: true,
|
||||
});
|
||||
}
|
||||
|
||||
let [challengeTasks, userTasks] = await Bluebird.all([
|
||||
// Find original challenge tasks
|
||||
Tasks.Task.find({
|
||||
userId: {$exists: false},
|
||||
'challenge.id': challenge._id,
|
||||
}).exec(),
|
||||
// Find user's tasks linked to this challenge
|
||||
Tasks.Task.find({
|
||||
userId: user._id,
|
||||
'challenge.id': challenge._id,
|
||||
}).exec(),
|
||||
]);
|
||||
|
||||
let toSave = []; // An array of things to save
|
||||
|
||||
challengeTasks.forEach(chalTask => {
|
||||
let matchingTask = _.find(userTasks, userTask => userTask.challenge.taskId === chalTask._id);
|
||||
|
||||
if (!matchingTask) { // If the task is new, create it
|
||||
matchingTask = new Tasks[chalTask.type](Tasks.Task.sanitize(_syncableAttrs(chalTask)));
|
||||
matchingTask.challenge = {taskId: chalTask._id, id: challenge._id};
|
||||
matchingTask.userId = user._id;
|
||||
user.tasksOrder[`${chalTask.type}s`].push(matchingTask._id);
|
||||
} else {
|
||||
_.merge(matchingTask, _syncableAttrs(chalTask));
|
||||
// Make sure the task is in user.tasksOrder
|
||||
let orderList = user.tasksOrder[`${chalTask.type}s`];
|
||||
if (orderList.indexOf(matchingTask._id) === -1 && (matchingTask.type !== 'todo' || !matchingTask.completed)) orderList.push(matchingTask._id);
|
||||
}
|
||||
|
||||
if (!matchingTask.notes) matchingTask.notes = chalTask.notes; // don't override the notes, but provide it if not provided
|
||||
if (matchingTask.tags.indexOf(challenge._id) === -1) matchingTask.tags.push(challenge._id); // add tag if missing
|
||||
toSave.push(matchingTask.save());
|
||||
});
|
||||
|
||||
// Flag deleted tasks as "broken"
|
||||
userTasks.forEach(userTask => {
|
||||
if (!_.find(challengeTasks, chalTask => chalTask._id === userTask.challenge.taskId)) {
|
||||
userTask.challenge.broken = 'TASK_DELETED';
|
||||
toSave.push(userTask.save());
|
||||
}
|
||||
});
|
||||
|
||||
toSave.push(user.save());
|
||||
return Bluebird.all(toSave);
|
||||
};
|
||||
|
||||
async function _fetchMembersIds (challengeId) {
|
||||
return (await User.find({challenges: {$in: [challengeId]}}).select('_id').lean().exec()).map(member => member._id);
|
||||
}
|
||||
|
||||
async function _addTaskFn (challenge, tasks, memberId) {
|
||||
let updateTasksOrderQ = {$push: {}};
|
||||
let toSave = [];
|
||||
|
||||
tasks.forEach(chalTask => {
|
||||
let userTask = new Tasks[chalTask.type](Tasks.Task.sanitize(_syncableAttrs(chalTask)));
|
||||
userTask.challenge = {taskId: chalTask._id, id: challenge._id};
|
||||
userTask.userId = memberId;
|
||||
|
||||
let tasksOrderList = updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`];
|
||||
if (!tasksOrderList) {
|
||||
updateTasksOrderQ.$push[`tasksOrder.${chalTask.type}s`] = {
|
||||
$position: 0, // unshift
|
||||
$each: [userTask._id],
|
||||
};
|
||||
} else {
|
||||
tasksOrderList.$each.unshift(userTask._id);
|
||||
}
|
||||
|
||||
toSave.push(userTask.save({
|
||||
validateBeforeSave: false, // no user data supplied
|
||||
}));
|
||||
});
|
||||
|
||||
// Update the user
|
||||
toSave.unshift(User.update({_id: memberId}, updateTasksOrderQ).exec());
|
||||
return await Bluebird.all(toSave);
|
||||
}
|
||||
|
||||
// Add a new task to challenge members
|
||||
schema.methods.addTasks = async function challengeAddTasks (tasks) {
|
||||
let challenge = this;
|
||||
let membersIds = await _fetchMembersIds(challenge._id);
|
||||
|
||||
let queue = new cwait.TaskQueue(Bluebird, 5); // process only 5 users concurrently
|
||||
|
||||
await Bluebird.map(membersIds, queue.wrap((memberId) => {
|
||||
return _addTaskFn(challenge, tasks, memberId);
|
||||
}));
|
||||
};
|
||||
|
||||
// Sync updated task to challenge members
|
||||
schema.methods.updateTask = async function challengeUpdateTask (task) {
|
||||
let challenge = this;
|
||||
|
||||
let updateCmd = {$set: {}};
|
||||
|
||||
let syncableAttrs = _syncableAttrs(task);
|
||||
for (let key in syncableAttrs) {
|
||||
updateCmd.$set[key] = syncableAttrs[key];
|
||||
}
|
||||
|
||||
// Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks
|
||||
await Tasks.Task.update({
|
||||
userId: {$exists: true},
|
||||
'challenge.id': challenge.id,
|
||||
'challenge.taskId': task._id,
|
||||
}, updateCmd, {multi: true}).exec();
|
||||
};
|
||||
|
||||
// Remove a task from challenge members
|
||||
schema.methods.removeTask = async function challengeRemoveTask (task) {
|
||||
let challenge = this;
|
||||
|
||||
// Set the task as broken
|
||||
await Tasks.Task.update({
|
||||
userId: {$exists: true},
|
||||
'challenge.id': challenge.id,
|
||||
'challenge.taskId': task._id,
|
||||
}, {
|
||||
$set: {'challenge.broken': 'TASK_DELETED'},
|
||||
}, {multi: true}).exec();
|
||||
};
|
||||
|
||||
// Unlink challenges tasks (and the challenge itself) from user
|
||||
schema.methods.unlinkTasks = async function challengeUnlinkTasks (user, keep) {
|
||||
let challengeId = this._id;
|
||||
let findQuery = {
|
||||
userId: user._id,
|
||||
'challenge.id': challengeId,
|
||||
};
|
||||
|
||||
removeFromArray(user.challenges, challengeId);
|
||||
|
||||
if (keep === 'keep-all') {
|
||||
await Tasks.Task.update(findQuery, {
|
||||
$set: {challenge: {}},
|
||||
}, {multi: true}).exec();
|
||||
|
||||
await user.save();
|
||||
} else { // keep = 'remove-all'
|
||||
let tasks = await Tasks.Task.find(findQuery).select('_id type completed').exec();
|
||||
let taskPromises = tasks.map(task => {
|
||||
// Remove task from user.tasksOrder and delete them
|
||||
if (task.type !== 'todo' || !task.completed) {
|
||||
removeFromArray(user.tasksOrder[`${task.type}s`], task._id);
|
||||
}
|
||||
|
||||
return task.remove();
|
||||
});
|
||||
user.markModified('tasksOrder');
|
||||
taskPromises.push(user.save());
|
||||
return Bluebird.all(taskPromises);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO everything here should be moved to a worker
|
||||
// actually even for a worker it's probably just too big and will kill mongo, figure out something else
|
||||
schema.methods.closeChal = async function closeChal (broken = {}) {
|
||||
let challenge = this;
|
||||
|
||||
let winner = broken.winner;
|
||||
let brokenReason = broken.broken;
|
||||
|
||||
// Delete the challenge
|
||||
await this.model('Challenge').remove({_id: challenge._id}).exec();
|
||||
|
||||
// Refund the leader if the challenge is closed and the group not the tavern
|
||||
if (challenge.group !== TAVERN_ID && brokenReason === 'CHALLENGE_DELETED') {
|
||||
await User.update({_id: challenge.leader}, {$inc: {balance: challenge.prize / 4}}).exec();
|
||||
}
|
||||
|
||||
// Update the challengeCount on the group
|
||||
await Group.update({_id: challenge.group}, {$inc: {challengeCount: -1}}).exec();
|
||||
|
||||
// Award prize to winner and notify
|
||||
if (winner) {
|
||||
winner.achievements.challenges.push(challenge.name);
|
||||
winner.balance += challenge.prize / 4;
|
||||
let savedWinner = await winner.save();
|
||||
if (savedWinner.preferences.emailNotifications.wonChallenge !== false) {
|
||||
txnEmail(savedWinner, 'won-challenge', [
|
||||
{name: 'CHALLENGE_NAME', content: challenge.name},
|
||||
]);
|
||||
}
|
||||
|
||||
sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name);
|
||||
}
|
||||
|
||||
// Run some operations in the background withouth blocking the thread
|
||||
let backgroundTasks = [
|
||||
// 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: 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(),
|
||||
];
|
||||
|
||||
Bluebird.all(backgroundTasks);
|
||||
};
|
||||
|
||||
// Methods to adapt the new schema to API v2 responses (mostly tasks inside the challenge model)
|
||||
// These will be removed once API v2 is discontinued
|
||||
|
||||
// Get all the tasks belonging to a challenge,
|
||||
schema.methods.getTasks = function getChallengeTasks () {
|
||||
let args = Array.from(arguments);
|
||||
let cb;
|
||||
let type;
|
||||
|
||||
if (args.length === 1) {
|
||||
cb = args[0];
|
||||
} else if (args.length > 1) {
|
||||
type = args[0];
|
||||
cb = args[1];
|
||||
} else {
|
||||
cb = function noop () {};
|
||||
}
|
||||
|
||||
let query = {
|
||||
userId: {
|
||||
$exists: false,
|
||||
},
|
||||
|
||||
'challenge.id': this._id,
|
||||
};
|
||||
|
||||
if (type) query.type = type;
|
||||
|
||||
return Tasks.Task.find(query, cb); // so we can use it as a promise
|
||||
};
|
||||
|
||||
// Given challenge and an array of tasks and one of members return an API compatible challenge + tasks obj + members
|
||||
schema.methods.addToChallenge = function addToChallenge (tasks, members) {
|
||||
let obj = this.toJSON();
|
||||
obj.members = members;
|
||||
|
||||
let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it
|
||||
|
||||
obj.habits = [];
|
||||
obj.dailys = [];
|
||||
obj.todos = [];
|
||||
obj.rewards = [];
|
||||
|
||||
obj.tasksOrder = undefined;
|
||||
let unordered = [];
|
||||
|
||||
tasks.forEach((task) => {
|
||||
// We want to push the task at the same position where it's stored in tasksOrder
|
||||
let pos = tasksOrder[`${task.type}s`].indexOf(task._id);
|
||||
if (pos === -1) { // Should never happen, it means the lists got out of sync
|
||||
unordered.push(task.toJSONV2());
|
||||
} else {
|
||||
obj[`${task.type}s`][pos] = task.toJSONV2();
|
||||
}
|
||||
});
|
||||
|
||||
// Reconcile unordered items
|
||||
unordered.forEach((task) => {
|
||||
obj[`${task.type}s`].push(task);
|
||||
});
|
||||
|
||||
// Remove null values that can be created when inserting tasks at an index > length
|
||||
['habits', 'dailys', 'rewards', 'todos'].forEach((type) => {
|
||||
obj[type] = _.compact(obj[type]);
|
||||
});
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
// Return the data maintaining backward compatibility
|
||||
schema.methods.getTransformedData = function getTransformedData (options) {
|
||||
let self = this;
|
||||
|
||||
let cb = options.cb;
|
||||
let populateMembers = options.populateMembers;
|
||||
|
||||
let queryMembers = {
|
||||
challenges: self._id,
|
||||
};
|
||||
|
||||
let selectDataMembers = '_id';
|
||||
|
||||
if (populateMembers) {
|
||||
selectDataMembers += ` ${populateMembers}`;
|
||||
}
|
||||
|
||||
let membersQuery = User.find(queryMembers).select(selectDataMembers);
|
||||
if (options.limitPopulation) membersQuery.limit(15);
|
||||
|
||||
Bluebird.all([
|
||||
membersQuery.exec(),
|
||||
self.getTasks(),
|
||||
])
|
||||
.then((results) => {
|
||||
cb(null, self.addToChallenge(results[1], results[0]));
|
||||
})
|
||||
.catch(cb);
|
||||
};
|
||||
|
||||
// END of API v2 methods
|
||||
|
||||
export let model = mongoose.model('Challenge', schema);
|
||||
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
import mongoose from 'mongoose';
|
||||
import _ from 'lodash';
|
||||
import shared from '../../../common';
|
||||
import couponCode from 'coupon-code';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
} from '../libs/api-v3/errors';
|
||||
|
||||
export let schema = new mongoose.Schema({
|
||||
_id: {type: String, default: couponCode.generate},
|
||||
event: {type: String, enum: ['wondercon', 'google_6mo']},
|
||||
user: {type: String, ref: 'User'},
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
timestamps: true,
|
||||
_id: false,
|
||||
});
|
||||
|
||||
schema.statics.generate = async function generateCoupons (event, count = 1) {
|
||||
let coupons = _.times(count, () => {
|
||||
return {event};
|
||||
});
|
||||
|
||||
return await this.create(coupons);
|
||||
};
|
||||
|
||||
schema.statics.apply = async function applyCoupon (user, req, code) {
|
||||
let coupon = await this.findById(couponCode.validate(code)).exec();
|
||||
if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon', req.language));
|
||||
if (coupon.user) throw new NotAuthorized(shared.i18n.t('couponUsed', req.language));
|
||||
|
||||
if (coupon.event === 'wondercon') {
|
||||
user.items.gear.owned.eyewear_special_wondercon_red = true;
|
||||
user.items.gear.owned.eyewear_special_wondercon_black = true;
|
||||
user.items.gear.owned.back_special_wondercon_black = true;
|
||||
user.items.gear.owned.back_special_wondercon_red = true;
|
||||
user.items.gear.owned.body_special_wondercon_red = true;
|
||||
user.items.gear.owned.body_special_wondercon_black = true;
|
||||
user.items.gear.owned.body_special_wondercon_gold = true;
|
||||
user.extra = {signupEvent: 'wondercon'};
|
||||
}
|
||||
|
||||
await user.save();
|
||||
coupon.user = user._id;
|
||||
await coupon.save();
|
||||
};
|
||||
|
||||
module.exports.schema = schema;
|
||||
export let model = mongoose.model('Coupon', schema);
|
||||
@@ -0,0 +1,24 @@
|
||||
import mongoose from 'mongoose';
|
||||
import validator from 'validator';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
|
||||
// A collection used to store mailing list unsubscription for non registered email addresses
|
||||
export let schema = new mongoose.Schema({
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
validator: [validator.isEmail, 'Invalid email.'],
|
||||
},
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
noSet: ['_id'],
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export let model = mongoose.model('EmailUnsubscription', schema);
|
||||
@@ -0,0 +1,760 @@
|
||||
import mongoose from 'mongoose';
|
||||
import {
|
||||
model as User,
|
||||
nameFields,
|
||||
} from './user';
|
||||
import shared from '../../../common';
|
||||
import _ from 'lodash';
|
||||
import { model as Challenge} from './challenge';
|
||||
import validator from 'validator';
|
||||
import { removeFromArray } from '../libs/api-v3/collectionManipulators';
|
||||
import {
|
||||
InternalServerError,
|
||||
BadRequest,
|
||||
} from '../libs/api-v3/errors';
|
||||
import * as firebase from '../libs/api-v2/firebase';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import { sendTxn as sendTxnEmail } from '../libs/api-v3/email';
|
||||
import Bluebird from 'bluebird';
|
||||
import nconf from 'nconf';
|
||||
import sendPushNotification from '../libs/api-v3/pushNotifications';
|
||||
|
||||
const questScrolls = shared.content.quests;
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
export const INVITES_LIMIT = 100;
|
||||
export const TAVERN_ID = shared.TAVERN_ID;
|
||||
|
||||
// NOTE once Firebase is enabled any change to groups' members in MongoDB will have to be run through the API
|
||||
// changes made directly to the db will cause Firebase to get out of sync
|
||||
export let schema = new Schema({
|
||||
name: {type: String, required: true},
|
||||
description: String,
|
||||
leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
|
||||
type: {type: String, enum: ['guild', 'party'], required: true},
|
||||
privacy: {type: String, enum: ['private', 'public'], default: 'private', required: true},
|
||||
chat: Array,
|
||||
/*
|
||||
# [{
|
||||
# timestamp: Date
|
||||
# user: String
|
||||
# text: String
|
||||
# contributor: String
|
||||
# uuid: String
|
||||
# id: String
|
||||
# }]
|
||||
*/
|
||||
leaderOnly: { // restrict group actions to leader (members can't do them)
|
||||
challenges: {type: Boolean, default: false, required: true},
|
||||
// invites: {type: Boolean, default: false, required: true},
|
||||
},
|
||||
memberCount: {type: Number, default: 1},
|
||||
challengeCount: {type: Number, default: 0},
|
||||
balance: {type: Number, default: 0},
|
||||
logo: String,
|
||||
leaderMessage: String,
|
||||
quest: {
|
||||
key: String,
|
||||
active: {type: Boolean, default: false},
|
||||
leader: {type: String, ref: 'User'},
|
||||
progress: {
|
||||
hp: Number,
|
||||
collect: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}}, // {feather: 5, ingot: 3}
|
||||
rage: Number, // limit break / "energy stored in shell", for explosion-attacks
|
||||
},
|
||||
|
||||
// Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click
|
||||
// 'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them.
|
||||
// TODO when booting user, remove from .joined and check again if we can now start the quest
|
||||
members: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
extra: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
},
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'challengeCount'],
|
||||
});
|
||||
|
||||
// A list of additional fields that cannot be updated (but can be set on creation)
|
||||
let noUpdate = ['privacy', 'type'];
|
||||
schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
|
||||
return this.sanitize(updateObj, noUpdate);
|
||||
};
|
||||
|
||||
// Basic fields to fetch for populating a group info
|
||||
export let basicFields = 'name type privacy';
|
||||
|
||||
schema.pre('remove', true, async function preRemoveGroup (next, done) {
|
||||
next();
|
||||
try {
|
||||
await this.removeGroupInvitations();
|
||||
done();
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
|
||||
schema.post('remove', function postRemoveGroup (group) {
|
||||
firebase.deleteGroup(group._id);
|
||||
});
|
||||
|
||||
schema.statics.getGroup = async function getGroup (options = {}) {
|
||||
let {user, groupId, fields, optionalMembership = false, populateLeader = false, requireMembership = false} = options;
|
||||
let query;
|
||||
|
||||
let isUserParty = groupId === 'party' || user.party._id === groupId;
|
||||
let isUserGuild = user.guilds.indexOf(groupId) !== -1;
|
||||
let isTavern = ['habitrpg', TAVERN_ID].indexOf(groupId) !== -1;
|
||||
|
||||
// When requireMembership is true check that user is member even in public guild
|
||||
if (requireMembership && !isUserParty && !isUserGuild && !isTavern) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// When optionalMembership is true it's not required for the user to be a member of the group
|
||||
if (isUserParty) {
|
||||
query = {type: 'party', _id: user.party._id};
|
||||
} else if (isTavern) {
|
||||
query = {_id: TAVERN_ID};
|
||||
} else if (optionalMembership === true) {
|
||||
query = {_id: groupId};
|
||||
} else if (isUserGuild) {
|
||||
query = {type: 'guild', _id: groupId};
|
||||
} else {
|
||||
query = {type: 'guild', privacy: 'public', _id: groupId};
|
||||
}
|
||||
|
||||
let mQuery = this.findOne(query);
|
||||
if (fields) mQuery.select(fields);
|
||||
if (populateLeader === true) mQuery.populate('leader', nameFields);
|
||||
let group = await mQuery.exec();
|
||||
return group;
|
||||
};
|
||||
|
||||
export const VALID_QUERY_TYPES = ['party', 'guilds', 'privateGuilds', 'publicGuilds', 'tavern'];
|
||||
|
||||
schema.statics.getGroups = async function getGroups (options = {}) {
|
||||
let {user, types, groupFields = basicFields, sort = '-memberCount', populateLeader = false} = options;
|
||||
let queries = [];
|
||||
|
||||
// Throw error if an invalid type is supplied
|
||||
let areValidTypes = types.every(type => VALID_QUERY_TYPES.indexOf(type) !== -1);
|
||||
if (!areValidTypes) throw new BadRequest(shared.i18n.t('groupTypesRequired'));
|
||||
|
||||
types.forEach(type => {
|
||||
switch (type) {
|
||||
case 'party': {
|
||||
queries.push(this.getGroup({user, groupId: 'party', fields: groupFields, populateLeader}));
|
||||
break;
|
||||
}
|
||||
case 'guilds': {
|
||||
let userGuildsQuery = this.find({
|
||||
type: 'guild',
|
||||
_id: {$in: user.guilds},
|
||||
}).select(groupFields);
|
||||
if (populateLeader === true) userGuildsQuery.populate('leader', nameFields);
|
||||
userGuildsQuery.sort(sort).exec();
|
||||
queries.push(userGuildsQuery);
|
||||
break;
|
||||
}
|
||||
case 'privateGuilds': {
|
||||
let privateGuildsQuery = this.find({
|
||||
type: 'guild',
|
||||
privacy: 'private',
|
||||
_id: {$in: user.guilds},
|
||||
}).select(groupFields);
|
||||
if (populateLeader === true) privateGuildsQuery.populate('leader', nameFields);
|
||||
privateGuildsQuery.sort(sort).exec();
|
||||
queries.push(privateGuildsQuery);
|
||||
break;
|
||||
}
|
||||
// NOTE: when returning publicGuilds we use `.lean()` so all mongoose methods won't be available.
|
||||
// Docs are going to be plain javascript objects
|
||||
case 'publicGuilds': {
|
||||
let publicGuildsQuery = this.find({
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
}).select(groupFields);
|
||||
if (populateLeader === true) publicGuildsQuery.populate('leader', nameFields);
|
||||
publicGuildsQuery.sort(sort).lean().exec();
|
||||
queries.push(publicGuildsQuery);
|
||||
break;
|
||||
}
|
||||
case 'tavern': {
|
||||
if (types.indexOf('publicGuilds') === -1) {
|
||||
queries.push(this.getGroup({user, groupId: TAVERN_ID, fields: groupFields}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let groupsArray = _.reduce(await Bluebird.all(queries), (previousValue, currentValue) => {
|
||||
if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array
|
||||
return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue
|
||||
}, []);
|
||||
|
||||
return groupsArray;
|
||||
};
|
||||
|
||||
// When converting to json remove chat messages with more than 1 flag and remove all flags info
|
||||
// unless the user is an admin
|
||||
// Not putting into toJSON because there we can't access user
|
||||
schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) {
|
||||
let toJSON = group.toJSON();
|
||||
if (!user.contributor.admin) {
|
||||
_.remove(toJSON.chat, chatMsg => {
|
||||
chatMsg.flags = {};
|
||||
return chatMsg.flagCount >= 2;
|
||||
});
|
||||
}
|
||||
return toJSON;
|
||||
};
|
||||
|
||||
schema.methods.removeGroupInvitations = async function removeGroupInvitations () {
|
||||
let group = this;
|
||||
|
||||
let usersToRemoveInvitationsFrom = await User.find({
|
||||
[`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id,
|
||||
}).exec();
|
||||
|
||||
let userUpdates = usersToRemoveInvitationsFrom.map(user => {
|
||||
if (group.type === 'party') {
|
||||
user.invitations.party = {};
|
||||
this.markModified('invitations.party');
|
||||
} else {
|
||||
removeFromArray(user.invitations.guilds, { id: group._id });
|
||||
}
|
||||
return user.save();
|
||||
});
|
||||
|
||||
return Bluebird.all(userUpdates);
|
||||
};
|
||||
|
||||
// Return true if user is a member of the group
|
||||
schema.methods.isMember = function isGroupMember (user) {
|
||||
if (this._id === TAVERN_ID) {
|
||||
return true; // everyone is considered part of the tavern
|
||||
} else if (this.type === 'party') {
|
||||
return user.party._id === this._id ? true : false;
|
||||
} else { // guilds
|
||||
return user.guilds.indexOf(this._id) !== -1;
|
||||
}
|
||||
};
|
||||
|
||||
export function chatDefaults (msg, user) {
|
||||
let message = {
|
||||
id: shared.uuid(),
|
||||
text: msg,
|
||||
timestamp: Number(new Date()),
|
||||
likes: {},
|
||||
flags: {},
|
||||
flagCount: 0,
|
||||
};
|
||||
|
||||
if (user) {
|
||||
_.defaults(message, {
|
||||
uuid: user._id,
|
||||
contributor: user.contributor && user.contributor.toObject(),
|
||||
backer: user.backer && user.backer.toObject(),
|
||||
user: user.profile.name,
|
||||
});
|
||||
} else {
|
||||
message.uuid = 'system';
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
const NO_CHAT_NOTIFICATIONS = [TAVERN_ID];
|
||||
schema.methods.sendChat = function sendChat (message, user) {
|
||||
this.chat.unshift(chatDefaults(message, user));
|
||||
this.chat.splice(200);
|
||||
|
||||
// Kick off chat notifications in the background.
|
||||
let lastSeenUpdate = {$set: {}, $inc: {_v: 1}};
|
||||
lastSeenUpdate.$set[`newMessages.${this._id}`] = {name: this.name, value: true};
|
||||
|
||||
// do not send notifications for guilds with more than 5000 users and for the tavern
|
||||
if (NO_CHAT_NOTIFICATIONS.indexOf(this._id) !== -1 || this.memberCount > 5000) {
|
||||
// TODO For Tavern, only notify them if their name was mentioned
|
||||
// var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names?
|
||||
// User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec();
|
||||
} else {
|
||||
let query = {};
|
||||
|
||||
if (this.type === 'party') {
|
||||
query['party._id'] = this._id;
|
||||
} else {
|
||||
query.guilds = this._id;
|
||||
}
|
||||
|
||||
query._id = { $ne: user ? user._id : ''};
|
||||
|
||||
User.update(query, lastSeenUpdate, {multi: true}).exec();
|
||||
}
|
||||
};
|
||||
|
||||
schema.methods.startQuest = async function startQuest (user) {
|
||||
// not using i18n strings because these errors are meant for devs who forgot to pass some parameters
|
||||
if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method');
|
||||
if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest');
|
||||
if (this.quest.active) throw new InternalServerError('Quest is already active');
|
||||
|
||||
let userIsParticipating = this.quest.members[user._id];
|
||||
let quest = questScrolls[this.quest.key];
|
||||
let collected = {};
|
||||
if (quest.collect) {
|
||||
collected = _.transform(quest.collect, (result, n, itemToCollect) => {
|
||||
result[itemToCollect] = 0;
|
||||
});
|
||||
}
|
||||
|
||||
this.markModified('quest');
|
||||
this.quest.active = true;
|
||||
if (quest.boss) {
|
||||
this.quest.progress.hp = quest.boss.hp;
|
||||
if (quest.boss.rage) this.quest.progress.rage = 0;
|
||||
} else if (quest.collect) {
|
||||
this.quest.progress.collect = collected;
|
||||
}
|
||||
|
||||
// Changes quest.members to only include participating members
|
||||
// TODO: is that important? What does it matter if the non-participating members
|
||||
// are still on the object?
|
||||
// TODO: is it important to run clean quest progress on non-members like we did in v2?
|
||||
this.quest.members = _.pick(this.quest.members, _.identity);
|
||||
let nonUserQuestMembers = _.keys(this.quest.members);
|
||||
removeFromArray(nonUserQuestMembers, user._id);
|
||||
|
||||
if (userIsParticipating) {
|
||||
user.party.quest.key = this.quest.key;
|
||||
user.party.quest.progress.down = 0;
|
||||
user.party.quest.progress.collect = collected;
|
||||
user.party.quest.completed = null;
|
||||
user.markModified('party.quest');
|
||||
}
|
||||
|
||||
// Remove the quest from the quest leader items (if they are the current user)
|
||||
if (this.quest.leader === user._id) {
|
||||
user.items.quests[this.quest.key] -= 1;
|
||||
user.markModified('items.quests');
|
||||
} else { // another user is starting the quest, update the leader separately
|
||||
await User.update({_id: this.quest.leader}, {
|
||||
$inc: {
|
||||
[`items.quests.${this.quest.key}`]: -1,
|
||||
},
|
||||
}).exec();
|
||||
}
|
||||
|
||||
// update the remaining users
|
||||
await User.update({
|
||||
_id: { $in: nonUserQuestMembers },
|
||||
}, {
|
||||
$set: {
|
||||
'party.quest.key': this.quest.key,
|
||||
'party.quest.progress.down': 0,
|
||||
'party.quest.progress.collect': collected,
|
||||
'party.quest.completed': null,
|
||||
},
|
||||
}, { multi: true }).exec();
|
||||
|
||||
// send notifications in the background without blocking
|
||||
User.find(
|
||||
{ _id: { $in: nonUserQuestMembers } },
|
||||
'party.quest items.quests auth.facebook auth.local preferences.emailNotifications pushDevices profile.name'
|
||||
).exec().then((membersToNotify) => {
|
||||
let membersToEmail = _.filter(membersToNotify, (member) => {
|
||||
// send push notifications and filter users that disabled emails
|
||||
sendPushNotification(member, 'HabitRPG', `${shared.i18n.t('questStarted')}: ${quest.text()}`);
|
||||
|
||||
return member.preferences.emailNotifications.questStarted !== false &&
|
||||
member._id !== user._id;
|
||||
});
|
||||
sendTxnEmail(membersToEmail, 'quest-started', [
|
||||
{ name: 'PARTY_URL', content: '/#/options/groups/party' },
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
// return a clean object for user.quest
|
||||
function _cleanQuestProgress (merge) {
|
||||
let clean = {
|
||||
key: null,
|
||||
progress: {
|
||||
up: 0,
|
||||
down: 0,
|
||||
collect: {},
|
||||
},
|
||||
completed: null,
|
||||
RSVPNeeded: false,
|
||||
};
|
||||
|
||||
if (merge) {
|
||||
_.merge(clean, _.omit(merge, 'progress'));
|
||||
if (merge.progress) _.merge(clean.progress, merge.progress);
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
schema.statics.cleanQuestProgress = _cleanQuestProgress;
|
||||
|
||||
// returns a clean object for group.quest
|
||||
schema.statics.cleanGroupQuest = function cleanGroupQuest () {
|
||||
return {
|
||||
key: null,
|
||||
active: false,
|
||||
leader: null,
|
||||
progress: {
|
||||
collect: {},
|
||||
},
|
||||
members: {},
|
||||
};
|
||||
};
|
||||
|
||||
// Participants: Grant rewards & achievements, finish quest
|
||||
// Returns the promise from update().exec()
|
||||
schema.methods.finishQuest = function finishQuest (quest) {
|
||||
let questK = quest.key;
|
||||
let updates = {$inc: {}, $set: {}};
|
||||
|
||||
updates.$inc[`achievements.quests.${questK}`] = 1;
|
||||
updates.$inc['stats.gp'] = Number(quest.drop.gp);
|
||||
updates.$inc['stats.exp'] = Number(quest.drop.exp);
|
||||
updates.$inc._v = 1;
|
||||
|
||||
if (this._id === TAVERN_ID) {
|
||||
updates.$set['party.quest.completed'] = questK; // Just show the notif
|
||||
} else {
|
||||
updates.$set['party.quest'] = _cleanQuestProgress({completed: questK}); // clear quest progress
|
||||
}
|
||||
|
||||
_.each(quest.drop.items, (item) => {
|
||||
let dropK = item.key;
|
||||
|
||||
switch (item.type) {
|
||||
case 'gear': {
|
||||
// TODO This means they can lose their new gear on death, is that what we want?
|
||||
updates.$set[`items.gear.owned.${dropK}`] = true;
|
||||
break;
|
||||
}
|
||||
case 'eggs':
|
||||
case 'food':
|
||||
case 'hatchingPotions':
|
||||
case 'quests': {
|
||||
updates.$inc[`items.${item.type}.${dropK}`] = _.where(quest.drop.items, {type: item.type, key: item.key}).length;
|
||||
break;
|
||||
}
|
||||
case 'pets': {
|
||||
updates.$set[`items.pets.${dropK}`] = 5;
|
||||
break;
|
||||
}
|
||||
case 'mounts': {
|
||||
updates.$set[`items.mounts.${dropK}`] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let q = this._id === TAVERN_ID ? {} : {_id: {$in: _.keys(this.quest.members)}};
|
||||
this.quest = {};
|
||||
this.markModified('quest');
|
||||
return User.update(q, updates, {multi: true}).exec();
|
||||
};
|
||||
|
||||
function _isOnQuest (user, progress, group) {
|
||||
return group && progress && group.quest && group.quest.active && group.quest.members[user._id] === true;
|
||||
}
|
||||
|
||||
// Returns a promise
|
||||
schema.statics.collectQuest = async function collectQuest (user, progress) {
|
||||
let group = await this.getGroup({user, groupId: 'party'});
|
||||
if (!_isOnQuest(user, progress, group)) return;
|
||||
let quest = shared.content.quests[group.quest.key];
|
||||
|
||||
_.each(progress.collect, (v, k) => {
|
||||
group.quest.progress.collect[k] += v;
|
||||
});
|
||||
|
||||
let foundText = _.reduce(progress.collect, (m, v, k) => {
|
||||
m.push(`${v} ${quest.collect[k].text('en')}`);
|
||||
return m;
|
||||
}, []);
|
||||
|
||||
foundText = foundText ? foundText.join(', ') : 'nothing';
|
||||
group.sendChat(`\`${user.profile.name} found ${foundText}.\``);
|
||||
group.markModified('quest.progress.collect');
|
||||
|
||||
// Still needs completing
|
||||
if (_.find(shared.content.quests[group.quest.key].collect, (v, k) => {
|
||||
return group.quest.progress.collect[k] < v.count;
|
||||
})) return group.save();
|
||||
|
||||
await group.finishQuest(quest);
|
||||
group.sendChat('`All items found! Party has received their rewards.`');
|
||||
return group.save();
|
||||
};
|
||||
|
||||
schema.statics.bossQuest = async function bossQuest (user, progress) {
|
||||
let group = await this.getGroup({user, groupId: 'party'});
|
||||
if (!_isOnQuest(user, progress, group)) return;
|
||||
|
||||
let quest = shared.content.quests[group.quest.key];
|
||||
if (!progress || !quest) return; // TODO why is this ever happening, progress should be defined at this point, log?
|
||||
|
||||
let down = progress.down * quest.boss.str; // multiply by boss strength
|
||||
|
||||
group.quest.progress.hp -= progress.up;
|
||||
// TODO Create a party preferred language option so emits like this can be localized. Suggestion: Always display the English version too. Or, if English is not displayed to the players, at least include it in a new field in the chat object that's visible in the database - essential for admins when troubleshooting quests!
|
||||
let playerAttack = `${user.profile.name} attacks ${quest.boss.name('en')} for ${progress.up.toFixed(1)} damage.`;
|
||||
let bossAttack = nconf.get('CRON_SAFE_MODE') === 'true' ? `${quest.boss.name('en')} did not attack the party because it was asleep while maintenance was happening.` : `${quest.boss.name('en')} attacks party for ${Math.abs(down).toFixed(1)} damage.`;
|
||||
group.sendChat(`\`${playerAttack}\` \`${bossAttack}\``);
|
||||
|
||||
// If boss has Rage, increment Rage as well
|
||||
if (quest.boss.rage) {
|
||||
group.quest.progress.rage += Math.abs(down);
|
||||
if (group.quest.progress.rage >= quest.boss.rage.value) {
|
||||
group.sendChat(quest.boss.rage.effect('en'));
|
||||
group.quest.progress.rage = 0;
|
||||
|
||||
// TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage
|
||||
if (quest.boss.rage.healing) group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing;
|
||||
if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp;
|
||||
}
|
||||
}
|
||||
|
||||
// Everyone takes damage
|
||||
await User.update({
|
||||
_id: {$in: _.keys(group.quest.members)},
|
||||
}, {
|
||||
$inc: {'stats.hp': down, _v: 1},
|
||||
}, {multi: true}).exec();
|
||||
// Apply changes the currently cronning user locally so we don't have to reload it to get the updated state
|
||||
// TODO how to mark not modified? https://github.com/Automattic/mongoose/pull/1167
|
||||
// must be notModified or otherwise could overwrite future changes: if the user is saved it'll save
|
||||
// the modified user.stats.hp but that must not happen as the hp value has already been updated by the User.update above
|
||||
// if (down) user.stats.hp += down;
|
||||
|
||||
// Boss slain, finish quest
|
||||
if (group.quest.progress.hp <= 0) {
|
||||
group.sendChat(`\`You defeated ${quest.boss.name('en')}! Questing party members receive the rewards of victory.\``);
|
||||
|
||||
// Participants: Grant rewards & achievements, finish quest
|
||||
await group.finishQuest(shared.content.quests[group.quest.key]);
|
||||
return group.save();
|
||||
}
|
||||
|
||||
return group.save();
|
||||
};
|
||||
|
||||
// to set a boss: `db.groups.update({_id:TAVERN_ID},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})`
|
||||
// we export an empty object that is then populated with the query-returned data
|
||||
export let tavernQuest = {};
|
||||
let tavernQ = {_id: TAVERN_ID, 'quest.key': {$ne: null}};
|
||||
|
||||
// we use process.nextTick because at this point the model is not yet available
|
||||
process.nextTick(() => {
|
||||
model // eslint-disable-line no-use-before-define
|
||||
.findOne(tavernQ).exec()
|
||||
.then(tavern => {
|
||||
if (!tavern) return; // No tavern quest
|
||||
|
||||
// Using _assign so we don't lose the reference to the exported tavernQuest
|
||||
_.assign(tavernQuest, tavern.quest.toObject());
|
||||
})
|
||||
.catch(err => {
|
||||
throw err;
|
||||
});
|
||||
});
|
||||
|
||||
// returns a promise
|
||||
schema.statics.tavernBoss = async function tavernBoss (user, progress) {
|
||||
if (!progress) return;
|
||||
|
||||
// hack: prevent crazy damage to world boss
|
||||
let dmg = Math.min(900, Math.abs(progress.up || 0));
|
||||
let rage = -Math.min(900, Math.abs(progress.down || 0));
|
||||
|
||||
let tavern = await this.findOne(tavernQ).exec();
|
||||
if (!(tavern && tavern.quest && tavern.quest.key)) return;
|
||||
|
||||
let quest = shared.content.quests[tavern.quest.key];
|
||||
|
||||
if (tavern.quest.progress.hp <= 0) {
|
||||
tavern.sendChat(quest.completionChat('en'));
|
||||
await tavern.finishQuest(quest);
|
||||
_.assign(tavernQuest, {extra: null});
|
||||
return tavern.save();
|
||||
} else {
|
||||
// Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database,
|
||||
// use those first - which allows us to update the boss on the go if things are too easy/hard.
|
||||
if (!tavern.quest.extra) tavern.quest.extra = {};
|
||||
tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def);
|
||||
tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str);
|
||||
|
||||
if (tavern.quest.progress.rage >= quest.boss.rage.value) {
|
||||
if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {};
|
||||
|
||||
let wd = tavern.quest.extra.worldDmg;
|
||||
// Burnout attacks Ian, Seasonal Sorceress, tavern
|
||||
// Be-Wilder attacks Alex, Matt, Bailey
|
||||
let scene = wd.market ? wd.stables ? wd.bailey ? false : 'bailey' : 'stables' : 'market'; // eslint-disable-line no-nested-ternary
|
||||
|
||||
if (!scene) {
|
||||
tavern.sendChat(`\`${quest.boss.name('en')} tries to unleash ${quest.boss.rage.title('en')} but is too tired.\``);
|
||||
tavern.quest.progress.rage = 0; // quest.boss.rage.value;
|
||||
} else {
|
||||
tavern.sendChat(quest.boss.rage[scene]('en'));
|
||||
tavern.quest.extra.worldDmg[scene] = true;
|
||||
tavern.quest.extra.worldDmg.recent = scene;
|
||||
tavern.markModified('quest.extra.worldDmg');
|
||||
tavern.quest.progress.rage = 0;
|
||||
if (quest.boss.rage.healing) {
|
||||
tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (quest.boss.desperation && tavern.quest.progress.hp < quest.boss.desperation.threshold && !tavern.quest.extra.desperate) {
|
||||
tavern.sendChat(quest.boss.desperation.text('en'));
|
||||
tavern.quest.extra.desperate = true;
|
||||
tavern.quest.extra.def = quest.boss.desperation.def;
|
||||
tavern.quest.extra.str = quest.boss.desperation.str;
|
||||
tavern.markModified('quest.extra');
|
||||
}
|
||||
|
||||
_.assign(tavernQuest, tavern.quest.toObject());
|
||||
return tavern.save();
|
||||
}
|
||||
};
|
||||
|
||||
schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
|
||||
let group = this;
|
||||
|
||||
let challenges = await Challenge.find({
|
||||
_id: {$in: user.challenges},
|
||||
group: group._id,
|
||||
});
|
||||
|
||||
let challengesToRemoveUserFrom = challenges.map(chal => {
|
||||
return chal.unlinkTasks(user, keep);
|
||||
});
|
||||
await Bluebird.all(challengesToRemoveUserFrom);
|
||||
|
||||
let promises = [];
|
||||
|
||||
// remove the group from the user's groups
|
||||
if (group.type === 'guild') {
|
||||
promises.push(User.update({_id: user._id}, {$pull: {guilds: group._id}}).exec());
|
||||
} else {
|
||||
promises.push(User.update({_id: user._id}, {$set: {party: {}}}).exec());
|
||||
}
|
||||
|
||||
// If user is the last one in group and group is private, delete it
|
||||
if (group.memberCount <= 1 && group.privacy === 'private') {
|
||||
promises.push(group.remove());
|
||||
} else { // otherwise If the leader is leaving (or if the leader previously left, and this wasn't accounted for)
|
||||
let update = {
|
||||
$inc: {memberCount: -1},
|
||||
};
|
||||
|
||||
if (group.leader === user._id) {
|
||||
let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id};
|
||||
query._id = {$ne: user._id};
|
||||
let seniorMember = await User.findOne(query).select('_id').exec();
|
||||
|
||||
// could be missing in case of public guild (that can have 0 members) with 1 member who is leaving
|
||||
if (seniorMember) update.$set = {leader: seniorMember._id};
|
||||
}
|
||||
promises.push(group.update(update).exec());
|
||||
}
|
||||
|
||||
firebase.removeUserFromGroup(group._id, user._id);
|
||||
|
||||
return await Bluebird.all(promises);
|
||||
};
|
||||
|
||||
// API v2 compatibility methods
|
||||
schema.methods.getTransformedData = function getTransformedData (options) {
|
||||
let cb = options.cb;
|
||||
let populateMembers = options.populateMembers;
|
||||
let populateInvites = options.populateInvites;
|
||||
let populateChallenges = options.populateChallenges;
|
||||
|
||||
let obj = this.toJSON();
|
||||
|
||||
let queryMembers = {};
|
||||
let queryInvites = {};
|
||||
|
||||
if (this.type === 'guild') {
|
||||
queryInvites['invitations.guilds.id'] = this._id;
|
||||
} else {
|
||||
queryInvites['invitations.party.id'] = this._id;
|
||||
}
|
||||
|
||||
if (this.type === 'guild') {
|
||||
queryMembers.guilds = this._id;
|
||||
} else {
|
||||
queryMembers['party._id'] = this._id;
|
||||
}
|
||||
|
||||
let selectDataMembers = '_id';
|
||||
let selectDataInvites = '_id';
|
||||
let selectDataChallenges = '_id';
|
||||
|
||||
if (populateMembers) {
|
||||
selectDataMembers += ` ${populateMembers}`;
|
||||
}
|
||||
if (populateInvites) {
|
||||
selectDataInvites += ` ${populateInvites}`;
|
||||
}
|
||||
if (populateChallenges) {
|
||||
selectDataChallenges += ` ${populateChallenges}`;
|
||||
}
|
||||
|
||||
let membersQuery = User.find(queryMembers).select(selectDataMembers);
|
||||
if (options.limitPopulation) membersQuery.limit(15);
|
||||
|
||||
Bluebird.all([
|
||||
membersQuery.exec(),
|
||||
User.find(queryInvites).select(populateInvites).exec(),
|
||||
Challenge.find({group: obj._id}).select(populateMembers).exec(),
|
||||
])
|
||||
.then((results) => {
|
||||
obj.members = results[0];
|
||||
obj.invites = results[1];
|
||||
obj.challenges = results[2];
|
||||
|
||||
cb(null, obj);
|
||||
})
|
||||
.catch(cb);
|
||||
};
|
||||
// END API v2 compatibility methods
|
||||
|
||||
export let model = mongoose.model('Group', schema);
|
||||
|
||||
// initialize tavern if !exists (fresh installs)
|
||||
// do not run when testing as it's handled by the tests and can easily cause a race condition
|
||||
if (!nconf.get('IS_TEST')) {
|
||||
model.count({_id: TAVERN_ID}, (err, ct) => {
|
||||
if (err) throw err;
|
||||
if (ct > 0) return;
|
||||
new model({ // eslint-disable-line babel/new-cap
|
||||
_id: TAVERN_ID,
|
||||
leader: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0', // Siena Leslie
|
||||
name: 'Tavern',
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
}).save();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import mongoose from 'mongoose';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import validator from 'validator';
|
||||
|
||||
let Schema = mongoose.Schema;
|
||||
|
||||
export let schema = new Schema({
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid,
|
||||
validate: [validator.isUUID, 'Invalid uuid.'],
|
||||
},
|
||||
name: {type: String, required: true},
|
||||
challenge: {type: String},
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
_id: false, // use id instead of _id
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
noSet: ['_id', 'id', 'challenge'],
|
||||
_id: false, // use id instead of _id
|
||||
});
|
||||
|
||||
export let model = mongoose.model('Tag', schema);
|
||||
@@ -0,0 +1,222 @@
|
||||
import mongoose from 'mongoose';
|
||||
import shared from '../../../common';
|
||||
import validator from 'validator';
|
||||
import moment from 'moment';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import _ from 'lodash';
|
||||
import { preenHistory } from '../libs/api-v3/preening';
|
||||
|
||||
let Schema = mongoose.Schema;
|
||||
let discriminatorOptions = {
|
||||
discriminatorKey: 'type', // the key that distinguishes task types
|
||||
};
|
||||
let subDiscriminatorOptions = _.defaults(_.cloneDeep(discriminatorOptions), {_id: false});
|
||||
|
||||
export let tasksTypes = ['habit', 'daily', 'todo', 'reward'];
|
||||
|
||||
// Important
|
||||
// When something changes here remember to update the client side model at common/script/libs/taskDefaults
|
||||
export let TaskSchema = new Schema({
|
||||
_legacyId: String, // TODO Remove when v2 is deprecated
|
||||
type: {type: String, enum: tasksTypes, required: true, default: tasksTypes[0]},
|
||||
text: {type: String, required: true},
|
||||
notes: {type: String, default: ''},
|
||||
tags: [{
|
||||
type: String,
|
||||
validate: [validator.isUUID, 'Invalid uuid.'],
|
||||
}],
|
||||
value: {type: Number, default: 0, required: true}, // redness or cost for rewards Required because it must be settable (for rewards)
|
||||
priority: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
required: true,
|
||||
validate: [
|
||||
(val) => [0.1, 1, 1.5, 2].indexOf(val) !== -1,
|
||||
'Valid priority values are 0.1, 1, 1.5, 2.',
|
||||
],
|
||||
},
|
||||
attribute: {type: String, default: 'str', enum: ['str', 'con', 'int', 'per']},
|
||||
userId: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set it belongs to a challenge
|
||||
|
||||
challenge: {
|
||||
id: {type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}, // When set (and userId not set) it's the original task
|
||||
taskId: {type: String, ref: 'Task', validate: [validator.isUUID, 'Invalid uuid.']}, // When not set but challenge.id defined it's the original task
|
||||
broken: {type: String, enum: ['CHALLENGE_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED', 'CHALLENGE_CLOSED', 'CHALLENGE_TASK_NOT_FOUND']}, // CHALLENGE_TASK_NOT_FOUND comes from v3 migration
|
||||
winner: String, // user.profile.name of the winner
|
||||
},
|
||||
|
||||
reminders: [{
|
||||
_id: false,
|
||||
id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], default: shared.uuid, required: true},
|
||||
startDate: {type: Date},
|
||||
time: {type: Date, required: true},
|
||||
}],
|
||||
}, _.defaults({
|
||||
minimize: true, // So empty objects are returned
|
||||
strict: true,
|
||||
}, discriminatorOptions));
|
||||
|
||||
TaskSchema.plugin(baseModel, {
|
||||
noSet: ['challenge', 'userId', 'completed', 'history', 'dateCompleted', '_legacyId'],
|
||||
sanitizeTransform (taskObj) {
|
||||
if (taskObj.type && taskObj.type !== 'reward') { // value should be settable directly only for rewards
|
||||
delete taskObj.value;
|
||||
}
|
||||
|
||||
return taskObj;
|
||||
},
|
||||
private: [],
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
// Sanitize user tasks linked to a challenge
|
||||
// See http://habitica.wikia.com/wiki/Challenges#Challenge_Participant.27s_Permissions for more info
|
||||
TaskSchema.statics.sanitizeUserChallengeTask = function sanitizeUserChallengeTask (taskObj) {
|
||||
let initialSanitization = this.sanitize(taskObj);
|
||||
|
||||
return _.pick(initialSanitization, ['streak', 'checklist', 'attribute', 'reminders', 'tags', 'notes']);
|
||||
};
|
||||
|
||||
// Sanitize checklist objects (disallowing id)
|
||||
TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) {
|
||||
delete checklistObj.id;
|
||||
return checklistObj;
|
||||
};
|
||||
|
||||
// Sanitize reminder objects (disallowing id)
|
||||
TaskSchema.statics.sanitizeReminder = function sanitizeReminder (reminderObj) {
|
||||
delete reminderObj.id;
|
||||
return reminderObj;
|
||||
};
|
||||
|
||||
TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta) {
|
||||
let chalTask = this;
|
||||
|
||||
chalTask.value += delta;
|
||||
|
||||
if (chalTask.type === 'habit' || chalTask.type === 'daily') {
|
||||
// Add only one history entry per day
|
||||
let lastChallengHistoryIndex = chalTask.history.length - 1;
|
||||
|
||||
if (chalTask.history[lastChallengHistoryIndex] &&
|
||||
moment(chalTask.history[lastChallengHistoryIndex].date).isSame(new Date(), 'day')) {
|
||||
chalTask.history[lastChallengHistoryIndex] = {
|
||||
date: Number(new Date()),
|
||||
value: chalTask.value,
|
||||
};
|
||||
chalTask.markModified(`history.${lastChallengHistoryIndex}`);
|
||||
} else {
|
||||
chalTask.history.push({
|
||||
date: Number(new Date()),
|
||||
value: chalTask.value,
|
||||
});
|
||||
|
||||
// Only preen task history once a day when the task is scored first
|
||||
if (chalTask.history.length > 365) {
|
||||
chalTask.history = preenHistory(chalTask.history, true); // true means the challenge will retain as much entries as a subscribed user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await chalTask.save();
|
||||
};
|
||||
|
||||
|
||||
// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model)
|
||||
// These will be removed once API v2 is discontinued
|
||||
|
||||
// toJSON for API v2
|
||||
TaskSchema.methods.toJSONV2 = function toJSONV2 () {
|
||||
let toJSON = this.toJSON();
|
||||
if (toJSON._legacyId) {
|
||||
toJSON.id = toJSON._legacyId;
|
||||
} else {
|
||||
toJSON.id = toJSON._id;
|
||||
}
|
||||
|
||||
if (!toJSON.challenge) toJSON.challenge = {};
|
||||
|
||||
let v3Tags = this.tags;
|
||||
|
||||
toJSON.tags = {};
|
||||
v3Tags.forEach(tag => {
|
||||
toJSON.tags[tag] = true;
|
||||
});
|
||||
|
||||
toJSON.dateCreated = this.createdAt;
|
||||
|
||||
return toJSON;
|
||||
};
|
||||
|
||||
TaskSchema.statics.fromJSONV2 = function fromJSONV2 (taskObj) {
|
||||
if (taskObj.id) taskObj._id = taskObj.id;
|
||||
|
||||
let v2Tags = taskObj.tags || {};
|
||||
|
||||
taskObj.tags = [];
|
||||
taskObj.tags = _.map(v2Tags, (tag, key) => key);
|
||||
|
||||
return taskObj;
|
||||
};
|
||||
|
||||
// END of API v2 methods
|
||||
|
||||
export let Task = mongoose.model('Task', TaskSchema);
|
||||
|
||||
// habits and dailies shared fields
|
||||
let habitDailySchema = () => {
|
||||
return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems
|
||||
};
|
||||
|
||||
// dailys and todos shared fields
|
||||
let dailyTodoSchema = () => {
|
||||
return {
|
||||
completed: {type: Boolean, default: false},
|
||||
// Checklist fields (dailies and todos)
|
||||
collapseChecklist: {type: Boolean, default: false},
|
||||
checklist: [{
|
||||
completed: {type: Boolean, default: false},
|
||||
text: {type: String, required: false, default: ''}, // required:false because it can be empty on creation
|
||||
_id: false,
|
||||
id: {type: String, default: shared.uuid, validate: [validator.isUUID, 'Invalid uuid.']},
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
export let HabitSchema = new Schema(_.defaults({
|
||||
up: {type: Boolean, default: true},
|
||||
down: {type: Boolean, default: true},
|
||||
}, habitDailySchema()), subDiscriminatorOptions);
|
||||
export let habit = Task.discriminator('habit', HabitSchema);
|
||||
|
||||
export let DailySchema = new Schema(_.defaults({
|
||||
frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly']},
|
||||
everyX: {type: Number, default: 1}, // e.g. once every X weeks
|
||||
startDate: {
|
||||
type: Date,
|
||||
default () {
|
||||
return moment().startOf('day').toDate();
|
||||
},
|
||||
},
|
||||
repeat: { // used only for 'weekly' frequency,
|
||||
m: {type: Boolean, default: true},
|
||||
t: {type: Boolean, default: true},
|
||||
w: {type: Boolean, default: true},
|
||||
th: {type: Boolean, default: true},
|
||||
f: {type: Boolean, default: true},
|
||||
s: {type: Boolean, default: true},
|
||||
su: {type: Boolean, default: true},
|
||||
},
|
||||
streak: {type: Number, default: 0},
|
||||
}, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions);
|
||||
export let daily = Task.discriminator('daily', DailySchema);
|
||||
|
||||
export let TodoSchema = new Schema(_.defaults({
|
||||
dateCompleted: Date,
|
||||
// TODO we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date see http://stackoverflow.com/questions/1353684/detecting-an-invalid-date-date-instance-in-javascript
|
||||
date: String, // due date for todos
|
||||
}, dailyTodoSchema()), subDiscriminatorOptions);
|
||||
export let todo = Task.discriminator('todo', TodoSchema);
|
||||
|
||||
export let RewardSchema = new Schema({}, subDiscriminatorOptions);
|
||||
export let reward = Task.discriminator('reward', RewardSchema);
|
||||
@@ -0,0 +1,824 @@
|
||||
import mongoose from 'mongoose';
|
||||
import shared from '../../../common';
|
||||
import _ from 'lodash';
|
||||
import validator from 'validator';
|
||||
import moment from 'moment';
|
||||
import * as Tasks from './task';
|
||||
import Bluebird from 'bluebird';
|
||||
import { schema as TagSchema } from './tag';
|
||||
import baseModel from '../libs/api-v3/baseModel';
|
||||
import {
|
||||
chatDefaults,
|
||||
TAVERN_ID,
|
||||
} from './group';
|
||||
import { defaults } from 'lodash';
|
||||
|
||||
let Schema = mongoose.Schema;
|
||||
|
||||
// User schema definition
|
||||
export let schema = new Schema({
|
||||
apiToken: {
|
||||
type: String,
|
||||
default: shared.uuid,
|
||||
},
|
||||
|
||||
auth: {
|
||||
blocked: Boolean,
|
||||
facebook: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
local: {
|
||||
email: {
|
||||
type: String,
|
||||
validate: [validator.isEmail, shared.i18n.t('invalidEmail')],
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
},
|
||||
// Store a lowercase version of username to check for duplicates
|
||||
lowerCaseUsername: String,
|
||||
hashed_password: String, // eslint-disable-line camelcase
|
||||
salt: String,
|
||||
},
|
||||
timestamps: {
|
||||
created: {type: Date, default: Date.now},
|
||||
loggedin: {type: Date, default: Date.now},
|
||||
},
|
||||
},
|
||||
// We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which
|
||||
// have been updated (http://goo.gl/gQLz41), but we want *every* update
|
||||
_v: { type: Number, default: 0 },
|
||||
migration: String,
|
||||
achievements: {
|
||||
originalUser: Boolean,
|
||||
habitSurveys: Number,
|
||||
ultimateGearSets: {
|
||||
healer: {type: Boolean, default: false},
|
||||
wizard: {type: Boolean, default: false},
|
||||
rogue: {type: Boolean, default: false},
|
||||
warrior: {type: Boolean, default: false},
|
||||
},
|
||||
beastMaster: Boolean,
|
||||
beastMasterCount: Number,
|
||||
mountMaster: Boolean,
|
||||
mountMasterCount: Number,
|
||||
triadBingo: Boolean,
|
||||
triadBingoCount: Number,
|
||||
veteran: Boolean,
|
||||
snowball: Number,
|
||||
spookySparkles: Number,
|
||||
shinySeed: Number,
|
||||
seafoam: Number,
|
||||
streak: Number,
|
||||
challenges: Array,
|
||||
quests: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
rebirths: Number,
|
||||
rebirthLevel: Number,
|
||||
perfect: {type: Number, default: 0},
|
||||
habitBirthdays: Number,
|
||||
valentine: Number,
|
||||
costumeContest: Boolean, // Superseded by costumeContests
|
||||
nye: Number,
|
||||
habiticaDays: Number,
|
||||
greeting: Number,
|
||||
thankyou: Number,
|
||||
costumeContests: Number,
|
||||
birthday: Number,
|
||||
partyUp: Boolean,
|
||||
partyOn: Boolean,
|
||||
},
|
||||
|
||||
backer: {
|
||||
tier: Number,
|
||||
npc: String,
|
||||
tokensApplied: Boolean,
|
||||
},
|
||||
|
||||
contributor: {
|
||||
// 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801
|
||||
level: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 9,
|
||||
},
|
||||
admin: Boolean,
|
||||
sudo: Boolean,
|
||||
// Artisan, Friend, Blacksmith, etc
|
||||
text: String,
|
||||
// a markdown textarea to list their contributions + links
|
||||
contributions: String,
|
||||
critical: String,
|
||||
},
|
||||
|
||||
balance: {type: Number, default: 0},
|
||||
// Not saved on the user right now
|
||||
filters: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
|
||||
purchased: {
|
||||
ads: {type: Boolean, default: false},
|
||||
// eg, {skeleton: true, pumpkin: true, eb052b: true}
|
||||
skin: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
hair: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
shirt: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
background: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
txnCount: {type: Number, default: 0},
|
||||
mobileChat: Boolean,
|
||||
plan: {
|
||||
planId: String,
|
||||
paymentMethod: String, // enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']}
|
||||
customerId: String, // Billing Agreement Id in case of Amazon Payments
|
||||
dateCreated: Date,
|
||||
dateTerminated: Date,
|
||||
dateUpdated: Date,
|
||||
extraMonths: {type: Number, default: 0},
|
||||
gemsBought: {type: Number, default: 0},
|
||||
mysteryItems: {type: Array, default: () => []},
|
||||
lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date
|
||||
consecutive: {
|
||||
count: {type: Number, default: 0},
|
||||
offset: {type: Number, default: 0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0
|
||||
gemCapExtra: {type: Number, default: 0},
|
||||
trinkets: {type: Number, default: 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
flags: {
|
||||
customizationsNotification: {type: Boolean, default: false},
|
||||
showTour: {type: Boolean, default: true},
|
||||
tour: {
|
||||
// -1 indicates "uninitiated", -2 means "complete", any other number is the current tour step (0-index)
|
||||
intro: {type: Number, default: -1},
|
||||
classes: {type: Number, default: -1},
|
||||
stats: {type: Number, default: -1},
|
||||
tavern: {type: Number, default: -1},
|
||||
party: {type: Number, default: -1},
|
||||
guilds: {type: Number, default: -1},
|
||||
challenges: {type: Number, default: -1},
|
||||
market: {type: Number, default: -1},
|
||||
pets: {type: Number, default: -1},
|
||||
mounts: {type: Number, default: -1},
|
||||
hall: {type: Number, default: -1},
|
||||
equipment: {type: Number, default: -1},
|
||||
},
|
||||
tutorial: {
|
||||
common: {
|
||||
habits: {type: Boolean, default: false},
|
||||
dailies: {type: Boolean, default: false},
|
||||
todos: {type: Boolean, default: false},
|
||||
rewards: {type: Boolean, default: false},
|
||||
party: {type: Boolean, default: false},
|
||||
pets: {type: Boolean, default: false},
|
||||
gems: {type: Boolean, default: false},
|
||||
skills: {type: Boolean, default: false},
|
||||
classes: {type: Boolean, default: false},
|
||||
tavern: {type: Boolean, default: false},
|
||||
equipment: {type: Boolean, default: false},
|
||||
items: {type: Boolean, default: false},
|
||||
},
|
||||
ios: {
|
||||
addTask: {type: Boolean, default: false},
|
||||
editTask: {type: Boolean, default: false},
|
||||
deleteTask: {type: Boolean, default: false},
|
||||
filterTask: {type: Boolean, default: false},
|
||||
groupPets: {type: Boolean, default: false},
|
||||
inviteParty: {type: Boolean, default: false},
|
||||
},
|
||||
},
|
||||
dropsEnabled: {type: Boolean, default: false},
|
||||
itemsEnabled: {type: Boolean, default: false},
|
||||
newStuff: {type: Boolean, default: false},
|
||||
rewrite: {type: Boolean, default: true},
|
||||
contributor: Boolean,
|
||||
classSelected: {type: Boolean, default: false},
|
||||
mathUpdates: Boolean,
|
||||
rebirthEnabled: {type: Boolean, default: false},
|
||||
levelDrops: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
chatRevoked: Boolean,
|
||||
// Used to track the status of recapture emails sent to each user,
|
||||
// can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user
|
||||
recaptureEmailsPhase: {type: Number, default: 0},
|
||||
// Needed to track the tip to send inside the email
|
||||
weeklyRecapEmailsPhase: {type: Number, default: 0},
|
||||
// Used to track when the next weekly recap should be sent
|
||||
lastWeeklyRecap: {type: Date, default: Date.now},
|
||||
// Used to enable weekly recap emails as users login
|
||||
lastWeeklyRecapDiscriminator: Boolean,
|
||||
communityGuidelinesAccepted: {type: Boolean, default: false},
|
||||
cronCount: {type: Number, default: 0},
|
||||
welcomed: {type: Boolean, default: false},
|
||||
armoireEnabled: {type: Boolean, default: false},
|
||||
armoireOpened: {type: Boolean, default: false},
|
||||
armoireEmpty: {type: Boolean, default: false},
|
||||
cardReceived: {type: Boolean, default: false},
|
||||
warnedLowHealth: {type: Boolean, default: false},
|
||||
},
|
||||
|
||||
history: {
|
||||
exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined
|
||||
todos: Array, // [{data: Date, value: Number}] // big peformance issues if these are defined
|
||||
},
|
||||
|
||||
items: {
|
||||
gear: {
|
||||
owned: _.transform(shared.content.gear.flat, (m, v) => {
|
||||
m[v.key] = {type: Boolean};
|
||||
if (v.key.match(/[armor|head|shield]_warrior_0/) || v.gearSet === 'glasses') {
|
||||
m[v.key].default = true;
|
||||
}
|
||||
}),
|
||||
|
||||
equipped: {
|
||||
weapon: String,
|
||||
armor: {type: String, default: 'armor_base_0'},
|
||||
head: {type: String, default: 'head_base_0'},
|
||||
shield: {type: String, default: 'shield_base_0'},
|
||||
back: String,
|
||||
headAccessory: String,
|
||||
eyewear: String,
|
||||
body: String,
|
||||
},
|
||||
costume: {
|
||||
weapon: String,
|
||||
armor: {type: String, default: 'armor_base_0'},
|
||||
head: {type: String, default: 'head_base_0'},
|
||||
shield: {type: String, default: 'shield_base_0'},
|
||||
back: String,
|
||||
headAccessory: String,
|
||||
eyewear: String,
|
||||
body: String,
|
||||
},
|
||||
},
|
||||
|
||||
special: {
|
||||
snowball: {type: Number, default: 0},
|
||||
spookySparkles: {type: Number, default: 0},
|
||||
shinySeed: {type: Number, default: 0},
|
||||
seafoam: {type: Number, default: 0},
|
||||
valentine: {type: Number, default: 0},
|
||||
valentineReceived: Array, // array of strings, by sender name
|
||||
nye: {type: Number, default: 0},
|
||||
nyeReceived: Array,
|
||||
greeting: {type: Number, default: 0},
|
||||
greetingReceived: Array,
|
||||
thankyou: {type: Number, default: 0},
|
||||
thankyouReceived: Array,
|
||||
birthday: {type: Number, default: 0},
|
||||
birthdayReceived: Array,
|
||||
},
|
||||
|
||||
// -------------- Animals -------------------
|
||||
// Complex bit here. The result looks like:
|
||||
// pets: {
|
||||
// 'Wolf-Desert': 0, // 0 means does not own
|
||||
// 'PandaCub-Red': 10, // Number represents "Growth Points"
|
||||
// etc...
|
||||
// }
|
||||
pets: _.defaults(
|
||||
// First transform to a 1D eggs/potions mapping
|
||||
_.transform(shared.content.pets, (m, v, k) => m[k] = Number),
|
||||
// Then add additional pets (quest, backer, contributor, premium)
|
||||
_.transform(shared.content.questPets, (m, v, k) => m[k] = Number),
|
||||
_.transform(shared.content.specialPets, (m, v, k) => m[k] = Number),
|
||||
_.transform(shared.content.premiumPets, (m, v, k) => m[k] = Number)
|
||||
),
|
||||
currentPet: String, // Cactus-Desert
|
||||
|
||||
// eggs: {
|
||||
// 'PandaCub': 0, // 0 indicates "doesn't own"
|
||||
// 'Wolf': 5 // Number indicates "stacking"
|
||||
// }
|
||||
eggs: _.transform(shared.content.eggs, (m, v, k) => m[k] = Number),
|
||||
|
||||
// hatchingPotions: {
|
||||
// 'Desert': 0, // 0 indicates "doesn't own"
|
||||
// 'CottonCandyBlue': 5 // Number indicates "stacking"
|
||||
// }
|
||||
hatchingPotions: _.transform(shared.content.hatchingPotions, (m, v, k) => m[k] = Number),
|
||||
|
||||
// Food: {
|
||||
// 'Watermelon': 0, // 0 indicates "doesn't own"
|
||||
// 'RottenMeat': 5 // Number indicates "stacking"
|
||||
// }
|
||||
food: _.transform(shared.content.food, (m, v, k) => m[k] = Number),
|
||||
|
||||
// mounts: {
|
||||
// 'Wolf-Desert': true,
|
||||
// 'PandaCub-Red': false,
|
||||
// etc...
|
||||
// }
|
||||
mounts: _.defaults(
|
||||
// First transform to a 1D eggs/potions mapping
|
||||
_.transform(shared.content.pets, (m, v, k) => m[k] = Boolean),
|
||||
// Then add quest and premium pets
|
||||
_.transform(shared.content.questPets, (m, v, k) => m[k] = Boolean),
|
||||
_.transform(shared.content.premiumPets, (m, v, k) => m[k] = Boolean),
|
||||
// Then add additional mounts (backer, contributor)
|
||||
_.transform(shared.content.specialMounts, (m, v, k) => m[k] = Boolean)
|
||||
),
|
||||
currentMount: String,
|
||||
|
||||
// Quests: {
|
||||
// 'boss_0': 0, // 0 indicates "doesn't own"
|
||||
// 'collection_honey': 5 // Number indicates "stacking"
|
||||
// }
|
||||
quests: _.transform(shared.content.quests, (m, v, k) => m[k] = Number),
|
||||
|
||||
lastDrop: {
|
||||
date: {type: Date, default: Date.now},
|
||||
count: {type: Number, default: 0},
|
||||
},
|
||||
},
|
||||
|
||||
lastCron: {type: Date, default: Date.now},
|
||||
|
||||
// {GROUP_ID: Boolean}, represents whether they have unseen chat messages
|
||||
newMessages: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
|
||||
challenges: [{type: String, ref: 'Challenge', validate: [validator.isUUID, 'Invalid uuid.']}],
|
||||
|
||||
invitations: {
|
||||
// Using an array without validation because otherwise mongoose treat this as a subdocument and applies _id by default
|
||||
// Schema is (id, name, inviter)
|
||||
// TODO one way to fix is http://mongoosejs.com/docs/guide.html#_id
|
||||
guilds: {type: Array, default: () => []},
|
||||
// Using a Mixed type because otherwise user.invitations.party = {} // to reset invitation, causes validation to fail TODO
|
||||
// schema is the same as for guild invitations (id, name, inviter)
|
||||
party: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
},
|
||||
|
||||
guilds: [{type: String, ref: 'Group', validate: [validator.isUUID, 'Invalid uuid.']}],
|
||||
|
||||
party: {
|
||||
_id: {type: String, validate: [validator.isUUID, 'Invalid uuid.'], ref: 'Group'},
|
||||
order: {type: String, default: 'level'},
|
||||
orderAscending: {type: String, default: 'ascending'},
|
||||
quest: {
|
||||
key: String,
|
||||
progress: {
|
||||
up: {type: Number, default: 0},
|
||||
down: {type: Number, default: 0},
|
||||
collect: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}}, // {feather:1, ingot:2}
|
||||
},
|
||||
completed: String, // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser
|
||||
RSVPNeeded: {type: Boolean, default: false}, // Set to true when invite is pending, set to false when quest invite is accepted or rejected, quest starts, or quest is cancelled
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
dayStart: {type: Number, default: 0, min: 0, max: 23},
|
||||
size: {type: String, enum: ['broad', 'slim'], default: 'slim'},
|
||||
hair: {
|
||||
color: {type: String, default: 'red'},
|
||||
base: {type: Number, default: 3},
|
||||
bangs: {type: Number, default: 1},
|
||||
beard: {type: Number, default: 0},
|
||||
mustache: {type: Number, default: 0},
|
||||
flower: {type: Number, default: 1},
|
||||
},
|
||||
hideHeader: {type: Boolean, default: false},
|
||||
skin: {type: String, default: '915533'},
|
||||
shirt: {type: String, default: 'blue'},
|
||||
timezoneOffset: {type: Number, default: 0},
|
||||
sound: {type: String, default: 'off', enum: ['off', 'danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme']},
|
||||
chair: {type: String, default: 'none'},
|
||||
timezoneOffsetAtLastCron: Number,
|
||||
language: String,
|
||||
automaticAllocation: Boolean,
|
||||
allocationMode: {type: String, enum: ['flat', 'classbased', 'taskbased'], default: 'flat'},
|
||||
autoEquip: {type: Boolean, default: true},
|
||||
costume: Boolean,
|
||||
dateFormat: {type: String, enum: ['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'], default: 'MM/dd/yyyy'},
|
||||
sleep: {type: Boolean, default: false},
|
||||
stickyHeader: {type: Boolean, default: true},
|
||||
disableClasses: {type: Boolean, default: false},
|
||||
newTaskEdit: {type: Boolean, default: false},
|
||||
dailyDueDefaultView: {type: Boolean, default: false},
|
||||
tagsCollapsed: {type: Boolean, default: false},
|
||||
advancedCollapsed: {type: Boolean, default: false},
|
||||
toolbarCollapsed: {type: Boolean, default: false},
|
||||
reverseChatOrder: {type: Boolean, default: false},
|
||||
background: String,
|
||||
displayInviteToPartyWhenPartyIs1: {type: Boolean, default: true},
|
||||
webhooks: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
// For the following fields make sure to use strict comparison when searching for falsey values (=== false)
|
||||
// As users who didn't login after these were introduced may have them undefined/null
|
||||
emailNotifications: {
|
||||
unsubscribeFromAll: {type: Boolean, default: false},
|
||||
newPM: {type: Boolean, default: true},
|
||||
kickedGroup: {type: Boolean, default: true},
|
||||
wonChallenge: {type: Boolean, default: true},
|
||||
giftedGems: {type: Boolean, default: true},
|
||||
giftedSubscription: {type: Boolean, default: true},
|
||||
invitedParty: {type: Boolean, default: true},
|
||||
invitedGuild: {type: Boolean, default: true},
|
||||
questStarted: {type: Boolean, default: true},
|
||||
invitedQuest: {type: Boolean, default: true},
|
||||
// remindersToLogin: {type: Boolean, default: true},
|
||||
// importantAnnouncements are in fact the recapture emails
|
||||
importantAnnouncements: {type: Boolean, default: true},
|
||||
weeklyRecaps: {type: Boolean, default: true},
|
||||
},
|
||||
suppressModals: {
|
||||
levelUp: {type: Boolean, default: false},
|
||||
hatchPet: {type: Boolean, default: false},
|
||||
raisePet: {type: Boolean, default: false},
|
||||
streak: {type: Boolean, default: false},
|
||||
},
|
||||
improvementCategories: {
|
||||
type: Array,
|
||||
validate: (categories) => {
|
||||
const validCategories = ['work', 'exercise', 'healthWellness', 'school', 'teams', 'chores', 'creativity'];
|
||||
let isValidCategory = categories.every(category => validCategories.indexOf(category) !== -1);
|
||||
return isValidCategory;
|
||||
},
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
blurb: String,
|
||||
imageUrl: String,
|
||||
name: String,
|
||||
},
|
||||
stats: {
|
||||
hp: {type: Number, default: shared.maxHealth},
|
||||
mp: {type: Number, default: 10},
|
||||
exp: {type: Number, default: 0},
|
||||
gp: {type: Number, default: 0},
|
||||
lvl: {type: Number, default: 1},
|
||||
|
||||
// Class System
|
||||
class: {type: String, enum: ['warrior', 'rogue', 'wizard', 'healer'], default: 'warrior', required: true},
|
||||
points: {type: Number, default: 0},
|
||||
str: {type: Number, default: 0},
|
||||
con: {type: Number, default: 0},
|
||||
int: {type: Number, default: 0},
|
||||
per: {type: Number, default: 0},
|
||||
buffs: {
|
||||
str: {type: Number, default: 0},
|
||||
int: {type: Number, default: 0},
|
||||
per: {type: Number, default: 0},
|
||||
con: {type: Number, default: 0},
|
||||
stealth: {type: Number, default: 0},
|
||||
streaks: {type: Boolean, default: false},
|
||||
snowball: {type: Boolean, default: false},
|
||||
spookySparkles: {type: Boolean, default: false},
|
||||
shinySeed: {type: Boolean, default: false},
|
||||
seafoam: {type: Boolean, default: false},
|
||||
},
|
||||
training: {
|
||||
int: {type: Number, default: 0},
|
||||
per: {type: Number, default: 0},
|
||||
str: {type: Number, default: 0},
|
||||
con: {type: Number, default: 0},
|
||||
},
|
||||
},
|
||||
|
||||
tags: [TagSchema],
|
||||
|
||||
inbox: {
|
||||
newMessages: {type: Number, default: 0},
|
||||
blocks: {type: Array, default: () => []},
|
||||
messages: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
optOut: {type: Boolean, default: false},
|
||||
},
|
||||
tasksOrder: {
|
||||
habits: [{type: String, ref: 'Task'}],
|
||||
dailys: [{type: String, ref: 'Task'}],
|
||||
todos: [{type: String, ref: 'Task'}],
|
||||
rewards: [{type: String, ref: 'Task'}],
|
||||
},
|
||||
extra: {type: Schema.Types.Mixed, default: () => {
|
||||
return {};
|
||||
}},
|
||||
pushDevices: {
|
||||
type: [{
|
||||
regId: {type: String},
|
||||
type: {type: String},
|
||||
}],
|
||||
default: () => [],
|
||||
},
|
||||
}, {
|
||||
strict: true,
|
||||
minimize: false, // So empty objects are returned
|
||||
});
|
||||
|
||||
schema.plugin(baseModel, {
|
||||
// noSet is not used as updating uses a whitelist and creating only accepts specific params (password, email, username, ...)
|
||||
noSet: [],
|
||||
private: ['auth.local.hashed_password', 'auth.local.salt'],
|
||||
toJSONTransform: function userToJSON (plainObj, originalDoc) {
|
||||
// plainObj.filters = {}; // TODO Not saved, remove?
|
||||
plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs
|
||||
|
||||
return plainObj;
|
||||
},
|
||||
});
|
||||
|
||||
// A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private)
|
||||
export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt
|
||||
preferences.costume preferences.sleep preferences.background profile stats achievements party
|
||||
backer contributor auth.timestamps items`;
|
||||
|
||||
// The minimum amount of data needed when populating multiple users
|
||||
export let nameFields = 'profile.name';
|
||||
|
||||
schema.post('init', function postInitUser (doc) {
|
||||
shared.wrap(doc);
|
||||
});
|
||||
|
||||
function _populateDefaultTasks (user, taskTypes) {
|
||||
let tagsI = taskTypes.indexOf('tag');
|
||||
|
||||
if (tagsI !== -1) {
|
||||
user.tags = _.map(shared.content.userDefaults.tags, (tag) => {
|
||||
let newTag = _.cloneDeep(tag);
|
||||
|
||||
// tasks automatically get _id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here
|
||||
newTag.id = shared.uuid();
|
||||
// Render tag's name in user's language
|
||||
newTag.name = newTag.name(user.preferences.language);
|
||||
return newTag;
|
||||
});
|
||||
}
|
||||
|
||||
let tasksToCreate = [];
|
||||
|
||||
if (tagsI !== -1) {
|
||||
taskTypes = _.clone(taskTypes);
|
||||
taskTypes.splice(tagsI, 1);
|
||||
}
|
||||
|
||||
_.each(taskTypes, (taskType) => {
|
||||
let tasksOfType = _.map(shared.content.userDefaults[`${taskType}s`], (taskDefaults) => {
|
||||
let newTask = new Tasks[taskType](taskDefaults);
|
||||
|
||||
newTask.userId = user._id;
|
||||
newTask.text = taskDefaults.text(user.preferences.language);
|
||||
if (newTask.notes) newTask.notes = taskDefaults.notes(user.preferences.language);
|
||||
if (taskDefaults.checklist) {
|
||||
newTask.checklist = _.map(taskDefaults.checklist, (checklistItem) => {
|
||||
checklistItem.text = checklistItem.text(user.preferences.language);
|
||||
return checklistItem;
|
||||
});
|
||||
}
|
||||
|
||||
return newTask.save();
|
||||
});
|
||||
|
||||
tasksToCreate.push(...tasksOfType);
|
||||
});
|
||||
|
||||
return Bluebird.all(tasksToCreate)
|
||||
.then((tasksCreated) => {
|
||||
_.each(tasksCreated, (task) => {
|
||||
user.tasksOrder[`${task.type}s`].push(task._id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _populateDefaultsForNewUser (user) {
|
||||
let taskTypes;
|
||||
let iterableFlags = user.flags.toObject();
|
||||
|
||||
if (user.registeredThrough === 'habitica-web' || user.registeredThrough === 'habitica-android') {
|
||||
taskTypes = ['habit', 'daily', 'todo', 'reward', 'tag'];
|
||||
|
||||
_.each(iterableFlags.tutorial.common, (val, section) => {
|
||||
user.flags.tutorial.common[section] = true;
|
||||
});
|
||||
} else {
|
||||
taskTypes = ['todo', 'tag'];
|
||||
user.flags.showTour = false;
|
||||
|
||||
_.each(iterableFlags.tour, (val, section) => {
|
||||
user.flags.tour[section] = -2;
|
||||
});
|
||||
}
|
||||
|
||||
return _populateDefaultTasks(user, taskTypes);
|
||||
}
|
||||
|
||||
function _setProfileName (user) {
|
||||
let fb = user.auth.facebook;
|
||||
|
||||
let localUsername = user.auth.local && user.auth.local.username;
|
||||
let facebookUsername = fb && (fb.displayName || fb.name || fb.username || `${fb.first_name && fb.first_name} ${fb.last_name}`);
|
||||
let anonymous = 'Anonymous';
|
||||
|
||||
return localUsername || facebookUsername || anonymous;
|
||||
}
|
||||
|
||||
schema.pre('save', true, function preSaveUser (next, done) {
|
||||
next();
|
||||
|
||||
if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) {
|
||||
this.preferences.dayStart = 0;
|
||||
}
|
||||
|
||||
if (!this.profile.name) {
|
||||
this.profile.name = _setProfileName(this);
|
||||
}
|
||||
|
||||
// Determines if Beast Master should be awarded
|
||||
let beastMasterProgress = shared.count.beastMasterProgress(this.items.pets);
|
||||
|
||||
if (beastMasterProgress >= 90 || this.achievements.beastMasterCount > 0) {
|
||||
this.achievements.beastMaster = true;
|
||||
}
|
||||
|
||||
// Determines if Mount Master should be awarded
|
||||
let mountMasterProgress = shared.count.mountMasterProgress(this.items.mounts);
|
||||
|
||||
if (mountMasterProgress >= 90 || this.achievements.mountMasterCount > 0) {
|
||||
this.achievements.mountMaster = true;
|
||||
}
|
||||
|
||||
// Determines if Triad Bingo should be awarded
|
||||
|
||||
let dropPetCount = shared.count.dropPetsCurrentlyOwned(this.items.pets);
|
||||
let qualifiesForTriad = dropPetCount >= 90 && mountMasterProgress >= 90;
|
||||
|
||||
if (qualifiesForTriad || this.achievements.triadBingoCount > 0) {
|
||||
this.achievements.triadBingo = true;
|
||||
}
|
||||
|
||||
// Enable weekly recap emails for old users who sign in
|
||||
if (this.flags.lastWeeklyRecapDiscriminator) {
|
||||
// Enable weekly recap emails in 24 hours
|
||||
this.flags.lastWeeklyRecap = moment().subtract(6, 'days').toDate();
|
||||
// Unset the field so this is run only once
|
||||
this.flags.lastWeeklyRecapDiscriminator = undefined;
|
||||
}
|
||||
|
||||
// EXAMPLE CODE for allowing all existing and new players to be
|
||||
// automatically granted an item during a certain time period:
|
||||
// if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01'))
|
||||
// this.items.pets['JackOLantern-Base'] = 5;
|
||||
|
||||
// our own version incrementer
|
||||
if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0;
|
||||
this._v++;
|
||||
|
||||
// Populate new users with default content
|
||||
if (this.isNew) {
|
||||
_populateDefaultsForNewUser(this)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
schema.pre('update', function preUpdateUser () {
|
||||
this.update({}, {$inc: {_v: 1}});
|
||||
});
|
||||
|
||||
schema.methods.isSubscribed = function isSubscribed () {
|
||||
return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion
|
||||
};
|
||||
|
||||
// Get an array of groups ids the user is member of
|
||||
schema.methods.getGroups = function getUserGroups () {
|
||||
let userGroups = this.guilds.slice(0); // clone user.guilds so we don't modify the original
|
||||
if (this.party._id) userGroups.push(this.party._id);
|
||||
userGroups.push(TAVERN_ID);
|
||||
return userGroups;
|
||||
};
|
||||
|
||||
schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, message) {
|
||||
let sender = this;
|
||||
|
||||
shared.refPush(userToReceiveMessage.inbox.messages, chatDefaults(message, sender));
|
||||
userToReceiveMessage.inbox.newMessages++;
|
||||
userToReceiveMessage._v++;
|
||||
userToReceiveMessage.markModified('inbox.messages');
|
||||
|
||||
shared.refPush(sender.inbox.messages, defaults({sent: true}, chatDefaults(message, userToReceiveMessage)));
|
||||
sender.markModified('inbox.messages');
|
||||
|
||||
let promises = [userToReceiveMessage.save(), sender.save()];
|
||||
await Bluebird.all(promises);
|
||||
};
|
||||
|
||||
// Methods to adapt the new schema to API v2 responses (mostly tasks inside the user model)
|
||||
// These will be removed once API v2 is discontinued
|
||||
|
||||
// Get all the tasks belonging to a user,
|
||||
schema.methods.getTasks = function getUserTasks () {
|
||||
let args = Array.from(arguments);
|
||||
let cb;
|
||||
let type;
|
||||
|
||||
if (args.length === 1) {
|
||||
cb = args[0];
|
||||
} else {
|
||||
type = args[0];
|
||||
cb = args[1];
|
||||
}
|
||||
|
||||
let query = {
|
||||
userId: this._id,
|
||||
};
|
||||
|
||||
if (type) query.type = type;
|
||||
|
||||
Tasks.Task.find(query, cb);
|
||||
};
|
||||
|
||||
// Given user and an array of tasks, return an API compatible user + tasks obj
|
||||
schema.methods.addTasksToUser = function addTasksToUser (tasks) {
|
||||
let obj = this.toJSON();
|
||||
|
||||
obj.id = obj._id;
|
||||
obj.filters = {};
|
||||
|
||||
obj.tags = obj.tags.map(tag => {
|
||||
return {
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
challenge: tag.challenge,
|
||||
};
|
||||
});
|
||||
|
||||
let tasksOrder = obj.tasksOrder; // Saving a reference because we won't return it
|
||||
|
||||
obj.habits = [];
|
||||
obj.dailys = [];
|
||||
obj.todos = [];
|
||||
obj.rewards = [];
|
||||
|
||||
obj.tasksOrder = undefined;
|
||||
let unordered = [];
|
||||
|
||||
tasks.forEach((task) => {
|
||||
// We want to push the task at the same position where it's stored in tasksOrder
|
||||
let pos = tasksOrder[`${task.type}s`].indexOf(task._id);
|
||||
if (pos === -1) { // Should never happen, it means the lists got out of sync
|
||||
unordered.push(task.toJSONV2());
|
||||
} else {
|
||||
obj[`${task.type}s`][pos] = task.toJSONV2();
|
||||
}
|
||||
});
|
||||
|
||||
// Reconcile unordered items
|
||||
unordered.forEach((task) => {
|
||||
obj[`${task.type}s`].push(task);
|
||||
});
|
||||
|
||||
// Remove null values that can be created when inserting tasks at an index > length
|
||||
['habits', 'dailys', 'rewards', 'todos'].forEach((type) => {
|
||||
obj[type] = _.compact(obj[type]);
|
||||
});
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
// Return the data maintaining backward compatibility
|
||||
schema.methods.getTransformedData = function getTransformedData (cb) {
|
||||
let self = this;
|
||||
this.getTasks((err, tasks) => {
|
||||
if (err) return cb(err);
|
||||
cb(null, self.addTasksToUser(tasks));
|
||||
});
|
||||
};
|
||||
|
||||
// END of API v2 methods
|
||||
export let model = mongoose.model('User', schema);
|
||||
|
||||
// Initially export an empty object so external requires will get
|
||||
// the right object by reference when it's defined later
|
||||
// Otherwise it would remain undefined if requested before the query executes
|
||||
export let mods = [];
|
||||
|
||||
mongoose.model('User')
|
||||
.find({'contributor.admin': true})
|
||||
.sort('-contributor.level -backer.npc profile.name')
|
||||
.select('profile contributor backer')
|
||||
.exec()
|
||||
.then((foundMods) => {
|
||||
// Using push to maintain the reference to mods
|
||||
mods.push(...foundMods);
|
||||
}); // In case of failure we don't want this to crash the whole server
|
||||
@@ -0,0 +1,21 @@
|
||||
var auth = require('../../controllers/api-v2/auth');
|
||||
var express = require('express');
|
||||
var i18n = require('../../libs/api-v2/i18n');
|
||||
var router = express.Router();
|
||||
import {
|
||||
getUserLanguage
|
||||
} from '../../middlewares/api-v3/language';
|
||||
|
||||
/* auth.auth*/
|
||||
// auth.setupPassport(router); //TODO make this consistent with the others
|
||||
router.post('/register', getUserLanguage, auth.registerUser);
|
||||
router.post('/user/auth/local', getUserLanguage, auth.loginLocal);
|
||||
router.post('/user/auth/social', getUserLanguage, auth.loginSocial);
|
||||
router.delete('/user/auth/social', getUserLanguage, auth.auth, auth.deleteSocial);
|
||||
router.post('/user/reset-password', getUserLanguage, auth.resetPassword);
|
||||
router.post('/user/change-password', getUserLanguage, auth.auth, auth.changePassword);
|
||||
router.post('/user/change-username', getUserLanguage, auth.auth, auth.changeUsername);
|
||||
router.post('/user/change-email', getUserLanguage, auth.auth, auth.changeEmail);
|
||||
// router.post('/user/auth/firebase', i18n.getUserLanguage, auth.auth, auth.getFirebaseToken);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,15 @@
|
||||
var nconf = require('nconf');
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var auth = require('../../controllers/api-v2/auth');
|
||||
var coupon = require('../../controllers/api-v2/coupon');
|
||||
var i18n = require('../../libs/api-v2/i18n');
|
||||
import {
|
||||
getUserLanguage
|
||||
} from '../../middlewares/api-v3/language';
|
||||
|
||||
router.get('/coupons', auth.authWithUrl, getUserLanguage, coupon.ensureAdmin, coupon.getCoupons);
|
||||
router.post('/coupons/generate/:event', auth.auth, getUserLanguage, coupon.ensureAdmin, coupon.generateCoupons);
|
||||
router.post('/user/coupon/:code', auth.auth, getUserLanguage, coupon.enterCode);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,778 @@
|
||||
/*
|
||||
---------- /api/v2 API ------------
|
||||
see https://github.com/wordnik/swagger-node-express
|
||||
Every url added to router is prefaced by /api/v2
|
||||
Note: Many user-route ops exist in ../../common/script/index.js#user.ops, so that they can (1) be called both
|
||||
client and server.
|
||||
v1 user. Requires x-api-user (user id) and x-api-key (api key) headers, Test with:
|
||||
*/
|
||||
|
||||
var user = require("../../controllers/api-v2/user");
|
||||
var groups = require("../../controllers/api-v2/groups");
|
||||
var members = require("../../controllers/api-v2/members");
|
||||
var auth = require("../../controllers/api-v2/auth");
|
||||
var hall = require("../../controllers/api-v2/hall");
|
||||
var challenges = require("../../controllers/api-v2/challenges");
|
||||
var dataexport = require("../../controllers/api-v2/dataexport");
|
||||
var nconf = require("nconf");
|
||||
var cron = user.cron;
|
||||
var _ = require('lodash');
|
||||
var content = require('../../../../common').content;
|
||||
var i18n = require('../../libs/api-v2/i18n');
|
||||
import {
|
||||
getUserLanguage
|
||||
} from '../../middlewares/api-v3/language';
|
||||
var forceRefresh = require('../../middlewares/forceRefresh').middleware;
|
||||
|
||||
module.exports = function(swagger, v2) {
|
||||
var path = swagger.pathParam;
|
||||
var body = swagger.bodyParam;
|
||||
var query = swagger.queryParam;
|
||||
|
||||
swagger.setAppHandler(v2);
|
||||
swagger.setErrorHandler("next");
|
||||
swagger.setHeaders = function() {};
|
||||
swagger.configureSwaggerPaths("", "/api-docs", "");
|
||||
|
||||
var api = {
|
||||
'/status': {
|
||||
spec: {
|
||||
description: "Returns the status of the server (up or down). Does not require authentication."
|
||||
},
|
||||
action: function(req, res) {
|
||||
return res.json({
|
||||
status: "up"
|
||||
});
|
||||
}
|
||||
},
|
||||
'/content': {
|
||||
spec: {
|
||||
description: "Get all available content objects. This is essential, since Habit often depends on item keys (eg, when purchasing a weapon). Does not require authentication.",
|
||||
parameters: [query("language", "Optional language to use for content's strings. Default is english.", "string")]
|
||||
},
|
||||
action: user.getContent
|
||||
},
|
||||
'/content/paths': {
|
||||
spec: {
|
||||
description: "Show user model tree. Does not require authentication."
|
||||
},
|
||||
action: user.getModelPaths
|
||||
},
|
||||
"/export/history": {
|
||||
spec: {
|
||||
description: "Export user history",
|
||||
method: 'GET'
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: dataexport.history
|
||||
},
|
||||
"/user/tasks/{id}/{direction}": {
|
||||
spec: {
|
||||
description: "Simple scoring of a task (Habit, Daily, To-Do, or Reward). This is most-likely the only API route you'll be using as a 3rd-party developer. The most common operation is for the user to gain or lose points based on some action (browsing Reddit, running a mile, 1 Pomodor, etc). Call this route, if the task you're trying to score doesn't exist, it will be created for you. When random events occur, the <b>user._tmp</b> variable will be filled. Critical hits can be accessed through <b>user._tmp.crit</b>. The Streakbonus can be accessed through <b>user._tmp.streakBonus</b>. Both will contain the multiplier value. When random drops occur, the following values are available: <b>user._tmp.drop = {text,type,dialog,value,key,notes}</b>",
|
||||
parameters: [path("id", "ID of the task to score. If this task doesn't exist, a task will be created automatically", "string"), path("direction", "Either 'up' or 'down'", "string"), body('', "If you're creating a 3rd-party task, pass up any task attributes in the body (see TaskSchema).", 'object')],
|
||||
method: 'POST'
|
||||
},
|
||||
action: user.score
|
||||
},
|
||||
"/user/tasks:GET": {
|
||||
spec: {
|
||||
path: '/user/tasks',
|
||||
description: "Get all user's tasks"
|
||||
},
|
||||
action: user.getTasks
|
||||
},
|
||||
"/user/tasks:POST": {
|
||||
spec: {
|
||||
path: '/user/tasks',
|
||||
description: "Create a task",
|
||||
method: 'POST',
|
||||
parameters: [body("", "Send up the whole task (see TaskSchema)", "object")]
|
||||
},
|
||||
action: user.addTask
|
||||
},
|
||||
"/user/tasks/{id}:GET": {
|
||||
spec: {
|
||||
path: '/user/tasks/{id}',
|
||||
description: "Get an individual task",
|
||||
parameters: [path("id", "Task ID", "string")]
|
||||
},
|
||||
action: user.getTask
|
||||
},
|
||||
"/user/tasks/{id}:PUT": {
|
||||
spec: {
|
||||
path: '/user/tasks/{id}',
|
||||
description: "Update a user's task",
|
||||
method: 'PUT',
|
||||
parameters: [path("id", "Task ID", "string"), body("", "Send up the whole task (see TaskSchema)", "object")]
|
||||
},
|
||||
action: user.updateTask
|
||||
},
|
||||
"/user/tasks/{id}:DELETE": {
|
||||
spec: {
|
||||
path: '/user/tasks/{id}',
|
||||
description: "Delete a task",
|
||||
method: 'DELETE',
|
||||
parameters: [path("id", "Task ID", "string")]
|
||||
},
|
||||
action: user.deleteTask
|
||||
},
|
||||
"/user/tasks/{id}/sort": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Sort tasks',
|
||||
parameters: [path("id", "Task ID", "string"), query("from", "Index where you're sorting from (0-based)", "integer"), query("to", "Index where you're sorting to (0-based)", "integer")]
|
||||
},
|
||||
action: user.sortTask
|
||||
},
|
||||
"/user/tasks/clear-completed": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Clears competed To-Dos (needed periodically for performance)."
|
||||
},
|
||||
action: user.clearCompleted
|
||||
},
|
||||
"/user/tasks/{id}/unlink": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Unlink a task from its challenge',
|
||||
parameters: [path("id", "Task ID", "string"), query('keep', "When unlinking a challenge task, how to handle the orphans?", 'string', ['keep', 'keep-all', 'remove', 'remove-all'])]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: challenges.unlink
|
||||
},
|
||||
"/user/inventory/buy": {
|
||||
spec: {
|
||||
description: "Get a list of buyable gear"
|
||||
},
|
||||
action: user.getBuyList
|
||||
},
|
||||
"/user/inventory/buy/{key}": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Buy a gear piece and equip it automatically",
|
||||
parameters: [path('key', "The key of the item to buy (call /content route for available keys)", 'string', _.keys(content.gear.flat))]
|
||||
},
|
||||
action: user.buy
|
||||
},
|
||||
"/user/inventory/sell/{type}/{key}": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Sell inventory items back to Alexander",
|
||||
parameters: [path('type', "The type of object you're selling back.", 'string', ['eggs', 'hatchingPotions', 'food']), path('key', "The object key you're selling back (call /content route for available keys)", 'string')]
|
||||
},
|
||||
action: user.sell
|
||||
},
|
||||
"/user/inventory/purchase/{type}/{key}": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Purchase a Gem-purchasable item from Alexander",
|
||||
parameters: [path('type', "The type of object you're purchasing.", 'string', ['eggs', 'hatchingPotions', 'food', 'quests', 'special']), path('key', "The object key you're purchasing (call /content route for available keys)", 'string')]
|
||||
},
|
||||
action: user.purchase
|
||||
},
|
||||
"/user/inventory/hourglass/{type}/{key}": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Purchase a pet or mount using a Mystic Hourglass",
|
||||
parameters: [path('type', "The type of object you're purchasing.", 'string', ['pets', 'mounts']), path('key', "The object key you're purchasing (call /content route for available keys)", 'string')]
|
||||
},
|
||||
action: user.hourglassPurchase
|
||||
},
|
||||
"/user/inventory/mystery/{key}": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Purchase a Mystery Item Set using a Mystic Hourglass",
|
||||
parameters: [path('key', "The key for the Mystery Set you're purchasing (call /content route for available keys)", 'string')]
|
||||
},
|
||||
action: user.buyMysterySet
|
||||
},
|
||||
"/user/inventory/feed/{pet}/{food}": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Feed your pet some food",
|
||||
parameters: [path('pet', "The key of the pet you're feeding", 'string', _.keys(content.pets)), path('food', "The key of the food to feed your pet", 'string', _.keys(content.food))]
|
||||
},
|
||||
action: user.feed
|
||||
},
|
||||
"/user/inventory/equip/{type}/{key}": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Equip an item (either pet, mount, equipped or costume)",
|
||||
parameters: [path('type', "Type to equip", 'string', ['pet', 'mount', 'equipped', 'costume']), path('key', "The object key you're equipping (call /content route for available keys)", 'string')]
|
||||
},
|
||||
action: user.equip
|
||||
},
|
||||
"/user/inventory/hatch/{egg}/{hatchingPotion}": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Pour a hatching potion on an egg",
|
||||
parameters: [path('egg', "The egg key to hatch", 'string', _.keys(content.eggs)), path('hatchingPotion', "The hatching potion to pour", 'string', _.keys(content.hatchingPotions))]
|
||||
},
|
||||
action: user.hatch
|
||||
},
|
||||
"/user:GET": {
|
||||
spec: {
|
||||
path: '/user',
|
||||
description: "Get the full user object"
|
||||
},
|
||||
action: user.getUser
|
||||
},
|
||||
"/user/anonymized": {
|
||||
spec: {
|
||||
description: "Get the user object without any personal data"
|
||||
},
|
||||
action: user.getUserAnonymized
|
||||
},
|
||||
"/user:PUT": {
|
||||
spec: {
|
||||
path: '/user',
|
||||
method: 'PUT',
|
||||
description: "Update the user object (only certain attributes are supported)",
|
||||
parameters: [body('', 'The user object (see UserSchema)', 'object')]
|
||||
},
|
||||
action: user.update
|
||||
},
|
||||
"/user:DELETE": {
|
||||
spec: {
|
||||
path: '/user',
|
||||
method: 'DELETE',
|
||||
description: "Delete a user object entirely, USE WITH CAUTION!"
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: user["delete"]
|
||||
},
|
||||
"/user/revive": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Revive your dead user"
|
||||
},
|
||||
action: user.revive
|
||||
},
|
||||
"/user/reroll": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Drink the Fortify Potion (Note, it used to be called re-roll)'
|
||||
},
|
||||
action: user.reroll
|
||||
},
|
||||
"/user/reset": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Completely reset your account"
|
||||
},
|
||||
action: user.reset
|
||||
},
|
||||
"/user/sleep": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Toggle whether you're resting in the inn"
|
||||
},
|
||||
action: user.sleep
|
||||
},
|
||||
"/user/rebirth": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Rebirth your avatar"
|
||||
},
|
||||
action: user.rebirth
|
||||
},
|
||||
"/user/class/change": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Either remove your avatar's class, or change it to something new",
|
||||
parameters: [query('class', "The key of the class to change to. If not provided, user's class is removed.", 'string', ['warrior', 'healer', 'rogue', 'wizard', ''])]
|
||||
},
|
||||
action: user.changeClass
|
||||
},
|
||||
"/user/class/allocate": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Allocate one point towards an attribute",
|
||||
parameters: [query('stat', 'The stat to allocate towards', 'string', ['str', 'per', 'int', 'con'])]
|
||||
},
|
||||
action: user.allocate
|
||||
},
|
||||
"/user/class/cast/{spell}": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Casts a spell on a target.",
|
||||
parameters: [path('spell', "The key of the spell to cast (see ../../common#content/index.js)", 'string'), query('targetType', "The type of object you're targeting", 'string', ['party', 'self', 'user', 'task']), query('targetId', "The ID of the object you're targeting", 'string')]
|
||||
},
|
||||
action: user.cast
|
||||
},
|
||||
"/user/unlock": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Unlock a certain gem-purchaseable path (or multiple paths)",
|
||||
parameters: [query('path', "The path to unlock, such as hair.green or shirts.red,shirts.blue", 'string')]
|
||||
},
|
||||
action: user.unlock
|
||||
},
|
||||
"/user/batch-update": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "This is an advanced route which is useful for apps which might for example need offline support. You can send a whole batch of user-based operations, which allows you to queue them up offline and send them all at once. The format is {op:'nameOfOperation',parameters:{},body:{},query:{}}",
|
||||
parameters: [body('', 'The array of batch-operations to perform', 'object')]
|
||||
},
|
||||
middleware: [forceRefresh, auth.auth, getUserLanguage, cron, user.sessionPartyInvite],
|
||||
action: user.batchUpdate
|
||||
},
|
||||
"/user/tags/{id}:GET": {
|
||||
spec: {
|
||||
path: '/user/tags/{id}',
|
||||
method: 'GET',
|
||||
description: "Get a tag",
|
||||
parameters: [path('id', 'The id of the tag to get', 'string')]
|
||||
},
|
||||
action: user.getTag
|
||||
},
|
||||
"/user/tags:POST": {
|
||||
spec: {
|
||||
path: "/user/tags",
|
||||
method: 'POST',
|
||||
description: 'Create a new tag',
|
||||
parameters: [body('', 'New tag (see UserSchema.tags)', 'object')]
|
||||
},
|
||||
action: user.addTag
|
||||
},
|
||||
"/user/tags:GET": {
|
||||
spec: {
|
||||
path: "/user/tags",
|
||||
method: 'GET',
|
||||
description: 'List all of a user\'s tags'
|
||||
},
|
||||
action: user.getTags
|
||||
},
|
||||
"/user/tags/sort": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Sort tags',
|
||||
parameters: [query("from", "Index where you're sorting from (0-based)", "integer"), query("to", "Index where you're sorting to (0-based)", "integer")]
|
||||
},
|
||||
action: user.sortTag
|
||||
},
|
||||
"/user/tags/{id}:PUT": {
|
||||
spec: {
|
||||
path: '/user/tags/{id}',
|
||||
method: 'PUT',
|
||||
description: "Edit a tag",
|
||||
parameters: [path('id', 'The id of the tag to edit', 'string'), body('', 'Tag edits (see UserSchema.tags)', 'object')]
|
||||
},
|
||||
action: user.updateTag
|
||||
},
|
||||
"/user/tags/{id}:DELETE": {
|
||||
spec: {
|
||||
path: '/user/tags/{id}',
|
||||
method: 'DELETE',
|
||||
description: 'Delete a tag',
|
||||
parameters: [path('id', 'Id of tag to delete', 'string')]
|
||||
},
|
||||
action: user.deleteTag
|
||||
},
|
||||
"/user/webhooks": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Create a new webhook',
|
||||
parameters: [body('', 'New Webhook {url:"webhook endpoint (required)", id:"id of webhook (shared.uuid(), optional)", enabled:"whether webhook is enabled (true by default, optional)"}', 'object')]
|
||||
},
|
||||
action: user.addWebhook
|
||||
},
|
||||
"/user/webhooks/{id}:PUT": {
|
||||
spec: {
|
||||
path: '/user/webhooks/{id}',
|
||||
method: 'PUT',
|
||||
description: "Edit a webhook",
|
||||
parameters: [path('id', 'The id of the webhook to edit', 'string'), body('', 'New Webhook {url:"webhook endpoint (required)", id:"id of webhook (shared.uuid(), optional)", enabled:"whether webhook is enabled (true by default, optional)"}', 'object')]
|
||||
},
|
||||
action: user.updateWebhook
|
||||
},
|
||||
"/user/webhooks/{id}:DELETE": {
|
||||
spec: {
|
||||
path: '/user/webhooks/{id}',
|
||||
method: 'DELETE',
|
||||
description: 'Delete a webhook',
|
||||
parameters: [path('id', 'Id of webhook to delete', 'string')]
|
||||
},
|
||||
action: user.deleteWebhook
|
||||
},
|
||||
"/user/pushDevice": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Add a new push devices registration ID',
|
||||
parameters: [body('', 'New push registration { regId: "123123", type: "android"}', 'object')]
|
||||
},
|
||||
action: user.addPushDevice
|
||||
},
|
||||
"/groups:GET": {
|
||||
spec: {
|
||||
path: '/groups',
|
||||
description: "Get a list of groups",
|
||||
parameters: [query('type', "Comma-separated types of groups to return, eg 'party,guilds,public,tavern'", 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: groups.list
|
||||
},
|
||||
"/groups:POST": {
|
||||
spec: {
|
||||
path: '/groups',
|
||||
method: 'POST',
|
||||
description: 'Create a group',
|
||||
parameters: [body('', 'Group object (see GroupSchema)', 'object')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: groups.create
|
||||
},
|
||||
"/groups/{gid}:GET": {
|
||||
spec: {
|
||||
path: '/groups/{gid}',
|
||||
description: "Get a group. The party the user currently is in can be accessed with the gid 'party'.",
|
||||
parameters: [path('gid', 'Group ID', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: groups.get
|
||||
},
|
||||
"/groups/{gid}:POST": {
|
||||
spec: {
|
||||
path: '/groups/{gid}',
|
||||
method: 'POST',
|
||||
description: "Edit a group",
|
||||
parameters: [body('', 'Group object (see GroupSchema)', 'object')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.update
|
||||
},
|
||||
"/groups/{gid}/join": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Join a group',
|
||||
parameters: [path('gid', 'Id of the group to join', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.join
|
||||
},
|
||||
"/groups/{gid}/leave": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Leave a group',
|
||||
parameters: [path('gid', 'ID of the group to leave', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.leave
|
||||
},
|
||||
"/groups/{gid}/invite": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Invite a user to a group",
|
||||
parameters: [path('gid', 'Group id', 'string'), body('', 'a payload of invites either under body.uuids or body.emails, only one of them!', 'object')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.invite
|
||||
},
|
||||
"/groups/{gid}/removeMember": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Remove / boot a member from a group",
|
||||
parameters: [path('gid', 'Group id', 'string'), query('uuid', 'User id to boot', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.removeMember
|
||||
},
|
||||
"/groups/{gid}/questAccept": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Accept a quest invitation",
|
||||
parameters: [path('gid', "Group id", 'string'), query('key', "optional. if provided, trigger new invite, if not, accept existing invite", 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.questAccept
|
||||
},
|
||||
"/groups/{gid}/questReject": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Reject quest invitation',
|
||||
parameters: [path('gid', 'Group id', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.questReject
|
||||
},
|
||||
"/groups/{gid}/questCancel": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Cancel quest before it starts (in invitation stage)',
|
||||
parameters: [path('gid', 'Group to cancel quest in', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.questCancel
|
||||
},
|
||||
"/groups/{gid}/questAbort": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Abort quest after it has started (all progress will be lost)',
|
||||
parameters: [path('gid', 'Group to abort quest in', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.questAbort
|
||||
},
|
||||
"/groups/{gid}/questLeave": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Leave an active quest (Quest leaders cannot leave active quests. They must abort the quest to leave)',
|
||||
parameters: [path('gid', 'Group to leave quest in', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.questLeave
|
||||
},
|
||||
"/groups/{gid}/chat:GET": {
|
||||
spec: {
|
||||
path: "/groups/{gid}/chat",
|
||||
description: "Get all chat messages",
|
||||
parameters: [path('gid', 'Group to return the chat from ', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.getChat
|
||||
},
|
||||
"/groups/{gid}/chat:POST": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
path: "/groups/{gid}/chat",
|
||||
description: "Send a chat message",
|
||||
parameters: [query('message', 'Chat message', 'string'), path('gid', 'Group id', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.postChat
|
||||
},
|
||||
"/groups/{gid}/chat/seen": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Flag chat messages for a particular group as seen",
|
||||
parameters: [path('gid', 'Group id', 'string')]
|
||||
},
|
||||
action: groups.seenMessage
|
||||
},
|
||||
"/groups/{gid}/chat/{messageId}": {
|
||||
spec: {
|
||||
method: 'DELETE',
|
||||
description: 'Delete a chat message in a given group',
|
||||
parameters: [path('gid', 'ID of the group containing the message to be deleted', 'string'), path('messageId', 'ID of message to be deleted', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.deleteChatMessage
|
||||
},
|
||||
"/groups/{gid}/chat/{mid}/like": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Like a chat message",
|
||||
parameters: [path('gid', 'Group id', 'string'), path('mid', 'Message id', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.likeChatMessage
|
||||
},
|
||||
"/groups/{gid}/chat/{mid}/flag": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Flag a chat message",
|
||||
parameters: [path('gid', 'Group id', 'string'), path('mid', 'Message id', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.flagChatMessage
|
||||
},
|
||||
"/groups/{gid}/chat/{mid}/clearflags": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Clear flag count from message and unhide it",
|
||||
parameters: [path('gid', 'Group id', 'string'), path('mid', 'Message id', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, groups.attachGroup],
|
||||
action: groups.clearFlagCount
|
||||
},
|
||||
"/members/{uuid}:GET": {
|
||||
spec: {
|
||||
path: '/members/{uuid}',
|
||||
description: "Get a member.",
|
||||
parameters: [path('uuid', 'Member ID', 'string')]
|
||||
},
|
||||
middleware: [getUserLanguage],
|
||||
action: members.getMember
|
||||
},
|
||||
"/members/{uuid}/message": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Send a private message to a member',
|
||||
parameters: [path('uuid', 'The UUID of the member to message', 'string'), body('', '{"message": "The private message to send"}', 'object')]
|
||||
},
|
||||
middleware: [auth.auth],
|
||||
action: members.sendPrivateMessage
|
||||
},
|
||||
"/members/{uuid}/block": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Block a member from sending private messages',
|
||||
parameters: [path('uuid', 'The UUID of the member to message', 'string')]
|
||||
},
|
||||
middleware: [auth.auth],
|
||||
action: user.blockUser
|
||||
},
|
||||
"/members/{uuid}/gift": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Send a gift to a member',
|
||||
parameters: [path('uuid', 'The UUID of the member', 'string'), body('', '{"type": "gems or subscription", "gems":{"amount":Number, "fromBalance":Boolean}, "subscription":{"months":Number}}', 'object')]
|
||||
},
|
||||
middleware: [auth.auth],
|
||||
action: members.sendGift
|
||||
},
|
||||
"/hall/heroes": {
|
||||
spec: {},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: hall.getHeroes
|
||||
},
|
||||
"/hall/heroes/{uid}:GET": {
|
||||
spec: {
|
||||
path: "/hall/heroes/{uid}"
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, hall.ensureAdmin],
|
||||
action: hall.getHero
|
||||
},
|
||||
"/hall/heroes/{uid}:POST": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
path: "/hall/heroes/{uid}"
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage, hall.ensureAdmin],
|
||||
action: hall.updateHero
|
||||
},
|
||||
"/hall/patrons": {
|
||||
spec: {
|
||||
parameters: [query('page', 'Page number to fetch (this list is long)', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: hall.getPatrons
|
||||
},
|
||||
"/challenges:GET": {
|
||||
spec: {
|
||||
path: '/challenges',
|
||||
description: "Get a list of challenges"
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: challenges.list
|
||||
},
|
||||
"/challenges:POST": {
|
||||
spec: {
|
||||
path: '/challenges',
|
||||
method: 'POST',
|
||||
description: "Create a challenge",
|
||||
parameters: [body('', 'Challenge object (see ChallengeSchema)', 'object')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: challenges.create
|
||||
},
|
||||
"/challenges/{cid}:GET": {
|
||||
spec: {
|
||||
path: '/challenges/{cid}',
|
||||
description: 'Get a challenge',
|
||||
parameters: [path('cid', 'Challenge id', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: challenges.get
|
||||
},
|
||||
"/challenges/{cid}/csv": {
|
||||
spec: {
|
||||
description: 'Get a challenge (csv format)',
|
||||
parameters: [path('cid', 'Challenge id', 'string')]
|
||||
},
|
||||
action: challenges.csv
|
||||
},
|
||||
"/challenges/{cid}:POST": {
|
||||
spec: {
|
||||
path: '/challenges/{cid}',
|
||||
method: 'POST',
|
||||
description: "Update a challenge",
|
||||
parameters: [path('cid', 'Challenge id', 'string'), body('', 'Challenge object (see ChallengeSchema)', 'object')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: challenges.update
|
||||
},
|
||||
"/challenges/{cid}:DELETE": {
|
||||
spec: {
|
||||
path: '/challenges/{cid}',
|
||||
method: 'DELETE',
|
||||
description: "Delete a challenge",
|
||||
parameters: [path('cid', 'Challenge id', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: challenges["delete"]
|
||||
},
|
||||
"/challenges/{cid}/close": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Close a challenge',
|
||||
parameters: [path('cid', 'Challenge id', 'string'), query('uid', 'User ID of the winner', 'string', true)]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: challenges.selectWinner
|
||||
},
|
||||
"/challenges/{cid}/join": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: "Join a challenge",
|
||||
parameters: [path('cid', 'Challenge id', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: challenges.join
|
||||
},
|
||||
"/challenges/{cid}/leave": {
|
||||
spec: {
|
||||
method: 'POST',
|
||||
description: 'Leave a challenge',
|
||||
parameters: [path('cid', 'Challenge id', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: challenges.leave
|
||||
},
|
||||
"/challenges/{cid}/member/{uid}": {
|
||||
spec: {
|
||||
description: "Get a member's progress in a particular challenge",
|
||||
parameters: [path('cid', 'Challenge id', 'string'), path('uid', 'User id', 'string')]
|
||||
},
|
||||
middleware: [auth.auth, getUserLanguage],
|
||||
action: challenges.getMember
|
||||
}
|
||||
};
|
||||
if (nconf.get("NODE_ENV") === "development") {
|
||||
api["/user/addTenGems"] = {
|
||||
spec: {
|
||||
method: 'POST'
|
||||
},
|
||||
action: user.addTenGems
|
||||
};
|
||||
api["/user/addHourglass"] = {
|
||||
spec: {
|
||||
method: 'POST'
|
||||
},
|
||||
action: user.addHourglass
|
||||
};
|
||||
};
|
||||
|
||||
_.each(api, function(route, path) {
|
||||
var base;
|
||||
if ((base = route.spec).description == null) {
|
||||
base.description = '';
|
||||
}
|
||||
_.defaults(route.spec, {
|
||||
path: path,
|
||||
nickname: path,
|
||||
notes: route.spec.description,
|
||||
summary: route.spec.description,
|
||||
parameters: [],
|
||||
errorResponses: [],
|
||||
method: 'GET'
|
||||
});
|
||||
if (route.middleware == null) {
|
||||
route.middleware = path.indexOf('/user') === 0 ? [auth.auth, getUserLanguage, cron] : [i18n.getUserLanguage];
|
||||
}
|
||||
swagger["add" + route.spec.method](route);
|
||||
return true;
|
||||
});
|
||||
|
||||
return swagger.configure((nconf.get('BASE_URL')) + "/api/v2", "2");
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var i18n = require('../../libs/api-v2/i18n');
|
||||
var unsubscription = require('../../controllers/api-v2/unsubscription');
|
||||
import {
|
||||
getUserLanguage
|
||||
} from '../../middlewares/api-v3/language';
|
||||
|
||||
router.get('/unsubscribe', getUserLanguage, unsubscription.unsubscribe);
|
||||
|
||||
module.exports = router;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user