diff --git a/api/v1/index.js b/api/v1/index.js index dc90dd4..c507c2e 100644 --- a/api/v1/index.js +++ b/api/v1/index.js @@ -33,10 +33,10 @@ v1.use('/health', controllers.HealthController.router); v1.use('/checkin', controllers.CheckInController.router); v1.use('/rsvp', controllers.RSVPController.router); v1.use('/announcement', controllers.AnnouncementController.router); -v1.use('/stats', controllers.StatsController.router); v1.use('/tracking', controllers.TrackingController.router); v1.use('/mail', controllers.MailController.router); v1.use('/event', controllers.EventController.router); +v1.use('/stats', controllers.StatsController.router); // logs resolved requests (the request once processed by various middleware) and outgoing responses v1.use((req, res, next) => { diff --git a/api/v1/models/Stat.js b/api/v1/models/Stat.js new file mode 100644 index 0000000..55bf923 --- /dev/null +++ b/api/v1/models/Stat.js @@ -0,0 +1,70 @@ +const _ = require('lodash'); + +const Model = require('./Model'); +const validators = require('../utils/validators'); + +const CATEGORIES = ['registration', 'rsvp', 'live_event']; + +const Stat = Model.extend({ + tableName: 'stats', + idAttribute: 'id', + validations: { + category: ['required', 'string', validators.in(CATEGORIES)], + stat: ['required', 'string'], + field: ['required', 'string'], + count: ['required', 'integer'] // Change to default 0? + } +}); + +/** + * Adds a row with category `category`, stat `stat`, and field `field`. + * Initializes count to 0 + * @param {String} category + * @param {String} stat + * @param {String} field + * @return {Promise} a Promise resolving to the newly-created Stat + */ +Stat.create = (category, stat, field) => { + const s = Stat.forge({ + category: category, + stat: stat, + field: field, + count: 0 + }); + + return s.save(); +}; + +/** + * Increments the specified stat by the amount + * @param {String} category + * @param {String} stat + * @param {String} field + * @param {Number} amount defaults to 1 + * @return {Promise} a Promise resolving to the updating Stat model + */ +Stat.increment = (category, stat, field, amount) => { + if (_.isUndefined(amount)) { + amount = 1; + } + + const s = Stat.where({ + category: category, + stat: stat, + field: field + }).fetch(); + + return s.then((model) => { + if(model == null) { + return Stat.create(category, stat, String(field)).then((createdModel) => { + createdModel.set('count', createdModel.get('count') + amount); + return createdModel.save(); + }); + } + model.set('count', model.get('count') + amount); + return model.save(); + + }).catch(() => null); +}; + +module.exports = Stat; diff --git a/api/v1/models/TrackingEvent.js b/api/v1/models/TrackingEvent.js index 7ed8d2c..d710fa3 100644 --- a/api/v1/models/TrackingEvent.js +++ b/api/v1/models/TrackingEvent.js @@ -17,4 +17,10 @@ TrackingEvent.findByName = function(searchName) { .fetch(); }; +TrackingEvent.findAll = function() { + return TrackingEvent.where({ + + }).fetchAll(); +}; + module.exports = TrackingEvent; diff --git a/api/v1/services/CheckInService.js b/api/v1/services/CheckInService.js index a0d1d0f..e109738 100644 --- a/api/v1/services/CheckInService.js +++ b/api/v1/services/CheckInService.js @@ -5,6 +5,7 @@ const NetworkCredential = require('../models/NetworkCredential'); const errors = require('../errors'); const utils = require('../utils'); +const StatsService = require('../services/StatsService'); /** * Finds a CheckIn by User ID @@ -61,6 +62,8 @@ module.exports.createCheckIn = (attributes) => { const credentialsRequested = attributes.credentialsRequested; delete attributes.credentialsRequested; + StatsService.incrementStat('liveEvent', 'attendees', 'count'); + return CheckIn.transaction((t) => new CheckIn(attributes) .save(null, { transacting: t diff --git a/api/v1/services/RSVPService.js b/api/v1/services/RSVPService.js index 1e5cde4..cf5501f 100644 --- a/api/v1/services/RSVPService.js +++ b/api/v1/services/RSVPService.js @@ -6,6 +6,10 @@ const RSVP = require('../models/AttendeeRSVP'); const UserRole = require('../models/UserRole'); const errors = require('../errors'); const utils = require('../utils'); + +const StatsService = require('../services/StatsService'); +const RegistrationService = require('../services/RegistrationService'); + /** * Gets an rsvp by its id * @param {integer} id the id of the RSVP to find @@ -22,6 +26,19 @@ module.exports.getRSVPById = (id) => RSVP.findById(id); * @throws {InvalidParameterError} thrown when an attendee already has an rsvp */ module.exports.createRSVP = (attendee, user, attributes) => { + + if(attributes.isAttending) { + StatsService.incrementStat('rsvp', 'school', attendee.get('school')) + .then(() => StatsService.incrementStat('rsvp', 'transportation', attendee.get('transportation'))) + .then(() => StatsService.incrementStat('rsvp', 'diet', attendee.get('diet'))) + .then(() => StatsService.incrementStat('rsvp', 'shirtSize', attendee.get('shirtSize'))) + .then(() => StatsService.incrementStat('rsvp', 'gender', attendee.get('gender'))) + .then(() => StatsService.incrementStat('rsvp', 'graduationYear', attendee.get('graduationYear'))) + .then(() => StatsService.incrementStat('rsvp', 'major', attendee.get('major'))) + .then(() => StatsService.incrementStat('rsvp', 'isNovice', attendee.get('isNovice') ? 1 : 0)) + .then(() => StatsService.incrementStat('rsvp', 'attendees', 'count')); + } + attributes.attendeeId = attendee.get('id'); const rsvp = RSVP.forge(attributes); @@ -66,6 +83,32 @@ module.exports.findRSVPByAttendee = (attendee) => RSVP * @returns {Promise} the resolved RSVP */ module.exports.updateRSVP = (user, rsvp, attributes) => { + const oldAttending = rsvp.get('isAttending'); + const newAttending = attributes.isAttending; + RegistrationService.findAttendeeByUser(user).then((attendee) => { + if(!oldAttending && newAttending) { + StatsService.incrementStat('rsvp', 'school', attendee.get('school')) + .then(() => StatsService.incrementStat('rsvp', 'transportation', attendee.get('transportation'))) + .then(() => StatsService.incrementStat('rsvp', 'diet', attendee.get('diet'))) + .then(() => StatsService.incrementStat('rsvp', 'shirtSize', attendee.get('shirtSize'))) + .then(() => StatsService.incrementStat('rsvp', 'gender', attendee.get('gender'))) + .then(() => StatsService.incrementStat('rsvp', 'graduationYear', attendee.get('graduationYear'))) + .then(() => StatsService.incrementStat('rsvp', 'major', attendee.get('major'))) + .then(() => StatsService.incrementStat('rsvp', 'isNovice', attendee.get('isNovice') ? 1 : 0)) + .then(() => StatsService.incrementStat('rsvp', 'attendees', 'count')); + } else if(oldAttending && !newAttending) { + StatsService.decrementStat('rsvp', 'school', attendee.get('school')) + .then(() => StatsService.decrementStat('rsvp', 'transportation', attendee.get('transportation'))) + .then(() => StatsService.decrementStat('rsvp', 'diet', attendee.get('diet'))) + .then(() => StatsService.decrementStat('rsvp', 'shirtSize', attendee.get('shirtSize'))) + .then(() => StatsService.decrementStat('rsvp', 'gender', attendee.get('gender'))) + .then(() => StatsService.decrementStat('rsvp', 'graduationYear', attendee.get('graduationYear'))) + .then(() => StatsService.decrementStat('rsvp', 'major', attendee.get('major'))) + .then(() => StatsService.decrementStat('rsvp', 'isNovice', attendee.get('isNovice') ? 1 : 0)) + .then(() => StatsService.decrementStat('rsvp', 'attendees', 'count')); + } + }); + rsvp.set(attributes); return rsvp diff --git a/api/v1/services/RegistrationService.js b/api/v1/services/RegistrationService.js index 7744807..8ca0ec4 100644 --- a/api/v1/services/RegistrationService.js +++ b/api/v1/services/RegistrationService.js @@ -14,6 +14,8 @@ const errors = require('../errors'); const utils = require('../utils'); const config = require('ctx').config(); +const StatsService = require('../services/StatsService'); + /** * Persists (insert or update) a model instance and creates (insert only) any * related models as provided by the related mapping. Use #extractRelatedObjects @@ -325,6 +327,20 @@ module.exports.createAttendee = (user, attributes) => { return _Promise.reject(new errors.InvalidParameterError(message, source)); } + const statAttributes = attributes.attendee; + + StatsService.incrementStat('registration', 'school', statAttributes.school) + .then(() => StatsService.incrementStat('registration', 'transportation', statAttributes.transportation)) + .then(() => StatsService.incrementStat('registration', 'diet', statAttributes.diet)) + .then(() => StatsService.incrementStat('registration', 'shirtSize', statAttributes.shirtSize)) + .then(() => StatsService.incrementStat('registration', 'gender', statAttributes.gender)) + .then(() => StatsService.incrementStat('registration', 'graduationYear', statAttributes.graduationYear)) + .then(() => StatsService.incrementStat('registration', 'major', statAttributes.major)) + .then(() => StatsService.incrementStat('registration', 'isNovice', statAttributes.isNovice ? 1 : 0)) + .then(() => StatsService.incrementStat('registration', 'attendees', 'count')) + .then(() => StatsService.incrementStat('registration', 'status', 'PENDING')); + + const attendeeAttrs = attributes.attendee; delete attributes.attendee; diff --git a/api/v1/services/StatsService.js b/api/v1/services/StatsService.js index 04d2337..26034ed 100644 --- a/api/v1/services/StatsService.js +++ b/api/v1/services/StatsService.js @@ -1,307 +1,294 @@ const _Promise = require('bluebird'); -const _ = require('lodash'); -const ctx = require('ctx'); -const database = ctx.database(); -const knex = database.connection(); - -const Attendee = require('../models/Attendee'); -const AttendeeRSVP = require('../models/AttendeeRSVP'); -const CheckIn = require('../models/CheckIn'); -const TrackedEvent = require('../models/TrackingEvent'); +const Stat = require('../models/Stat'); +const TrackingEvent = require('../models/TrackingEvent'); const utils = require('../utils'); const cache = utils.cache; const STATS_CACHE_KEY = 'stats'; -const STATS_LIVE_HEADER = 'liveevent'; -const STATS_RSVP_HEADER = 'rsvp'; -const STATS_REG_HEADER = 'registration'; - -/** - * Returns a function that takes a query result and populates a stats object - * @param {String} key the key to use to nest the stats - * @param {Object} stats the stats object to be populated - * @return {Function} The generated function - */ -function _populateStats(key, stats) { - return function(result) { - stats[key] = {}; - _.forEach(result.models, (model) => { - stats[key][model.attributes.name] = model.attributes.count; - }); - }; -} - -/** - * Returns a function that takes a query result and populates a stats object - * Differs from above in that it doesn't process a collection - * @param {String} key the key to use to map a count result - * @param {Object} stats the stats object to be populated - * @return {Function} The generated function - */ -function _populateStatsField(key, stats) { - return function(result) { - stats[key] = result.attributes.count; - }; -} -function _populateCheckins(cb) { - return CheckIn.query((qb) => { - qb.count('id as count'); - }) - .fetch() - .then(cb); -} +module.exports.createStat = function (category, stat, field) { + return Stat.create(category, stat, field); +}; +module.exports.find = function (category, stat, field) { + return Stat.where({ + category: category, + stat: stat, + field: field + }); +}; -/** - * Queries Attendee rsvps and performs a callback on the results - * @param {Function} cb the function to process the query results with - * @return {Promise} resolving to the return value of the callback - */ -function _populateRSVPs(cb) { - return AttendeeRSVP.query((qb) => { - qb.select('is_attending as name') - .count('is_attending as count') - .from('attendee_rsvps') - .groupBy('is_attending'); - }) - .fetchAll() - .then(cb); +function _findAll(category, stat) { + return Stat.where({ + category: category, + stat: stat + }).fetchAll(); } +module.exports.findAll = _findAll; + +module.exports.incrementStat = function (category, stat, field) { + cache.hasKey(STATS_CACHE_KEY).then((hasKey) => { + if (!hasKey) { + _resetCachedStat().then(() => _incrementCachedStat(category, stat, field)); + } else { + _incrementCachedStat(category, stat, field); + } + }); + if(category == 'liveEvent' && category == 'events') { + return _Promise.resolve(true); + } + return Stat.increment(category, stat, field); + +}; -/** - * Queries Attendee rsvp types interests and performs a callback on the results - * @param {Function} cb the function to process the query results with - * @return {Promise} resolving to the return value of the callback - */ -function _populateRSVPTypes(cb) { - return AttendeeRSVP.query((qb) => { - qb.select('type as name') - .count('is_attending as count') - .from('attendee_rsvps') - .groupBy('type'); - }) - .fetchAll() - .then(cb); -} +module.exports.decrementStat = function (category, stat, field) { + cache.hasKey(STATS_CACHE_KEY).then((hasKey) => { + if (!hasKey) { + _resetCachedStat().then(() => _decrementCachedStat(category, stat, field)); + } else { + _decrementCachedStat(category, stat, field); + } + }); + if(category == 'liveEvent' && category == 'events') { + return _Promise.resolve(true); + } + return Stat.increment(category, stat, field, -1); + +}; -/** - * Queries an attendee attribute and counts the unique entries - * @param {String} attribute the attribute to query for - * @param {Function} cb the function to process the query results with - * @return {Promise} resolving to the return value of the callback - */ -function _populateAttendeeAttribute(attribute, cb) { - return Attendee.query((qb) => { - qb.select(attribute + ' as name') - .count(attribute + ' as count') - .from('attendees') - .groupBy(attribute); - }) - .fetchAll() - .then(cb); +function _resetCachedStat() { + const stats = {}; + stats['registration'] = {}; + stats['registration']['school'] = {}; + stats['registration']['transportation'] = {}; + stats['registration']['diet'] = {}; + stats['registration']['shirtSize'] = {}; + stats['registration']['gender'] = {}; + stats['registration']['graduationYear'] = {}; + stats['registration']['isNovice'] = {}; + stats['registration']['status'] = {}; + stats['registration']['major'] = {}; + stats['registration']['attendees'] = {}; + stats['rsvp'] = {}; + stats['rsvp']['school'] = {}; + stats['rsvp']['transportation'] = {}; + stats['rsvp']['diet'] = {}; + stats['rsvp']['shirtSize'] = {}; + stats['rsvp']['gender'] = {}; + stats['rsvp']['graduationYear'] = {}; + stats['rsvp']['isNovice'] = {}; + stats['rsvp']['major'] = {}; + stats['rsvp']['attendees'] = {}; + stats['liveEvent'] = {}; + stats['liveEvent']['attendees'] = {}; + stats['liveEvent']['events'] = {}; + return cache.storeString(STATS_CACHE_KEY, JSON.stringify(stats)); } -/** - * Queries an (attending) attendee attribute and counts the unique entries - * Attending is defined as a ACCEPTED status and is_attending RSVP - * @param {String} attribute the attribute to query for - * @param {Function} cb the function to process the query results with - * @return {Promise} resolving to the return value of the callback - */ -function _populateAttendingAttendeeAttribute(attribute, cb) { - return Attendee.query((qb) => { - qb.select(attribute + ' as name') - .count(attribute + ' as count') - .innerJoin('attendee_rsvps as ar', function() { - this.on('attendees.status', '=', knex.raw('?', [ 'ACCEPTED' ])) - .andOn('ar.is_attending', '=', knex.raw('?', [ '1' ])); - }) - .groupBy(attribute); - }) - .fetchAll() - .then(cb); +function _incrementCachedStat(category, stat, field) { + cache.getString(STATS_CACHE_KEY).then((object) => JSON.parse(object)).then((stats) => { + if(stats == null) { + stats = {}; + } + if(stats[category] == null) { + stats[category] = {}; + } + if(stats[category][stat] == null) { + stats[category][stat] = {}; + } + if(stats[category][stat][field] == null) { + stats[category][stat][field] = 0; + } + stats[category][stat][field] += 1; + return cache.storeString(STATS_CACHE_KEY, JSON.stringify(stats)); + }); } -/** - * Queries the total number of attendees - * @param {Function} cb the function to process the query results with - * @return {Promise} resolving to the return value of the callback - */ -function _populateAttendees(cb) { - return Attendee.query((qb) => { - qb.count('a.id as attending') - .from('attendees as a') - .innerJoin('attendee_rsvps as ar', function() { - this.on('a.status', '=', knex.raw('?', [ 'ACCEPTED' ])) - .andOn('ar.is_attending', '=', knex.raw('?', [ '1' ])); - }); - }) - .fetch() - .then(cb); +function _decrementCachedStat(category, stat, field) { + cache.getString(STATS_CACHE_KEY).then((object) => JSON.parse(object)).then((stats) => { + if(stats == null) { + stats = {}; + } + if(stats[category] == null) { + stats[category] = {}; + } + if(stats[category][stat] == null) { + stats[category][stat] = {}; + } + if(stats[category][stat][field] == null) { + stats[category][stat][field] = 0; + } + stats[category][stat][field] -= 1; + return cache.storeString(STATS_CACHE_KEY, JSON.stringify(stats)); + }); } -/** - * Queries the current stats for tracked events - * @param {Function} cb the function to process the query results with - * @return {Promise} resolving to the return value of the callback - */ -function _populateTrackedEvents(cb) { - return TrackedEvent.query((qb) => { - qb.select('name', 'count') - .groupBy('name'); - }) - .fetchAll() - .then(cb); +function _readStatsFromDatabase() { + return _resetCachedStat().then(() => cache.getString(STATS_CACHE_KEY).then((object) => JSON.parse(object)).then((stats) => { + + const queries = []; + + queries.push(_findAll('registration', 'school').then((collection) => { + collection.forEach((model) => { + stats['registration']['school'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('registration', 'transportation').then((collection) => { + collection.forEach((model) => { + stats['registration']['transportation'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('registration', 'diet').then((collection) => { + collection.forEach((model) => { + stats['registration']['diet'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('registration', 'shirtSize').then((collection) => { + collection.forEach((model) => { + stats['registration']['shirtSize'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('registration', 'gender').then((collection) => { + collection.forEach((model) => { + stats['registration']['gender'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('registration', 'graduationYear').then((collection) => { + collection.forEach((model) => { + stats['registration']['graduationYear'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('registration', 'isNovice').then((collection) => { + collection.forEach((model) => { + stats['registration']['isNovice'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('registration', 'status').then((collection) => { + collection.forEach((model) => { + stats['registration']['status'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('registration', 'major').then((collection) => { + collection.forEach((model) => { + stats['registration']['major'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('registration', 'attendees').then((collection) => { + collection.forEach((model) => { + stats['registration']['attendees'][model.get('field')] = model.get('count'); + }); + })); + + + queries.push(_findAll('rsvp', 'school').then((collection) => { + collection.forEach((model) => { + stats['rsvp']['school'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('rsvp', 'transportation').then((collection) => { + collection.forEach((model) => { + stats['rsvp']['transportation'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('rsvp', 'diet').then((collection) => { + collection.forEach((model) => { + stats['rsvp']['diet'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('rsvp', 'shirtSize').then((collection) => { + collection.forEach((model) => { + stats['rsvp']['shirtSize'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('rsvp', 'gender').then((collection) => { + collection.forEach((model) => { + stats['rsvp']['gender'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('rsvp', 'graduationYear').then((collection) => { + collection.forEach((model) => { + stats['rsvp']['graduationYear'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('rsvp', 'isNovice').then((collection) => { + collection.forEach((model) => { + stats['rsvp']['isNovice'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('rsvp', 'major').then((collection) => { + collection.forEach((model) => { + stats['rsvp']['major'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(_findAll('rsvp', 'attendees').then((collection) => { + collection.forEach((model) => { + stats['rsvp']['attendees'][model.get('field')] = model.get('count'); + }); + })); + + + queries.push(_findAll('liveEvent', 'attendees').then((collection) => { + collection.forEach((model) => { + stats['liveEvent']['attendees'][model.get('field')] = model.get('count'); + }); + })); + + queries.push(TrackingEvent.findAll().then((collection) => { + collection.forEach((model) => { + stats['liveEvent']['events'][model.get('name')] = model.get('count'); + }); + })); + + return _Promise.all(queries).then(() => cache.storeString(STATS_CACHE_KEY, JSON.stringify(stats))); + + })); } /** * Fetches the current stats, requerying them if not cached * @return {Promise} resolving to key-value pairs of stats */ -module.exports.fetchAllStats = () => { - const stats = {}; - stats.registrationStats = {}; - stats.rsvpStats = {}; - stats.liveEventStats = {}; - - return module.exports.fetchRegistrationStats() - .then((regstats) => { - stats.registrationStats = regstats; - return module.exports.fetchRSVPStats(); - }) - .then((rsvpstats) => { - stats.rsvpStats = rsvpstats; - return module.exports.fetchLiveEventStats(); - }) - .then((livestats) => { - stats.liveEventStats = livestats; - return stats; - }); -}; - -module.exports.fetchRegistrationStats = () => cache.hasKey(STATS_REG_HEADER + STATS_CACHE_KEY) - .then((hasKey) => { - if (hasKey) { - return cache.getString(STATS_REG_HEADER + STATS_CACHE_KEY) - .then((object) => JSON.parse(object)); - } - const stats = {}; - const queries = []; - - const schoolQuery = _populateAttendeeAttribute('school', _populateStats('school', stats)); - queries.push(schoolQuery); - - const transportationQuery = _populateAttendeeAttribute('transportation', _populateStats('transportation', stats)); - queries.push(transportationQuery); - - const dietQuery = _populateAttendeeAttribute('diet', _populateStats('diet', stats)); - queries.push(dietQuery); - - const shirtSizeQuery = _populateAttendeeAttribute('shirt_size', _populateStats('shirtSize', stats)); - queries.push(shirtSizeQuery); - - const genderQuery = _populateAttendeeAttribute('gender', _populateStats('gender', stats)); - queries.push(genderQuery); - - const graduationYearQuery = _populateAttendeeAttribute('graduation_year', _populateStats('graduationYear', stats)); - queries.push(graduationYearQuery); - - const majorQuery = _populateAttendeeAttribute('major', _populateStats('major', stats)); - queries.push(majorQuery); - - const isNoviceQuery = _populateAttendeeAttribute('is_novice', _populateStats('isNovice', stats)); - queries.push(isNoviceQuery); - - const attendeeQuery = _populateAttendees(_populateStatsField('attendees', stats)); - queries.push(attendeeQuery); - - const statusQuery = _populateAttendeeAttribute('status', _populateStats('status', stats)); - queries.push(statusQuery); - - return _Promise.all(queries) - .then(() => cache.storeString(STATS_REG_HEADER + STATS_CACHE_KEY, JSON.stringify(stats)) - .then(() => { - const tenMinutesFromNow = (10 * 60); - return cache.expireKey(STATS_REG_HEADER + STATS_CACHE_KEY, tenMinutesFromNow) - .then(() => stats); - })); - - }); - -module.exports.fetchRSVPStats = () => cache.hasKey(STATS_RSVP_HEADER + STATS_CACHE_KEY) - .then((hasKey) => { - if (hasKey) { - return cache.getString(STATS_RSVP_HEADER + STATS_CACHE_KEY) - .then((object) => JSON.parse(object)); - } - const stats = {}; - const queries = []; - - const attendingSchoolQuery = _populateAttendingAttendeeAttribute('school', _populateStats('school', stats)); - queries.push(attendingSchoolQuery); - - const attendingTransportationQuery = _populateAttendingAttendeeAttribute('transportation', _populateStats('transportation', stats)); - queries.push(attendingTransportationQuery); - - const attendingDietQuery = _populateAttendingAttendeeAttribute('diet', _populateStats('diet', stats)); - queries.push(attendingDietQuery); - - const attendingShirtSizeQuery = _populateAttendingAttendeeAttribute('shirt_size', _populateStats('shirtSize', stats)); - queries.push(attendingShirtSizeQuery); - - const attendingGenderQuery = _populateAttendingAttendeeAttribute('gender', _populateStats('gender', stats)); - queries.push(attendingGenderQuery); - - const attendingGraduationYearQuery = _populateAttendingAttendeeAttribute('graduation_year', _populateStats('graduationYear', stats)); - queries.push(attendingGraduationYearQuery); - - const attendingMajorQuery = _populateAttendingAttendeeAttribute('major', _populateStats('major', stats)); - queries.push(attendingMajorQuery); - - const attendingIsNoviceQuery = _populateAttendingAttendeeAttribute('is_novice', _populateStats('isNovice', stats)); - queries.push(attendingIsNoviceQuery); - - const RSVPsQuery = _populateRSVPs(_populateStats('rsvps', stats)); - queries.push(RSVPsQuery); - - const RSVPTypesQuery = _populateRSVPTypes(_populateStats('type', stats)); - queries.push(RSVPTypesQuery); - - return _Promise.all(queries) - .then(() => cache.storeString(STATS_RSVP_HEADER + STATS_CACHE_KEY, JSON.stringify(stats)) - .then(() => { - const tenMinutesFromNow = (10 * 60); - return cache.expireKey(STATS_RSVP_HEADER + STATS_CACHE_KEY, tenMinutesFromNow) - .then(() => stats); - })); - - }); - -module.exports.fetchLiveEventStats = () => cache.hasKey(STATS_LIVE_HEADER + STATS_CACHE_KEY) - .then((hasKey) => { - if (hasKey) { - return cache.getString(STATS_LIVE_HEADER + STATS_CACHE_KEY) - .then((object) => JSON.parse(object)); - } - const stats = {}; - const queries = []; +function _fetchAllStats() { + + return cache.hasKey(STATS_CACHE_KEY).then((hasKey) => { + if (!hasKey) { + return _readStatsFromDatabase().then(() => cache.getString(STATS_CACHE_KEY).then((object) => JSON.parse(object)).then((stats) => stats)); + } + return cache.getString(STATS_CACHE_KEY).then((object) => JSON.parse(object)).then((stats) => stats); + + }); +} - const checkIns = _populateCheckins(_populateStatsField('checkins', stats)); - queries.push(checkIns); +module.exports.fetchAllStats = _fetchAllStats; - const trackedEventQuery = _populateTrackedEvents(_populateStats('trackedEvents', stats)); - queries.push(trackedEventQuery); +module.exports.fetchRegistrationStats = function() { + return _fetchAllStats().then((stats) => stats['registration']); +}; - return _Promise.all(queries) - .then(() => cache.storeString(STATS_LIVE_HEADER + STATS_CACHE_KEY, JSON.stringify(stats)) - .then(() => { - const oneMinuteFromNow = 60; - return cache.expireKey(STATS_LIVE_HEADER + STATS_CACHE_KEY, oneMinuteFromNow) - .then(() => stats); - })); +module.exports.fetchRSVPStats = function() { + return _fetchAllStats().then((stats) => stats['rsvp']); +}; - }); +module.exports.fetchLiveEventStats = function() { + return _fetchAllStats().then((stats) => stats['liveEvent']); +}; diff --git a/api/v1/services/TrackingService.js b/api/v1/services/TrackingService.js index 18d0e9e..f14b69d 100644 --- a/api/v1/services/TrackingService.js +++ b/api/v1/services/TrackingService.js @@ -8,6 +8,8 @@ const TrackingItem = require('../models/TrackingEvent'); const errors = require('../errors'); const utils = require('../utils'); +const StatsService = require('../services/StatsService'); + const TRACKING_NAMESPACE = 'utracking_'; const TRACKED_EVENT = 'trackedEvent'; @@ -55,6 +57,7 @@ module.exports.createTrackingEvent = (attributes) => { * @throws {InvalidParameterError} when an attendee has already participated in an event */ module.exports.addEventParticipant = (participantId) => { + let currentEvent; return cache.getAsync(TRACKED_EVENT) .then((result) => { @@ -66,6 +69,8 @@ module.exports.addEventParticipant = (participantId) => { currentEvent = result; + StatsService.incrementStat('liveEvent', 'events', currentEvent); + return cache.getAsync(TRACKING_NAMESPACE + participantId); }) .then((result) => { diff --git a/database/migration/V20171011_2012__addStats.sql b/database/migration/V20171011_2012__addStats.sql new file mode 100644 index 0000000..54e27ac --- /dev/null +++ b/database/migration/V20171011_2012__addStats.sql @@ -0,0 +1,97 @@ +CREATE TABLE `stats` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `category` VARCHAR(255) NOT NULL, + `stat` VARCHAR(255) NOT NULL, + `field` VARCHAR(255) NOT NULL, + `count` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + CONSTRAINT `fk_unique_stats` UNIQUE (`category`, `stat`, `field`) +); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'transportation', 'NOT_NEEDED'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'transportation', 'BUS_REQUESTED'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'transportation', 'IN_STATE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'transportation', 'OUT_OF_STATE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'transportation', 'INTERNATIONAL'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'diet', 'VEGETARIAN'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'diet', 'VEGAN'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'diet', 'NONE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'diet', 'GLUTEN_FREE'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'shirtSize', 'S'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'shirtSize', 'M'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'shirtSize', 'L'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'shirtSize', 'XL'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'gender', 'MALE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'gender', 'FEMALE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'gender', 'NON_BINARY'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'gender', 'OTHER'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'isNovice', '0'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'isNovice', '1'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'attendees', 'count'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'status', 'ACCEPTED'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'status', 'WAITLISTED'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'status', 'REJECTED'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('registration', 'status', 'PENDING'); + + + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'transportation', 'NOT_NEEDED'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'transportation', 'BUS_REQUESTED'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'transportation', 'IN_STATE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'transportation', 'OUT_OF_STATE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'transportation', 'INTERNATIONAL'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'diet', 'VEGETARIAN'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'diet', 'VEGAN'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'diet', 'NONE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'diet', 'GLUTEN_FREE'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'shirtSize', 'S'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'shirtSize', 'M'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'shirtSize', 'L'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'shirtSize', 'XL'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'gender', 'MALE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'gender', 'FEMALE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'gender', 'NON_BINARY'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'gender', 'OTHER'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'isNovice', '0'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'isNovice', '1'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'attendees', 'count'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('rsvp', 'status', 'ACCEPTED'); + + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'transportation', 'NOT_NEEDED'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'transportation', 'BUS_REQUESTED'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'transportation', 'IN_STATE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'transportation', 'OUT_OF_STATE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'transportation', 'INTERNATIONAL'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'diet', 'VEGETARIAN'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'diet', 'VEGAN'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'diet', 'NONE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'diet', 'GLUTEN_FREE'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'shirtSize', 'S'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'shirtSize', 'M'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'shirtSize', 'L'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'shirtSize', 'XL'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'gender', 'MALE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'gender', 'FEMALE'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'gender', 'NON_BINARY'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'gender', 'OTHER'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'isNovice', '0'); +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'isNovice', '1'); + +INSERT INTO `stats` (`category`, `stat`, `field`) VALUES ('liveEvent', 'attendees', 'count'); diff --git a/database/revert/V20171011_2012__addStats.revert.sql b/database/revert/V20171011_2012__addStats.revert.sql new file mode 100644 index 0000000..46d03b9 --- /dev/null +++ b/database/revert/V20171011_2012__addStats.revert.sql @@ -0,0 +1 @@ +DROP TABLE `stats`; \ No newline at end of file diff --git a/docs/Attendee-Statistics-Documentation.md b/docs/Attendee-Statistics-Documentation.md index a869036..6c9a92b 100644 --- a/docs/Attendee-Statistics-Documentation.md +++ b/docs/Attendee-Statistics-Documentation.md @@ -1,6 +1,6 @@ ### Purpose -Provides functionality for statistics. Registration and RSVP stats are cached for 10 minutes before being requeried, live event stats are cached for two minutes. +Provides functionality for statistics. Registration, RSVP, and live event stats are computed in realtime. --- @@ -21,6 +21,111 @@ None Response ``` +{ + "meta": null, + "data": { + "registration": { + "school": { + "University of Illinois at Urbana-Champaign": 4 + }, + "transportation": { + "BUS_REQUESTED": 0, + "INTERNATIONAL": 0, + "IN_STATE": 0, + "NOT_NEEDED": 9, + "OUT_OF_STATE": 0 + }, + "diet": { + "GLUTEN_FREE": 0, + "NONE": 9, + "VEGAN": 0, + "VEGETARIAN": 0 + }, + "shirtSize": { + "L": 0, + "M": 8, + "S": 0, + "XL": 0 + }, + "gender": { + "FEMALE": 0, + "MALE": 9, + "NON_BINARY": 0, + "OTHER": 0 + }, + "graduationYear": { + "2019": 3 + }, + "isNovice": { + "0": 0, + "1": 9 + }, + "status": { + "ACCEPTED": 0, + "PENDING": 0, + "REJECTED": 0, + "WAITLISTED": 0 + }, + "major": { + "Computer Science": 4 + }, + "attendees": { + "count": 9 + } + }, + "rsvp": { + "school": { + "University of Illinois at Urbana-Champaign": 1 + }, + "transportation": { + "BUS_REQUESTED": 0, + "INTERNATIONAL": 0, + "IN_STATE": 0, + "NOT_NEEDED": 1, + "OUT_OF_STATE": 0 + }, + "diet": { + "GLUTEN_FREE": 0, + "NONE": 1, + "VEGAN": 0, + "VEGETARIAN": 0 + }, + "shirtSize": { + "L": 0, + "M": 1, + "S": 0, + "XL": 0 + }, + "gender": { + "FEMALE": 0, + "MALE": 1, + "NON_BINARY": 0, + "OTHER": 0 + }, + "graduationYear": { + "2019": 1 + }, + "isNovice": { + "0": 0, + "1": 1 + }, + "major": { + "Computer Science": 1 + }, + "attendees": { + "count": 0 + } + }, + "liveevent": { + "attendees": { + "count": 17 + }, + "events": { + "Example Event": 1 + } + } + } +} ``` @@ -49,6 +154,58 @@ None Response ``` +{ + "meta": null, + "data": { + "school": { + "University of Illinois at Urbana-Champaign": 4 + }, + "transportation": { + "BUS_REQUESTED": 0, + "INTERNATIONAL": 0, + "IN_STATE": 0, + "NOT_NEEDED": 9, + "OUT_OF_STATE": 0 + }, + "diet": { + "GLUTEN_FREE": 0, + "NONE": 9, + "VEGAN": 0, + "VEGETARIAN": 0 + }, + "shirtSize": { + "L": 0, + "M": 8, + "S": 0, + "XL": 0 + }, + "gender": { + "FEMALE": 0, + "MALE": 9, + "NON_BINARY": 0, + "OTHER": 0 + }, + "graduationYear": { + "2019": 3 + }, + "isNovice": { + "0": 0, + "1": 9 + }, + "status": { + "ACCEPTED": 0, + "PENDING": 0, + "REJECTED": 0, + "WAITLISTED": 0 + }, + "major": { + "Computer Science": 4 + }, + "attendees": { + "count": 9 + } + } +} ``` @@ -77,7 +234,52 @@ None Response ``` - +{ + "meta": null, + "data": { + "school": { + "University of Illinois at Urbana-Champaign": 1 + }, + "transportation": { + "BUS_REQUESTED": 0, + "INTERNATIONAL": 0, + "IN_STATE": 0, + "NOT_NEEDED": 1, + "OUT_OF_STATE": 0 + }, + "diet": { + "GLUTEN_FREE": 0, + "NONE": 1, + "VEGAN": 0, + "VEGETARIAN": 0 + }, + "shirtSize": { + "L": 0, + "M": 1, + "S": 0, + "XL": 0 + }, + "gender": { + "FEMALE": 0, + "MALE": 1, + "NON_BINARY": 0, + "OTHER": 0 + }, + "graduationYear": { + "2019": 1 + }, + "isNovice": { + "0": 0, + "1": 1 + }, + "major": { + "Computer Science": 1 + }, + "attendees": { + "count": 0 + } + } +} ``` Errors:
@@ -105,7 +307,17 @@ None Response ``` - +{ + "metad null, + "data": { + "attendees": { + "count": 17 + }, + "events": { + "Example Event": 1 + } + } +} ``` Errors:
diff --git a/test/rsvp.js b/test/rsvp.js index 02fa642..13669f8 100644 --- a/test/rsvp.js +++ b/test/rsvp.js @@ -156,6 +156,7 @@ describe('RSVPService', () => { }); const RSVP = RSVPService.updateRSVP(testUser, testAttendeeRSVP, testRSVPClone); + RSVP.bind(this).then(() => { assert(_setRSVP.calledOnce, 'RSVP update not called with right parameters'); assert(_saveRSVP.calledOnce, 'RSVP save not called');