mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-08 18:31:43 +00:00
Commit backend changes for recommandation feature
This commit is contained in:
parent
b8b7a272f9
commit
f60449ed6a
3 changed files with 310 additions and 0 deletions
77
server/models/BookRecommendation.js
Normal file
77
server/models/BookRecommendation.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
20
server/models/RecommendationTag.js
Normal file
20
server/models/RecommendationTag.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
213
server/routers/recommendations.js
Normal file
213
server/routers/recommendations.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue