diff --git a/Halsey - Without Me (Lyrics)/Halsey - Without Me (Lyrics).mp3 b/Halsey - Without Me (Lyrics)/Halsey - Without Me (Lyrics).mp3 new file mode 100644 index 000000000..403bab071 Binary files /dev/null and b/Halsey - Without Me (Lyrics)/Halsey - Without Me (Lyrics).mp3 differ diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index 5f3642011..ac54a61c0 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -103,6 +103,13 @@
+ + + workspace_premium +

{{ $strings.ButtonBadges || 'My Badges' }}

+
+ + warning @@ -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' }, diff --git a/client/components/badges/BadgeGrid.vue b/client/components/badges/BadgeGrid.vue new file mode 100644 index 000000000..11175e87e --- /dev/null +++ b/client/components/badges/BadgeGrid.vue @@ -0,0 +1,25 @@ + + + diff --git a/client/components/badges/BadgeTile.vue b/client/components/badges/BadgeTile.vue new file mode 100644 index 000000000..31b36d6af --- /dev/null +++ b/client/components/badges/BadgeTile.vue @@ -0,0 +1,44 @@ + + + diff --git a/client/components/badges/CollectionCard.vue b/client/components/badges/CollectionCard.vue new file mode 100644 index 000000000..d224cd78a --- /dev/null +++ b/client/components/badges/CollectionCard.vue @@ -0,0 +1,18 @@ + + + diff --git a/client/components/modals/AchievementPopup.vue b/client/components/modals/AchievementPopup.vue new file mode 100644 index 000000000..d331a41b0 --- /dev/null +++ b/client/components/modals/AchievementPopup.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/client/nuxt.config.js b/client/nuxt.config.js index 7219c7847..c44481e8e 100644 --- a/client/nuxt.config.js +++ b/client/nuxt.config.js @@ -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.*'] diff --git a/client/pages/library/_library/search.vue b/client/pages/library/_library/search.vue index 429603bff..b3a2b59de 100644 --- a/client/pages/library/_library/search.vue +++ b/client/pages/library/_library/search.vue @@ -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() {} } diff --git a/client/pages/my-badges/_collectionId.vue b/client/pages/my-badges/_collectionId.vue new file mode 100644 index 000000000..3f7718413 --- /dev/null +++ b/client/pages/my-badges/_collectionId.vue @@ -0,0 +1,95 @@ + + + diff --git a/client/pages/my-badges/index.vue b/client/pages/my-badges/index.vue new file mode 100644 index 000000000..70592e246 --- /dev/null +++ b/client/pages/my-badges/index.vue @@ -0,0 +1,80 @@ + + + diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js index 5c5f281f6..ff281d9f8 100644 --- a/client/players/PlayerHandler.js +++ b/client/players/PlayerHandler.js @@ -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) { diff --git a/client/plugins/achievement-hooks.client.js b/client/plugins/achievement-hooks.client.js new file mode 100644 index 000000000..e9cf2d92b --- /dev/null +++ b/client/plugins/achievement-hooks.client.js @@ -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' }) + }) + } +} diff --git a/client/plugins/achievement-route-hooks.client.js b/client/plugins/achievement-route-hooks.client.js new file mode 100644 index 000000000..10e9bbd7e --- /dev/null +++ b/client/plugins/achievement-route-hooks.client.js @@ -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') + } + } + }) +} diff --git a/client/plugins/achievements-axios-hook.client.js b/client/plugins/achievements-axios-hook.client.js new file mode 100644 index 000000000..55ae0d1a3 --- /dev/null +++ b/client/plugins/achievements-axios-hook.client.js @@ -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 */ } + }) +} diff --git a/client/plugins/achievements-login.client.js b/client/plugins/achievements-login.client.js new file mode 100644 index 000000000..c682985eb --- /dev/null +++ b/client/plugins/achievements-login.client.js @@ -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(() => {}) +} diff --git a/client/plugins/achievements.axios.client.js b/client/plugins/achievements.axios.client.js new file mode 100644 index 000000000..b0a4c0995 --- /dev/null +++ b/client/plugins/achievements.axios.client.js @@ -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 {} + }) +} diff --git a/client/plugins/achievements.routes.client.js b/client/plugins/achievements.routes.client.js new file mode 100644 index 000000000..83247b103 --- /dev/null +++ b/client/plugins/achievements.routes.client.js @@ -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)) +} diff --git a/client/services/AchievementService.js b/client/services/AchievementService.js new file mode 100644 index 000000000..88e41a271 --- /dev/null +++ b/client/services/AchievementService.js @@ -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' }) + } +} diff --git a/client/static/badges/author_finder.png b/client/static/badges/author_finder.png new file mode 100644 index 000000000..0543a3ac4 Binary files /dev/null and b/client/static/badges/author_finder.png differ diff --git a/client/static/badges/avid.png b/client/static/badges/avid.png new file mode 100644 index 000000000..e705e43c4 Binary files /dev/null and b/client/static/badges/avid.png differ diff --git a/client/static/badges/back_to_back.png b/client/static/badges/back_to_back.png new file mode 100644 index 000000000..589815e17 Binary files /dev/null and b/client/static/badges/back_to_back.png differ diff --git a/client/static/badges/book_downloader.png b/client/static/badges/book_downloader.png new file mode 100644 index 000000000..6b1baf3db Binary files /dev/null and b/client/static/badges/book_downloader.png differ diff --git a/client/static/badges/book_uploader.png b/client/static/badges/book_uploader.png new file mode 100644 index 000000000..cd4450a6f Binary files /dev/null and b/client/static/badges/book_uploader.png differ diff --git a/client/static/badges/bookworm.png b/client/static/badges/bookworm.png new file mode 100644 index 000000000..64864a5f9 Binary files /dev/null and b/client/static/badges/bookworm.png differ diff --git a/client/static/badges/centurion.png b/client/static/badges/centurion.png new file mode 100644 index 000000000..a0e9f60f4 Binary files /dev/null and b/client/static/badges/centurion.png differ diff --git a/client/static/badges/collection_creator.png b/client/static/badges/collection_creator.png new file mode 100644 index 000000000..3318f8b19 Binary files /dev/null and b/client/static/badges/collection_creator.png differ diff --git a/client/static/badges/collection_maker.png b/client/static/badges/collection_maker.png new file mode 100644 index 000000000..80c201ff9 Binary files /dev/null and b/client/static/badges/collection_maker.png differ diff --git a/client/static/badges/curious.png b/client/static/badges/curious.png new file mode 100644 index 000000000..0abddd7c6 Binary files /dev/null and b/client/static/badges/curious.png differ diff --git a/client/static/badges/double_dip.png b/client/static/badges/double_dip.png new file mode 100644 index 000000000..419531dba Binary files /dev/null and b/client/static/badges/double_dip.png differ diff --git a/client/static/badges/excellence-awards.png b/client/static/badges/excellence-awards.png new file mode 100644 index 000000000..148a042ca Binary files /dev/null and b/client/static/badges/excellence-awards.png differ diff --git a/client/static/badges/explorer.png b/client/static/badges/explorer.png new file mode 100644 index 000000000..81f654ce8 Binary files /dev/null and b/client/static/badges/explorer.png differ diff --git a/client/static/badges/first_login.png b/client/static/badges/first_login.png new file mode 100644 index 000000000..f49872d3f Binary files /dev/null and b/client/static/badges/first_login.png differ diff --git a/client/static/badges/first_search.png b/client/static/badges/first_search.png new file mode 100644 index 000000000..a8383475b Binary files /dev/null and b/client/static/badges/first_search.png differ diff --git a/client/static/badges/focused_five.png b/client/static/badges/focused_five.png new file mode 100644 index 000000000..6d8226a94 Binary files /dev/null and b/client/static/badges/focused_five.png differ diff --git a/client/static/badges/hunter.png b/client/static/badges/hunter.png new file mode 100644 index 000000000..68541b8ad Binary files /dev/null and b/client/static/badges/hunter.png differ diff --git a/client/static/badges/legend.png b/client/static/badges/legend.png new file mode 100644 index 000000000..43248a376 Binary files /dev/null and b/client/static/badges/legend.png differ diff --git a/client/static/badges/library_creator.png b/client/static/badges/library_creator.png new file mode 100644 index 000000000..bf091b4b0 Binary files /dev/null and b/client/static/badges/library_creator.png differ diff --git a/client/static/badges/library_editor.png b/client/static/badges/library_editor.png new file mode 100644 index 000000000..84f81e0eb Binary files /dev/null and b/client/static/badges/library_editor.png differ diff --git a/client/static/badges/marathoner.png b/client/static/badges/marathoner.png new file mode 100644 index 000000000..5b02e8596 Binary files /dev/null and b/client/static/badges/marathoner.png differ diff --git a/client/static/badges/master_queries.png b/client/static/badges/master_queries.png new file mode 100644 index 000000000..20232097d Binary files /dev/null and b/client/static/badges/master_queries.png differ diff --git a/client/static/badges/monthly_reader.png b/client/static/badges/monthly_reader.png new file mode 100644 index 000000000..1b71b4326 Binary files /dev/null and b/client/static/badges/monthly_reader.png differ diff --git a/client/static/badges/narrator_finder.png b/client/static/badges/narrator_finder.png new file mode 100644 index 000000000..033e3010a Binary files /dev/null and b/client/static/badges/narrator_finder.png differ diff --git a/client/static/badges/new_reader.png b/client/static/badges/new_reader.png new file mode 100644 index 000000000..c0f099301 Binary files /dev/null and b/client/static/badges/new_reader.png differ diff --git a/client/static/badges/one_day_finish.png b/client/static/badges/one_day_finish.png new file mode 100644 index 000000000..1e766e956 Binary files /dev/null and b/client/static/badges/one_day_finish.png differ diff --git a/client/static/badges/page_turner.png b/client/static/badges/page_turner.png new file mode 100644 index 000000000..1299b10df Binary files /dev/null and b/client/static/badges/page_turner.png differ diff --git a/client/static/badges/pathfinder.png b/client/static/badges/pathfinder.png new file mode 100644 index 000000000..5cc8b30dc Binary files /dev/null and b/client/static/badges/pathfinder.png differ diff --git a/client/static/badges/refined_explorer.png b/client/static/badges/refined_explorer.png new file mode 100644 index 000000000..4d652f2cb Binary files /dev/null and b/client/static/badges/refined_explorer.png differ diff --git a/client/static/badges/searcher.png b/client/static/badges/searcher.png new file mode 100644 index 000000000..6707ca37d Binary files /dev/null and b/client/static/badges/searcher.png differ diff --git a/client/static/badges/seeker.png b/client/static/badges/seeker.png new file mode 100644 index 000000000..148a042ca Binary files /dev/null and b/client/static/badges/seeker.png differ diff --git a/client/static/badges/series_starter.png b/client/static/badges/series_starter.png new file mode 100644 index 000000000..0b309abe1 Binary files /dev/null and b/client/static/badges/series_starter.png differ diff --git a/client/static/badges/streak_3.png b/client/static/badges/streak_3.png new file mode 100644 index 000000000..48dc85418 Binary files /dev/null and b/client/static/badges/streak_3.png differ diff --git a/client/static/badges/streak_30.png b/client/static/badges/streak_30.png new file mode 100644 index 000000000..e731bf577 Binary files /dev/null and b/client/static/badges/streak_30.png differ diff --git a/client/static/badges/streak_7.png b/client/static/badges/streak_7.png new file mode 100644 index 000000000..4691905a8 Binary files /dev/null and b/client/static/badges/streak_7.png differ diff --git a/client/static/badges/tune_weaver.png b/client/static/badges/tune_weaver.png new file mode 100644 index 000000000..498089446 Binary files /dev/null and b/client/static/badges/tune_weaver.png differ diff --git a/client/static/badges/weekly_reader.png b/client/static/badges/weekly_reader.png new file mode 100644 index 000000000..033e3010a Binary files /dev/null and b/client/static/badges/weekly_reader.png differ diff --git a/server/Server.js b/server/Server.js index 457aa61ab..640a9fd3e 100644 --- a/server/Server.js +++ b/server/Server.js @@ -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 diff --git a/server/controllers/AchievementController.js b/server/controllers/AchievementController.js new file mode 100644 index 000000000..4b661808e --- /dev/null +++ b/server/controllers/AchievementController.js @@ -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) + } + } +} diff --git a/server/data/achievements.catalog.json b/server/data/achievements.catalog.json new file mode 100644 index 000000000..d512f0e93 --- /dev/null +++ b/server/data/achievements.catalog.json @@ -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": "Adventurer’s 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":"Can’t 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" } + } +} diff --git a/server/managers/AchievementManager.js b/server/managers/AchievementManager.js new file mode 100644 index 000000000..d4cc27e19 --- /dev/null +++ b/server/managers/AchievementManager.js @@ -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) + + // Adventurer’s 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 +} diff --git a/server/middleware/achievementAutoTracker.js b/server/middleware/achievementAutoTracker.js new file mode 100644 index 000000000..37b4b4679 --- /dev/null +++ b/server/middleware/achievementAutoTracker.js @@ -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) => { + // We’ll 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() + } +} diff --git a/server/middleware/sessionOrJwt.js b/server/middleware/sessionOrJwt.js new file mode 100644 index 000000000..4e5b4c0f5 --- /dev/null +++ b/server/middleware/sessionOrJwt.js @@ -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' }) + }) + } +} diff --git a/server/middleware/trackDeletion.js b/server/middleware/trackDeletion.js new file mode 100644 index 000000000..9340476de --- /dev/null +++ b/server/middleware/trackDeletion.js @@ -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) { + // We’ll 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() + } +} diff --git a/server/routers/achievements.js b/server/routers/achievements.js new file mode 100644 index 000000000..5d62c8a4c --- /dev/null +++ b/server/routers/achievements.js @@ -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 +} diff --git a/server/utils/userProgressStore.js b/server/utils/userProgressStore.js new file mode 100644 index 000000000..0443d45b0 --- /dev/null +++ b/server/utils/userProgressStore.js @@ -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 doesn’t 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 }