diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index f74134041..39056083b 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -36,6 +36,18 @@ + + + + diff --git a/client/components/recommendations/RecommendButton.vue b/client/components/recommendations/RecommendButton.vue new file mode 100644 index 000000000..17520fea1 --- /dev/null +++ b/client/components/recommendations/RecommendButton.vue @@ -0,0 +1,130 @@ + + + diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 1d8f0f20b..c6bd0f079 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -120,6 +120,8 @@ + +
@@ -137,6 +139,8 @@ + +
@@ -147,7 +151,9 @@ diff --git a/client/pages/me/recommendations/sent.vue b/client/pages/me/recommendations/sent.vue new file mode 100644 index 000000000..de4ca7375 --- /dev/null +++ b/client/pages/me/recommendations/sent.vue @@ -0,0 +1,82 @@ + + + diff --git a/server/Database.js b/server/Database.js index 213c2c61b..576a6c736 100644 --- a/server/Database.js +++ b/server/Database.js @@ -162,6 +162,13 @@ class Database { return this.models.device } + get recommendationTagModel() { + return this.models.recommendationTag + } + get bookRecommendationModel() { + return this.models.bookRecommendation + } + /** * Check if db file exists * @returns {boolean} @@ -345,6 +352,13 @@ class Database { require('./models/Setting').init(this.sequelize) require('./models/CustomMetadataProvider').init(this.sequelize) require('./models/MediaItemShare').init(this.sequelize) + require('./models/RecommendationTag').init(this.sequelize) + require('./models/BookRecommendation').init(this.sequelize) + // One association pass BEFORE sync + const models = this.sequelize.models + Object.values(models).forEach((m) => { + if (typeof m?.associate === 'function') m.associate(models) + }) return this.sequelize.sync({ force, alter: false }) } diff --git a/server/Server.js b/server/Server.js index 457aa61ab..359843283 100644 --- a/server/Server.js +++ b/server/Server.js @@ -38,6 +38,8 @@ const ApiCacheManager = require('./managers/ApiCacheManager') const BinaryManager = require('./managers/BinaryManager') const ShareManager = require('./managers/ShareManager') const LibraryScanner = require('./scanner/LibraryScanner') +const buildRecommendationsRouter = require('./routers/recommendations') +const RecommendationManager = require('./managers/RecommendationManager') //Import the main Passport and Express-Session library const passport = require('passport') @@ -55,6 +57,8 @@ class Server { global.RouterBasePath = ROUTER_BASE_PATH global.XAccel = process.env.USE_X_ACCEL global.AllowCors = process.env.ALLOW_CORS === '1' + global.ServerSettings = global.ServerSettings || {} + global.ServerSettings.recommendationsEnabled = String(process.env.RECOMMENDATIONS_ENABLED) === 'true' if (process.env.EXP_PROXY_SUPPORT === '1') { // https://github.com/advplyr/audiobookshelf/pull/3754 @@ -321,6 +325,15 @@ class Server { // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) + router.use( + '/api/recommendations', + this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), + buildRecommendationsRouter({ + manager: RecommendationManager, + featureFlag: () => String(process.env.RECOMMENDATIONS_ENABLED) === 'true' + }) + ) + // RSS Feed temp route router.get('/feed/:slug', (req, res) => { Logger.info(`[Server] Requesting rss feed ${req.params.slug}`) diff --git a/server/controllers/recommendationsController.js b/server/controllers/recommendationsController.js new file mode 100644 index 000000000..31e153826 --- /dev/null +++ b/server/controllers/recommendationsController.js @@ -0,0 +1,100 @@ +'use strict' + +module.exports = (injectedManager) => { + const RecommendationManager = injectedManager || require('../managers/RecommendationManager') + + return { + async listTags(req, res) { + await RecommendationManager.ensureTagsSeeded() + const tags = await RecommendationManager.getActiveTags() + res.json(tags) + }, + + async create(req, res) { + const userId = req.user?.id + if (!userId) return res.status(401).send('Unauthorized') + + const { bookId, tagId, tagSlug, recipientUserId, note, visibility } = req.body || {} + if (!bookId) return res.status(400).json({ message: 'bookId is required' }) + if (!tagId && !tagSlug) return res.status(400).json({ message: 'tagId or tagSlug is required' }) + + const { data, error } = await RecommendationManager.createRecommendation({ + userId, + bookId, + tagId, + tagSlug, + recipientUserId, + note, + visibility + }) + if (error) return res.status(error.status || 400).json({ message: error.message || 'Invalid request' }) + res.status(201).json(data) + }, + + async byBook(req, res) { + const rows = await RecommendationManager.getBookFeed({ + bookId: req.params.bookId, + me: req.user || null, + limit: req.query.limit, + offset: req.query.offset + }) + res.json(rows) + }, + + async inbox(req, res) { + const userId = req.user?.id + if (!userId) return res.status(401).send('Unauthorized') + + const rows = await RecommendationManager.getInbox({ + userId, + bookId: req.query.bookId, + tagSlug: req.query.tagSlug, + limit: req.query.limit, + offset: req.query.offset + }) + res.json(rows) + }, + + async sent(req, res) { + const userId = req.user?.id + if (!userId) return res.status(401).send('Unauthorized') + + const rows = await RecommendationManager.getSent({ + userId, + limit: req.query.limit, + offset: req.query.offset + }) + res.json(rows) + }, + + async update(req, res) { + const userId = req.user?.id + if (!userId) return res.status(401).send('Unauthorized') + + const { data, error } = await RecommendationManager.updateRecommendation({ + id: req.params.id, + userId, + note: req.body?.note, + visibility: req.body?.visibility, + tagId: req.body?.tagId + }) + if (error) { + if (error.message) return res.status(error.status || 400).json({ message: error.message }) + return res.sendStatus(error.status || 400) + } + res.json(data) + }, + + async destroy(req, res) { + const userId = req.user?.id + if (!userId) return res.status(401).send('Unauthorized') + + const { error } = await RecommendationManager.deleteRecommendation({ + id: req.params.id, + userId + }) + if (error) return res.sendStatus(error.status || 400) + res.sendStatus(204) + } + } +} diff --git a/server/managers/RecommendationManager.js b/server/managers/RecommendationManager.js new file mode 100644 index 000000000..0be609c91 --- /dev/null +++ b/server/managers/RecommendationManager.js @@ -0,0 +1,200 @@ +'use strict' + +const { Op } = require('sequelize') +const Database = require('../Database') + +const DEFAULT_TAGS = [ + { slug: 'fiction', label: 'Fiction' }, + { slug: 'non-fiction', label: 'Non-Fiction' }, + { slug: 'mystery', label: 'Mystery' }, + { slug: 'romance', label: 'Romance' }, + { slug: 'science-fiction', label: 'Science Fiction' }, + { slug: 'fantasy', label: 'Fantasy' }, + { slug: 'biography', label: 'Biography' }, + { slug: 'history', label: 'History' }, + { slug: 'self-help', label: 'Self-Help' }, + { slug: 'young-adult', label: 'Young Adult' } +] + +const BASE_INCLUDE = [ + { association: 'tag', attributes: ['id', 'slug', 'label'] }, + { association: 'recommender', attributes: ['id', 'username'] }, + { association: 'recipient', attributes: ['id', 'username'] }, + { + association: 'item', + attributes: ['id'], + include: [ + { association: 'book', attributes: ['id', 'title'] }, + { association: 'podcast', attributes: ['id', 'title'] } + ] + } +] + +const sanitizeNote = (n) => { + if (!n) return null + n = String(n).trim() + return n.length > 1000 ? n.slice(0, 1000) : n +} + +const clampLimit = (v, dflt = 50, max = 100) => { + const n = Number(v) + if (!Number.isFinite(n) || n <= 0) return dflt + return Math.min(n, max) +} + +const toOffset = (v) => { + const n = Number(v) + return Number.isFinite(n) && n >= 0 ? n : 0 +} + +const addBookTitle = (rowsOrInstance) => { + const toJSON = (r) => (typeof r?.toJSON === 'function' ? r.toJSON() : r || {}) + const shape = (j) => ({ ...j, bookTitle: j?.item?.book?.title || j?.item?.podcast?.title || null }) + if (Array.isArray(rowsOrInstance)) return rowsOrInstance.map((r) => shape(toJSON(r))) + if (rowsOrInstance) return shape(toJSON(rowsOrInstance)) + return rowsOrInstance +} + +const RecommendationManager = { + async ensureTagsSeeded() { + const Tag = Database.recommendationTagModel + if ((await Tag.count()) > 0) return + const now = new Date() + await Tag.bulkCreate(DEFAULT_TAGS.map((t) => ({ ...t, isActive: true, createdAt: now, updatedAt: now }))) + }, + + async getActiveTags() { + const Tag = Database.recommendationTagModel + const tags = await Tag.findAll({ + where: { isActive: true }, + order: [['label', 'ASC']], + attributes: ['id', 'slug', 'label'] + }) + return tags + }, + + async resolveTag({ tagId, tagSlug }) { + const Tag = Database.recommendationTagModel + if (tagId) { + const tag = await Tag.findByPk(tagId) + return tag && tag.isActive ? tag : null + } + if (tagSlug) { + const tag = await Tag.findOne({ where: { slug: String(tagSlug).toLowerCase(), isActive: true } }) + return tag || null + } + return null + }, + + async resolveRecipient(recipientUserId) { + if (!recipientUserId) return null + const user = await Database.userModel.findByPk(recipientUserId) + return user || null + }, + + async createRecommendation({ userId, bookId, tagId, tagSlug, recipientUserId, note, visibility }) { + const tag = await this.resolveTag({ tagId, tagSlug }) + if (!tag) return { error: { status: 400, message: 'Invalid tag' } } + + const recip = await this.resolveRecipient(recipientUserId) + if (recipientUserId && !recip) return { error: { status: 400, message: 'Recipient not found' } } + + const created = await Database.bookRecommendationModel.create({ + bookId: String(bookId), + tagId: tag.id, + recommenderUserId: userId, + recipientUserId: recip ? recip.id : null, + note: sanitizeNote(note), + visibility: visibility === 'recipient-only' ? 'recipient-only' : 'public' + }) + + const full = await Database.bookRecommendationModel.findByPk(created.id, { include: BASE_INCLUDE }) + return { data: addBookTitle(full) } + }, + + async getBookFeed({ bookId, me /* may be null */, limit, offset }) { + const where = { + bookId: String(bookId), + [Op.or]: [ + { visibility: 'public' }, + me + ? { + [Op.and]: [{ visibility: 'recipient-only' }, { [Op.or]: [{ recommenderUserId: me.id }, { recipientUserId: me.id }] }] + } + : { id: null } + ] + } + + const rows = await Database.bookRecommendationModel.findAll({ + where, + order: [['createdAt', 'DESC']], + include: BASE_INCLUDE, + limit: clampLimit(limit), + offset: toOffset(offset) + }) + return addBookTitle(rows) + }, + + async getInbox({ userId, bookId, tagSlug, limit, offset }) { + const where = { + [Op.or]: [{ recipientUserId: userId }, { [Op.and]: [{ visibility: 'public' }, { recommenderUserId: { [Op.ne]: userId } }] }] + } + if (bookId) where.bookId = String(bookId) + if (tagSlug) { + const tag = await Database.recommendationTagModel.findOne({ + where: { slug: String(tagSlug).toLowerCase(), isActive: true } + }) + if (!tag) return [] + where.tagId = tag.id + } + + const rows = await Database.bookRecommendationModel.findAll({ + where, + order: [['createdAt', 'DESC']], + include: BASE_INCLUDE, + limit: clampLimit(limit), + offset: toOffset(offset) + }) + return addBookTitle(rows) + }, + + async getSent({ userId, limit, offset }) { + const rows = await Database.bookRecommendationModel.findAll({ + where: { recommenderUserId: userId }, + order: [['createdAt', 'DESC']], + include: BASE_INCLUDE, + limit: clampLimit(limit), + offset: toOffset(offset) + }) + return addBookTitle(rows) + }, + + async updateRecommendation({ id, userId, note, visibility, tagId }) { + const rec = await Database.bookRecommendationModel.findByPk(id) + if (!rec) return { error: { status: 404 } } + if (rec.recommenderUserId !== userId) return { error: { status: 403 } } + + const update = {} + if (typeof note !== 'undefined') update.note = sanitizeNote(note) + if (['public', 'recipient-only'].includes(visibility)) update.visibility = visibility + if (tagId) { + const tag = await Database.recommendationTagModel.findByPk(tagId) + if (!tag || tag.isActive === false) return { error: { status: 400, message: 'Invalid tag' } } + update.tagId = tag.id + } + + await rec.update(update) + const full = await Database.bookRecommendationModel.findByPk(rec.id, { include: BASE_INCLUDE }) + return { data: addBookTitle(full) } + }, + + async deleteRecommendation({ id, userId }) { + const rec = await Database.bookRecommendationModel.findByPk(id) + if (!rec) return { error: { status: 404 } } + if (rec.recommenderUserId !== userId) return { error: { status: 403 } } + await rec.destroy() + return { ok: true } + } +} + +module.exports = RecommendationManager diff --git a/server/models/BookRecommendation.js b/server/models/BookRecommendation.js new file mode 100644 index 000000000..d50db571c --- /dev/null +++ b/server/models/BookRecommendation.js @@ -0,0 +1,53 @@ +'use strict' + +module.exports.init = (sequelize) => { + const { DataTypes } = require('sequelize') + + const BookRecommendation = sequelize.define( + 'bookRecommendation', + { + bookId: { type: DataTypes.STRING, allowNull: false }, + + recommenderUserId: { type: DataTypes.STRING, allowNull: false }, + + recipientUserId: { type: DataTypes.STRING, allowNull: true }, + + tagId: { type: DataTypes.INTEGER, allowNull: false }, + + note: { type: DataTypes.TEXT, allowNull: true, validate: { len: [0, 1000] } }, + + visibility: { + type: DataTypes.ENUM('public', 'recipient-only'), + allowNull: false, + defaultValue: 'public' + } + }, + { + tableName: 'book_recommendations', + indexes: [{ fields: ['bookId'] }, { fields: ['recommenderUserId'] }, { fields: ['recipientUserId'] }, { fields: ['tagId'] }, { fields: ['visibility'] }] + } + ) + + BookRecommendation.associate = (models) => { + const { user: User, recommendationTag: RecommendationTag } = models + + if (User) { + BookRecommendation.belongsTo(User, { as: 'recommender', foreignKey: 'recommenderUserId' }) + BookRecommendation.belongsTo(User, { as: 'recipient', foreignKey: 'recipientUserId' }) + } + + if (RecommendationTag) { + BookRecommendation.belongsTo(RecommendationTag, { as: 'tag', foreignKey: 'tagId' }) + } + + if (models.libraryItem) { + BookRecommendation.belongsTo(models.libraryItem, { + as: 'item', + foreignKey: 'bookId', + targetKey: 'id' + }) + } + } + + return BookRecommendation +} diff --git a/server/models/RecommendationTag.js b/server/models/RecommendationTag.js new file mode 100644 index 000000000..c8a361a79 --- /dev/null +++ b/server/models/RecommendationTag.js @@ -0,0 +1,20 @@ +'use strict' +module.exports.init = (sequelize) => { + const { DataTypes } = require('sequelize') + const RecommendationTag = sequelize.define( + 'recommendationTag', + { + slug: { type: DataTypes.STRING, allowNull: false, unique: true, validate: { is: /^[a-z0-9-]+$/ } }, + label: { type: DataTypes.STRING, allowNull: false }, + isActive: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true } + }, + { tableName: 'recommendation_tags' } + ) + + RecommendationTag.associate = (models) => { + if (models.bookRecommendation) { + RecommendationTag.hasMany(models.bookRecommendation, { as: 'recommendations', foreignKey: 'tagId' }) + } + } + return RecommendationTag +} diff --git a/server/routers/recommendations.js b/server/routers/recommendations.js new file mode 100644 index 000000000..b192fc30a --- /dev/null +++ b/server/routers/recommendations.js @@ -0,0 +1,31 @@ +'use strict' + +const express = require('express') + +const requireAuth = (req, res, next) => { + if (req.user && req.user.id) return next() + return res.status(401).send('Unauthorized') +} + +module.exports = ({ manager, featureFlag } = {}) => { + const router = express.Router() + + const isEnabled = typeof featureFlag === 'function' ? featureFlag : () => String(process.env.RECOMMENDATIONS_ENABLED) === 'true' + + router.use((req, res, next) => { + if (!isEnabled()) return res.status(501).json({ message: 'Recommendations disabled' }) + next() + }) + + const Controller = require('../controllers/recommendationsController')(manager) + + router.get('/tags', Controller.listTags) + router.post('/', requireAuth, Controller.create) + router.get('/book/:bookId', Controller.byBook) + router.get('/inbox', requireAuth, Controller.inbox) + router.get('/sent', requireAuth, Controller.sent) + router.put('/:id', requireAuth, Controller.update) + router.delete('/:id', requireAuth, Controller.destroy) + + return router +}