mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 18:01:42 +00:00
Fixes after review: add tests, tighten schema and errors
This commit is contained in:
parent
19497add98
commit
c6e804bcae
12 changed files with 725 additions and 0 deletions
|
|
@ -36,6 +36,18 @@
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
|
<nuxt-link to="/me/recommendations/inbox" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||||
|
<ui-tooltip text="Recommendations Inbox" direction="bottom" class="flex items-center">
|
||||||
|
<span class="material-symbols text-2xl" aria-label="Recommendations Inbox" role="button">inbox</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<nuxt-link to="/me/recommendations/sent" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||||
|
<ui-tooltip text="Recommendations Sent" direction="bottom" class="flex items-center">
|
||||||
|
<span class="material-symbols text-2xl" aria-label="Recommendations Sent" role="button">send</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
|
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
|
||||||
<span class="material-symbols text-2xl" aria-label="System Settings" role="button"></span>
|
<span class="material-symbols text-2xl" aria-label="System Settings" role="button"></span>
|
||||||
|
|
|
||||||
130
client/components/recommendations/RecommendButton.vue
Normal file
130
client/components/recommendations/RecommendButton.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="enabled">
|
||||||
|
<button class="rounded px-3 py-1 text-sm bg-primary text-white border border-primary hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/40" @click="onClick">Recommend</button>
|
||||||
|
|
||||||
|
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 bg-black/60" @click="close"></div>
|
||||||
|
|
||||||
|
<div class="relative z-10 w-full max-w-md rounded-md border border-gray-700 bg-bg p-4">
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<h3 class="text-lg font-semibold">Recommend this</h3>
|
||||||
|
<button class="ml-auto text-gray-400 hover:text-gray-200" @click="close" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tagsLoading" class="text-gray-400">Loading tags…</div>
|
||||||
|
<div v-else>
|
||||||
|
<label class="block text-sm mb-1">Tag</label>
|
||||||
|
<select v-model="form.tagId" class="w-full mb-3 rounded border px-2 py-1 bg-[#111827] text-gray-100 border-gray-600" style="color-scheme: dark">
|
||||||
|
<option disabled value="">Select a tag…</option>
|
||||||
|
<option v-for="t in tags" :key="t.id" :value="t.id" class="bg-[#111827] text-gray-100">
|
||||||
|
{{ t.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label class="block text-sm mb-1">Visibility</label>
|
||||||
|
<select v-model="form.visibility" class="w-full mb-3 rounded border px-2 py-1 bg-[#111827] text-gray-100 border-gray-600" style="color-scheme: dark">
|
||||||
|
<option value="public">public</option>
|
||||||
|
<option value="recipient-only">recipient-only</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label class="block text-sm mb-1">Note <span class="opacity-60">(optional)</span></label>
|
||||||
|
<textarea v-model="form.note" rows="3" maxlength="1000" class="w-full rounded border border-gray-600 bg-transparent px-2 py-1" placeholder="Why should someone read/listen to this?"></textarea>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center gap-2">
|
||||||
|
<button :disabled="submitting || !form.tagId" class="rounded px-3 py-1 text-sm bg-primary text-white border border-primary hover:bg-primary/90 disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-primary/40" @click="submit">
|
||||||
|
{{ submitting ? 'Posting…' : 'Post recommendation' }}
|
||||||
|
</button>
|
||||||
|
<span v-if="error" class="text-sm text-error">{{ error }}</span>
|
||||||
|
<span v-if="ok" class="text-sm text-success">Posted</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'RecommendButton',
|
||||||
|
props: { bookId: { type: String, required: true } },
|
||||||
|
data: () => ({
|
||||||
|
enabled: false,
|
||||||
|
user: null,
|
||||||
|
open: false,
|
||||||
|
tags: [],
|
||||||
|
tagsLoading: false,
|
||||||
|
submitting: false,
|
||||||
|
ok: false,
|
||||||
|
error: '',
|
||||||
|
form: { tagId: '', visibility: 'public', note: '' }
|
||||||
|
}),
|
||||||
|
mounted() {
|
||||||
|
const flag = this.$store.getters['getServerSetting']?.('recommendationsEnabled')
|
||||||
|
this.enabled = !!flag
|
||||||
|
this.user = this.$store?.state?.user?.user || null
|
||||||
|
|
||||||
|
if (!this.enabled) {
|
||||||
|
this.$axios
|
||||||
|
.$get('/api/recommendations/tags')
|
||||||
|
.then(() => {
|
||||||
|
this.enabled = true
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.enabled = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async onClick() {
|
||||||
|
if (!this.enabled) return
|
||||||
|
|
||||||
|
if (!this.user) {
|
||||||
|
const back = encodeURIComponent(this.$route.fullPath)
|
||||||
|
return this.$router.push(`/login?redirect=${back}`)
|
||||||
|
}
|
||||||
|
this.open = true
|
||||||
|
this.ok = false
|
||||||
|
this.error = ''
|
||||||
|
if (!this.tags.length) {
|
||||||
|
this.tagsLoading = true
|
||||||
|
try {
|
||||||
|
this.tags = await this.$axios.$get('/api/recommendations/tags')
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to load tags'
|
||||||
|
} finally {
|
||||||
|
this.tagsLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.open = false
|
||||||
|
this.form = { tagId: '', visibility: 'public', note: '' }
|
||||||
|
this.error = ''
|
||||||
|
this.ok = false
|
||||||
|
},
|
||||||
|
async submit() {
|
||||||
|
if (!this.form.tagId) return
|
||||||
|
this.submitting = true
|
||||||
|
this.error = ''
|
||||||
|
this.ok = false
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
bookId: this.bookId,
|
||||||
|
tagId: this.form.tagId,
|
||||||
|
note: this.form.note || '',
|
||||||
|
visibility: this.form.visibility
|
||||||
|
}
|
||||||
|
const created = await this.$axios.$post('/api/recommendations', payload)
|
||||||
|
this.$emit('recommended', created)
|
||||||
|
this.ok = true
|
||||||
|
this.$toast?.success?.('Recommendation posted')
|
||||||
|
setTimeout(() => this.close(), 700)
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e?.response?.data?.message || 'Failed to post'
|
||||||
|
} finally {
|
||||||
|
this.submitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -120,6 +120,8 @@
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</ui-context-menu-dropdown>
|
</ui-context-menu-dropdown>
|
||||||
|
|
||||||
|
<recommend-button v-if="isBook && libraryItem?.id" :book-id="libraryItem.id" class="ml-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4 w-full">
|
<div class="my-4 w-full">
|
||||||
|
|
@ -137,6 +139,8 @@
|
||||||
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" class="mt-6" />
|
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" class="mt-6" />
|
||||||
|
|
||||||
<tables-library-files-table v-if="libraryFiles.length" :library-item="libraryItem" class="mt-6" />
|
<tables-library-files-table v-if="libraryFiles.length" :library-item="libraryItem" class="mt-6" />
|
||||||
|
|
||||||
|
<book-recommendations v-if="libraryItem?.id" :book-id="libraryItem.id" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -147,7 +151,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import RecommendButton from '@/components/recommendations/RecommendButton.vue'
|
||||||
export default {
|
export default {
|
||||||
|
components: { RecommendButton },
|
||||||
async asyncData({ store, params, app, redirect, route }) {
|
async asyncData({ store, params, app, redirect, route }) {
|
||||||
if (!store.state.user.user) {
|
if (!store.state.user.user) {
|
||||||
return redirect(`/login?redirect=${route.path}`)
|
return redirect(`/login?redirect=${route.path}`)
|
||||||
|
|
|
||||||
64
client/pages/me/recommendations/inbox.vue
Normal file
64
client/pages/me/recommendations/inbox.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<template>
|
||||||
|
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<h1 class="text-2xl font-semibold mb-4">Recommendations · Inbox</h1>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-gray-300">Loading…</div>
|
||||||
|
<div v-else-if="!items.length" class="text-gray-400">You have no incoming recommendations yet.</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div v-for="r in items" :key="r.id" class="border border-gray-700 rounded-md p-3">
|
||||||
|
<div class="text-sm text-gray-400 mb-1 flex items-center">
|
||||||
|
<div>
|
||||||
|
from <span class="text-gray-200">{{ r.recommender?.username || 'Someone' }}</span>
|
||||||
|
<span class="text-gray-500"> • {{ fmt(r.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<nuxt-link class="ml-auto text-xs px-2 py-1 rounded bg-primary text-white border border-primary hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/40" :to="itemLink(r)"> Go to </nuxt-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start">
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border border-gray-500 mr-2">
|
||||||
|
{{ r.tag?.label || 'Recommended' }}
|
||||||
|
</span>
|
||||||
|
<div class="text-sm text-gray-200">
|
||||||
|
<span>{{ r.bookTitle || `Book #${r.bookId}` }}</span>
|
||||||
|
<span v-if="r.note" class="block text-gray-300 mt-1">{{ r.note }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
middleware: 'authenticated',
|
||||||
|
async asyncData({ store, app, route, redirect }) {
|
||||||
|
if (!store.state.user.user) return redirect(`/login?redirect=${route.path}`)
|
||||||
|
try {
|
||||||
|
const items = await app.$axios.$get('/api/recommendations/inbox?include=tag,recommender,item')
|
||||||
|
return { items, loading: false }
|
||||||
|
} catch (_) {
|
||||||
|
return { items: [], loading: false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: () => ({ loading: true, items: [] }),
|
||||||
|
computed: {
|
||||||
|
dateFormat() {
|
||||||
|
return this.$store.getters['getServerSetting']('dateFormat') || 'yyyy-MM-dd'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fmt(v) {
|
||||||
|
try {
|
||||||
|
return this.$formatDate(v, this.dateFormat)
|
||||||
|
} catch {
|
||||||
|
return new Date(v).toLocaleString()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemLink(r) {
|
||||||
|
const id = r?.item?.id || r.bookId
|
||||||
|
return `/item/${id}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
82
client/pages/me/recommendations/sent.vue
Normal file
82
client/pages/me/recommendations/sent.vue
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<template>
|
||||||
|
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<h1 class="text-2xl font-semibold mb-4">Recommendations · Sent</h1>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-gray-300">Loading…</div>
|
||||||
|
<div v-else-if="!items.length" class="text-gray-400">You haven’t sent any recommendations yet.</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div v-for="r in items" :key="r.id" class="border border-gray-700 rounded-md p-3">
|
||||||
|
<div class="flex items-center text-sm text-gray-400 mb-1">
|
||||||
|
<div class="truncate">
|
||||||
|
to <span class="text-gray-200">{{ r.recipient?.username || 'Public' }}</span>
|
||||||
|
<span class="text-gray-500"> • {{ fmt(r.createdAt) }}</span>
|
||||||
|
<span
|
||||||
|
class="ml-2 text-xs px-2 py-0.5 border rounded-full"
|
||||||
|
:class="r.visibility === 'public' ? 'border-primary text-primary' : 'border-gray-500 text-gray-400'"
|
||||||
|
>{{ r.visibility }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="ml-auto text-xs px-2 py-1 rounded border border-error/60 text-error hover:bg-error/10 disabled:opacity-50"
|
||||||
|
:disabled="!!deleting[r.id]"
|
||||||
|
@click="confirmDelete(r)"
|
||||||
|
>{{ deleting[r.id] ? 'Deleting…' : 'Delete' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start">
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs border border-gray-500 mr-2">
|
||||||
|
{{ r.tag?.label || 'Recommended' }}
|
||||||
|
</span>
|
||||||
|
<div class="text-sm text-gray-200">
|
||||||
|
<span class="block">{{ r.bookTitle || `Book #${r.bookId}` }}</span>
|
||||||
|
<span v-if="r.note" class="block text-gray-300 mt-1">{{ r.note }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
middleware: 'authenticated',
|
||||||
|
async asyncData({ store, app, route, redirect }) {
|
||||||
|
if (!store.state.user.user) return redirect(`/login?redirect=${route.path}`)
|
||||||
|
try {
|
||||||
|
const items = await app.$axios.$get('/api/recommendations/sent?include=tag,recipient,item')
|
||||||
|
return { items, loading: false }
|
||||||
|
} catch {
|
||||||
|
return { items: [], loading: false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: () => ({ loading: true, items: [], deleting: {} }),
|
||||||
|
computed: {
|
||||||
|
dateFormat() {
|
||||||
|
return this.$store.getters['getServerSetting']('dateFormat') || 'yyyy-MM-dd'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fmt(v) {
|
||||||
|
try { return this.$formatDate(v, this.dateFormat) }
|
||||||
|
catch { return new Date(v).toLocaleString() }
|
||||||
|
},
|
||||||
|
async confirmDelete(r) {
|
||||||
|
if (!window.confirm('Delete this recommendation?')) return
|
||||||
|
await this.del(r.id)
|
||||||
|
},
|
||||||
|
async del(id) {
|
||||||
|
if (this.deleting[id]) return
|
||||||
|
this.$set(this.deleting, id, true)
|
||||||
|
try {
|
||||||
|
await this.$axios.$delete(`/api/recommendations/${id}`)
|
||||||
|
this.items = this.items.filter((i) => i.id !== id)
|
||||||
|
this.$toast?.success('Recommendation deleted')
|
||||||
|
} catch (e) {
|
||||||
|
this.$toast?.error(e?.response?.data?.message || 'Failed to delete recommendation')
|
||||||
|
} finally {
|
||||||
|
this.$delete(this.deleting, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -162,6 +162,13 @@ class Database {
|
||||||
return this.models.device
|
return this.models.device
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get recommendationTagModel() {
|
||||||
|
return this.models.recommendationTag
|
||||||
|
}
|
||||||
|
get bookRecommendationModel() {
|
||||||
|
return this.models.bookRecommendation
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if db file exists
|
* Check if db file exists
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
|
|
@ -345,6 +352,13 @@ class Database {
|
||||||
require('./models/Setting').init(this.sequelize)
|
require('./models/Setting').init(this.sequelize)
|
||||||
require('./models/CustomMetadataProvider').init(this.sequelize)
|
require('./models/CustomMetadataProvider').init(this.sequelize)
|
||||||
require('./models/MediaItemShare').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 })
|
return this.sequelize.sync({ force, alter: false })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ const ApiCacheManager = require('./managers/ApiCacheManager')
|
||||||
const BinaryManager = require('./managers/BinaryManager')
|
const BinaryManager = require('./managers/BinaryManager')
|
||||||
const ShareManager = require('./managers/ShareManager')
|
const ShareManager = require('./managers/ShareManager')
|
||||||
const LibraryScanner = require('./scanner/LibraryScanner')
|
const LibraryScanner = require('./scanner/LibraryScanner')
|
||||||
|
const buildRecommendationsRouter = require('./routers/recommendations')
|
||||||
|
const RecommendationManager = require('./managers/RecommendationManager')
|
||||||
|
|
||||||
//Import the main Passport and Express-Session library
|
//Import the main Passport and Express-Session library
|
||||||
const passport = require('passport')
|
const passport = require('passport')
|
||||||
|
|
@ -55,6 +57,8 @@ class Server {
|
||||||
global.RouterBasePath = ROUTER_BASE_PATH
|
global.RouterBasePath = ROUTER_BASE_PATH
|
||||||
global.XAccel = process.env.USE_X_ACCEL
|
global.XAccel = process.env.USE_X_ACCEL
|
||||||
global.AllowCors = process.env.ALLOW_CORS === '1'
|
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') {
|
if (process.env.EXP_PROXY_SUPPORT === '1') {
|
||||||
// https://github.com/advplyr/audiobookshelf/pull/3754
|
// https://github.com/advplyr/audiobookshelf/pull/3754
|
||||||
|
|
@ -321,6 +325,15 @@ class Server {
|
||||||
// Static folder
|
// Static folder
|
||||||
router.use(express.static(Path.join(global.appRoot, 'static')))
|
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
|
// RSS Feed temp route
|
||||||
router.get('/feed/:slug', (req, res) => {
|
router.get('/feed/:slug', (req, res) => {
|
||||||
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
|
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
|
||||||
|
|
|
||||||
100
server/controllers/recommendationsController.js
Normal file
100
server/controllers/recommendationsController.js
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
server/managers/RecommendationManager.js
Normal file
200
server/managers/RecommendationManager.js
Normal file
|
|
@ -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
|
||||||
53
server/models/BookRecommendation.js
Normal file
53
server/models/BookRecommendation.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
31
server/routers/recommendations.js
Normal file
31
server/routers/recommendations.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue