fix(smart-scope): enable per-user kobo sync for shared smart scopes

Store per-user Kobo sync subscriptions for shared smart scopes in a new database table. This allows non-owner users to independently toggle Kobo sync on public smart scopes without modifying the owner's settings.

Closes #795
This commit is contained in:
neon
2026-07-28 21:28:12 +00:00
parent c2884bd664
commit 75a7cd58b2
31 changed files with 16486 additions and 45 deletions
+10 -1
View File
@@ -48,6 +48,15 @@ useLibraryScanRefresh()
const SUPPORT_URL = 'https://ko-fi.com/neonbookorbit'
// Shared scopes belong to their owner, so their display order is not this user's to
// persist. Sending them anyway makes the server reject the whole reorder.
async function persistSmartScopeOrder(order: { id: number; displayOrder: number }[]) {
const ownedIds = new Set(smartScopes.value.filter((scope) => scope.isOwner).map((scope) => scope.id))
const ownedOrder = order.filter((entry) => ownedIds.has(entry.id))
if (ownedOrder.length === 0) return
await reorderSmartScopes(ownedOrder)
}
const createSmartScopeOpen = ref(false)
const createCollectionOpen = ref(false)
const createLibraryOpen = ref(false)
@@ -312,7 +321,7 @@ onUnmounted(() => stopLibraryUploadListener())
can-add
:add-label="t('components.sidebar.newSmartScope')"
can-reorder
:persist-order="reorderSmartScopes"
:persist-order="persistSmartScopeOrder"
@add="openCreateSmartScope"
@navigate="handleNavigate"
/>
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { Permission, type SmartScope } from '@bookorbit/types'
const mockState = vi.hoisted(() => ({
permissions: [] as string[],
isSuperuser: false,
setKoboSync: vi.fn<(id: number, enabled: boolean) => Promise<SmartScope>>(),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({
hasPermission: (name: string) => mockState.isSuperuser || mockState.permissions.includes(name),
}),
}))
vi.mock('../useSmartScopes', () => ({
useSmartScopes: () => ({ setKoboSync: mockState.setKoboSync }),
}))
function makeSmartScope(overrides: Partial<SmartScope> = {}): SmartScope {
return {
id: 11,
userId: 3,
name: 'Book Club',
icon: null,
filter: null,
defaultSort: [],
isPublic: true,
syncToKobo: false,
koboSyncEnabled: false,
isOwner: false,
displayOrder: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
}
describe('useSmartScopeKoboSync', () => {
beforeEach(() => {
mockState.permissions = [Permission.KoboSync]
mockState.isSuperuser = false
mockState.setKoboSync.mockReset()
mockState.setKoboSync.mockResolvedValue(makeSmartScope())
})
it('offers the opt-in to a viewer of a shared scope', async () => {
const { useSmartScopeKoboSync } = await import('../useSmartScopeKoboSync')
const { canToggle, isOwner, enabled } = useSmartScopeKoboSync(ref(makeSmartScope()))
expect(canToggle.value).toBe(true)
expect(isOwner.value).toBe(false)
expect(enabled.value).toBe(false)
})
it('hides the opt-in from the owner, whose toggle lives in the editor', async () => {
const { useSmartScopeKoboSync } = await import('../useSmartScopeKoboSync')
const { canToggle, isOwner } = useSmartScopeKoboSync(ref(makeSmartScope({ isOwner: true, syncToKobo: true })))
expect(canToggle.value).toBe(false)
expect(isOwner.value).toBe(true)
})
it('hides the opt-in from users without Kobo sync permission', async () => {
mockState.permissions = []
const { useSmartScopeKoboSync } = await import('../useSmartScopeKoboSync')
const { canToggle } = useSmartScopeKoboSync(ref(makeSmartScope()))
expect(canToggle.value).toBe(false)
})
it('reports the viewer own opt-in, not the owner Kobo preference', async () => {
const { useSmartScopeKoboSync } = await import('../useSmartScopeKoboSync')
const { enabled } = useSmartScopeKoboSync(ref(makeSmartScope({ syncToKobo: true, koboSyncEnabled: false })))
expect(enabled.value).toBe(false)
})
it('sends the inverted state and clears pending when it succeeds', async () => {
const { useSmartScopeKoboSync } = await import('../useSmartScopeKoboSync')
const scope = ref(makeSmartScope({ koboSyncEnabled: false }))
const { toggle, pending } = useSmartScopeKoboSync(scope)
const promise = toggle()
expect(pending.value).toBe(true)
await expect(promise).resolves.toBe(true)
expect(mockState.setKoboSync).toHaveBeenCalledWith(11, true)
expect(pending.value).toBe(false)
})
it('turns sync back off for a scope the viewer already opted into', async () => {
const { useSmartScopeKoboSync } = await import('../useSmartScopeKoboSync')
const { toggle } = useSmartScopeKoboSync(ref(makeSmartScope({ koboSyncEnabled: true })))
await expect(toggle()).resolves.toBe(false)
expect(mockState.setKoboSync).toHaveBeenCalledWith(11, false)
})
it('rethrows failures and clears pending so the control stays usable', async () => {
mockState.setKoboSync.mockRejectedValue(new Error('HTTP 500'))
const { useSmartScopeKoboSync } = await import('../useSmartScopeKoboSync')
const { toggle, pending } = useSmartScopeKoboSync(ref(makeSmartScope()))
await expect(toggle()).rejects.toThrow('HTTP 500')
expect(pending.value).toBe(false)
})
it('does nothing for the owner or a missing scope instead of calling a request that would fail', async () => {
const { useSmartScopeKoboSync } = await import('../useSmartScopeKoboSync')
await expect(useSmartScopeKoboSync(ref(makeSmartScope({ isOwner: true }))).toggle()).resolves.toBeNull()
await expect(useSmartScopeKoboSync(ref(null)).toggle()).resolves.toBeNull()
expect(mockState.setKoboSync).not.toHaveBeenCalled()
})
it('ignores a second toggle while the first is still in flight', async () => {
let resolveRequest!: (value: SmartScope) => void
mockState.setKoboSync.mockReturnValueOnce(new Promise<SmartScope>((resolve) => (resolveRequest = resolve)))
const { useSmartScopeKoboSync } = await import('../useSmartScopeKoboSync')
const { toggle } = useSmartScopeKoboSync(ref(makeSmartScope()))
const first = toggle()
await expect(toggle()).resolves.toBeNull()
resolveRequest(makeSmartScope({ koboSyncEnabled: true }))
await first
expect(mockState.setKoboSync).toHaveBeenCalledTimes(1)
})
})
@@ -17,6 +17,8 @@ function makeSmartScope(overrides: Partial<SmartScope> = {}): SmartScope {
defaultSort: [],
isPublic: false,
syncToKobo: false,
koboSyncEnabled: false,
isOwner: true,
displayOrder: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
@@ -67,6 +69,39 @@ describe('useSmartScopes', () => {
expect(smartScopes.value).not.toBe(previous)
})
it('sends the Kobo sync opt-in and merges the response without dropping the cached book count', async () => {
const shared = { ...makeSmartScope({ id: 11, userId: 4, isOwner: false, isPublic: true }), bookCount: 42 }
apiMock
.mockResolvedValueOnce(makeResponse([shared]))
.mockResolvedValueOnce(makeResponse(makeSmartScope({ id: 11, userId: 4, isOwner: false, isPublic: true, koboSyncEnabled: true })))
const { useSmartScopes } = await import('../useSmartScopes')
const { smartScopes, fetchSmartScopes, setKoboSync } = useSmartScopes()
await fetchSmartScopes()
await setKoboSync(11, true)
expect(apiMock).toHaveBeenLastCalledWith('/api/v1/smart-scopes/11/kobo-sync', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true }),
})
expect(smartScopes.value[0]).toEqual(expect.objectContaining({ id: 11, koboSyncEnabled: true, bookCount: 42 }))
})
it('leaves cached smartScopes untouched when the Kobo sync opt-in fails', async () => {
const shared = makeSmartScope({ id: 11, userId: 4, isOwner: false, isPublic: true })
apiMock.mockResolvedValueOnce(makeResponse([shared])).mockResolvedValueOnce({ ok: false, status: 403, json: async () => undefined } as Response)
const { useSmartScopes } = await import('../useSmartScopes')
const { smartScopes, fetchSmartScopes, setKoboSync } = useSmartScopes()
await fetchSmartScopes()
await expect(setKoboSync(11, true)).rejects.toThrow('HTTP 403')
expect(smartScopes.value).toEqual([shared])
})
it('resets cached smartScopes so the next fetch reloads them', async () => {
const first = makeSmartScope({ id: 1, name: 'Owner Scope' })
const second = makeSmartScope({ id: 2, userId: 4, name: 'Next User Scope' })
@@ -0,0 +1,33 @@
import { computed, ref, type Ref } from 'vue'
import { Permission, type SmartScope } from '@bookorbit/types'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useSmartScopes } from './useSmartScopes'
/**
* Kobo sync for a shared Smart Scope is a per-user opt-in: the owner's own toggle lives in
* the editor and only governs their devices, so a viewer needs a separate control here.
*/
export function useSmartScopeKoboSync(smartScope: Ref<SmartScope | null | undefined>) {
const { hasPermission } = usePermissions()
const { setKoboSync } = useSmartScopes()
const pending = ref(false)
const isOwner = computed(() => smartScope.value?.isOwner ?? false)
const enabled = computed(() => smartScope.value?.koboSyncEnabled ?? false)
const canToggle = computed(() => Boolean(smartScope.value) && !isOwner.value && hasPermission(Permission.KoboSync))
async function toggle(): Promise<boolean | null> {
const scope = smartScope.value
if (!scope || !canToggle.value || pending.value) return null
const next = !scope.koboSyncEnabled
pending.value = true
try {
await setKoboSync(scope.id, next)
return next
} finally {
pending.value = false
}
}
return { isOwner, enabled, canToggle, pending, toggle }
}
@@ -95,6 +95,18 @@ export function useSmartScopes() {
return updated
}
async function setKoboSync(id: number, enabled: boolean): Promise<SmartScope> {
const res = await api(`/api/v1/smart-scopes/${id}/kobo-sync`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const updated: SmartScope = await res.json()
smartScopes.value = smartScopes.value.map((l) => (l.id === id ? { ...l, ...updated } : l))
return updated
}
async function deleteSmartScope(id: number): Promise<void> {
const res = await api(`/api/v1/smart-scopes/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
@@ -119,6 +131,7 @@ export function useSmartScopes() {
refreshSmartScopes,
createSmartScope,
updateSmartScope,
setKoboSync,
deleteSmartScope,
reorderSmartScopes,
}
+9 -1
View File
@@ -6657,6 +6657,11 @@
"defaultName": "Smart Scope",
"filter": "Filter",
"edit": "Smart Scope bearbeiten",
"koboSync": {
"label": "Sync to my Kobo",
"enabledHint": "Books in this shared Smart Scope sync to your Kobo device. Turn off to stop syncing them.",
"disabledHint": "Turn on to sync books in this shared Smart Scope to your Kobo device."
},
"showControlsAria": "Smart-Scope-Steuerelemente anzeigen",
"hideFilter": "Filter ausblenden",
"showFilter": "Filter anzeigen",
@@ -6664,7 +6669,10 @@
"loadError": "Dieser SmartScope konnte nicht geladen werden",
"toast": {
"deleted": "\"{name}\" gelöscht",
"deleteFailed": "Löschen von \"{name}\" fehlgeschlagen"
"deleteFailed": "Löschen von \"{name}\" fehlgeschlagen",
"koboSyncEnabled": "\"{name}\" now syncs to your Kobo",
"koboSyncDisabled": "\"{name}\" no longer syncs to your Kobo",
"koboSyncFailed": "Failed to update Kobo sync for \"{name}\""
},
"empty": {
"noRules": "Keine Regeln konfiguriert",
+9 -1
View File
@@ -6657,6 +6657,11 @@
"defaultName": "Smart scope",
"filter": "Filter",
"edit": "Edit Smart Scope",
"koboSync": {
"label": "Sync to my Kobo",
"enabledHint": "Books in this shared Smart Scope sync to your Kobo device. Turn off to stop syncing them.",
"disabledHint": "Turn on to sync books in this shared Smart Scope to your Kobo device."
},
"showControlsAria": "Show Smart Scope controls",
"hideFilter": "Hide filter",
"showFilter": "Show filter",
@@ -6664,7 +6669,10 @@
"loadError": "Could not load this SmartScope",
"toast": {
"deleted": "\"{name}\" deleted",
"deleteFailed": "Failed to delete \"{name}\""
"deleteFailed": "Failed to delete \"{name}\"",
"koboSyncEnabled": "\"{name}\" now syncs to your Kobo",
"koboSyncDisabled": "\"{name}\" no longer syncs to your Kobo",
"koboSyncFailed": "Failed to update Kobo sync for \"{name}\""
},
"empty": {
"noRules": "No rules configured",
+9 -1
View File
@@ -6657,6 +6657,11 @@
"defaultName": "Alcance inteligente",
"filter": "Filtrar",
"edit": "Editar alcance inteligente",
"koboSync": {
"label": "Sync to my Kobo",
"enabledHint": "Books in this shared Smart Scope sync to your Kobo device. Turn off to stop syncing them.",
"disabledHint": "Turn on to sync books in this shared Smart Scope to your Kobo device."
},
"showControlsAria": "Mostrar controles de alcance inteligente",
"hideFilter": "Ocultar filtro",
"showFilter": "Mostrar filtro",
@@ -6664,7 +6669,10 @@
"loadError": "No se pudo cargar este SmartScope",
"toast": {
"deleted": "\"{name}\" eliminado",
"deleteFailed": "No se pudo eliminar \"{name}\""
"deleteFailed": "No se pudo eliminar \"{name}\"",
"koboSyncEnabled": "\"{name}\" now syncs to your Kobo",
"koboSyncDisabled": "\"{name}\" no longer syncs to your Kobo",
"koboSyncFailed": "Failed to update Kobo sync for \"{name}\""
},
"empty": {
"noRules": "No hay reglas configuradas",
+9 -1
View File
@@ -6657,6 +6657,11 @@
"defaultName": "Portée intelligente",
"filter": "Filtrer",
"edit": "Modifier la portée intelligente",
"koboSync": {
"label": "Sync to my Kobo",
"enabledHint": "Books in this shared Smart Scope sync to your Kobo device. Turn off to stop syncing them.",
"disabledHint": "Turn on to sync books in this shared Smart Scope to your Kobo device."
},
"showControlsAria": "Afficher les commandes Smart Scope",
"hideFilter": "Masquer le filtre",
"showFilter": "Afficher le filtre",
@@ -6664,7 +6669,10 @@
"loadError": "Impossible de charger ce SmartScope",
"toast": {
"deleted": "\"{name}\" supprimé",
"deleteFailed": "Échec de la suppression de \"{name}\""
"deleteFailed": "Échec de la suppression de \"{name}\"",
"koboSyncEnabled": "\"{name}\" now syncs to your Kobo",
"koboSyncDisabled": "\"{name}\" no longer syncs to your Kobo",
"koboSyncFailed": "Failed to update Kobo sync for \"{name}\""
},
"empty": {
"noRules": "Aucune règle configurée",
+9 -1
View File
@@ -6657,6 +6657,11 @@
"defaultName": "Ambito intelligente",
"filter": "Filtra",
"edit": "Modifica ambito intelligente",
"koboSync": {
"label": "Sync to my Kobo",
"enabledHint": "Books in this shared Smart Scope sync to your Kobo device. Turn off to stop syncing them.",
"disabledHint": "Turn on to sync books in this shared Smart Scope to your Kobo device."
},
"showControlsAria": "Mostra i controlli Smart Scope",
"hideFilter": "Nascondi filtro",
"showFilter": "Mostra filtro",
@@ -6664,7 +6669,10 @@
"loadError": "Impossibile caricare questo SmartScope",
"toast": {
"deleted": "\"{name}\" eliminato",
"deleteFailed": "Impossibile eliminare \"{name}\""
"deleteFailed": "Impossibile eliminare \"{name}\"",
"koboSyncEnabled": "\"{name}\" now syncs to your Kobo",
"koboSyncDisabled": "\"{name}\" no longer syncs to your Kobo",
"koboSyncFailed": "Failed to update Kobo sync for \"{name}\""
},
"empty": {
"noRules": "Nessuna regola configurata",
+9 -1
View File
@@ -6657,6 +6657,11 @@
"defaultName": "Smart scope",
"filter": "Filter",
"edit": "Smart Scope bewerken",
"koboSync": {
"label": "Sync to my Kobo",
"enabledHint": "Books in this shared Smart Scope sync to your Kobo device. Turn off to stop syncing them.",
"disabledHint": "Turn on to sync books in this shared Smart Scope to your Kobo device."
},
"showControlsAria": "Smart Scope-bediening tonen",
"hideFilter": "Filter verbergen",
"showFilter": "Filter tonen",
@@ -6664,7 +6669,10 @@
"loadError": "Kan deze SmartScope niet laden",
"toast": {
"deleted": "\"{name}\" verwijderd",
"deleteFailed": "Verwijderen van \"{name}\" mislukt"
"deleteFailed": "Verwijderen van \"{name}\" mislukt",
"koboSyncEnabled": "\"{name}\" now syncs to your Kobo",
"koboSyncDisabled": "\"{name}\" no longer syncs to your Kobo",
"koboSyncFailed": "Failed to update Kobo sync for \"{name}\""
},
"empty": {
"noRules": "Geen regels ingesteld",
+9 -1
View File
@@ -6657,6 +6657,11 @@
"defaultName": "Inteligentny zakres",
"filter": "Filtruj",
"edit": "Edytuj inteligentny zakres",
"koboSync": {
"label": "Sync to my Kobo",
"enabledHint": "Books in this shared Smart Scope sync to your Kobo device. Turn off to stop syncing them.",
"disabledHint": "Turn on to sync books in this shared Smart Scope to your Kobo device."
},
"showControlsAria": "Pokaż elementy sterujące inteligentnego zakresu",
"hideFilter": "Ukryj filtr",
"showFilter": "Pokaż filtr",
@@ -6664,7 +6669,10 @@
"loadError": "Nie można załadować tego SmartScope",
"toast": {
"deleted": "„{name}” usunięty",
"deleteFailed": "Nie udało się usunąć „{name}”"
"deleteFailed": "Nie udało się usunąć „{name}”",
"koboSyncEnabled": "\"{name}\" now syncs to your Kobo",
"koboSyncDisabled": "\"{name}\" no longer syncs to your Kobo",
"koboSyncFailed": "Failed to update Kobo sync for \"{name}\""
},
"empty": {
"noRules": "Nie skonfigurowano żadnych reguł",
+9 -1
View File
@@ -6657,6 +6657,11 @@
"defaultName": "Escopo inteligente",
"filter": "Filtro",
"edit": "Editar SmartScope",
"koboSync": {
"label": "Sync to my Kobo",
"enabledHint": "Books in this shared Smart Scope sync to your Kobo device. Turn off to stop syncing them.",
"disabledHint": "Turn on to sync books in this shared Smart Scope to your Kobo device."
},
"showControlsAria": "Exibir controles do SmartScope",
"hideFilter": "Ocultar filtro",
"showFilter": "Mostrar filtro",
@@ -6664,7 +6669,10 @@
"loadError": "Não foi possível carregar este SmartScope",
"toast": {
"deleted": "\"{name}\" excluído(a)",
"deleteFailed": "Falha ao excluir \"{name}\""
"deleteFailed": "Falha ao excluir \"{name}\"",
"koboSyncEnabled": "\"{name}\" now syncs to your Kobo",
"koboSyncDisabled": "\"{name}\" no longer syncs to your Kobo",
"koboSyncFailed": "Failed to update Kobo sync for \"{name}\""
},
"empty": {
"noRules": "Nenhuma regra configurada",
+9 -1
View File
@@ -6657,6 +6657,11 @@
"defaultName": "Pametni obseg",
"filter": "Filter",
"edit": "Uredi Smart Scope",
"koboSync": {
"label": "Sync to my Kobo",
"enabledHint": "Books in this shared Smart Scope sync to your Kobo device. Turn off to stop syncing them.",
"disabledHint": "Turn on to sync books in this shared Smart Scope to your Kobo device."
},
"showControlsAria": "Prikaži kontrolnike Smart Scope",
"hideFilter": "Skrij filter",
"showFilter": "Prikaži filter",
@@ -6664,7 +6669,10 @@
"loadError": "Tega SmartScope ni bilo mogoče naložiti",
"toast": {
"deleted": "\"{name}\" izbrisan",
"deleteFailed": "Brisanje \"{name}\" ni uspelo"
"deleteFailed": "Brisanje \"{name}\" ni uspelo",
"koboSyncEnabled": "\"{name}\" now syncs to your Kobo",
"koboSyncDisabled": "\"{name}\" no longer syncs to your Kobo",
"koboSyncFailed": "Failed to update Kobo sync for \"{name}\""
},
"empty": {
"noRules": "Ni nastavljenih pravil",
+71 -6
View File
@@ -18,6 +18,7 @@ import {
Search,
SlidersHorizontal,
Square,
Tablet,
X,
} from '@lucide/vue'
import VirtualBookGrid from '@/features/book/components/VirtualBookGrid.vue'
@@ -40,6 +41,7 @@ import { toast } from 'vue-sonner'
import { useBookViewWindow } from '@/features/book/composables/useBookViewWindow'
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes'
import { useSmartScopeKoboSync } from '@/features/smart-scope/composables/useSmartScopeKoboSync'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useEffectiveViewMode } from '@/composables/useEffectiveViewMode'
import { useViewDisplaySettings } from '@/composables/useViewDisplaySettings'
@@ -286,6 +288,26 @@ const editorOpen = ref(false)
const confirmSmartScopeDelete = ref(false)
const deleting = ref(false)
const {
isOwner: isSmartScopeOwner,
enabled: koboSyncEnabled,
canToggle: canToggleKoboSync,
pending: koboSyncPending,
toggle: toggleKoboSync,
} = useSmartScopeKoboSync(smartScope)
async function handleToggleKoboSync() {
const name = smartScope.value?.name ?? t('views.smartScope.defaultName')
try {
const next = await toggleKoboSync()
if (next === null) return
toast.success(next ? t('views.smartScope.toast.koboSyncEnabled', { name }) : t('views.smartScope.toast.koboSyncDisabled', { name }))
} catch {
toast.error(t('views.smartScope.toast.koboSyncFailed', { name }))
}
collapseMobileControlsIfNeeded()
}
async function handleDelete() {
if (!confirmSmartScopeDelete.value) {
confirmSmartScopeDelete.value = true
@@ -330,6 +352,7 @@ function toggleFilterSummary() {
}
function openEditor() {
if (!isSmartScopeOwner.value) return
editorOpen.value = true
confirmSmartScopeDelete.value = false
collapseMobileControlsIfNeeded()
@@ -536,7 +559,30 @@ defineOptions({ name: 'SmartScopeView' })
</TooltipTrigger>
<TooltipContent>{{ t('views.bookView.exportMetadata') }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="canToggleKoboSync">
<TooltipTrigger as-child>
<button
type="button"
role="switch"
:aria-checked="koboSyncEnabled"
:disabled="koboSyncPending"
class="hidden sm:flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors disabled:opacity-60"
:class="
koboSyncEnabled
? 'border-primary text-primary hover:bg-primary/10'
: 'border-input text-muted-foreground hover:bg-muted hover:text-foreground'
"
@click="handleToggleKoboSync"
>
<Tablet :size="13" />
<span>{{ t('views.smartScope.koboSync.label') }}</span>
</button>
</TooltipTrigger>
<TooltipContent>
{{ koboSyncEnabled ? t('views.smartScope.koboSync.enabledHint') : t('views.smartScope.koboSync.disabledHint') }}
</TooltipContent>
</Tooltip>
<Tooltip v-if="isSmartScopeOwner">
<TooltipTrigger as-child>
<button
class="hidden sm:flex h-8 items-center gap-1.5 rounded-md border border-input px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@@ -549,7 +595,7 @@ defineOptions({ name: 'SmartScopeView' })
</TooltipTrigger>
<TooltipContent>{{ t('views.smartScope.edit') }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="isSmartScopeOwner">
<TooltipTrigger as-child>
<button
:disabled="deleting"
@@ -643,6 +689,20 @@ defineOptions({ name: 'SmartScopeView' })
<span>{{ t('views.bookView.export') }}</span>
</button>
<button
v-if="canToggleKoboSync"
type="button"
role="switch"
:aria-checked="koboSyncEnabled"
:disabled="koboSyncPending"
class="flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-sm transition-colors disabled:opacity-60"
:class="koboSyncEnabled ? 'border-primary text-primary' : 'border-input text-muted-foreground hover:bg-muted hover:text-foreground'"
@click="handleToggleKoboSync"
>
<Tablet :size="13" />
<span>{{ t('views.smartScope.koboSync.label') }}</span>
</button>
<button
v-if="isSmartScopeOwner"
@click="openEditor"
class="flex h-8 items-center gap-1.5 rounded-md border border-input px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
@@ -650,6 +710,7 @@ defineOptions({ name: 'SmartScopeView' })
<span>{{ t('common.edit') }}</span>
</button>
<button
v-if="isSmartScopeOwner"
@click="handleDelete"
:disabled="deleting"
class="flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-sm transition-colors"
@@ -693,8 +754,9 @@ defineOptions({ name: 'SmartScopeView' })
<!-- Filter summary -->
<div
v-if="filterExpanded && (smartScope?.filter || sortChip)"
class="flex flex-wrap items-center gap-2 mb-4 cursor-pointer"
@click="editorOpen = true"
class="flex flex-wrap items-center gap-2 mb-4"
:class="isSmartScopeOwner ? 'cursor-pointer' : ''"
@click="openEditor"
>
<FilterSummary v-if="smartScope?.filter" :node="smartScope.filter as GroupRule" />
<span v-if="sortChip" class="inline-flex items-center text-xs rounded-md border border-border/60 overflow-hidden">
@@ -721,7 +783,8 @@ defineOptions({ name: 'SmartScopeView' })
</p>
</div>
<button
@click="editorOpen = true"
v-if="isSmartScopeOwner"
@click="openEditor"
class="h-9 px-5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
{{ t('views.smartScope.empty.configure') }}
@@ -738,7 +801,9 @@ defineOptions({ name: 'SmartScopeView' })
</div>
<p class="text-sm font-medium text-foreground">{{ t('views.smartScope.empty.noMatch') }}</p>
<p class="text-xs text-muted-foreground">{{ t('views.smartScope.empty.noMatchHint') }}</p>
<button @click="editorOpen = true" class="text-xs text-primary hover:underline">{{ t('views.smartScope.empty.edit') }}</button>
<button v-if="isSmartScopeOwner" @click="openEditor" class="text-xs text-primary hover:underline">
{{ t('views.smartScope.empty.edit') }}
</button>
</div>
<!-- Grid view -->
+4
View File
@@ -8,7 +8,11 @@ export interface SmartScope {
filter: GroupRule | null;
defaultSort: SortSpec[];
isPublic: boolean;
/** The owner's own preference. Only the owner's devices follow it. */
syncToKobo: boolean;
/** Whether this scope reaches the requesting user's Kobo: the owner's flag for the owner, an explicit opt-in for everyone else. */
koboSyncEnabled: boolean;
isOwner: boolean;
displayOrder: number;
bookCount?: number | null;
createdAt: string;
@@ -0,0 +1,10 @@
CREATE TABLE "smart_scope_kobo_subscriptions" (
"user_id" integer NOT NULL,
"smart_scope_id" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "smart_scope_kobo_subscriptions_user_id_smart_scope_id_pk" PRIMARY KEY("user_id","smart_scope_id")
);
--> statement-breakpoint
ALTER TABLE "smart_scope_kobo_subscriptions" ADD CONSTRAINT "smart_scope_kobo_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "smart_scope_kobo_subscriptions" ADD CONSTRAINT "smart_scope_kobo_subscriptions_smart_scope_id_smart_scopes_id_fk" FOREIGN KEY ("smart_scope_id") REFERENCES "public"."smart_scopes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "smart_scope_kobo_subscriptions_smart_scope_id_idx" ON "smart_scope_kobo_subscriptions" USING btree ("smart_scope_id");
File diff suppressed because it is too large Load Diff
@@ -414,6 +414,13 @@
"when": 1785219732532,
"tag": "0058_add_fb2_file_write_settings",
"breakpoints": true
},
{
"idx": 59,
"version": "7",
"when": 1785272589212,
"tag": "0059_add_smart_scope_kobo_subscriptions",
"breakpoints": true
}
]
}
+37
View File
@@ -0,0 +1,37 @@
import { getTableConfig } from 'drizzle-orm/pg-core';
import { smartScopeKoboSubscriptions } from './smart-scopes';
describe('smart scope Kobo subscriptions table', () => {
const config = getTableConfig(smartScopeKoboSubscriptions);
it('keys on user and scope so opting in twice cannot duplicate a row', () => {
const primaryKeyColumns = config.primaryKeys.map((key) => key.columns.map((column) => column.name));
expect(primaryKeyColumns).toEqual([['user_id', 'smart_scope_id']]);
});
it('leads the primary key with user_id so per-user lookups use it', () => {
expect(config.primaryKeys[0]?.columns[0]?.name).toBe('user_id');
});
it('indexes smart_scope_id so deleting a scope does not scan the table', () => {
const leadingIndexColumns = config.indexes.map((tableIndex) => tableIndex.config.columns[0]?.name);
expect(leadingIndexColumns).toContain('smart_scope_id');
});
it('cascades from both owners so deleting a user or a scope cannot strand a subscription', () => {
const cascades = config.foreignKeys.map((foreignKey) => ({
columns: foreignKey.reference().columns.map((column) => column.name),
onDelete: foreignKey.onDelete,
}));
expect(cascades).toEqual(
expect.arrayContaining([
{ columns: ['user_id'], onDelete: 'cascade' },
{ columns: ['smart_scope_id'], onDelete: 'cascade' },
]),
);
});
});
+22 -1
View File
@@ -1,4 +1,4 @@
import { boolean, integer, jsonb, pgTable, serial, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
import { boolean, index, integer, jsonb, pgTable, primaryKey, serial, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
import type { GroupRule, SortSpec } from '@bookorbit/types';
import { users } from './auth';
@@ -28,3 +28,24 @@ export const smartScopes = pgTable(
export type SmartScope = typeof smartScopes.$inferSelect;
export type NewSmartScope = typeof smartScopes.$inferInsert;
/**
* Opt-in Kobo sync for shared scopes. `smartScopes.syncToKobo` only governs the
* owner's own devices; every other user decides for themselves whether a public
* scope reaches their Kobo, so nothing lands on a device without consent.
*/
export const smartScopeKoboSubscriptions = pgTable(
'smart_scope_kobo_subscriptions',
{
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
smartScopeId: integer('smart_scope_id')
.notNull()
.references(() => smartScopes.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => [primaryKey({ columns: [t.userId, t.smartScopeId] }), index('smart_scope_kobo_subscriptions_smart_scope_id_idx').on(t.smartScopeId)],
);
export type SmartScopeKoboSubscription = typeof smartScopeKoboSubscriptions.$inferSelect;
+2
View File
@@ -6,6 +6,7 @@ import { AnnotationModule } from '../annotation/annotation.module';
import { BookModule } from '../book/book.module';
import { PositionConverterModule } from '../position-converter/position-converter.module';
import { ReadingSessionModule } from '../reading-session/reading-session.module';
import { SmartScopeModule } from '../smart-scope/smart-scope.module';
import { UserModule } from '../user/user.module';
import { UserBookStatusModule } from '../user-book-status/user-book-status.module';
import { KoboAuthController } from './kobo-auth.controller';
@@ -43,6 +44,7 @@ import { KoboAnalyticsService } from './services/kobo-analytics.service';
UserModule,
UserBookStatusModule,
ReadingSessionModule,
SmartScopeModule,
],
controllers: [KoboUserController, KoboAuthController, KoboSyncController, KoboDeviceController, KoboReadingServicesController],
providers: [
@@ -86,7 +86,6 @@ function makeDb(state?: Partial<QueueState>) {
koboSnapshotBooks: { findFirst: vi.fn() },
koboSyncSettings: { findFirst: vi.fn() },
collections: { findMany: vi.fn() },
smartScopes: { findMany: vi.fn().mockResolvedValue([]) },
},
select: vi.fn(() => {
const chain = makeChain(queue.select.shift() ?? []);
@@ -159,6 +158,9 @@ describe('KoboSyncService', () => {
const queryBuilder = {
buildWhere: vi.fn(),
};
const smartScopeService = {
findKoboSyncScopes: vi.fn(),
};
function makeIdentity(bookId: number, needsLegacyNumericRemoval = false) {
return {
@@ -181,12 +183,14 @@ describe('KoboSyncService', () => {
contentFilterRepository as never,
bookIdentityService as never,
queryBuilder as never,
smartScopeService as never,
);
}
beforeEach(() => {
vi.clearAllMocks();
bookAccessService.getAccessibleLibraryIds.mockResolvedValue(null);
smartScopeService.findKoboSyncScopes.mockResolvedValue([]);
readingStateService.getRawState.mockResolvedValue(null);
contentFilterRepository.findByUserId.mockResolvedValue([]);
bookIdentityService.ensureForBooks.mockImplementation((_userId: number, bookIds: number[], needsLegacyNumericRemoval: boolean) =>
@@ -579,7 +583,7 @@ describe('KoboSyncService', () => {
});
db.query.collections.findMany.mockResolvedValue([]);
const filter = { type: 'group', join: 'AND', rules: [] };
db.query.smartScopes.findMany.mockResolvedValue([{ id: 5, name: 'To Read', filter, syncToKobo: true }]);
smartScopeService.findKoboSyncScopes.mockResolvedValue([{ id: 5, name: 'To Read', filter, syncToKobo: true }]);
bookAccessService.getAccessibleLibraryIds.mockResolvedValue([1, 2]);
queryBuilder.buildWhere.mockReturnValue('WHERE_CLAUSE');
const service = makeService(db);
@@ -605,7 +609,7 @@ describe('KoboSyncService', () => {
it('buildTagItems excludes synced smart scopes without a filter instead of matching everything', async () => {
const db = makeDb();
db.query.collections.findMany.mockResolvedValue([]);
db.query.smartScopes.findMany.mockResolvedValue([{ id: 6, name: 'Empty Scope', filter: null, syncToKobo: true }]);
smartScopeService.findKoboSyncScopes.mockResolvedValue([{ id: 6, name: 'Empty Scope', filter: null, syncToKobo: true }]);
bookAccessService.getAccessibleLibraryIds.mockResolvedValue([1]);
const service = makeService(db);
@@ -625,7 +629,7 @@ describe('KoboSyncService', () => {
const db = makeDb({ select: [[{ id: 10 }], [{ id: 20 }, { id: 21 }]] });
const filterA = { type: 'group', join: 'AND', rules: [] };
const filterB = { type: 'group', join: 'OR', rules: [] };
db.query.smartScopes.findMany.mockResolvedValue([
smartScopeService.findKoboSyncScopes.mockResolvedValue([
{ id: 1, name: 'Scope A', filter: filterA, syncToKobo: true },
{ id: 2, name: 'Scope B', filter: filterB, syncToKobo: true },
]);
@@ -640,10 +644,37 @@ describe('KoboSyncService', () => {
expect(matches.get(2)).toEqual({ name: 'Scope B', bookIds: [20, 21], where: 'WHERE_CLAUSE' });
});
it('syncs a scope owned by another user when the smart scope service resolves it for this user', async () => {
const db = makeDb({ select: [[{ id: 30 }]] });
db.query.collections.findMany.mockResolvedValue([]);
const filter = { type: 'group', join: 'AND', rules: [] };
// A shared scope: owned by user 1, opted into by user 9. Ownership and opt-in are the
// smart scope module's rules, so sync must not re-filter by owner (issue #795).
smartScopeService.findKoboSyncScopes.mockResolvedValue([{ id: 7, userId: 1, name: 'Book Club', filter, isPublic: true, syncToKobo: false }]);
bookAccessService.getAccessibleLibraryIds.mockResolvedValue([1]);
queryBuilder.buildWhere.mockReturnValue('WHERE_CLAUSE');
const service = makeService(db);
const tags = await (service as any).buildTagItems(9, new Set([30]), new Map());
expect(smartScopeService.findKoboSyncScopes).toHaveBeenCalledWith(9);
expect(tags).toEqual([
expect.objectContaining({
ChangedTag: expect.objectContaining({
Tag: expect.objectContaining({
Id: 'ss-7',
Name: 'Book Club',
Items: [{ RevisionId: 'entitlement-30', Type: 'ProductRevisionTagItem' }],
}),
}),
}),
]);
});
it('fetches synced smart scope book ids with the metadata join required by metadata-backed filters', async () => {
const db = makeDb({ select: [[{ id: 10 }]] });
const filter = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }] };
db.query.smartScopes.findMany.mockResolvedValue([{ id: 1, name: 'Dune Scope', filter, syncToKobo: true }]);
smartScopeService.findKoboSyncScopes.mockResolvedValue([{ id: 1, name: 'Dune Scope', filter, syncToKobo: true }]);
queryBuilder.buildWhere.mockReturnValue(sql`${schema.bookMetadata.title} ilike ${'%Dune%'}`);
const service = makeService(db);
@@ -658,7 +689,7 @@ describe('KoboSyncService', () => {
select: [[{ id: 10 }]], // getSyncedSmartScopeMatches' single per-scope book lookup
});
db.query.collections.findMany.mockResolvedValue([]);
db.query.smartScopes.findMany.mockResolvedValue([
smartScopeService.findKoboSyncScopes.mockResolvedValue([
{ id: 5, name: 'To Read', filter: { type: 'group', join: 'AND', rules: [] }, syncToKobo: true },
]);
bookAccessService.getAccessibleLibraryIds.mockResolvedValue([1]);
@@ -670,14 +701,14 @@ describe('KoboSyncService', () => {
await (service as any).buildEligibleBooksWhereClause(9, cache);
await (service as any).buildTagItems(9, new Set([10]), cache);
expect(db.query.smartScopes.findMany).toHaveBeenCalledTimes(1);
expect(smartScopeService.findKoboSyncScopes).toHaveBeenCalledTimes(1);
});
it("buildEligibleBooksWhereClause ORs in each scope's own SQL predicate instead of an inArray of matched book ids", async () => {
const manyBookIds = Array.from({ length: 500 }, (_, i) => i + 1);
const db = makeDb({ select: [manyBookIds.map((id) => ({ id }))] });
db.query.collections.findMany.mockResolvedValue([]);
db.query.smartScopes.findMany.mockResolvedValue([
smartScopeService.findKoboSyncScopes.mockResolvedValue([
{ id: 5, name: 'To Read', filter: { type: 'group', join: 'AND', rules: [] }, syncToKobo: true },
]);
bookAccessService.getAccessibleLibraryIds.mockResolvedValue([1]);
@@ -9,6 +9,7 @@ import { buildContentFilterClauses } from '../../../common/utils/content-filter-
import { resolveTimeZone } from '../../../common/utils/timezone.utils';
import { ContentFilterRepository } from '../../user/content-filter.repository';
import { BookQueryBuilder } from '../../book/book-query-builder.service';
import { SmartScopeService } from '../../smart-scope/smart-scope.service';
import { KoboBookAccessService } from './kobo-book-access.service';
import { KoboBookIdentityService } from './kobo-book-identity.service';
import { KoboReadingStateService } from './kobo-reading-state.service';
@@ -101,6 +102,7 @@ export class KoboSyncService {
private readonly contentFilterRepository: ContentFilterRepository,
private readonly bookIdentityService: KoboBookIdentityService,
private readonly queryBuilder: BookQueryBuilder,
private readonly smartScopeService: SmartScopeService,
) {}
async getDelta(
@@ -690,9 +692,7 @@ export class KoboSyncService {
accessibleLibraryIds: number[] | null,
timeZone: string,
): Promise<Map<number, SmartScopeMatch>> {
const scopes = await this.db.query.smartScopes.findMany({
where: and(eq(schema.smartScopes.userId, userId), eq(schema.smartScopes.syncToKobo, true)),
});
const scopes = await this.smartScopeService.findKoboSyncScopes(userId);
if (scopes.length === 0) return new Map();
@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class SetKoboSyncDto {
@IsBoolean()
enabled: boolean;
}
@@ -37,6 +37,7 @@ describe('SmartScopeController', () => {
create: vi.fn().mockResolvedValue({ id: 2 }),
reorder: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue({ id: 1, name: 'Updated' }),
setKoboSync: vi.fn().mockResolvedValue({ id: 1, koboSyncEnabled: true }),
remove: vi.fn().mockResolvedValue(undefined),
executeSmartScope: vi.fn().mockResolvedValue({ items: [], total: 0, page: 0, size: 50 }),
queryBooks: vi.fn().mockResolvedValue({ items: [], total: 0, page: 0, size: 50 }),
@@ -50,6 +51,7 @@ describe('SmartScopeController', () => {
await controller.create({ name: 'New Smart Scope', icon: 'Aperture' } as never, user);
await controller.reorder({ order: [{ id: 1, displayOrder: 0 }] } as never, user);
await controller.update(1, { name: 'Updated' } as never, user);
await controller.setKoboSync(1, { enabled: true }, user);
await controller.remove(1, user);
await controller.executeSmartScope(1, user, 2, 25);
const query: BookQuery = { sort: [{ field: 'title', dir: 'asc' }], pagination: { page: 0, size: 50 } };
@@ -61,6 +63,7 @@ describe('SmartScopeController', () => {
expect(smartScopeService.create).toHaveBeenCalledWith({ name: 'New Smart Scope', icon: 'Aperture' }, user);
expect(smartScopeService.reorder).toHaveBeenCalledWith({ order: [{ id: 1, displayOrder: 0 }] }, user);
expect(smartScopeService.update).toHaveBeenCalledWith(1, { name: 'Updated' }, user);
expect(smartScopeService.setKoboSync).toHaveBeenCalledWith(1, user, true);
expect(smartScopeService.remove).toHaveBeenCalledWith(1, user);
expect(smartScopeService.executeSmartScope).toHaveBeenCalledWith(1, user, 2, 25, undefined);
expect(smartScopeService.queryBooks).toHaveBeenCalledWith(1, user, query);
@@ -11,6 +11,7 @@ import {
ParseIntPipe,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
@@ -23,6 +24,7 @@ import type { RequestUser } from '../../common/types/request-user';
import { BookQueryPipe, JumpBucketsQueryPipe } from '../book/pipes/book-query.pipe';
import { CreateSmartScopeDto } from './dto/create-smart-scope.dto';
import { ReorderSmartScopesDto } from './dto/reorder-smart-scopes.dto';
import { SetKoboSyncDto } from './dto/set-kobo-sync.dto';
import { UpdateSmartScopeDto } from './dto/update-smart-scope.dto';
import { SmartScopeService } from './smart-scope.service';
@@ -89,6 +91,17 @@ export class SmartScopeController {
return this.smartScopeService.update(id, dto, user);
}
@Put(':id/kobo-sync')
@Auditable({
action: AuditAction.SmartScopeUpdate,
resource: AuditResource.SmartScope,
getResourceId: (req) => parseInt(req.params['id'] as string, 10),
description: (req) => `Updated Kobo sync for smartScope #${req.params['id']}`,
})
setKoboSync(@Param('id', ParseIntPipe) id: number, @Body() dto: SetKoboSyncDto, @CurrentUser() user: RequestUser) {
return this.smartScopeService.setKoboSync(id, user, dto.enabled);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Auditable({
@@ -1,6 +1,96 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from '../../db/schema';
import { smartScopes } from '../../db/schema';
import { SmartScopeRepository } from './smart-scope.repository';
type CapturedQuery = { text: string; values: unknown[] };
// A real drizzle instance over a stub pg client: the queries below are never executed
// against a server, but they are compiled by the real dialect, so the assertions are on
// the SQL that production would actually send.
function makeCapturingRepo(rows: unknown[][] = []) {
const queries: CapturedQuery[] = [];
const client = {
query: (config: { text: string }, values: unknown[] = []) => {
queries.push({ text: config.text, values });
return Promise.resolve({ rows, fields: [{ name: 'smartScopeId' }] });
},
};
return { repo: new SmartScopeRepository(drizzle({ client: client as never, schema }) as never), queries };
}
function compile(query: unknown): CapturedQuery {
const { sql, params } = (query as { toSQL: () => { sql: string; params: unknown[] } }).toSQL();
return { text: sql, values: params };
}
describe('SmartScopeRepository Kobo sync selection', () => {
it("matches a user's own flagged scopes and the shared scopes they opted into", () => {
const { repo } = makeCapturingRepo();
const { text, values } = compile(repo.findKoboSyncScopesForUser(9));
expect(text).toContain('"smart_scopes"."user_id" = $1 and "smart_scopes"."sync_to_kobo" = $2');
expect(text).toContain('"smart_scopes"."user_id" <> $3 and "smart_scopes"."is_public" = $4');
expect(text).toContain(
'exists (select 1 from "smart_scope_kobo_subscriptions" where ("smart_scope_kobo_subscriptions"."smart_scope_id" = "smart_scopes"."id" and "smart_scope_kobo_subscriptions"."user_id" = $5)',
);
expect(values).toEqual([9, true, 9, true, 9]);
});
it("never consults the owner flag for shared scopes, so opting in is the subscriber's decision alone", () => {
const { repo } = makeCapturingRepo();
const { text } = compile(repo.findKoboSyncScopesForUser(9));
const whereClause = text.slice(text.indexOf(' where '));
// One occurrence only: the owned branch. The shared branch is gated on is_public + subscription.
expect(whereClause.match(/sync_to_kobo/g)).toHaveLength(1);
});
it('requires the scope to still be shared, so unsharing stops sync even with a subscription row', () => {
const { repo } = makeCapturingRepo();
const { text } = compile(repo.findKoboSyncScopesForUser(9));
const sharedBranch = text.slice(text.indexOf('user_id" <> '));
expect(sharedBranch).toContain('is_public');
expect(sharedBranch).toContain('smart_scope_kobo_subscriptions');
});
it('reads subscribed ids scoped to the user and the requested scopes', async () => {
const { repo, queries } = makeCapturingRepo([[5], [6]]);
await expect(repo.findKoboSubscribedScopeIds(9, [5, 6, 7])).resolves.toEqual([5, 6]);
expect(queries[0]?.text).toContain('from "smart_scope_kobo_subscriptions"');
expect(queries[0]?.text).toContain('"smart_scope_kobo_subscriptions"."user_id" = $1');
expect(queries[0]?.text).toContain('"smart_scope_kobo_subscriptions"."smart_scope_id" in ($2, $3, $4)');
expect(queries[0]?.values).toEqual([9, 5, 6, 7]);
});
it('skips the query entirely when there are no scopes to check', async () => {
const { repo, queries } = makeCapturingRepo();
await expect(repo.findKoboSubscribedScopeIds(9, [])).resolves.toEqual([]);
expect(queries).toHaveLength(0);
});
it("subscribes idempotently and unsubscribes only the caller's own row", async () => {
const { repo, queries } = makeCapturingRepo();
await repo.subscribeToKobo(9, 5);
await repo.unsubscribeFromKobo(9, 5);
expect(queries[0]?.text).toContain('insert into "smart_scope_kobo_subscriptions"');
expect(queries[0]?.text).toContain('on conflict do nothing');
expect(queries[0]?.values).toEqual([9, 5]);
expect(queries[1]?.text).toContain('delete from "smart_scope_kobo_subscriptions"');
expect(queries[1]?.text).toContain('"smart_scope_kobo_subscriptions"."user_id" = $1');
expect(queries[1]?.values).toEqual([9, 5]);
});
});
describe('SmartScopeRepository', () => {
it('builds find/update/delete queries and counts display-order updates inside a transaction', async () => {
const findAllOrderBy = vi.fn().mockReturnValue('find-all-query');
@@ -1,10 +1,10 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, or, sql } from 'drizzle-orm';
import { and, eq, exists, inArray, ne, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { smartScopes } from '../../db/schema';
import { smartScopeKoboSubscriptions, smartScopes } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type SmartScopeDisplayOrderUpdate = { id: number; displayOrder: number };
@@ -44,6 +44,51 @@ export class SmartScopeRepository {
.returning();
}
private koboSubscriptionExists(userId: number) {
return exists(
this.db
.select({ one: sql`1` })
.from(smartScopeKoboSubscriptions)
.where(and(eq(smartScopeKoboSubscriptions.smartScopeId, smartScopes.id), eq(smartScopeKoboSubscriptions.userId, userId))),
);
}
/**
* Scopes that must reach this user's Kobo: their own scopes with the flag set,
* plus shared scopes they opted into. A scope the owner unshares drops out even
* if the subscription row survives.
*/
findKoboSyncScopesForUser(userId: number) {
return this.db
.select()
.from(smartScopes)
.where(
or(
and(eq(smartScopes.userId, userId), eq(smartScopes.syncToKobo, true)),
and(ne(smartScopes.userId, userId), eq(smartScopes.isPublic, true), this.koboSubscriptionExists(userId)),
),
);
}
async findKoboSubscribedScopeIds(userId: number, smartScopeIds: number[]): Promise<number[]> {
if (smartScopeIds.length === 0) return [];
const rows = await this.db
.select({ smartScopeId: smartScopeKoboSubscriptions.smartScopeId })
.from(smartScopeKoboSubscriptions)
.where(and(eq(smartScopeKoboSubscriptions.userId, userId), inArray(smartScopeKoboSubscriptions.smartScopeId, smartScopeIds)));
return rows.map((row) => row.smartScopeId);
}
async subscribeToKobo(userId: number, smartScopeId: number): Promise<void> {
await this.db.insert(smartScopeKoboSubscriptions).values({ userId, smartScopeId }).onConflictDoNothing();
}
async unsubscribeFromKobo(userId: number, smartScopeId: number): Promise<void> {
await this.db
.delete(smartScopeKoboSubscriptions)
.where(and(eq(smartScopeKoboSubscriptions.userId, userId), eq(smartScopeKoboSubscriptions.smartScopeId, smartScopeId)));
}
updateDisplayOrders(userId: number, order: SmartScopeDisplayOrderUpdate[]): Promise<number> {
return this.db.transaction(async (tx) => {
let updatedCount = 0;
@@ -51,6 +51,10 @@ function makeService() {
update: vi.fn(),
delete: vi.fn(),
updateDisplayOrders: vi.fn(),
findKoboSyncScopesForUser: vi.fn(),
findKoboSubscribedScopeIds: vi.fn().mockResolvedValue([]),
subscribeToKobo: vi.fn(),
unsubscribeFromKobo: vi.fn(),
};
const bookReadService = {
countWhere: vi.fn(),
@@ -102,7 +106,7 @@ describe('SmartScopeService', () => {
const smartScope = makeSmartScope({ userId: 20, isPublic: true });
smartScopeRepo.findById.mockResolvedValue([smartScope]);
await expect(service.findOne(5, makeUser({ id: 12 }))).resolves.toEqual(smartScope);
await expect(service.findOne(5, makeUser({ id: 12 }))).resolves.toEqual({ ...smartScope, isOwner: false, koboSyncEnabled: false });
});
it('findAll returns bookCount=0 without querying for filter-less smart scopes', async () => {
@@ -124,8 +128,8 @@ describe('SmartScopeService', () => {
expect(queryBuilder.buildWhere).toHaveBeenCalledTimes(1);
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(secondSmartScope.filter, { accessibleLibraryIds: [2, 3], userId: 8, timeZone: 'UTC' });
expect(result).toEqual([
{ ...firstSmartScope, bookCount: 0 },
{ ...secondSmartScope, bookCount: 7 },
{ ...firstSmartScope, isOwner: false, koboSyncEnabled: false, bookCount: 0 },
{ ...secondSmartScope, isOwner: false, koboSyncEnabled: false, bookCount: 7 },
]);
});
@@ -153,8 +157,8 @@ describe('SmartScopeService', () => {
const result = await service.findAll(user);
expect(result).toEqual([
{ ...brokenScope, bookCount: null },
{ ...healthyScope, bookCount: 7 },
{ ...brokenScope, isOwner: false, koboSyncEnabled: false, bookCount: null },
{ ...healthyScope, isOwner: false, koboSyncEnabled: false, bookCount: 7 },
]);
expect(queryBuilder.buildWhere).toHaveBeenCalledTimes(1);
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(healthyScope.filter, { accessibleLibraryIds: [2, 3], userId: 8, timeZone: 'UTC' });
@@ -163,6 +167,123 @@ describe('SmartScopeService', () => {
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('durationMs='));
});
describe('Kobo sync opt-in for shared scopes', () => {
it('reports the owner flag for own scopes and the opt-in for shared ones', async () => {
const { service, smartScopeRepo, libraryService } = makeService();
const user = makeUser({ id: 8 });
const own = makeSmartScope({ id: 1, userId: 8, filter: null, syncToKobo: true });
const subscribedShare = makeSmartScope({ id: 2, userId: 20, filter: null, isPublic: true, syncToKobo: false });
const ignoredShare = makeSmartScope({ id: 3, userId: 20, filter: null, isPublic: true, syncToKobo: true });
smartScopeRepo.findAllForUser.mockResolvedValue([own, subscribedShare, ignoredShare]);
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
smartScopeRepo.findKoboSubscribedScopeIds.mockResolvedValue([2]);
const result = await service.findAll(user);
// Only shared scopes need a subscription lookup; the user's own rows carry their own flag.
expect(smartScopeRepo.findKoboSubscribedScopeIds).toHaveBeenCalledWith(8, [2, 3]);
expect(result).toEqual([
expect.objectContaining({ id: 1, isOwner: true, koboSyncEnabled: true }),
expect.objectContaining({ id: 2, isOwner: false, koboSyncEnabled: true }),
// Shared and flagged by its owner, but this user never opted in.
expect.objectContaining({ id: 3, isOwner: false, koboSyncEnabled: false }),
]);
});
it('does not leak the owner Kobo preference as the viewer own sync state', async () => {
const { service, smartScopeRepo } = makeService();
const shared = makeSmartScope({ id: 4, userId: 20, isPublic: true, syncToKobo: true });
smartScopeRepo.findById.mockResolvedValue([shared]);
smartScopeRepo.findKoboSubscribedScopeIds.mockResolvedValue([]);
const result = await service.findOne(4, makeUser({ id: 8 }));
expect(result).toEqual(expect.objectContaining({ syncToKobo: true, koboSyncEnabled: false, isOwner: false }));
});
it('writes the scope flag when the owner toggles their own scope', async () => {
const { service, smartScopeRepo } = makeService();
const own = makeSmartScope({ id: 5, userId: 8, syncToKobo: false });
smartScopeRepo.findById.mockResolvedValue([own]);
smartScopeRepo.update.mockResolvedValue([{ ...own, syncToKobo: true }]);
const result = await service.setKoboSync(5, makeUser({ id: 8 }), true);
expect(smartScopeRepo.update).toHaveBeenCalledWith(5, 8, { syncToKobo: true });
expect(smartScopeRepo.subscribeToKobo).not.toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ isOwner: true, koboSyncEnabled: true, syncToKobo: true }));
});
it('subscribes a non-owner without touching the shared scope row', async () => {
const { service, smartScopeRepo } = makeService();
const shared = makeSmartScope({ id: 6, userId: 20, isPublic: true, syncToKobo: false });
smartScopeRepo.findById.mockResolvedValue([shared]);
const result = await service.setKoboSync(6, makeUser({ id: 8 }), true);
expect(smartScopeRepo.subscribeToKobo).toHaveBeenCalledWith(8, 6);
expect(smartScopeRepo.update).not.toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ isOwner: false, koboSyncEnabled: true, syncToKobo: false }));
});
it('unsubscribes a non-owner without touching the shared scope row', async () => {
const { service, smartScopeRepo } = makeService();
const shared = makeSmartScope({ id: 6, userId: 20, isPublic: true, syncToKobo: true });
smartScopeRepo.findById.mockResolvedValue([shared]);
const result = await service.setKoboSync(6, makeUser({ id: 8 }), false);
expect(smartScopeRepo.unsubscribeFromKobo).toHaveBeenCalledWith(8, 6);
expect(smartScopeRepo.update).not.toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ koboSyncEnabled: false }));
});
it('opts a superuser in for themselves instead of flipping another user scope flag', async () => {
const { service, smartScopeRepo } = makeService();
const shared = makeSmartScope({ id: 7, userId: 20, isPublic: true, syncToKobo: false });
smartScopeRepo.findById.mockResolvedValue([shared]);
await service.setKoboSync(7, makeUser({ id: 1, isSuperuser: true }), true);
expect(smartScopeRepo.subscribeToKobo).toHaveBeenCalledWith(1, 7);
expect(smartScopeRepo.update).not.toHaveBeenCalled();
});
it('rejects a non-owner opting into a scope that is not shared', async () => {
const { service, smartScopeRepo } = makeService();
smartScopeRepo.findById.mockResolvedValue([makeSmartScope({ id: 8, userId: 20, isPublic: false })]);
await expect(service.setKoboSync(8, makeUser({ id: 8 }), true)).rejects.toThrow(ForbiddenException);
expect(smartScopeRepo.subscribeToKobo).not.toHaveBeenCalled();
});
it('rejects a superuser opting into a private scope of another user', async () => {
const { service, smartScopeRepo } = makeService();
smartScopeRepo.findById.mockResolvedValue([makeSmartScope({ id: 9, userId: 20, isPublic: false })]);
await expect(service.setKoboSync(9, makeUser({ id: 1, isSuperuser: true }), true)).rejects.toThrow(ForbiddenException);
expect(smartScopeRepo.subscribeToKobo).not.toHaveBeenCalled();
expect(smartScopeRepo.update).not.toHaveBeenCalled();
});
it('throws NotFoundException for a missing scope', async () => {
const { service, smartScopeRepo } = makeService();
smartScopeRepo.findById.mockResolvedValue([]);
await expect(service.setKoboSync(99, makeUser(), true)).rejects.toThrow(NotFoundException);
});
it('delegates Kobo sync scope resolution to the repository', async () => {
const { service, smartScopeRepo } = makeService();
const scopes = [makeSmartScope({ id: 1 })];
smartScopeRepo.findKoboSyncScopesForUser.mockResolvedValue(scopes);
await expect(service.findKoboSyncScopes(8)).resolves.toBe(scopes);
expect(smartScopeRepo.findKoboSyncScopesForUser).toHaveBeenCalledWith(8);
});
});
it('findAll propagates unexpected count query failures', async () => {
const { service, smartScopeRepo, libraryService, queryBuilder, bookReadService } = makeService();
const user = makeUser({ id: 8 });
@@ -198,7 +319,7 @@ describe('SmartScopeService', () => {
it('create sets defaults and persists validated values', async () => {
const { service, smartScopeRepo } = makeService();
const created = makeSmartScope({ id: 7, isPublic: false, defaultSort: [{ field: 'title', dir: 'asc' }] });
const created = makeSmartScope({ id: 7, userId: 44, isPublic: false, defaultSort: [{ field: 'title', dir: 'asc' }] });
smartScopeRepo.insert.mockResolvedValue([created]);
const result = await service.create(
@@ -215,7 +336,7 @@ describe('SmartScopeService', () => {
isPublic: false,
syncToKobo: false,
});
expect(result).toEqual(created);
expect(result).toEqual({ ...created, isOwner: true, koboSyncEnabled: false });
});
it('create rejects missing icons', async () => {
@@ -248,7 +369,7 @@ describe('SmartScopeService', () => {
defaultSort: undefined,
isPublic: undefined,
});
expect(result).toEqual(updated);
expect(result).toEqual({ ...updated, isOwner: false, koboSyncEnabled: false });
});
it('update rejects changes that would leave a smartScope without an icon', async () => {
@@ -66,20 +66,33 @@ export class SmartScopeService {
}
}
private toResponse(smartScope: SmartScope, user: RequestUser, koboSyncEnabled: boolean) {
return { ...smartScope, isOwner: smartScope.userId === user.id, koboSyncEnabled };
}
private async resolveKoboSyncEnabled(smartScope: SmartScope, user: RequestUser): Promise<boolean> {
if (smartScope.userId === user.id) return smartScope.syncToKobo;
const subscribed = await this.smartScopeRepo.findKoboSubscribedScopeIds(user.id, [smartScope.id]);
return subscribed.length > 0;
}
async findAll(user: RequestUser) {
const smartScopes = await this.smartScopeRepo.findAllForUser(user.id);
const accessibleLibraryIds = await this.libraryService.findAccessibleLibraryIds(user);
const timeZone = resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC');
const sharedScopeIds = smartScopes.filter((smartScope) => smartScope.userId !== user.id).map((smartScope) => smartScope.id);
const subscribedIds = new Set(await this.smartScopeRepo.findKoboSubscribedScopeIds(user.id, sharedScopeIds));
const koboSyncEnabledFor = (smartScope: SmartScope) => (smartScope.userId === user.id ? smartScope.syncToKobo : subscribedIds.has(smartScope.id));
return Promise.all(
smartScopes.map(async (smartScope) => {
if (!smartScope.filter) {
return { ...smartScope, bookCount: 0 };
return { ...this.toResponse(smartScope, user, koboSyncEnabledFor(smartScope)), bookCount: 0 };
}
const startedAt = Date.now();
let where: SQL | undefined;
try {
const filter = validateGroupRule(smartScope.filter);
if (!filter) return { ...smartScope, bookCount: 0 };
if (!filter) return { ...this.toResponse(smartScope, user, koboSyncEnabledFor(smartScope)), bookCount: 0 };
where = this.queryBuilder.buildWhere(filter, { accessibleLibraryIds, userId: user.id, timeZone });
} catch (err) {
if (!(err instanceof BadRequestException)) throw err;
@@ -88,10 +101,10 @@ export class SmartScopeService {
this.logger.error(
`[smart_scope.count] [fail] scopeId=${smartScope.id} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${error}" - smart scope filter is invalid`,
);
return { ...smartScope, bookCount: null };
return { ...this.toResponse(smartScope, user, koboSyncEnabledFor(smartScope)), bookCount: null };
}
const bookCount = await this.bookReadService.countWhere(where);
return { ...smartScope, bookCount };
return { ...this.toResponse(smartScope, user, koboSyncEnabledFor(smartScope)), bookCount };
}),
);
}
@@ -99,7 +112,36 @@ export class SmartScopeService {
async findOne(id: number, user: RequestUser) {
const smartScope = await this.getSmartScopeOrThrow(id);
this.assertReadAccess(smartScope, user);
return smartScope;
return this.toResponse(smartScope, user, await this.resolveKoboSyncEnabled(smartScope, user));
}
/**
* Scopes whose books belong on this user's Kobo. Exposed for the Kobo module so
* shared-scope opt-in stays owned by this feature.
*/
findKoboSyncScopes(userId: number): Promise<SmartScope[]> {
return this.smartScopeRepo.findKoboSyncScopesForUser(userId);
}
async setKoboSync(id: number, user: RequestUser, enabled: boolean) {
const smartScope = await this.getSmartScopeOrThrow(id);
this.assertReadAccess(smartScope, user);
if (smartScope.userId === user.id) {
const [updated] = await this.smartScopeRepo.update(id, user.id, { syncToKobo: enabled });
return this.toResponse(updated ?? { ...smartScope, syncToKobo: enabled }, user, enabled);
}
if (!smartScope.isPublic) {
throw new ForbiddenException('Cannot sync a smartScope that is not shared');
}
if (enabled) {
await this.smartScopeRepo.subscribeToKobo(user.id, id);
} else {
await this.smartScopeRepo.unsubscribeFromKobo(user.id, id);
}
return this.toResponse(smartScope, user, enabled);
}
async create(dto: CreateSmartScopeDto, user: RequestUser) {
@@ -117,7 +159,7 @@ export class SmartScopeService {
isPublic: dto.isPublic ?? false,
syncToKobo: dto.syncToKobo ?? false,
});
return smartScope;
return this.toResponse(smartScope, user, smartScope.syncToKobo);
}
async update(id: number, dto: UpdateSmartScopeDto, user: RequestUser) {
@@ -138,7 +180,7 @@ export class SmartScopeService {
isPublic: dto.isPublic,
syncToKobo: dto.syncToKobo,
});
return updated;
return this.toResponse(updated, user, await this.resolveKoboSyncEnabled(updated, user));
}
async remove(id: number, user: RequestUser) {