Source: routes/v1/add-edition.js

/** @module routes/v1 */

// Register router

const { authorize } = require('../../utils.js');
const fs = require('fs');

const express = require('express');
const router = express.Router();

const BookController = require('../../controllers/BookController.js');
const EditionController = require('../../controllers/EditionController.js');
const SpineImageController = require('../../controllers/SpineImageController.js');
const AuthorController = require('../../controllers/AuthorController.js');
const TokenController = require('../../controllers/TokenController.js');

const { ISBNFactory, ISBN } = require('../../classes/books/ISBN.js');
const { Edition, EditionFactory } = require('../../classes/books/Edition.js');
const { SpineImageFactory } = require('../../classes/books/SpineImage.js');
const { BookFactory } = require('../../classes/books/Book.js');
const { AuthorFactory } = require('../../classes/books/Author.js');
const { TokenFactory } = require('../../classes/users/Token.js');

/** Route for adding a new edition */
router.post('/contribute/edition/add', async (req, res) => {
    if (!await authorize(['contribute.edition'], req, res)) {
        return;
    }

    let isbnString = req.headers.isbn;

    if (!isbnString) {
        res.status(400).send({ message: 'Missing ISBN' });
        return;
    }

    const isbn = await new ISBNFactory().setISBN(isbnString).create();

    if (!isbn) {
        res.status(400).send({ message: 'Invalid ISBN' });
        return;
    }

    if (!req.busboy) {
        res.status(400).send({ message: 'Missing spine image' });
        return;
    }

    const spineImageFilepath = generateSpineImagePath();

    let fstream;
    let streamClosed = false;
    req.pipe(req.busboy);
    req.busboy.on('file', (fieldname, file, filename) => {
        fstream = fs.createWriteStream(spineImageFilepath);
        file.pipe(fstream);
        file.on('close', () => {
            streamClosed = true;
        });
    });

    const user = await (await new TokenFactory().load(await new TokenController().byTokenString(req.headers['authorization'])).create()).getUser();

    const editionDbRecord = await new EditionController().byISBN(isbn);
    let edition;
    if (editionDbRecord) {
        edition = await new EditionFactory().load(editionDbRecord).create();
    } else {
        edition = await addEdition(isbn);
        await user.addContribution("ADD_EDITION_AUTOMATIC", edition.id);
    }

    const spineImageFilename = spineImageFilepath.split('/').pop();
    const newSpineImage = await new SpineImageFactory().load(await new SpineImageController().insert(edition.id, spineImageFilename)).create();

    if (fstream) {
        while (!streamClosed) {
            await new Promise(resolve => setTimeout(resolve, 100));
        }

        await user.addContribution("ADD_SPINE_IMAGE", newSpineImage.id);
        await user.changeGoalProgressByTrackName('Contributor', 'spine images added', 1);
        await user.changeGoalProgressByTrackName('Contributor', 'points earned', (await user.getContributionType('ADD_SPINE_IMAGE')).point_value);

        res.status(200).send({ message: 'Added edition successfully' });
    } else {
        res.status(500).send({ message: 'Failed to save spine image' });
    }
});

module.exports = router;

// Functions

/**
 * Adds an edition to the database using OpenLibrary API if it doesn't exist in our database
 * 
 * @param {ISBN} isbn
 * @returns {Promise<Edition>} edition
 */
async function addEdition(isbn) {
    const bookInfo = await getOLBookInfo(isbn);
    if (!bookInfo) {
        return;
    }

    const title = bookInfo.title;
    const subtitle = bookInfo.subtitle;
    const publishDate = parseDate(bookInfo.publish_date);

    const bookController = new BookController();

    const existingBookDbRecord = await bookController.byTitle(title);
    let existingBook;
    if (!existingBookDbRecord) {
        existingBook = await new BookFactory().load(await bookController.insert(title, subtitle, publishDate)).create();
    } else {
        existingBook = await new BookFactory().load(existingBookDbRecord).create();
    }

    const authors = [];
    if (bookInfo.authors) {
        for (const authorKeyObj of bookInfo.authors) {
            const authorInfo = await getOLAuthorInfo(authorKeyObj.key.replace('/authors/', ''));
            authors.push({
                name: authorInfo.name,
                personal_name: authorInfo.personal_name,
            });
        }

        const authorController = new AuthorController();

        for (const author of authors) {
            const existingAuthorDbRecord = await authorController.byName(author.name);
            let existingAuthor;
            if (!existingAuthorDbRecord) {
                existingAuthor = await new AuthorFactory().load(await authorController.insert(author.name, author.personal_name)).create();
            } else {
                existingAuthor = await new AuthorFactory().load(existingAuthorDbRecord).create();
            }

            await existingAuthor.linkToBook(existingBook);
        }
    }

    return await new EditionFactory().load(await new EditionController().insert(existingBook.id, isbn.getISBN10(), isbn.getISBN(), bookInfo.key)).create();
}


/**
 * Get book info from OpenLibrary API using ISBN
 * 
 * @param {ISBN} isbn
 * @returns {Promise<object>}
 */
async function getOLBookInfo(isbn) {
    return (await fetch(`https://openlibrary.org/isbn/${isbn.getISBN()}.json`, { redirect: 'follow' })).json();
}

/**
 * Get author info from OpenLibrary API using author key
 * 
 * @param {string} authorKey
 * @returns {Promise<object>}
 */
async function getOLAuthorInfo(authorKey) {
    return (await fetch(`https://openlibrary.org/authors/${authorKey}.json`, { redirect: 'follow' })).json();
}

/**
 * Generates a new unique spine image path using the current date and a random number
 * 
 * @returns {string} path
 */
function generateSpineImagePath() {
    const date = new Date();
    const random = Math.floor(Math.random() * 1000);
    return process.env.STORAGE_PATH + '/spine-images/' + date.getTime() + '-' + random + '.jpg';
}

/**
 * Parses a date string into a date object
 * 
 * @param {any} date_str
 * @returns {date} parsedDate
 */
function parseDate(date_str) {
    if (!date_str) {
        return null;
    }

    const date = new Date(date_str);
    if (isNaN(date.getTime())) {
        return null;
    }

    return date;
}