app/models/cache-model.js

/**
 * @module cache-model
 */

const transformer = require('enketo-transformer');
const { promisify } = require('util');
const { cacheClient } = require('../lib/db');
const utils = require('../lib/utils');

const prefix = 'ca:';
const expiry = 30 * 24 * 60 * 60;
const debug = require('debug')('enketo:cache-model');

const clientGet = promisify(cacheClient.get).bind(cacheClient);
const clientSet = promisify(cacheClient.set).bind(cacheClient);
const expire = promisify(cacheClient.expire).bind(cacheClient);

/**
 * Gets an item from the cache.
 *
 * @static
 * @name get
 * @function
 * @param {module:survey-model~SurveyObject} survey - survey object
 * @return {Promise<Error|null|module:survey-model~SurveyObject>} Promise that resolves with cached {@link module:survey-model~SurveyObject|SurveyObject} or `null`
 */
function getSurvey(survey) {
    return new Promise((resolve, reject) => {
        if (!survey || !survey.openRosaServer || !survey.openRosaId) {
            const error = new Error(
                'Bad Request. Survey information to perform cache lookup is not complete.'
            );
            error.status = 400;
            reject(error);
        } else {
            const key = _getKey(survey);

            cacheClient.hgetall(key, (error, cacheObj) => {
                if (error) {
                    reject(error);
                } else if (!cacheObj) {
                    resolve(null);
                } else {
                    // form is 'actively used' so we're resetting the cache expiry
                    debug('cache is up to date and used, resetting expiry');
                    cacheClient.expire(key, expiry);
                    survey.form = cacheObj.form;
                    survey.model = cacheObj.model;
                    survey.formHash = cacheObj.formHash;
                    survey.xslHash = cacheObj.xslHash;
                    survey.languageMap = JSON.parse(
                        cacheObj.languageMap || '{}'
                    );
                    resolve(survey);
                }
            });
        }
    });
}

/**
 * Gets the hashes of an item from the cache.
 *
 * @static
 * @name getHashes
 * @function
 * @param {module:survey-model~SurveyObject} survey - survey object
 * @return {Promise<Error|module:survey-model~SurveyObject>} Promise that resolves with {@link module:survey-model~SurveyObject|SurveyObject} (updated with hash array if such exist)
 */
function getSurveyHashes(survey) {
    return new Promise((resolve, reject) => {
        if (!survey || !survey.openRosaServer || !survey.openRosaId) {
            const error = new Error(
                'Bad Request. Survey information to perform cache lookup is not complete.'
            );
            error.status = 400;
            reject(error);
        } else {
            const key = _getKey(survey);

            cacheClient.hmget(
                key,
                ['formHash', 'xslHash'],
                (error, hashArr) => {
                    if (error) {
                        reject(error);
                    } else if (!hashArr || !hashArr[0] || !hashArr[1]) {
                        resolve(survey);
                    } else {
                        survey.formHash = hashArr[0];
                        survey.xslHash = hashArr[1];
                        resolve(survey);
                    }
                }
            );
        }
    });
}

/**
 * Checks if cache is present and up to date
 *
 * @static
 * @name check
 * @function
 * @param {module:survey-model~SurveyObject} survey - survey object
 * @return {Promise<Error|null|boolean>} a Promise that resolves with a boolean
 */
function isCacheUpToDate(survey) {
    return new Promise((resolve, reject) => {
        if (
            !survey ||
            !survey.openRosaServer ||
            !survey.openRosaId ||
            !survey.info.hash
        ) {
            const error = new Error(
                'Bad Request. Survey information to perform cache check is not complete.'
            );
            error.status = 400;
            reject(error);
        } else {
            // clean up the survey object to make sure no artefacts of cached survey are present
            survey = {
                openRosaServer: survey.openRosaServer,
                openRosaId: survey.openRosaId,
                info: {
                    hash: survey.info.hash,
                },
                manifest: survey.manifest,
            };

            const key = _getKey(survey);

            cacheClient.hgetall(key, (error, cacheObj) => {
                if (error) {
                    reject(error);
                } else if (!cacheObj) {
                    debug('cache is missing');
                    resolve(null);
                } else {
                    // Adding the hashes to the referenced survey object can be efficient, since this object
                    // is passed around. The hashes may therefore already have been calculated
                    // when setting the cache later on.
                    // Note that this server cache only cares about media URLs, not media content.
                    // This allows the same cache to be used for a form for the OpenRosa server serves different media content,
                    // e.g. based on the user credentials.
                    _addHashes(survey);
                    if (
                        cacheObj.formHash !== survey.formHash ||
                        cacheObj.xslHash !== survey.xslHash ||
                        cacheObj.mediaUrlHash
                    ) {
                        debug('cache is obsolete');
                        resolve(false);
                    } else {
                        debug('cache is up to date');
                        resolve(true);
                    }
                }
            });
        }
    });
}

/**
 * Adds an item to the cache
 *
 * @static
 *
 * @name set
 * @function
 * @param {module:survey-model~SurveyObject} survey - survey object
 * @return {Promise<Error|module:survey-model~SurveyObject>} a Promise that resolves with the survey object
 */
function setSurvey(survey) {
    return new Promise((resolve, reject) => {
        if (
            !survey ||
            !survey.openRosaServer ||
            !survey.openRosaId ||
            !survey.info.hash ||
            !survey.form ||
            !survey.model
        ) {
            const error = new Error(
                'Bad Request. Survey information to cache is not complete.'
            );
            error.status = 400;
            reject(error);
        } else {
            _addHashes(survey);
            const obj = {
                formHash: survey.formHash,
                xslHash: survey.xslHash,
                form: survey.form,
                model: survey.model,
                // The mediaUrlHash property is an artefact and no longer used.
                // When hmset updates the database it would keep it in place, so we explicitly set it to empty.s
                mediaUrlHash: '',
                languageMap: JSON.stringify(survey.languageMap || {}),
            };

            const key = _getKey(survey);

            cacheClient.hmset(key, obj, (error) => {
                if (error) {
                    reject(error);
                } else {
                    debug('cache has been updated');
                    // expire in 30 days
                    cacheClient.expire(key, expiry);
                    resolve(survey);
                }
            });
        }
    });
}

/**
 * Flushes the cache of a single survey
 *
 * @static
 * @name flush
 * @function
 * @param {module:survey-model~SurveyObject} survey - survey object
 * @return {Promise<Error|module:survey-model~SurveyObject>} Flushed {@link module:survey-model~SurveyObject|SurveyObject}
 */
function flushSurvey(survey) {
    return new Promise((resolve, reject) => {
        if (!survey || !survey.openRosaServer || !survey.openRosaId) {
            const error = new Error(
                'Bad Request. Survey information to cache is not complete.'
            );
            error.status = 400;
            reject(error);
        } else {
            const key = _getKey(survey);

            cacheClient.hgetall(key, (error, cacheObj) => {
                if (error) {
                    reject(error);
                } else if (!cacheObj) {
                    error = new Error('Survey cache not found.');
                    error.status = 404;
                    reject(error);
                } else {
                    cacheClient.del(key, (error) => {
                        if (error) {
                            reject(error);
                        } else {
                            delete survey.form;
                            delete survey.model;
                            delete survey.formHash;
                            delete survey.xslHash;
                            delete survey.mediaHash;
                            delete survey.mediaUrlHash;
                            delete survey.languageMap;
                            resolve(survey);
                        }
                    });
                }
            });
        }
    });
}

/**
 * Completely empties the cache
 *
 * @static
 * @return {Promise<Error|boolean>} Promise that resolves `true` after all cache is flushed
 */
function flushAll() {
    return new Promise((resolve, reject) => {
        // TODO: "Don't use KEYS in your regular application code"
        // (https://redis.io/commands/keys)
        cacheClient.keys(`${prefix}*`, (error, keys) => {
            if (error) {
                reject(error);
            }
            keys.forEach((key) => {
                cacheClient.del(key, (error) => {
                    if (error) {
                        console.error(error);
                    }
                });
            });
            // TODO: use Promise.All to resolve when all deletes have completed.
            resolve(true);
        });
    });
}

/**
 * Gets the key used for the cache item
 *
 * @param {module:survey-model~SurveyObject} survey - survey object
 * @return {string|null} openRosaKey or `null`
 *
 */
function _getKey(survey) {
    const openRosaKey = utils.getOpenRosaKey(survey, prefix);

    return openRosaKey || null;
}

/**
 * Adds the 3 relevant hashes to the survey object if they haven't been added already.
 *
 * @param {module:survey-model~SurveyObject} survey - survey object
 *
 */
function _addHashes(survey) {
    survey.formHash = survey.formHash || survey.info.hash;
    survey.xslHash = survey.xslHash || transformer.version;
}

/**
 * @param {string} mediaURL
 * @param {string} hostURL
 */
const cacheManifestItem = async (mediaURL, hostURL) => {
    const key = `${prefix}${mediaURL}`;

    await clientSet(key, hostURL);
    await expire('GT', 10);

    return [key, hostURL];
};

const getManifestItem = (mediaURL) => {
    const key = `${prefix}${mediaURL}`;

    return clientGet(key);
};

module.exports = {
    get: getSurvey,
    getHashes: getSurveyHashes,
    set: setSurvey,
    check: isCacheUpToDate,
    flush: flushSurvey,
    flushAll,
    cacheManifestItem,
    getManifestItem,
};