Source: models/review.js

'use strict';

/**
 * Model for reviews, including the full text.
 *
 * @namespace Review
 */

const thinky = require('../db');
const type = thinky.type;
const r = thinky.r;
const mlString = require('./helpers/ml-string');

const ReportedError = require('../util/reported-error');
const User = require('./user');
const Thing = require('./thing');
const File = require('./file');

const revision = require('./helpers/revision');
const isValidLanguage = require('../locales/languages').isValid;
const adapters = require('../adapters/adapters');

const reviewOptions = {
  maxTitleLength: 255
};

/* eslint-disable newline-per-chained-call */
/* for schema readability */

let reviewSchema = {
  id: type.string().uuid(4),
  thingID: type.string().uuid(4),
  title: mlString.getSchema({
    maxLength: reviewOptions.maxTitleLength
  }),
  text: mlString.getSchema(),
  html: mlString.getSchema(),
  starRating: type.number().min(1).max(5).integer(),

  // Track original authorship across revisions
  createdOn: type.date().required(true),
  createdBy: type.string().uuid(4).required(true),
  // We track this for all objects where we want to be able to handle
  // translation permissions separately from edit permissions
  originalLanguage: type.string().max(4).validator(isValidLanguage),

  socialImageID: type.string().uuid(4),

  // These can only be populated from the outside using a user object
  userCanDelete: type.virtual().default(false),
  userCanEdit: type.virtual().default(false),
  userIsAuthor: type.virtual().default(false)
};

/* eslint-enable newline-per-chained-call */
/* for schema readability */

// Add versioning related fields
Object.assign(reviewSchema, revision.getSchema());

// Table generation is handled by thinky. URLs for reviews are stored as "things".
let Review = thinky.createModel("reviews", reviewSchema);

Review.options = reviewOptions;
Object.freeze(Review.options);

Review.belongsTo(User, "creator", "createdBy", "id");
Review.belongsTo(Thing, "thing", "thingID", "id");
Review.belongsTo(File, "socialImage", "socialImageID", "id");
Thing.hasMany(Review, "reviews", "id", "thingID");
Review.ensureIndex("createdOn");


// NOTE: STATIC METHODS START HERE ---------------------------------------------

// Standard handlers -----------------------------------------------------------

Review.filterNotStaleOrDeleted = revision.getNotStaleOrDeletedFilterHandler(Review);
Review.getNotStaleOrDeleted = revision.getNotStaleOrDeletedGetHandler(Review);

// Custom methods --------------------------------------------------------------

/**
 * Get a review by ID, including commonly joined data: the review subject
 * (thing), the user who created the review, and the teams with which it was
 * associated. **WARNING:** since the password is filtered out, any future calls
 * to `saveAll()` must be explicitly parametrized to *not* include the user, or
 * the save will throw an error.
 *
 * @async
 * @param {String} id
 *  the unique ID to look up
 * @returns {Review}
 *  the review and associated data
 */
Review.getWithData = async function(id) {
  return await Review
    .getNotStaleOrDeleted(id, {
      thing: {
        files: true,
      },
      teams: true,
      socialImage: true,
      creator: {
        _apply: seq => seq.without('password')
      }
    });
};

/**
 * Create and save a review and the associated Thing and Teams. If there is no
 * Thing record, this function creates and saves one via
 * {@link Review.findOrCreateThing}.
 *
 * @async
 * @param {Object} reviewObj
 *  object containing the data to associate with this review, as defined in
 *  Review schema.
 * @param {Object} [options]
 *  options for the created revision
 * @param {String[]} options.tags
 *  tags to associate with this revision
 * @param {String[]} options.files
 *  UUIDs of files to add to the Thing for this review
 * @returns {Review}
 *  the saved review
 */
Review.create = async function(reviewObj, { tags, files } = {}) {
  const thing = await Review.findOrCreateThing(reviewObj);

  const existingReviews = await Review
    .filter({
      thingID: thing.id,
      createdBy: reviewObj.createdBy
    })
    .filter({ _oldRevOf: false }, { default: true })
    .filter({ _revDeleted: false }, { default: true });

  if (existingReviews.length)
    throw new ReportedError({
      message: 'User has previously reviewed this subject.',
      userMessage: 'previously reviewed submission',
      userMessageParams: [
          `/review/${existingReviews[0].id}`,
          `/review/${existingReviews[0].id}/edit`
      ]
    });

  // Only images associated with the review subject can be selected
  Review.validateSocialImage({
    socialImageID: reviewObj.socialImageID,
    newFileIDs: files,
    fileObjects: thing.files
  });

  // If we uploaded files in the process of writing this review, we add them
  // to the associated review subject
  if (Array.isArray(files))
    await thing.addFilesByIDsAndSave(files, reviewObj.createdBy);

  let review = new Review({
    thing, // joined
    teams: reviewObj.teams, // joined
    title: reviewObj.title,
    text: reviewObj.text,
    html: reviewObj.html,
    starRating: reviewObj.starRating,
    createdOn: reviewObj.createdOn,
    createdBy: reviewObj.createdBy,
    originalLanguage: reviewObj.originalLanguage,
    socialImageID: reviewObj.socialImageID,
    _revID: r.uuid(),
    _revUser: reviewObj.createdBy,
    _revDate: reviewObj.createdOn,
    _revTags: tags
  });
  try {
    return await review.saveAll({
      teams: true,
      thing: true
    });
  } catch (error) {
    if (error instanceof ReviewError)
      throw error;
    else
      throw new ReviewError({
        parentError: error,
        payload: {
          review
        }
      });
  }
};

/**
 * Ensure that the social media image specified for this review is associated
 * already with the review subject, or in the list of newly uploaded files.
 * Throws a ReviewError if not.
 *
 * @param {Object} [options]
 *  Validation data
 * @param {String} options.socialImageID
 *  Social media image UUID
 * @param {String[]} options.newFileIDs
 *  Array of newly uploaded file IDs
 * @param {File[]} options.fileObjects
 *  Array of previously uploaded file objects associated with review subject
 *
 */
Review.validateSocialImage = function({
  socialImageID = undefined,
  newFileIDs = [],
  fileObjects = []
  } = {}) {

  if (socialImageID) {
    let isValidFile = false;
    for (let file of fileObjects) {
      if (file.id == socialImageID)
        isValidFile = true;
    }
    for (let fileID of newFileIDs) {
      if (fileID == socialImageID)
        isValidFile = true;
    }
    if (!isValidFile)
      throw new ReviewError({ userMessage: 'invalid image selected' });
  }
};


/**
 * Locate the review subject (Thing) for a new review, or create and save a new
 * Thing based on the provided URL. This will also perform adapter lookups for
 * external metadata (e.g., from Wikidata).
 *
 * This function is called from {@link Review.create}.
 *
 * @async
 * @param {Object} reviewObj
 *  the data associated with the review we're locating or creating a Thing
 *  record for
 * @returns {Thing}
 *  the located or created Thing
 */
Review.findOrCreateThing = async function(reviewObj) {
  // We have an existing thing to add this review to
  if (reviewObj.thing)
    return reviewObj.thing;

  let queries = [
    Thing.lookupByURL(reviewObj.url),
    // Look up this URL in adapters that support it. Promises will not reject,
    // so can be added to Promise.all below. Order is specified in adapters.js
    // and is important (see below)
    ...adapters.getSupportedLookupsAsSafePromises(reviewObj.url)
  ];

  const results = await Promise.all(queries);
  let things = results.shift();
  if (things.length)
    return things[0]; // we have an entry with this URL already

  // Let's make one!
  let thing = new Thing({});
  let date = new Date();
  thing.urls = [reviewObj.url];
  thing.createdOn = date;
  thing.createdBy = reviewObj.createdBy;
  thing._revDate = date;
  thing._revUser = reviewObj.createdBy;
  thing._revID = r.uuid();
  thing.originalLanguage = reviewObj.originalLanguage;

  // The first result ("first" in the array of adapters) for the URL
  // specified by the user will be used to initalize values like label,
  // description, subtitle, authors, or other supported fields. These
  // fields will also be set to read-only, with synchronization metadata
  // stored in the thing.sync property.
  thing.initializeFieldsFromAdapter(adapters.getFirstResultWithData(results));

  // If the user provided a valid label, it always overrides any label
  // from an adapter.
  if (reviewObj.label && reviewObj.label[reviewObj.originalLanguage])
    thing.label = reviewObj.label;

  // Set short identifier (slug) if we have a label for this review subject
  if (thing.label)
    thing = await thing.updateSlug(reviewObj.createdBy, reviewObj.originalLanguage);

  thing = await thing.save();
  return thing;
};


/**
 * Get an ordered array of reviews, optionally filtered by user, date, review
 * subject, and other criteria.
 *
   @async
 * @param {Object} [options]
 *  Feed selection criteria
 * @param {User} options.createdBy
 *  author to filter by
 * @param {Date} options.offsetDate
 *  get reviews older than this date
 * @param {Boolean} options.onlyTrusted=false
 *  only get reviews by users whose user.isTrusted is truthy. Is applied after
 *  the limit, so you may end up with fewer reviews than specified.
 * @param {String} options.thingID
 *  only get reviews of the Thing with the provided ID
 * @param {Boolean} options.withThing=true
 *  join the associated Thing object with each review
 * @param {Boolean} options.withTeams=true
 *  join the associated Team objects with each review
 * @param {String} options.withoutCreator
 *  exclude reviews by the user with the provided ID
 * @param {Number} options.limit=10
 *  how many reviews to load
 *
 * @returns {Review[]}
 *  the reviews matching the provided criteria
 */
Review.getFeed = async function({
  createdBy = undefined,
  offsetDate = undefined,
  onlyTrusted = false,
  thingID = undefined,
  withThing = true,
  withTeams = true,
  withoutCreator = undefined,
  limit = 10
} = {}) {

  let query = Review;

  if (offsetDate && offsetDate.valueOf)
    query = query.between(r.minval, r.epochTime(offsetDate.valueOf() / 1000), {
      index: 'createdOn',
      rightBound: 'open' // Do not return previous record that exactly matches offset
    });

  query = query.orderBy({ index: r.desc('createdOn') });

  if (thingID)
    query = query.filter({ thingID });

  if (withoutCreator)
    query = query.filter(r.row("createdBy").ne(withoutCreator));

  if (createdBy)
    query = query.filter({ createdBy });

  query = query
    .filter(r.row('_revDeleted').eq(false), { default: true }) // Exclude deleted
    .filter(r.row('_oldRevOf').eq(false), { default: true }) // Exclude old
    .limit(limit + 1); // One over limit to check if we need potentially another set

  if (withThing)
    query = query.getJoin({ thing: true });

  if (withTeams)
    query = query.getJoin({ teams: true });

  query = query.getJoin({
    creator: {
      _apply: seq => seq.without('password')
    }
  });

  let feedItems = await query;
  const result = {};

  // At least one additional document available, set offset for pagination
  if (feedItems.length == limit + 1) {
    result.offsetDate = feedItems[limit - 1].createdOn;
    feedItems.pop();
  }

  if (onlyTrusted)
    feedItems = feedItems.filter(item => item.creator.isTrusted ? true : false);

  result.feedItems = feedItems;
  return result;

};

// NOTE: INSTANCE METHODS START HERE -------------------------------------------

// Standard handlers -----------------------------------------------------------

Review.define("newRevision", revision.getNewRevisionHandler(Review));
Review.define("deleteAllRevisions", revision.getDeleteAllRevisionsHandler(Review));

// Custom methods

Review.define("populateUserInfo", populateUserInfo);
Review.define("deleteAllRevisionsWithThing", deleteAllRevisionsWithThing);

/**
 * Populate virtual fields with permissions for a given user
 *
 * @param {User} user
 *  the user whose permissions to check
 * @memberof Review
 * @instance
 */
function populateUserInfo(user) {
  if (!user)
    return; // fields will be at their default value (false)

  if (user.isSuperUser || user.isSiteModerator || user.id === this.createdBy)
    this.userCanDelete = true;

  if (user.isSuperUser || user.id === this.createdBy)
    this.userCanEdit = true;

  if (user.id === this.createdBy)
    this.userIsAuthor = true;
}

/**
 * Delete all revisions of a review including the associated review subject
 * (thing).
 *
 * @param {User} user
 *  user initiating the action
 * @returns {Promise}
 *  promise that resolves when all content has been deleted
 * @memberof Review
 * @instance
 */
function deleteAllRevisionsWithThing(user) {

  let p1 = this
    .deleteAllRevisions(user, {
      tags: ['delete-with-thing']
    });

  // We rely on the thing property having been populated. This will fail on
  // a shallow Review object!
  let p2 = this.thing
    .deleteAllRevisions(user, {
      tags: ['delete-via-review']
    });

  return Promise.all([p1, p2]);

}

/**
 * Custom error class that detects validation errors reported by the model
 * and translates them to internationalized messages
 */
class ReviewError extends ReportedError {

  /**
   * @param {Object} [options]
   *  error data
   * @param {Review} options.payload
   *  the review that triggered this error
   * @param {Error} options.parentError
   *  the original error
   */
  constructor(options) {
    if (typeof options == 'object' && options.parentError instanceof Error &&
      typeof options.payload.review == 'object') {
      switch (options.parentError.message) {
        case 'Value for [starRating] must be greater than or equal to 1.':
        case 'Value for [starRating] must be less than or equal to 5.':
        case 'Value for [starRating] must be an integer.':
        case 'Value for [starRating] must be a finite number or null.':
          options.userMessage = 'invalid star rating';
          options.userMessageParams = [String(options.payload.review.starRating)];
          break;
        case `Value for [title][${options.payload.review.originalLanguage}] must not be longer than ${Review.options.maxTitleLength}.`:
          options.userMessage = 'review title too long';
          break;
        case 'Validator for the field [originalLanguage] returned `false`.':
          options.userMessage = 'invalid language';
          options.userMessageParams = [String(options.payload.review.originalLanguage)];
          break;
        default:
      }
    }
    super(options);
  }

}

module.exports = Review;