diff --git a/.vscode/settings.json b/.vscode/settings.json index 75503e6a4..bcfecf743 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,5 +23,8 @@ }, "[vue]": { "editor.defaultFormatter": "octref.vetur" + }, + "[javascript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" } } \ No newline at end of file diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index 9fa7661a1..c7b7127ef 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -68,6 +68,14 @@
+ + star + +

{{ $strings.ButtonRatings }}

+ +
+ + @@ -174,6 +182,9 @@ export default { isNarratorsPage() { return this.$route.name === 'library-library-narrators' }, + isRatingsPage() { + return this.$route.name === 'library-library-ratings' + }, isPlaylistsPage() { return this.paramId === 'playlists' }, @@ -196,6 +207,12 @@ export default { numIssues() { return this.$store.state.libraries.issues || 0 }, + enableReviews() { + return this.$store.getters['getServerSetting']('enableReviews') + }, + showReviewsInSidebar() { + return this.$store.getters['getServerSetting']('showReviewsInSidebar') + }, versionData() { return this.$store.state.versionData || {} }, diff --git a/client/components/modals/ReviewModal.vue b/client/components/modals/ReviewModal.vue new file mode 100644 index 000000000..c236b2c97 --- /dev/null +++ b/client/components/modals/ReviewModal.vue @@ -0,0 +1,128 @@ + + + diff --git a/client/components/tables/ReviewsTable.vue b/client/components/tables/ReviewsTable.vue new file mode 100644 index 000000000..a0111e73c --- /dev/null +++ b/client/components/tables/ReviewsTable.vue @@ -0,0 +1,147 @@ + + + diff --git a/client/components/ui/StarRating.vue b/client/components/ui/StarRating.vue new file mode 100644 index 000000000..cbeb84210 --- /dev/null +++ b/client/components/ui/StarRating.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index b8cf3cff2..471cba996 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -103,6 +103,26 @@
+ +
+ + +
+ +
+ + +
diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 1d8f0f20b..a6655196c 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -128,6 +128,8 @@
+ + @@ -143,6 +145,7 @@ + @@ -343,6 +346,9 @@ export default { isQueued() { return this.$store.getters['getIsMediaQueued'](this.libraryItemId) }, + enableReviews() { + return this.$store.getters['getServerSetting']('enableReviews') + }, userCanUpdate() { return this.$store.getters['user/getUserCanUpdate'] }, @@ -434,6 +440,9 @@ export default { } }, methods: { + onReviewUpdated(review) { + this.$root.$emit('review-updated', review) + }, selectBookmark(bookmark) { if (!bookmark) return if (this.isStreaming) { diff --git a/client/pages/library/_library/ratings.vue b/client/pages/library/_library/ratings.vue new file mode 100644 index 000000000..635a9369a --- /dev/null +++ b/client/pages/library/_library/ratings.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/client/pages/library/_library/stats.vue b/client/pages/library/_library/stats.vue index 0fcc2a414..d27c363f6 100644 --- a/client/pages/library/_library/stats.vue +++ b/client/pages/library/_library/stats.vue @@ -43,6 +43,26 @@ +
+

{{ $strings.HeaderStatsTopRated }}

+ +

{{ $strings.HeaderStatsLongestItems }}

{{ $strings.MessageNoItems }}

@@ -154,6 +174,9 @@ export default { top10Authors() { return this.authorsWithCount?.slice(0, 10) || [] }, + top10RatedItems() { + return this.libraryStats?.topRatedItems || [] + }, currentLibraryId() { return this.$store.state.libraries.currentLibraryId }, @@ -165,6 +188,9 @@ export default { }, isBookLibrary() { return this.currentLibraryMediaType === 'book' + }, + enableReviews() { + return this.$store.getters['getServerSetting']('enableReviews') } }, methods: { diff --git a/client/store/globals.js b/client/store/globals.js index 7b416196a..d9fc8a96e 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -25,8 +25,10 @@ export const state = () => ({ selectedRawCoverUrl: null, selectedMediaItemShare: null, isCasting: false, // Actively casting - isChromecastInitialized: false, // Script loadeds - showBatchQuickMatchModal: false, + isChromecastInitialized: false, // Script loadeds + showReviewModal: false, + selectedReviewItem: null, + showBatchQuickMatchModal: false, dateFormats: [ { text: 'MM/DD/YYYY', @@ -204,6 +206,16 @@ export const mutations = { setShowBatchQuickMatchModal(state, val) { state.showBatchQuickMatchModal = val }, + setShowReviewModal(state, val) { + state.showReviewModal = val + }, + setReviewModal(state, { libraryItem, review }) { + state.selectedReviewItem = { + libraryItem, + review + } + state.showReviewModal = true + }, resetSelectedMediaItems(state) { state.selectedMediaItems = [] }, diff --git a/client/strings/en-us.json b/client/strings/en-us.json index fb2bcb281..35fe7a83e 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -73,10 +73,13 @@ "ButtonQuickEmbed": "Quick Embed", "ButtonQuickEmbedMetadata": "Quick Embed Metadata", "ButtonQuickMatch": "Quick Match", + "ButtonRatings": "Ratings", "ButtonReScan": "Re-Scan", "ButtonRead": "Read", "ButtonReadLess": "Read less", "ButtonReadMore": "Read more", + "ButtonReviewEdit": "Edit Review", + "ButtonReviewWrite": "Write a Review", "ButtonRefresh": "Refresh", "ButtonRemove": "Remove", "ButtonRemoveAll": "Remove All", @@ -208,6 +211,7 @@ "HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)", "HeaderStatsRecentSessions": "Recent Sessions", "HeaderStatsTop10Authors": "Top 10 Authors", + "HeaderStatsTopRated": "Top 10 Best Rated", "HeaderStatsTop5Genres": "Top 5 Genres", "HeaderTableOfContents": "Table of Contents", "HeaderTools": "Tools", @@ -237,10 +241,19 @@ "LabelAddedDate": "Added {0}", "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", + "LabelAllReviews": "All Reviews", "LabelAllEpisodesDownloaded": "All episodes downloaded", "LabelAllUsers": "All Users", "LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersIncludingGuests": "All users including guests", + "LabelFilterByRating": "Filter by Rating", + "LabelFilterByUser": "Filter by User", + "LabelSortHighestRated": "Highest Rated", + "LabelSortLowestRated": "Lowest Rated", + "LabelSortNewestFirst": "Newest First", + "LabelSortOldestFirst": "Oldest First", + "LabelSortTitleAZ": "Title A-Z", + "PlaceholderSearchReviews": "Search by title or author...", "LabelAlreadyInYourLibrary": "Already in your library", "LabelApiKeyCreated": "API Key \"{0}\" created successfully.", "LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.", @@ -255,6 +268,7 @@ "LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthors": "Authors", + "LabelAverageRating": "Average Rating", "LabelAutoDownloadEpisodes": "Auto Download Episodes", "LabelAutoFetchMetadata": "Auto Fetch Metadata", "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", @@ -481,6 +495,7 @@ "LabelNextScheduledRun": "Next scheduled run", "LabelNoApiKeys": "No API keys", "LabelNoCustomMetadataProviders": "No custom metadata providers", + "LabelNoReviews": "No reviews yet.", "LabelNoEpisodesSelected": "No episodes selected", "LabelNotFinished": "Not Finished", "LabelNotStarted": "Not Started", @@ -550,6 +565,7 @@ "LabelReadAgain": "Read Again", "LabelReadEbookWithoutProgress": "Read ebook without keeping progress", "LabelRecentSeries": "Recent Series", + "LabelRating": "Rating", "LabelRecentlyAdded": "Recently Added", "LabelRecommended": "Recommended", "LabelRedo": "Redo", @@ -561,6 +577,8 @@ "LabelRemoveCover": "Remove cover", "LabelRemoveMetadataFile": "Remove metadata files in library item folders", "LabelRemoveMetadataFileHelp": "Remove all metadata.json and metadata.abs files in your {0} folders.", + "LabelReviewComment": "Comment", + "LabelReviews": "Reviews", "LabelRowsPerPage": "Rows per page", "LabelSearchTerm": "Search Term", "LabelSearchTitle": "Search Title", @@ -591,6 +609,10 @@ "LabelSettingsEnableWatcher": "Automatically watch libraries for changes", "LabelSettingsEnableWatcherForLibrary": "Automatically watch library for changes", "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart", + "LabelSettingsEnableReviews": "Enable Reviews", + "LabelSettingsEnableReviewsHelp": "Allow users to rate and review books", + "LabelSettingsShowRatingsPage": "Show Ratings Page in Sidebar", + "LabelSettingsShowRatingsPageHelp": "Display the Ratings page link in the library sidebar", "LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs", "LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.", "LabelSettingsExperimentalFeatures": "Experimental features", @@ -816,6 +838,7 @@ "MessageFeedURLWillBe": "Feed URL will be {0}", "MessageFetching": "Fetching...", "MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.", + "MessageGoRateBooks": "Go rate some books to see them here!", "MessageHeatmapListeningTimeTooltip": "{0} listening on {1}", "MessageHeatmapNoListeningSessions": "No listening sessions on {0}", "MessageImportantNotice": "Important Notice!", @@ -963,6 +986,7 @@ "PlaceholderNewPlaylist": "New playlist name", "PlaceholderSearch": "Search..", "PlaceholderSearchEpisode": "Search episode..", + "PlaceholderReviewWrite": "Write a personal comment...", "StatsAuthorsAdded": "authors added", "StatsBooksAdded": "books added", "StatsBooksAdditional": "Some additions include…", diff --git a/server/Database.js b/server/Database.js index 213c2c61b..0054e6be1 100644 --- a/server/Database.js +++ b/server/Database.js @@ -162,6 +162,11 @@ class Database { return this.models.device } + /** @type {typeof import('./models/Review')} */ + get reviewModel() { + return this.models.review + } + /** * Check if db file exists * @returns {boolean} @@ -345,6 +350,7 @@ class Database { require('./models/Setting').init(this.sequelize) require('./models/CustomMetadataProvider').init(this.sequelize) require('./models/MediaItemShare').init(this.sequelize) + require('./models/Review').init(this.sequelize) return this.sequelize.sync({ force, alter: false }) } diff --git a/server/Server.js b/server/Server.js index d6f748a1e..201e99cc7 100644 --- a/server/Server.js +++ b/server/Server.js @@ -387,6 +387,7 @@ class Server { '/library/:library/authors', '/library/:library/narrators', '/library/:library/stats', + '/library/:library/ratings', '/library/:library/series/:id?', '/library/:library/podcast/search', '/library/:library/podcast/latest', diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 55ef45690..ccfb1188b 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -995,6 +995,47 @@ class LibraryController { stats.totalSize = bookStats.totalSize stats.totalDuration = bookStats.totalDuration stats.numAudioTracks = bookStats.numAudioFiles + + // Get top 10 rated items + try { + const topRatedReviews = await Database.reviewModel.findAll({ + attributes: [ + 'libraryItemId', + [Sequelize.fn('AVG', Sequelize.col('review.rating')), 'avgRating'], + [Sequelize.fn('COUNT', Sequelize.col('review.id')), 'numReviews'] + ], + include: [ + { + model: Database.libraryItemModel, + attributes: ['id'], + where: { libraryId: req.library.id }, + include: [ + { + model: Database.bookModel, + attributes: ['id', 'title'] + } + ] + } + ], + group: ['libraryItemId', 'libraryItem.id', 'libraryItem.book.id'], + order: [ + [Sequelize.literal('avgRating'), 'DESC'], + [Sequelize.literal('numReviews'), 'DESC'] + ], + limit: 10 + }) + stats.topRatedItems = topRatedReviews.map((r) => { + return { + id: r.libraryItemId, + title: r.libraryItem?.book?.title || 'Unknown', + avgRating: parseFloat(r.getDataValue('avgRating')), + numReviews: parseInt(r.getDataValue('numReviews')) + } + }) + } catch (error) { + Logger.error('[LibraryController] Failed to get top rated items for stats', error) + stats.topRatedItems = [] + } } else { const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id) const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id) diff --git a/server/controllers/ReviewController.js b/server/controllers/ReviewController.js new file mode 100644 index 000000000..78a88fe86 --- /dev/null +++ b/server/controllers/ReviewController.js @@ -0,0 +1,388 @@ +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() diff --git a/server/migrations/v2.33.0-create-reviews-table.js b/server/migrations/v2.33.0-create-reviews-table.js new file mode 100644 index 000000000..e9198cd83 --- /dev/null +++ b/server/migrations/v2.33.0-create-reviews-table.js @@ -0,0 +1,110 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.33.0' +const migrationName = `${migrationVersion}-create-reviews-table` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration creates a reviews table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + // Check if table exists + if (await queryInterface.tableExists('reviews')) { + logger.info(`${loggerPrefix} table "reviews" already exists`) + } else { + // Create table + logger.info(`${loggerPrefix} creating table "reviews"`) + const DataTypes = queryInterface.sequelize.Sequelize.DataTypes + await queryInterface.createTable('reviews', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + rating: { + type: DataTypes.INTEGER, + allowNull: false + }, + reviewText: { + type: DataTypes.TEXT, + allowNull: true + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + }, + userId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'users' + }, + key: 'id' + }, + allowNull: false, + onDelete: 'CASCADE' + }, + libraryItemId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'libraryItems' + }, + key: 'id' + }, + allowNull: false, + onDelete: 'CASCADE' + } + }) + + // Add unique constraint on (userId, libraryItemId) + await queryInterface.addIndex('reviews', ['userId', 'libraryItemId'], { + unique: true, + name: 'reviews_user_id_library_item_id_unique' + }) + + logger.info(`${loggerPrefix} created table "reviews"`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script removes the reviews table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('reviews')) { + logger.info(`${loggerPrefix} dropping table "reviews"`) + await queryInterface.dropTable('reviews') + logger.info(`${loggerPrefix} dropped table "reviews"`) + } else { + logger.info(`${loggerPrefix} table "reviews" does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/Review.js b/server/models/Review.js new file mode 100644 index 000000000..d581d219a --- /dev/null +++ b/server/models/Review.js @@ -0,0 +1,107 @@ +const { DataTypes, Model } = require('sequelize') + +/** + * @typedef ReviewJSON + * @property {string} id + * @property {number} rating + * @property {string} reviewText + * @property {string} userId + * @property {string} libraryItemId + * @property {number} updatedAt + * @property {number} createdAt + * @property {Object} [user] + * @property {string} user.id + * @property {string} user.username + */ + +class Review extends Model { + constructor(values, options) { + super(values, options) + + /** @type {string} */ + this.id + /** @type {number} */ + this.rating + /** @type {string} */ + this.reviewText + /** @type {string} */ + this.userId + /** @type {string} */ + this.libraryItemId + /** @type {Date} */ + this.updatedAt + /** @type {Date} */ + this.createdAt + } + + /** + * Initialize the Review model and associations. + * A user can have only one review per library item. + * + * @param {import('sequelize').Sequelize} sequelize + */ + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + rating: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + min: 1, + max: 5 + } + }, + reviewText: { + type: DataTypes.TEXT, + allowNull: true + } + }, + { + sequelize, + modelName: 'review', + indexes: [ + { + unique: true, + fields: ['userId', 'libraryItemId'] + } + ] + } + ) + + const { user, libraryItem } = sequelize.models + + user.hasMany(Review, { onDelete: 'CASCADE' }) + Review.belongsTo(user) + + libraryItem.hasMany(Review, { onDelete: 'CASCADE' }) + Review.belongsTo(libraryItem) + } + + /** + * Convert to the old JSON format for the browser. + * + * @returns {ReviewJSON} + */ + toOldJSON() { + return { + id: this.id, + rating: this.rating, + reviewText: this.reviewText, + userId: this.userId, + libraryItemId: this.libraryItemId, + updatedAt: this.updatedAt.valueOf(), + createdAt: this.createdAt.valueOf(), + user: this.user ? { + id: this.user.id, + username: this.user.username + } : undefined + } + } +} + +module.exports = Review \ No newline at end of file diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index a03e17c75..c421a4dfc 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -55,6 +55,11 @@ class ServerSettings { this.language = 'en-us' this.allowedOrigins = [] + /** @type {boolean} If true, users can rate and review library items */ + this.enableReviews = true + /** @type {boolean} If true, the Ratings page link is shown in the library sidebar */ + this.showReviewsInSidebar = true + this.logLevel = Logger.logLevel this.version = packageJson.version @@ -122,6 +127,9 @@ class ServerSettings { this.timeFormat = settings.timeFormat || 'HH:mm' this.language = settings.language || 'en-us' this.allowedOrigins = settings.allowedOrigins || [] + + this.enableReviews = settings.enableReviews !== false + this.showReviewsInSidebar = settings.showReviewsInSidebar !== false this.logLevel = settings.logLevel || Logger.logLevel this.version = settings.version || null this.buildNumber = settings.buildNumber || 0 // Added v2.4.5 @@ -234,6 +242,8 @@ class ServerSettings { timeFormat: this.timeFormat, language: this.language, allowedOrigins: this.allowedOrigins, + enableReviews: this.enableReviews, + showReviewsInSidebar: this.showReviewsInSidebar, logLevel: this.logLevel, version: this.version, buildNumber: this.buildNumber, diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5ec..d798e751a 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -35,6 +35,7 @@ const MiscController = require('../controllers/MiscController') const ShareController = require('../controllers/ShareController') const StatsController = require('../controllers/StatsController') const ApiKeyController = require('../controllers/ApiKeyController') +const ReviewController = require('../controllers/ReviewController') class ApiRouter { constructor(Server) { @@ -83,6 +84,7 @@ class ApiRouter { this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this)) this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this)) this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this)) + this.router.get('/libraries/:id/reviews', LibraryController.middleware.bind(this), ReviewController.findAllForLibrary.bind(this)) this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this)) this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this)) this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this)) @@ -127,6 +129,11 @@ class ApiRouter { this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this)) this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this)) + this.router.get('/items/:id/reviews', ReviewController.middleware.bind(this), ReviewController.findAllForItem.bind(this)) + this.router.post('/items/:id/review', ReviewController.middleware.bind(this), ReviewController.createUpdate.bind(this)) + this.router.delete('/items/:id/review', ReviewController.middleware.bind(this), ReviewController.delete.bind(this)) + this.router.delete('/reviews/:id', ReviewController.deleteById.bind(this)) + // // User Routes // @@ -188,6 +195,7 @@ class ApiRouter { this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this)) this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this)) + this.router.get('/me/reviews', ReviewController.findAllForUser.bind(this)) // // Backup Routes diff --git a/test/server/controllers/ReviewController.test.js b/test/server/controllers/ReviewController.test.js new file mode 100644 index 000000000..a5afb6bf9 --- /dev/null +++ b/test/server/controllers/ReviewController.test.js @@ -0,0 +1,154 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') +const sinon = require('sinon') + +const Database = require('../../../server/Database') +const ApiRouter = require('../../../server/routers/ApiRouter') +const ReviewController = require('../../../server/controllers/ReviewController') +const ApiCacheManager = require('../../../server/managers/ApiCacheManager') +const Auth = require('../../../server/Auth') +const Logger = require('../../../server/Logger') + +describe('ReviewController', () => { + /** @type {ApiRouter} */ + let apiRouter + + beforeEach(async () => { + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + + Database.serverSettings = { + enableReviews: true + } + + apiRouter = new ApiRouter({ + auth: new Auth(), + apiCacheManager: new ApiCacheManager() + }) + + sinon.stub(Logger, 'info') + sinon.stub(Logger, 'error') + }) + + afterEach(async () => { + sinon.restore() + await Database.sequelize.close() + }) + + async function createTestLibraryItem() { + const library = await Database.libraryModel.create({ name: 'Test', mediaType: 'book' }) + const book = await Database.bookModel.create({ title: 'Test Book' }) + return await Database.libraryItemModel.create({ mediaId: book.id, mediaType: 'book', libraryId: library.id }) + } + + describe('createUpdate', () => { + it('should create a new review', async () => { + const user = await Database.userModel.create({ username: 'testuser', type: 'root' }) + const libraryItem = await createTestLibraryItem() + + const fakeReq = { + params: { id: libraryItem.id }, + body: { rating: 5, reviewText: 'Great book!' }, + user + } + const fakeRes = { + json: sinon.spy(), + status: sinon.stub().returns({ send: sinon.spy() }) + } + + await ReviewController.createUpdate(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const review = fakeRes.json.firstCall.args[0] + expect(review.rating).to.equal(5) + expect(review.reviewText).to.equal('Great book!') + expect(review.userId).to.equal(user.id) + }) + + it('should update an existing review', async () => { + const user = await Database.userModel.create({ username: 'testuser', type: 'root' }) + const libraryItem = await createTestLibraryItem() + + await Database.reviewModel.create({ userId: user.id, libraryItemId: libraryItem.id, rating: 3 }) + + const fakeReq = { + params: { id: libraryItem.id }, + body: { rating: 4, reviewText: 'Actually better' }, + user + } + const fakeRes = { + json: sinon.spy() + } + + await ReviewController.createUpdate(fakeReq, fakeRes) + + const review = fakeRes.json.firstCall.args[0] + expect(review.rating).to.equal(4) + expect(review.reviewText).to.equal('Actually better') + }) + + it('should return 400 for invalid rating', async () => { + const fakeReq = { + params: { id: 'some-id' }, + body: { rating: 6 }, + user: { id: 'u1' } + } + const fakeRes = { + status: sinon.stub().returns({ send: sinon.spy() }) + } + + await ReviewController.createUpdate(fakeReq, fakeRes) + expect(fakeRes.status.calledWith(400)).to.be.true + }) + }) + + describe('findAllForItem', () => { + it('should return all reviews for an item', async () => { + const user1 = await Database.userModel.create({ username: 'u1', type: 'user' }) + const user2 = await Database.userModel.create({ username: 'u2', type: 'user' }) + const libraryItem = await createTestLibraryItem() + + await Database.reviewModel.create({ userId: user1.id, libraryItemId: libraryItem.id, rating: 5 }) + await Database.reviewModel.create({ userId: user2.id, libraryItemId: libraryItem.id, rating: 4 }) + + const fakeReq = { params: { id: libraryItem.id } } + const fakeRes = { json: sinon.spy() } + + await ReviewController.findAllForItem(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + expect(fakeRes.json.firstCall.args[0]).to.have.lengthOf(2) + }) + }) + + describe('delete', () => { + it('should delete a review', async () => { + const user = await Database.userModel.create({ username: 'u1', type: 'user' }) + const libraryItem = await createTestLibraryItem() + await Database.reviewModel.create({ userId: user.id, libraryItemId: libraryItem.id, rating: 5 }) + + const fakeReq = { params: { id: libraryItem.id }, user } + const fakeRes = { sendStatus: sinon.spy() } + + await ReviewController.delete(fakeReq, fakeRes) + expect(fakeRes.sendStatus.calledWith(200)).to.be.true + + const count = await Database.reviewModel.count() + expect(count).to.equal(0) + }) + }) + + describe('middleware', () => { + it('should block when enableReviews is false', async () => { + Database.serverSettings.enableReviews = false + const fakeReq = {} + const fakeRes = { status: sinon.stub().returns({ send: sinon.spy() }) } + const next = sinon.spy() + + await ReviewController.middleware(fakeReq, fakeRes, next) + expect(fakeRes.status.calledWith(403)).to.be.true + expect(next.called).to.be.false + }) + }) +}) diff --git a/test/server/models/Review.test.js b/test/server/models/Review.test.js new file mode 100644 index 000000000..bce1925ad --- /dev/null +++ b/test/server/models/Review.test.js @@ -0,0 +1,97 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') +const Database = require('../../../server/Database') + +describe('Review Model', () => { + beforeEach(async () => { + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + await Database.buildModels() + }) + + afterEach(async () => { + await Database.sequelize.close() + }) + + async function createTestLibraryItem(id = 'li1') { + const library = await Database.libraryModel.create({ name: 'Test', mediaType: 'book' }) + const book = await Database.bookModel.create({ title: 'Test Book' }) + return await Database.libraryItemModel.create({ id, mediaId: book.id, mediaType: 'book', libraryId: library.id }) + } + + it('should validate rating between 1 and 5', async () => { + const user = await Database.userModel.create({ username: 'u1' }) + const item = await createTestLibraryItem('li1') + const item2 = await createTestLibraryItem('li2') + const item3 = await createTestLibraryItem('li3') + + // Valid + await Database.reviewModel.create({ userId: user.id, libraryItemId: item.id, rating: 5 }) + + // Invalid - too high + let error + try { + await Database.reviewModel.create({ userId: user.id, libraryItemId: item2.id, rating: 6 }) + } catch (err) { + error = err + } + expect(error).to.exist + expect(error.name).to.equal('SequelizeValidationError') + + // Invalid - too low + error = null + try { + await Database.reviewModel.create({ userId: user.id, libraryItemId: item3.id, rating: 0 }) + } catch (err) { + error = err + } + expect(error).to.exist + expect(error.name).to.equal('SequelizeValidationError') + }) + + it('should enforce unique constraint on userId and libraryItemId', async () => { + const user = await Database.userModel.create({ username: 'u1' }) + const item = await createTestLibraryItem('li1') + + await Database.reviewModel.create({ userId: user.id, libraryItemId: item.id, rating: 5 }) + + let error + try { + await Database.reviewModel.create({ userId: user.id, libraryItemId: item.id, rating: 4 }) + } catch (err) { + error = err + } + expect(error).to.exist + expect(error.name).to.equal('SequelizeUniqueConstraintError') + }) + + it('should cascade delete when user is deleted', async () => { + const user = await Database.userModel.create({ username: 'u1' }) + const item = await createTestLibraryItem('li1') + await Database.reviewModel.create({ userId: user.id, libraryItemId: item.id, rating: 5 }) + + await user.destroy() + const count = await Database.reviewModel.count() + expect(count).to.equal(0) + }) + + it('should return correct format in toOldJSON', async () => { + const user = await Database.userModel.create({ username: 'testuser' }) + const item = await createTestLibraryItem('li1') + const review = await Database.reviewModel.create({ + userId: user.id, + libraryItemId: item.id, + rating: 4, + reviewText: 'Nice' + }) + + // Manually associate user for the test + review.user = user + + const json = review.toOldJSON() + expect(json.rating).to.equal(4) + expect(json.reviewText).to.equal('Nice') + expect(json.user.username).to.equal('testuser') + expect(json.createdAt).to.be.a('number') + expect(json.updatedAt).to.be.a('number') + }) +})