Asymmetrik/akin

View on GitHub
lib/activity.service.js

Summary

Maintainability
A
0 mins
Test Coverage
'use strict';

const _ = require('lodash'),
    Promise = require('bluebird'),
    moment = require('moment');

// Default values. Allow for overriding
let ageOffConfig = {
    maxDays: 180,
    exponent: 3,
    easing: 2
};

/**
 * How many users will be processed at a time
 */
let concurrency = 2;

/**
 * Default weight for actions not configured.
 */
const defaultActionWeight = 1;

/**
 * Non-default max weights for given actions. Otherwise, defaults to 1
 */
const actionWeights = {};

const ModelService = require('./model.service'),
    UserActivity = ModelService.model.UserActivity,
    UserItemWeights = ModelService.model.UserItemWeights;

/**
 * Saves a user action on an item to the database.
 * @param {string} userId a unique identifier for the user
 * @param {string} itemId a unique identifier for the item
 * @param {object} itemMetadata metadata about the item that may be used in scoring weights
 * @param {string} action the action that the user took on the item
 * @return {promise} returns a promise after the user activity was saved
 */
const log = (userId, itemId, itemMetadata, action) => {

    return new Promise((resolve, reject) => {
        new UserActivity({
            user: userId,
            item: itemId,
            itemMetadata: itemMetadata,
            action: action
        })
            .save()
            .then(resolve, reject);

    });

};

const removeLog = (userId, item, action) => {

    return new Promise((resolve, reject) => {
        UserActivity
            .remove({
                user: userId,
                item: item,
                action: action
            })
            .then(resolve, reject);
    });

};

const convertItemsFromMapToArray = (itemMap) => {
    return _.map(_.keys(itemMap), (itemId) => {
        return itemMap[itemId];
    });
};

const calculateRowWeight = (itemWeights) => {
    var weightValues = _.map(itemWeights, 'weight');
    var rowWeight = _.reduce( weightValues, (sum, itemWeight) => {
        return sum + (itemWeight * itemWeight);
    }, 0); // initial value of 0
    return Math.sqrt(rowWeight);
};

/**
 * Calculate how old this activity is.
 * If it mistakenly in the future, use 0 for present time
 */
const getDaysOld = ( inputDate ) => {
    var created = moment( inputDate );
    var age = moment().diff(created, 'days');
    return (age < 0) ? 0 : age;
};

/**
 * Based on the type and age of the activity, return a normalized activity weight
 * to be added to that user's overall activity weight for a particular item
 */
const getActivityWeight = (userActivity) => {

    const maxActionWeight = _.has(actionWeights, userActivity.action) ?
                            actionWeights[userActivity.action] :
                            defaultActionWeight;

    // Time-based decay function using an inverted ease in out cubic (configurable)
    var daysOld = getDaysOld(userActivity.dateCreated);

    var maxDays = ageOffConfig.maxDays,
        exponent = ageOffConfig.exponent,
        easing = ageOffConfig.easing;

    if( daysOld > maxDays ) {
        return 0;
    }

    // between 0 and 1
    var relativeAge = daysOld / maxDays;
    // percentage will be between 0 and 1
    const percentage = (relativeAge < 0.5) ?
                    (1 - easing * Math.pow(relativeAge, exponent)) :
                    (easing * Math.pow((1 - relativeAge), exponent));

    return maxActionWeight * percentage;
};

const handleUserActivityData = (itemMap, userActivity) => {
    var itemId = userActivity.item;
    if( !_.has(itemMap, itemId) ) {
        itemMap[itemId] = {
            item: itemId,
            itemMetadata: userActivity.itemMetadata,
            weight: 0
        };
    }

    itemMap[itemId].weight += getActivityWeight(userActivity);

};

const calculateUserItemWeightsFromActivity = (userId, activitiesCursor) => {

    return new Promise((resolve, reject) => {

        var itemMap = {};

        activitiesCursor.on('data', handleUserActivityData.bind(this, itemMap));

        activitiesCursor.on('end', () => {
            var itemWeights = convertItemsFromMapToArray(itemMap);

            var rowWeight = calculateRowWeight(itemWeights);

            var userItemWeights = {
                user: userId,
                itemWeights: itemWeights,
                rowWeight: rowWeight
            };

            resolve(userItemWeights);
        });

    });

};

const getActivityAndCalculateItemWeightsForUserId = (userId) => {
    return ModelService.getActivityForUserCursor(userId)
        .then( calculateUserItemWeightsFromActivity.bind(this, userId) )
        .then( (userItemWeights) => {
            return new Promise((resolve, reject) => {
                new UserItemWeights(userItemWeights)
                    .save()
                    .then(() => {
                        resolve(); // ignore the response so it can be garbage collected
                    },
                    reject);
            });
        });
};

const recalculateUserItemWeights = () => {

    return ModelService.dropUserItemWeights()
        .then( ModelService.getAllUserIdsWithActivity )
        .then( (userIds) => {
            return Promise.map(userIds, getActivityAndCalculateItemWeightsForUserId, { concurrency: concurrency });
        });

};

/**
 * Sets the age-off configuration to something other than the default
 * @param {object} newAgeOffConfig - the configuration of the age-off criteria. All attributes are required.
 * Default value:
 * {
 *   maxDays: 180,
 *   exponent: 3,
 *   easing: 2
 * }
 */
const setAgeOffConfig = (newAgeOffConfig) => {
    ageOffConfig = newAgeOffConfig;
};

/**
 * Updates the max, default weight for an action to the input value
 * @param {string} action the action that will use the configured weight
 * @param {number} weight the default, max weight for an action
 */
const setActionWeight = (action, weight) => {
    actionWeights[action] = weight;
};

/**
 * Updates the concurrency level used for calculating user-to-item weights. Default: 2
 * @param {number} newConcurrency - the number of concurrent users whose weights will be calculated for the items
 */
const setConcurrency = (newConcurrency) => {
    concurrency = newConcurrency;
};

module.exports = {
    log,
    recalculateUserItemWeights,
    removeLog,
    setActionWeight,
    setAgeOffConfig,
    setConcurrency
};