diff --git a/client/src/components/AppSidebar.vue b/client/src/components/AppSidebar.vue index 758b5bc98..255aa8ca0 100644 --- a/client/src/components/AppSidebar.vue +++ b/client/src/components/AppSidebar.vue @@ -40,6 +40,7 @@ import SettingsSidebar from '@/features/settings/components/SettingsSidebar.vue' import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew' import { useBookRequestSummary } from '@/features/book-requests/composables/useBookRequestSummary' import { useBookRequestProgress } from '@/features/book-requests/composables/useBookRequestProgress' +import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility' const { t } = useI18n() const route = useRoute() @@ -50,6 +51,7 @@ const { libraries, fetchLibraries, refreshLibraries, reorderLibraries } = useLib const { bookScopes, podcastScopes, fetchSmartScopes, reorderSmartScopes } = useSmartScopes() const { bookCollections, podcastCollections, fetchCollections, reorderCollections } = useCollections() const { hasPermission } = usePermissions() +const { showBookRequests } = useBookRequestVisibility() const { subscribeLibrary, getProgress } = useScanProgress() const podcastImportProgress = APP_FEATURES.podcasts ? usePodcastImportProgress() : null const { handleLibraryCreated } = useLibraryCreationRedirect() @@ -268,7 +270,7 @@ onMounted(async () => { void fetchSmartScopes() void fetchCollections() void fetchBrowseCounts() - if (hasPermission(Permission.BookRequestAccess)) void fetchBookRequestSummary() + if (showBookRequests.value && hasPermission(Permission.BookRequestAccess)) void fetchBookRequestSummary() void loadAppInfo() if (hasPermission('book_dock_access')) { void fetchBookDockSummary() @@ -277,15 +279,19 @@ onMounted(async () => { }) requestProgress?.onRequestsChanged(() => { - void refreshBookRequestSummary() + if (showBookRequests.value) void refreshBookRequestSummary() }) if (requestProgress) { watch(requestProgress.connected, (connected) => { - if (connected) void refreshBookRequestSummary() + if (connected && showBookRequests.value) void refreshBookRequestSummary() }) } +watch(showBookRequests, (visible) => { + if (visible && hasPermission(Permission.BookRequestAccess)) void fetchBookRequestSummary() +}) + const { onLibraryUploadCompleted } = useLibraryUploadEvents() const stopLibraryUploadListener = onLibraryUploadCompleted((event) => { if (event.uploadedCount === 0) return diff --git a/client/src/composables/__tests__/useSidebarNav.spec.ts b/client/src/composables/__tests__/useSidebarNav.spec.ts index c29bbf5ce..a68b513b8 100644 --- a/client/src/composables/__tests__/useSidebarNav.spec.ts +++ b/client/src/composables/__tests__/useSidebarNav.spec.ts @@ -31,6 +31,7 @@ vi.mock('@/features/auth/composables/usePermissions', () => ({ function makeContext( overrides: Partial<{ permissions: string[] + showBookRequests: boolean bookDockTotal: number outstandingRequestTotal: number outstandingRequestLabel: string @@ -41,6 +42,7 @@ function makeContext( const permissions = overrides.permissions ?? [] return { hasPermission: (name: string) => permissions.includes(name), + showBookRequests: overrides.showBookRequests, bookDockTotal: overrides.bookDockTotal ?? 0, outstandingRequestTotal: overrides.outstandingRequestTotal ?? 0, outstandingRequestLabel: overrides.outstandingRequestLabel ?? '', @@ -97,6 +99,12 @@ describe('sidebar nav registry', () => { expect(allowedIds(makeContext())).not.toContain('book-requests') }) + it('hides Requests without changing permission when the user turns visibility off', () => { + const context = makeContext({ permissions: ['book_request_access'], showBookRequests: false }) + expect(allowedIds(context)).not.toContain('book-requests') + expect(context.hasPermission('book_request_access')).toBe(true) + }) + it.each(['book-requests', 'book-request-detail', 'book-request-releases'])('keeps Requests active on %s', (routeName) => { const resolved = resolveNavEntry(entry('book-requests'), makeContext({ permissions: ['book_request_access'] }), makeRoute(routeName), 'Requests') diff --git a/client/src/composables/useSidebarNav.ts b/client/src/composables/useSidebarNav.ts index 6e3a635ed..98e22d779 100644 --- a/client/src/composables/useSidebarNav.ts +++ b/client/src/composables/useSidebarNav.ts @@ -8,12 +8,14 @@ import { useBookDockSummary } from '@/features/book-dock/composables/useBookDock import { useLibraries } from '@/features/library/composables/useLibraries' import { useBrowseCounts } from '@/composables/useBrowseCounts' import { useMediaMode } from '@/composables/useMediaMode' +import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility' export const SIDEBAR_ZONE_IDS = ['primary', 'browse'] as const export type SidebarZoneId = (typeof SIDEBAR_ZONE_IDS)[number] interface NavContext { hasPermission: (name: string) => boolean + showBookRequests?: boolean bookDockTotal: number outstandingRequestTotal: number outstandingRequestLabel: string @@ -115,6 +117,7 @@ export const SIDEBAR_NAV_REGISTRY: readonly SidebarNavEntry[] = [ // The drawer routes are children of this one, so the row has to stay lit while one is open. isActive: (route) => routeNameStartsWith(route, 'book-request'), permission: 'book_request_access', + visible: (context) => context.showBookRequests !== false, badge: (context) => context.outstandingRequestTotal > 0 ? { value: context.outstandingRequestTotal, label: context.outstandingRequestLabel, tone: 'accent' } : null, }, @@ -203,6 +206,7 @@ export function useSidebarNav(getOutstandingRequestTotal: () => number = () => 0 const { counts: browseCounts } = useBrowseCounts() const { libraries } = useLibraries() const { mode } = useMediaMode() + const { showBookRequests } = useBookRequestVisibility() const context = computed(() => { const outstandingRequestTotal = getOutstandingRequestTotal() @@ -212,6 +216,7 @@ export function useSidebarNav(getOutstandingRequestTotal: () => number = () => 0 return { hasPermission, + showBookRequests: showBookRequests.value, bookDockTotal: bookDockSummary.value.total, outstandingRequestTotal, outstandingRequestLabel: t(outstandingRequestLabelKey, { count: outstandingRequestTotal }), diff --git a/client/src/features/book-requests/composables/useBookRequestVisibility.spec.ts b/client/src/features/book-requests/composables/useBookRequestVisibility.spec.ts new file mode 100644 index 000000000..d602751c0 --- /dev/null +++ b/client/src/features/book-requests/composables/useBookRequestVisibility.spec.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { useBookRequestVisibility } from './useBookRequestVisibility' + +const state = vi.hoisted(() => ({ + user: null as { id: number; settings: { showBookRequests?: boolean } } | null, + api: vi.fn<() => Promise<{ ok: boolean }>>(), + fetchNotifications: vi.fn<() => Promise>(), + fetchUnreadCount: vi.fn<() => Promise>(), +})) + +vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: () => ({ user: ref(state.user) }) })) +vi.mock('@/lib/api', () => ({ api: state.api })) +vi.mock('@/features/notifications/composables/useNotifications', () => ({ + useNotifications: () => ({ fetchNotifications: state.fetchNotifications, fetchUnreadCount: state.fetchUnreadCount }), +})) + +beforeEach(() => { + vi.clearAllMocks() + state.user = { id: 7, settings: {} } + state.api.mockResolvedValue({ ok: true }) +}) + +describe('useBookRequestVisibility', () => { + it('shows Requests when the preference is absent, including older server responses', () => { + expect(useBookRequestVisibility().showBookRequests.value).toBe(true) + }) + + it('saves only the new setting and updates the current account after success', async () => { + const visibility = useBookRequestVisibility() + expect(await visibility.setShowBookRequests(false)).toBe(true) + expect(state.api).toHaveBeenCalledWith('/api/v1/users/me/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: { showBookRequests: false } }), + }) + expect(visibility.showBookRequests.value).toBe(false) + expect(state.fetchNotifications).toHaveBeenCalledWith(true) + expect(state.fetchUnreadCount).toHaveBeenCalled() + }) + + it('leaves visibility unchanged when the save fails', async () => { + state.api.mockResolvedValue({ ok: false }) + const visibility = useBookRequestVisibility() + expect(await visibility.setShowBookRequests(false)).toBe(false) + expect(visibility.showBookRequests.value).toBe(true) + }) +}) diff --git a/client/src/features/book-requests/composables/useBookRequestVisibility.ts b/client/src/features/book-requests/composables/useBookRequestVisibility.ts new file mode 100644 index 000000000..66eabf6bf --- /dev/null +++ b/client/src/features/book-requests/composables/useBookRequestVisibility.ts @@ -0,0 +1,37 @@ +import { computed, ref } from 'vue' +import { useAuth } from '@/features/auth/composables/useAuth' +import { api } from '@/lib/api' +import { useNotifications } from '@/features/notifications/composables/useNotifications' + +const saving = ref(false) + +export function useBookRequestVisibility() { + const { user } = useAuth() + const showBookRequests = computed(() => user.value?.settings?.showBookRequests !== false) + + async function setShowBookRequests(show: boolean): Promise { + if (!user.value || saving.value) return false + if (showBookRequests.value === show) return true + + const userId = user.value.id + saving.value = true + try { + const response = await api('/api/v1/users/me/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: { showBookRequests: show } }), + }) + if (!response.ok || user.value?.id !== userId) return false + user.value = { ...user.value, settings: { ...user.value.settings, showBookRequests: show } } + const { fetchNotifications, fetchUnreadCount } = useNotifications() + void Promise.all([fetchNotifications(true), fetchUnreadCount()]) + return true + } catch { + return false + } finally { + saving.value = false + } + } + + return { showBookRequests, saving, setShowBookRequests } +} diff --git a/client/src/features/notifications/components/NotificationPreferences.vue b/client/src/features/notifications/components/NotificationPreferences.vue index 8f1134cb7..f0ba8af81 100644 --- a/client/src/features/notifications/components/NotificationPreferences.vue +++ b/client/src/features/notifications/components/NotificationPreferences.vue @@ -12,12 +12,14 @@ import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue' import ToggleSwitch from '@/components/ui/ToggleSwitch.vue' import NotificationLevelSegmented from './NotificationLevelSegmented.vue' import { NOTIFICATION_CATEGORY_GROUPS, NOTIFICATION_CATEGORY_ICONS } from '../lib/notification-category-groups' +import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility' const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false }) const { t } = useI18n() const { user, me } = useAuth() +const { showBookRequests } = useBookRequestVisibility() const { popupEnabled, setPopupEnabled, loadPrefs } = useWhatsNew() const saving = ref(false) @@ -54,8 +56,15 @@ const hasChanges = computed(() => { return NOTIFICATION_CATEGORY_IDS.some((key) => preferences.value[key] !== resolveNotificationLevel(userPrefs?.[key])) }) -const enabledCount = computed(() => NOTIFICATION_CATEGORY_IDS.filter((key) => preferences.value[key] !== NotificationLevel.Off).length) -const totalCount = computed(() => NOTIFICATION_CATEGORY_IDS.length) +const visibleGroups = computed(() => + NOTIFICATION_CATEGORY_GROUPS.map((group) => ({ + ...group, + categories: group.categories.filter((category) => category !== 'bookRequests' || showBookRequests.value), + })).filter((group) => group.categories.length > 0), +) +const visibleCategoryIds = computed(() => visibleGroups.value.flatMap((group) => group.categories)) +const enabledCount = computed(() => visibleCategoryIds.value.filter((key) => preferences.value[key] !== NotificationLevel.Off).length) +const totalCount = computed(() => visibleCategoryIds.value.length) function categoryLabel(category: NotificationCategory): string { return t(`notifications.preferences.categories.${category}.label`) @@ -126,7 +135,7 @@ async function handleSave() {
-
+

{{ t(`notifications.preferences.groups.${group.id}`) }} diff --git a/client/src/features/settings/AppearanceBehaviorSettings.vue b/client/src/features/settings/AppearanceBehaviorSettings.vue index ea9c3d90a..2ef2e5b7d 100644 --- a/client/src/features/settings/AppearanceBehaviorSettings.vue +++ b/client/src/features/settings/AppearanceBehaviorSettings.vue @@ -2,14 +2,17 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { BookOpen, BookText } from '@lucide/vue' +import { toast } from 'vue-sonner' import ToggleSwitch from '@/components/ui/ToggleSwitch.vue' import { useDisplaySettings, type BookThumbnailClickAction } from '@/composables/useDisplaySettings' import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference' +import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility' const { t } = useI18n() const { smartScopeFilterExpanded, thumbnailClickAction } = useDisplaySettings() const { prefs, setPreference } = useSeriesCollapsePreference() +const { showBookRequests, saving: savingBookRequestVisibility, setShowBookRequests } = useBookRequestVisibility() const globalCollapseEnabled = computed(() => prefs.value?.global ?? false) @@ -39,6 +42,10 @@ async function handleGlobalCollapseToggle(value: boolean) { await setPreference('global', value) } +async function handleBookRequestVisibilityToggle(value: boolean) { + if (!(await setShowBookRequests(value))) toast.error(t('settings.appearance.behavior.bookRequests.saveFailed')) +} + function setThumbnailClickAction(action: BookThumbnailClickAction) { thumbnailClickAction.value = action } @@ -100,6 +107,18 @@ function setThumbnailClickAction(action: BookThumbnailClickAction) {

+
+
+

{{ t('settings.appearance.behavior.bookRequests.label') }}

+

{{ t('settings.appearance.behavior.bookRequests.hint') }}

+
+ +
diff --git a/client/src/features/settings/__tests__/settings-nav.spec.ts b/client/src/features/settings/__tests__/settings-nav.spec.ts index 730fa2faf..2215d49ee 100644 --- a/client/src/features/settings/__tests__/settings-nav.spec.ts +++ b/client/src/features/settings/__tests__/settings-nav.spec.ts @@ -57,6 +57,13 @@ describe('visibleSettingsNav', () => { expect(visibleSettingsNav(SUPERUSER).map((group) => group.id)).toEqual(['you', 'library', 'devices', 'accounts', 'server']) }) + it('hides only request administration when Requests are hidden', () => { + const normal = visibleSettingsNav(SUPERUSER).flatMap((group) => group.items.map((item) => item.id)) + const hidden = visibleSettingsNav({ ...SUPERUSER, showBookRequests: false }).flatMap((group) => group.items.map((item) => item.id)) + expect(normal).toContain('requests') + expect(hidden).toEqual(normal.filter((id) => id !== 'requests')) + }) + it('hides notifications without notification access', () => { const you = visibleSettingsNav(NOBODY)[0] expect(you?.items.map((item) => item.id)).not.toContain('notifications') diff --git a/client/src/features/settings/components/SettingsNav.vue b/client/src/features/settings/components/SettingsNav.vue index c1b02316a..b70e1a162 100644 --- a/client/src/features/settings/components/SettingsNav.vue +++ b/client/src/features/settings/components/SettingsNav.vue @@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router' import { ArrowLeft, ChevronRight, Search, X } from '@lucide/vue' import { useModifierKey } from '@/composables/useModifierKey' import { usePermissions } from '@/features/auth/composables/usePermissions' +import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility' import { useSettingsNavStatus } from '../composables/useSettingsNavStatus' import { visibleSettingsNav, type SettingsNavGroup, type SettingsNavItem } from '../lib/settings-nav' interface SearchHit { @@ -18,6 +19,7 @@ const { t } = useI18n() const route = useRoute() const router = useRouter() const { isSuperuser, userPermissions, isDemoRestrictedAccount } = usePermissions() +const { showBookRequests } = useBookRequestVisibility() const { isMac, modifierKey } = useModifierKey() /** Apple writes the combo as one glyph pair, everyone else separates the two keys. */ @@ -31,6 +33,7 @@ const groups = computed(() => isSuperuser: isSuperuser.value, permissions: userPermissions.value, isDemoRestricted: isDemoRestrictedAccount.value, + showBookRequests: showBookRequests.value, }), ) diff --git a/client/src/features/settings/components/SettingsSidebar.vue b/client/src/features/settings/components/SettingsSidebar.vue index 319bf909f..ec8d1a6d5 100644 --- a/client/src/features/settings/components/SettingsSidebar.vue +++ b/client/src/features/settings/components/SettingsSidebar.vue @@ -5,6 +5,7 @@ import { useRoute } from 'vue-router' import { ArrowLeft } from '@lucide/vue' import SidebarSectionPopover from '@/components/sidebar/SidebarSectionPopover.vue' import { usePermissions } from '@/features/auth/composables/usePermissions' +import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility' import { visibleSettingsNav, type SettingsNavItem } from '../lib/settings-nav' import SettingsNav from './SettingsNav.vue' @@ -13,12 +14,14 @@ withDefaults(defineProps<{ isRail?: boolean }>(), { isRail: false }) const { t } = useI18n() const route = useRoute() const { isSuperuser, userPermissions, isDemoRestrictedAccount } = usePermissions() +const { showBookRequests } = useBookRequestVisibility() const groups = computed(() => visibleSettingsNav({ isSuperuser: isSuperuser.value, permissions: userPermissions.value, isDemoRestricted: isDemoRestrictedAccount.value, + showBookRequests: showBookRequests.value, }), ) diff --git a/client/src/features/settings/lib/settings-nav.ts b/client/src/features/settings/lib/settings-nav.ts index 53d431a73..ef6782037 100644 --- a/client/src/features/settings/lib/settings-nav.ts +++ b/client/src/features/settings/lib/settings-nav.ts @@ -49,6 +49,7 @@ export interface SettingsNavContext { isSuperuser: boolean permissions: readonly string[] isDemoRestricted: boolean + showBookRequests?: boolean } /** Live signals the rail renders beside a row. Resolved by the nav, not stored in this file. */ @@ -496,7 +497,7 @@ export const SETTINGS_NAV: readonly SettingsNavGroup[] = [ descriptionKey: 'settings.nav.descriptions.requests', icon: DownloadCloud, keywords: 'request download client qbittorrent torrent magnet path mapping hardlink', - isVisible: anyPermission(Permission.ManageAppSettings), + isVisible: (context) => context.showBookRequests !== false && anyPermission(Permission.ManageAppSettings)(context), }, { id: 'admin-tts', diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 42b720378..e1170f25a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -428,6 +428,11 @@ "collapseSeries": { "label": "Collapse series by default", "hint": "Group books in the same series into a single card in library and collection views" + }, + "bookRequests": { + "label": "Show Book Requests", + "hint": "Show request pages, prompts, and settings in this account. Existing requests keep running when hidden.", + "saveFailed": "Could not update Book Requests visibility" } }, "icons": { diff --git a/client/src/router/__tests__/auth.guard.spec.ts b/client/src/router/__tests__/auth.guard.spec.ts index c990203c7..468711268 100644 --- a/client/src/router/__tests__/auth.guard.spec.ts +++ b/client/src/router/__tests__/auth.guard.spec.ts @@ -51,6 +51,10 @@ function makeRouter() { { path: '/setup', name: 'setup', component: blank, meta: { public: true } }, { path: '/forgot-password', name: 'forgot-password', component: blank, meta: { public: true } }, { path: '/reset-password', name: 'reset-password', component: blank, meta: { public: true } }, + { path: '/requests', name: 'book-requests', component: blank }, + { path: '/requests/:id', name: 'book-request-detail', component: blank }, + { path: '/settings/admin/requests', name: 'settings-admin-requests', component: blank }, + { path: '/settings/appearance/behavior', name: 'settings-appearance-behavior', component: blank }, ], }) registerAuthGuard(router) @@ -65,6 +69,22 @@ beforeEach(() => { statusState.passwordLoginEnabled = true }) +describe('auth guard: hidden book requests', () => { + it.each(['/requests', '/requests/42', '/settings/admin/requests'])('redirects %s to the visibility setting', async (path) => { + authState.user = { id: 1, isDefaultPassword: false, permissions: [], settings: { showBookRequests: false } } + const router = makeRouter() + await router.push(path) + expect(router.currentRoute.value.name).toBe('settings-appearance-behavior') + }) + + it('preserves existing request links when the preference is absent', async () => { + authState.user = { id: 1, isDefaultPassword: false, permissions: [], settings: {} } + const router = makeRouter() + await router.push('/requests') + expect(router.currentRoute.value.name).toBe('book-requests') + }) +}) + describe('auth guard: /register', () => { it('redirects a visitor to /login when self-registration is closed', async () => { const router = makeRouter() diff --git a/client/src/router/guards/auth.guard.ts b/client/src/router/guards/auth.guard.ts index 0209936d8..c3263cf7d 100644 --- a/client/src/router/guards/auth.guard.ts +++ b/client/src/router/guards/auth.guard.ts @@ -72,6 +72,16 @@ export function registerAuthGuard(router: Router): void { return { name: 'settings-account-profile' } } + if ( + user.value.settings.showBookRequests === false && + (to.name === 'book-requests' || + to.name === 'book-request-detail' || + to.name === 'book-request-releases' || + to.name === 'settings-admin-requests') + ) { + return { name: 'settings-appearance-behavior' } + } + return true }) } diff --git a/packages/types/src/auth.ts b/packages/types/src/auth.ts index 16888dba0..1cb32b2f5 100644 --- a/packages/types/src/auth.ts +++ b/packages/types/src/auth.ts @@ -33,6 +33,7 @@ export const ProvisioningMethod = { export type ProvisioningMethod = (typeof ProvisioningMethod)[keyof typeof ProvisioningMethod]; export interface UserSettings { + showBookRequests?: boolean; syncReaderPreferences?: boolean; syncThemePreferences?: boolean; statisticsConfig?: import("./statistics").StatisticsSettings; diff --git a/server/src/modules/notification/notification.repository.ts b/server/src/modules/notification/notification.repository.ts index 9b5672bd5..98b0e4074 100644 --- a/server/src/modules/notification/notification.repository.ts +++ b/server/src/modules/notification/notification.repository.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { and, count, desc, eq, inArray, lt, notInArray, sql } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; -import { APP_FEATURES, NotificationType } from '@bookorbit/types'; +import { APP_FEATURES, NOTIFICATION_TYPE_META, NotificationType } from '@bookorbit/types'; import { DB } from '../../db'; import * as schema from '../../db/schema'; import { notifications, users, userPermissions, userLibraryAccess } from '../../db/schema'; @@ -17,6 +17,7 @@ const PODCAST_NOTIFICATION_TYPES = [ ]; const visibleNotificationCondition = APP_FEATURES.podcasts ? undefined : notInArray(notifications.type, PODCAST_NOTIFICATION_TYPES); +const BOOK_REQUEST_NOTIFICATION_TYPES = Object.values(NotificationType).filter((type) => NOTIFICATION_TYPE_META[type]?.category === 'bookRequests'); @Injectable() export class NotificationRepository { @@ -43,30 +44,37 @@ export class NotificationRepository { } async findByUser(userId: number, limit: number, offset: number): Promise<{ items: Notification[]; total: number }> { + const requestCondition = await this.requestVisibilityCondition(userId); + const condition = and(eq(notifications.userId, userId), visibleNotificationCondition, requestCondition); const [items, [{ value: total }]] = await Promise.all([ this.db .select() .from(notifications) - .where(and(eq(notifications.userId, userId), visibleNotificationCondition)) + .where(condition) .orderBy(desc(notifications.updatedAt), desc(notifications.id)) .limit(limit) .offset(offset), - this.db - .select({ value: count() }) - .from(notifications) - .where(and(eq(notifications.userId, userId), visibleNotificationCondition)), + this.db.select({ value: count() }).from(notifications).where(condition), ]); return { items, total }; } async countUnread(userId: number): Promise { + const requestCondition = await this.requestVisibilityCondition(userId); const [{ value }] = await this.db .select({ value: count() }) .from(notifications) - .where(and(eq(notifications.userId, userId), eq(notifications.read, false), visibleNotificationCondition)); + .where(and(eq(notifications.userId, userId), eq(notifications.read, false), visibleNotificationCondition, requestCondition)); return value; } + private async requestVisibilityCondition(userId: number) { + const [user] = await this.db.select({ settings: users.settings }).from(users).where(eq(users.id, userId)).limit(1); + return (user?.settings as Record | null)?.showBookRequests === false + ? notInArray(notifications.type, BOOK_REQUEST_NOTIFICATION_TYPES) + : undefined; + } + async setRead(id: number, userId: number): Promise { const result = await this.db .update(notifications) @@ -76,10 +84,11 @@ export class NotificationRepository { } async setAllRead(userId: number): Promise { + const requestCondition = await this.requestVisibilityCondition(userId); const result = await this.db .update(notifications) .set({ read: true, updatedAt: sql`${notifications.updatedAt}` }) - .where(and(eq(notifications.userId, userId), eq(notifications.read, false), visibleNotificationCondition)); + .where(and(eq(notifications.userId, userId), eq(notifications.read, false), visibleNotificationCondition, requestCondition)); return result.rowCount ?? 0; } @@ -91,7 +100,8 @@ export class NotificationRepository { } async deleteAllForUser(userId: number): Promise { - const result = await this.db.delete(notifications).where(and(eq(notifications.userId, userId), visibleNotificationCondition)); + const requestCondition = await this.requestVisibilityCondition(userId); + const result = await this.db.delete(notifications).where(and(eq(notifications.userId, userId), visibleNotificationCondition, requestCondition)); return result.rowCount ?? 0; } diff --git a/server/src/modules/notification/notification.service.test.ts b/server/src/modules/notification/notification.service.test.ts index afc3f38a1..0389d437d 100644 --- a/server/src/modules/notification/notification.service.test.ts +++ b/server/src/modules/notification/notification.service.test.ts @@ -88,6 +88,21 @@ describe('NotificationService', () => { // ---------- notify() ---------- describe('notify()', () => { + it('does not dispatch request notifications to users who hide Requests', async () => { + repo.findUserSettings.mockResolvedValue( + new Map([ + [1, { showBookRequests: false }], + [2, {}], + ]), + ); + repo.insertOrCollapse.mockResolvedValue([makeInserted(2, { type: NotificationType.BookRequestSubmitted })]); + + await service.notify(makePayload({ kind: 'users', userIds: [1, 2] }, { type: NotificationType.BookRequestSubmitted })); + + expect(repo.insertOrCollapse).toHaveBeenCalledWith([expect.objectContaining({ userId: 2 })]); + expect(gateway.emitNew).toHaveBeenCalledTimes(1); + }); + it.each([NotificationType.PodcastEpisodePublished, NotificationType.PodcastFeedUnhealthy, NotificationType.PodcastDownloadFailed])( 'ignores disabled podcast notification type %s', async (type) => { diff --git a/server/src/modules/notification/notification.service.ts b/server/src/modules/notification/notification.service.ts index bf426168d..6dd188ebc 100644 --- a/server/src/modules/notification/notification.service.ts +++ b/server/src/modules/notification/notification.service.ts @@ -228,6 +228,7 @@ export class NotificationService { private isEnabled(settings: Record | undefined, type: NotificationType): boolean { const meta = NOTIFICATION_TYPE_META[type]; if (!meta) return true; + if (meta.category === 'bookRequests' && settings?.showBookRequests === false) return false; const prefs = settings?.notificationPreferences as NotificationPreferences | undefined; const level = resolveNotificationLevel(prefs?.[meta.category]); return isNotificationAllowed(level, meta.severity);