mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-02-28 21:19:42 +00:00
- Implemented delete functionality for reviews in both the ReviewModal and ReviewsTable components. - Added confirmation prompts for review deletion actions. - Updated ReviewController to allow deletion of reviews by admins or the review owner. - Enhanced event handling to refresh the review list upon deletion. - Improved UI to include delete buttons for admins in the ratings page and reviews table.
388 lines
11 KiB
JavaScript
388 lines
11 KiB
JavaScript
const Database = require('../Database')
|
|
const Logger = require('../Logger')
|
|
|
|
class ReviewController {
|
|
constructor() {}
|
|
|
|
/**
|
|
* POST: /api/items/:id/review
|
|
* Create or update the current user's review for a library item.
|
|
*
|
|
* @param {import('express').Request} req
|
|
* @param {import('express').Response} res
|
|
*/
|
|
async createUpdate(req, res) {
|
|
const { rating, reviewText } = req.body
|
|
const libraryItemId = req.params.id
|
|
|
|
if (isNaN(rating) || rating < 1 || rating > 5) {
|
|
return res.status(400).send('Invalid rating. Must be an integer between 1 and 5.')
|
|
}
|
|
|
|
const cleanReviewText = reviewText ? String(reviewText).trim().substring(0, 5000) : null
|
|
|
|
try {
|
|
const [review, created] = await Database.reviewModel.findOrCreate({
|
|
where: {
|
|
userId: req.user.id,
|
|
libraryItemId
|
|
},
|
|
defaults: {
|
|
rating,
|
|
reviewText: cleanReviewText
|
|
}
|
|
})
|
|
|
|
if (!created) {
|
|
review.rating = rating
|
|
review.reviewText = cleanReviewText
|
|
await review.save()
|
|
}
|
|
|
|
// Load user for toOldJSON
|
|
review.user = req.user
|
|
|
|
res.json(review.toOldJSON())
|
|
} catch (error) {
|
|
Logger.error(`[ReviewController] Failed to create/update review`, error)
|
|
res.status(500).send('Failed to save review')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET: /api/items/:id/reviews
|
|
* Get all reviews for a library item.
|
|
*
|
|
* @param {import('express').Request} req
|
|
* @param {import('express').Response} res
|
|
*/
|
|
async findAllForItem(req, res) {
|
|
const libraryItemId = req.params.id
|
|
|
|
try {
|
|
const reviews = await Database.reviewModel.findAll({
|
|
where: { libraryItemId },
|
|
include: [
|
|
{
|
|
model: Database.userModel,
|
|
attributes: ['id', 'username']
|
|
}
|
|
],
|
|
order: [['createdAt', 'DESC']]
|
|
})
|
|
|
|
res.json(reviews.map((r) => r.toOldJSON()))
|
|
} catch (error) {
|
|
Logger.error(`[ReviewController] Failed to fetch reviews for item ${libraryItemId}`, error)
|
|
res.status(500).send('Failed to fetch reviews')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DELETE: /api/items/:id/review
|
|
* Delete the current user's review for a library item.
|
|
*
|
|
* @param {import('express').Request} req
|
|
* @param {import('express').Response} res
|
|
*/
|
|
async delete(req, res) {
|
|
const libraryItemId = req.params.id
|
|
|
|
try {
|
|
const review = await Database.reviewModel.findOne({
|
|
where: {
|
|
userId: req.user.id,
|
|
libraryItemId
|
|
}
|
|
})
|
|
|
|
if (!review) {
|
|
return res.sendStatus(404)
|
|
}
|
|
|
|
await review.destroy()
|
|
res.sendStatus(200)
|
|
} catch (error) {
|
|
Logger.error(`[ReviewController] Failed to delete review for item ${libraryItemId}`, error)
|
|
res.status(500).send('Failed to delete review')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DELETE: /api/reviews/:id
|
|
* Delete a review by ID.
|
|
* Admin or the owner of the review can delete it.
|
|
*
|
|
* @param {import('express').Request} req
|
|
* @param {import('express').Response} res
|
|
*/
|
|
async deleteById(req, res) {
|
|
if (!Database.serverSettings.enableReviews) {
|
|
return res.status(403).send('Review feature is disabled')
|
|
}
|
|
|
|
const { id } = req.params
|
|
|
|
try {
|
|
const review = await Database.reviewModel.findByPk(id)
|
|
if (!review) {
|
|
return res.sendStatus(404)
|
|
}
|
|
|
|
// Check if user is owner or admin
|
|
if (review.userId !== req.user.id && req.user.type !== 'admin' && req.user.type !== 'root') {
|
|
return res.status(403).send('Not authorized to delete this review')
|
|
}
|
|
|
|
await review.destroy()
|
|
res.sendStatus(200)
|
|
} catch (error) {
|
|
Logger.error(`[ReviewController] Failed to delete review ${id}`, error)
|
|
res.status(500).send('Failed to delete review')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET: /api/me/reviews
|
|
* Get all reviews by the current user.
|
|
*
|
|
* @param {import('express').Request} req
|
|
* @param {import('express').Response} res
|
|
*/
|
|
async findAllForUser(req, res) {
|
|
if (!Database.serverSettings.enableReviews) {
|
|
return res.status(403).send('Review feature is disabled')
|
|
}
|
|
|
|
try {
|
|
const reviews = await Database.reviewModel.findAll({
|
|
where: { userId: req.user.id },
|
|
include: [
|
|
{
|
|
model: Database.libraryItemModel,
|
|
include: [
|
|
{
|
|
model: Database.bookModel,
|
|
include: [
|
|
{
|
|
model: Database.authorModel,
|
|
through: { attributes: [] }
|
|
},
|
|
{
|
|
model: Database.seriesModel,
|
|
through: { attributes: ['id', 'sequence'] }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
model: Database.podcastModel,
|
|
include: {
|
|
model: Database.podcastEpisodeModel
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
order: [['createdAt', 'DESC']]
|
|
})
|
|
|
|
res.json(reviews.map((r) => {
|
|
const json = r.toOldJSON()
|
|
if (r.libraryItem) {
|
|
// Manually set media if missing (Sequelize hooks don't run on nested includes)
|
|
if (!r.libraryItem.media) {
|
|
if (r.libraryItem.mediaType === 'book' && r.libraryItem.book) {
|
|
r.libraryItem.media = r.libraryItem.book
|
|
} else if (r.libraryItem.mediaType === 'podcast' && r.libraryItem.podcast) {
|
|
r.libraryItem.media = r.libraryItem.podcast
|
|
}
|
|
}
|
|
|
|
if (r.libraryItem.media) {
|
|
try {
|
|
json.libraryItem = r.libraryItem.toOldJSONMinified()
|
|
} catch (err) {
|
|
Logger.error(`[ReviewController] Failed to minify library item ${r.libraryItem.id}`, err)
|
|
}
|
|
}
|
|
}
|
|
return json
|
|
}))
|
|
} catch (error) {
|
|
Logger.error(`[ReviewController] Failed to fetch reviews for user ${req.user.id}`, error)
|
|
res.status(500).send('Failed to fetch reviews')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET: /api/libraries/:id/reviews
|
|
* Get all reviews for items in a library.
|
|
* Supports sorting by newest, oldest, highest, lowest.
|
|
* Supports filtering by user or rating.
|
|
* Supports pagination with limit and page.
|
|
*
|
|
* @param {import('express').Request} req
|
|
* @param {import('express').Response} res
|
|
*/
|
|
async findAllForLibrary(req, res) {
|
|
if (!Database.serverSettings.enableReviews) {
|
|
return res.status(403).send('Review feature is disabled')
|
|
}
|
|
|
|
const libraryId = req.params.id
|
|
const { sort, filter, limit, page } = req.query
|
|
|
|
try {
|
|
const where = {}
|
|
const include = [
|
|
{
|
|
model: Database.userModel,
|
|
attributes: ['id', 'username']
|
|
},
|
|
{
|
|
model: Database.libraryItemModel,
|
|
where: { libraryId },
|
|
required: true,
|
|
include: [
|
|
{
|
|
model: Database.bookModel,
|
|
include: [
|
|
{ model: Database.authorModel, through: { attributes: [] } },
|
|
{ model: Database.seriesModel, through: { attributes: ['id', 'sequence'] } }
|
|
]
|
|
},
|
|
{
|
|
model: Database.podcastModel,
|
|
include: { model: Database.podcastEpisodeModel }
|
|
}
|
|
]
|
|
}
|
|
]
|
|
|
|
if (filter) {
|
|
const [filterType, filterValue] = filter.split('.')
|
|
if (filterType === 'user' && filterValue) {
|
|
where.userId = filterValue
|
|
} else if (filterType === 'rating' && filterValue) {
|
|
where.rating = filterValue
|
|
}
|
|
}
|
|
|
|
let order = [['createdAt', 'DESC']]
|
|
if (sort === 'oldest') order = [['createdAt', 'ASC']]
|
|
else if (sort === 'highest') order = [['rating', 'DESC'], ['createdAt', 'DESC']]
|
|
else if (sort === 'lowest') order = [['rating', 'ASC'], ['createdAt', 'DESC']]
|
|
|
|
const limitNum = limit ? parseInt(limit) : 50
|
|
const pageNum = page ? parseInt(page) : 0
|
|
const offset = pageNum * limitNum
|
|
|
|
const { count, rows: reviews } = await Database.reviewModel.findAndCountAll({
|
|
where,
|
|
include,
|
|
order,
|
|
limit: limitNum,
|
|
offset
|
|
})
|
|
|
|
const results = reviews.map((r) => {
|
|
const json = r.toOldJSON()
|
|
if (r.libraryItem) {
|
|
if (!r.libraryItem.media) {
|
|
if (r.libraryItem.mediaType === 'book' && r.libraryItem.book) {
|
|
r.libraryItem.media = r.libraryItem.book
|
|
} else if (r.libraryItem.mediaType === 'podcast' && r.libraryItem.podcast) {
|
|
r.libraryItem.media = r.libraryItem.podcast
|
|
}
|
|
}
|
|
if (r.libraryItem.media) {
|
|
try {
|
|
json.libraryItem = r.libraryItem.toOldJSONMinified()
|
|
} catch (err) {
|
|
Logger.error(`[ReviewController] Failed to minify library item ${r.libraryItem.id}`, err)
|
|
}
|
|
}
|
|
}
|
|
return json
|
|
})
|
|
|
|
// Collect unique reviewers for the filter dropdown
|
|
const allReviewers = await Database.reviewModel.findAll({
|
|
attributes: ['userId'],
|
|
include: [
|
|
{
|
|
model: Database.libraryItemModel,
|
|
where: { libraryId },
|
|
required: true,
|
|
attributes: []
|
|
},
|
|
{
|
|
model: Database.userModel,
|
|
attributes: ['id', 'username']
|
|
}
|
|
],
|
|
group: ['review.userId']
|
|
})
|
|
const reviewers = allReviewers
|
|
.filter((r) => r.user)
|
|
.map((r) => ({ id: r.user.id, username: r.user.username }))
|
|
.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
|
|
|
|
// Get counts for each rating level
|
|
const ratingCountsResults = await Database.reviewModel.findAll({
|
|
attributes: ['rating', [Database.sequelize.fn('COUNT', Database.sequelize.col('review.id')), 'count']],
|
|
include: [
|
|
{
|
|
model: Database.libraryItemModel,
|
|
where: { libraryId },
|
|
required: true,
|
|
attributes: []
|
|
}
|
|
],
|
|
group: ['rating']
|
|
})
|
|
const ratingCounts = {}
|
|
for (let i = 1; i <= 5; i++) ratingCounts[i] = 0
|
|
ratingCountsResults.forEach((r) => {
|
|
ratingCounts[r.rating] = parseInt(r.get('count'))
|
|
})
|
|
|
|
res.json({
|
|
reviews: results,
|
|
total: count,
|
|
page: pageNum,
|
|
limit: limitNum,
|
|
reviewers,
|
|
ratingCounts
|
|
})
|
|
} catch (error) {
|
|
Logger.error(`[ReviewController] Failed to fetch reviews for library ${libraryId}`, error)
|
|
res.status(500).send('Failed to fetch reviews')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Middleware for review routes.
|
|
*
|
|
* @param {import('express').Request} req
|
|
* @param {import('express').Response} res
|
|
* @param {import('express').NextFunction} next
|
|
*/
|
|
async middleware(req, res, next) {
|
|
if (!Database.serverSettings.enableReviews) {
|
|
return res.status(403).send('Review feature is disabled')
|
|
}
|
|
|
|
// Basic library item access check
|
|
req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
|
|
if (!req.libraryItem?.media) return res.sendStatus(404)
|
|
|
|
if (!req.user.checkCanAccessLibraryItem(req.libraryItem)) {
|
|
return res.sendStatus(403)
|
|
}
|
|
|
|
next()
|
|
}
|
|
}
|
|
|
|
module.exports = new ReviewController()
|