From f60449ed6ab54c9ae100b6b5c75ae7192fb48970 Mon Sep 17 00:00:00 2001 From: ra939 Date: Sun, 5 Oct 2025 08:53:25 +0000 Subject: [PATCH] Commit backend changes for recommandation feature --- server/models/BookRecommendation.js | 77 ++++++++++ server/models/RecommendationTag.js | 20 +++ server/routers/recommendations.js | 213 ++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 server/models/BookRecommendation.js create mode 100644 server/models/RecommendationTag.js create mode 100644 server/routers/recommendations.js diff --git a/server/models/BookRecommendation.js b/server/models/BookRecommendation.js new file mode 100644 index 000000000..dc6df3fa8 --- /dev/null +++ b/server/models/BookRecommendation.js @@ -0,0 +1,77 @@ +// server/models/BookRecommendation.js +'use strict' + +module.exports.init = (sequelize) => { + const { DataTypes } = require('sequelize') + + const BookRecommendation = sequelize.define( + 'bookRecommendation', + { + // LibraryItem id (UUID string) + bookId: { type: DataTypes.STRING, allowNull: false }, + + // -> users.id (UUID string) — the recommender (sender) + recommenderUserId: { type: DataTypes.STRING, allowNull: false }, + + // -> users.id (UUID string), nullable — the recipient (if any) + recipientUserId: { type: DataTypes.STRING, allowNull: true }, + + // -> recommendation_tags.id (INTEGER) + 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', + // helpful indexes for common queries + indexes: [ + { fields: ['bookId'] }, + { fields: ['recommenderUserId'] }, + { fields: ['recipientUserId'] }, + { fields: ['tagId'] }, + { fields: ['visibility'] } + ] + } + ) + + BookRecommendation.associate = (models) => { + const { user: User, recommendationTag: RecommendationTag } = models + + if (User) { + // Canonical aliases + BookRecommendation.belongsTo(User, { + as: 'recommender', + foreignKey: 'recommenderUserId' + }) + BookRecommendation.belongsTo(User, { + as: 'recipient', + foreignKey: 'recipientUserId' + }) + // (removed the duplicate alias "user") + } + + if (RecommendationTag) { + BookRecommendation.belongsTo(RecommendationTag, { + as: 'tag', + foreignKey: 'tagId' + }) + } + + // Link to libraryItem so router can include media title + 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..70db4cfeb --- /dev/null +++ b/server/routers/recommendations.js @@ -0,0 +1,213 @@ +// server/routers/recommendations.js +// Community recommendations API (minimal) +const express = require('express') +const { Op } = require('sequelize') +const Database = require('../Database') +const sessionOrJwt = require('../middleware/sessionOrJwt') +const requireAuthFactory = require('../middleware/requireAuth') + +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' } +] + +function enabled(res) { + if (String(process.env.RECOMMENDATIONS_ENABLED) !== 'true') { + res.status(501).json({ message: 'Recommendations disabled' }) + return false + } + return true +} +const sanitizeNote = (n) => { if (!n) return null; n = String(n).trim(); return n.length > 1000 ? n.slice(0, 1000) : n } + +// Common include that also brings back the media title +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'] } + ] + } +] + +// helper to flatten title +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 +} + +module.exports = (auth) => { + const router = express.Router() + const requireAuth = requireAuthFactory(auth) + + router.use(sessionOrJwt(auth)) + + // GET /tags (public) + router.get('/tags', async (req, res) => { + if (!enabled(res)) return + const Tag = Database.recommendationTagModel + if ((await Tag.count()) === 0) { + const now = new Date() + await Tag.bulkCreate(DEFAULT_TAGS.map((t) => ({ ...t, isActive: true, createdAt: now, updatedAt: now }))) + } + const tags = await Tag.findAll({ + where: { isActive: true }, + order: [['label', 'ASC']], + attributes: ['id', 'slug', 'label'] + }) + res.json(tags) + }) + + // POST / (create; auth) + router.post('/', requireAuth, async (req, res) => { + if (!enabled(res)) return + const { bookId, tagSlug, tagId, recipientUserId, note, visibility } = req.body || {} + if (!bookId) return res.status(400).json({ message: 'bookId is required' }) + if (!tagSlug && !tagId) return res.status(400).json({ message: 'tagSlug or tagId is required' }) + + const Tag = Database.recommendationTagModel + const tag = tagId + ? await Tag.findByPk(tagId) + : await Tag.findOne({ where: { slug: String(tagSlug).toLowerCase() } }) + if (!tag || tag.isActive === false) return res.status(400).json({ message: 'Invalid tag' }) + + let recip = null + if (recipientUserId) { + recip = await Database.userModel.findByPk(recipientUserId) + if (!recip) return res.status(400).json({ message: 'Recipient not found' }) + } + + const created = await Database.bookRecommendationModel.create({ + bookId: String(bookId), + tagId: tag.id, + recommenderUserId: req.user.id, + 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 }) + res.status(201).json(addBookTitle(full)) + }) + + // GET /book/:bookId + router.get('/book/:bookId', async (req, res) => { + if (!enabled(res)) return + const me = req.user || null + const where = { + bookId: String(req.params.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: Math.min(Number(req.query.limit) || 50, 100), + offset: Number(req.query.offset) || 0 + }) + res.json(addBookTitle(rows)) + }) + + // GET /inbox (auth) + router.get('/inbox', requireAuth, async (req, res) => { + if (!enabled(res)) return + const me = req.user.id + const { bookId, tagSlug } = req.query + + const where = { + [Op.or]: [ + { recipientUserId: me }, + { [Op.and]: [{ visibility: 'public' }, { recommenderUserId: { [Op.ne]: me } }] } + ] + } + if (bookId) where.bookId = String(bookId) + if (tagSlug) { + const tag = await Database.recommendationTagModel.findOne({ + where: { slug: String(tagSlug).toLowerCase() } + }) + if (!tag) return res.json([]) + where.tagId = tag.id + } + + const rows = await Database.bookRecommendationModel.findAll({ + where, + order: [['createdAt', 'DESC']], + include: BASE_INCLUDE, + limit: Math.min(Number(req.query.limit) || 50, 100), + offset: Number(req.query.offset) || 0 + }) + res.json(addBookTitle(rows)) + }) + + // GET /sent (auth) + router.get('/sent', requireAuth, async (req, res) => { + if (!enabled(res)) return + const rows = await Database.bookRecommendationModel.findAll({ + where: { recommenderUserId: req.user.id }, + order: [['createdAt', 'DESC']], + include: BASE_INCLUDE, + limit: Math.min(Number(req.query.limit) || 50, 100), + offset: Number(req.query.offset) || 0 + }) + res.json(addBookTitle(rows)) + }) + + // PUT /:id (auth + owner) + router.put('/:id', requireAuth, async (req, res) => { + if (!enabled(res)) return + const rec = await Database.bookRecommendationModel.findByPk(req.params.id) + if (!rec) return res.sendStatus(404) + if (rec.recommenderUserId !== req.user.id) return res.sendStatus(403) + + const update = {} + if (typeof req.body.note !== 'undefined') update.note = sanitizeNote(req.body.note) + if (['public', 'recipient-only'].includes(req.body.visibility)) update.visibility = req.body.visibility + if (req.body.tagId) { + const tag = await Database.recommendationTagModel.findByPk(req.body.tagId) + if (!tag || tag.isActive === false) return res.status(400).json({ message: 'Invalid tag' }) + update.tagId = tag.id + } + await rec.update(update) + const full = await Database.bookRecommendationModel.findByPk(rec.id, { include: BASE_INCLUDE }) + res.json(addBookTitle(full)) + }) + + // DELETE /:id (auth + owner) + router.delete('/:id', requireAuth, async (req, res) => { + if (!enabled(res)) return + const rec = await Database.bookRecommendationModel.findByPk(req.params.id) + if (!rec) return res.sendStatus(404) + if (rec.recommenderUserId !== req.user.id) return res.sendStatus(403) + await rec.destroy() + res.sendStatus(204) + }) + + return router +}