mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ł",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user