huridocs/uwazi

View on GitHub
app/api/search/search.js

Summary

Maintainability
A
1 hr
Test Coverage
A
99%
import _ from 'lodash';

import date from 'api/utils/date';
import propertiesHelper from 'shared/commonProperties';
import dictionariesModel from 'api/thesauri/dictionariesModel';
import { createError } from 'api/utils';
import { filterOptions } from 'shared/optionsUtils';
import { preloadOptionsLimit, preloadOptionsSearch } from 'shared/config';
import { permissionsContext } from 'api/permissions/permissionsContext';
import { checkWritePermissions } from 'shared/permissionsUtils';
import usersModel from 'api/users/users';
import userGroups from 'api/usergroups/userGroups';
import { sequentialPromises } from 'shared/asyncUtils';
import { objectIndex } from 'shared/data_utils/objectIndex';
import { propertyTypes } from 'shared/propertyTypes';
import { UserRole } from 'shared/types/userSchema';
import documentQueryBuilder from './documentQueryBuilder';
import { elastic } from './elastic';
import entitiesModel from '../entities/entitiesModel';
import templatesModel from '../templates';
import { bulkIndex, indexEntities, updateMapping } from './entitiesIndex';
import thesauri from '../thesauri';
import * as v2 from './v2_support';

function processParentThesauri(property, values, dictionaries, properties) {
  if (!values) {
    return values;
  }

  const sourceProperty =
    property.type === 'relationship' && property.inherit
      ? properties.find(p => p._id.toString() === property.inherit.property.toString())
      : property;

  if (!sourceProperty || !['select', 'multiselect'].includes(sourceProperty.type)) {
    return values;
  }

  const dictionary = dictionaries.find(d => d._id.toString() === sourceProperty.content.toString());
  return values.reduce((memo, v) => {
    const dictionaryValue = dictionary.values.find(dv => dv.id === v);

    if (!dictionaryValue || !dictionaryValue.values) {
      return [...memo, v];
    }

    return [...memo, ...dictionaryValue.values.map(dvv => dvv.id)];
  }, []);
}

function processFilters(filters, properties, dictionaries) {
  return Object.keys(filters || {}).reduce((res, filterName) => {
    const suggested = filterName.startsWith('__');
    const propertyName = suggested ? filterName.substring(2) : filterName;
    const property = properties.find(p => p.name === propertyName);

    if (!property) {
      return res;
    }

    let { type } = property;
    let value = filters[filterName];

    if (property.inherit) {
      ({ type } = propertiesHelper.getInheritedProperty(property, properties));
    }

    if (property.type === 'newRelationship') {
      type = properties.find(p => p.name === property.denormalizedProperty)?.type || 'select';
    }

    if (['multidaterange', 'daterange', 'date', 'multidate'].includes(type)) {
      value.from = date.descriptionToTimestamp(value.from);
      value.to = date.descriptionToTimestamp(value.to);
    }

    if (['text', 'markdown', 'generatedid'].includes(type) && typeof value === 'string') {
      value = value.toLowerCase();
    }

    if (['date', 'multidate', 'numeric'].includes(type)) {
      type = 'range';
    }

    if (['select', 'multiselect', 'relationship'].includes(type)) {
      type = 'multiselect';
      value.values = processParentThesauri(property, value.values, dictionaries, properties);
    }

    if (type === 'multidaterange' || type === 'daterange') {
      type = 'daterange';
    }

    return [
      ...res,
      {
        ...property,
        value,
        suggested,
        type,
        name: property.inherit ? `${property.name}.inheritedValue.value` : `${property.name}.value`,
      },
    ];
  }, []);
}

function getContent(property, allProperties, newRelationshipsEnabled) {
  return (
    v2.deducePropertyContent(property, newRelationshipsEnabled) ||
    (property.inherit
      ? propertiesHelper.getInheritedProperty(property, allProperties).content
      : property.content)
  );
}

function getAggregatedIndexedPropertyPath(property, newRelationshipsEnabled) {
  return (
    v2.getAggregatedIndexedPropertyPath(property, newRelationshipsEnabled) ||
    (property.inherit ? `${property.name}.inheritedValue.value` : `${property.name}.value`)
  );
}

function toAggregationData(allProperties, newRelationshipsEnabled) {
  return property => ({
    ...property,
    name: getAggregatedIndexedPropertyPath(property, newRelationshipsEnabled),
    content: getContent(property, allProperties, newRelationshipsEnabled),
  });
}

function getTypeToAggregate(property, allProperties, newRelationshipsEnabled) {
  return (
    v2.getTypeToAggregate(property, allProperties, newRelationshipsEnabled) ||
    (property.inherit ? property.inherit.type : property.type)
  );
}

async function aggregationProperties(propertiesToBeAggregated, allProperties) {
  const newRelationshipsEnabled = await v2.checkFeatureEnabled();
  return propertiesToBeAggregated
    .filter(property => {
      const type = getTypeToAggregate(property, allProperties, newRelationshipsEnabled);

      return (
        type === 'select' ||
        type === 'multiselect' ||
        type === 'relationship' ||
        type === 'nested' ||
        type === propertyTypes.newRelationship
      );
    })
    .map(toAggregationData(allProperties, newRelationshipsEnabled));
}

function metadataSnippetsFromSearchHit(hit) {
  const defaultSnippets = { count: 0, snippets: [] };
  if (hit.highlight) {
    const metadataHighlights = hit.highlight;
    const metadataSnippets = Object.keys(metadataHighlights).reduce((foundSnippets, field) => {
      const fieldSnippets = { field, texts: metadataHighlights[field] };
      return {
        count: foundSnippets.count + fieldSnippets.texts.length,
        snippets: [...foundSnippets.snippets, fieldSnippets],
      };
    }, defaultSnippets);
    return metadataSnippets;
  }
  return defaultSnippets;
}

function getSnippets() {
  return snippet => {
    const regex = /\[\[(\d+)\]\]/g;
    const matches = regex.exec(snippet);
    return {
      text: snippet.replace(regex, ''),
      page: matches ? Number(matches[1]) : 0,
    };
  };
}

function getHits(hit) {
  return hit.inner_hits &&
    hit.inner_hits.fullText.hits.hits &&
    hit.inner_hits.fullText.hits.hits.length > 0 &&
    hit.inner_hits.fullText.hits.hits[0].highlight
    ? hit.inner_hits.fullText.hits.hits
    : undefined;
}
function fullTextSnippetsFromSearchHit(hit) {
  const hits = getHits(hit);
  if (hits) {
    const fullTextHighlights = hits[0].highlight;
    const fullTextLanguageKey = Object.keys(fullTextHighlights)[0];
    return fullTextHighlights[fullTextLanguageKey].map(getSnippets());
  }
  return [];
}

function snippetsFromSearchHit(hit) {
  const snippets = {
    count: 0,
    metadata: [],
    fullText: [],
  };

  const metadataSnippets = metadataSnippetsFromSearchHit(hit);
  const fullTextSnippets = fullTextSnippetsFromSearchHit(hit);
  snippets.count = metadataSnippets.count + fullTextSnippets.length;
  snippets.metadata = metadataSnippets.snippets;
  snippets.fullText = fullTextSnippets;

  return snippets;
}

function searchGeolocation(documentsQuery, templates) {
  documentsQuery.limit(9999);
  documentsQuery.from(0);
  const geolocationProperties = [];

  templates.forEach(template => {
    template.properties.forEach(prop => {
      if (prop.type === 'geolocation') {
        geolocationProperties.push(`${prop.name}`);
      }

      if (prop.type === 'relationship' && prop.inherit) {
        const contentTemplate = templates.find(t => t._id.toString() === prop.content.toString());
        const inheritedProperty = propertiesHelper.getInheritedProperty(
          prop,
          contentTemplate.properties
        );
        if (inheritedProperty?.type === 'geolocation') {
          geolocationProperties.push(`${prop.name}`);
        }
      }
    });
  });

  documentsQuery.hasMetadataProperties(geolocationProperties);

  const selectProps = geolocationProperties
    .map(p => `metadata.${p}`)
    .concat(['title', 'template', 'sharedId', 'language']);

  documentsQuery.select(selectProps);
}

const _sanitizeAgregationNames = aggregations =>
  Object.keys(aggregations).reduce((allAggregations, key) => {
    const sanitizedKey = key.replace('.inheritedValue.value', '').replace('.value', '');
    return Object.assign(allAggregations, { [sanitizedKey]: aggregations[key] });
  }, {});

const indexedDictionaryValues = dictionary =>
  dictionary.values
    .reduce((values, v) => {
      if (v.values) {
        return values.concat(v.values, [v]);
      }
      values.push(v);
      return values;
    }, [])
    .reduce((v, value) => {
      // eslint-disable-next-line no-param-reassign
      v[value.id] = value;
      return v;
    }, {});

const _getAggregationDictionary = async (
  aggregation,
  language,
  property,
  dictionariesById,
  dictionaryCache
) => {
  if (property.type === 'relationship' || property.type === propertyTypes.newRelationship) {
    const entitiesSharedId = aggregation.buckets.map(bucket => bucket.key);

    const bucketEntities = await entitiesModel.getUnrestricted(
      {
        sharedId: { $in: entitiesSharedId },
        language,
      },
      {
        sharedId: 1,
        title: 1,
        icon: 1,
      }
    );

    const dictionary = thesauri.entitiesToThesauri(bucketEntities);
    return [dictionary, indexedDictionaryValues(dictionary)];
  }

  const propContent = property.content.toString();
  if (!dictionaryCache[propContent]) {
    const dictionary = dictionariesById[propContent];
    const dictionaryValues = indexedDictionaryValues(dictionary);
    dictionaryCache[propContent] = [dictionary, dictionaryValues];
  }
  return dictionaryCache[propContent];
};

const extractMissingBucket = buckets => {
  const [missingBuckets, remainingBuckets] = _.partition(buckets, b => b.key === 'missing');
  const missingBucket = missingBuckets && missingBuckets.length ? missingBuckets[0] : null;
  return { missingBucket, remainingBuckets };
};

const groupAndLimitBuckets = (buckets, dictionary, _limit) => {
  const aggregationBucketsByKey = objectIndex(
    buckets,
    b => b.key,
    b => b
  );
  const missingBucket = aggregationBucketsByKey.missing;
  const limit = missingBucket ? _limit - 1 : _limit;
  const newBuckets = [];

  let dictIndex = 0;
  while (newBuckets.length < limit && dictIndex < dictionary.values.length) {
    const dictionaryValue = dictionary.values[dictIndex];
    const bucket = aggregationBucketsByKey[dictionaryValue.id];
    if (bucket) {
      if (dictionaryValue.values) {
        bucket.values = dictionaryValue.values
          .map(v => aggregationBucketsByKey[v.id])
          .filter(b => b);
      }
      newBuckets.push(bucket);
    }
    dictIndex += 1;
  }

  if (missingBucket) newBuckets.push(missingBucket);
  return newBuckets;
};

const limitBuckets = (buckets, _limit) => {
  if (buckets.length <= _limit) {
    return buckets;
  }

  const { missingBucket, remainingBuckets } = extractMissingBucket(buckets);
  const limit = missingBucket ? _limit - 1 : _limit;
  const limitedBuckets = remainingBuckets.slice(0, limit);
  if (missingBucket) limitedBuckets.push(missingBucket);

  return limitedBuckets;
};

function assignLabels(buckets, dictionaryValues) {
  const labeledBuckets = [];

  buckets.forEach(bucket => {
    const newBucket = {
      ...bucket,
    };

    const labelItem =
      bucket.key === 'missing' ? { label: 'No label' } : dictionaryValues[bucket.key];

    if (bucket.values) {
      newBucket.values = assignLabels(bucket.values, dictionaryValues);
    }

    if (labelItem) {
      const { label, icon } = labelItem;
      newBucket.label = label;
      newBucket.icon = icon;
      labeledBuckets.push(newBucket);
    }
  });

  return labeledBuckets;
}

const SIMPLE_LIMIT_KEYS = new Set(['_types', 'generatedToc', '_permissions.self', '_published']);
const PERMISSION_KEYS = new Set(['_permissions.read', '_permissions.write']);
const _denormalizeAndLimitAggregations = async (
  aggregations,
  templates,
  dictionaries,
  language,
  limit
) => {
  const properties = propertiesHelper.allProperties(templates);
  const propertiesByName = objectIndex(
    properties,
    p => p.name,
    p => p
  );
  const propertiesById = objectIndex(
    properties,
    p => p._id.toString(),
    p => p
  );
  const newRelationshipsEnabled = v2.checkFeatureEnabled();
  const dictionariesById = objectIndex(
    dictionaries,
    d => d._id.toString(),
    d => d
  );
  const dictionaryCache = {};
  const denormalizedAggregations = {};
  // eslint-disable-next-line max-statements
  await sequentialPromises(Object.keys(aggregations), async key => {
    if (!aggregations[key].buckets || aggregations[key].type === 'nested') {
      denormalizedAggregations[key] = aggregations[key];
      return;
    }

    if (SIMPLE_LIMIT_KEYS.has(key)) {
      denormalizedAggregations[key] = {
        ...aggregations[key],
        buckets: limitBuckets(aggregations[key].buckets, limit),
      };
      return;
    }

    if (PERMISSION_KEYS.has(key)) {
      const [users, groups] = await Promise.all([usersModel.get(), userGroups.get()]);

      const info = [
        ...users.map(u => ({ type: 'user', refId: u._id, label: u.username })),
        ...groups.map(g => ({ type: 'group', refId: g._id, label: g.name })),
      ];
      const infoByRefId = objectIndex(
        info,
        i => i.refId.toString(),
        i => i
      );

      const role = permissionsContext.getUserInContext()?.role;
      const refIds = permissionsContext.permissionsRefIds();

      let buckets = limitBuckets(aggregations[key].buckets, limit);
      buckets =
        role === UserRole.ADMIN ? buckets : buckets.filter(bucket => refIds.includes(bucket.key));
      buckets = buckets
        .map(bucket => {
          const itemInfo = infoByRefId[bucket.key];

          if (!itemInfo) return null;

          return {
            ...bucket,
            label: itemInfo.label,
            ...(itemInfo.type === 'group' ? { icon: 'users' } : {}),
          };
        })
        .filter(b => b);

      denormalizedAggregations[key] = { ...aggregations[key], buckets };
      return;
    }

    let property = propertiesByName[key];
    if (!property && key.startsWith('__')) {
      const _key = key.substring(2);
      property = _key in propertiesByName ? propertiesByName[_key] : null;
    }

    if (property.inherit) {
      property = propertiesHelper.getInheritedProperty(property, properties, propertiesById);
    }

    property = v2.findDenormalizedProperty(property, properties, newRelationshipsEnabled);

    const [dictionary, dictionaryValues] = await _getAggregationDictionary(
      aggregations[key],
      language,
      property,
      dictionariesById,
      dictionaryCache
    );

    let groupedBuckets = aggregations[key].buckets;
    if (dictionary && dictionary.values.find(v => v.values)) {
      groupedBuckets = groupAndLimitBuckets(groupedBuckets, dictionary, limit);
    } else {
      groupedBuckets = limitBuckets(groupedBuckets, limit);
    }

    const denormalizedBuckets = assignLabels(groupedBuckets, dictionaryValues);

    const denormalizedAggregation = Object.assign(aggregations[key], {
      buckets: denormalizedBuckets,
    });
    denormalizedAggregations[key] = denormalizedAggregation;
  });
  return denormalizedAggregations;
};

const _sanitizeGroupedSelectAggregationStructure = aggregation => {
  const parentBuckets = aggregation.parent.buckets.filter(b => b.key !== 'missing');
  const valueBuckets = aggregation.self.buckets;
  const newBuckets = parentBuckets.concat(valueBuckets);
  return newBuckets;
};

const _sanitizeAggregationsStructure = aggregations => {
  const result = {};
  Object.keys(aggregations).forEach(aggregationKey => {
    const aggregation = aggregations[aggregationKey];

    //grouped dictionary
    if (aggregation.parent && aggregation.self) {
      aggregation.buckets = _sanitizeGroupedSelectAggregationStructure(aggregation);
    }

    //permissions
    if (['_permissions.read', '_permissions.write', '_permissions.self'].includes(aggregationKey)) {
      aggregation.buckets = aggregation.nestedPermissions.filtered.buckets.map(b => ({
        key: b.key,
        filtered: { doc_count: b.filteredByUser.uniqueEntities.doc_count },
      }));
    }

    //published
    if (aggregationKey === '_published') {
      aggregation.buckets = aggregation.filtered.buckets.map(b => ({
        key: b.key,
        filtered: { doc_count: b.doc_count },
      }));
    }

    //nested
    if (!aggregation.buckets) {
      Object.keys(aggregation).forEach(key => {
        if (aggregation[key].buckets) {
          const buckets = aggregation[key].buckets.map(option => ({
            key: option.key,
            ...option.filtered.total,
          }));
          result[key] = {
            type: 'nested',
            doc_count: aggregation[key].doc_count,
            buckets,
          };
        }
      });
    }

    if (aggregation.buckets) {
      aggregation.buckets = aggregation.buckets.filter(b => b.filtered.doc_count);
      aggregation.count = aggregation.buckets.length;
    }

    result[aggregationKey] = aggregation;
  });

  return result;
};

const _addAnyAggregation = (aggregations, filters, response) => {
  const result = {};
  Object.keys(aggregations).map(aggregationKey => {
    const aggregation = aggregations[aggregationKey];

    if (aggregation.buckets && aggregationKey !== '_types') {
      const missingBucket = aggregation.buckets.find(b => b.key === 'missing');
      const keyFilters = ((filters || {})[aggregationKey.replace('.value', '')] || {}).values || [];
      const filterNoneOrMissing =
        !keyFilters.filter(v => v !== 'any').length || keyFilters.find(v => v === 'missing');

      const anyCount =
        (typeof response.body.hits.total === 'object'
          ? response.body.hits.total.value
          : response.body.hits.total) -
        (missingBucket && filterNoneOrMissing ? missingBucket.filtered.doc_count : 0);

      aggregation.buckets.push({
        key: 'any',
        doc_count: anyCount,
        label: 'Any',
        filtered: { doc_count: anyCount },
      });
      aggregation.count += 1;
    }

    result[aggregationKey] = aggregation;
  });

  return result;
};

const _sanitizeAggregations = async (
  aggregations,
  templates,
  dictionaries,
  language,
  limit = preloadOptionsLimit()
) => {
  const sanitizedAggregations = _sanitizeAggregationsStructure(aggregations);
  const sanitizedAggregationNames = _sanitizeAgregationNames(sanitizedAggregations);
  return _denormalizeAndLimitAggregations(
    sanitizedAggregationNames,
    templates,
    dictionaries,
    language,
    limit
  );
};

const permissionsInformation = (hit, user) => {
  const { permissions } = hit._source;

  const canWrite = checkWritePermissions(user, permissions);

  return canWrite ? permissions : undefined;
};

const processResponse = async (response, templates, dictionaries, language, filters) => {
  const user = permissionsContext.getUserInContext();

  const v2processors = await v2.createResponseProcessors(response.body.hits.hits, language);

  const rows = response.body.hits.hits.map(hit => {
    const result = hit._source;
    result._explanation = hit._explanation;
    result.snippets = snippetsFromSearchHit(hit);
    result._id = hit._id;
    result.permissions = permissionsInformation(hit, user);
    result.metadata = v2processors.metadata(hit);
    result.obsoleteMetadata = v2processors.obsoleteMetadata(hit);
    return result;
  });
  const sanitizedAggregations = await _sanitizeAggregations(
    response.body.aggregations.all,
    templates,
    dictionaries,
    language
  );

  const aggregationsWithAny = _addAnyAggregation(sanitizedAggregations, filters, response);

  return {
    rows,
    totalRows: response.body.hits.total.value,
    relation: response.body.hits.total.relation,
    aggregations: { all: aggregationsWithAny },
  };
};

const entityHasGeolocation = entity =>
  entity.metadata &&
  !!Object.keys(entity.metadata)
    .filter(field => entity.metadata[field])
    .find(field => {
      if (/_geolocation/.test(field) && entity.metadata[field].length) {
        return true;
      }
      if (Array.isArray(entity.metadata[field])) {
        return !!entity.metadata[field].find(
          f => f.inherit_geolocation && f.inherit_geolocation.length
        );
      }
      return false;
    });

const processGeolocationResults = _results => {
  const results = _results;
  results.rows = results.rows
    .filter(r => r.metadata)
    .map(_row => ({
      ..._row,
      metadata: Object.fromEntries(
        Object.entries(_row.metadata).map(([key, values]) => [
          key,
          values.map(_v => {
            const newValue = { ..._v };
            if (_v.inheritedType === propertyTypes.geolocation) {
              newValue.inherit_geolocation = (_v.inheritedValue || []).filter(iv => iv.value);
            }
            return newValue;
          }),
        ])
      ),
    }))
    .filter(r => r.metadata && Object.keys(r.metadata).length && entityHasGeolocation(r));
  results.totalRows = results.rows.length;
  return results;
};

const _getTextFields = (query, templates) =>
  query.fields ||
  propertiesHelper
    .allUniqueProperties(templates)
    .map(prop =>
      ['text', 'markdown', 'generatedid'].includes(prop.type)
        ? `metadata.${prop.name}.value`
        : `metadata.${prop.name}.label`
    )
    .concat(['title', 'fullText']);

async function searchTypeFromSearchTermValidity(searchTerm) {
  const validationResult = await elastic.indices.validateQuery({
    body: { query: { query_string: { query: searchTerm } } },
  });
  return validationResult.body.valid ? 'query_string' : 'simple_query_string';
}

const buildQuery = async (query, language, user, resources, includeReviewAggregations) => {
  const [templates, dictionaries] = resources;
  const textFieldsToSearch = _getTextFields(query, templates);
  const searchTextType = query.searchTerm
    ? await searchTypeFromSearchTermValidity(query.searchTerm)
    : 'query_string';
  const onlyPublished = query.published || !(query.includeUnpublished || query.unpublished);
  const queryBuilder = documentQueryBuilder()
    .include(query.include)
    .fullTextSearch(query.searchTerm, textFieldsToSearch, 2, searchTextType)
    .filterByTemplate(query.types)
    .filterById(query.ids)
    .language(language)
    .filterByPermissions(onlyPublished);

  if (Number.isInteger(parseInt(query.from, 10))) {
    queryBuilder.from(query.from);
  }

  if (Number.isInteger(parseInt(query.limit, 10))) {
    queryBuilder.limit(query.limit);
  }

  if (query.includeUnpublished && user && !query.unpublished) {
    queryBuilder.includeUnpublished();
  }

  if (query.unpublished && user) {
    queryBuilder.onlyUnpublished();
  }

  const allProps = propertiesHelper.allProperties(templates);
  if (query.sort) {
    const sortingProp = allProps.find(p =>
      [`metadata.${p.name}`, `metadata.${p.name}.inheritedValue`].includes(query.sort)
    );
    const sortByLabel =
      sortingProp &&
      (sortingProp.type === 'select' ||
        (sortingProp.inherit && sortingProp.inherit.type === 'select'));
    queryBuilder.sort(query.sort, query.order, sortByLabel);
  }

  const allTemplates = templates.map(t => t._id);
  const filteringTypes = query.types && query.types.length ? query.types : allTemplates;
  let properties =
    !query.types || !query.types.length
      ? propertiesHelper.defaultFilters(templates)
      : propertiesHelper.comonFilters(templates, filteringTypes);

  if (query.allAggregations) {
    properties = allProps;
  }

  // this is where we decide which aggregations to send to elastic
  const aggregations = await aggregationProperties(properties, allProps);

  const filters = processFilters(query.filters, [...allProps, ...properties], dictionaries);
  // this is where the query filters are built
  queryBuilder.filterMetadata(filters);
  queryBuilder.customFilters(query.customFilters);
  // this is where the query aggregations are built
  queryBuilder.aggregations(aggregations, includeReviewAggregations);

  return queryBuilder;
};

const search = {
  async search(query, language, user) {
    const resources = await Promise.all([templatesModel.get(), dictionariesModel.get()]);
    const [templates, dictionaries] = resources;
    const includeReviewAggregations = query.includeReviewAggregations || false;
    const queryBuilder = await buildQuery(
      query,
      language,
      user,
      resources,
      includeReviewAggregations
    );
    if (query.geolocation) {
      searchGeolocation(queryBuilder, templates);
    }

    if (query.aggregatePermissionsByLevel) {
      queryBuilder.permissionsLevelAgreggations();
    }

    if (query.aggregatePermissionsByUsers) {
      queryBuilder.permissionsUsersAgreggations();
    }

    if (query.aggregateGeneratedToc) {
      queryBuilder.generatedTocAggregations();
    }

    if (query.aggregatePublishingStatus) {
      queryBuilder.publishingStatusAggregations();
    }

    return elastic
      .search({ body: queryBuilder.query() })
      .then(async response => {
        const processed = await processResponse(
          response,
          templates,
          dictionaries,
          language,
          query.filters
        );
        return processed;
      })
      .catch(e => {
        throw createError(e, 400);
      });
  },

  async searchGeolocations(query, language, user) {
    let results = await this.search({ ...query, geolocation: true }, language, user);

    if (results.rows.length) {
      results = processGeolocationResults(results);
    }

    return results;
  },

  async searchSnippets(searchTerm, sharedId, language, user) {
    const templates = await templatesModel.get();

    const searchTextType = searchTerm
      ? await searchTypeFromSearchTermValidity(searchTerm)
      : 'query_string';
    const searchFields = propertiesHelper
      .textFields(templates)
      .map(prop => `metadata.${prop.name}.value`)
      .concat(['title', 'fullText']);
    const query = documentQueryBuilder()
      .fullTextSearch(searchTerm, searchFields, 9999, searchTextType)
      .filterById(sharedId)
      .language(language);

    if (user) {
      query.includeUnpublished();
    }

    const response = await elastic.search({
      body: query.query(),
    });

    if (response.body.hits.hits.length === 0) {
      return {
        count: 0,
        metadata: [],
        fullText: [],
      };
    }
    return snippetsFromSearchHit(response.body.hits.hits[0]);
  },

  async indexEntities(query, select = '', limit = 50, batchCallback = () => {}) {
    return indexEntities({
      query,
      select,
      limit,
      batchCallback,
      searchInstance: this,
    });
  },

  async bulkIndex(docs, action = 'index') {
    return bulkIndex(docs, action);
  },

  bulkDelete(docs) {
    const body = docs.map(doc => ({
      delete: { _id: doc._id },
    }));
    return elastic.bulk({ body });
  },

  delete(entity) {
    const id = entity._id.toString();
    return elastic.delete({ id });
  },

  deleteLanguage(language) {
    const query = { query: { match: { language } } };
    return elastic.deleteByQuery({ body: query });
  },

  appendAutoCompleteFilters(property, searchTerm, aggregation) {
    const aggregationObjectsToUpdate = propertiesHelper.isOrInheritsSelect(property)
      ? [aggregation.aggregations.self, aggregation.aggregations.parent]
      : [aggregation];
    aggregationObjectsToUpdate.forEach(aggregationObject => {
      aggregationObject.aggregations.filtered.filter.bool.filter.push({
        wildcard: {
          [`metadata.${property.name}.label`]: { value: `*${searchTerm.toLowerCase()}*` },
        },
      });
    });
  },

  async autocompleteAggregations(query, language, propertyName, searchTerm, user) {
    const [templates, dictionaries] = await Promise.all([
      templatesModel.get(),
      dictionariesModel.get(),
    ]);

    const queryBuilder = await buildQuery({ ...query, limit: 0 }, language, user, [
      templates,
      dictionaries,
    ]);

    const property = propertiesHelper
      .allUniqueProperties(templates)
      .find(p => p.name === propertyName);

    queryBuilder
      .resetAggregations()
      .aggregations([{ ...property, name: `${propertyName}.value` }], dictionaries);

    const body = queryBuilder.query();

    const aggregation = body.aggregations.all.aggregations[`${propertyName}.value`];

    this.appendAutoCompleteFilters(property, searchTerm, aggregation);

    const response = await elastic.search({
      body,
    });

    const sanitizedAggregations = await _sanitizeAggregations(
      response.body.aggregations.all,
      templates,
      dictionaries,
      language,
      preloadOptionsSearch()
    );

    const options = sanitizedAggregations[propertyName].buckets
      .map(bucket => ({
        label: bucket.label,
        value: bucket.key,
        icon: bucket.icon,
        results: bucket.filtered.doc_count,
      }))
      .filter(o => o.results);

    const filteredOptions = filterOptions(searchTerm, options);

    return {
      options: filteredOptions.slice(0, preloadOptionsLimit()),
      count: filteredOptions.length,
    };
  },

  async autocomplete(searchTerm, language, templates = []) {
    const queryBuilder = documentQueryBuilder()
      .include(['title', 'template', 'sharedId', 'icon'])
      .language(language)
      .limit(preloadOptionsSearch())
      .filterByPermissions()
      .includeUnpublished();

    if (templates.length) {
      queryBuilder.filterByTemplate(templates);
    }

    const body = queryBuilder.query();

    body.query.bool.must = [
      {
        multi_match: {
          query: searchTerm,
          type: 'bool_prefix',
          fields: ['title.sayt', 'title.sayt._2gram', 'title.sayt._3gram', 'title.sayt_ngram'],
        },
      },
    ];

    delete body.aggregations;

    const response = await elastic.search({ body });

    const options = response.body.hits.hits.slice(0, preloadOptionsLimit()).map(hit => ({
      value: hit._source.sharedId,
      label: hit._source.title,
      template: hit._source.template,
      icon: hit._source.icon,
    }));

    return { count: response.body.hits.hits.length, options };
  },

  async updateTemplatesMapping() {
    const templates = await templatesModel.get();
    return updateMapping(templates);
  },

  async countPerTemplate(language) {
    const queryBuilder = documentQueryBuilder().language(language).includeUnpublished().limit(0);

    return (
      await elastic.search({ body: queryBuilder.query() })
    ).body.aggregations.all._types.buckets.reduce((map, bucket) => {
      // eslint-disable-next-line no-param-reassign
      map[bucket.key] = bucket.filtered.doc_count;
      return map;
    }, {});
  },
};

export { search };