<commit_before>import { Mongo } from 'meteor/mongo';
import SimpleSchema from 'simpl-schema';
import { addGraphQLCollection, addToGraphQLContext } from './graphql';
import { Utils } from './utils.js';
import { runCallbacks, runCallbacksAsync, registerCallback, addCallback } from './callbacks.js';
import { getSetting, registerSetting } from './settings.js';
import { registerFragment, getDefaultFragmentText } from './fragments.js';
import escapeStringRegexp from 'escape-string-regexp';
import { validateIntlField, getIntlString, isIntlField, schemaHasIntlFields } from './intl';
import clone from 'lodash/clone';
import isEmpty from 'lodash/isEmpty';
import moment from 'moment';

const wrapAsync = Meteor.wrapAsync ? Meteor.wrapAsync : Meteor._wrapAsync;
// import { debug } from './debug.js';

registerSetting('maxDocumentsPerRequest', 1000, 'Maximum documents per request');

// will be set to `true` if there is one or more intl schema fields
export let hasIntlFields = false;

export const Collections = [];

export const getCollection = name =>
  Collections.find(
    ({ options: { collectionName } }) =>
      name === collectionName || name === collectionName.toLowerCase()
  );

// TODO: find more reliable way to get collection name from type name?
export const getCollectionName = typeName => Utils.pluralize(typeName);

// TODO: find more reliable way to get type name from collection name?
export const getTypeName = collectionName => collectionName.slice(0, -1);

/**
 * @summary replacement for Collection2's attachSchema. Pass either a schema, to
 * initialize or replace the schema, or some fields, to extend the current schema
 * @class Mongo.Collection
 */
Mongo.Collection.prototype.attachSchema = function(schemaOrFields) {
  if (schemaOrFields instanceof SimpleSchema) {
    this.simpleSchema = () => schemaOrFields;
  } else {
    this.simpleSchema().extend(schemaOrFields);
  }
};

/**
 * @summary Add an additional field (or an array of fields) to a schema.
 * @param {Object|Object[]} field
 */
Mongo.Collection.prototype.addField = function(fieldOrFieldArray) {
  const collection = this;
  const fieldSchema = {};

  const fieldArray = Array.isArray(fieldOrFieldArray) ? fieldOrFieldArray : [fieldOrFieldArray];

  // loop over fields and add them to schema (or extend existing fields)
  fieldArray.forEach(function(field) {
    fieldSchema[field.fieldName] = field.fieldSchema;
  });

  // add field schema to collection schema
  collection.attachSchema(fieldSchema);
};

/**
 * @summary Remove a field from a schema.
 * @param {String} fieldName
 */
Mongo.Collection.prototype.removeField = function(fieldName) {
  var collection = this;
  var schema = _.omit(collection.simpleSchema()._schema, fieldName);

  // add field schema to collection schema
  collection.attachSchema(new SimpleSchema(schema));
};

/**
 * @summary Add a default view function.
 * @param {Function} view
 */
Mongo.Collection.prototype.addDefaultView = function(view) {
  this.defaultView = view;
};

/**
 * @summary Add a named view function.
 * @param {String} viewName
 * @param {Function} view
 */
Mongo.Collection.prototype.addView = function(viewName, view) {
  this.views[viewName] = view;
};

/**
 * @summary Allow mongodb aggregation
 * @param {Array} pipelines mongodb pipeline
 * @param {Object} options mongodb option object
 */
Mongo.Collection.prototype.aggregate = function(pipelines, options) {
  var coll = this.rawCollection();
  return wrapAsync(coll.aggregate.bind(coll))(pipelines, options);
};

// see https://github.com/dburles/meteor-collection-helpers/blob/master/collection-helpers.js
Mongo.Collection.prototype.helpers = function(helpers) {
  var self = this;

  if (self._transform && !self._helpers)
    throw new Meteor.Error(
      "Can't apply helpers to '" + self._name + "' a transform function already exists!"
    );

  if (!self._helpers) {
    self._helpers = function Document(doc) {
      return _.extend(this, doc);
    };
    self._transform = function(doc) {
      return new self._helpers(doc);
    };
  }

  _.each(helpers, function(helper, key) {
    self._helpers.prototype[key] = helper;
  });
};

export const createCollection = options => {
  const {
    typeName,
    collectionName = getCollectionName(typeName),
    generateGraphQLSchema = true,
    dbCollectionName,
  } = options;
  let { schema } = options;

  // initialize new Mongo collection
  const collection =
    collectionName === 'Users' && Meteor.users
      ? Meteor.users
      : new Mongo.Collection(dbCollectionName ? dbCollectionName : collectionName.toLowerCase());

  // decorate collection with options
  collection.options = options;

  // add typeName if missing
  collection.typeName = typeName;
  collection.options.typeName = typeName;
  collection.options.singleResolverName = Utils.camelCaseify(typeName);
  collection.options.multiResolverName = Utils.camelCaseify(Utils.pluralize(typeName));

  // add collectionName if missing
  collection.collectionName = collectionName;
  collection.options.collectionName = collectionName;

  // add views
  collection.views = [];

  //register individual collection callback
  registerCollectionCallback(typeName.toLowerCase());

  // if schema has at least one intl field, add intl callback just before
  // `${collectionName}.collection` callbacks run to make sure it always runs last
  if (schemaHasIntlFields(schema)) {
    hasIntlFields = true; // we have at least one intl field
    addCallback(`${typeName.toLowerCase()}.collection`, addIntlFields);
  }

  //run schema callbacks and run general callbacks last
  schema = runCallbacks({
    name: `${typeName.toLowerCase()}.collection`,
    iterator: schema,
    properties: { options },
  });
  schema = runCallbacks({ name: '*.collection', iterator: schema, properties: { options } });

  if (schema) {
    // attach schema to collection
    collection.attachSchema(new SimpleSchema(schema));
  }

  // add collection to resolver context
  const context = {};
  context[collectionName] = collection;
  addToGraphQLContext(context);

  if (generateGraphQLSchema) {
    // add collection to list of dynamically generated GraphQL schemas
    addGraphQLCollection(collection);
  }

  runCallbacksAsync({ name: '*.collection.async', properties: { options } });
  runCallbacksAsync({ name: `${collectionName}.collection.async`, properties: { options } });

  // ------------------------------------- Default Fragment -------------------------------- //

  const defaultFragment = getDefaultFragmentText(collection);
  if (defaultFragment) registerFragment(defaultFragment);

  // ------------------------------------- Parameters -------------------------------- //

  collection.getParameters = (terms = {}, apolloClient, context) => {
    // console.log(terms);

    let parameters = {
      selector: {},
      options: {},
    };

    if (collection.defaultView) {
      parameters = Utils.deepExtend(
        true,
        parameters,
        collection.defaultView(terms, apolloClient, context)
      );
    }

    // handle view option
    if (terms.view && collection.views[terms.view]) {
      const viewFn = collection.views[terms.view];
      const view = viewFn(terms, apolloClient, context);
      let mergedParameters = Utils.deepExtend(true, parameters, view);

      if (
        mergedParameters.options &&
        mergedParameters.options.sort &&
        view.options &&
        view.options.sort
      ) {
        // If both the default view and the selected view have sort options,
        // don't merge them together; take the selected view's sort. (Otherwise
        // they merge in the wrong order, so that the default-view's sort takes
        // precedence over the selected view's sort.)
        mergedParameters.options.sort = view.options.sort;
      }
      parameters = mergedParameters;
    }

    // iterate over posts.parameters callbacks
    parameters = runCallbacks(
      `${typeName.toLowerCase()}.parameters`,
      parameters,
      clone(terms),
      apolloClient,
      context
    );
    // OpenCRUD backwards compatibility
    parameters = runCallbacks(
      `${collectionName.toLowerCase()}.parameters`,
      parameters,
      clone(terms),
      apolloClient,
      context
    );

    if (Meteor.isClient) {
      parameters = runCallbacks(
        `${typeName.toLowerCase()}.parameters.client`,
        parameters,
        clone(terms),
        apolloClient
      );
      // OpenCRUD backwards compatibility
      parameters = runCallbacks(
        `${collectionName.toLowerCase()}.parameters.client`,
        parameters,
        clone(terms),
        apolloClient
      );
    }

    // note: check that context exists to avoid calling this from withList during SSR
    if (Meteor.isServer && context) {
      parameters = runCallbacks(
        `${typeName.toLowerCase()}.parameters.server`,
        parameters,
        clone(terms),
        context
      );
      // OpenCRUD backwards compatibility
      parameters = runCallbacks(
        `${collectionName.toLowerCase()}.parameters.server`,
        parameters,
        clone(terms),
        context
      );
    }

    /*

    Add filters. Note: array filters work by addition. Specifying { category: ['foo', 'bar']}
    returns the sum of all items that have category `foo` *or* have category `bar`

    */
    if (terms.filterBy && !isEmpty(terms.filterBy)) {
      Object.keys(terms.filterBy).forEach(propertyName => {
        const filter = terms.filterBy[propertyName];
        if (Array.isArray(filter)) {
          parameters.selector[propertyName] = { $in: filter };
        } else if (filter.after || filter.before) {
          const after = moment(filter.after, 'YYYY-MM-DD').toDate();
          const before = moment(filter.before, 'YYYY-MM-DD').toDate();
          parameters.selector[propertyName] = { $gte: after, $lte: before };
        }
      });
    }

    // sort using terms.orderBy (overwrite defaultView's sort)
    if (terms.orderBy && !isEmpty(terms.orderBy)) {
      parameters.options.sort = terms.orderBy;
    }

    // if there is no sort, default to sorting by createdAt descending
    if (!parameters.options.sort) {
      parameters.options.sort = { createdAt: -1 };
    }

    // extend sort to sort posts by _id to break ties, unless there's already an id sort
    // NOTE: always do this last to avoid overriding another sort
    if (!(parameters.options.sort && parameters.options.sort._id)) {
      parameters = Utils.deepExtend(true, parameters, { options: { sort: { _id: -1 } } });
    }

    // remove any null fields (setting a field to null means it should be deleted)
    _.keys(parameters.selector).forEach(key => {
      if (parameters.selector[key] === null) delete parameters.selector[key];
    });
    if (parameters.options.sort) {
      _.keys(parameters.options.sort).forEach(key => {
        if (parameters.options.sort[key] === null) delete parameters.options.sort[key];
      });
    }

    if (terms.query) {
      const query = escapeStringRegexp(terms.query);
      const currentSchema = collection.simpleSchema()._schema;
      const searchableFieldNames = _.filter(
        _.keys(currentSchema),
        fieldName => currentSchema[fieldName].searchable
      );
      if (searchableFieldNames.length) {
        parameters = Utils.deepExtend(true, parameters, {
          selector: {
            $or: searchableFieldNames.map(fieldName => ({
              [fieldName]: { $regex: query, $options: 'i' },
            })),
          },
        });
      } else {
        // eslint-disable-next-line no-console
        console.warn(
          `Warning: terms.query is set but schema ${
            collection.options.typeName
          } has no searchable field. Set "searchable: true" for at least one field to enable search.`
        );
      }
    }

    // limit number of items to 1000 by default
    const maxDocuments = getSetting('maxDocumentsPerRequest', 1000);
    const limit = terms.limit || parameters.options.limit;
    parameters.options.limit = !limit || limit < 1 || limit > maxDocuments ? maxDocuments : limit;

    // console.log(JSON.stringify(parameters, 2));

    return parameters;
  };

  Collections.push(collection);

  return collection;
};

//register collection creation hook for each collection
function registerCollectionCallback(typeName) {
  registerCallback({
    name: `${typeName}.collection`,
    iterator: { schema: 'the schema of the collection' },
    properties: [
      { schema: 'The schema of the collection' },
      { validationErrors: 'An Object that can be used to accumulate validation errors' },
    ],
    runs: 'sync',
    returns: 'schema',
    description: 'Modifies schemas on collection creation',
  });
}

//register colleciton creation hook
registerCallback({
  name: '*.collection',
  iterator: { schema: 'the schema of the collection' },
  properties: [
    { schema: 'The schema of the collection' },
    { validationErrors: 'An object that can be used to accumulate validation errors' },
  ],
  runs: 'sync',
  returns: 'schema',
  description: 'Modifies schemas on collection creation',
});

// generate foo_intl fields
function addIntlFields(schema) {
  Object.keys(schema).forEach(fieldName => {
    const fieldSchema = schema[fieldName];
    if (isIntlField(fieldSchema)) {
      // remove `intl` to avoid treating new _intl field as a field to internationalize
      // eslint-disable-next-line no-unused-vars
      const { intl, ...propertiesToCopy } = schema[fieldName];

      schema[`${fieldName}_intl`] = {
        ...propertiesToCopy, // copy properties from regular field
        hidden: true,
        type: Array,
        isIntlData: true,
      };

      delete schema[`${fieldName}_intl`].intl;

      schema[`${fieldName}_intl.$`] = {
        type: getIntlString(),
      };

      // if original field is required, enable custom validation function instead of `optional` property
      if (!schema[fieldName].optional) {
        schema[`${fieldName}_intl`].optional = true;
        schema[`${fieldName}_intl`].custom = validateIntlField;
      }

      // make original non-intl field optional
      schema[fieldName].optional = true;
    }
  });
  return schema;
}
<commit_msg>Fix date filtering logic when only one parameter is present<commit_after>import { Mongo } from 'meteor/mongo';
import SimpleSchema from 'simpl-schema';
import { addGraphQLCollection, addToGraphQLContext } from './graphql';
import { Utils } from './utils.js';
import { runCallbacks, runCallbacksAsync, registerCallback, addCallback } from './callbacks.js';
import { getSetting, registerSetting } from './settings.js';
import { registerFragment, getDefaultFragmentText } from './fragments.js';
import escapeStringRegexp from 'escape-string-regexp';
import { validateIntlField, getIntlString, isIntlField, schemaHasIntlFields } from './intl';
import clone from 'lodash/clone';
import isEmpty from 'lodash/isEmpty';
import moment from 'moment';

const wrapAsync = Meteor.wrapAsync ? Meteor.wrapAsync : Meteor._wrapAsync;
// import { debug } from './debug.js';

registerSetting('maxDocumentsPerRequest', 1000, 'Maximum documents per request');

// will be set to `true` if there is one or more intl schema fields
export let hasIntlFields = false;

export const Collections = [];

export const getCollection = name =>
  Collections.find(
    ({ options: { collectionName } }) =>
      name === collectionName || name === collectionName.toLowerCase()
  );

// TODO: find more reliable way to get collection name from type name?
export const getCollectionName = typeName => Utils.pluralize(typeName);

// TODO: find more reliable way to get type name from collection name?
export const getTypeName = collectionName => collectionName.slice(0, -1);

/**
 * @summary replacement for Collection2's attachSchema. Pass either a schema, to
 * initialize or replace the schema, or some fields, to extend the current schema
 * @class Mongo.Collection
 */
Mongo.Collection.prototype.attachSchema = function(schemaOrFields) {
  if (schemaOrFields instanceof SimpleSchema) {
    this.simpleSchema = () => schemaOrFields;
  } else {
    this.simpleSchema().extend(schemaOrFields);
  }
};

/**
 * @summary Add an additional field (or an array of fields) to a schema.
 * @param {Object|Object[]} field
 */
Mongo.Collection.prototype.addField = function(fieldOrFieldArray) {
  const collection = this;
  const fieldSchema = {};

  const fieldArray = Array.isArray(fieldOrFieldArray) ? fieldOrFieldArray : [fieldOrFieldArray];

  // loop over fields and add them to schema (or extend existing fields)
  fieldArray.forEach(function(field) {
    fieldSchema[field.fieldName] = field.fieldSchema;
  });

  // add field schema to collection schema
  collection.attachSchema(fieldSchema);
};

/**
 * @summary Remove a field from a schema.
 * @param {String} fieldName
 */
Mongo.Collection.prototype.removeField = function(fieldName) {
  var collection = this;
  var schema = _.omit(collection.simpleSchema()._schema, fieldName);

  // add field schema to collection schema
  collection.attachSchema(new SimpleSchema(schema));
};

/**
 * @summary Add a default view function.
 * @param {Function} view
 */
Mongo.Collection.prototype.addDefaultView = function(view) {
  this.defaultView = view;
};

/**
 * @summary Add a named view function.
 * @param {String} viewName
 * @param {Function} view
 */
Mongo.Collection.prototype.addView = function(viewName, view) {
  this.views[viewName] = view;
};

/**
 * @summary Allow mongodb aggregation
 * @param {Array} pipelines mongodb pipeline
 * @param {Object} options mongodb option object
 */
Mongo.Collection.prototype.aggregate = function(pipelines, options) {
  var coll = this.rawCollection();
  return wrapAsync(coll.aggregate.bind(coll))(pipelines, options);
};

// see https://github.com/dburles/meteor-collection-helpers/blob/master/collection-helpers.js
Mongo.Collection.prototype.helpers = function(helpers) {
  var self = this;

  if (self._transform && !self._helpers)
    throw new Meteor.Error(
      "Can't apply helpers to '" + self._name + "' a transform function already exists!"
    );

  if (!self._helpers) {
    self._helpers = function Document(doc) {
      return _.extend(this, doc);
    };
    self._transform = function(doc) {
      return new self._helpers(doc);
    };
  }

  _.each(helpers, function(helper, key) {
    self._helpers.prototype[key] = helper;
  });
};

export const createCollection = options => {
  const {
    typeName,
    collectionName = getCollectionName(typeName),
    generateGraphQLSchema = true,
    dbCollectionName,
  } = options;
  let { schema } = options;

  // initialize new Mongo collection
  const collection =
    collectionName === 'Users' && Meteor.users
      ? Meteor.users
      : new Mongo.Collection(dbCollectionName ? dbCollectionName : collectionName.toLowerCase());

  // decorate collection with options
  collection.options = options;

  // add typeName if missing
  collection.typeName = typeName;
  collection.options.typeName = typeName;
  collection.options.singleResolverName = Utils.camelCaseify(typeName);
  collection.options.multiResolverName = Utils.camelCaseify(Utils.pluralize(typeName));

  // add collectionName if missing
  collection.collectionName = collectionName;
  collection.options.collectionName = collectionName;

  // add views
  collection.views = [];

  //register individual collection callback
  registerCollectionCallback(typeName.toLowerCase());

  // if schema has at least one intl field, add intl callback just before
  // `${collectionName}.collection` callbacks run to make sure it always runs last
  if (schemaHasIntlFields(schema)) {
    hasIntlFields = true; // we have at least one intl field
    addCallback(`${typeName.toLowerCase()}.collection`, addIntlFields);
  }

  //run schema callbacks and run general callbacks last
  schema = runCallbacks({
    name: `${typeName.toLowerCase()}.collection`,
    iterator: schema,
    properties: { options },
  });
  schema = runCallbacks({ name: '*.collection', iterator: schema, properties: { options } });

  if (schema) {
    // attach schema to collection
    collection.attachSchema(new SimpleSchema(schema));
  }

  // add collection to resolver context
  const context = {};
  context[collectionName] = collection;
  addToGraphQLContext(context);

  if (generateGraphQLSchema) {
    // add collection to list of dynamically generated GraphQL schemas
    addGraphQLCollection(collection);
  }

  runCallbacksAsync({ name: '*.collection.async', properties: { options } });
  runCallbacksAsync({ name: `${collectionName}.collection.async`, properties: { options } });

  // ------------------------------------- Default Fragment -------------------------------- //

  const defaultFragment = getDefaultFragmentText(collection);
  if (defaultFragment) registerFragment(defaultFragment);

  // ------------------------------------- Parameters -------------------------------- //

  collection.getParameters = (terms = {}, apolloClient, context) => {
    // console.log(terms);

    let parameters = {
      selector: {},
      options: {},
    };

    if (collection.defaultView) {
      parameters = Utils.deepExtend(
        true,
        parameters,
        collection.defaultView(terms, apolloClient, context)
      );
    }

    // handle view option
    if (terms.view && collection.views[terms.view]) {
      const viewFn = collection.views[terms.view];
      const view = viewFn(terms, apolloClient, context);
      let mergedParameters = Utils.deepExtend(true, parameters, view);

      if (
        mergedParameters.options &&
        mergedParameters.options.sort &&
        view.options &&
        view.options.sort
      ) {
        // If both the default view and the selected view have sort options,
        // don't merge them together; take the selected view's sort. (Otherwise
        // they merge in the wrong order, so that the default-view's sort takes
        // precedence over the selected view's sort.)
        mergedParameters.options.sort = view.options.sort;
      }
      parameters = mergedParameters;
    }

    // iterate over posts.parameters callbacks
    parameters = runCallbacks(
      `${typeName.toLowerCase()}.parameters`,
      parameters,
      clone(terms),
      apolloClient,
      context
    );
    // OpenCRUD backwards compatibility
    parameters = runCallbacks(
      `${collectionName.toLowerCase()}.parameters`,
      parameters,
      clone(terms),
      apolloClient,
      context
    );

    if (Meteor.isClient) {
      parameters = runCallbacks(
        `${typeName.toLowerCase()}.parameters.client`,
        parameters,
        clone(terms),
        apolloClient
      );
      // OpenCRUD backwards compatibility
      parameters = runCallbacks(
        `${collectionName.toLowerCase()}.parameters.client`,
        parameters,
        clone(terms),
        apolloClient
      );
    }

    // note: check that context exists to avoid calling this from withList during SSR
    if (Meteor.isServer && context) {
      parameters = runCallbacks(
        `${typeName.toLowerCase()}.parameters.server`,
        parameters,
        clone(terms),
        context
      );
      // OpenCRUD backwards compatibility
      parameters = runCallbacks(
        `${collectionName.toLowerCase()}.parameters.server`,
        parameters,
        clone(terms),
        context
      );
    }

    /*

    Add filters. Note: array filters work by addition. Specifying { category: ['foo', 'bar']}
    returns the sum of all items that have category `foo` *or* have category `bar`

    */
    if (terms.filterBy && !isEmpty(terms.filterBy)) {
      Object.keys(terms.filterBy).forEach(propertyName => {
        const filter = terms.filterBy[propertyName];
        if (Array.isArray(filter)) {
          parameters.selector[propertyName] = { $in: filter };
        } else if (filter.after || filter.before) {
          const dateFilter = {};
          if (filter.after) {
            dateFilter.$gte = moment(filter.after, 'YYYY-MM-DD').toDate();
          }
          if (filter.before) {
            dateFilter.$lte = moment(filter.before, 'YYYY-MM-DD').toDate();
          }
          parameters.selector[propertyName] = dateFilter;
        }
      });
    }

    // sort using terms.orderBy (overwrite defaultView's sort)
    if (terms.orderBy && !isEmpty(terms.orderBy)) {
      parameters.options.sort = terms.orderBy;
    }

    // if there is no sort, default to sorting by createdAt descending
    if (!parameters.options.sort) {
      parameters.options.sort = { createdAt: -1 };
    }

    // extend sort to sort posts by _id to break ties, unless there's already an id sort
    // NOTE: always do this last to avoid overriding another sort
    if (!(parameters.options.sort && parameters.options.sort._id)) {
      parameters = Utils.deepExtend(true, parameters, { options: { sort: { _id: -1 } } });
    }

    // remove any null fields (setting a field to null means it should be deleted)
    _.keys(parameters.selector).forEach(key => {
      if (parameters.selector[key] === null) delete parameters.selector[key];
    });
    if (parameters.options.sort) {
      _.keys(parameters.options.sort).forEach(key => {
        if (parameters.options.sort[key] === null) delete parameters.options.sort[key];
      });
    }

    if (terms.query) {
      const query = escapeStringRegexp(terms.query);
      const currentSchema = collection.simpleSchema()._schema;
      const searchableFieldNames = _.filter(
        _.keys(currentSchema),
        fieldName => currentSchema[fieldName].searchable
      );
      if (searchableFieldNames.length) {
        parameters = Utils.deepExtend(true, parameters, {
          selector: {
            $or: searchableFieldNames.map(fieldName => ({
              [fieldName]: { $regex: query, $options: 'i' },
            })),
          },
        });
      } else {
        // eslint-disable-next-line no-console
        console.warn(
          `Warning: terms.query is set but schema ${
            collection.options.typeName
          } has no searchable field. Set "searchable: true" for at least one field to enable search.`
        );
      }
    }

    // limit number of items to 1000 by default
    const maxDocuments = getSetting('maxDocumentsPerRequest', 1000);
    const limit = terms.limit || parameters.options.limit;
    parameters.options.limit = !limit || limit < 1 || limit > maxDocuments ? maxDocuments : limit;

    // console.log(JSON.stringify(parameters, 2));

    return parameters;
  };

  Collections.push(collection);

  return collection;
};

//register collection creation hook for each collection
function registerCollectionCallback(typeName) {
  registerCallback({
    name: `${typeName}.collection`,
    iterator: { schema: 'the schema of the collection' },
    properties: [
      { schema: 'The schema of the collection' },
      { validationErrors: 'An Object that can be used to accumulate validation errors' },
    ],
    runs: 'sync',
    returns: 'schema',
    description: 'Modifies schemas on collection creation',
  });
}

//register colleciton creation hook
registerCallback({
  name: '*.collection',
  iterator: { schema: 'the schema of the collection' },
  properties: [
    { schema: 'The schema of the collection' },
    { validationErrors: 'An object that can be used to accumulate validation errors' },
  ],
  runs: 'sync',
  returns: 'schema',
  description: 'Modifies schemas on collection creation',
});

// generate foo_intl fields
function addIntlFields(schema) {
  Object.keys(schema).forEach(fieldName => {
    const fieldSchema = schema[fieldName];
    if (isIntlField(fieldSchema)) {
      // remove `intl` to avoid treating new _intl field as a field to internationalize
      // eslint-disable-next-line no-unused-vars
      const { intl, ...propertiesToCopy } = schema[fieldName];

      schema[`${fieldName}_intl`] = {
        ...propertiesToCopy, // copy properties from regular field
        hidden: true,
        type: Array,
        isIntlData: true,
      };

      delete schema[`${fieldName}_intl`].intl;

      schema[`${fieldName}_intl.$`] = {
        type: getIntlString(),
      };

      // if original field is required, enable custom validation function instead of `optional` property
      if (!schema[fieldName].optional) {
        schema[`${fieldName}_intl`].optional = true;
        schema[`${fieldName}_intl`].custom = validateIntlField;
      }

      // make original non-intl field optional
      schema[fieldName].optional = true;
    }
  });
  return schema;
}
<|endoftext|>


<commit_before>require('ember-model/adapter');

var get = Ember.get;

Ember.RESTAdapter = Ember.Adapter.extend({
  find: function(record, id) {
    var url = this.buildURL(record.constructor, id),
        self = this;

    return this.ajax(url).then(function(data) {
      self.didFind(record, id, data);
      return record;
    });
  },

  didFind: function(record, id, data) {
    var rootKey = get(record.constructor, 'rootKey'),
        dataToLoad = rootKey ? get(data, rootKey) : data;

    record.load(id, dataToLoad);
  },

  findAll: function(klass, records) {
    var url = this.buildURL(klass),
        self = this;

    return this.ajax(url).then(function(data) {
      self.didFindAll(klass, records, data);
      return records;
    });
  },

  didFindAll: function(klass, records, data) {
    var collectionKey = get(klass, 'collectionKey'),
        dataToLoad = collectionKey ? get(data, collectionKey) : data;

    records.load(klass, dataToLoad);
  },

  findQuery: function(klass, records, params) {
    var url = this.buildURL(klass),
        self = this;

    return this.ajax(url, params).then(function(data) {
      self.didFindQuery(klass, records, params, data);
      return records;
    });
  },

  didFindQuery: function(klass, records, params, data) {
      var collectionKey = get(klass, 'collectionKey'),
          dataToLoad = collectionKey ? get(data, collectionKey) : data;

      records.load(klass, dataToLoad);
  },

  createRecord: function(record) {
    var url = this.buildURL(record.constructor),
        self = this;

    return this.ajax(url, record.toJSON(), "POST").then(function(data) {
      self.didCreateRecord(record, data);
      return record;
    });
  },

  didCreateRecord: function(record, data) {
    this._loadRecordFromData(record, data);
    record.didCreateRecord();
  },

  saveRecord: function(record) {
    var primaryKey = get(record.constructor, 'primaryKey'),
        url = this.buildURL(record.constructor, get(record, primaryKey)),
        self = this;

    return this.ajax(url, record.toJSON(), "PUT").then(function(data) {  // TODO: Some APIs may or may not return data
      self.didSaveRecord(record, data);
      return record;
    });
  },

  didSaveRecord: function(record, data) {
    this._loadRecordFromData(record, data);
    record.didSaveRecord();
  },

  deleteRecord: function(record) {
    var primaryKey = get(record.constructor, 'primaryKey'),
        url = this.buildURL(record.constructor, get(record, primaryKey)),
        self = this;

    return this.ajax(url, record.toJSON(), "DELETE").then(function(data) {  // TODO: Some APIs may or may not return data
      self.didDeleteRecord(record, data);
    });
  },

  didDeleteRecord: function(record, data) {
    record.didDeleteRecord();
  },

  ajax: function(url, params, method, settings) {
    return this._ajax(url, params, (method || "GET"), settings);
  },

  buildURL: function(klass, id) {
    var urlRoot = get(klass, 'url');
    var urlSuffix = get(klass, 'urlSuffix') || '';
    if (!urlRoot) { throw new Error('Ember.RESTAdapter requires a `url` property to be specified'); }

    if (!Ember.isEmpty(id)) {
      return urlRoot + "/" + id + urlSuffix;
    } else {
      return urlRoot + urlSuffix;
    }
  },

  ajaxSettings: function(url, method) {
    return {
      url: url,
      type: method,
      dataType: "json"
    };
  },

  _ajax: function(url, params, method, settings) {
    if (!settings) {
      settings = this.ajaxSettings(url, method);
    }

    return new Ember.RSVP.Promise(function(resolve, reject) {
      if (params) {
        if (settings.type.trim().toUpperCase() === "GET") {
          settings.data = params;
        } else { // POST, PUT
          if (settings.dataType.trim().toUpperCase() === "JSON") {
            settings.contentType = "application/json; charset=utf-8";
            settings.data = JSON.stringify(params);
          } else {
            settings.contentType = "application/x-www-form-urlencoded; charset=UTF-8";
            settings.data = params;
          }
        }
      }

      settings.success = function(json, textStatus, jqXHR) {
        Ember.run(null, resolve, json);
        if (settings.successCallback) {
          settings.successCallback.call(this, json, textStatus, jqXHR);
        }
      };

      settings.error = function(jqXHR, textStatus, errorThrown) {
        // https://github.com/ebryn/ember-model/issues/202
        if (jqXHR && typeof jqXHR === 'object') {
          jqXHR.then = null;
        }

        Ember.run(null, reject, jqXHR);

        if (settings.errorCallback) {
          settings.errorCallback.call(this, jqXHR, textStatus, errorThrown);
        }
      };

      Ember.$.ajax(settings);
   });
  },

  _loadRecordFromData: function(record, data) {
    var rootKey = get(record.constructor, 'rootKey'),
        primaryKey = get(record.constructor, 'primaryKey');
    // handle HEAD response where no data is provided by server
    if (data) {
      data = rootKey ? get(data, rootKey) : data;
      if (!Ember.isEmpty(data)) {
        record.load(data[primaryKey], data);
      }
    }
  }
});
<commit_msg>Gives you the ability to override both dataType and contentType<commit_after>require('ember-model/adapter');

var get = Ember.get;

Ember.RESTAdapter = Ember.Adapter.extend({
  find: function(record, id) {
    var url = this.buildURL(record.constructor, id),
        self = this;

    return this.ajax(url).then(function(data) {
      self.didFind(record, id, data);
      return record;
    });
  },

  didFind: function(record, id, data) {
    var rootKey = get(record.constructor, 'rootKey'),
        dataToLoad = rootKey ? get(data, rootKey) : data;

    record.load(id, dataToLoad);
  },

  findAll: function(klass, records) {
    var url = this.buildURL(klass),
        self = this;

    return this.ajax(url).then(function(data) {
      self.didFindAll(klass, records, data);
      return records;
    });
  },

  didFindAll: function(klass, records, data) {
    var collectionKey = get(klass, 'collectionKey'),
        dataToLoad = collectionKey ? get(data, collectionKey) : data;

    records.load(klass, dataToLoad);
  },

  findQuery: function(klass, records, params) {
    var url = this.buildURL(klass),
        self = this;

    return this.ajax(url, params).then(function(data) {
      self.didFindQuery(klass, records, params, data);
      return records;
    });
  },

  didFindQuery: function(klass, records, params, data) {
      var collectionKey = get(klass, 'collectionKey'),
          dataToLoad = collectionKey ? get(data, collectionKey) : data;

      records.load(klass, dataToLoad);
  },

  createRecord: function(record) {
    var url = this.buildURL(record.constructor),
        self = this;

    return this.ajax(url, record.toJSON(), "POST").then(function(data) {
      self.didCreateRecord(record, data);
      return record;
    });
  },

  didCreateRecord: function(record, data) {
    this._loadRecordFromData(record, data);
    record.didCreateRecord();
  },

  saveRecord: function(record) {
    var primaryKey = get(record.constructor, 'primaryKey'),
        url = this.buildURL(record.constructor, get(record, primaryKey)),
        self = this;

    return this.ajax(url, record.toJSON(), "PUT").then(function(data) {  // TODO: Some APIs may or may not return data
      self.didSaveRecord(record, data);
      return record;
    });
  },

  didSaveRecord: function(record, data) {
    this._loadRecordFromData(record, data);
    record.didSaveRecord();
  },

  deleteRecord: function(record) {
    var primaryKey = get(record.constructor, 'primaryKey'),
        url = this.buildURL(record.constructor, get(record, primaryKey)),
        self = this;

    return this.ajax(url, record.toJSON(), "DELETE").then(function(data) {  // TODO: Some APIs may or may not return data
      self.didDeleteRecord(record, data);
    });
  },

  didDeleteRecord: function(record, data) {
    record.didDeleteRecord();
  },

  ajax: function(url, params, method, settings) {
    return this._ajax(url, params, (method || "GET"), settings);
  },

  buildURL: function(klass, id) {
    var urlRoot = get(klass, 'url');
    var urlSuffix = get(klass, 'urlSuffix') || '';
    if (!urlRoot) { throw new Error('Ember.RESTAdapter requires a `url` property to be specified'); }

    if (!Ember.isEmpty(id)) {
      return urlRoot + "/" + id + urlSuffix;
    } else {
      return urlRoot + urlSuffix;
    }
  },

  ajaxSettings: function(url, method) {
    return {
      url         : url,
      type        : method,
      dataType    : "json",
      contentType : "application/json"
    };
  },

  _ajax: function(url, params, method, settings) {
    if (!settings) {
      settings = this.ajaxSettings(url, method);
    }

    return new Ember.RSVP.Promise(function(resolve, reject) {
      if (params) {
        if (/get/i.test(settings.type)) { // GET
          settings.data = params;
        } else { // POST, PUT
          if (/json/i.test(settings.contentType)) {
            settings.data = JSON.stringify(params);
          } else { // application/x-www-form-urlencoded; charset=UTF-8
            settings.data = params;
          }
        }
      }

      settings.success = function(json, textStatus, jqXHR) {
        Ember.run(null, resolve, json);
        if (settings.successCallback) {
          settings.successCallback.call(this, json, textStatus, jqXHR);
        }
      };

      settings.error = function(jqXHR, textStatus, errorThrown) {
        // https://github.com/ebryn/ember-model/issues/202
        if (jqXHR && typeof jqXHR === 'object') {
          jqXHR.then = null;
        }

        Ember.run(null, reject, jqXHR);

        if (settings.errorCallback) {
          settings.errorCallback.call(this, jqXHR, textStatus, errorThrown);
        }
      };

      Ember.$.ajax(settings);
   });
  },

  _loadRecordFromData: function(record, data) {
    var rootKey = get(record.constructor, 'rootKey'),
        primaryKey = get(record.constructor, 'primaryKey');
    // handle HEAD response where no data is provided by server
    if (data) {
      data = rootKey ? get(data, rootKey) : data;
      if (!Ember.isEmpty(data)) {
        record.load(data[primaryKey], data);
      }
    }
  }
});
<|endoftext|>


<commit_before>import { get, omit } from 'lodash';
import errors from '../../../lib/errors';
import roles from '../../../constants/roles';
import statuses from '../../../constants/expense_status';
import activities from '../../../constants/activities';
import models from '../../../models';
import paymentProviders from '../../../paymentProviders';
import * as libPayments from '../../../lib/payments';
import { formatCurrency } from '../../../lib/utils';
import { createFromPaidExpense as createTransactionFromPaidExpense } from '../../../lib/transactions';
import { getFxRate } from '../../../lib/currency';
import debugLib from 'debug';

const debug = debugLib('expenses');

/**
 * Only admin of expense.collective or of expense.collective.host can approve/reject expenses
 */
function canUpdateExpenseStatus(remoteUser, expense) {
  if (remoteUser.hasRole([roles.ADMIN], expense.CollectiveId)) {
    return true;
  }
  if (remoteUser.hasRole([roles.ADMIN], expense.collective.HostCollectiveId)) {
    return true;
  }
  return false;
}

/**
 * Only admin of expense.collective.host can mark expenses unpaid
 */
function canMarkExpenseUnpaid(remoteUser, expense) {
  if (remoteUser.hasRole([roles.ADMIN], expense.collective.HostCollectiveId)) {
    return true;
  }
  return false;
}

/**
 * Only the author or an admin of the collective or collective.host can edit an expense when it hasn't been paid yet
 */
function canEditExpense(remoteUser, expense) {
  if (expense.status === statuses.PAID) return false;
  if (remoteUser.id === expense.UserId) {
    return true;
  }
  return canUpdateExpenseStatus(remoteUser, expense);
}

export async function updateExpenseStatus(remoteUser, expenseId, status) {
  if (!remoteUser) {
    throw new errors.Unauthorized('You need to be logged in to update the status of an expense');
  }

  if (Object.keys(statuses).indexOf(status) === -1) {
    throw new errors.ValidationFailed('Invalid status, status must be one of ', Object.keys(statuses).join(', '));
  }

  const expense = await models.Expense.findByPk(expenseId, {
    include: [{ model: models.Collective, as: 'collective' }],
  });

  if (!expense) {
    throw new errors.Unauthorized('Expense not found');
  }

  if (!canUpdateExpenseStatus(remoteUser, expense)) {
    throw new errors.Unauthorized("You don't have permission to approve this expense");
  }
  switch (status) {
    case statuses.APPROVED:
      if (expense.status === statuses.PAID) {
        throw new errors.Unauthorized("You can't approve an expense that is already paid");
      }
      break;
    case statuses.REJECTED:
      if (expense.status === statuses.PAID) {
        throw new errors.Unauthorized("You can't reject an expense that is already paid");
      }
      break;
    case statuses.PAID:
      if (expense.status !== statuses.APPROVED) {
        throw new errors.Unauthorized('The expense must be approved before you can set it to paid');
      }
      break;
  }
  const res = await expense.update({ status, lastEditedById: remoteUser.id });
  return res;
}

export async function createExpense(remoteUser, expenseData) {
  if (!remoteUser) {
    throw new errors.Unauthorized('You need to be logged in to create an expense');
  }
  if (!get(expenseData, 'collective.id')) {
    throw new errors.Unauthorized('Missing expense.collective.id');
  }

  // Update remoteUser's paypal email if it has changed
  if (get(expenseData, 'user.paypalEmail') !== remoteUser.paypalEmail) {
    remoteUser.paypalEmail = get(expenseData, 'user.paypalEmail');
    remoteUser.save();
  }
  expenseData.UserId = remoteUser.id;

  const collective = await models.Collective.findByPk(expenseData.collective.id);

  if (expenseData.currency && expenseData.currency !== collective.currency) {
    throw new errors.ValidationFailed(
      `The currency of the expense (${expenseData.currency}) needs to be the same as the currency of the collective (${expenseData.collective.currency})`,
    );
  }

  if (!collective) {
    throw new errors.ValidationFailed('Collective not found');
  }

  if (expenseData.currency && expenseData.currency !== collective.currency) {
    throw new errors.ValidationFailed(
      `The currency of the expense (${expenseData.currency}) needs to be the same as the currency of the collective (${collective.currency})`,
    );
  }

  const expense = await models.Expense.create({
    ...expenseData,
    status: statuses.PENDING,
    CollectiveId: collective.id,
    lastEditedById: expenseData.UserId,
    incurredAt: expenseData.incurredAt || new Date(),
  });

  collective.addUserWithRole(remoteUser, roles.CONTRIBUTOR).catch(e => {
    if (e.name === 'SequelizeUniqueConstraintError') {
      console.log('User ', remoteUser.id, 'is already a contributor');
    } else {
      console.error(e);
    }
  });

  expense.user = remoteUser;
  expense.collective = collective;
  await expense.createActivity(activities.COLLECTIVE_EXPENSE_CREATED);
  return expense;
}

export async function editExpense(remoteUser, expenseData) {
  if (!remoteUser) {
    throw new errors.Unauthorized('You need to be logged in to edit an expense');
  }

  const expense = await models.Expense.findByPk(expenseData.id, {
    include: [{ model: models.Collective, as: 'collective' }],
  });

  if (!expense) {
    throw new errors.Unauthorized('Expense not found');
  }

  if (!canEditExpense(remoteUser, expense)) {
    throw new errors.Unauthorized("You don't have permission to edit this expense");
  }

  if (expenseData.currency && expenseData.currency !== expense.collective.currency) {
    throw new errors.ValidationFailed(
      `The currency of the expense (${expenseData.currency}) needs to be the same as the currency of the collective (${expense.collective.currency})`,
    );
  }

  // When updating amount, attachment or payoutMethod, we reset its status to PENDING
  if (
    expenseData.amount !== expense.amount ||
    expenseData.payoutMethod !== expense.payoutMethod ||
    expenseData.attachment !== expense.attachment
  ) {
    expenseData.status = statuses.PENDING;
  } else {
    // make sure that we don't override the status of the expense.
    delete expenseData.status;
  }

  expenseData.lastEditedById = remoteUser.id;
  await expense.update(expenseData);
  expense.createActivity(activities.COLLECTIVE_EXPENSE_UPDATED);
  return expense;
}

export async function deleteExpense(remoteUser, expenseId) {
  if (!remoteUser) {
    throw new errors.Unauthorized('You need to be logged in to delete an expense');
  }

  const expense = await models.Expense.findByPk(expenseId, {
    include: [{ model: models.Collective, as: 'collective' }],
  });

  if (!expense) {
    throw new errors.NotFound('Expense not found');
  }

  if (!canEditExpense(remoteUser, expense)) {
    throw new errors.Unauthorized("You don't have permission to delete this expense");
  }

  if (expense.status !== statuses.REJECTED) {
    throw new errors.Unauthorized('Only rejected expense can be deleted');
  }

  const res = await expense.destroy();
  return res;
}

/** Helper that finishes the process of paying an expense */
async function markExpenseAsPaid(expense) {
  debug('update expense status to PAID', expense.id);
  const updatedExpense = await expense.update({ status: statuses.PAID });
  await expense.createActivity(activities.COLLECTIVE_EXPENSE_PAID);
  return updatedExpense;
}

async function createTransactions(host, expense, fees = {}) {
  debug('marking expense as paid and creating transactions in the ledger', expense.id);
  return await createTransactionFromPaidExpense(
    host,
    null,
    expense,
    null,
    expense.UserId,
    fees.paymentProcessorFeeInHostCurrency,
    fees.hostFeeInHostCurrency,
    fees.platformFeeInHostCurrency,
  );
}

async function payExpenseWithPayPal(remoteUser, expense, host, paymentMethod, fees = {}) {
  debug('payExpenseWithPayPal', expense.id);
  try {
    const paymentResponse = await paymentProviders[expense.payoutMethod].types['adaptive'].pay(
      expense.collective,
      expense,
      expense.paypalEmail,
      paymentMethod.token,
    );
    await createTransactionFromPaidExpense(
      host,
      paymentMethod,
      expense,
      paymentResponse,
      expense.UserId,
      fees.paymentProcessorFeeInHostCurrency,
      fees.hostFeeInHostCurrency,
      fees.platformFeeInHostCurrency,
    );
    expense.setPaid(remoteUser.id);
    await paymentMethod.updateBalance();
  } catch (err) {
    debug('paypal> error', JSON.stringify(err, null, '  '));
    if (
      err.message.indexOf('The total amount of all payments exceeds the maximum total amount for all payments') !== -1
    ) {
      throw new errors.BadRequest(
        'Not enough funds in your existing Paypal preapproval. Please refill your PayPal payment balance.',
      );
    } else {
      throw new errors.BadRequest(err.message);
    }
  }
}

/**
 * Pay an expense based on the payout method defined in the Expense object
 * @PRE: fees { id, paymentProcessorFeeInCollectiveCurrency, hostFeeInCollectiveCurrency, platformFeeInCollectiveCurrency }
 * Note: some payout methods like PayPal will automatically define `paymentProcessorFeeInCollectiveCurrency`
 */
export async function payExpense(remoteUser, args) {
  const expenseId = args.id;
  const fees = omit(args, ['id', 'forceManual']);

  if (!remoteUser) {
    throw new errors.Unauthorized('You need to be logged in to pay an expense');
  }
  const expense = await models.Expense.findByPk(expenseId, {
    include: [{ model: models.Collective, as: 'collective' }],
  });
  if (!expense) {
    throw new errors.Unauthorized('Expense not found');
  }
  if (expense.status === statuses.PAID) {
    throw new errors.Unauthorized('Expense has already been paid');
  }
  if (expense.status !== statuses.APPROVED) {
    throw new errors.Unauthorized(`Expense needs to be approved. Current status of the expense: ${expense.status}.`);
  }
  if (!canUpdateExpenseStatus(remoteUser, expense)) {
    throw new errors.Unauthorized("You don't have permission to pay this expense");
  }
  const host = await expense.collective.getHostCollective();

  if (expense.payoutMethod === 'donation') {
    throw new Error('"In kind" donations are not supported anymore');
  }

  const balance = await expense.collective.getBalance();

  if (expense.amount > balance) {
    throw new errors.Unauthorized(
      `You don't have enough funds to pay this expense. Current balance: ${formatCurrency(
        balance,
        expense.collective.currency,
      )}, Expense amount: ${formatCurrency(expense.amount, expense.collective.currency)}`,
    );
  }

  const fxrate = await getFxRate(expense.collective.currency, host.currency);
  const feesInHostCurrency = {};
  if (paymentProviders[expense.payoutMethod]) {
    fees.paymentProcessorFeeInCollectiveCurrency = await paymentProviders[expense.payoutMethod].types['adaptive'].fees({
      amount: expense.amount,
      currency: expense.collective.currency,
      host,
    });
  }

  feesInHostCurrency.paymentProcessorFeeInHostCurrency = Math.round(
    fxrate * (fees.paymentProcessorFeeInCollectiveCurrency || 0),
  );
  feesInHostCurrency.hostFeeInHostCurrency = Math.round(fxrate * (fees.hostFeeInCollectiveCurrency || 0));
  feesInHostCurrency.platformFeeInHostCurrency = Math.round(fxrate * (fees.platformFeeInCollectiveCurrency || 0));

  if (expense.amount + fees.paymentProcessorFeeInCollectiveCurrency > balance) {
    throw new Error(
      `You don't have enough funds to cover for the fees of this payment method. Current balance: ${formatCurrency(
        balance,
        expense.collective.currency,
      )}, Expense amount: ${formatCurrency(expense.amount, expense.collective.currency)}, Estimated ${
        expense.payoutMethod
      } fees: ${formatCurrency(fees.paymentProcessorFeeInCollectiveCurrency, expense.collective.currency)}`,
    );
  }
  if (expense.payoutMethod === 'paypal') {
    expense.paypalEmail = await expense.getPaypalEmail();
    const paymentMethod = await host.getPaymentMethod({
      service: expense.payoutMethod,
    });
    // If the expense has been filed with the same paypal email than the host paypal
    // then we simply mark the expense as paid
    if (expense.paypalEmail === paymentMethod.name) {
      feesInHostCurrency.paymentProcessorFeeInHostCurrency = 0;
      await createTransactions(host, expense, feesInHostCurrency);
    } else if (args.forceManual) {
      await createTransactions(host, expense, feesInHostCurrency);
    } else {
      await payExpenseWithPayPal(remoteUser, expense, host, paymentMethod, feesInHostCurrency);
    }
  }

  // note: we need to check for manual and other for legacy reasons
  if (expense.payoutMethod === 'manual' || expense.payoutMethod === 'other') {
    await createTransactions(host, expense, feesInHostCurrency);
  }

  return markExpenseAsPaid(expense);
}

export async function markExpenseAsUnpaid(remoteUser, ExpenseId, processorFeeRefunded) {
  if (!remoteUser) {
    throw new errors.Unauthorized('You need to be logged in to unpay an expense');
  }

  const expense = await models.Expense.findByPk(ExpenseId, {
    include: [
      { model: models.Collective, as: 'collective' },
      { model: models.User, as: 'User' },
    ],
  });

  if (!expense) {
    throw new errors.NotFound('No expense found');
  }

  if (!canMarkExpenseUnpaid(remoteUser, expense)) {
    throw new errors.Unauthorized("You don't have permission to mark this expense as unpaid");
  }

  if (expense.status !== statuses.PAID) {
    throw new errors.Unauthorized('Expense has not been paid yet');
  }

  if (expense.payoutMethod !== 'other') {
    throw new errors.Unauthorized('Only expenses with "other" payout method can be marked as unpaid');
  }

  const transaction = await models.Transaction.findOne({
    where: { ExpenseId },
    include: [{ model: models.Expense }],
  });

  const paymentProcessorFeeInHostCurrency = processorFeeRefunded ? transaction.paymentProcessorFeeInHostCurrency : 0;
  const refundedTransaction = await libPayments.createRefundTransaction(
    transaction,
    paymentProcessorFeeInHostCurrency,
    null,
    expense.User,
  );
  await libPayments.associateTransactionRefundId(transaction, refundedTransaction);

  return expense.update({ status: statuses.APPROVED, lastEditedById: remoteUser.id });
}
<commit_msg>chore(payExpense): ignore adaptive fees when payExpense is forceManual<commit_after>import { get, omit } from 'lodash';
import errors from '../../../lib/errors';
import roles from '../../../constants/roles';
import statuses from '../../../constants/expense_status';
import activities from '../../../constants/activities';
import models from '../../../models';
import paymentProviders from '../../../paymentProviders';
import * as libPayments from '../../../lib/payments';
import { formatCurrency } from '../../../lib/utils';
import { createFromPaidExpense as createTransactionFromPaidExpense } from '../../../lib/transactions';
import { getFxRate } from '../../../lib/currency';
import debugLib from 'debug';

const debug = debugLib('expenses');

/**
 * Only admin of expense.collective or of expense.collective.host can approve/reject expenses
 */
function canUpdateExpenseStatus(remoteUser, expense) {
  if (remoteUser.hasRole([roles.ADMIN], expense.CollectiveId)) {
    return true;
  }
  if (remoteUser.hasRole([roles.ADMIN], expense.collective.HostCollectiveId)) {
    return true;
  }
  return false;
}

/**
 * Only admin of expense.collective.host can mark expenses unpaid
 */
function canMarkExpenseUnpaid(remoteUser, expense) {
  if (remoteUser.hasRole([roles.ADMIN], expense.collective.HostCollectiveId)) {
    return true;
  }
  return false;
}

/**
 * Only the author or an admin of the collective or collective.host can edit an expense when it hasn't been paid yet
 */
function canEditExpense(remoteUser, expense) {
  if (expense.status === statuses.PAID) return false;
  if (remoteUser.id === expense.UserId) {
    return true;
  }
  return canUpdateExpenseStatus(remoteUser, expense);
}

export async function updateExpenseStatus(remoteUser, expenseId, status) {
  if (!remoteUser) {
    throw new errors.Unauthorized('You need to be logged in to update the status of an expense');
  }

  if (Object.keys(statuses).indexOf(status) === -1) {
    throw new errors.ValidationFailed('Invalid status, status must be one of ', Object.keys(statuses).join(', '));
  }

  const expense = await models.Expense.findByPk(expenseId, {
    include: [{ model: models.Collective, as: 'collective' }],
  });

  if (!expense) {
    throw new errors.Unauthorized('Expense not found');
  }

  if (!canUpdateExpenseStatus(remoteUser, expense)) {
    throw new errors.Unauthorized("You don't have permission to approve this expense");
  }
  switch (status) {
    case statuses.APPROVED:
      if (expense.status === statuses.PAID) {
        throw new errors.Unauthorized("You can't approve an expense that is already paid");
      }
      break;
    case statuses.REJECTED:
      if (expense.status === statuses.PAID) {
        throw new errors.Unauthorized("You can't reject an expense that is already paid");
      }
      break;
    case statuses.PAID:
      if (expense.status !== statuses.APPROVED) {
        throw new errors.Unauthorized('The expense must be approved before you can set it to paid');
      }
      break;
  }
  const res = await expense.update({ status, lastEditedById: remoteUser.id });
  return res;
}

export async function createExpense(remoteUser, expenseData) {
  if (!remoteUser) {
    throw new errors.Unauthorized('You need to be logged in to create an expense');
  }
  if (!get(expenseData, 'collective.id')) {
    throw new errors.Unauthorized('Missing expense.collective.id');
  }

  // Update remoteUser's paypal email if it has changed
  if (get(expenseData, 'user.paypalEmail') !== remoteUser.paypalEmail) {
    remoteUser.paypalEmail = get(expenseData, 'user.paypalEmail');
    remoteUser.save();
  }
  expenseData.UserId = remoteUser.id;

  const collective = await models.Collective.findByPk(expenseData.collective.id);

  if (expenseData.currency && expenseData.currency !== collective.currency) {
    throw new errors.ValidationFailed(
      `The currency of the expense (${expenseData.currency}) needs to be the same as the currency of the collective (${expenseData.collective.currency})`,
    );
  }

  if (!collective) {
    throw new errors.ValidationFailed('Collective not found');
  }

  if (expenseData.currency && expenseData.currency !== collective.currency) {
    throw new errors.ValidationFailed(
      `The currency of the expense (${expenseData.currency}) needs to be the same as the currency of the collective (${collective.currency})`,
    );
  }

  const expense = await models.Expense.create({
    ...expenseData,
    status: statuses.PENDING,
    CollectiveId: collective.id,
    lastEditedById: expenseData.UserId,
    incurredAt: expenseData.incurredAt || new Date(),
  });

  collective.addUserWithRole(remoteUser, roles.CONTRIBUTOR).catch(e => {
    if (e.name === 'SequelizeUniqueConstraintError') {
      console.log('User ', remoteUser.id, 'is already a contributor');
    } else {
      console.error(e);
    }
  });

  expense.user = remoteUser;
  expense.collective = collective;
  await expense.createActivity(activities.COLLECTIVE_EXPENSE_CREATED);
  return expense;
}

export async function editExpense(remoteUser, expenseData) {
  if (!remoteUser) {
    throw new errors.Unauthorized('You need to be logged in to edit an expense');
  }

  const expense = await models.Expense.findByPk(expenseData.id, {
    include: [{ model: models.Collective, as: 'collective' }],
  });

  if (!expense) {
    throw new errors.Unauthorized('Expense not found');
  }

  if (!canEditExpense(remoteUser, expense)) {
    throw new errors.Unauthorized("You don't have permission to edit this expense");
  }

  if (expenseData.currency && expenseData.currency !== expense.collective.currency) {
    throw new errors.ValidationFailed(
      `The currency of the expense (${expenseData.currency}) needs to be the same as the currency of the collective (${expense.collective.currency})`,
    );
  }

  // When updating amount, attachment or payoutMethod, we reset its status to PENDING
  if (
    expenseData.amount !== expense.amount ||
    expenseData.payoutMethod !== expense.payoutMethod ||
    expenseData.attachment !== expense.attachment
  ) {
    expenseData.status = statuses.PENDING;
  } else {
    // make sure that we don't override the status of the expense.
    delete expenseData.status;
  }

  expenseData.lastEditedById = remoteUser.id;
  await expense.update(expenseData);
  expense.createActivity(activities.COLLECTIVE_EXPENSE_UPDATED);
  return expense;
}

export async function deleteExpense(remoteUser, expenseId) {
  if (!remoteUser) {
    throw new errors.Unauthorized('You need to be logged in to delete an expense');
  }

  const expense = await models.Expense.findByPk(expenseId, {
    include: [{ model: models.Collective, as: 'collective' }],
  });

  if (!expense) {
    throw new errors.NotFound('Expense not found');
  }

  if (!canEditExpense(remoteUser, expense)) {
    throw new errors.Unauthorized("You don't have permission to delete this expense");
  }

  if (expense.status !== statuses.REJECTED) {
    throw new errors.Unauthorized('Only rejected expense can be deleted');
  }

  const res = await expense.destroy();
  return res;
}

/** Helper that finishes the process of paying an expense */
async function markExpenseAsPaid(expense) {
  debug('update expense status to PAID', expense.id);
  const updatedExpense = await expense.update({ status: statuses.PAID });
  await expense.createActivity(activities.COLLECTIVE_EXPENSE_PAID);
  return updatedExpense;
}

async function createTransactions(host, expense, fees = {}) {
  debug('marking expense as paid and creating transactions in the ledger', expense.id);
  return await createTransactionFromPaidExpense(
    host,
    null,
    expense,
    null,
    expense.UserId,
    fees.paymentProcessorFeeInHostCurrency,
    fees.hostFeeInHostCurrency,
    fees.platformFeeInHostCurrency,
  );
}

async function payExpenseWithPayPal(remoteUser, expense, host, paymentMethod, fees = {}) {
  debug('payExpenseWithPayPal', expense.id);
  try {
    const paymentResponse = await paymentProviders[expense.payoutMethod].types['adaptive'].pay(
      expense.collective,
      expense,
      expense.paypalEmail,
      paymentMethod.token,
    );
    await createTransactionFromPaidExpense(
      host,
      paymentMethod,
      expense,
      paymentResponse,
      expense.UserId,
      fees.paymentProcessorFeeInHostCurrency,
      fees.hostFeeInHostCurrency,
      fees.platformFeeInHostCurrency,
    );
    expense.setPaid(remoteUser.id);
    await paymentMethod.updateBalance();
  } catch (err) {
    debug('paypal> error', JSON.stringify(err, null, '  '));
    if (
      err.message.indexOf('The total amount of all payments exceeds the maximum total amount for all payments') !== -1
    ) {
      throw new errors.BadRequest(
        'Not enough funds in your existing Paypal preapproval. Please refill your PayPal payment balance.',
      );
    } else {
      throw new errors.BadRequest(err.message);
    }
  }
}

/**
 * Pay an expense based on the payout method defined in the Expense object
 * @PRE: fees { id, paymentProcessorFeeInCollectiveCurrency, hostFeeInCollectiveCurrency, platformFeeInCollectiveCurrency }
 * Note: some payout methods like PayPal will automatically define `paymentProcessorFeeInCollectiveCurrency`
 */
export async function payExpense(remoteUser, args) {
  const expenseId = args.id;
  const fees = omit(args, ['id', 'forceManual']);

  if (!remoteUser) {
    throw new errors.Unauthorized('You need to be logged in to pay an expense');
  }
  const expense = await models.Expense.findByPk(expenseId, {
    include: [{ model: models.Collective, as: 'collective' }],
  });
  if (!expense) {
    throw new errors.Unauthorized('Expense not found');
  }
  if (expense.status === statuses.PAID) {
    throw new errors.Unauthorized('Expense has already been paid');
  }
  if (expense.status !== statuses.APPROVED) {
    throw new errors.Unauthorized(`Expense needs to be approved. Current status of the expense: ${expense.status}.`);
  }
  if (!canUpdateExpenseStatus(remoteUser, expense)) {
    throw new errors.Unauthorized("You don't have permission to pay this expense");
  }
  const host = await expense.collective.getHostCollective();

  if (expense.payoutMethod === 'donation') {
    throw new Error('"In kind" donations are not supported anymore');
  }

  const balance = await expense.collective.getBalance();

  if (expense.amount > balance) {
    throw new errors.Unauthorized(
      `You don't have enough funds to pay this expense. Current balance: ${formatCurrency(
        balance,
        expense.collective.currency,
      )}, Expense amount: ${formatCurrency(expense.amount, expense.collective.currency)}`,
    );
  }

  const fxrate = await getFxRate(expense.collective.currency, host.currency);
  const feesInHostCurrency = {};
  if (paymentProviders[expense.payoutMethod] && !(expense.payoutMethod === 'paypal' && args.forceManual)) {
    fees.paymentProcessorFeeInCollectiveCurrency = await paymentProviders[expense.payoutMethod].types['adaptive'].fees({
      amount: expense.amount,
      currency: expense.collective.currency,
      host,
    });
  }

  feesInHostCurrency.paymentProcessorFeeInHostCurrency = Math.round(
    fxrate * (fees.paymentProcessorFeeInCollectiveCurrency || 0),
  );
  feesInHostCurrency.hostFeeInHostCurrency = Math.round(fxrate * (fees.hostFeeInCollectiveCurrency || 0));
  feesInHostCurrency.platformFeeInHostCurrency = Math.round(fxrate * (fees.platformFeeInCollectiveCurrency || 0));

  if (!fees.paymentProcessorFeeInCollectiveCurrency) {
    fees.paymentProcessorFeeInCollectiveCurrency = 0;
  }

  if (expense.amount + fees.paymentProcessorFeeInCollectiveCurrency > balance) {
    throw new Error(
      `You don't have enough funds to cover for the fees of this payment method. Current balance: ${formatCurrency(
        balance,
        expense.collective.currency,
      )}, Expense amount: ${formatCurrency(expense.amount, expense.collective.currency)}, Estimated ${
        expense.payoutMethod
      } fees: ${formatCurrency(fees.paymentProcessorFeeInCollectiveCurrency, expense.collective.currency)}`,
    );
  }
  if (expense.payoutMethod === 'paypal') {
    expense.paypalEmail = await expense.getPaypalEmail();
    const paymentMethod = await host.getPaymentMethod({
      service: expense.payoutMethod,
    });
    // If the expense has been filed with the same paypal email than the host paypal
    // then we simply mark the expense as paid
    if (expense.paypalEmail === paymentMethod.name) {
      feesInHostCurrency.paymentProcessorFeeInHostCurrency = 0;
      await createTransactions(host, expense, feesInHostCurrency);
    } else if (args.forceManual) {
      await createTransactions(host, expense, feesInHostCurrency);
    } else {
      await payExpenseWithPayPal(remoteUser, expense, host, paymentMethod, feesInHostCurrency);
    }
  }

  // note: we need to check for manual and other for legacy reasons
  if (expense.payoutMethod === 'manual' || expense.payoutMethod === 'other') {
    await createTransactions(host, expense, feesInHostCurrency);
  }

  return markExpenseAsPaid(expense);
}

export async function markExpenseAsUnpaid(remoteUser, ExpenseId, processorFeeRefunded) {
  if (!remoteUser) {
    throw new errors.Unauthorized('You need to be logged in to unpay an expense');
  }

  const expense = await models.Expense.findByPk(ExpenseId, {
    include: [
      { model: models.Collective, as: 'collective' },
      { model: models.User, as: 'User' },
    ],
  });

  if (!expense) {
    throw new errors.NotFound('No expense found');
  }

  if (!canMarkExpenseUnpaid(remoteUser, expense)) {
    throw new errors.Unauthorized("You don't have permission to mark this expense as unpaid");
  }

  if (expense.status !== statuses.PAID) {
    throw new errors.Unauthorized('Expense has not been paid yet');
  }

  if (expense.payoutMethod !== 'other') {
    throw new errors.Unauthorized('Only expenses with "other" payout method can be marked as unpaid');
  }

  const transaction = await models.Transaction.findOne({
    where: { ExpenseId },
    include: [{ model: models.Expense }],
  });

  const paymentProcessorFeeInHostCurrency = processorFeeRefunded ? transaction.paymentProcessorFeeInHostCurrency : 0;
  const refundedTransaction = await libPayments.createRefundTransaction(
    transaction,
    paymentProcessorFeeInHostCurrency,
    null,
    expense.User,
  );
  await libPayments.associateTransactionRefundId(transaction, refundedTransaction);

  return expense.update({ status: statuses.APPROVED, lastEditedById: remoteUser.id });
}
<|endoftext|>


<commit_before>/**
 * Converts selector and options to Mongo parameters (selector, fields)
 */
import mapValues from 'lodash/mapValues';
import uniq from 'lodash/uniq';
import isEmpty from 'lodash/isEmpty';
import escapeStringRegexp from 'escape-string-regexp';
import merge from 'lodash/merge';

import { getSetting } from './settings.js';
// convert GraphQL selector into Mongo-compatible selector
// TODO: add support for more than just documentId/_id and slug, potentially making conversion unnecessary
// see https://github.com/VulcanJS/Vulcan/issues/2000
export const convertSelector = selector => {
  return selector;
};
export const convertUniqueSelector = selector => {
  if (selector.documentId) {
    selector._id = selector.documentId;
    delete selector.documentId;
  }
  return selector;
};
/*

Filtering

*/
const conversionTable = {
  _eq: '$eq',
  _gt: '$gt',
  _gte: '$gte',
  _in: '$in',
  _lt: '$lt',
  _lte: '$lte',
  _neq: '$ne',
  _nin: '$nin',
  _is_null: value => ({ $exists: !value }),
  _is: value => value,
  _arrayContains: value => value,
  asc: 1,
  desc: -1,
};

// get all fields mentioned in an expression like [ { foo: { _gt: 2 } }, { bar: { _eq : 3 } } ]
const getFieldNames = expressionArray => {
  return expressionArray.map(exp => {
    const [fieldName] = Object.keys(exp);
    return fieldName;
  });
};

const isEmptyOrUndefined = value =>
  typeof value === 'undefined' ||
  value === null ||
  value === '' ||
  (typeof value === 'object' && isEmpty(value) && !(value instanceof Date));

export const filterFunction = async (collection, input = {}, context) => {
  // eslint-disable-next-line no-unused-vars
  const { filter, limit, sort, search, filterArguments, offset, id } = input;
  let selector = {};
  let options = {
    sort: {},
  };
  let filteredFields = [];

  const schema = collection.simpleSchema()._schema;

  /*

    Convert GraphQL expression into MongoDB expression, for example

    { fieldName: { operator: value } }

    { title: { _in: ["foo", "bar"] } }

    to:

    { title: { $in: ["foo", "bar"] } }

    or (intl fields):

    { title_intl.value: { $in: ["foo", "bar"] } }

    */
  const convertExpression = fieldExpression => {
    const [fieldName] = Object.keys(fieldExpression);
    const [operator] = Object.keys(fieldExpression[fieldName]);
    const value = fieldExpression[fieldName][operator];
    if (isEmptyOrUndefined(value)) {
      throw new Error(`Detected empty filter value for field ${fieldName} with operator ${operator}`);
    }
    const mongoOperator = conversionTable[operator];
    if (!mongoOperator) {
      throw new Error(`Operator ${operator} is not valid. Possible operators are: ${Object.keys(conversionTable)}`);
    }
    const isIntl = schema[fieldName].intl;
    const mongoFieldName = isIntl ? `${fieldName}_intl.value` : fieldName;
    const mongoObject = typeof mongoOperator === 'function' ? mongoOperator(value) : { [mongoOperator]: value };
    return { [mongoFieldName]: mongoObject };
  };

  // id
  if (id) {
    selector = { _id: id };
  }

  // filter
  if (!isEmpty(filter)) {
    Object.keys(filter).forEach(fieldName => {
      switch (fieldName) {
        case '_and':
          filteredFields = filteredFields.concat(getFieldNames(filter._and));
          selector['$and'] = filter._and.map(convertExpression);
          break;

        case '_or':
          filteredFields = filteredFields.concat(getFieldNames(filter._or));
          selector['$or'] = filter._or.map(convertExpression);
          break;

        case '_not':
          filteredFields = filteredFields.concat(getFieldNames(filter._not));
          selector['$not'] = filter._not.map(convertExpression);
          break;

        case 'search':
          break;

        default:
          const customFilters = collection.options.customFilters;
          const customFilter = customFilters && customFilters.find(f => f.name === fieldName);
          if (customFilter) {
            // field is not actually a field, but a custom filter
            const filterArguments = filter[customFilter.name];
            // TODO: make this work with await
            const filterObject = customFilter.filter({
              input,
              context,
              filterArguments,
            });
            selector = merge({}, selector, filterObject.selector);
            options = merge({}, options, filterObject.options);
          } else {
            // regular field
            filteredFields.push(fieldName);
            selector = { ...selector, ...convertExpression({ [fieldName]: filter[fieldName] }) };
          }
          break;
      }
    });
  }

  // sort
  if (!isEmpty(sort)) {
    options.sort = merge(
      {},
      options.sort,
      mapValues(sort, order => {
        const mongoOrder = conversionTable[order];
        if (!order) {
          throw new Error(`Operator ${order} is not valid. Possible operators: asc, desc`);
        }
        return mongoOrder;
      })
    );
  } else {
    options.sort = { createdAt: -1 }; // reliable default order
  }

  // search
  if (!isEmpty(search)) {
    const searchQuery = escapeStringRegexp(search);
    const searchableFieldNames = Object.keys(schema).filter(
      // do not include intl fields here
      fieldName => !fieldName.includes('_intl') && schema[fieldName].searchable
    );
    if (searchableFieldNames.length) {
      selector = {
        ...selector,
        $or: searchableFieldNames.map(fieldName => {
          const isIntl = schema[fieldName].intl;
          return {
            [isIntl ? `${fieldName}_intl.value` : fieldName]: {
              $regex: searchQuery,
              $options: 'i',
            },
          };
        }),
      };
    } else {
      // eslint-disable-next-line no-console
      console.warn(
        `Warning: search argument is set but schema ${
        collection.options.collectionName
        } has no searchable field. Set "searchable: true" for at least one field to enable search.`
      );
    }
  }

  // limit
  const maxLimit = getSetting('maxDocumentsPerRequest', 1000);
  options.limit = limit ? Math.min(limit, maxLimit) : maxLimit;

  // offest
  if (offset) {
    options.skip = offset;
  }

  // console.log('// collection');
  // console.log(collection.options.collectionName);
  // console.log('// input');
  // console.log(JSON.stringify(input, 2));
  // console.log('// selector');
  // console.log(JSON.stringify(selector, 2));
  // console.log('// filterFields');
  // console.log(uniq(filteredFields));

  return {
    selector,
    options,
    filteredFields: uniq(filteredFields),
  };
};
<commit_msg>bugfix for the input api so it can accept more than one input for a samme field<commit_after>/**
 * Converts selector and options to Mongo parameters (selector, fields)
 */
import mapValues from 'lodash/mapValues';
import uniq from 'lodash/uniq';
import isEmpty from 'lodash/isEmpty';
import escapeStringRegexp from 'escape-string-regexp';
import merge from 'lodash/merge';

import { getSetting } from './settings.js';
// convert GraphQL selector into Mongo-compatible selector
// TODO: add support for more than just documentId/_id and slug, potentially making conversion unnecessary
// see https://github.com/VulcanJS/Vulcan/issues/2000
export const convertSelector = selector => {
  return selector;
};
export const convertUniqueSelector = selector => {
  if (selector.documentId) {
    selector._id = selector.documentId;
    delete selector.documentId;
  }
  return selector;
};
/*

Filtering

*/
const conversionTable = {
  _eq: '$eq',
  _gt: '$gt',
  _gte: '$gte',
  _in: '$in',
  _lt: '$lt',
  _lte: '$lte',
  _neq: '$ne',
  _nin: '$nin',
  _is_null: value => ({ $exists: !value }),
  _is: value => value,
  _arrayContains: value => value,
  asc: 1,
  desc: -1,
};

// get all fields mentioned in an expression like [ { foo: { _gt: 2 } }, { bar: { _eq : 3 } } ]
const getFieldNames = expressionArray => {
  return expressionArray.map(exp => {
    const [fieldName] = Object.keys(exp);
    return fieldName;
  });
};

const isEmptyOrUndefined = value =>
  typeof value === 'undefined' ||
  value === null ||
  value === '' ||
  (typeof value === 'object' && isEmpty(value) && !(value instanceof Date));

export const filterFunction = async (collection, input = {}, context) => {
  // eslint-disable-next-line no-unused-vars
  const { filter, limit, sort, search, filterArguments, offset, id } = input;
  let selector = {};
  let options = {
    sort: {},
  };
  let filteredFields = [];

  const schema = collection.simpleSchema()._schema;

  /*

    Convert GraphQL expression into MongoDB expression, for example

    { fieldName: { operator: value } }

    { title: { _in: ["foo", "bar"] } }

    to:

    { title: { $in: ["foo", "bar"] } }

    or (intl fields):

    { title_intl.value: { $in: ["foo", "bar"] } }

    */
   const convertExpression = fieldExpression => {
    const [fieldName] = Object.keys(fieldExpression);
    const operators = Object.keys(fieldExpression[fieldName]);
    const mongoExpression = {};
    operators.forEach(operator => {
      const value = fieldExpression[fieldName][operator];
      if (isEmptyOrUndefined(value)) {
        throw new Error(`Detected empty filter value for field ${fieldName} with operator ${operator}`);
      }
      const mongoOperator = conversionTable[operator];
      if (!mongoOperator) {
        throw new Error(`Operator ${operator} is not valid. Possible operators are: ${Object.keys(conversionTable)}`);
      }
      const mongoObject = typeof mongoOperator === 'function' ? mongoOperator(value) : { [mongoOperator]: value };
      merge(mongoExpression, mongoObject);
    });
    const isIntl = schema[fieldName].intl;
    const mongoFieldName = isIntl ? `${fieldName}_intl.value` : fieldName;
    return { [mongoFieldName]: mongoExpression };
  };

  // id
  if (id) {
    selector = { _id: id };
  }

  // filter
  if (!isEmpty(filter)) {
    Object.keys(filter).forEach(fieldName => {
      switch (fieldName) {
        case '_and':
          filteredFields = filteredFields.concat(getFieldNames(filter._and));
          selector['$and'] = filter._and.map(convertExpression);
          break;

        case '_or':
          filteredFields = filteredFields.concat(getFieldNames(filter._or));
          selector['$or'] = filter._or.map(convertExpression);
          break;

        case '_not':
          filteredFields = filteredFields.concat(getFieldNames(filter._not));
          selector['$not'] = filter._not.map(convertExpression);
          break;

        case 'search':
          break;

        default:
          const customFilters = collection.options.customFilters;
          const customFilter = customFilters && customFilters.find(f => f.name === fieldName);
          if (customFilter) {
            // field is not actually a field, but a custom filter
            const filterArguments = filter[customFilter.name];
            // TODO: make this work with await
            const filterObject = customFilter.filter({
              input,
              context,
              filterArguments,
            });
            selector = merge({}, selector, filterObject.selector);
            options = merge({}, options, filterObject.options);
          } else {
            // regular field
            filteredFields.push(fieldName);
            selector = { ...selector, ...convertExpression({ [fieldName]: filter[fieldName] }) };
          }
          break;
      }
    });
  }

  // sort
  if (!isEmpty(sort)) {
    options.sort = merge(
      {},
      options.sort,
      mapValues(sort, order => {
        const mongoOrder = conversionTable[order];
        if (!order) {
          throw new Error(`Operator ${order} is not valid. Possible operators: asc, desc`);
        }
        return mongoOrder;
      })
    );
  } else {
    options.sort = { createdAt: -1 }; // reliable default order
  }

  // search
  if (!isEmpty(search)) {
    const searchQuery = escapeStringRegexp(search);
    const searchableFieldNames = Object.keys(schema).filter(
      // do not include intl fields here
      fieldName => !fieldName.includes('_intl') && schema[fieldName].searchable
    );
    if (searchableFieldNames.length) {
      selector = {
        ...selector,
        $or: searchableFieldNames.map(fieldName => {
          const isIntl = schema[fieldName].intl;
          return {
            [isIntl ? `${fieldName}_intl.value` : fieldName]: {
              $regex: searchQuery,
              $options: 'i',
            },
          };
        }),
      };
    } else {
      // eslint-disable-next-line no-console
      console.warn(
        `Warning: search argument is set but schema ${
        collection.options.collectionName
        } has no searchable field. Set "searchable: true" for at least one field to enable search.`
      );
    }
  }

  // limit
  const maxLimit = getSetting('maxDocumentsPerRequest', 1000);
  options.limit = limit ? Math.min(limit, maxLimit) : maxLimit;

  // offest
  if (offset) {
    options.skip = offset;
  }

  // console.log('// collection');
  // console.log(collection.options.collectionName);
  // console.log('// input');
  // console.log(JSON.stringify(input, 2));
  // console.log('// selector');
  // console.log(JSON.stringify(selector, 2));
  // console.log('// filterFields');
  // console.log(uniq(filteredFields));

  return {
    selector,
    options,
    filteredFields: uniq(filteredFields),
  };
};
<|endoftext|>