Added badge collection changes

This commit is contained in:
ra939 2025-09-10 06:53:19 +00:00
parent fb3834156b
commit 8c3f0a5042
64 changed files with 1664 additions and 177 deletions

View file

@ -103,6 +103,13 @@
<div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<!-- New: My Badges -->
<nuxt-link :to="`/my-badges`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isBadgesPage ? 'bg-primary/80' : 'bg-bg/60'">
<span class="material-symbols text-2xl">workspace_premium</span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonBadges || 'My Badges' }}</p>
<div v-show="isBadgesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-error/40 cursor-pointer relative" :class="showingIssues ? 'bg-error/40' : 'bg-error/20'">
<span class="material-symbols text-2xl">warning</span>
@ -191,6 +198,10 @@ export default {
isStatsPage() {
return this.$route.name === 'library-library-stats'
},
/* ✅ highlight for My Badges pages */
isBadgesPage() {
return this.$route.name === 'my-badges' || this.$route.name === 'my-badges-collectionId'
},
libraryBookshelfPage() {
return this.$route.name === 'library-library-bookshelf-id'
},

View file

@ -0,0 +1,25 @@
<template>
<div class="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
<BadgeTile
v-for="b in badges"
:key="b.id"
:badge="b"
:locked="!unlockedIdsSet.has(b.id)"
/>
</div>
</template>
<script>
import BadgeTile from './BadgeTile.vue'
export default {
components: { BadgeTile },
props: {
badges: { type: Array, required: true },
unlockedIds: { type: [Array, Set], default: () => [] }
},
computed: {
unlockedIdsSet () { return this.unlockedIds instanceof Set ? this.unlockedIds : new Set(this.unlockedIds) }
}
}
</script>

View file

@ -0,0 +1,44 @@
<template>
<div class="rounded-2xl bg-bg/60 border border-primary/20 p-3 flex flex-col items-center text-center">
<img
:src="src"
alt=""
class="w-20 h-20 object-contain select-none"
:class="locked ? 'filter grayscale opacity-60' : ''"
@error="onImgError"
draggable="false"
/>
<div class="mt-2">
<p class="text-sm font-medium">{{ badge.name }}</p>
<p v-if="badge.blurb" class="text-xxs text-gray-400 mt-0.5">{{ badge.blurb }}</p>
</div>
<div v-if="!locked" class="mt-1 text-xxs text-green-400">Unlocked </div>
</div>
</template>
<script>
export default {
props: {
badge: { type: Object, required: true },
locked: { type: Boolean, default: true }
},
data () {
return { imgError: false }
},
computed: {
src () {
if (this.imgError) {
// Simple inline SVG placeholder
return `data:image/svg+xml;utf8,` + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><rect width="100%" height="100%" fill="#1f2937"/><text x="50%" y="52%" fill="#9ca3af" font-size="12" text-anchor="middle" font-family="sans-serif">${this.badge.name}</text></svg>`
)
}
// your files are under /static/badges and named like new_reader.png etc.
return `/badges/${this.badge.id}.png`
}
},
methods: {
onImgError () { this.imgError = true }
}
}
</script>

View file

@ -0,0 +1,18 @@
<template>
<nuxt-link :to="to" class="block rounded-2xl bg-bg/60 border border-primary/30 hover:bg-primary/10 transition p-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{{ title }}</h3>
<span class="text-xs text-gray-400">{{ count }} badges</span>
</div>
</nuxt-link>
</template>
<script>
export default {
props: {
title: { type: String, required: true },
count: { type: Number, required: true },
to: { type: String, required: true }
}
}
</script>

View file

@ -0,0 +1,56 @@
<template>
<transition name="abs-pop-fade">
<div
v-if="value"
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label="Achievement unlocked"
>
<div class="absolute inset-0 bg-black/60" @click="$emit('input', false)" aria-hidden="true" />
<div class="relative w-full max-w-sm rounded-2xl shadow-2xl ring-1 ring-black/20 bg-gray-900 text-gray-50 p-6">
<button class="absolute right-2 top-2 p-1 text-gray-400 hover:text-white" @click="$emit('input', false)" aria-label="Close"></button>
<div class="w-16 h-16 rounded-xl mb-3 grid place-items-center mx-auto"
:style="{ background: 'linear-gradient(135deg,#1f2937,#0b1220)' }">
<svg viewBox="0 0 24 24" class="w-9 h-9">
<path fill="currentColor" d="M12 2l3 5 5 .5-3.7 3.1L17 16l-5-2.6L7 16l.7-5.4L4 7.5 9 7l3-5z"/>
</svg>
</div>
<h2 class="text-2xl font-bold text-center">Congratulations!</h2>
<p class="text-center text-gray-300 mt-1">
You unlocked: <span class="font-semibold">{{ badge?.name || 'Badge' }}</span>
</p>
<div class="mt-5 flex justify-center">
<button class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500" @click="$emit('input', false)">Close</button>
</div>
</div>
</div>
</transition>
</template>
<script>
export default {
name: 'AchievementPopup',
props: { value: { type: Boolean, default: false }, badge: { type: Object, default: null } },
mounted () {
if (typeof window !== 'undefined') {
window.addEventListener('achievement:unlocked', (e) => {
const [first] = e.detail?.badges || []
if (first) {
this.$emit('input', true)
this.$data.last = first
}
})
}
},
data () { return { last: null } }
}
</script>
<style>
.abs-pop-fade-enter-active, .abs-pop-fade-leave-active { transition: opacity .2s ease, transform .2s ease }
.abs-pop-fade-enter-from, .abs-pop-fade-leave-to { opacity: 0; transform: scale(0.98) }
</style>

View file

@ -6,7 +6,6 @@ const serverPaths = ['api/', 'public/', 'hls/', 'auth/', 'feed/', 'status', 'log
const proxy = Object.fromEntries(serverPaths.map((path) => [`${routerBasePath}/${path}`, { target: process.env.NODE_ENV !== 'production' ? serverHostUrl : '/' }]))
module.exports = {
// Disable server-side rendering: https://go.nuxtjs.dev/ssr-mode
ssr: false,
target: 'static',
dev: process.env.NODE_ENV !== 'production',
@ -21,12 +20,9 @@ module.exports = {
routerBasePath
},
// Global page headers: https://go.nuxtjs.dev/config-head
head: {
title: 'Audiobookshelf',
htmlAttrs: {
lang: 'en'
},
htmlAttrs: { lang: 'en' },
meta: [{ charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }, { hid: 'description', name: 'description', content: '' }, { hid: 'robots', name: 'robots', content: 'noindex' }],
script: [],
link: [
@ -35,49 +31,41 @@ module.exports = {
]
},
router: {
base: routerBasePath
},
router: { base: routerBasePath },
// Global CSS: https://go.nuxtjs.dev/config-css
css: ['@/assets/tailwind.css', '@/assets/app.css'],
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: ['@/plugins/constants.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/toast.js', '@/plugins/utils.js', '@/plugins/i18n.js'],
plugins: [
'@/plugins/constants.js',
'@/plugins/init.client.js',
'@/plugins/axios.js',
'@/plugins/toast.js',
'@/plugins/utils.js',
'@/plugins/i18n.js',
// Auto import components: https://go.nuxtjs.dev/config-components
components: true,
// Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
buildModules: [
// https://go.nuxtjs.dev/tailwindcss
'@nuxtjs/pwa'
// ✅ NEW: robust achievement hooks
'@/plugins/achievements.routes.client.js',
'@/plugins/achievements.axios.client.js',
'@/plugins/achievement-hooks.client.js'
],
// Modules: https://go.nuxtjs.dev/config-modules
components: true,
buildModules: ['@nuxtjs/pwa'],
modules: ['nuxt-socket-io', '@nuxtjs/axios', '@nuxtjs/proxy'],
proxy,
io: {
sockets: [
{
name: 'dev',
url: serverHostUrl
},
{
name: 'prod'
}
]
sockets: [{ name: 'dev', url: serverHostUrl }, { name: 'prod' }]
},
// Axios module configuration: https://go.nuxtjs.dev/config-axios
axios: {
baseURL: routerBasePath,
progress: false
},
// nuxt/pwa https://pwa.nuxtjs.org
pwa: {
icon: false,
meta: {
@ -93,44 +81,18 @@ module.exports = {
display: 'standalone',
background_color: '#232323',
icons: [
{
src: routerBasePath + '/icon.svg',
sizes: 'any'
},
{
src: routerBasePath + '/icon192.png',
type: 'image/png',
sizes: 'any'
}
{ src: routerBasePath + '/icon.svg', sizes: 'any' },
{ src: routerBasePath + '/icon192.png', type: 'image/png', sizes: 'any' }
]
},
workbox: {
offline: false,
cacheAssets: false,
preCaching: [],
runtimeCaching: []
}
workbox: { offline: false, cacheAssets: false, preCaching: [], runtimeCaching: [] }
},
// Build Configuration: https://go.nuxtjs.dev/config-build
build: {},
watchers: {
webpack: {
aggregateTimeout: 300,
poll: 1000
}
},
server: {
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
host: '0.0.0.0'
},
watchers: { webpack: { aggregateTimeout: 300, poll: 1000 } },
server: { port: process.env.NODE_ENV === 'production' ? 80 : 3000, host: '0.0.0.0' },
/**
* Temporary workaround for @nuxt-community/tailwindcss-module.
*
* Reported: 2022-05-23
* See: [Issue tracker](https://github.com/nuxt-community/tailwindcss-module/issues/480)
*/
// tailwind workaround
devServerHandlers: [],
ignore: ['**/*.test.*', '**/*.cy.*']

View file

@ -74,9 +74,22 @@ export default {
this.$refs.bookshelf.setShelvesFromSearch()
}
})
// 🔔 count this search toward Search Master
try {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('abs:search-performed'))
}
} catch (_) {}
}
},
mounted() {},
mounted() {
// count the initial load as a search as well
try {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('abs:search-performed'))
}
} catch (_) {}
},
beforeDestroy() {}
}
</script>

View file

@ -0,0 +1,95 @@
<template>
<div class="px-6 py-4">
<div class="flex items-center gap-3 mb-4">
<nuxt-link to="/my-badges" class="text-primary hover:underline text-sm"> All Collections</nuxt-link>
<h1 class="text-2xl font-semibold">{{ title }}</h1>
<span v-if="ready && collection" class="text-sm text-gray-400 ml-auto">
{{ unlockedInThis }}/{{ totalInThis }} unlocked
</span>
</div>
<div v-if="error" class="text-error mb-4">{{ error }}</div>
<div v-else-if="!ready" class="text-gray-300">Loading</div>
<div v-else-if="!collection" class="text-gray-300">Collection not found.</div>
<div v-else>
<BadgeGrid :badges="badgesInCollection" :unlocked-ids="unlockedSet" />
</div>
</div>
</template>
<script>
import AchievementService from '@/services/AchievementService'
import BadgeGrid from '@/components/badges/BadgeGrid.vue'
export default {
components: { BadgeGrid },
data () {
return {
catalog: null,
my: { unlocked: [] },
collection: null,
error: '',
unlisten: null
}
},
computed: {
ready () { return !!this.catalog },
title () { return this.collection?.name || 'Reading Journey' },
badgesInCollection () {
if (!this.collection || !this.catalog) return []
// catalog.badges is an object keyed by id
return (this.collection.badgeIds || [])
.map(id => this.catalog.badges[id])
.filter(Boolean)
},
unlockedSet () {
return new Set(this.my?.unlocked || [])
},
unlockedInThis () {
const ids = new Set(this.collection?.badgeIds || [])
let n = 0
for (const id of this.my?.unlocked || []) if (ids.has(id)) n++
return n
},
totalInThis () {
return this.collection?.badgeIds?.length || 0
}
},
watch: {
'$route.params.collectionId': {
immediate: true,
async handler () {
await this.load()
}
}
},
mounted () {
// If a badge is unlocked elsewhere, refresh progress so tiles flip to color
const onUnlocked = async () => {
try { this.my = await AchievementService.getMy() } catch (_) {}
}
window.addEventListener('achievement:unlocked', onUnlocked)
this.unlisten = () => window.removeEventListener('achievement:unlocked', onUnlocked)
},
beforeDestroy () {
if (this.unlisten) this.unlisten()
},
methods: {
async load () {
this.error = ''
try {
const [catalog, my] = await Promise.all([
AchievementService.getCatalog(),
AchievementService.getMy()
])
this.catalog = catalog
this.my = my || { unlocked: [] }
const cid = this.$route.params.collectionId
this.collection = this.catalog.collections.find(c => c.id === cid)
} catch (e) {
this.error = e?.message || 'Failed to load collection'
}
}
}
}
</script>

View file

@ -0,0 +1,80 @@
<template>
<div class="px-6 py-4">
<h1 class="text-2xl font-semibold mb-4">My Badges</h1>
<div v-if="error" class="text-error mb-4">{{ error }}</div>
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-gray-300">
{{ catalogLoaded ? 'Choose a collection to explore your badges.' : 'Loading…' }}
</p>
<p v-if="catalogLoaded" class="text-sm text-gray-300">
Progress: <span class="font-semibold">{{ unlockedCount }}</span> unlocked
</p>
</div>
<div v-if="catalogLoaded" class="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<CollectionCard
v-for="c in catalog.collections"
:key="c.id"
:title="c.name"
:count="c.badgeIds.length"
:to="`/my-badges/${c.id}`"
/>
</div>
</div>
</template>
<script>
import AchievementService from '@/services/AchievementService'
import CollectionCard from '@/components/badges/CollectionCard.vue'
export default {
components: { CollectionCard },
data () {
return {
catalog: null,
my: { userId: 'guest', counters: {}, unlocked: [], history: [] },
error: '',
unlisten: null
}
},
computed: {
catalogLoaded () { return !!this.catalog },
// Defensive: only count IDs that exist in the catalog (avoids mismatch)
unlockedCount () {
if (!this.catalog) return 0
const valid = new Set(Object.keys(this.catalog.badges || {}))
return (this.my?.unlocked || []).filter(id => valid.has(id)).length
}
},
async mounted () {
await this.load()
// keep progress fresh when a badge is unlocked elsewhere
const onUnlocked = async () => {
try { this.my = await AchievementService.getMy() } catch (_) {}
}
window.addEventListener('achievement:unlocked', onUnlocked)
this.unlisten = () => window.removeEventListener('achievement:unlocked', onUnlocked)
},
beforeDestroy () { if (this.unlisten) this.unlisten() },
methods: {
async load () {
try {
const [catalog, my] = await Promise.all([
AchievementService.getCatalog(),
AchievementService.getMy()
])
this.catalog = catalog
this.my = my || this.my
} catch (e) {
this.error = e?.message || 'Failed to load achievements'
if (!this.catalog) {
try { this.catalog = await AchievementService.getCatalog() } catch (_) {}
}
if (!this.my) this.my = { userId: 'guest', counters: {}, unlocked: [], history: [] }
}
}
}
}
</script>

View file

@ -1,6 +1,7 @@
import LocalAudioPlayer from './LocalAudioPlayer'
import CastPlayer from './CastPlayer'
import AudioTrack from './AudioTrack'
import AchievementService from '@/services/AchievementService'
export default class PlayerHandler {
constructor(ctx) {
@ -136,6 +137,13 @@ export default class PlayerHandler {
this.sendProgressSync(currentTime)
this.ctx.mediaFinished(this.libraryItemId, this.episodeId)
// ✅ Notify Achievements that this library item has been finished.
// This is used to count UNIQUE finished items (for Reading Journey).
try {
// fire-and-forget; no await so UI stays snappy
AchievementService.complete({ event: 'itemFinished', meta: { itemId: this.libraryItemId } })
} catch (_) {}
}
playerStateChange(state) {

View file

@ -0,0 +1,74 @@
// client/plugins/achievement-hooks.client.js
import AchievementService from '@/services/AchievementService'
export default ({ app }, inject) => {
const seen = new Set()
const once = async (key, fn) => {
try {
if (seen.has(key)) return
seen.add(key)
await fn()
} catch (_) {}
}
// --------------------- DAILY LOGIN PING (UTC-day safe) --------------------
const todayKey = () => Math.floor(Date.now() / (24 * 60 * 60 * 1000)) // UTC-ish
const storageKey = () => `abs:login-ping:${todayKey()}`
const trySendDailyLoginPing = async () => {
const k = storageKey()
if (sessionStorage.getItem(k)) return
// Ensure we are authenticated before we count a login
const me = await AchievementService.getMy().catch(() => null)
if (me && me.userId && me.userId !== 'guest') {
await AchievementService.complete({ event: 'userLoggedIn' })
sessionStorage.setItem(k, '1')
}
}
if (process.browser) {
// fire on app load and after each route change (first successful auth wins)
trySendDailyLoginPing()
app.router.afterEach(() => trySendDailyLoginPing())
// also check again every ~15 minutes in long-lived tabs crossing midnight
setInterval(() => trySendDailyLoginPing(), 15 * 60 * 1000)
}
// -------------------------- NAVIGATION HOOKS ------------------------------
app.router.afterEach((to) => {
// Upload page
if (to.path.startsWith('/upload')) {
once('visitedUpload', () => AchievementService.complete({ event: 'visitedUpload' }))
}
// Authors
if (to.path.startsWith('/author') || to.path.startsWith('/authors')) {
once('openedAuthor', () => AchievementService.complete({ event: 'openedAuthor' }))
}
// Narrators
if (to.path.startsWith('/narrator') || to.path.startsWith('/narrators')) {
once('openedNarrator', () => AchievementService.complete({ event: 'openedNarrator' }))
}
// Stats pages (Your Stats / Library Stats)
if (to.path.startsWith('/stats') || to.path.startsWith('/your-stats') || to.path.startsWith('/library-stats')) {
once('visitedSettings', () => AchievementService.complete({ event: 'visitedSettings' }))
}
})
// ------------------------ OPTIONAL ACTION HOOKS ---------------------------
if (process.browser) {
window.addEventListener('abs:search-performed', () => {
AchievementService.complete({ event: 'performedSearch' })
})
window.addEventListener('abs:playlist-created', () => {
AchievementService.complete({ event: 'createdPlaylist' })
})
window.addEventListener('abs:book-downloaded', () => {
AchievementService.complete({ event: 'downloadedBook' })
})
}
}

View file

@ -0,0 +1,42 @@
// client/plugins/achievement-route-hooks.client.js
export default ({ app }) => {
const fire = async (event) => {
try {
const svc = (await import('@/services/AchievementService')).default
svc.complete({ event })
} catch (_) {}
}
const base = app.$config?.routerBasePath || ''
const stripBase = (p) => (base && p.startsWith(base) ? p.slice(base.length) : p)
const seen = new Set()
app.router.afterEach((to) => {
const full = to.fullPath || to.path || ''
const path = stripBase(full)
const purePath = path.split('?')[0]
if (!seen.has('visited_settings') && purePath.startsWith('/config')) {
seen.add('visited_settings')
fire('visitedSettings')
}
if (!seen.has('opened_author') && (purePath.includes('/authors') || /^\/author\//.test(purePath))) {
seen.add('opened_author')
fire('openedAuthor')
}
if (!seen.has('opened_narrator') && (purePath.includes('/narrators') || /^\/narrator\//.test(purePath))) {
seen.add('opened_narrator')
fire('openedNarrator')
}
if (purePath.startsWith('/library/')) {
const keys = Object.keys(to.query || {})
const effective = keys.filter((k) => !['q', 'page'].includes(k))
if (effective.length && !seen.has('applied_filter')) {
seen.add('applied_filter')
fire('appliedFilter')
}
}
})
}

View file

@ -0,0 +1,29 @@
// client/plugins/achievements-axios-hook.client.js
import AchievementService from '@/services/AchievementService'
const hit = (event) => AchievementService.complete({ event }).catch(() => {})
export default (ctx) => {
const isAchUrl = (u) => typeof u === 'string' && u.includes('/api/achievements/')
ctx.$axios.onResponse((response) => {
try {
const { config, status } = response || {}
if (!config || status >= 400) return
const method = (config.method || 'get').toLowerCase()
const url = config.url || ''
if (isAchUrl(url)) return
// Library edited
if ((method === 'patch' || method === 'put') && /\/api\/libraries\/[^/]+$/.test(url)) return hit('editedLibrary')
// Collections & Playlists created
if (method === 'post' && /\/api\/collections(\/|$)/.test(url)) return hit('createdCollection')
if (method === 'post' && /\/api\/playlists(\/|$)/.test(url)) return hit('createdPlaylist')
// Series created
if (method === 'post' && /\/api\/series(\/|$)/.test(url)) return hit('createdSeries')
// Book uploaded already backfilled; download is normally not via axios
} catch { /* noop */ }
})
}

View file

@ -0,0 +1,11 @@
// client/plugins/achievements-login.client.js
import AchievementService from '@/services/AchievementService'
export default () => {
// Fire a login event on each app start when the user has a token
const hasToken = !!(typeof window !== 'undefined' && (localStorage.getItem('accessToken') || localStorage.getItem('token')))
if (!hasToken) return
// To allow "double dip", we DO send each time the app boots.
AchievementService.complete({ event: 'login' }).catch(() => {})
}

View file

@ -0,0 +1,37 @@
// client/plugins/achievements.axios.client.js
import AchievementService from '@/services/AchievementService'
function matches (url, method, needle, m) {
return method === m && url.includes(needle)
}
export default function ({ $axios }) {
$axios.onResponse((resp) => {
try {
const { config, status } = resp || {}
const url = (config?.url || '').toLowerCase()
const method = (config?.method || 'get').toLowerCase()
if (!url || status >= 400) return
// Edited library
if (matches(url, method, '/api/libraries/', 'patch') || matches(url, method, '/api/libraries/', 'put')) {
AchievementService.complete({ event: 'editedLibrary' })
}
// Created collection
if (matches(url, method, '/api/collections', 'post')) {
AchievementService.complete({ event: 'createdCollection' })
}
// Created playlist
if (matches(url, method, '/api/playlists', 'post')) {
AchievementService.complete({ event: 'createdPlaylist' })
}
// Downloaded a book (server routes often contain /download/)
if (url.includes('/download/') || url.includes('/public/download/')) {
AchievementService.complete({ event: 'downloadedBook' })
}
} catch {}
})
}

View file

@ -0,0 +1,37 @@
// client/plugins/achievements.routes.client.js
import AchievementService from '@/services/AchievementService'
let fired = new Set()
const once = (k, fn) => { if (fired.has(k)) return; fired.add(k); try { fn() } catch {} }
export default function ({ app }) {
const handle = (to) => {
const p = to?.path || ''
const q = to?.query || {}
// Author
if (/^\/author\/[^/]+$/.test(p)) once('openedAuthor', () => AchievementService.complete({ event: 'openedAuthor' }))
// Narrators tab
if (/^\/library\/[^/]+\/narrators/.test(p)) once('openedNarrator', () => AchievementService.complete({ event: 'openedNarrator' }))
// ✅ Stats pages (any of these routes count)
if (
/^\/library\/[^/]+\/stats/.test(p) ||
/^\/config\/stats/.test(p) ||
/^\/stats(\/|$)/.test(p) ||
/^\/your-stats(\/|$)/.test(p)
) {
once('visitedStats', () => AchievementService.complete({ event: 'visitedStats' }))
}
// Applied filter (search page with any extra query besides q)
if (/^\/library\/[^/]+\/search/.test(p)) {
const keys = Object.keys(q).filter(k => k !== 'q')
if (keys.length) once('appliedFilter', () => AchievementService.complete({ event: 'appliedFilter' }))
}
}
handle(app.router.currentRoute)
app.router.afterEach((to) => handle(to))
}

View file

@ -0,0 +1,99 @@
// client/services/AchievementService.js
const json = (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
function dispatchUnlocked (badges) {
try {
if (typeof window !== 'undefined' && badges && badges.length) {
window.dispatchEvent(new CustomEvent('achievement:unlocked', { detail: { badges } }))
}
} catch (_) {}
}
// Try best-effort ways ABS stores the access token
function getAccessToken () {
try {
if (typeof window === 'undefined') return null
const w = window
const nuxtStore = w.$nuxt?.$store || null
const nuxtState = w.__NUXT__?.state || null
const tryFromStore = (s) => {
if (!s) return null
if (s.user?.user?.accessToken) return s.user.user.accessToken
if (s.user?.accessToken) return s.user.accessToken
if (s.user?.token) return s.user.token
return null
}
const tokenFromStore = tryFromStore(nuxtStore) || tryFromStore(nuxtState)
const tokenFromStorage =
w.localStorage?.getItem('accessToken') ||
w.localStorage?.getItem('token') ||
null
return tokenFromStore || tokenFromStorage || null
} catch {
return null
}
}
function authHeaders () {
const t = getAccessToken()
return t ? { Authorization: `Bearer ${t}` } : {}
}
const normalizeProgress = (p) => ({
userId: p?.userId || 'guest',
counters: p?.counters || {},
unlocked: Array.isArray(p?.unlocked) ? p.unlocked : [],
history: Array.isArray(p?.history) ? p.history : []
})
async function postComplete (payload) {
const data = await fetch('/api/achievements/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
credentials: 'include',
body: JSON.stringify(payload || {})
})
.then(json)
.catch(() => ({}))
if (data?.unlockedNow?.length) dispatchUnlocked(data.unlockedNow)
return data
}
export default {
async getCatalog () {
return fetch('/api/achievements/catalog', {
credentials: 'include'
}).then(json)
},
async getMy () {
return fetch('/api/achievements/me', {
credentials: 'include',
headers: { ...authHeaders() }
})
.then(json)
.then(normalizeProgress)
.catch(() => normalizeProgress(null))
},
async complete (payload) {
return postComplete(payload)
},
// NEW — convenience wrappers
loginPing () {
return postComplete({ event: 'login' })
},
markSearched () {
return postComplete({ event: 'search' })
},
markReadToday () {
return postComplete({ event: 'readUploadedToday' })
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -1,3 +1,4 @@
// server/Server.js
const Path = require('path')
const Sequelize = require('sequelize')
const express = require('express')
@ -23,6 +24,7 @@ const SocketAuthority = require('./SocketAuthority')
const ApiRouter = require('./routers/ApiRouter')
const HlsRouter = require('./routers/HlsRouter')
const PublicRouter = require('./routers/PublicRouter')
const createAchievementsRouter = require('./routers/achievements') // ✅ single import
const LogManager = require('./managers/LogManager')
const EmailManager = require('./managers/EmailManager')
@ -39,11 +41,44 @@ const BinaryManager = require('./managers/BinaryManager')
const ShareManager = require('./managers/ShareManager')
const LibraryScanner = require('./scanner/LibraryScanner')
//Import the main Passport and Express-Session library
// Passport / session
const passport = require('passport')
const expressSession = require('express-session')
const MemoryStore = require('./libs/memorystore')
// 👉 Achievements
const AchievementManager = require('./managers/AchievementManager')
// Small helper for “once per day” user login ping
const DAY_MS = 24 * 60 * 60 * 1000
const todayKey = () => Math.floor(Date.now() / DAY_MS)
function loginAchievementPing (req, _res, next) {
try {
const user = req.user
const userId = user && (user.id || user.userId)
if (!userId) return next()
// Use the session to avoid re-emitting within the same day
if (req.session) {
const tkey = todayKey()
const markerKey = '_absLoginPingDay'
if (req.session[markerKey] !== tkey) {
req.session[markerKey] = tkey
AchievementManager.applyEvent(String(userId), 'userLoggedIn').catch(() => {})
}
} else {
// No session available; still try once (per request)
if (!req._absLoginPingSent) {
req._absLoginPingSent = true
AchievementManager.applyEvent(String(userId), 'userLoggedIn').catch(() => {})
}
}
} catch (_) {
// never block
}
next()
}
class Server {
constructor(SOURCE, PORT, HOST, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
this.Port = PORT
@ -57,7 +92,6 @@ class Server {
global.AllowCors = process.env.ALLOW_CORS === '1'
if (process.env.EXP_PROXY_SUPPORT === '1') {
// https://github.com/advplyr/audiobookshelf/pull/3754
Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`)
global.DisableSsrfRequestFilter = () => true
@ -71,7 +105,6 @@ class Server {
url: error.response.headers.location
})
}
return Promise.reject(error)
}
)
@ -88,12 +121,8 @@ class Server {
global.PodcastDownloadTimeout = toNumber(process.env.PODCAST_DOWNLOAD_TIMEOUT, 30000)
global.MaxFailedEpisodeChecks = toNumber(process.env.MAX_FAILED_EPISODE_CHECKS, 24)
if (!fs.pathExistsSync(global.ConfigPath)) {
fs.mkdirSync(global.ConfigPath)
}
if (!fs.pathExistsSync(global.MetadataPath)) {
fs.mkdirSync(global.MetadataPath)
}
if (!fs.pathExistsSync(global.ConfigPath)) fs.mkdirSync(global.ConfigPath)
if (!fs.pathExistsSync(global.MetadataPath)) fs.mkdirSync(global.MetadataPath)
this.auth = new Auth()
@ -118,15 +147,7 @@ class Server {
this.server = null
}
/**
* Middleware to check if the current request is authenticated
*
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
authMiddleware(req, res, next) {
// ask passportjs if the current request is authenticated
this.auth.isAuthenticated(req, res, next)
}
@ -134,10 +155,6 @@ class Server {
LibraryScanner.setCancelLibraryScan(libraryId)
}
/**
* Initialize database, backups, logs, rss feeds, cron jobs & watcher
* Cleanup stale/invalid data
*/
async init() {
Logger.info('[Server] Init v' + version)
Logger.info('[Server] Node.js Version:', process.version)
@ -146,22 +163,14 @@ class Server {
await this.playbackSessionManager.removeOrphanStreams()
/**
* Docker container ffmpeg/ffprobe binaries are included in the image.
* Docker is currently using ffmpeg/ffprobe v6.1 instead of v5.1 so skipping the check
* TODO: Support binary check for all sources
*/
if (global.Source !== 'docker') {
await this.binaryManager.init()
}
await Database.init(false)
// Create or set JWT secret in token manager
await this.auth.tokenManager.initTokenSecret()
await Logger.logManager.init()
await this.cleanUserData() // Remove invalid user item progress
await this.cleanUserData()
await CacheManager.ensureCachePaths()
await ShareManager.init()
@ -183,9 +192,6 @@ class Server {
}
}
/**
* Listen for SIGINT and uncaught exceptions
*/
initProcessEventListeners() {
let sigintAlreadyReceived = false
process.on('SIGINT', async () => {
@ -200,15 +206,9 @@ class Server {
process.exit(0)
})
/**
* @see https://nodejs.org/api/process.html#event-uncaughtexceptionmonitor
*/
process.on('uncaughtExceptionMonitor', async (error, origin) => {
await Logger.fatal(`[Server] Uncaught exception origin: ${origin}, error:`, util.format('%O', error))
})
/**
* @see https://nodejs.org/api/process.html#event-unhandledrejection
*/
process.on('unhandledRejection', async (reason, promise) => {
await Logger.fatal('[Server] Unhandled rejection:', reason, '\npromise:', util.format('%O', promise))
process.exit(1)
@ -225,75 +225,56 @@ class Server {
app.use((req, res, next) => {
if (!global.ServerSettings.allowIframe) {
// Prevent clickjacking by disallowing iframes
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
}
// Security: Prevent referrer leakage to protect against token exposure
// Using 'no-referrer' to completely prevent token leakage in referer headers
res.setHeader('Referrer-Policy', 'no-referrer')
/**
* @temporary
* This is necessary for the ebook & cover API endpoint in the mobile apps
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
* The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors
* @see https://ionicframework.com/docs/troubleshooting/cors
*
* Running in development allows cors to allow testing the mobile apps in the browser
* or env variable ALLOW_CORS = '1'
*/
if (global.AllowCors || Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/) || global.ServerSettings.allowedOrigins?.length) {
if (
global.AllowCors ||
Logger.isDev ||
req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/) ||
global.ServerSettings.allowedOrigins?.length
) {
const allowedOrigins = ['capacitor://localhost', 'http://localhost', ...(global.ServerSettings.allowedOrigins ? global.ServerSettings.allowedOrigins : [])]
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
res.header('Access-Control-Allow-Origin', req.get('origin'))
res.header('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
res.header('Access-Control-Allow-Headers', '*')
res.header('Access-Control-Allow-Credentials', true)
if (req.method === 'OPTIONS') {
return res.sendStatus(200)
}
if (req.method === 'OPTIONS') return res.sendStatus(200)
}
}
next()
})
// parse cookies in requests
// cookie + session + passport
app.use(cookieParser())
// enable express-session
app.use(
expressSession({
secret: this.auth.tokenManager.TokenSecret,
resave: false,
saveUninitialized: false,
cookie: {
// also send the cookie if were are not on https (not every use has https)
secure: false
},
cookie: { secure: false },
store: new MemoryStore(86400000, 86400000, 1000)
})
)
// init passport.js
app.use(passport.initialize())
// register passport in express-session
app.use(this.auth.ifAuthNeeded(passport.session()))
// config passport.js
await this.auth.initPassportJs()
// 🔔 Global login ping: session users (first authed request of the day)
app.use(loginAchievementPing)
const router = express.Router()
// if RouterBasePath is set, modify all requests to include the base path
// Base path handling
app.use((req, res, next) => {
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
const host = req.get('host')
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
req.originalHostPrefix = `${protocol}://${host}${prefix}`
if (!urlStartsWithRouterBasePath) {
req.url = `${global.RouterBasePath}${req.url}`
}
if (!urlStartsWithRouterBasePath) req.url = `${global.RouterBasePath}${req.url}`
next()
})
app.use(global.RouterBasePath, router)
@ -310,18 +291,26 @@ class Server {
})
)
router.use(express.urlencoded({ extended: true, limit: '5mb' }))
// Skip JSON parsing for internal-api routes
router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' }))
router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)
// ✅ Achievements API first (unchanged)
router.use('/api/achievements', createAchievementsRouter({ auth: this.auth }))
// Existing routers
// For /api we must place login ping AFTER auth so JWT users also unlock
router.use(
'/api',
this.auth.ifAuthNeeded(this.authMiddleware.bind(this)),
loginAchievementPing, // 🔔 ensure JWT logins get recorded
this.apiRouter.router
)
router.use('/hls', this.hlsRouter.router)
router.use('/public', this.publicRouter.router)
// Static folder
// Static
router.use(express.static(Path.join(global.appRoot, 'static')))
// RSS Feed temp route
// RSS
router.get('/feed/:slug', (req, res) => {
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
RssFeedManager.getFeed(req, res)
@ -345,8 +334,6 @@ class Server {
this.initializeServer(req, res)
})
router.get('/status', (req, res) => {
// status check for client to see if server has been initialized
// server has been initialized if a root user exists
const payload = {
app: 'audiobookshelf',
serverVersion: version,
@ -369,11 +356,10 @@ class Server {
const ReactClientPath = process.env.REACT_CLIENT_PATH
if (!ReactClientPath) {
// Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist')
router.use(express.static(distPath))
// Client dynamic routes
// SPA fallbacks (add badges)
const dynamicRoutes = [
'/item/:id',
'/author/:id',
@ -395,11 +381,12 @@ class Server {
'/config/item-metadata-utils/:id',
'/collection/:id',
'/playlist/:id',
'/share/:slug'
'/share/:slug',
'/my-badges',
'/my-badges/:collectionId'
]
dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
} else {
// This is for using the experimental Next.js client
Logger.info(`Using React client at ${ReactClientPath}`)
const nextPath = Path.join(ReactClientPath, 'node_modules/next')
const next = require(nextPath)
@ -424,7 +411,6 @@ class Server {
})
}
// Start listening for socket connections
SocketAuthority.initialize(this)
}
@ -435,15 +421,10 @@ class Server {
const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
await Database.createRootUser(rootUsername, rootPash, this.auth)
res.sendStatus(200)
}
/**
* Remove user media progress for items that no longer exist & remove seriesHideFrom that no longer exist
*/
async cleanUserData() {
// Get all media progress without an associated media item
const mediaProgressToRemove = await Database.mediaProgressModel.findAll({
where: {
'$podcastEpisode.id$': null,
@ -451,36 +432,27 @@ class Server {
},
attributes: ['id'],
include: [
{
model: Database.bookModel,
attributes: ['id']
},
{
model: Database.podcastEpisodeModel,
attributes: ['id']
}
{ model: Database.bookModel, attributes: ['id'] },
{ model: Database.podcastEpisodeModel, attributes: ['id'] }
]
})
if (mediaProgressToRemove.length) {
// Remove media progress
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
where: {
id: {
[Sequelize.Op.in]: mediaProgressToRemove.map((mp) => mp.id)
}
}
where: { id: { [Sequelize.Op.in]: mediaProgressToRemove.map((mp) => mp.id) } }
})
if (mediaProgressRemoved) {
Logger.info(`[Server] Removed ${mediaProgressRemoved} media progress for media items that no longer exist in db`)
}
}
// Remove series from hide from continue listening that no longer exist
try {
const users = await Database.sequelize.query(`SELECT u.id, u.username, u.extraData, json_group_array(value) AS seriesIdsToRemove FROM users u, json_each(u.extraData->"seriesHideFromContinueListening") LEFT JOIN series se ON se.id = value WHERE se.id IS NULL GROUP BY u.id;`, {
model: Database.userModel,
type: Sequelize.QueryTypes.SELECT
})
const users = await Database.sequelize.query(
`SELECT u.id, u.username, u.extraData, json_group_array(value) AS seriesIdsToRemove
FROM users u, json_each(u.extraData->"seriesHideFromContinueListening")
LEFT JOIN series se ON se.id = value
WHERE se.id IS NULL GROUP BY u.id;`,
{ model: Database.userModel, type: Sequelize.QueryTypes.SELECT }
)
for (const user of users) {
const extraData = JSON.parse(user.extraData)
const existingSeriesIds = extraData.seriesHideFromContinueListening
@ -497,18 +469,19 @@ class Server {
}
}
/**
* Gracefully stop server
* Stops watcher and socket server
*/
async stop() {
Logger.info('=== Stopping Server ===')
Watcher.close()
Logger.info('[Server] Watcher Closed')
await SocketAuthority.close()
Logger.info('[Server] Closing HTTP Server')
await new Promise((resolve) => this.server.close(resolve))
Logger.info('[Server] HTTP Server Closed')
if (this.server) {
await new Promise((resolve) => this.server.close(resolve))
Logger.info('[Server] HTTP Server Closed')
} else {
Logger.info('[Server] HTTP Server not running')
}
}
}
module.exports = Server

View file

@ -0,0 +1,111 @@
// server/controllers/AchievementController.js
const AchievementManager = require('../managers/AchievementManager')
/**
* Controller for Achievement endpoints.
* You can wire these handlers from any Express router:
*
* const ctrl = require('../controllers/AchievementController')
* router.get('/catalog', ctrl.catalog)
* router.get('/me', auth.jwtAuthCheck, ctrl.me)
* router.post('/complete', auth.jwtAuthCheck, express.json(), ctrl.complete)
*/
module.exports = {
/**
* GET /api/achievements/catalog
* Public returns the full catalog (collections + badges).
*/
async catalog(req, res, next) {
try {
const data = await AchievementManager.getCatalog()
res.json(data)
} catch (err) {
next(err)
}
},
/**
* GET /api/achievements/me
* Authenticated returns the caller's progress.
*/
async me(req, res, next) {
try {
const userId = String(req.user?.id || req.user?.userId || '')
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
const progress = await AchievementManager.getUserProgress(userId)
res.json(progress)
} catch (err) {
next(err)
}
},
/**
* POST /api/achievements/complete
* Authenticated applies a user event and returns any newly unlocked badges.
* Expects: { event: string, meta?: object }
*/
async complete(req, res, next) {
try {
const userId = String(req.user?.id || req.user?.userId || '')
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
const { event, meta } = req.body || {}
if (!event) return res.status(400).json({ error: 'Missing event' })
const result = await AchievementManager.applyEvent(userId, event, meta || {})
res.json(result)
} catch (err) {
next(err)
}
},
async getCatalog(req, res, next) {
try {
const data = await AchievementManager.getCatalog()
res.json(data)
} catch (err) {
next(err)
}
},
async getUserAchievements(req, res, next) {
try {
const userId = String(req.user?.id || req.user?.userId || '')
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
const achievements = await AchievementManager.getUserAchievements(userId)
res.json(achievements)
} catch (err) {
next(err)
}
},
async getMyAchievements(req, res, next) {
try {
const userId = String(req.user?.id || req.user?.userId || '')
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
const myAchievements = await AchievementManager.getMyAchievements(userId)
res.json(myAchievements)
} catch (err) {
next(err)
}
},
async completeItem(req, res, next) {
try {
const userId = String(req.user?.id || req.user?.userId || '')
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
const { itemId } = req.body || {}
if (!itemId) return res.status(400).json({ error: 'Missing item ID' })
const result = await AchievementManager.completeItem(userId, itemId)
res.json(result)
} catch (err) {
next(err)
}
}
}

View file

@ -0,0 +1,79 @@
{
"collections": [
{
"id": "reading_journey",
"name": "Reading Journey",
"badgeIds": ["new_reader","bookworm","avid","page_turner","marathoner","centurion","legend"]
},
{
"id": "getting_started",
"name": "Getting Started",
"badgeIds": ["library_creator","library_editor","book_uploader","book_downloader","series_starter","collection_maker","tune_weaver"]
},
{
"id": "adventurers_path",
"name": "Adventurers Path",
"badgeIds": ["searcher","refined_explorer","author_finder","narrator_finder","pathfinder"]
},
{
"id": "daily_devotion",
"name": "Daily Devotion",
"badgeIds": ["first_login","double_dip","streak_3","streak_7","streak_30"]
},
{
"id": "search_master",
"name": "Search Master",
"badgeIds": ["first_search","curious","seeker","explorer","hunter","master_queries"]
},
{
"id": "reading_streak",
"name": "Reading Streak",
"badgeIds": ["one_day_finish","back_to_back","focused_five","weekly_reader","monthly_reader"]
}
],
"badges": {
"new_reader": { "id":"new_reader","name":"Turned the First Page","collectionId":"reading_journey","type":"threshold","threshold":1,"blurb":"Finished your first item" },
"bookworm": { "id":"bookworm","name":"Lost in Stories","collectionId":"reading_journey","type":"threshold","threshold":5,"blurb":"Finished 5 items" },
"avid": { "id":"avid","name":"Cant Put It Down","collectionId":"reading_journey","type":"threshold","threshold":10,"blurb":"Finished 10 items" },
"page_turner": { "id":"page_turner","name":"Flipping Through Worlds","collectionId":"reading_journey","type":"threshold","threshold":25,"blurb":"Finished 25 items" },
"marathoner": { "id":"marathoner","name":"Endurance Listener","collectionId":"reading_journey","type":"threshold","threshold":50,"blurb":"Finished 50 items" },
"centurion": { "id":"centurion","name":"100 Tales Conquered","collectionId":"reading_journey","type":"threshold","threshold":100,"blurb":"Finished 100 items" },
"legend": { "id":"legend","name":"Legend of the Library","collectionId":"reading_journey","type":"threshold","threshold":250,"blurb":"Finished 250 items" },
"library_creator": { "id":"library_creator","name":"Builder of Shelves","collectionId":"getting_started","type":"first","blurb":"Created your first library" },
"library_editor": { "id":"library_editor","name":"Shelf Organizer","collectionId":"getting_started","type":"first","blurb":"Edited a library" },
"book_uploader": { "id":"book_uploader","name":"First Contribution","collectionId":"getting_started","type":"first","blurb":"Uploaded your first book" },
"book_downloader": { "id":"book_downloader","name":"Pocket Librarian","collectionId":"getting_started","type":"first","blurb":"Downloaded a book" },
"series_starter": { "id":"series_starter","name":"Saga Initiator","collectionId":"getting_started","type":"first","blurb":"Deleted your first uploaded file" },
"collection_maker": { "id":"collection_maker","name":"Curator in Training","collectionId":"getting_started","type":"first","blurb":"Created your first collection/playlist" },
"tune_weaver": { "id":"tune_weaver","name":"Tune Weaver","collectionId":"getting_started","type":"first","blurb":"Created your first playlist" },
"searcher": { "id":"searcher","name":"Seeker of Stories","collectionId":"adventurers_path","type":"first","blurb":"Performed your first search" },
"refined_explorer": { "id":"refined_explorer","name":"Refined Explorer","collectionId":"adventurers_path","type":"first","blurb":"Visited the upload page" },
"author_finder": { "id":"author_finder","name":"Author Discoverer","collectionId":"adventurers_path","type":"first","blurb":"Visited author view" },
"narrator_finder": { "id":"narrator_finder","name":"Voice Hunter","collectionId":"adventurers_path","type":"first","blurb":"Visited narrator view" },
"pathfinder": { "id":"pathfinder","name":"Pathfinder","collectionId":"adventurers_path","type":"first","blurb":"Visited settings" },
"first_login": { "id":"first_login","name":"First Login","collectionId":"daily_devotion","type":"first","blurb":"Logged in for the first time" },
"double_dip": { "id":"double_dip","name":"Double Dip","collectionId":"daily_devotion","type":"first","blurb":"Logged in twice in the same day" },
"streak_3": { "id":"streak_3","name":"3-Day Streak","collectionId":"daily_devotion","type":"threshold","threshold":3,"blurb":"Logged in 3 days in a row" },
"streak_7": { "id":"streak_7","name":"Weekly Streak","collectionId":"daily_devotion","type":"threshold","threshold":7,"blurb":"Logged in 7 days in a row" },
"streak_30": { "id":"streak_30","name":"Monthly Streak","collectionId":"daily_devotion","type":"threshold","threshold":30,"blurb":"Logged in 30 days in a row" },
"first_search": { "id":"first_search","name":"First Search","collectionId":"search_master","type":"threshold","threshold":1,"blurb":"Searched for 1 item" },
"curious": { "id":"curious","name":"Curious","collectionId":"search_master","type":"threshold","threshold":3,"blurb":"Searched for 3 items" },
"seeker": { "id":"seeker","name":"Seeker","collectionId":"search_master","type":"threshold","threshold":5,"blurb":"Searched for 5 items" },
"explorer": { "id":"explorer","name":"Explorer","collectionId":"search_master","type":"threshold","threshold":10,"blurb":"Searched for 10 items" },
"hunter": { "id":"hunter","name":"Hunter","collectionId":"search_master","type":"threshold","threshold":50,"blurb":"Searched for 50 items" },
"master_queries": { "id":"master_queries","name":"Master of Queries","collectionId":"search_master","type":"threshold","threshold":100,"blurb":"Searched for 100 items" },
"one_day_finish": { "id":"one_day_finish","name":"Day One Reader","collectionId":"reading_streak","type":"first","blurb":"Read an uploaded book today" },
"back_to_back": { "id":"back_to_back","name":"Back-to-Back","collectionId":"reading_streak","type":"threshold","threshold":2,"blurb":"Read uploaded books 2 days in a row" },
"focused_five": { "id":"focused_five","name":"Focused Five","collectionId":"reading_streak","type":"threshold","threshold":5,"blurb":"Read uploaded books 5 days in a row" },
"weekly_reader": { "id":"weekly_reader","name":"Weekly Reader","collectionId":"reading_streak","type":"threshold","threshold":7,"blurb":"Read uploaded books 7 days in a row" },
"monthly_reader": { "id":"monthly_reader","name":"Monthly Reader","collectionId":"reading_streak","type":"threshold","threshold":30,"blurb":"Read uploaded books 30 days in a row" }
}
}

View file

@ -0,0 +1,407 @@
// server/managers/AchievementManager.js
const Path = require('path')
const Sequelize = require('sequelize')
const fsx = require('../libs/fsExtra')
const Database = require('../Database')
const Store = require('../utils/userProgressStore')
const CATALOG_PATH = Path.join(__dirname, '..', 'data', 'achievements.catalog.json')
let _catalogCache = null
async function loadCatalog () {
if (_catalogCache) return _catalogCache
const raw = await fsx.readFile(CATALOG_PATH, 'utf8')
_catalogCache = JSON.parse(raw)
return _catalogCache
}
// === helpers ================================================================
const DAY_MS = 24 * 60 * 60 * 1000
const dayKey = (ts = Date.now()) => Math.floor(ts / DAY_MS)
const toDbUserId = (userId) => {
const n = Number(userId)
return Number.isFinite(n) ? n : userId
}
// ============================================================================
// a clean default shape persisted per user
function emptyProgress (userId) {
return {
userId,
counters: {
// thresholds (unique finishes)
finishedUniqueIds: [],
finishedUniqueCount: 0,
finishedCount: 0,
// Search Master
searchCount: 0,
// first-time flags
createdLibrary: false,
editedLibrary: false,
uploadedBook: false,
downloadedBook: false,
createdSeries: false,
createdCollection: false,
createdPlaylist: false,
performedSearch: false,
visitedUpload: false,
openedAuthor: false,
openedNarrator: false,
visitedSettings: false,
// 🔸 NEW: first time deleting an uploaded file
deletedFirstFile: false,
// 🔸 NEW: fallback detector storage
lastSeenBookCount: null,
// Daily Devotion
totalLogins: 0,
lastLoginKey: null,
todayLoginCount: 0,
loginStreak: 0,
// Reading Streak
lastFinishedKey: null,
readingStreak: 0,
finishedWithinDay: false
},
unlocked: [],
history: [],
seenPopups: []
}
}
function normalizeWithCatalog (progress, catalog) {
progress.counters = progress.counters || {}
if (!Array.isArray(progress.counters.finishedUniqueIds)) progress.counters.finishedUniqueIds = []
if (typeof progress.counters.finishedUniqueCount !== 'number') progress.counters.finishedUniqueCount = 0
const valid = new Set(Object.keys(catalog.badges || {}))
progress.unlocked = Array.isArray(progress.unlocked) ? progress.unlocked.filter(id => valid.has(id)) : []
progress.unlocked = [...new Set(progress.unlocked)]
progress.history = Array.isArray(progress.history) ? progress.history.filter(h => h && valid.has(h.badgeId)) : []
return progress
}
// thresholds
function readingThresholds () {
return [
{ id: 'new_reader', threshold: 1 },
{ id: 'bookworm', threshold: 5 },
{ id: 'avid', threshold: 10 },
{ id: 'page_turner', threshold: 25 },
{ id: 'marathoner', threshold: 50 },
{ id: 'centurion', threshold: 100 },
{ id: 'legend', threshold: 250 }
]
}
function searchThresholds () {
return [
{ id: 'first_search', threshold: 1 },
{ id: 'curious', threshold: 3 },
{ id: 'seeker', threshold: 5 },
{ id: 'explorer', threshold: 10 },
{ id: 'hunter', threshold: 50 },
{ id: 'master_queries', threshold: 100 }
]
}
function computeUnlockedFresh (progress) {
const out = new Set()
// Reading Journey (by UNIQUE finished items)
const fc = Number(progress.counters.finishedUniqueCount || 0)
for (const t of readingThresholds()) if (fc >= t.threshold) out.add(t.id)
// Search Master
const sc = Number(progress.counters.searchCount || 0)
for (const t of searchThresholds()) if (sc >= t.threshold) out.add(t.id)
// Getting Started + playlist + 🔸deletedFirstFile → Saga Initiator
const started = [
['createdLibrary', 'library_creator'],
['editedLibrary', 'library_editor'],
['uploadedBook', 'book_uploader'],
['downloadedBook', 'book_downloader'],
// 🔸 re-purpose the existing badge id for the new action
['deletedFirstFile', 'series_starter'],
['createdCollection', 'collection_maker'],
['createdPlaylist', 'tune_weaver']
]
for (const [flag, badgeId] of started) if (progress.counters[flag]) out.add(badgeId)
// Adventurers Path
const explore = [
['performedSearch', 'searcher'],
['visitedUpload', 'refined_explorer'],
['openedAuthor', 'author_finder'],
['openedNarrator', 'narrator_finder'],
['visitedSettings', 'pathfinder']
]
for (const [flag, badgeId] of explore) if (progress.counters[flag]) out.add(badgeId)
// Daily Devotion
const ls = Number(progress.counters.loginStreak || 0)
if (Number(progress.counters.totalLogins || 0) >= 1) out.add('first_login')
if (Number(progress.counters.todayLoginCount || 0) >= 2) out.add('double_dip')
if (ls >= 3) out.add('streak_3')
if (ls >= 7) out.add('streak_7')
if (ls >= 30) out.add('streak_30')
// Reading Streak
const rs = Number(progress.counters.readingStreak || 0)
if (progress.counters.finishedWithinDay) out.add('one_day_finish')
if (rs >= 2) out.add('back_to_back')
if (rs >= 5) out.add('focused_five')
if (rs >= 7) out.add('weekly_reader')
if (rs >= 30) out.add('monthly_reader')
return [...out]
}
// robust DISTINCT finished ids from DB, with fallbacks
async function getDistinctFinishedIds (userId) {
const uid = toDbUserId(userId)
const Op = Sequelize.Op
try {
const rows = await Database.mediaProgressModel.findAll({
where: { userId: uid, finishedAt: { [Op.ne]: null } },
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col('libraryItemId')), 'libraryItemId']],
raw: true
})
const ids = rows.map(r => String(r.libraryItemId)).filter(Boolean)
if (ids.length) return [...new Set(ids)]
} catch {}
try {
const rows = await Database.mediaProgressModel.findAll({
where: { userId: uid, isFinished: 1 },
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col('libraryItemId')), 'libraryItemId']],
raw: true
})
const ids = rows.map(r => String(r.libraryItemId)).filter(Boolean)
if (ids.length) return [...new Set(ids)]
} catch {}
try {
const rows = await Database.mediaProgressModel.findAll({
where: { userId: uid },
attributes: ['libraryItemId', 'finishedAt', 'isFinished', 'currentTime', 'duration'],
raw: true
})
const ids = []
for (const r of rows) {
const id = r && r.libraryItemId != null ? String(r.libraryItemId) : null
if (!id) continue
const finishedAt = r.finishedAt != null
const isFinished = Number(r.isFinished || 0) === 1
const ratioOk = Number(r.duration || 0) > 0 && Number(r.currentTime || 0) / Number(r.duration) >= 0.98
if (finishedAt || isFinished || ratioOk) ids.push(id)
}
if (ids.length) return [...new Set(ids)]
} catch {}
return []
}
async function finishedWithinOneDay (userId, itemId = null) {
const uid = toDbUserId(userId)
const Op = Sequelize.Op
try {
const where = itemId
? { userId: uid, libraryItemId: itemId }
: { userId: uid, finishedAt: { [Op.ne]: null } }
const mp = await Database.mediaProgressModel.findOne({
where,
order: itemId ? [['updatedAt', 'DESC']] : [['finishedAt', 'DESC']]
})
if (!mp) return false
const started = mp.createdAt ? new Date(mp.createdAt).getTime() : null
const finished = mp.finishedAt ? new Date(mp.finishedAt).getTime() : null
if (!started || !finished) return false
return (finished - started) <= DAY_MS
} catch {
return false
}
}
async function syncFromDb (userId, progress) {
let idsFromDb = []
try { idsFromDb = await getDistinctFinishedIds(userId) } catch {}
const fallbackIds = Array.isArray(progress.counters.finishedUniqueIds)
? [...new Set(progress.counters.finishedUniqueIds)]
: []
const finalIds = idsFromDb.length ? idsFromDb : fallbackIds
progress.counters.finishedUniqueIds = finalIds
progress.counters.finishedUniqueCount = finalIds.length
progress.counters.finishedCount = finalIds.length
// best-effort backfills
try {
const libCount = await Database.libraryModel.count()
if (libCount > 0) progress.counters.createdLibrary = true
} catch {}
try {
const bookCount = await Database.bookModel.count()
if (bookCount > 0) progress.counters.uploadedBook = true
} catch {}
try {
if (!progress.counters.finishedWithinDay) {
progress.counters.finishedWithinDay = await finishedWithinOneDay(userId, null)
}
} catch {}
// 🔸 NEW: fallback detection for a deletion
try {
const currentBooks = await Database.bookModel.count()
const lastSeen = progress.counters.lastSeenBookCount
progress.counters.lastSeenBookCount = currentBooks
if (
progress.counters.uploadedBook && // user has ever uploaded
lastSeen !== null &&
currentBooks < lastSeen && // total down → something was deleted
!progress.counters.deletedFirstFile // only set once
) {
progress.counters.deletedFirstFile = true
}
} catch {}
const before = new Set(progress.unlocked)
progress.unlocked = computeUnlockedFresh(progress)
const now = new Date().toISOString()
for (const id of progress.unlocked) if (!before.has(id)) {
progress.history.push({ badgeId: id, unlockedAt: now })
}
return progress
}
async function getUserProgress (userId, opts = {}) {
const catalog = await loadCatalog()
let progress = (await Store.read(userId)) || emptyProgress(userId)
progress = normalizeWithCatalog(progress, catalog)
if (opts.syncFromDb) {
await syncFromDb(userId, progress)
progress = normalizeWithCatalog(progress, catalog)
await Store.write(userId, progress)
}
return progress
}
function updateLoginStreak (c) {
const today = dayKey()
if (c.lastLoginKey === today) {
c.todayLoginCount = (c.todayLoginCount || 0) + 1
return
}
const yesterday = today - 1
if (c.lastLoginKey === yesterday) c.loginStreak = (c.loginStreak || 0) + 1
else c.loginStreak = 1
c.lastLoginKey = today
c.todayLoginCount = 1
c.totalLogins = (c.totalLogins || 0) + 1
}
function updateReadingStreakOnFinish (c) {
const today = dayKey()
if (c.lastFinishedKey === today) {
// already counted today
} else if (c.lastFinishedKey === today - 1) {
c.readingStreak = (c.readingStreak || 0) + 1
c.lastFinishedKey = today
} else {
c.readingStreak = 1
c.lastFinishedKey = today
}
}
async function applyEvent (userId, event, meta = {}) {
const catalog = await loadCatalog()
const progress = await getUserProgress(userId)
switch (event) {
case 'itemFinished': {
updateReadingStreakOnFinish(progress.counters)
const rawId = meta.itemId != null ? meta.itemId : meta.libraryItemId
const itemId = rawId != null ? String(rawId) : null
if (itemId) {
const set = new Set(progress.counters.finishedUniqueIds || [])
set.add(itemId)
progress.counters.finishedUniqueIds = [...set]
}
let ids = []
try { ids = await getDistinctFinishedIds(userId) } catch {}
if (!ids.length && progress.counters.finishedUniqueIds?.length) {
ids = [...new Set(progress.counters.finishedUniqueIds)]
}
progress.counters.finishedUniqueIds = ids
progress.counters.finishedUniqueCount = ids.length
progress.counters.finishedCount = ids.length
try {
const withinDay = await finishedWithinOneDay(userId, itemId || null)
if (withinDay) progress.counters.finishedWithinDay = true
} catch {}
break
}
// first-time/flags
case 'createdLibrary': progress.counters.createdLibrary = true; break
case 'editedLibrary': progress.counters.editedLibrary = true; break
case 'uploadedBook': progress.counters.uploadedBook = true; break
case 'downloadedBook': progress.counters.downloadedBook = true; break
case 'createdSeries': progress.counters.createdSeries = true; break
case 'createdCollection': progress.counters.createdCollection = true; break
case 'createdPlaylist': progress.counters.createdPlaylist = true; break
case 'openedAuthor': progress.counters.openedAuthor = true; break
case 'openedNarrator': progress.counters.openedNarrator = true; break
case 'visitedSettings': progress.counters.visitedSettings = true; break
case 'visitedUpload': progress.counters.visitedUpload = true; break
// 🔸 NEW: first file deletion
case 'deletedFile':
if (!progress.counters.deletedFirstFile) progress.counters.deletedFirstFile = true
break
// searches
case 'performedSearch':
progress.counters.performedSearch = true
progress.counters.searchCount = (progress.counters.searchCount || 0) + 1
break
// login
case 'userLoggedIn':
updateLoginStreak(progress.counters)
break
default: break
}
const before = new Set(progress.unlocked)
progress.unlocked = computeUnlockedFresh(progress)
const unlockedNow = []
const now = new Date().toISOString()
for (const id of progress.unlocked) {
if (!before.has(id)) {
unlockedNow.push(catalog.badges?.[id] || { id })
progress.history.push({ badgeId: id, unlockedAt: now })
}
}
await Store.write(userId, progress)
return { unlockedNow, progress }
}
module.exports = {
getCatalog: loadCatalog,
getUserProgress,
applyEvent
}

View file

@ -0,0 +1,89 @@
// server/middleware/achievementAutoTracker.js
// Best-effort, low-overhead server middleware that translates common API
// requests into achievement events. It listens for the final status code so we
// only record events on successful requests.
const AchievementManager = require('../managers/AchievementManager')
// normalize to YYYY-MM-DD
const today = () => new Date().toISOString().slice(0, 10)
module.exports = function achievementAutoTracker () {
return (req, res, next) => {
// Well run *after* the auth middleware inside the /api chain so req.user
// is populated (either session or JWT).
const userId = req.user && (req.user.id || req.user.userId)
? String(req.user.id || req.user.userId)
: null
const method = req.method || 'GET'
const path = req.path || ''
const q = req.query || {}
const fire = (event) => {
if (!userId) return
AchievementManager.applyEvent(userId, event).catch(() => {})
}
// Mark a login once per day on the first authed API hit we see.
if (userId) {
const t = today()
if (!req.session) req.session = {}
if (req.session.__achLoginDate !== t) {
req.session.__achLoginDate = t
fire('login')
}
}
// -------------------- SEARCH + FILTERS --------------------
// Explicit search endpoint
if (method === 'GET' && /^\/api\/libraries\/[^/]+\/search\b/.test(path)) {
fire('performedSearch')
}
// Filters on listing endpoints (heuristic: presence of filter-ish query keys)
if (method === 'GET' && /^\/api\/libraries\/[^/]+\//.test(path)) {
const keysStr = Object.keys(q).join(',').toLowerCase()
if (keysStr && /(filter|filters|sort|order|tag|genre|author|narrator|series|collection|min|max|asc|desc)/.test(keysStr)) {
fire('appliedFilter')
}
}
// -------------------- LIBRARIES ---------------------------
// Created library
if (userId && method === 'POST' && /^\/api\/libraries\b/.test(path)) {
res.on('finish', () => { if (res.statusCode < 400) fire('createdLibrary') })
}
// Edited library (PUT or PATCH /api/libraries/:id)
if (userId && (method === 'PUT' || method === 'PATCH') && /^\/api\/libraries\/[^/]+$/.test(path)) {
res.on('finish', () => { if (res.statusCode < 400) fire('editedLibrary') })
}
// -------------------- COLLECTIONS / PLAYLISTS -------------
// Created collection
if (userId && method === 'POST' && /^\/api\/collections?\b/.test(path)) {
res.on('finish', () => { if (res.statusCode < 400) fire('createdCollection') })
}
// Created playlist (covers /api/playlists or /api/playlist)
if (userId && method === 'POST' && /^\/api\/playlists?\b/.test(path)) {
res.on('finish', () => { if (res.statusCode < 400) fire('createdPlaylist') })
}
// -------------------- BOOK DOWNLOADS ----------------------
// ABS uses a few different download/serve endpoints. We fire when it looks
// like the user fetched a file for an item.
if (userId && method === 'GET') {
const isItemDownload =
/^\/api\/items\/[a-z0-9-]{36}\//.test(path) &&
/(download|ebook|file|audio|stream)/i.test(path)
const isGenericDownload = /\/download\b/i.test(path)
if (isItemDownload || isGenericDownload) {
res.on('finish', () => { if (res.statusCode < 400) fire('downloadedBook') })
}
}
next()
}
}

View file

@ -0,0 +1,50 @@
// server/middleware/sessionOrJwt.js
/**
* Accept either an existing session user (passport session)
* or a JWT bearer token (API). Returns 401 only if neither is present.
*
* 👉 Enhancement:
* When a user is authenticated, we emit the "userLoggedIn" achievement
* event in a fire-and-forget way so "First Login" (and streaks) unlock
* immediately for brand-new accounts too.
*/
const AchievementManager = require('../managers/AchievementManager')
function fireLoginEvent(req) {
try {
if (req._absLoginEventSent) return
const user = req.user
const uid = user && (user.id || user.userId)
if (!uid) return
req._absLoginEventSent = true
// Do not block the request if achievements write fails
AchievementManager.applyEvent(String(uid), 'userLoggedIn').catch(() => {})
} catch (_) {
// swallow — achievements must never break auth
}
}
module.exports = (auth) => {
if (!auth || typeof auth.isAuthenticated !== 'function') {
throw new Error('[sessionOrJwt] auth.isAuthenticated missing')
}
return (req, res, next) => {
// Session user already present?
if (req.user && (req.user.id || req.user.userId)) {
fireLoginEvent(req)
return next()
}
// Try JWT auth (no session). Use custom callback so we can continue.
auth.isAuthenticated(req, res, (err) => {
if (err) return next(err)
if (req.user && (req.user.id || req.user.userId)) {
fireLoginEvent(req)
return next()
}
return res.status(401).json({ error: 'Unauthorized' })
})
}
}

View file

@ -0,0 +1,41 @@
// server/middleware/trackDeletion.js
//
// Listens for successful DELETE/“delete”/“remove” API calls that target items/files,
// and awards the “deleted first file” achievement exactly once for the user.
//
const AchievementManager = require('../managers/AchievementManager')
module.exports = function trackDeletion () {
// Any API route that looks like it deletes items/files/books etc.
const looksLikeDeletePath = (p) => {
if (!p) return false
const path = String(p)
const hasDeleteVerb = /\/(delete|remove)(\/|$)/i.test(path)
const deleteMethod = /\/api\//i.test(path) && (
hasDeleteVerb || /\/(items?|books?|library-items?|uploads?|files?)\//i.test(path)
)
return deleteMethod
}
return function (req, res, next) {
// Well track a successful DELETE method OR POSTs that hit “/delete|/remove”
const shouldWatch =
(req.method === 'DELETE' && looksLikeDeletePath(req.originalUrl || req.url)) ||
(['POST', 'PUT', 'PATCH'].includes(req.method) && /\/(delete|remove)(\/|$)/i.test(req.originalUrl || req.url))
if (!shouldWatch) return next()
const userId = String(req.user?.id || req.user?.userId || req.session?.userId || '')
// Only attach a listener; do not block the request handler
res.on('finish', () => {
if (!userId) return
// Only award if the operation actually succeeded
if (res.statusCode >= 200 && res.statusCode < 300) {
AchievementManager.applyEvent(userId, 'deletedFile').catch(() => {})
}
})
next()
}
}

View file

@ -0,0 +1,41 @@
// server/routers/achievements.js
const express = require('express')
const AchievementManager = require('../managers/AchievementManager')
const sessionOrJwt = require('../middleware/sessionOrJwt')
module.exports = ({ auth } = {}) => {
if (!auth) throw new Error('[achievements router] Missing auth')
const router = express.Router()
const requireUser = sessionOrJwt(auth)
// PUBLIC — catalog
router.get('/catalog', async (req, res, next) => {
try {
const data = await AchievementManager.getCatalog()
res.json(data)
} catch (e) { next(e) }
})
// Needs user (session OR JWT) — also backfill/sync counts from DB
router.get('/me', requireUser, async (req, res, next) => {
try {
const userId = String(req.user?.id || req.user?.userId || req.session?.userId)
const progress = await AchievementManager.getUserProgress(userId, { syncFromDb: true })
res.json(progress)
} catch (e) { next(e) }
})
// Mark an achievement-related event as completed
router.post('/complete', requireUser, express.json(), async (req, res, next) => {
try {
const userId = String(req.user?.id || req.user?.userId || req.session?.userId)
const { event, meta } = req.body || {}
if (!event) return res.status(400).json({ error: 'Missing event' })
const result = await AchievementManager.applyEvent(userId, event, meta || {})
res.json(result)
} catch (e) { next(e) }
})
return router
}

View file

@ -0,0 +1,55 @@
// server/utils/userProgressStore.js
const Path = require('path')
const fsx = require('../libs/fsExtra')
function dir () {
// Persist under MetadataPath so it survives restarts/backups similar to other ABS data
const root = global.MetadataPath || Path.join(process.cwd(), '.metadata')
const p = Path.join(root, 'achievements')
if (!fsx.pathExistsSync(p)) fsx.mkdirpSync(p)
return p
}
function file (userId) {
return Path.join(dir(), `${userId}.json`)
}
async function read (userId) {
const f = file(userId)
if (!fsx.pathExistsSync(f)) return null
try {
return JSON.parse(await fsx.readFile(f, 'utf8'))
} catch {
return null
}
}
async function write (userId, data) {
const f = file(userId)
const payload = JSON.stringify(data, null, 2)
// Your fs-extra wrapper doesnt expose writeFileAtomic.
// Use a safe fallback; prefer atomic (tmp + rename) when possible.
try {
if (typeof fsx.writeFileAtomic === 'function') {
await fsx.writeFileAtomic(f, payload, 'utf8')
} else {
const tmp = f + '.tmp'
await fsx.writeFile(tmp, payload, 'utf8')
// Some wrappers expose rename, some move; fall back to writeFile
if (typeof fsx.rename === 'function') {
await fsx.rename(tmp, f)
} else if (typeof fsx.move === 'function') {
await fsx.move(tmp, f, { overwrite: true })
} else {
// last-resort non-atomic
await fsx.writeFile(f, payload, 'utf8')
}
}
} catch {
// Non-atomic final fallback to guarantee progress is saved
await fsx.writeFile(f, payload, 'utf8')
}
}
module.exports = { read, write }