2024-08-11 17:01:25 -05:00
const { Request , Response , NextFunction } = require ( 'express' )
2023-08-12 15:01:27 -05:00
const Sequelize = require ( 'sequelize' )
2021-11-21 20:00:40 -06:00
const Logger = require ( '../Logger' )
2022-11-24 15:53:58 -06:00
const SocketAuthority = require ( '../SocketAuthority' )
2023-07-04 18:14:44 -05:00
const Database = require ( '../Database' )
2026-03-19 16:53:21 -05:00
const htmlSanitizer = require ( '../utils/htmlSanitizer' )
2022-11-24 15:53:58 -06:00
2024-12-15 12:37:01 -06:00
const RssFeedManager = require ( '../managers/RssFeedManager' )
2021-11-21 20:00:40 -06:00
2024-08-11 17:01:25 -05:00
/ * *
* @ typedef RequestUserObject
* @ property { import ( '../models/User' ) } user
*
* @ typedef { Request & RequestUserObject } RequestWithUser
2024-12-30 16:54:48 -06:00
*
* @ typedef RequestEntityObject
* @ property { import ( '../models/Collection' ) } collection
*
* @ typedef { RequestWithUser & RequestEntityObject } CollectionControllerRequest
2024-08-11 17:01:25 -05:00
* /
2021-11-21 20:00:40 -06:00
class CollectionController {
2024-08-10 17:15:21 -05:00
constructor ( ) { }
2021-11-21 20:00:40 -06:00
2023-08-12 15:01:27 -05:00
/ * *
* POST : / a p i / c o l l e c t i o n s
* Create new collection
2024-08-11 17:01:25 -05:00
*
* @ param { RequestWithUser } req
* @ param { Response } res
2023-08-12 15:01:27 -05:00
* /
2021-11-21 20:00:40 -06:00
async create ( req , res ) {
2024-12-30 16:54:48 -06:00
const reqBody = req . body || { }
2026-03-19 16:53:21 -05:00
const nameCleaned = htmlSanitizer . stripAllTags ( reqBody . name )
2024-12-30 16:54:48 -06:00
// Validation
2026-03-19 16:53:21 -05:00
if ( ! nameCleaned || ! reqBody . libraryId ) {
2023-08-12 15:01:27 -05:00
return res . status ( 400 ) . send ( 'Invalid collection data' )
2021-11-21 20:00:40 -06:00
}
2024-12-31 17:01:42 -06:00
if ( reqBody . description && typeof reqBody . description !== 'string' ) {
return res . status ( 400 ) . send ( 'Invalid collection description' )
}
2026-04-22 16:29:47 -05:00
if ( ! req . user . checkCanAccessLibrary ( reqBody . libraryId ) ) {
Logger . warn ( ` [CollectionController] User " ${ req . user . username } " attempted to create collection in inaccessible library ${ reqBody . libraryId } ` )
return res . sendStatus ( 403 )
}
2024-12-30 16:54:48 -06:00
const libraryItemIds = ( reqBody . books || [ ] ) . filter ( ( b ) => ! ! b && typeof b == 'string' )
if ( ! libraryItemIds . length ) {
return res . status ( 400 ) . send ( 'Invalid collection data. No books' )
}
2023-08-11 17:49:06 -05:00
2024-12-30 16:54:48 -06:00
// Load library items
const libraryItems = await Database . libraryItemModel . findAll ( {
attributes : [ 'id' , 'mediaId' , 'mediaType' , 'libraryId' ] ,
where : {
id : libraryItemIds ,
libraryId : reqBody . libraryId ,
mediaType : 'book'
}
} )
if ( libraryItems . length !== libraryItemIds . length ) {
return res . status ( 400 ) . send ( 'Invalid collection data. Invalid books' )
}
2023-08-12 15:01:27 -05:00
2024-12-30 16:54:48 -06:00
/** @type {import('../models/Collection')} */
let newCollection = null
const transaction = await Database . sequelize . transaction ( )
try {
// Create collection
newCollection = await Database . collectionModel . create (
{
libraryId : reqBody . libraryId ,
2026-03-19 16:53:21 -05:00
name : nameCleaned ,
2024-12-30 16:54:48 -06:00
description : reqBody . description || null
} ,
{ transaction }
)
2023-08-12 15:01:27 -05:00
2024-12-30 16:54:48 -06:00
// Create collectionBooks
const collectionBookPayloads = libraryItemIds . map ( ( llid , index ) => {
const libraryItem = libraryItems . find ( ( li ) => li . id === llid )
return {
2023-08-12 15:01:27 -05:00
collectionId : newCollection . id ,
2024-12-30 16:54:48 -06:00
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' )
2023-08-12 15:01:27 -05:00
}
2024-12-30 16:54:48 -06:00
// Load books expanded
newCollection . books = await newCollection . getBooksExpandedWithLibraryItem ( )
// Note: The old collection model stores expanded libraryItems in the books property
const jsonExpanded = newCollection . toOldJSONExpanded ( )
2022-11-24 15:53:58 -06:00
SocketAuthority . emitter ( 'collection_added' , jsonExpanded )
2021-11-21 20:00:40 -06:00
res . json ( jsonExpanded )
}
2024-08-11 17:01:25 -05:00
/ * *
* GET : / a p i / c o l l e c t i o n s
*
* @ param { RequestWithUser } req
* @ param { Response } res
* /
2023-07-22 16:18:55 -05:00
async findAll ( req , res ) {
2024-08-11 16:07:29 -05:00
const collectionsExpanded = await Database . collectionModel . getOldCollectionsJsonExpanded ( req . user )
2026-04-22 16:29:47 -05:00
const accessibleCollections = collectionsExpanded . filter ( ( c ) => req . user . checkCanAccessLibrary ( c . libraryId ) )
2022-11-29 11:48:21 -06:00
res . json ( {
2026-04-22 16:29:47 -05:00
collections : accessibleCollections
2022-11-29 11:48:21 -06:00
} )
2021-11-21 20:00:40 -06:00
}
2024-08-11 17:01:25 -05:00
/ * *
* GET : / a p i / c o l l e c t i o n s / : i d
*
2024-12-30 16:54:48 -06:00
* @ param { CollectionControllerRequest } req
2024-08-11 17:01:25 -05:00
* @ param { Response } res
* /
2023-07-17 16:48:46 -05:00
async findOne ( req , res ) {
2022-12-27 18:03:31 -06:00
const includeEntities = ( req . query . include || '' ) . split ( ',' )
2024-08-11 16:07:29 -05:00
const collectionExpanded = await req . collection . getOldJsonExpanded ( req . user , includeEntities )
2023-08-12 15:01:27 -05:00
if ( ! collectionExpanded ) {
// This may happen if the user is restricted from all books
return res . sendStatus ( 404 )
2022-12-27 18:03:31 -06:00
}
res . json ( collectionExpanded )
2021-11-21 20:00:40 -06:00
}
2023-08-12 15:01:27 -05:00
/ * *
* PATCH : / a p i / c o l l e c t i o n s / : i d
* Update collection
2024-08-11 17:01:25 -05:00
*
2024-12-30 16:54:48 -06:00
* @ param { CollectionControllerRequest } req
2024-08-11 17:01:25 -05:00
* @ param { Response } res
2023-08-12 15:01:27 -05:00
* /
2021-11-21 20:00:40 -06:00
async update ( req , res ) {
2023-08-12 15:01:27 -05:00
let wasUpdated = false
// Update description and name if defined
const collectionUpdatePayload = { }
if ( req . body . description !== undefined && req . body . description !== req . collection . description ) {
collectionUpdatePayload . description = req . body . description
wasUpdated = true
}
2026-03-19 16:53:21 -05:00
if ( req . body . name !== undefined && typeof req . body . name === 'string' ) {
const nameCleaned = htmlSanitizer . stripAllTags ( req . body . name )
if ( nameCleaned !== req . collection . name ) {
collectionUpdatePayload . name = nameCleaned
wasUpdated = true
}
2023-08-12 15:01:27 -05:00
}
if ( wasUpdated ) {
await req . collection . update ( collectionUpdatePayload )
}
// If books array is passed in then update order in collection
2024-12-15 16:56:59 -06:00
let collectionBooksUpdated = false
2023-08-12 15:01:27 -05:00
if ( req . body . books ? . length ) {
const collectionBooks = await req . collection . getCollectionBooks ( {
include : {
2023-08-20 13:34:03 -05:00
model : Database . bookModel ,
include : Database . libraryItemModel
2023-08-12 15:01:27 -05:00
} ,
order : [ [ 'order' , 'ASC' ] ]
} )
collectionBooks . sort ( ( a , b ) => {
2024-08-10 17:15:21 -05:00
const aIndex = req . body . books . findIndex ( ( lid ) => lid === a . book . libraryItem . id )
const bIndex = req . body . books . findIndex ( ( lid ) => lid === b . book . libraryItem . id )
2023-08-12 15:01:27 -05:00
return aIndex - bIndex
} )
for ( let i = 0 ; i < collectionBooks . length ; i ++ ) {
if ( collectionBooks [ i ] . order !== i + 1 ) {
await collectionBooks [ i ] . update ( {
order : i + 1
} )
2024-12-15 16:56:59 -06:00
collectionBooksUpdated = true
2023-08-12 15:01:27 -05:00
}
}
2024-12-15 16:56:59 -06:00
if ( collectionBooksUpdated ) {
req . collection . changed ( 'updatedAt' , true )
await req . collection . save ( )
wasUpdated = true
}
2023-08-12 15:01:27 -05:00
}
const jsonExpanded = await req . collection . getOldJsonExpanded ( )
2021-11-21 20:00:40 -06:00
if ( wasUpdated ) {
2022-11-24 15:53:58 -06:00
SocketAuthority . emitter ( 'collection_updated' , jsonExpanded )
2021-11-21 20:00:40 -06:00
}
res . json ( jsonExpanded )
}
2024-08-11 17:01:25 -05:00
/ * *
* DELETE : / a p i / c o l l e c t i o n s / : i d
*
2024-12-15 12:37:01 -06:00
* @ this { import ( '../routers/ApiRouter' ) }
*
2024-12-30 16:54:48 -06:00
* @ param { CollectionControllerRequest } req
2024-08-11 17:01:25 -05:00
* @ param { Response } res
* /
2021-11-21 20:00:40 -06:00
async delete ( req , res ) {
2023-08-12 15:01:27 -05:00
const jsonExpanded = await req . collection . getOldJsonExpanded ( )
2022-12-31 14:08:34 -06:00
// Close rss feed - remove from db and emit socket event
2024-12-15 12:37:01 -06:00
await RssFeedManager . closeFeedForEntityId ( req . collection . id )
2023-08-12 15:01:27 -05:00
await req . collection . destroy ( )
2022-12-31 14:08:34 -06:00
2022-11-24 15:53:58 -06:00
SocketAuthority . emitter ( 'collection_removed' , jsonExpanded )
2021-11-21 20:00:40 -06:00
res . sendStatus ( 200 )
}
2023-08-12 15:01:27 -05:00
/ * *
* POST : / a p i / c o l l e c t i o n s / : i d / b o o k
* Add a single book to a collection
* Req . body { id : < library item id > }
2024-08-11 17:01:25 -05:00
*
2024-12-30 16:54:48 -06:00
* @ param { CollectionControllerRequest } req
2024-08-11 17:01:25 -05:00
* @ param { Response } res
2023-08-12 15:01:27 -05:00
* /
2021-11-21 20:00:40 -06:00
async addBook ( req , res ) {
2025-01-04 15:20:41 -06:00
const libraryItem = await Database . libraryItemModel . findByPk ( req . body . id , {
attributes : [ 'libraryId' , 'mediaId' ]
} )
2022-03-12 18:50:31 -06:00
if ( ! libraryItem ) {
2023-08-12 15:01:27 -05:00
return res . status ( 404 ) . send ( 'Book not found' )
2021-11-21 20:00:40 -06:00
}
2023-08-12 15:01:27 -05:00
if ( libraryItem . libraryId !== req . collection . libraryId ) {
return res . status ( 400 ) . send ( 'Book in different library' )
2021-11-21 20:00:40 -06:00
}
2023-08-12 15:01:27 -05:00
// Check if book is already in collection
const collectionBooks = await req . collection . getCollectionBooks ( )
2025-01-04 15:20:41 -06:00
if ( collectionBooks . some ( ( cb ) => cb . bookId === libraryItem . mediaId ) ) {
2023-08-12 15:01:27 -05:00
return res . status ( 400 ) . send ( 'Book already in collection' )
2021-11-21 20:00:40 -06:00
}
2023-07-04 18:14:44 -05:00
2023-08-12 15:01:27 -05:00
// Create collectionBook record
2023-08-20 13:34:03 -05:00
await Database . collectionBookModel . create ( {
2023-08-12 15:01:27 -05:00
collectionId : req . collection . id ,
2025-01-04 15:20:41 -06:00
bookId : libraryItem . mediaId ,
2023-08-12 15:01:27 -05:00
order : collectionBooks . length + 1
} )
const jsonExpanded = await req . collection . getOldJsonExpanded ( )
2022-11-24 15:53:58 -06:00
SocketAuthority . emitter ( 'collection_updated' , jsonExpanded )
2021-11-21 20:00:40 -06:00
res . json ( jsonExpanded )
}
2023-08-12 15:01:27 -05:00
/ * *
* DELETE : / a p i / c o l l e c t i o n s / : i d / b o o k / : b o o k I d
* Remove a single book from a collection . Re - order books
2025-02-07 17:09:48 -06:00
* Users with update permission can remove books from collections
2023-08-12 15:01:27 -05:00
* TODO : bookId is actually libraryItemId . Clients need updating to use bookId
2024-08-11 17:01:25 -05:00
*
2024-12-30 16:54:48 -06:00
* @ param { CollectionControllerRequest } req
2024-08-11 17:01:25 -05:00
* @ param { Response } res
2023-08-12 15:01:27 -05:00
* /
2021-11-21 20:00:40 -06:00
async removeBook ( req , res ) {
2025-01-04 15:20:41 -06:00
const libraryItem = await Database . libraryItemModel . findByPk ( req . params . bookId , {
attributes : [ 'mediaId' ]
} )
2023-07-04 18:14:44 -05:00
if ( ! libraryItem ) {
return res . sendStatus ( 404 )
}
2023-08-12 15:01:27 -05:00
// Get books in collection ordered
const collectionBooks = await req . collection . getCollectionBooks ( {
order : [ [ 'order' , 'ASC' ] ]
} )
let jsonExpanded = null
2025-01-04 15:20:41 -06:00
const collectionBookToRemove = collectionBooks . find ( ( cb ) => cb . bookId === libraryItem . mediaId )
2023-08-12 15:01:27 -05:00
if ( collectionBookToRemove ) {
// Remove collection book record
await collectionBookToRemove . destroy ( )
// Update order on collection books
let order = 1
for ( const collectionBook of collectionBooks ) {
2025-01-04 15:20:41 -06:00
if ( collectionBook . bookId === libraryItem . mediaId ) continue
2023-08-12 15:01:27 -05:00
if ( collectionBook . order !== order ) {
await collectionBook . update ( {
order
} )
}
order ++
}
jsonExpanded = await req . collection . getOldJsonExpanded ( )
2022-11-24 15:53:58 -06:00
SocketAuthority . emitter ( 'collection_updated' , jsonExpanded )
2023-08-12 15:01:27 -05:00
} else {
jsonExpanded = await req . collection . getOldJsonExpanded ( )
2021-11-27 16:01:53 -06:00
}
2023-08-12 15:01:27 -05:00
res . json ( jsonExpanded )
2021-11-27 16:01:53 -06:00
}
2023-08-12 15:01:27 -05:00
/ * *
* POST : / a p i / c o l l e c t i o n s / : i d / b a t c h / a d d
* Add multiple books to collection
* Req . body { books : < Array of library item ids > }
2024-08-11 17:01:25 -05:00
*
2024-12-30 16:54:48 -06:00
* @ param { CollectionControllerRequest } req
2024-08-11 17:01:25 -05:00
* @ param { Response } res
2023-08-12 15:01:27 -05:00
* /
2021-11-27 16:01:53 -06:00
async addBatch ( req , res ) {
2023-08-12 15:01:27 -05:00
// filter out invalid libraryItemIds
2024-08-10 17:15:21 -05:00
const bookIdsToAdd = ( req . body . books || [ ] ) . filter ( ( b ) => ! ! b && typeof b == 'string' )
2023-08-12 15:01:27 -05:00
if ( ! bookIdsToAdd . length ) {
2024-12-30 16:54:48 -06:00
return res . status ( 400 ) . send ( 'Invalid request body' )
2021-11-27 16:01:53 -06:00
}
2023-08-12 15:01:27 -05:00
// Get library items associated with ids
2023-08-20 13:34:03 -05:00
const libraryItems = await Database . libraryItemModel . findAll ( {
2024-12-30 16:54:48 -06:00
attributes : [ 'id' , 'mediaId' , 'mediaType' , 'libraryId' ] ,
2023-08-12 15:01:27 -05:00
where : {
2024-12-30 16:54:48 -06:00
id : bookIdsToAdd ,
libraryId : req . collection . libraryId ,
mediaType : 'book'
2023-08-12 15:01:27 -05:00
}
} )
2024-12-30 16:54:48 -06:00
if ( ! libraryItems . length ) {
return res . status ( 400 ) . send ( 'Invalid request body. No valid books' )
}
2023-08-12 15:01:27 -05:00
// Get collection books already in collection
2024-12-30 16:54:48 -06:00
/** @type {import('../models/CollectionBook')[]} */
2023-08-12 15:01:27 -05:00
const collectionBooks = await req . collection . getCollectionBooks ( )
let order = collectionBooks . length + 1
2023-07-04 18:14:44 -05:00
const collectionBooksToAdd = [ ]
let hasUpdated = false
2023-08-12 15:01:27 -05:00
// Check and set new collection books to add
for ( const libraryItem of libraryItems ) {
2024-12-30 16:54:48 -06:00
if ( ! collectionBooks . some ( ( cb ) => cb . bookId === libraryItem . mediaId ) ) {
2023-07-04 18:14:44 -05:00
collectionBooksToAdd . push ( {
2023-08-12 15:01:27 -05:00
collectionId : req . collection . id ,
2024-12-30 16:54:48 -06:00
bookId : libraryItem . mediaId ,
2023-07-04 18:14:44 -05:00
order : order ++
} )
2021-11-27 16:01:53 -06:00
hasUpdated = true
2023-08-12 15:01:27 -05:00
} else {
Logger . warn ( ` [CollectionController] addBatch: Library item ${ libraryItem . id } already in collection ` )
2021-11-27 16:01:53 -06:00
}
}
2023-07-04 18:14:44 -05:00
2023-08-12 15:01:27 -05:00
let jsonExpanded = null
2021-11-27 16:01:53 -06:00
if ( hasUpdated ) {
2024-12-30 16:54:48 -06:00
await Database . collectionBookModel . bulkCreate ( collectionBooksToAdd )
2023-08-12 15:01:27 -05:00
jsonExpanded = await req . collection . getOldJsonExpanded ( )
SocketAuthority . emitter ( 'collection_updated' , jsonExpanded )
} else {
jsonExpanded = await req . collection . getOldJsonExpanded ( )
2021-11-27 16:01:53 -06:00
}
2023-08-12 15:01:27 -05:00
res . json ( jsonExpanded )
2021-11-27 16:01:53 -06:00
}
2023-08-12 15:01:27 -05:00
/ * *
* POST : / a p i / c o l l e c t i o n s / : i d / b a t c h / r e m o v e
* Remove multiple books from collection
* Req . body { books : < Array of library item ids > }
2024-08-11 17:01:25 -05:00
*
2024-12-30 16:54:48 -06:00
* @ param { CollectionControllerRequest } req
2024-08-11 17:01:25 -05:00
* @ param { Response } res
2023-08-12 15:01:27 -05:00
* /
2021-11-27 16:01:53 -06:00
async removeBatch ( req , res ) {
2023-08-12 15:01:27 -05:00
// filter out invalid libraryItemIds
2024-08-10 17:15:21 -05:00
const bookIdsToRemove = ( req . body . books || [ ] ) . filter ( ( b ) => ! ! b && typeof b == 'string' )
2023-08-12 15:01:27 -05:00
if ( ! bookIdsToRemove . length ) {
2021-11-27 16:01:53 -06:00
return res . status ( 500 ) . send ( 'Invalid request body' )
}
2023-07-04 18:14:44 -05:00
2023-08-12 15:01:27 -05:00
// Get library items associated with ids
2023-08-20 13:34:03 -05:00
const libraryItems = await Database . libraryItemModel . findAll ( {
2023-08-12 15:01:27 -05:00
where : {
2024-12-30 16:54:48 -06:00
id : bookIdsToRemove
2023-08-12 15:01:27 -05:00
} ,
include : {
2023-08-20 13:34:03 -05:00
model : Database . bookModel
2023-08-12 15:01:27 -05:00
}
} )
// Get collection books already in collection
2024-12-30 16:54:48 -06:00
/** @type {import('../models/CollectionBook')[]} */
2023-08-12 15:01:27 -05:00
const collectionBooks = await req . collection . getCollectionBooks ( {
order : [ [ 'order' , 'ASC' ] ]
} )
// Remove collection books and update order
let order = 1
let hasUpdated = false
for ( const collectionBook of collectionBooks ) {
2024-08-10 17:15:21 -05:00
if ( libraryItems . some ( ( li ) => li . media . id === collectionBook . bookId ) ) {
2023-08-12 15:01:27 -05:00
await collectionBook . destroy ( )
hasUpdated = true
continue
} else if ( collectionBook . order !== order ) {
await collectionBook . update ( {
order
} )
2021-11-27 16:01:53 -06:00
hasUpdated = true
}
2023-08-12 15:01:27 -05:00
order ++
2021-11-27 16:01:53 -06:00
}
2023-08-12 15:01:27 -05:00
let jsonExpanded = await req . collection . getOldJsonExpanded ( )
2021-11-27 16:01:53 -06:00
if ( hasUpdated ) {
2023-08-12 15:01:27 -05:00
SocketAuthority . emitter ( 'collection_updated' , jsonExpanded )
2021-11-21 20:00:40 -06:00
}
2023-08-12 15:01:27 -05:00
res . json ( jsonExpanded )
2021-11-21 20:00:40 -06:00
}
2022-08-31 15:46:10 -05:00
2024-08-11 17:01:25 -05:00
/ * *
*
* @ param { RequestWithUser } req
* @ param { Response } res
* @ param { NextFunction } next
* /
2023-07-22 16:18:55 -05:00
async middleware ( req , res , next ) {
2022-08-31 15:46:10 -05:00
if ( req . params . id ) {
2023-08-20 13:34:03 -05:00
const collection = await Database . collectionModel . findByPk ( req . params . id )
2022-08-31 15:46:10 -05:00
if ( ! collection ) {
return res . status ( 404 ) . send ( 'Collection not found' )
}
2026-04-22 16:29:47 -05:00
if ( ! req . user . checkCanAccessLibrary ( collection . libraryId ) ) {
Logger . warn ( ` [CollectionController] User " ${ req . user . username } " attempted to access collection ${ collection . id } in inaccessible library ${ collection . libraryId } ` )
return res . status ( 404 ) . send ( 'Collection not found' )
}
2022-08-31 15:46:10 -05:00
req . collection = collection
}
2025-02-07 17:09:48 -06:00
// Users with update permission can remove books from collections
if ( req . method == 'DELETE' && ! req . params . bookId && ! req . user . canDelete ) {
2024-08-11 16:07:29 -05:00
Logger . warn ( ` [CollectionController] User " ${ req . user . username } " attempted to delete without permission ` )
2022-08-31 15:46:10 -05:00
return res . sendStatus ( 403 )
2024-08-11 16:07:29 -05:00
} else if ( ( req . method == 'PATCH' || req . method == 'POST' ) && ! req . user . canUpdate ) {
Logger . warn ( ` [CollectionController] User " ${ req . user . username } " attempted to update without permission ` )
2022-08-31 15:46:10 -05:00
return res . sendStatus ( 403 )
}
next ( )
}
2021-11-21 20:00:40 -06:00
}
2024-08-10 17:15:21 -05:00
module . exports = new CollectionController ( )