punchcard-cms/punchcard

View on GitHub
lib/api/utils.js

Summary

Maintainability
D
2 days
Test Coverage
'use strict';

const _ = require('lodash');

const database = require('../database');
const schemas = require('../database/schemas');
const utils = require('../utils');

const liveSchema = utils.singleItem('name', 'live', schemas).fields.map(field => {
  return field.name;
});

/**
 * Convert value to a reference value
 *
 * @param {string} val - content of input
 * @param {object} input - input object from content model
 * @param {string} location - The location of the reference
 * @param {object} references - All references
 *
 * @returns {string|object} either the original value or an object for a reference value
 */
const reference = (val, input, location, references) => {
  const value = val;
  const refs = references;

  if (input.hasOwnProperty('reference') && input.reference === true) {
    const type = _.get(input, 'settings.contentType', null);

    // add content type information to value
    if (type !== null) {
      if (!refs.hasOwnProperty(value)) {
        refs[value] = {
          paths: [],
          type,
        };
      }

      refs[value].paths.push(location);

      return refs;
    }
  }

  return false;
};

/*
 * Formats raw attributes in to API output
 *
 * @param {array} attrs - Array of objects representing the raw attribute content
 * @param {array} model - Array of objects representing the content type's attribute model
 */
const attributes = (attrs, model, models, query, trx) => {
  const params = _.cloneDeep(query) || {};
  const result = {};
  let references = {};

  // check that query has depth property
  if (!params.hasOwnProperty('depth') || isNaN(parseInt(params.depth, 10))) {
    params.depth = 0;
  }

  model.forEach(attr => {
    if (!attr.hasOwnProperty('repeatable')) {
      const inputs = Object.keys(attr.inputs);
      const total = Object.keys(inputs).length;

      // one input, not repeatable
      if (total === 1) {
        const value = _.get(attrs, `${attr.id}.${inputs[0]}.value`, null);

        // add value to result
        result[attr.id] = value;

        // add reference object, if there is one
        const ref = reference(value, attr.inputs[inputs[0]], `${attr.id}`, references);

        if (ref) {
          references = ref;
        }
      }
      else {
        result[attr.id] = {};

        inputs.forEach(inp => {
          const value = _.get(attrs, `${attr.id}.${inp}.value`, null);

          // add value to result
          result[attr.id][inp] = value;

          // add reference object, if there is one
          const ref = reference(value, attr.inputs[inp], `${attr.id}.${inp}`, references);

          if (ref) {
            references = ref;
          }
        });
      }
    }
    else {
      let inputs;
      let input;

      // compensates for array of inputs, this is a bug: https://github.com/punchcard-cms/content-types/issues/133
      //  content-types adds inputs according the the `min` config attribute
      if (Array.isArray(attr.inputs)) {
        // will get the 1st input in a repeatable with a `min` config
        inputs = Object.keys(attr.inputs[0]);
        input = attr.inputs[0][inputs[0]];
      }
      else {
        inputs = Object.keys(attr.inputs);
        input = attr.inputs[inputs[0]];
      }

      if (_.has(attrs, attr.id)) {
        // items is the repeated content for this attribute
        let items = _.get(attrs, attr.id, []);

        if (!Array.isArray(items)) {
          items = [items];
        }

        result[attr.id] = items.map((item, index) => {
          if (Object.keys(items[0]).length === 1) {
            const value = _.get(item, `${inputs[0]}.value`, null);

            // add reference object, if there is one
            const ref = reference(value, input, `${attr.id}[${index}]`, references);

            if (ref) {
              references = ref;
            }

            // add value to result
            return _.get(item, `${inputs[0]}.value`, null);
          }

          const repeat = {};

          inputs.forEach((inp) => {
            const value = _.get(item, `${inp}.value`, null);

            // add reference object, if there is one
            const ref = reference(value, attr.inputs[inp], `${attr.id}[${index}].${inp}`, references);

            if (ref) {
              references = ref;
            }

            // add value to result
            repeat[inp] = value;
          });

          return repeat;
        });
      }
      else {
        result[attr.id] = null;
      }
    }
  });

  if (Object.keys(references).length > 0) {
    params.depth--;

    // we've reached our depth, follow no more
    if (params.depth < 0) {
      params.follow = false;
    }

    const items = Object.keys(references).map(id => {
      const mod = models.find(type => {
        return type.id === references[id].type;
      });

      // eslint will loop on this, so hiding
      return one(params, id, mod.attributes, models, trx); // eslint-disable-line no-use-before-define
    });

    return Promise.all(items).then(row => {
      if (row.length > 0 && row[0] !== null) {
        row.forEach(rw => {
          if (references[rw.id] && Array.isArray(references[rw.id].paths)) {
            references[rw.id].paths.forEach(path => {
              _.set(result, path, rw);
            });
          }
        });
      }

      return result;
    }).catch(e => {
      console.log(e); // eslint-disable-line no-console
    });
  }

  return new Promise(res => {
    res(result);
  });
};

/*
 * Formats raw live data in to consumable returns
 *
 * @param {array} content - Array of objects representing the raw DB query of content
 * @param {array} attrs - If included, will include attributes of the content model
 * @param {array} models - If included, will include all content type models
 * @param {object} query - If included, will include query parameters
 *
 * @returns {array} - formatted object of content
 */
const format = (content, attrs, models, query, trx) => {
  const params = _.cloneDeep(query) || {};
  let atrs = attrs || [];
  const mods = _.cloneDeep(models) || [];

  if (!params.hasOwnProperty('depth') || isNaN(parseInt(params.depth, 10))) {
    params.depth = 0;
  }

  if (!Array.isArray(atrs)) {
    return new Promise((res, rej) => {
      rej('Content model attributes must be an array');
    });
  }

  if (!Array.isArray(mods)) {
    return new Promise((res, rej) => {
      rej('Content models parameter must be an array');
    });
  }

  const formatted = content.map(item => {
    const result = {
      id: item.id,
      type: item.type,
      type_slug: item['type-slug'], // eslint-disable-line camelcase
      key: item.key,
      key_slug: item['key-slug'], // eslint-disable-line camelcase
    };

    // get the attributes model for this piece of content
    if (mods.length > 1) {
      const type = mods.find(typ => {
        return typ.id === item['type-slug'];
      });

      if (type && type.hasOwnProperty('attributes')) {
        atrs = type.attributes;
      }
    }

    if (atrs.length > 0 && params.follow) {
      return attributes(item.attributes, atrs, mods, params, trx).then(rattrs => {
        result.attributes = rattrs;

        return result;
      });
    }

    result.meta = {
      url: `/api/types/${item['type-slug']}/${item.id}`,
    };

    return new Promise(res => {
      res(result);
    });
  });

  return Promise.all(formatted);
};

/*
 * Determines raw live data organization
 *
 * @param {object} query - Request query params
 * @param {array} custom - A custom array of keys to use
 *
 * @returns {object} - Object with sort key, sort direction, pagination count and offset
 */
const organize = (query, custom) => {
  const params = query || {};
  let lookup = custom;

  if (lookup && !Array.isArray(lookup)) {
    lookup = false;
  }

  let sort = params.sort || 'key';
  let sortDir = params.sort_dir || 'asc';
  let sortKeys = lookup || liveSchema;

  let page = parseInt(params.page, 10) || 1;
  let perPage = parseInt(params.per_page, 10) || 30;
  let count = 0;


  //////////////////////////////
  // Sorting
  //////////////////////////////
  // Ensure sort is an available key
  sortKeys = sortKeys.filter(key => {
    if (key === 'attributes') {
      return false;
    }

    return true;
  });
  if (sortKeys.indexOf(sort) < 0) {
    if (lookup) {
      sort = lookup[0];
    }
    else {
      sort = 'key';
    }
  }

  // Ensure sorting is either ascending or descending
  if (sortDir !== 'asc' && sortDir !== 'desc') {
    sortDir = 'asc';
  }

  //////////////////////////////
  // Pagination
  //////////////////////////////
  // Ensure there is at least one page returned
  if (page < 1) {
    page = 1;
  }

  // Ensure pagination fits within parameters
  if (perPage < 1) {
    perPage = 1;
  }
  else if (perPage > 100) {
    perPage = 100;
  }

  // Calculate offset
  count = (page - 1) * perPage;

  return {
    sort: {
      by: sort,
      dir: sortDir,
    },
    page: {
      offset: count,
      limit: perPage,
      page,
    },
  };
};

/*
 * Build Pagination Links
 *
 * @param {string} base - Base URL
 * @param {object} organized - Organized sort and pagination
 * @param {number} count - Total number of items
 */
const page = (base, organized, count) => {
  const baseURL = `/${base}?sort=${organized.sort.by}&sort_dir=${organized.sort.dir}&per_page=${organized.page.limit}`;
  const pages = {
    first: `${baseURL}&page=1`,
  };
  let total = Math.floor(count / organized.page.limit);
  if (count % organized.page.limit !== 0) {
    total += 1;
  }

  // If not the first page, add a previous link
  if (organized.page.page !== 1 && organized.page.page <= total) {
    pages.prev = `${baseURL}&page=${organized.page.page - 1}`;
  }
  else {
    pages.prev = false;
  }

  // Add next link if not on the last page
  if (organized.page.page + 1 <= total) {
    pages.next = `${baseURL}&page=${organized.page.page + 1}`;
  }
  else {
    pages.next = false;
  }

  // Add last page
  pages.last = `${baseURL}&page=${total}`;

  // If first and last are the same, set them to false
  if (pages.first === pages.last) {
    pages.first = false;
    pages.last = false;
  }

  // If there are no items, set everything to false
  if (total === 0) {
    pages.next = false;
    pages.prev = false;
    pages.first = false;
    pages.last = false;
  }

  return pages;
};

/*
 *  A Specific Piece of Content
 *
 * @param {object} query - Request queries
 * @param {string} id - The GUID to search
 * @param {array} model - Array of content type attributes
 * @param {function} trx - Database transaction object
 *
 * @returns {object} - All items and the pagination for the items
 */
// TODO: Return error if no items are found
const one = (query, id, model, models, trx) => {
  const db = trx || database;
  const params = _.cloneDeep(query) || {};

  // check that query has depth property
  if (!params.hasOwnProperty('depth') || isNaN(parseInt(params.depth, 10))) {
    params.depth = 0;
  }

  return db('live').select('*').where({
    id,
  }).then(rows => {
    return format(rows, model, models, params, db).then(fmtd => {
      const formatted = fmtd[0];

      if (formatted) {
        const type = {
          name: formatted.type,
          slug: formatted.type_slug,
          url: `/api/types/${formatted.type_slug}`,
        };

        formatted.type = type;
        delete formatted.type_slug;

        return formatted;
      }

      return {};
    });
  });
};

module.exports = {
  attributes,
  format,
  organize,
  page,
  one,
};