leaderboard.js

const _ = require('underscore');
const { request } = require('./request');
const { ensureArray, parseDate } = require('./utils');
const AV = require('./av');

/**
 * The version change interval for Leaderboard
 * @enum
 */
AV.LeaderboardVersionChangeInterval = {
  NEVER: 'never',
  DAY: 'day',
  WEEK: 'week',
  MONTH: 'month',
};

/**
 * The order of the leaderboard results
 * @enum
 */
AV.LeaderboardOrder = {
  ASCENDING: 'ascending',
  DESCENDING: 'descending',
};

/**
 * The update strategy for Leaderboard
 * @enum
 */
AV.LeaderboardUpdateStrategy = {
  /** Only keep the best statistic. If the leaderboard is in descending order, the best statistic is the highest one. */
  BETTER: 'better',
  /** Keep the last updated statistic */
  LAST: 'last',
  /** Keep the sum of all updated statistics */
  SUM: 'sum',
};

/**
 * @typedef {Object} Ranking
 * @property {number} rank Starts at 0
 * @property {number} value the statistic value of this ranking
 * @property {AV.User} user The user of this ranking
 * @property {Statistic[]} [includedStatistics] Other statistics of the user, specified by the `includeStatistic` option of `AV.Leaderboard.getResults()`
 */

/**
 * @typedef {Object} LeaderboardArchive
 * @property {string} statisticName
 * @property {number} version version of the leaderboard
 * @property {string} status
 * @property {string} url URL for the downloadable archive
 * @property {Date} activatedAt time when this version became active
 * @property {Date} deactivatedAt time when this version was deactivated by a version incrementing
 */

/**
 * @class
 */
function Statistic({ name, value, version }) {
  /**
   * @type {string}
   */
  this.name = name;
  /**
   * @type {number}
   */
  this.value = value;
  /**
   * @type {number?}
   */
  this.version = version;
}

const parseStatisticData = statisticData => {
  const { statisticName: name, statisticValue: value, version } = AV._decode(
    statisticData
  );
  return new Statistic({ name, value, version });
};

/**
 * @class
 */
AV.Leaderboard = function Leaderboard(statisticName) {
  /**
   * @type {string}
   */
  this.statisticName = statisticName;
  /**
   * @type {AV.LeaderboardOrder}
   */
  this.order = undefined;
  /**
   * @type {AV.LeaderboardUpdateStrategy}
   */
  this.updateStrategy = undefined;
  /**
   * @type {AV.LeaderboardVersionChangeInterval}
   */
  this.versionChangeInterval = undefined;
  /**
   * @type {number}
   */
  this.version = undefined;
  /**
   * @type {Date?}
   */
  this.nextResetAt = undefined;
  /**
   * @type {Date?}
   */
  this.createdAt = undefined;
};
const Leaderboard = AV.Leaderboard;

/**
 * Create an instance of Leaderboard for the give statistic name.
 * @param {string} statisticName
 * @return {AV.Leaderboard}
 */
AV.Leaderboard.createWithoutData = statisticName =>
  new Leaderboard(statisticName);
/**
 * (masterKey required) Create a new Leaderboard.
 * @param {Object} options
 * @param {string} options.statisticName
 * @param {AV.LeaderboardOrder} options.order
 * @param {AV.LeaderboardVersionChangeInterval} [options.versionChangeInterval] default to WEEK
 * @param {AV.LeaderboardUpdateStrategy} [options.updateStrategy] default to BETTER
 * @param {AuthOptions} [authOptions]
 * @return {Promise<AV.Leaderboard>}
 */
AV.Leaderboard.createLeaderboard = (
  { statisticName, order, versionChangeInterval, updateStrategy },
  authOptions
) =>
  request({
    method: 'POST',
    path: '/leaderboard/leaderboards',
    data: {
      statisticName,
      order,
      versionChangeInterval,
      updateStrategy,
    },
    authOptions,
  }).then(data => {
    const leaderboard = new Leaderboard(statisticName);
    return leaderboard._finishFetch(data);
  });
/**
 * Get the Leaderboard with the specified statistic name.
 * @param {string} statisticName
 * @param {AuthOptions} [authOptions]
 * @return {Promise<AV.Leaderboard>}
 */
AV.Leaderboard.getLeaderboard = (statisticName, authOptions) =>
  Leaderboard.createWithoutData(statisticName).fetch(authOptions);
/**
 * Get Statistics for the specified user.
 * @param {AV.User} user The specified AV.User pointer.
 * @param {Object} [options]
 * @param {string[]} [options.statisticNames] Specify the statisticNames. If not set, all statistics of the user will be fetched.
 * @param {AuthOptions} [authOptions]
 * @return {Promise<Statistic[]>}
 */
AV.Leaderboard.getStatistics = (user, { statisticNames } = {}, authOptions) =>
  Promise.resolve().then(() => {
    if (!(user && user.id)) throw new Error('user must be an AV.User');
    return request({
      method: 'GET',
      path: `/leaderboard/users/${user.id}/statistics`,
      query: {
        statistics: statisticNames
          ? ensureArray(statisticNames).join(',')
          : undefined,
      },
      authOptions,
    }).then(({ results }) => results.map(parseStatisticData));
  });

/**
 * Update Statistics for the specified user.
 * @param {AV.User} user The specified AV.User pointer.
 * @param {Object} statistics A name-value pair representing the statistics to update.
 * @param {AuthOptions} [options] AuthOptions plus:
 * @param {boolean} [options.overwrite] Wethere to overwrite these statistics disregarding the updateStrategy of there leaderboards
 * @return {Promise<Statistic[]>}
 */
AV.Leaderboard.updateStatistics = (user, statistics, options = {}) =>
  Promise.resolve().then(() => {
    if (!(user && user.id)) throw new Error('user must be an AV.User');
    const data = _.map(statistics, (value, key) => ({
      statisticName: key,
      statisticValue: value,
    }));
    const { overwrite } = options;
    return request({
      method: 'POST',
      path: `/leaderboard/users/${user.id}/statistics`,
      query: {
        overwrite: overwrite ? 1 : undefined,
      },
      data,
      authOptions: options,
    }).then(({ results }) => results.map(parseStatisticData));
  });

/**
 * Delete Statistics for the specified user.
 * @param {AV.User} user The specified AV.User pointer.
 * @param {Object} statistics A name-value pair representing the statistics to delete.
 * @param {AuthOptions} [options]
 * @return {Promise<void>}
 */
AV.Leaderboard.deleteStatistics = (user, statisticNames, authOptions) =>
  Promise.resolve().then(() => {
    if (!(user && user.id)) throw new Error('user must be an AV.User');
    return request({
      method: 'DELETE',
      path: `/leaderboard/users/${user.id}/statistics`,
      query: {
        statistics: ensureArray(statisticNames).join(','),
      },
      authOptions,
    }).then(() => undefined);
  });

_.extend(
  Leaderboard.prototype,
  /** @lends AV.Leaderboard.prototype */ {
    _finishFetch(data) {
      _.forEach(data, (value, key) => {
        if (key === 'updatedAt' || key === 'objectId') return;
        if (key === 'expiredAt') {
          key = 'nextResetAt';
        }
        if (key === 'createdAt') {
          value = parseDate(value);
        }
        if (value && value.__type === 'Date') {
          value = parseDate(value.iso);
        }
        this[key] = value;
      });
      return this;
    },
    /**
     * Fetch data from the srever.
     * @param {AuthOptions} [authOptions]
     * @return {Promise<AV.Leaderboard>}
     */
    fetch(authOptions) {
      return request({
        method: 'GET',
        path: `/leaderboard/leaderboards/${this.statisticName}`,
        authOptions,
      }).then(data => this._finishFetch(data));
    },
    /**
     * Counts the number of users participated in this leaderboard
     * @param {Object} [options]
     * @param {number} [options.version] Specify the version of the leaderboard
     * @param {AuthOptions} [authOptions]
     * @return {Promise<number>}
     */
    count({ version } = {}, authOptions) {
      return request({
        method: 'GET',
        path: `/leaderboard/leaderboards/${this.statisticName}/ranks`,
        query: {
          count: 1,
          limit: 0,
          version,
        },
        authOptions,
      }).then(({ count }) => count);
    },
    _getResults(
      {
        skip,
        limit,
        selectUserKeys,
        includeUserKeys,
        includeStatistics,
        version,
      },
      authOptions,
      userId
    ) {
      return request({
        method: 'GET',
        path: `/leaderboard/leaderboards/${this.statisticName}/ranks${
          userId ? `/${userId}` : ''
        }`,
        query: {
          skip,
          limit,
          selectUserKeys:
            _.union(
              ensureArray(selectUserKeys),
              ensureArray(includeUserKeys)
            ).join(',') || undefined,
          includeUser: includeUserKeys
            ? ensureArray(includeUserKeys).join(',')
            : undefined,
          includeStatistics: includeStatistics
            ? ensureArray(includeStatistics).join(',')
            : undefined,
          version,
        },
        authOptions,
      }).then(({ results: rankings }) =>
        rankings.map(rankingData => {
          const {
            user,
            statisticValue: value,
            rank,
            statistics = [],
          } = AV._decode(rankingData);
          return {
            user,
            value,
            rank,
            includedStatistics: statistics.map(parseStatisticData),
          };
        })
      );
    },
    /**
     * Retrieve a list of ranked users for this Leaderboard.
     * @param {Object} [options]
     * @param {number} [options.skip] The number of results to skip. This is useful for pagination.
     * @param {number} [options.limit] The limit of the number of results.
     * @param {string[]} [options.selectUserKeys] Specify keys of the users to include in the Rankings
     * @param {string[]} [options.includeUserKeys] If the value of a selected user keys is a Pointer, use this options to include its value.
     * @param {string[]} [options.includeStatistics] Specify other statistics to include in the Rankings
     * @param {number} [options.version] Specify the version of the leaderboard
     * @param {AuthOptions} [authOptions]
     * @return {Promise<Ranking[]>}
     */
    getResults(
      {
        skip,
        limit,
        selectUserKeys,
        includeUserKeys,
        includeStatistics,
        version,
      } = {},
      authOptions
    ) {
      return this._getResults(
        {
          skip,
          limit,
          selectUserKeys,
          includeUserKeys,
          includeStatistics,
          version,
        },
        authOptions
      );
    },
    /**
     * Retrieve a list of ranked users for this Leaderboard, centered on the specified user.
     * @param {AV.User} user The specified AV.User pointer.
     * @param {Object} [options]
     * @param {number} [options.limit] The limit of the number of results.
     * @param {string[]} [options.selectUserKeys] Specify keys of the users to include in the Rankings
     * @param {string[]} [options.includeUserKeys] If the value of a selected user keys is a Pointer, use this options to include its value.
     * @param {string[]} [options.includeStatistics] Specify other statistics to include in the Rankings
     * @param {number} [options.version] Specify the version of the leaderboard
     * @param {AuthOptions} [authOptions]
     * @return {Promise<Ranking[]>}
     */
    getResultsAroundUser(user, options = {}, authOptions) {
      // getResultsAroundUser(options, authOptions)
      if (user && typeof user.id !== 'string') {
        return this.getResultsAroundUser(undefined, user, options);
      }
      const {
        limit,
        selectUserKeys,
        includeUserKeys,
        includeStatistics,
        version,
      } = options;
      return this._getResults(
        { limit, selectUserKeys, includeUserKeys, includeStatistics, version },
        authOptions,
        user ? user.id : 'self'
      );
    },
    _update(data, authOptions) {
      return request({
        method: 'PUT',
        path: `/leaderboard/leaderboards/${this.statisticName}`,
        data,
        authOptions,
      }).then(result => this._finishFetch(result));
    },
    /**
     * (masterKey required) Update the version change interval of the Leaderboard.
     * @param {AV.LeaderboardVersionChangeInterval} versionChangeInterval
     * @param {AuthOptions} [authOptions]
     * @return {Promise<AV.Leaderboard>}
     */
    updateVersionChangeInterval(versionChangeInterval, authOptions) {
      return this._update({ versionChangeInterval }, authOptions);
    },
    /**
     * (masterKey required) Update the version change interval of the Leaderboard.
     * @param {AV.LeaderboardUpdateStrategy} updateStrategy
     * @param {AuthOptions} [authOptions]
     * @return {Promise<AV.Leaderboard>}
     */
    updateUpdateStrategy(updateStrategy, authOptions) {
      return this._update({ updateStrategy }, authOptions);
    },
    /**
     * (masterKey required) Reset the Leaderboard. The version of the Leaderboard will be incremented by 1.
     * @param {AuthOptions} [authOptions]
     * @return {Promise<AV.Leaderboard>}
     */
    reset(authOptions) {
      return request({
        method: 'PUT',
        path: `/leaderboard/leaderboards/${this.statisticName}/incrementVersion`,
        authOptions,
      }).then(data => this._finishFetch(data));
    },
    /**
     * (masterKey required) Delete the Leaderboard and its all archived versions.
     * @param {AuthOptions} [authOptions]
     * @return {void}
     */
    destroy(authOptions) {
      return AV.request({
        method: 'DELETE',
        path: `/leaderboard/leaderboards/${this.statisticName}`,
        authOptions,
      }).then(() => undefined);
    },
    /**
     * (masterKey required) Get archived versions.
     * @param {Object} [options]
     * @param {number} [options.skip] The number of results to skip. This is useful for pagination.
     * @param {number} [options.limit] The limit of the number of results.
     * @param {AuthOptions} [authOptions]
     * @return {Promise<LeaderboardArchive[]>}
     */
    getArchives({ skip, limit } = {}, authOptions) {
      return request({
        method: 'GET',
        path: `/leaderboard/leaderboards/${this.statisticName}/archives`,
        query: {
          skip,
          limit,
        },
        authOptions,
      }).then(({ results }) =>
        results.map(({ version, status, url, activatedAt, deactivatedAt }) => ({
          statisticName: this.statisticName,
          version,
          status,
          url,
          activatedAt: parseDate(activatedAt.iso),
          deactivatedAt: parseDate(deactivatedAt.iso),
        }))
      );
    },
  }
);