Source: course/index.js

/**
 * @module course
 */
const courseRepo = require("../../repositories/course");
const coursePhaseRepo = require("../../repositories/course/phase");
const log = require("../../util/log");
const filtering = require("../filtering");
const cache = require("../cache");
const _ = require('lodash');

/**
 * Fetch similar course records by provided course and course-phase
 * records. If the provided course has multiple phases, one is picked
 * to avoid duplicate results.
 * Returns an array of course records with a default maximum size of 5.
 * @param {Object} course
 * @param {Number} [maximum=5] default is 5
 * @return {Array} similar course records
 */
const fetchSimilarCourseRecords = async (course, maximum) => {
    let findRecords = [];
    if (cache.has("courses")) {
        const cachedVal = cache.get("courses");
        log.info("Course %s: Fetching similar courses from CACHE", course.id);
        const matching = [];
        cachedVal.forEach(e => {
            if (
                (e.capabilityId === course.capabilityId) &&
                (e.categoryId === course.categoryId) &&
                (e.competencyId === course.competencyId) &&
                (e.phases[0] === course.phases[0])
            ) {
                matching.push(e);
            }
        });
        findRecords = matching;
    } else {
        log.info("Course %s: Fetching similar courses from DB", course.id);
        const findWithPhaseResult = await courseRepo.findByFilters({
            capabilityId: course.capabilityId,
            categoryId: course.categoryId,
            competencyId: course.competencyId,
            // if a course has multiple phases, one of them is picked for search
            phaseId: course.phases[0],
        });
        findRecords = findWithPhaseResult.rows;
    }

    // TODO: fetch more

    const filteredRecords = findRecords.filter(e => e.id !== course.id);

    log.info("Course %s: Found %s similar course(s), returning %s",
        course.id, filteredRecords.length,
        ((filteredRecords.length > 5) ? (maximum ? maximum : 5) : filteredRecords.length));

    // return maximum 5 by default
    return filteredRecords.slice(0, (maximum ? maximum : 5));
};

/**
 * Fetches all info related to course and resolves
 * all identifiers to values in the database.
 * Returns resolved course object.
 * @param {Number} courseId
 * @return {Object} course
 */
const fetchAndResolveCourse = async (courseId) => {
    log.info("Course %s: Fetching all info", courseId);
    if (cache.has(`course-${courseId}`)) {
        log.info("Course %s: Fetched all info from CACHE", courseId);
        return cache.get(`course-${courseId}`);
    } else {
        throw new Error(`No course found by id '${courseId}'`);
    }
};

/**
 * Fetch courses based on input filters.
 * Fetches from cache if possible
 * @param {Object} filters
 * @return {Array} matching course objects
 */
const fetchByFilters = async (filters) => {
    if (cache.has("courses")) {
        const cachedVal = cache.get("courses");
        const matching = [];
        const safeKey = _.escapeRegExp(filters.keyword);
        const regex = RegExp(safeKey ? safeKey : '', 'i');
        cachedVal.forEach(e => {
            if (
                (
                    !filters.capability ||
                    parseInt(filters.capability, 10) === -1 ||
                    e.capabilityId === parseInt(filters.capability, 10)
                ) &&
                (
                    !filters.category ||
                    parseInt(filters.category, 10) === -1 ||
                    e.categoryId === parseInt(filters.category, 10)
                ) &&
                (
                    !filters.competency ||
                    parseInt(filters.competency, 10) === -1 ||
                    e.competencyId === parseInt(filters.competency, 10)
                ) &&
                (
                    !filters.phase ||
                    parseInt(filters.phase, 10) === -1 ||
                    e.phases.includes(parseInt(filters.phase, 10))
                ) &&
                (
                    regex.test(e.title)
                )
            ) {
                matching.push(e);
            }
        });
        log.info("Fetched %s courses with %s filters from CACHE", matching.length, JSON.stringify(filters));
        return matching;
    } else {
        const findResult = await courseRepo.findByFiltersAndKeywordJoint({
            filters: {
                capabilityId: filters.capability ? parseInt(filters.capability, 10) : -1,
                categoryId: filters.category ? parseInt(filters.category, 10) : -1,
                competencyId: filters.competency ? parseInt(filters.competency, 10) : -1,
                phaseId: filters.phase ? parseInt(filters.phase, 10) : -1,
            },
            keyword: filters.keyword ? filters.keyword : '',
        });
        log.info("Fetched %s courses with %s filters from DB", findResult.rows.length, JSON.stringify(filters));
        return findResult.rows;
    }
};

/**
 * Fetch call courses.
 * Fetches from cache if possible, otherwise
 * fetches from database and caches values
 * for future use.
 */
const fetchAll = async () => {
    if (cache.has("courses")) {
        return cache.get("courses");
    } else {
        const courses = await fetchByFilters({});
        cache.set("courses", courses);
        courses.forEach(e => {
            cache.set(`course-${e.id}`, e);
        });
        return (courses);
    }
};

/**
 * Fetch all courses that have unique titles.
 * Fetches all courses then filters and sorts
 * by the titles.
 * @return {Array} courses
 */
const fetchAllWithUniqueTitles = async () => {
    const allCourses = await fetchAll();
    return filtering.filterAndSortByTitle(allCourses);
};

/**
 * Add a new course: insert new course object to the database,
 * and immediately attempt to update the cache with the new object.
 * The course object is only cached if it can successfully be updated
 * from the LinkedIn Learning API.
 * @param {Object} course
 */
const addNewCourse = async (course) => {
    const insertionResult = await courseRepo.insert({
        title: course.title,
        hyperlink: course.hyperlink,
        capabilityId: parseInt(course.capability, 10),
        categoryId: parseInt(course.category, 10),
        competencyId: parseInt(course.competency, 10),
        urn: course.urn,
    });
    const courseId = insertionResult.rows[0].id;
    if (Array.isArray(course.phases)) {
        for await (const phase of course.phases) {
            await coursePhaseRepo.insert({
                courseId,
                phaseId: parseInt(phase, 10),
            });
        }
    } else {
        await coursePhaseRepo.insert({
            courseId,
            phaseId: parseInt(course.phases, 10),
        });
    }
    log.info("Course %d: Successfully inserted new record to database", courseId);
    log.info("Course %d: Caching new course...", courseId);
    const findResult = await courseRepo.findByIdWithFullInfo(courseId);
    const temp = findResult.rows[0];
    cache.set(`course-${courseId}`, temp);
    await cache.updateFromAPI(`course-${courseId}`);
    log.info("Course %d: Cache updated with new course.", courseId);
    return courseId;
};

/**
 * Update a course: update a stored course object in the database,
 * and immediately attempt to update the cache with the object.
 * The course object is only cached if it can successfully be updated
 * from the LinkedIn Learning API.
 * @param {Object} course
 */
const updateCourse = async (course) => {
    const courseId = parseInt(course.id, 10);
    await courseRepo.update({
        title: course.title,
        hyperlink: course.hyperlink,
        capabilityId: parseInt(course.capability, 10),
        categoryId: parseInt(course.category, 10),
        competencyId: parseInt(course.competency, 10),
        urn: course.urn,
        id: courseId,
    });
    await coursePhaseRepo.removeByCourseId(courseId);
    if (Array.isArray(course.phases)) {
        for await (const phase of course.phases) {
            await coursePhaseRepo.insert({
                courseId,
                phaseId: parseInt(phase, 10),
            });
        }
    } else {
        await coursePhaseRepo.insert({
            courseId,
            phaseId: parseInt(course.phases, 10),
        });
    }
    log.info("Course %d: Successfully updated record in database", courseId);
    log.info("Course %d: Caching updated course...", courseId);
    const findResult = await courseRepo.findByIdWithFullInfo(courseId);
    const temp = findResult.rows[0];
    const coursesArray = cache.get("courses");
    const index = coursesArray.findIndex((e) => {
        return e.id === courseId;
    });
    coursesArray[index] = temp;
    cache.set(`course-${courseId}`, temp);
    cache.set("courses", coursesArray);
    log.info("Course %d: Cache updated with updated course.", courseId);
};

/**
 * Delete a course from the database and efficiently remove it
 * from the cache so full flushing of cache can be avoided.
 * @param {Number} id
 */
const deleteCourse = async (id) => {
    log.info("Course %d: Deleting course with all details...", id);
    await courseRepo.removeById(id);
    await coursePhaseRepo.removeByCourseId(id);
    log.info("Course %d: Deleting course from cache...", id);
    cache.del(`course-${id}`);
    const coursesArray = cache.get("courses");
    const index = coursesArray.findIndex((e) => {
        return e.id === parseInt(id, 10);
    });
    coursesArray.splice(index, 1);
    cache.set("courses", coursesArray);
    log.info("Course %d: Deleted course from cache...", id);
    log.info("Course %d: Deleted course with all details...", id);
};

module.exports = {
    fetchSimilarCourseRecords,
    fetchAndResolveCourse,
    fetchByFilters,
    fetchAll,
    fetchAllWithUniqueTitles,
    addNewCourse,
    updateCourse,
    deleteCourse,
};