diff --git a/client/components/recommendations/RecommendButton.vue b/client/components/recommendations/RecommendButton.vue index 17520fea1..4e5400c67 100644 --- a/client/components/recommendations/RecommendButton.vue +++ b/client/components/recommendations/RecommendButton.vue @@ -27,11 +27,17 @@ +
+ + +

Enter the user’s username. We’ll resolve it on the server.

+
+
- {{ error }} @@ -56,7 +62,7 @@ export default { submitting: false, ok: false, error: '', - form: { tagId: '', visibility: 'public', note: '' } + form: { tagId: '', visibility: 'public', recipient: '', note: '' } }), mounted() { const flag = this.$store.getters['getServerSetting']?.('recommendationsEnabled') @@ -104,6 +110,10 @@ export default { }, async submit() { if (!this.form.tagId) return + if (this.form.visibility === 'recipient-only' && !this.form.recipient) { + this.error = 'Recipient is required' + return + } this.submitting = true this.error = '' this.ok = false @@ -112,7 +122,8 @@ export default { bookId: this.bookId, tagId: this.form.tagId, note: this.form.note || '', - visibility: this.form.visibility + visibility: this.form.visibility, + recipientUsername: this.form.visibility === 'recipient-only' ? this.form.recipient : null } const created = await this.$axios.$post('/api/recommendations', payload) this.$emit('recommended', created) diff --git a/server/Database.js b/server/Database.js index 00bdc97e5..9b1540bf2 100644 --- a/server/Database.js +++ b/server/Database.js @@ -162,7 +162,6 @@ class Database { return this.models.device } -<<<<<<< HEAD get recommendationTagModel() { return this.models.recommendationTag } @@ -170,8 +169,6 @@ class Database { return this.models.bookRecommendation } -======= ->>>>>>> parent of ae4bd94b (add database changes for recommandation sent and inbox) /** * Check if db file exists * @returns {boolean} @@ -355,7 +352,7 @@ class Database { require('./models/Setting').init(this.sequelize) require('./models/CustomMetadataProvider').init(this.sequelize) require('./models/MediaItemShare').init(this.sequelize) -<<<<<<< HEAD + require('./models/RecommendationTag').init(this.sequelize) require('./models/BookRecommendation').init(this.sequelize) // One association pass BEFORE sync @@ -363,8 +360,6 @@ class Database { Object.values(models).forEach((m) => { if (typeof m?.associate === 'function') m.associate(models) }) -======= ->>>>>>> parent of ae4bd94b (add database changes for recommandation sent and inbox) return this.sequelize.sync({ force, alter: false }) } diff --git a/server/Server.js b/server/Server.js index 359843283..ce3e4ce22 100644 --- a/server/Server.js +++ b/server/Server.js @@ -38,7 +38,6 @@ 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 @@ -325,15 +324,6 @@ 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 index 31e153826..456f41808 100644 --- a/server/controllers/recommendationsController.js +++ b/server/controllers/recommendationsController.js @@ -14,7 +14,7 @@ module.exports = (injectedManager) => { const userId = req.user?.id if (!userId) return res.status(401).send('Unauthorized') - const { bookId, tagId, tagSlug, recipientUserId, note, visibility } = req.body || {} + const { bookId, tagId, tagSlug, recipientUserId, recipientUsername, 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' }) @@ -24,6 +24,7 @@ module.exports = (injectedManager) => { tagId, tagSlug, recipientUserId, + recipientUsername, note, visibility }) diff --git a/server/managers/RecommendationManager.js b/server/managers/RecommendationManager.js index 0be609c91..9ef501eae 100644 --- a/server/managers/RecommendationManager.js +++ b/server/managers/RecommendationManager.js @@ -86,19 +86,30 @@ const RecommendationManager = { return null }, - async resolveRecipient(recipientUserId) { - if (!recipientUserId) return null - const user = await Database.userModel.findByPk(recipientUserId) - return user || null + async resolveRecipient({ recipientUserId, recipientUsername }) { + const User = Database.userModel + if (recipientUserId) { + const u = await User.findByPk(recipientUserId) + return u || null + } + if (recipientUsername) { + const u = await User.findOne({ where: { username: String(recipientUsername) } }) + return u || null + } + return null }, - async createRecommendation({ userId, bookId, tagId, tagSlug, recipientUserId, note, visibility }) { + async createRecommendation({ userId, bookId, tagId, tagSlug, recipientUserId, recipientUsername, 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' } } - + if (visibility === 'recipient-only' && !recipientUserId && !recipientUsername) { + return { error: { status: 400, message: 'Recipient is required for recipient-only visibility' } } + } + const recip = await this.resolveRecipient({ recipientUserId, recipientUsername }) + if (visibility === 'recipient-only' && !recip) { + return { error: { status: 400, message: 'Recipient not found' } } + } const created = await Database.bookRecommendationModel.create({ bookId: String(bookId), tagId: tag.id, diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 6446ecc80..085b4f6e7 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -35,6 +35,8 @@ const MiscController = require('../controllers/MiscController') const ShareController = require('../controllers/ShareController') const StatsController = require('../controllers/StatsController') const ApiKeyController = require('../controllers/ApiKeyController') +const RecommendationManager = require('../managers/RecommendationManager') +const buildRecommendationsController = require('../controllers/recommendationsController') class ApiRouter { constructor(Server) { @@ -96,6 +98,31 @@ class ApiRouter { this.router.get('/libraries/:id/podcast-titles', LibraryController.middleware.bind(this), LibraryController.getPodcastTitles.bind(this)) this.router.get('/libraries/:id/download', LibraryController.middleware.bind(this), LibraryController.downloadMultiple.bind(this)) + // + // Recommendations Routes (feature-flagged) + // + const RecommendationsController = buildRecommendationsController(RecommendationManager) + const recommendationsEnabled = () => String(process.env.RECOMMENDATIONS_ENABLED) === 'true' + const recommendationsGate = (req, res, next) => { + if (!recommendationsEnabled()) return res.status(501).json({ message: 'Recommendations disabled' }) + next() + } + const requireAuth = (req, res, next) => { + if (req.user && req.user.id) return next() + return res.status(401).send('Unauthorized') + } + + // Apply the feature gate to all /recommendations paths + this.router.use('/recommendations', recommendationsGate) + + // Endpoints + this.router.get('/recommendations/tags', RecommendationsController.listTags) + this.router.post('/recommendations', requireAuth, RecommendationsController.create) + this.router.get('/recommendations/book/:bookId', RecommendationsController.byBook) + this.router.get('/recommendations/inbox', requireAuth, RecommendationsController.inbox) + this.router.get('/recommendations/sent', requireAuth, RecommendationsController.sent) + this.router.put('/recommendations/:id', requireAuth, RecommendationsController.update) + this.router.delete('/recommendations/:id', requireAuth, RecommendationsController.destroy) // // Item Routes // diff --git a/server/routers/recommendations.js b/server/routers/recommendations.js deleted file mode 100644 index b192fc30a..000000000 --- a/server/routers/recommendations.js +++ /dev/null @@ -1,31 +0,0 @@ -'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 -}