diff --git a/server/Database.js b/server/Database.js index afb09dae..070c89d7 100644 --- a/server/Database.js +++ b/server/Database.js @@ -406,11 +406,6 @@ class Database { return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook))) } - createBulkCollectionBooks(collectionBooks) { - if (!this.sequelize) return false - return this.models.collectionBook.bulkCreate(collectionBooks) - } - createPlaylistMediaItem(playlistMediaItem) { if (!this.sequelize) return false return this.models.playlistMediaItem.create(playlistMediaItem) diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js index 3e35c08b..23f8796f 100644 --- a/server/controllers/CollectionController.js +++ b/server/controllers/CollectionController.js @@ -5,13 +5,17 @@ const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') const RssFeedManager = require('../managers/RssFeedManager') -const Collection = require('../objects/Collection') /** * @typedef RequestUserObject * @property {import('../models/User')} user * * @typedef {Request & RequestUserObject} RequestWithUser + * + * @typedef RequestEntityObject + * @property {import('../models/Collection')} collection + * + * @typedef {RequestWithUser & RequestEntityObject} CollectionControllerRequest */ class CollectionController { @@ -25,36 +29,68 @@ class CollectionController { * @param {Response} res */ async create(req, res) { - const newCollection = new Collection() - req.body.userId = req.user.id - if (!newCollection.setData(req.body)) { + const reqBody = req.body || {} + + // Validation + if (!reqBody.name || !reqBody.libraryId) { return res.status(400).send('Invalid collection data') } + const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string') + if (!libraryItemIds.length) { + return res.status(400).send('Invalid collection data. No books') + } - // Create collection record - await Database.collectionModel.createFromOld(newCollection) - - // Get library items in collection - const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection) - - // Create collectionBook records - let order = 1 - const collectionBooksToAdd = [] - for (const libraryItemId of newCollection.books) { - const libraryItem = libraryItemsInCollection.find((li) => li.id === libraryItemId) - if (libraryItem) { - collectionBooksToAdd.push({ - collectionId: newCollection.id, - bookId: libraryItem.media.id, - order: order++ - }) + // Load library items + const libraryItems = await Database.libraryItemModel.findAll({ + attributes: ['id', 'mediaId', 'mediaType', 'libraryId'], + where: { + id: libraryItemIds, + libraryId: reqBody.libraryId, + mediaType: 'book' } - } - if (collectionBooksToAdd.length) { - await Database.createBulkCollectionBooks(collectionBooksToAdd) + }) + if (libraryItems.length !== libraryItemIds.length) { + return res.status(400).send('Invalid collection data. Invalid books') } - const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection) + /** @type {import('../models/Collection')} */ + let newCollection = null + + const transaction = await Database.sequelize.transaction() + try { + // Create collection + newCollection = await Database.collectionModel.create( + { + libraryId: reqBody.libraryId, + name: reqBody.name, + description: reqBody.description || null + }, + { transaction } + ) + + // Create collectionBooks + const collectionBookPayloads = libraryItemIds.map((llid, index) => { + const libraryItem = libraryItems.find((li) => li.id === llid) + return { + collectionId: newCollection.id, + bookId: libraryItem.mediaId, + order: index + 1 + } + }) + await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction }) + + await transaction.commit() + } catch (error) { + await transaction.rollback() + Logger.error('[CollectionController] create:', error) + return res.status(500).send('Failed to create collection') + } + + // Load books expanded + newCollection.books = await newCollection.getBooksExpandedWithLibraryItem() + + // Note: The old collection model stores expanded libraryItems in the books property + const jsonExpanded = newCollection.toOldJSONExpanded() SocketAuthority.emitter('collection_added', jsonExpanded) res.json(jsonExpanded) } @@ -75,7 +111,7 @@ class CollectionController { /** * GET: /api/collections/:id * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async findOne(req, res) { @@ -94,7 +130,7 @@ class CollectionController { * PATCH: /api/collections/:id * Update collection * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async update(req, res) { @@ -158,7 +194,7 @@ class CollectionController { * * @this {import('../routers/ApiRouter')} * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async delete(req, res) { @@ -178,7 +214,7 @@ class CollectionController { * Add a single book to a collection * Req.body { id: } * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async addBook(req, res) { @@ -212,7 +248,7 @@ class CollectionController { * Remove a single book from a collection. Re-order books * TODO: bookId is actually libraryItemId. Clients need updating to use bookId * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async removeBook(req, res) { @@ -257,29 +293,31 @@ class CollectionController { * Add multiple books to collection * Req.body { books: } * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async addBatch(req, res) { // filter out invalid libraryItemIds const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string') if (!bookIdsToAdd.length) { - return res.status(500).send('Invalid request body') + return res.status(400).send('Invalid request body') } // Get library items associated with ids const libraryItems = await Database.libraryItemModel.findAll({ + attributes: ['id', 'mediaId', 'mediaType', 'libraryId'], where: { - id: { - [Sequelize.Op.in]: bookIdsToAdd - } - }, - include: { - model: Database.bookModel + id: bookIdsToAdd, + libraryId: req.collection.libraryId, + mediaType: 'book' } }) + if (!libraryItems.length) { + return res.status(400).send('Invalid request body. No valid books') + } // Get collection books already in collection + /** @type {import('../models/CollectionBook')[]} */ const collectionBooks = await req.collection.getCollectionBooks() let order = collectionBooks.length + 1 @@ -288,10 +326,10 @@ class CollectionController { // Check and set new collection books to add for (const libraryItem of libraryItems) { - if (!collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) { + if (!collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) { collectionBooksToAdd.push({ collectionId: req.collection.id, - bookId: libraryItem.media.id, + bookId: libraryItem.mediaId, order: order++ }) hasUpdated = true @@ -302,7 +340,8 @@ class CollectionController { let jsonExpanded = null if (hasUpdated) { - await Database.createBulkCollectionBooks(collectionBooksToAdd) + await Database.collectionBookModel.bulkCreate(collectionBooksToAdd) + jsonExpanded = await req.collection.getOldJsonExpanded() SocketAuthority.emitter('collection_updated', jsonExpanded) } else { @@ -316,7 +355,7 @@ class CollectionController { * Remove multiple books from collection * Req.body { books: } * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async removeBatch(req, res) { @@ -329,9 +368,7 @@ class CollectionController { // Get library items associated with ids const libraryItems = await Database.libraryItemModel.findAll({ where: { - id: { - [Sequelize.Op.in]: bookIdsToRemove - } + id: bookIdsToRemove }, include: { model: Database.bookModel @@ -339,6 +376,7 @@ class CollectionController { }) // Get collection books already in collection + /** @type {import('../models/CollectionBook')[]} */ const collectionBooks = await req.collection.getCollectionBooks({ order: [['order', 'ASC']] }) diff --git a/server/models/Collection.js b/server/models/Collection.js index e01ad90a..c8f62e69 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -1,7 +1,5 @@ const { DataTypes, Model, Sequelize } = require('sequelize') -const oldCollection = require('../objects/Collection') - class Collection extends Model { constructor(values, options) { super(values, options) @@ -26,12 +24,12 @@ class Collection extends Model { } /** - * Get all old collections toJSONExpanded, items filtered for user permissions + * Get all toOldJSONExpanded, items filtered for user permissions * * @param {import('./User')} user * @param {string} [libraryId] * @param {string[]} [include] - * @returns {Promise} oldCollection.toJSONExpanded + * @async */ static async getOldCollectionsJsonExpanded(user, libraryId, include) { let collectionWhere = null @@ -79,8 +77,6 @@ class Collection extends Model { // TODO: Handle user permission restrictions on initial query return collections .map((c) => { - const oldCollection = this.getOldCollection(c) - // Filter books using user permissions const books = c.books?.filter((b) => { @@ -95,20 +91,14 @@ class Collection extends Model { return true }) || [] - // Map to library items - const libraryItems = books.map((b) => { - const libraryItem = b.libraryItem - delete b.libraryItem - libraryItem.media = b - return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) - }) - // Users with restricted permissions will not see this collection - if (!books.length && oldCollection.books.length) { + if (!books.length && c.books.length) { return null } - const collectionExpanded = oldCollection.toJSONExpanded(libraryItems) + this.books = books + + const collectionExpanded = c.toOldJSONExpanded() // Map feed if found if (c.feeds?.length) { @@ -153,69 +143,6 @@ class Collection extends Model { }) } - /** - * Get old collection from Collection - * @param {Collection} collectionExpanded - * @returns {oldCollection} - */ - static getOldCollection(collectionExpanded) { - const libraryItemIds = collectionExpanded.books?.map((b) => b.libraryItem?.id || null).filter((lid) => lid) || [] - return new oldCollection({ - id: collectionExpanded.id, - libraryId: collectionExpanded.libraryId, - name: collectionExpanded.name, - description: collectionExpanded.description, - books: libraryItemIds, - lastUpdate: collectionExpanded.updatedAt.valueOf(), - createdAt: collectionExpanded.createdAt.valueOf() - }) - } - - /** - * - * @param {oldCollection} oldCollection - * @returns {Promise} - */ - static createFromOld(oldCollection) { - const collection = this.getFromOld(oldCollection) - return this.create(collection) - } - - static getFromOld(oldCollection) { - return { - id: oldCollection.id, - name: oldCollection.name, - description: oldCollection.description, - libraryId: oldCollection.libraryId - } - } - - static removeById(collectionId) { - return this.destroy({ - where: { - id: collectionId - } - }) - } - - /** - * Get old collection by id - * @param {string} collectionId - * @returns {Promise} returns null if not found - */ - static async getOldById(collectionId) { - if (!collectionId) return null - const collection = await this.findByPk(collectionId, { - include: { - model: this.sequelize.models.book, - include: this.sequelize.models.libraryItem - }, - order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']] - }) - if (!collection) return null - return this.getOldCollection(collection) - } - /** * Remove all collections belonging to library * @param {string} libraryId @@ -286,64 +213,37 @@ class Collection extends Model { } /** - * Get old collection toJSONExpanded, items filtered for user permissions + * Get toOldJSONExpanded, items filtered for user permissions * * @param {import('./User')|null} user * @param {string[]} [include] - * @returns {Promise} oldCollection.toJSONExpanded + * @async */ async getOldJsonExpanded(user, include) { - this.books = - (await this.getBooks({ - include: [ - { - model: this.sequelize.models.libraryItem - }, - { - model: this.sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['sequence'] - } - } - ], - order: [Sequelize.literal('`collectionBook.order` ASC')] - })) || [] + this.books = await this.getBooksExpandedWithLibraryItem() // Filter books using user permissions // TODO: Handle user permission restrictions on initial query - const books = - this.books?.filter((b) => { - if (user) { - if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { - return false - } - if (b.explicit === true && !user.canAccessExplicitContent) { - return false - } + if (user) { + const books = this.books.filter((b) => { + if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { + return false + } + if (b.explicit === true && !user.canAccessExplicitContent) { + return false } return true - }) || [] + }) - // Map to library items - const libraryItems = books.map((b) => { - const libraryItem = b.libraryItem - delete b.libraryItem - libraryItem.media = b - return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) - }) + // Users with restricted permissions will not see this collection + if (!books.length && this.books.length) { + return null + } - // Users with restricted permissions will not see this collection - if (!books.length && this.books.length) { - return null + this.books = books } - const collectionExpanded = this.toOldJSONExpanded(libraryItems) + const collectionExpanded = this.toOldJSONExpanded() if (include?.includes('rssfeed')) { const feeds = await this.getFeeds() @@ -357,10 +257,10 @@ class Collection extends Model { /** * - * @param {string[]} libraryItemIds + * @param {string[]} [libraryItemIds=[]] * @returns */ - toOldJSON(libraryItemIds) { + toOldJSON(libraryItemIds = []) { return { id: this.id, libraryId: this.libraryId, @@ -372,19 +272,19 @@ class Collection extends Model { } } - /** - * - * @param {import('../objects/LibraryItem')} oldLibraryItems - * @returns - */ - toOldJSONExpanded(oldLibraryItems) { - const json = this.toOldJSON(oldLibraryItems.map((li) => li.id)) - json.books = json.books - .map((libraryItemId) => { - const book = oldLibraryItems.find((li) => li.id === libraryItemId) - return book ? book.toJSONExpanded() : null - }) - .filter((b) => !!b) + toOldJSONExpanded() { + if (!this.books) { + throw new Error('Books are required to expand Collection') + } + + const json = this.toOldJSON() + json.books = this.books.map((book) => { + const libraryItem = book.libraryItem + delete book.libraryItem + libraryItem.media = book + return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded() + }) + return json } } diff --git a/server/models/CollectionBook.js b/server/models/CollectionBook.js index e04da3b2..e706d68c 100644 --- a/server/models/CollectionBook.js +++ b/server/models/CollectionBook.js @@ -16,15 +16,6 @@ class CollectionBook extends Model { this.createdAt } - static removeByIds(collectionId, bookId) { - return this.destroy({ - where: { - bookId, - collectionId - } - }) - } - static init(sequelize) { super.init( { diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 8ebed1d5..bed96631 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -123,7 +123,7 @@ class LibraryItem extends Model { } /** - * Currently unused because this is too slow and uses too much mem + * * @param {import('sequelize').WhereOptions} [where] * @returns {Array} old library items */ diff --git a/server/objects/Collection.js b/server/objects/Collection.js deleted file mode 100644 index 970d714b..00000000 --- a/server/objects/Collection.js +++ /dev/null @@ -1,115 +0,0 @@ -const uuidv4 = require("uuid").v4 - -class Collection { - constructor(collection) { - this.id = null - this.libraryId = null - - this.name = null - this.description = null - - this.cover = null - this.coverFullPath = null - this.books = [] - - this.lastUpdate = null - this.createdAt = null - - if (collection) { - this.construct(collection) - } - } - - toJSON() { - return { - id: this.id, - libraryId: this.libraryId, - name: this.name, - description: this.description, - cover: this.cover, - coverFullPath: this.coverFullPath, - books: [...this.books], - lastUpdate: this.lastUpdate, - createdAt: this.createdAt - } - } - - toJSONExpanded(libraryItems, minifiedBooks = false) { - const json = this.toJSON() - json.books = json.books.map(bookId => { - const book = libraryItems.find(li => li.id === bookId) - return book ? minifiedBooks ? book.toJSONMinified() : book.toJSONExpanded() : null - }).filter(b => !!b) - return json - } - - // Expanded and filtered out items not accessible to user - toJSONExpandedForUser(user, libraryItems) { - const json = this.toJSON() - json.books = json.books.map(libraryItemId => { - const libraryItem = libraryItems.find(li => li.id === libraryItemId) - return libraryItem ? libraryItem.toJSONExpanded() : null - }).filter(li => { - return li && user.checkCanAccessLibraryItem(li) - }) - return json - } - - construct(collection) { - this.id = collection.id - this.libraryId = collection.libraryId - this.name = collection.name - this.description = collection.description || null - this.cover = collection.cover || null - this.coverFullPath = collection.coverFullPath || null - this.books = collection.books ? [...collection.books] : [] - this.lastUpdate = collection.lastUpdate || null - this.createdAt = collection.createdAt || null - } - - setData(data) { - if (!data.libraryId || !data.name) { - return false - } - this.id = uuidv4() - this.libraryId = data.libraryId - this.name = data.name - this.description = data.description || null - this.cover = data.cover || null - this.coverFullPath = data.coverFullPath || null - this.books = data.books ? [...data.books] : [] - this.lastUpdate = Date.now() - this.createdAt = Date.now() - return true - } - - addBook(bookId) { - this.books.push(bookId) - this.lastUpdate = Date.now() - } - - removeBook(bookId) { - this.books = this.books.filter(bid => bid !== bookId) - this.lastUpdate = Date.now() - } - - update(payload) { - let hasUpdates = false - for (const key in payload) { - if (key === 'books') { - if (payload.books && this.books.join(',') !== payload.books.join(',')) { - this.books = [...payload.books] - hasUpdates = true - } - } else if (this[key] !== undefined && this[key] !== payload[key]) { - hasUpdates = true - this[key] = payload[key] - } - } - if (hasUpdates) { - this.lastUpdate = Date.now() - } - return hasUpdates - } -} -module.exports = Collection \ No newline at end of file