Source: lib/redis-leaderboard.js

/**
 * @module  redis-leaderboard
 * @desc    The leaderboard-api Redis leaderboard module.
 * @version 1.0.0
 * @author  Essam A. El-Sherif
 */

/**
 * @class
 * @static
 * @desc Class to manage leaderboard system using Redis.
 */
export class LeaderBoard{

	/**
	 * @method   constructor
	 * @instance
	 * @memberof module:redis-leaderboard.LeaderBoard
	 * @param    {object} client - Redis client object.
	 * @param    {string} keyA - Namespace for Redis activities key(s).
	 * @param    {string} keyT - Namespace for Redis timestamp key(s).
	 * @desc     Constructs and returns a LeaderBoard object.
	 */
	constructor(client, keyT, keyA){
		this.client = client;
		this.keyT = keyT;
		this.keyA = keyA;
	}

	/**
	 * @method
	 * @instance
	 * @memberof module:redis-leaderboard.LeaderBoard
	 * @param    {string} activity.
	 * @param    {string} username.
	 * @param    {number} score.
	 * @return   {Promise} A Promise object that will fulfill upon adding the user's activity/score.
	 * @desc     Add a user activity/score.
	 */
	addUserScore(activity, username, score){

		let timestamp = String(Date.now());
		score = String(score);

		return this.client.sendCommand([
			'HSET',
			`${this.keyT}${activity}:${username}`,
			'timestamp', timestamp,
		])
		.then(()=> {
			return this.client.sendCommand([
				'ZADD',
				`${this.keyA}${activity}`,
				score, username
			]);
		})
		.then(() => {
			return {activity, username, score, timestamp};
		});
	}

	/**
	 * @method
	 * @instance
	 * @memberof module:redis-leaderboard.LeaderBoard
	 * @param    {string} username.
	 * @param    {string} activity - (Optional).
	 * @return   {Promise} A Promise object that will fulfill upon removing the user's activity/score.
	 * @desc     Remove a user's activity/score, or remove all user activities if second parameter was not given.
	 */
	removeUserScore(username, activity){

		let result;

		return this.client.sendCommand([
			'KEYS',
			`${this.keyT}${activity || '*'}:${username}`,
		])
		.then((res) => {
			res = res.filter(str => str.endsWith(username));
			result = res.map(key => ({
						activity: key.replace(`${this.keyT}`, '').replace(`:${username}`, ''),
						username
					}));
			if(res.length)
				return Promise.all(res.map(tsKey => this.client.sendCommand(['DEL', tsKey])));
			else return null;
		})
		.then((res) => {
			if(res)
				return this.client.sendCommand(['KEYS', `${this.keyA}${activity || '*'}`]);
			else return null;
		})
		.then((res) => {
			if(res)
				return Promise.all(res.map(key => this.client.sendCommand(['ZREM', key, username])));
			else return null;
		})
		.then(() => {
			return result;
		});
	}

	/**
	 * @method
	 * @instance
	 * @memberof module:redis-leaderboard.LeaderBoard
	 * @param    {string} activity.
	 * @param    {string} username.
	 * @return   {Promise} A Promise object that will fulfill with the user score and rank object.
	 * @desc     Return a user score and rank object.
	 * @throws   {Error} Throws an error if the given activity does not exist.
	 */
	getUserScoreAndRank(activity, username){
		let score, rank, timestamp;

		return this.client.sendCommand([
			'KEYS',
			`${this.keyA}${activity}`,
		])
		.then((res) => {
			if(!res.length){
				throw new Error(`no activity '${activity}'`);
			}
			return this.client.sendCommand([
				'ZSCORE',
				`${this.keyA}${activity}`, username
			]);
		})
		.then((res) => {
			if(res !== null){
				score = res;
				return this.client.sendCommand([
					'ZREVRANK',
					`${this.keyA}${activity}`, username
				]);
			}
			else return res;
		})
		.then((res) => {
			if(res !== null){
				rank = res + 1;
				return this.client.sendCommand([
					'HGET',
					`${this.keyT}${activity}:${username}`, 'timestamp'
				]);
			}
			else return res;
		})
		.then((res) => {
			if(res !== null){
				timestamp = res;
				return {activity, username, score, timestamp, rank};
			}
			else return {};
		});
	}

	/**
	 * @method
	 * @instance
	 * @memberof module:redis-leaderboard.LeaderBoard
	 * @param    {string} activity
	 * @return   {Promise} A Promise object that will fulfill with a leaderboard array of objects.
	 * @desc     Retrieve a leaderboard array of objects for such activity.
	 * @throws   {Error} Throws an error if the given activity does not exist.
	 */
	getActivity(activity){
		let actUser = [];
		let tsKeys = null, tsValues = null;

		return this.client.sendCommand([
			'KEYS',
			`${this.keyA}${activity}`,
		])
		.then((res) => {
			if(!res.length){
				throw new Error(`no activity '${activity}'`);
			}
			return this.client.sendCommand([
				'ZREVRANGE',
				`${this.keyA}${activity}`,
				'0', '-1', 'WITHSCORES'
			]);
		})
		.then((res) => {

			for(let i = 0; i < res.length; i += 2){
				let entry = {};
				entry.activity  = activity;
				entry.username  = res[i];
				entry.score     = res[i+1];
				entry.timestamp = -1;
				entry.rank      = i / 2 + 1;
				actUser.push(entry);
			}

			return this.client.sendCommand([
				'KEYS',
				`${this.keyT}${activity}*`,
			]);
		})
		.then((res) => {
			tsKeys = res;
			return Promise.all(res.map(key =>
				this.client.sendCommand([
					'HGET', key, 'timestamp'])
				));
		})
		.then((res) => {
			tsValues = res;

			let tsObj = {};
			for(let i = 0; i < tsKeys.length; i++){
				tsObj[tsKeys[i].replace(`${this.keyT}`, '')] = tsValues[i];
			}

			for(let i = 0; i < actUser.length; i++){
				let key = `${actUser[i].activity}:${actUser[i].username}`;
				actUser[i].timestamp = tsObj[key];
			}

			return actUser;
		});
	}

	/**
	 * @method
	 * @instance
	 * @memberof module:redis-leaderboard.LeaderBoard
	 * @return   {Promise} A Promise object that will fulfill with a leaderboard array of objects.
	 * @desc     Retrieve a leaderboard array of objects for all activities with rank.
	 */
	getActivities(){
		let actUser = [];
		let actKeys = null, actValues = null;
		let tsKeys = null, tsValues = null;

		return this.client.sendCommand([
			'KEYS',
			`${this.keyA}*`,
		])
		.then((res) => {
			actKeys = res;
			return Promise.all(res.map(key =>
				this.client.sendCommand([
					'ZRANGE',
					key,
					'0', '-1', 'WITHSCORES'])
				)
			);
		})
		.then((res) => {
			actValues = res;

			for(let i = 0; i < actValues.length; i++){
				for(let j = 0; j < actValues[i].length; j += 2){
					let entry = {};
					entry.activity = actKeys[i].replace(`${this.keyA}`, '');
					entry.username = actValues[i][j];
					entry.score    = actValues[i][j+1];
					entry.timestamp = -1;
					entry.rank      = -1;
					actUser.push(entry);
				}
			}

			return this.client.sendCommand([
				'KEYS',
				`${this.keyT}*`,
			]);
		})
		.then((res) => {
			tsKeys = res;
			return Promise.all(res.map(key =>
				this.client.sendCommand([
					'HGET', key, 'timestamp'])
			));
		})
		.then((res) => {
			tsValues = res;

			let tsObj = {};
			for(let i = 0; i < tsKeys.length; i++){
				tsObj[tsKeys[i].replace(`${this.keyT}`, '')] = tsValues[i];
			}

			for(let i = 0; i < actUser.length; i++){
				let key = `${actUser[i].activity}:${actUser[i].username}`;
				actUser[i].timestamp = tsObj[key];
			}

			actUser.sort((a, b) => {
				if(+a.score < +b.score) return 1;
				else if(+a.score > +b.score) return -1;
				else if(a.activity < b.activity) return -1;
				else if(a.activity > b.activity) return 1;
				else if(a.username < b.username) return -1;
				else return 1;
			})
			.forEach((e, i, a) => e.rank = i + 1);

			return actUser;
		});
	}

	/**
	 * @method
	 * @instance
	 * @memberof module:redis-leaderboard.LeaderBoard
	 * @param    {string} username
	 * @return   {Promise} A Promise object that will fulfill with a leaderboard array of objects.
	 * @desc     Retrieve a leaderboard array of objects for all activities of the given username.
	 */
	getUserActivities(username){
		let actUser = [];
		let actKeys = null, actValues = null;
		let tsKeys = null, tsValues = null;

		return this.client.sendCommand([
			'KEYS',
			`${this.keyT}*:${username}`,
		])
		.then((res) => {
			tsKeys = res;

			return Promise.all(res.map(key =>
				this.client.sendCommand([
					'HGET', key, 'timestamp'])
			));
		})
		.then((res) => {
			tsValues = res;

			actKeys = tsKeys
				.map(key => key.replace(`:${username}`, ''))
				.map(key => key.replace(`${this.keyT}`, `${this.keyA}`));

			return Promise.all(actKeys.map(key =>
				this.client.sendCommand([
					'ZRANGE',
					key,
					'0', '-1', 'WITHSCORES'])
				)
			);
		})
		.then((res) => {
			for(let i = 0; i < res.length; i++){
				for(let j = 0; j < res[i].length; j += 2){
					if(res[i][j] === username){
						res[i] = [res[i][j], res[i][j+1]];
						break;
					}
				}
			}
			actValues = res;
			for(let i = 0; i < actValues.length; i++){
				let entry = {};
				entry.activity = actKeys[i].replace(`${this.keyA}`, '');
				entry.username = actValues[i][0];
				entry.score    = actValues[i][1];
				entry.timestamp = -1;
				entry.rank      = -1;
				actUser.push(entry);
			}

			let tsObj = {};
			for(let i = 0; i < tsKeys.length; i++){
				tsObj[tsKeys[i].replace(`${this.keyT}`, '')] = tsValues[i];
			}

			for(let i = 0; i < actUser.length; i++){
				let key = `${actUser[i].activity}:${actUser[i].username}`;
				actUser[i].timestamp = tsObj[key];
			}

			actUser.sort((a, b) => {
				if(+a.score < +b.score) return 1;
				else if(+a.score > +b.score) return -1;
				else if(a.activity < b.activity) return -1;
				else if(a.activity > b.activity) return 1;
				else return 1;
			})
			.forEach((e, i, a) => e.rank = i + 1);

			return actUser;
		});
	}

	/**
	 * @method
	 * @instance
	 * @memberof module:redis-leaderboard.LeaderBoard
	 * @param    {string} activity.
	 * @param    {number} n.
	 * @return   {Promise} A Promise object that will fulfill with a leaderboard array of objects.
	 * @desc     Retrieve a leaderboard array of objects for n top users of the given activity.
	 */
	getActivityTopUsers(activity, n){

		let tsKeys = null, tsValues = null;

		return this.client.sendCommand([
			'KEYS',
			`${this.keyT}${activity}*`
		])
		.then((res) => {
			tsKeys = res;
			return Promise.all(res.map(key =>
				this.client.sendCommand([
					'HGET', key, 'timestamp'])
				));
		})
		.then((res) => {
			tsValues = res;
			return this.client.sendCommand([
				'ZREVRANGE',
				`${this.keyA}${activity}`, String(0), String(n - 1), 'WITHSCORES']);
		})
		.then((res) => {

			let tsObj = {};
			for(let i = 0; i < tsKeys.length; i++){
				tsObj[tsKeys[i].replace(`${this.keyT}`, '').replace(`${activity}:`, '')] = tsValues[i];
			}

			const actUser = [];
			for(let i = 0, rank = 1 ; i < res.length ; i += 2, rank++){
				actUser.push({ activity, username: res[i], score: `${res[i + 1]}`, timestamp: tsObj[res[i]], rank: rank });
			}
			return actUser;
		});
	}
}