mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(readwise): sync highlights to Readwise (#578)
* feat(types): add readwise_sync permission * feat(types): add readwise settings types * feat(db): add readwise_user_settings table * feat(readwise): add constants * feat(readwise): add throttle queue * feat(readwise): add REST client * feat(readwise): add repository * feat(readwise): add settings service * feat(readwise): add sync service with watermark * refactor(readwise): drop unused logger in settings service * feat(readwise): add debounced scheduler * feat(readwise): add event listener * feat(readwise): add controller and dtos * feat(readwise): wire module into app * test(readwise): add readwise_sync to authorization matrix * feat(annotation): emit annotation.created on device sync ingest * feat(client): add readwise api client * feat(client): add useReadwiseSettings composable * feat(client): add Readwise settings page * feat(client): add Readwise settings route * feat(client): add Readwise to settings nav * fix(readwise): avoid typeof this in settings type annotation Production build (nest build → tsc via tsconfig.build.json) rejects `Parameters<typeof this.repo.upsertSettings>[1]` with TS2304. Reference the repository class type directly so the prod image compiles. * fix(client): add Readwise sync to admin permission list The admin UserFormDrawer renders a hardcoded permission list, not an iteration of PERMISSION_LABELS, so readwise_sync was ungrantable via UI. Add it to the Devices & Access group and the Standard preset, mirroring Hardcover sync. * feat(readwise): send book cover to Readwise via OpenLibrary ISBN Populate the Readwise highlight image_url with the OpenLibrary cover URL built from the book's isbn13 (falling back to isbn10). Uses ?default=false so a missing cover yields 404 (no image) rather than a blank placeholder. Books without an ISBN send no image_url. BookOrbit's own cover endpoint is auth-gated and stores no public source URL, so a public third-party cover URL is the zero-infra way to give Readwise an image. * test(readwise): add type parameters to vi.fn mocks oxlint (vitest/require-mock-type-parameters) fails on untyped vi.fn() in the settings page test; the pre-commit hook only runs eslint, so this surfaced under full lint:check. * docs(readme): add Readwise Sync to features list * fix(readwise): surface settings fetch errors in the UI The settings page destructured error from useReadwiseSettings but never rendered it. A failed fetch on mount left the user with an empty default form and no indication that loading failed. Render an error banner when error is set, and cover it with component tests. * test(readwise): cover settings page token test, save, and visibility toggle Adds component tests for the Test-connection flow (empty/valid/invalid token), Save (success clears the field, failure surfaces an error toast), and the show/hide token toggle — the interactive handlers Codecov flagged as uncovered in ReadwiseSettings.vue. * test(readwise): match settings buttons by exact label in test helper Use an exact text match instead of substring matching in the testButton helper so it can never return the wrong button if labels overlap. * fix(readwise): harden highlight sync --------- Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com> Co-authored-by: Oscar Nogueira Neto <oscarnogueira@gmail.com>
This commit is contained in:
committed by
GitHub
parent
d82a018d1e
commit
4eed87a6fd
@@ -57,6 +57,7 @@ _Experience the interface, built-in readers, and dashboard first-hand._
|
||||
- **KOReader Plugin**: Native on-device catalog browser with search, download, and status/rating management, alongside full progress and annotation sync.
|
||||
- **Annotations & Highlights**: Highlights from the web reader, KOReader, and Kobo merge into a unified searchable hub - filterable by color, style, and source, exportable as Markdown, CSV, or JSON.
|
||||
- **Hardcover Sync**: Automatically pushes status, progress, reading dates, and ratings to Hardcover on configurable triggers. Pull read history back from Hardcover to backfill blank BookOrbit entries.
|
||||
- **Readwise Sync**: Automatically pushes new highlights and notes to Readwise as you create them, from both the web reader and synced devices, with book covers matched by ISBN.
|
||||
- **Achievements & Reading Goals**: Yearly goals, daily streaks, monthly challenges, and 50+ achievements across five categories. Reading DNA profiles your reading style from your actual session history.
|
||||
- **Reading Statistics**: Track your daily reading time, view heatmaps, maintain streaks, and monitor library health.
|
||||
|
||||
|
||||
@@ -38,7 +38,14 @@ const PERMISSION_GROUPS: { label: string; permissions: Permission[] }[] = [
|
||||
},
|
||||
{
|
||||
label: 'Devices & Access',
|
||||
permissions: [Permission.KoboSync, Permission.KoreaderSync, Permission.HardcoverSync, Permission.OpdsAccess, Permission.BookDockAccess],
|
||||
permissions: [
|
||||
Permission.KoboSync,
|
||||
Permission.KoreaderSync,
|
||||
Permission.HardcoverSync,
|
||||
Permission.ReadwiseSync,
|
||||
Permission.OpdsAccess,
|
||||
Permission.BookDockAccess,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
@@ -108,6 +115,7 @@ function applyPreset(preset: 'standard' | 'admin' | 'clear') {
|
||||
Permission.KoboSync,
|
||||
Permission.KoreaderSync,
|
||||
Permission.HardcoverSync,
|
||||
Permission.ReadwiseSync,
|
||||
Permission.OpdsAccess,
|
||||
Permission.BookDockAccess,
|
||||
])
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type ApiFn = (input: RequestInfo | URL, init?: RequestInit & { _isRetry?: boolean }) => Promise<Response>
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: vi.fn<ApiFn>(),
|
||||
}))
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
import { fetchReadwiseSettings, upsertReadwiseSettings, validateReadwiseToken } from '../readwise.api'
|
||||
|
||||
const mockApi = vi.mocked(api)
|
||||
|
||||
function jsonResponse(body: unknown, ok = true): Response {
|
||||
return {
|
||||
ok,
|
||||
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(body),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('readwise.api', () => {
|
||||
beforeEach(() => {
|
||||
mockApi.mockReset()
|
||||
})
|
||||
|
||||
it('fetches settings', async () => {
|
||||
mockApi.mockResolvedValueOnce(
|
||||
jsonResponse({ tokenConfigured: true, enabled: true, effectiveEnabled: true, disabledReason: null, lastSyncedAt: null }),
|
||||
)
|
||||
|
||||
await expect(fetchReadwiseSettings()).resolves.toEqual({
|
||||
tokenConfigured: true,
|
||||
enabled: true,
|
||||
effectiveEnabled: true,
|
||||
disabledReason: null,
|
||||
lastSyncedAt: null,
|
||||
})
|
||||
|
||||
expect(mockApi).toHaveBeenNthCalledWith(1, '/api/v1/readwise/settings')
|
||||
})
|
||||
|
||||
it('surfaces server message when fetching settings fails', async () => {
|
||||
mockApi.mockResolvedValueOnce(jsonResponse({ message: 'Permission denied' }, false))
|
||||
|
||||
await expect(fetchReadwiseSettings()).rejects.toThrow('Permission denied')
|
||||
})
|
||||
|
||||
it('uses default message when fetch failure has no message', async () => {
|
||||
mockApi.mockResolvedValueOnce(jsonResponse({}, false))
|
||||
|
||||
await expect(fetchReadwiseSettings()).rejects.toThrow('Failed to fetch Readwise settings')
|
||||
})
|
||||
|
||||
it('saves settings via PATCH', async () => {
|
||||
mockApi.mockResolvedValueOnce(
|
||||
jsonResponse({ tokenConfigured: true, enabled: false, effectiveEnabled: false, disabledReason: null, lastSyncedAt: null }),
|
||||
)
|
||||
|
||||
await expect(upsertReadwiseSettings({ apiToken: 'tok', enabled: false })).resolves.toEqual({
|
||||
tokenConfigured: true,
|
||||
enabled: false,
|
||||
effectiveEnabled: false,
|
||||
disabledReason: null,
|
||||
lastSyncedAt: null,
|
||||
})
|
||||
|
||||
expect(mockApi).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/v1/readwise/settings',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ apiToken: 'tok', enabled: false }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces server message on save failure', async () => {
|
||||
mockApi.mockResolvedValueOnce(jsonResponse({ message: 'Invalid token' }, false))
|
||||
|
||||
await expect(upsertReadwiseSettings({ apiToken: 'bad' })).rejects.toThrow('Invalid token')
|
||||
})
|
||||
|
||||
it('uses default message when save failure has no message', async () => {
|
||||
mockApi.mockResolvedValueOnce(jsonResponse({}, false))
|
||||
|
||||
await expect(upsertReadwiseSettings({ enabled: true })).rejects.toThrow('Failed to save settings')
|
||||
})
|
||||
|
||||
it('validates a token with a provided value', async () => {
|
||||
mockApi.mockResolvedValueOnce(jsonResponse({ valid: true }))
|
||||
|
||||
await expect(validateReadwiseToken('t')).resolves.toEqual({ valid: true })
|
||||
|
||||
expect(mockApi).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/v1/readwise/validate-token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token: 't' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('validates using the stored token when no argument is given', async () => {
|
||||
mockApi.mockResolvedValueOnce(jsonResponse({ valid: false }))
|
||||
|
||||
await expect(validateReadwiseToken()).resolves.toEqual({ valid: false })
|
||||
|
||||
expect(mockApi).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/v1/readwise/validate-token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces server message when token validation fails', async () => {
|
||||
mockApi.mockResolvedValueOnce(jsonResponse({ message: 'Missing token' }, false))
|
||||
|
||||
await expect(validateReadwiseToken('t')).rejects.toThrow('Missing token')
|
||||
})
|
||||
|
||||
it('uses default message when token validation failure has no message', async () => {
|
||||
mockApi.mockResolvedValueOnce(jsonResponse({}, false))
|
||||
|
||||
await expect(validateReadwiseToken('t')).rejects.toThrow('Failed to validate token')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type { ReadwiseSettings, ReadwiseTokenValidationResult, UpsertReadwiseSettingsPayload } from '@bookorbit/types'
|
||||
|
||||
const BASE = '/api/v1/readwise'
|
||||
|
||||
async function errorMessage(res: Response, fallback: string): Promise<string> {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
return (body as { message?: string }).message ?? fallback
|
||||
}
|
||||
|
||||
export async function fetchReadwiseSettings(): Promise<ReadwiseSettings> {
|
||||
const res = await api(`${BASE}/settings`)
|
||||
if (!res.ok) throw new Error(await errorMessage(res, 'Failed to fetch Readwise settings'))
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function upsertReadwiseSettings(payload: UpsertReadwiseSettingsPayload): Promise<ReadwiseSettings> {
|
||||
const res = await api(`${BASE}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessage(res, 'Failed to save settings'))
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function validateReadwiseToken(token?: string): Promise<ReadwiseTokenValidationResult> {
|
||||
const res = await api(`${BASE}/validate-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(token ? { token } : {}),
|
||||
})
|
||||
if (!res.ok) throw new Error(await errorMessage(res, 'Failed to validate token'))
|
||||
return res.json()
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Link, Save, CheckCircle2, AlertCircle, Info, Loader2 } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { ReadwiseSyncDisabledReason, ReadwiseTokenValidationResult } from '@bookorbit/types'
|
||||
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import { useReadwiseSettings } from '../composables/useReadwiseSettings'
|
||||
|
||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
const { settings, saving, validating, error, fetchSettings, saveSettings, validateToken } = useReadwiseSettings()
|
||||
|
||||
const tokenInput = ref('')
|
||||
const tokenVisible = ref(false)
|
||||
const tokenInputId = 'readwise-access-token'
|
||||
const validationResult = ref<ReadwiseTokenValidationResult | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const disabledReasonMessages: Record<ReadwiseSyncDisabledReason, string> = {
|
||||
permission_denied: "Your account doesn't have Readwise sync permission.",
|
||||
missing_token: 'Add your Readwise access token to start syncing.',
|
||||
user_disabled: 'Sync is turned off.',
|
||||
invalid_token: 'Your Readwise token was rejected. Paste a new one and re-enable.',
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
form.enabled = settings.value?.enabled ?? false
|
||||
})
|
||||
|
||||
async function handleValidateToken() {
|
||||
const token = tokenInput.value.trim()
|
||||
if (!token && !settings.value?.tokenConfigured) {
|
||||
toast.error('Enter your Readwise access token first')
|
||||
return
|
||||
}
|
||||
const result = await validateToken(token || undefined)
|
||||
validationResult.value = result
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const ok = await saveSettings({
|
||||
...(tokenInput.value.trim() ? { apiToken: tokenInput.value.trim() } : {}),
|
||||
enabled: form.enabled,
|
||||
})
|
||||
if (ok) {
|
||||
tokenInput.value = ''
|
||||
toast.success('Readwise settings saved')
|
||||
} else {
|
||||
toast.error(error.value ?? 'Failed to save settings')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTokenVisible() {
|
||||
tokenVisible.value = !tokenVisible.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<SettingsPageHeader v-if="!props.embedded" title="Readwise" subtitle="Send your highlights to Readwise automatically." />
|
||||
|
||||
<div class="border border-border rounded-lg bg-card px-4 py-4 md:px-5 md:py-5 shadow-xs space-y-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<Link class="size-5 text-primary shrink-0" />
|
||||
<div>
|
||||
<p class="font-medium text-sm">Connection</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">Connect your Readwise account to sync your highlights.</p>
|
||||
</div>
|
||||
<div v-if="settings?.tokenConfigured" class="ml-auto flex items-center gap-1.5 text-xs text-primary">
|
||||
<CheckCircle2 class="size-3.5" />
|
||||
Connected
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-2.5 py-2">
|
||||
<AlertCircle class="size-3.5 shrink-0 text-destructive mt-0.5" />
|
||||
<p class="text-xs text-destructive leading-relaxed">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="settings?.disabledReason" class="flex items-start gap-2 rounded-md border border-border/70 bg-muted/40 px-2.5 py-2">
|
||||
<Info class="size-3.5 shrink-0 text-muted-foreground mt-0.5" />
|
||||
<p class="text-xs text-muted-foreground leading-relaxed">
|
||||
{{ disabledReasonMessages[settings.disabledReason] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label :for="tokenInputId" class="text-xs font-medium text-muted-foreground uppercase tracking-wider"> Access Token </label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
:id="tokenInputId"
|
||||
v-model="tokenInput"
|
||||
:type="tokenVisible ? 'text' : 'password'"
|
||||
placeholder="Paste your Readwise access token"
|
||||
class="flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 text-xs rounded-md border border-border bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
@click="toggleTokenVisible"
|
||||
>
|
||||
{{ tokenVisible ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Find your token at
|
||||
<a href="https://readwise.io/access_token" target="_blank" rel="noopener" class="text-primary underline underline-offset-2"
|
||||
>readwise.io/access_token</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="validating"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
@click="handleValidateToken"
|
||||
>
|
||||
<Loader2 v-if="validating" class="size-3 animate-spin" />
|
||||
Test
|
||||
</button>
|
||||
<span
|
||||
v-if="validationResult !== null"
|
||||
class="flex items-center gap-1 text-xs"
|
||||
:class="validationResult.valid ? 'text-primary' : 'text-destructive'"
|
||||
>
|
||||
<CheckCircle2 v-if="validationResult.valid" class="size-3.5" />
|
||||
<AlertCircle v-else class="size-3.5" />
|
||||
{{ validationResult.valid ? 'Valid token' : 'Invalid token' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-2 border-t border-border">
|
||||
<div>
|
||||
<p class="text-sm">Enable sync</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">Automatically send your highlights to Readwise.</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="form.enabled" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
class="flex items-center gap-1.5 px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Loader2 v-if="saving" class="size-3.5 animate-spin" />
|
||||
<Save v-else class="size-3.5" />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,187 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import type { ReadwiseSettings } from '@bookorbit/types'
|
||||
import ReadwiseSettingsPage from '../ReadwiseSettings.vue'
|
||||
|
||||
const settings = ref<ReadwiseSettings | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const { fetchSettings, saveSettings, validateToken, toastSuccess, toastError } = vi.hoisted(() => ({
|
||||
fetchSettings: vi.fn<() => Promise<void>>(),
|
||||
saveSettings: vi.fn<(payload: unknown) => Promise<boolean>>(),
|
||||
validateToken: vi.fn<(token?: string) => Promise<{ valid: boolean }>>(),
|
||||
toastSuccess: vi.fn<(message: string) => void>(),
|
||||
toastError: vi.fn<(message: string) => void>(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-sonner', () => ({ toast: { success: toastSuccess, error: toastError } }))
|
||||
|
||||
vi.mock('../../composables/useReadwiseSettings', () => ({
|
||||
useReadwiseSettings: () => ({
|
||||
settings,
|
||||
loading: ref(false),
|
||||
saving: ref(false),
|
||||
validating: ref(false),
|
||||
error,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
validateToken,
|
||||
}),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
settings.value = null
|
||||
error.value = null
|
||||
fetchSettings.mockReset().mockResolvedValue(undefined)
|
||||
saveSettings.mockReset().mockResolvedValue(true)
|
||||
validateToken.mockReset().mockResolvedValue({ valid: true })
|
||||
toastSuccess.mockReset()
|
||||
toastError.mockReset()
|
||||
})
|
||||
|
||||
function testButton(wrapper: Awaited<ReturnType<typeof mountPage>>, label: string) {
|
||||
const btn = wrapper.findAll('button').find((b) => b.text().trim() === label)
|
||||
if (!btn) throw new Error(`button "${label}" not found`)
|
||||
return btn
|
||||
}
|
||||
|
||||
function baseSettings(overrides: Partial<ReadwiseSettings> = {}): ReadwiseSettings {
|
||||
return {
|
||||
tokenConfigured: false,
|
||||
enabled: false,
|
||||
effectiveEnabled: false,
|
||||
disabledReason: null,
|
||||
lastSyncedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function mountPage() {
|
||||
const wrapper = mount(ReadwiseSettingsPage)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('ReadwiseSettings', () => {
|
||||
it('renders the invalid_token disabledReason banner message', async () => {
|
||||
settings.value = baseSettings({ disabledReason: 'invalid_token' })
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.text()).toContain('Your Readwise token was rejected. Paste a new one and re-enable.')
|
||||
})
|
||||
|
||||
it('renders the missing_token disabledReason banner message', async () => {
|
||||
settings.value = baseSettings({ disabledReason: 'missing_token' })
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.text()).toContain('Add your Readwise access token to start syncing.')
|
||||
})
|
||||
|
||||
it('does not render a banner when disabledReason is null', async () => {
|
||||
settings.value = baseSettings({ disabledReason: null })
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.text()).not.toContain('Add your Readwise access token to start syncing.')
|
||||
})
|
||||
|
||||
it('shows the Connected indicator when a token is configured', async () => {
|
||||
settings.value = baseSettings({ tokenConfigured: true })
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.text()).toContain('Connected')
|
||||
})
|
||||
|
||||
it('surfaces a fetch error when loading settings fails', async () => {
|
||||
fetchSettings.mockImplementationOnce(async () => {
|
||||
error.value = 'Failed to load settings'
|
||||
})
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.text()).toContain('Failed to load settings')
|
||||
})
|
||||
|
||||
it('does not show an error banner when there is no error', async () => {
|
||||
settings.value = baseSettings()
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.text()).not.toContain('Failed to load settings')
|
||||
})
|
||||
|
||||
it('rejects the Test action with an empty token when no token is saved', async () => {
|
||||
settings.value = baseSettings()
|
||||
const wrapper = await mountPage()
|
||||
|
||||
await testButton(wrapper, 'Test').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(validateToken).not.toHaveBeenCalled()
|
||||
expect(toastError).toHaveBeenCalledWith('Enter your Readwise access token first')
|
||||
})
|
||||
|
||||
it('validates a trimmed token and shows the valid result', async () => {
|
||||
settings.value = baseSettings()
|
||||
const wrapper = await mountPage()
|
||||
|
||||
await wrapper.get('#readwise-access-token').setValue(' my-token ')
|
||||
await testButton(wrapper, 'Test').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(validateToken).toHaveBeenCalledWith('my-token')
|
||||
expect(wrapper.text()).toContain('Valid token')
|
||||
})
|
||||
|
||||
it('validates the saved token when the token input is empty', async () => {
|
||||
settings.value = baseSettings({ tokenConfigured: true })
|
||||
const wrapper = await mountPage()
|
||||
|
||||
await testButton(wrapper, 'Test').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(validateToken).toHaveBeenCalledWith(undefined)
|
||||
expect(toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an invalid result when the token is rejected', async () => {
|
||||
settings.value = baseSettings()
|
||||
validateToken.mockResolvedValue({ valid: false })
|
||||
const wrapper = await mountPage()
|
||||
|
||||
await wrapper.get('#readwise-access-token').setValue('bad-token')
|
||||
await testButton(wrapper, 'Test').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Invalid token')
|
||||
})
|
||||
|
||||
it('saves the trimmed token and enabled flag, then clears the token field', async () => {
|
||||
settings.value = baseSettings({ enabled: true })
|
||||
const wrapper = await mountPage()
|
||||
|
||||
await wrapper.get('#readwise-access-token').setValue(' tok-123 ')
|
||||
await testButton(wrapper, 'Save').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledWith({ apiToken: 'tok-123', enabled: true })
|
||||
expect(toastSuccess).toHaveBeenCalledWith('Readwise settings saved')
|
||||
expect((wrapper.get('#readwise-access-token').element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('shows an error toast when saving fails', async () => {
|
||||
settings.value = baseSettings()
|
||||
saveSettings.mockResolvedValue(false)
|
||||
error.value = 'Something broke'
|
||||
const wrapper = await mountPage()
|
||||
|
||||
await testButton(wrapper, 'Save').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith('Something broke')
|
||||
})
|
||||
|
||||
it('toggles the token field between hidden and visible', async () => {
|
||||
settings.value = baseSettings()
|
||||
const wrapper = await mountPage()
|
||||
const input = wrapper.get('#readwise-access-token')
|
||||
|
||||
expect((input.element as HTMLInputElement).type).toBe('password')
|
||||
await testButton(wrapper, 'Show').trigger('click')
|
||||
expect((input.element as HTMLInputElement).type).toBe('text')
|
||||
await testButton(wrapper, 'Hide').trigger('click')
|
||||
expect((input.element as HTMLInputElement).type).toBe('password')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReadwiseSettings, ReadwiseTokenValidationResult, UpsertReadwiseSettingsPayload } from '@bookorbit/types'
|
||||
|
||||
vi.mock('../../api/readwise.api', () => ({
|
||||
fetchReadwiseSettings: vi.fn<() => Promise<ReadwiseSettings>>(),
|
||||
upsertReadwiseSettings: vi.fn<(payload: UpsertReadwiseSettingsPayload) => Promise<ReadwiseSettings>>(),
|
||||
validateReadwiseToken: vi.fn<(token?: string) => Promise<ReadwiseTokenValidationResult>>(),
|
||||
}))
|
||||
|
||||
import { fetchReadwiseSettings, upsertReadwiseSettings, validateReadwiseToken } from '../../api/readwise.api'
|
||||
|
||||
const mockFetchSettings = vi.mocked(fetchReadwiseSettings)
|
||||
const mockUpsert = vi.mocked(upsertReadwiseSettings)
|
||||
const mockValidate = vi.mocked(validateReadwiseToken)
|
||||
|
||||
const SETTINGS: ReadwiseSettings = {
|
||||
tokenConfigured: true,
|
||||
enabled: true,
|
||||
effectiveEnabled: true,
|
||||
disabledReason: null,
|
||||
lastSyncedAt: null,
|
||||
}
|
||||
|
||||
async function loadComposable() {
|
||||
const { useReadwiseSettings } = await import('../useReadwiseSettings')
|
||||
return useReadwiseSettings()
|
||||
}
|
||||
|
||||
describe('useReadwiseSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('fetchSettings populates settings on success', async () => {
|
||||
mockFetchSettings.mockResolvedValue(SETTINGS)
|
||||
const c = await loadComposable()
|
||||
await c.fetchSettings()
|
||||
expect(c.settings.value).toEqual(SETTINGS)
|
||||
expect(c.loading.value).toBe(false)
|
||||
expect(c.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('fetchSettings sets error on failure', async () => {
|
||||
mockFetchSettings.mockRejectedValue(new Error('Network error'))
|
||||
const c = await loadComposable()
|
||||
await c.fetchSettings()
|
||||
expect(c.settings.value).toBeNull()
|
||||
expect(c.error.value).toBe('Network error')
|
||||
expect(c.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('saveSettings updates settings and returns true on success', async () => {
|
||||
const payload: UpsertReadwiseSettingsPayload = { apiToken: 'tok', enabled: true }
|
||||
mockUpsert.mockResolvedValue(SETTINGS)
|
||||
const c = await loadComposable()
|
||||
const ok = await c.saveSettings(payload)
|
||||
expect(ok).toBe(true)
|
||||
expect(c.settings.value).toEqual(SETTINGS)
|
||||
expect(c.saving.value).toBe(false)
|
||||
expect(c.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('saveSettings sets error and returns false on failure', async () => {
|
||||
mockUpsert.mockRejectedValue(new Error('Bad token'))
|
||||
const c = await loadComposable()
|
||||
const ok = await c.saveSettings({ apiToken: 'bad' })
|
||||
expect(ok).toBe(false)
|
||||
expect(c.error.value).toBe('Bad token')
|
||||
expect(c.saving.value).toBe(false)
|
||||
})
|
||||
|
||||
it('validateToken returns valid true on success', async () => {
|
||||
const result: ReadwiseTokenValidationResult = { valid: true }
|
||||
mockValidate.mockResolvedValue(result)
|
||||
const c = await loadComposable()
|
||||
const r = await c.validateToken('tok')
|
||||
expect(r.valid).toBe(true)
|
||||
expect(c.validating.value).toBe(false)
|
||||
})
|
||||
|
||||
it('validateToken returns valid false on error', async () => {
|
||||
mockValidate.mockRejectedValue(new Error('Unauthorized'))
|
||||
const c = await loadComposable()
|
||||
const r = await c.validateToken()
|
||||
expect(r.valid).toBe(false)
|
||||
expect(c.validating.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ref } from 'vue'
|
||||
import type { ReadwiseSettings, ReadwiseTokenValidationResult, UpsertReadwiseSettingsPayload } from '@bookorbit/types'
|
||||
import { fetchReadwiseSettings, upsertReadwiseSettings, validateReadwiseToken } from '../api/readwise.api'
|
||||
|
||||
const settings = ref<ReadwiseSettings | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const validating = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
export function useReadwiseSettings() {
|
||||
async function fetchSettings(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
settings.value = await fetchReadwiseSettings()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load settings'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(payload: UpsertReadwiseSettingsPayload): Promise<boolean> {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
settings.value = await upsertReadwiseSettings(payload)
|
||||
return true
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to save settings'
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function validateToken(token?: string): Promise<ReadwiseTokenValidationResult> {
|
||||
validating.value = true
|
||||
try {
|
||||
return await validateReadwiseToken(token)
|
||||
} catch {
|
||||
return { valid: false }
|
||||
} finally {
|
||||
validating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { settings, loading, saving, validating, error, fetchSettings, saveSettings, validateToken }
|
||||
}
|
||||
@@ -54,6 +54,10 @@ const sections = computed<Section[]>(() => {
|
||||
result.push({ label: 'Hardcover', routeName: 'settings-hardcover' })
|
||||
}
|
||||
|
||||
if (su || perms.includes(Permission.ReadwiseSync)) {
|
||||
result.push({ label: 'Readwise', routeName: 'settings-readwise' })
|
||||
}
|
||||
|
||||
if (su || perms.includes('manage_users') || perms.includes('manage_app_settings')) {
|
||||
result.push({ label: 'Admin', routeName: 'settings-admin' })
|
||||
}
|
||||
|
||||
@@ -83,17 +83,34 @@ describe('SettingsHeader', () => {
|
||||
expect(labels).not.toContain('Integrations')
|
||||
})
|
||||
|
||||
it('shows Readwise tab for users with readwise_sync', () => {
|
||||
const labels = getTabLabels(mountHeader({ perms: ['readwise_sync'] }))
|
||||
expect(labels).toContain('Readwise')
|
||||
expect(labels).not.toContain('Integrations')
|
||||
})
|
||||
|
||||
it('hides Readwise tab when user lacks readwise_sync', () => {
|
||||
const labels = getTabLabels(mountHeader())
|
||||
expect(labels).not.toContain('Readwise')
|
||||
})
|
||||
|
||||
it('shows integration tabs for superusers', () => {
|
||||
const labels = getTabLabels(mountHeader({ su: true }))
|
||||
expect(labels).toContain('Kobo')
|
||||
expect(labels).toContain('KOReader')
|
||||
expect(labels).toContain('Hardcover')
|
||||
expect(labels).toContain('Readwise')
|
||||
expect(labels).not.toContain('Integrations')
|
||||
})
|
||||
|
||||
it('places integration tabs after OPDS', () => {
|
||||
const labels = getTabLabels(mountHeader({ su: true }))
|
||||
expect(labels.slice(labels.indexOf('OPDS'), labels.indexOf('Admin'))).toEqual(['OPDS', 'Kobo', 'KOReader', 'Hardcover'])
|
||||
expect(labels.slice(labels.indexOf('OPDS'), labels.indexOf('Admin'))).toEqual(['OPDS', 'Kobo', 'KOReader', 'Hardcover', 'Readwise'])
|
||||
})
|
||||
|
||||
it('places Readwise tab after Hardcover', () => {
|
||||
const labels = getTabLabels(mountHeader({ su: true }))
|
||||
expect(labels.indexOf('Readwise')).toBe(labels.indexOf('Hardcover') + 1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@ function walkRoutes(records: RouteRecordRaw[], visit: (route: RouteRecordRaw, fu
|
||||
}
|
||||
}
|
||||
|
||||
function findRoute(records: RouteRecordRaw[], name: string): RouteRecordRaw | undefined {
|
||||
for (const record of records) {
|
||||
if (record.name === name) return record
|
||||
const child = record.children ? findRoute(record.children, name) : undefined
|
||||
if (child) return child
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
describe('router title metadata', () => {
|
||||
it('requires meta.title on all named non-redirect routes', () => {
|
||||
const missing: MissingRoute[] = []
|
||||
@@ -34,3 +43,13 @@ describe('router title metadata', () => {
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('router redirects', () => {
|
||||
it('redirects legacy integrations readwise tab to the Readwise settings route', () => {
|
||||
const route = findRoute(routes, 'settings-integrations')
|
||||
expect(route?.redirect).toBeTypeOf('function')
|
||||
|
||||
const redirect = route!.redirect as (to: { query: Record<string, unknown> }) => unknown
|
||||
expect(redirect({ query: { tab: 'readwise' } })).toEqual({ name: 'settings-readwise' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,6 +72,8 @@ function resolveLegacyIntegrationsRoute(tab: unknown): string {
|
||||
return 'settings-koreader'
|
||||
case 'hardcover':
|
||||
return 'settings-hardcover'
|
||||
case 'readwise':
|
||||
return 'settings-readwise'
|
||||
case 'kobo':
|
||||
default:
|
||||
return 'settings-kobo'
|
||||
@@ -146,6 +148,12 @@ export const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/features/hardcover/components/HardcoverSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-3xl', title: 'Hardcover' },
|
||||
},
|
||||
{
|
||||
path: 'readwise',
|
||||
name: 'settings-readwise',
|
||||
component: () => import('@/features/readwise/components/ReadwiseSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-3xl', title: 'Readwise' },
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'settings-email',
|
||||
|
||||
@@ -48,4 +48,5 @@ export * from "./reading-session";
|
||||
export * from "./reading-session-source-bucket";
|
||||
export * from "./annotation";
|
||||
export * from "./hardcover";
|
||||
export * from "./readwise";
|
||||
export * from "./path";
|
||||
|
||||
@@ -10,6 +10,7 @@ export enum Permission {
|
||||
KoboSync = "kobo_sync",
|
||||
KoreaderSync = "koreader_sync",
|
||||
HardcoverSync = "hardcover_sync",
|
||||
ReadwiseSync = "readwise_sync",
|
||||
OpdsAccess = "opds_access",
|
||||
BookDockAccess = "book_dock_access",
|
||||
|
||||
@@ -38,6 +39,7 @@ export const PERMISSION_LABELS: Record<Permission, string> = {
|
||||
[Permission.KoboSync]: "Kobo sync",
|
||||
[Permission.KoreaderSync]: "KOReader sync",
|
||||
[Permission.HardcoverSync]: "Hardcover sync",
|
||||
[Permission.ReadwiseSync]: "Readwise sync",
|
||||
[Permission.OpdsAccess]: "OPDS access",
|
||||
[Permission.BookDockAccess]: "Book Dock",
|
||||
[Permission.EmailSend]: "Send by email",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type ReadwiseSyncDisabledReason = "permission_denied" | "missing_token" | "user_disabled" | "invalid_token";
|
||||
|
||||
export interface ReadwiseSettings {
|
||||
tokenConfigured: boolean;
|
||||
enabled: boolean;
|
||||
effectiveEnabled: boolean;
|
||||
disabledReason: ReadwiseSyncDisabledReason | null;
|
||||
lastSyncedAt: string | null;
|
||||
}
|
||||
|
||||
export interface UpsertReadwiseSettingsPayload {
|
||||
apiToken?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ReadwiseTokenValidationResult {
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import { AppInfoModule } from './modules/app-info/app-info.module';
|
||||
import { ReleaseNotesModule } from './modules/release-notes/release-notes.module';
|
||||
import { AchievementModule } from './modules/achievement/achievement.module';
|
||||
import { HardcoverModule } from './modules/hardcover/hardcover.module';
|
||||
import { ReadwiseModule } from './modules/readwise/readwise.module';
|
||||
import { CustomMetadataModule } from './modules/custom-metadata/custom-metadata.module';
|
||||
import { CustomIconModule } from './modules/custom-icon/custom-icon.module';
|
||||
|
||||
@@ -139,6 +140,7 @@ import { CustomIconModule } from './modules/custom-icon/custom-icon.module';
|
||||
ReleaseNotesModule,
|
||||
AchievementModule,
|
||||
HardcoverModule,
|
||||
ReadwiseModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "readwise_user_settings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"api_token" text,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"last_synced_annotation_id" integer DEFAULT 0 NOT NULL,
|
||||
"disabled_reason" text,
|
||||
"last_synced_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "readwise_user_settings_user_id_unique" UNIQUE("user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "readwise_user_settings" ADD CONSTRAINT "readwise_user_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1 @@
|
||||
CREATE INDEX "annotations_user_id_id_idx" ON "annotations" USING btree ("user_id","id");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -281,6 +281,20 @@
|
||||
"when": 1783458659132,
|
||||
"tag": "0039_add_sync_to_kobo_smart_scopes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "7",
|
||||
"when": 1783465177576,
|
||||
"tag": "0040_add_readwise_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "7",
|
||||
"when": 1783469412249,
|
||||
"tag": "0041_readwise_annotation_user_id_id_index",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -29,4 +29,5 @@ export * from './fonts';
|
||||
export * from './koreader';
|
||||
export * from './achievements';
|
||||
export * from './hardcover';
|
||||
export * from './readwise';
|
||||
export * from './user-preferences';
|
||||
|
||||
@@ -306,6 +306,7 @@ export const annotations = pgTable(
|
||||
},
|
||||
(t) => [
|
||||
index('annotations_user_id_idx').on(t.userId),
|
||||
index('annotations_user_id_id_idx').on(t.userId, t.id),
|
||||
index('annotations_user_book_idx').on(t.userId, t.bookId),
|
||||
index('annotations_user_book_active_idx')
|
||||
.on(t.userId, t.bookId)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { boolean, integer, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { users } from './auth';
|
||||
|
||||
export const readwiseUserSettings = pgTable('readwise_user_settings', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.unique(),
|
||||
apiToken: text('api_token'),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
lastSyncedAnnotationId: integer('last_synced_annotation_id').notNull().default(0),
|
||||
disabledReason: text('disabled_reason'),
|
||||
lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
});
|
||||
|
||||
export type ReadwiseUserSetting = typeof readwiseUserSettings.$inferSelect;
|
||||
export type NewReadwiseUserSetting = typeof readwiseUserSettings.$inferInsert;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ACHIEVEMENT_EVENT_ANNOTATION_CREATED, AchievementEventsService } from '../achievement/achievement-events.service';
|
||||
import type { AnnotationSyncRepository } from './annotation-sync.repository';
|
||||
import { AnnotationSyncService, buildAnnotationKey, type IncomingDeviceAnnotation } from './annotation-sync.service';
|
||||
|
||||
@@ -119,8 +120,8 @@ function makeRepo(): RepoMock {
|
||||
} as unknown as RepoMock;
|
||||
}
|
||||
|
||||
function makeService(repo: RepoMock) {
|
||||
return new AnnotationSyncService(repo as unknown as AnnotationSyncRepository);
|
||||
function makeService(repo: RepoMock, achievementEvents: AchievementEventsService) {
|
||||
return new AnnotationSyncService(repo as unknown as AnnotationSyncRepository, achievementEvents);
|
||||
}
|
||||
|
||||
function ingest(service: AnnotationSyncService, annotations: IncomingDeviceAnnotation[]) {
|
||||
@@ -137,13 +138,17 @@ function ingest(service: AnnotationSyncService, annotations: IncomingDeviceAnnot
|
||||
describe('AnnotationSyncService', () => {
|
||||
let repo: RepoMock;
|
||||
let service: AnnotationSyncService;
|
||||
let achievementEvents: AchievementEventsService;
|
||||
let emitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
repo = makeRepo();
|
||||
service = makeService(repo);
|
||||
achievementEvents = new AchievementEventsService();
|
||||
emitSpy = vi.spyOn(achievementEvents, 'emit');
|
||||
service = makeService(repo, achievementEvents);
|
||||
});
|
||||
|
||||
describe('buildAnnotationKey', () => {
|
||||
@@ -195,6 +200,92 @@ describe('AnnotationSyncService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('annotation.created event emission', () => {
|
||||
it('emits ACHIEVEMENT_EVENT_ANNOTATION_CREATED once with the created row payload', async () => {
|
||||
const result = await ingest(service, [makeIncoming()]);
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||
expect(emitSpy).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_ANNOTATION_CREATED, {
|
||||
userId: USER_ID,
|
||||
bookId: BOOK_ID,
|
||||
annotationId: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits exactly once for a batch mixing one created and one unchanged annotation', async () => {
|
||||
const unchangedKey = buildAnnotationKey('2026-06-01 21:14:03', '/body/DocFragment[8]/body/p[12]/text().0');
|
||||
// The default makeIncoming key resolves to an existing device state (unchanged);
|
||||
// the second annotation has a fresh key and is genuinely created.
|
||||
repo.findStateByDeviceKey.mockImplementation(((_u: unknown, _s: unknown, _d: unknown, _b: unknown, key: string) =>
|
||||
Promise.resolve(key === unchangedKey ? makeStateRow() : null)) as never);
|
||||
repo.findAnnotationById.mockResolvedValue(makeAnnotationRow());
|
||||
repo.findDevicePosition.mockResolvedValue(makePositionRow());
|
||||
|
||||
const created = makeIncoming({ datetime: '2026-06-02 09:00:00', pos0: '/body/DocFragment[9]/body/p[3]/text().0' });
|
||||
const result = await ingest(service, [makeIncoming(), created]);
|
||||
|
||||
expect(result).toMatchObject({ created: 1, unchanged: 1 });
|
||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||
expect(emitSpy).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_ANNOTATION_CREATED, {
|
||||
userId: USER_ID,
|
||||
bookId: BOOK_ID,
|
||||
annotationId: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not emit for non-create outcomes', async () => {
|
||||
repo.findStateByDeviceKey.mockResolvedValue(makeStateRow());
|
||||
repo.findAnnotationById.mockResolvedValue(makeAnnotationRow());
|
||||
repo.findDevicePosition.mockResolvedValue(makePositionRow());
|
||||
|
||||
const result = await ingest(service, [makeIncoming()]);
|
||||
|
||||
expect(result.unchanged).toBe(1);
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits only after the transaction commits, never inside the tx callback', async () => {
|
||||
let emitCallsAtTxResolve = -1;
|
||||
repo.transaction.mockImplementation((async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const r = await fn(TX);
|
||||
// Snapshot emit count at the moment the tx body finishes but before the service
|
||||
// runs its post-commit emit loop.
|
||||
emitCallsAtTxResolve = emitSpy.mock.calls.length;
|
||||
return r;
|
||||
}) as never);
|
||||
|
||||
await ingest(service, [makeIncoming()]);
|
||||
|
||||
expect(emitCallsAtTxResolve).toBe(0);
|
||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fail ingest or skip later emits when a post-commit listener throws', async () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
let nextId = 100;
|
||||
repo.createCanonical.mockImplementation(((annotation: Record<string, unknown>) =>
|
||||
Promise.resolve(makeAnnotationRow({ ...annotation, id: nextId++ }))) as never);
|
||||
emitSpy.mockImplementationOnce(() => {
|
||||
throw new Error('listener failed');
|
||||
});
|
||||
|
||||
const result = await ingest(service, [
|
||||
makeIncoming(),
|
||||
makeIncoming({ datetime: '2026-06-02 09:00:00', pos0: '/body/DocFragment[9]/body/p[3]/text().0' }),
|
||||
]);
|
||||
|
||||
expect(result.created).toBe(2);
|
||||
expect(emitSpy).toHaveBeenCalledTimes(2);
|
||||
expect(emitSpy).toHaveBeenNthCalledWith(2, ACHIEVEMENT_EVENT_ANNOTATION_CREATED, {
|
||||
userId: USER_ID,
|
||||
bookId: BOOK_ID,
|
||||
annotationId: 101,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[annotation.sync_event] [fail]'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('device-key match path', () => {
|
||||
it('treats a re-upload with no new edit as unchanged and only touches state', async () => {
|
||||
repo.findStateByDeviceKey.mockResolvedValue(makeStateRow());
|
||||
|
||||
@@ -13,8 +13,14 @@ import {
|
||||
styleFromDrawer,
|
||||
} from './annotation-style-map';
|
||||
import { AnnotationSyncRepository, type DbTx } from './annotation-sync.repository';
|
||||
import {
|
||||
ACHIEVEMENT_EVENT_ANNOTATION_CREATED,
|
||||
AchievementEventsService,
|
||||
type AnnotationCreatedPayload,
|
||||
} from '../achievement/achievement-events.service';
|
||||
|
||||
const INGEST_EVENT = 'annotation.sync_ingest';
|
||||
const INGEST_EMIT_EVENT = 'annotation.sync_event';
|
||||
|
||||
export interface IncomingDeviceAnnotation {
|
||||
/**
|
||||
@@ -75,7 +81,10 @@ function siblingFormatsOf(posFormat: IncomingDeviceAnnotation['posFormat']): Ann
|
||||
export class AnnotationSyncService {
|
||||
private readonly logger = new Logger(AnnotationSyncService.name);
|
||||
|
||||
constructor(private readonly syncRepo: AnnotationSyncRepository) {}
|
||||
constructor(
|
||||
private readonly syncRepo: AnnotationSyncRepository,
|
||||
private readonly achievementEvents: AchievementEventsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ingests a batch of device annotations for one book. Identity is the external key
|
||||
@@ -85,13 +94,19 @@ export class AnnotationSyncService {
|
||||
async ingestDeviceAnnotations(params: IngestParams): Promise<IngestResult> {
|
||||
const startedAtMs = Date.now();
|
||||
const result: IngestResult = { created: 0, updated: 0, moved: 0, unchanged: 0, skippedDeleted: 0 };
|
||||
const createdIds: number[] = [];
|
||||
|
||||
try {
|
||||
await this.syncRepo.transaction(async (tx) => {
|
||||
for (const incoming of this.dedupeByKey(params.annotations)) {
|
||||
await this.ingestOne(params, incoming, result, tx);
|
||||
await this.ingestOne(params, incoming, result, createdIds, tx);
|
||||
}
|
||||
});
|
||||
// Emit only after the tx commits so listeners (e.g. Readwise flush that queries
|
||||
// rows with id > watermark) see the committed, visible rows.
|
||||
for (const annotationId of createdIds) {
|
||||
this.emitCreatedAnnotation(params.userId, params.bookId, annotationId);
|
||||
}
|
||||
this.logger.log(
|
||||
`[${INGEST_EVENT}] [end] userId=${params.userId} bookId=${params.bookId} deviceId=${params.deviceId.slice(0, 8)} durationMs=${Date.now() - startedAtMs} created=${result.created} updated=${result.updated} moved=${result.moved} unchanged=${result.unchanged} skippedDeleted=${result.skippedDeleted} - device annotations ingested`,
|
||||
);
|
||||
@@ -113,7 +128,29 @@ export class AnnotationSyncService {
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
private async ingestOne(params: IngestParams, incoming: IncomingDeviceAnnotation, result: IngestResult, tx: DbTx): Promise<void> {
|
||||
private emitCreatedAnnotation(userId: number, bookId: number, annotationId: number): void {
|
||||
const startedAtMs = Date.now();
|
||||
try {
|
||||
this.achievementEvents.emit(ACHIEVEMENT_EVENT_ANNOTATION_CREATED, {
|
||||
userId,
|
||||
bookId,
|
||||
annotationId,
|
||||
} satisfies AnnotationCreatedPayload);
|
||||
} catch (error) {
|
||||
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
|
||||
this.logger.warn(
|
||||
`[${INGEST_EMIT_EVENT}] [fail] userId=${userId} bookId=${bookId} annotationId=${annotationId} durationMs=${Date.now() - startedAtMs} errorClass=${errorClass} error="${sanitizeLogValue(error instanceof Error ? error.message : 'unknown error')}" - created annotation event dispatch failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async ingestOne(
|
||||
params: IngestParams,
|
||||
incoming: IncomingDeviceAnnotation,
|
||||
result: IngestResult,
|
||||
createdIds: number[],
|
||||
tx: DbTx,
|
||||
): Promise<void> {
|
||||
const { userId, source, deviceId, bookId, bookFileId } = params;
|
||||
const key = keyOf(incoming);
|
||||
|
||||
@@ -237,7 +274,7 @@ export class AnnotationSyncService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.syncRepo.createCanonical(
|
||||
const createdRow = await this.syncRepo.createCanonical(
|
||||
{
|
||||
userId,
|
||||
bookId,
|
||||
@@ -268,6 +305,7 @@ export class AnnotationSyncService {
|
||||
},
|
||||
tx,
|
||||
);
|
||||
createdIds.push(createdRow.id);
|
||||
result.created += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './readwise.dto';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpsertReadwiseSettingsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
apiToken?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class ValidateReadwiseTokenDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
token?: string;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ReadwiseAutoSyncSchedulerService } from './readwise-auto-sync-scheduler.service';
|
||||
import {
|
||||
READWISE_AUTO_SYNC_DEBOUNCE_MS,
|
||||
READWISE_AUTO_SYNC_MAX_RETRIES,
|
||||
READWISE_AUTO_SYNC_MAX_WAIT_MS,
|
||||
READWISE_AUTO_SYNC_RETRY_BASE_MS,
|
||||
} from './readwise.constants';
|
||||
import { ReadwiseSyncService } from './readwise-sync.service';
|
||||
|
||||
const mockSyncService = {
|
||||
flush: vi.fn(),
|
||||
};
|
||||
|
||||
function makeDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('ReadwiseAutoSyncSchedulerService', () => {
|
||||
let service: ReadwiseAutoSyncSchedulerService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockSyncService.flush.mockResolvedValue(undefined);
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
service = new ReadwiseAutoSyncSchedulerService(mockSyncService as unknown as ReadwiseSyncService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('collapses multiple requests within the debounce window into a single flush', async () => {
|
||||
service.requestSync(1);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
service.requestSync(1);
|
||||
service.requestSync(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(READWISE_AUTO_SYNC_DEBOUNCE_MS - 1);
|
||||
expect(mockSyncService.flush).not.toHaveBeenCalled();
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockSyncService.flush).toHaveBeenCalledTimes(1);
|
||||
expect(mockSyncService.flush).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('flushes independently for different users', async () => {
|
||||
service.requestSync(1);
|
||||
service.requestSync(2);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockSyncService.flush).toHaveBeenCalledTimes(2);
|
||||
expect(mockSyncService.flush).toHaveBeenCalledWith(1);
|
||||
expect(mockSyncService.flush).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('triggers a second flush for a fresh request after a completed flush', async () => {
|
||||
service.requestSync(1);
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockSyncService.flush).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.requestSync(1);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockSyncService.flush).toHaveBeenCalledTimes(2);
|
||||
expect(mockSyncService.flush).toHaveBeenNthCalledWith(2, 1);
|
||||
});
|
||||
|
||||
it('schedules exactly one follow-up flush for a request arriving while a flush is in flight', async () => {
|
||||
const firstFlush = makeDeferred<void>();
|
||||
mockSyncService.flush.mockReturnValueOnce(firstFlush.promise).mockResolvedValueOnce(undefined);
|
||||
|
||||
service.requestSync(1);
|
||||
await vi.advanceTimersByTimeAsync(READWISE_AUTO_SYNC_DEBOUNCE_MS);
|
||||
expect(mockSyncService.flush).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Arrives while the first flush is still in flight.
|
||||
service.requestSync(1);
|
||||
await vi.advanceTimersByTimeAsync(READWISE_AUTO_SYNC_DEBOUNCE_MS);
|
||||
expect(mockSyncService.flush).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Complete the first flush; a follow-up should now be scheduled.
|
||||
firstFlush.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(READWISE_AUTO_SYNC_DEBOUNCE_MS - 1);
|
||||
expect(mockSyncService.flush).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mockSyncService.flush).toHaveBeenCalledTimes(2);
|
||||
expect(mockSyncService.flush).toHaveBeenNthCalledWith(2, 1);
|
||||
});
|
||||
|
||||
it('does not postpone a pending flush past the max wait under continuous requests', async () => {
|
||||
service.requestSync(1);
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await vi.advanceTimersByTimeAsync(900);
|
||||
service.requestSync(1);
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(READWISE_AUTO_SYNC_MAX_WAIT_MS - 4500 - 1);
|
||||
expect(mockSyncService.flush).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mockSyncService.flush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries and logs transient flush failures', async () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
mockSyncService.flush.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
service.requestSync(1);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockSyncService.flush).toHaveBeenCalledTimes(2);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[readwise.scheduler] [fail] userId=1'));
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(`nextDelayMs=${READWISE_AUTO_SYNC_RETRY_BASE_MS}`));
|
||||
});
|
||||
|
||||
it('stops retrying after the retry budget is exhausted', async () => {
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
mockSyncService.flush.mockRejectedValue(new Error('boom'));
|
||||
|
||||
service.requestSync(1);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockSyncService.flush).toHaveBeenCalledTimes(READWISE_AUTO_SYNC_MAX_RETRIES + 1);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('retryExhausted=true'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import {
|
||||
READWISE_AUTO_SYNC_DEBOUNCE_MS,
|
||||
READWISE_AUTO_SYNC_MAX_RETRIES,
|
||||
READWISE_AUTO_SYNC_MAX_WAIT_MS,
|
||||
READWISE_AUTO_SYNC_RETRY_BASE_MS,
|
||||
} from './readwise.constants';
|
||||
import { ReadwiseSyncService } from './readwise-sync.service';
|
||||
|
||||
interface AutoSyncState {
|
||||
userId: number;
|
||||
timer: NodeJS.Timeout | null;
|
||||
inFlight: boolean;
|
||||
pending: boolean;
|
||||
retryAttempt: number;
|
||||
firstPendingAtMs: number | null;
|
||||
}
|
||||
|
||||
const SCHEDULER_EVENT = 'readwise.scheduler';
|
||||
|
||||
@Injectable()
|
||||
export class ReadwiseAutoSyncSchedulerService {
|
||||
private readonly logger = new Logger(ReadwiseAutoSyncSchedulerService.name);
|
||||
private readonly states = new Map<number, AutoSyncState>();
|
||||
private readonly userSyncQueues = new Map<number, Promise<void>>();
|
||||
|
||||
constructor(private readonly syncService: ReadwiseSyncService) {}
|
||||
|
||||
requestSync(userId: number): void {
|
||||
const state = this.states.get(userId) ?? {
|
||||
userId,
|
||||
timer: null,
|
||||
inFlight: false,
|
||||
pending: false,
|
||||
retryAttempt: 0,
|
||||
firstPendingAtMs: null,
|
||||
};
|
||||
this.states.set(userId, state);
|
||||
if (!state.pending) state.firstPendingAtMs = Date.now();
|
||||
state.pending = true;
|
||||
state.retryAttempt = 0;
|
||||
if (state.inFlight) return;
|
||||
this.schedule(state);
|
||||
}
|
||||
|
||||
private schedule(state: AutoSyncState, delayMs = READWISE_AUTO_SYNC_DEBOUNCE_MS, capByMaxWait = true): void {
|
||||
if (state.timer) clearTimeout(state.timer);
|
||||
const cappedDelayMs = capByMaxWait ? Math.min(delayMs, this.remainingMaxWaitMs(state)) : delayMs;
|
||||
state.timer = setTimeout(() => {
|
||||
state.timer = null;
|
||||
void this.runDue(state.userId);
|
||||
}, cappedDelayMs);
|
||||
}
|
||||
|
||||
private async runDue(userId: number): Promise<void> {
|
||||
const state = this.states.get(userId);
|
||||
if (!state || state.inFlight || !state.pending) return;
|
||||
state.pending = false;
|
||||
state.firstPendingAtMs = null;
|
||||
state.inFlight = true;
|
||||
let nextDelayMs = READWISE_AUTO_SYNC_DEBOUNCE_MS;
|
||||
const startedAtMs = Date.now();
|
||||
try {
|
||||
await this.enqueue(userId, () => this.syncService.flush(userId));
|
||||
state.retryAttempt = 0;
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startedAtMs;
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
|
||||
if (state.retryAttempt < READWISE_AUTO_SYNC_MAX_RETRIES) {
|
||||
state.retryAttempt += 1;
|
||||
state.pending = true;
|
||||
state.firstPendingAtMs ??= Date.now();
|
||||
nextDelayMs = READWISE_AUTO_SYNC_RETRY_BASE_MS * Math.pow(2, state.retryAttempt - 1);
|
||||
this.logger.warn(
|
||||
`[${SCHEDULER_EVENT}] [fail] userId=${userId} durationMs=${durationMs} errorClass=${errorClass} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" retryAttempt=${state.retryAttempt} nextDelayMs=${nextDelayMs} - Readwise flush failed; retry scheduled`,
|
||||
);
|
||||
} else {
|
||||
state.retryAttempt = 0;
|
||||
this.logger.error(
|
||||
`[${SCHEDULER_EVENT}] [fail] userId=${userId} durationMs=${durationMs} errorClass=${errorClass} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" retryExhausted=true - Readwise flush failed; retries exhausted`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
state.inFlight = false;
|
||||
if (state.pending) this.schedule(state, nextDelayMs, nextDelayMs === READWISE_AUTO_SYNC_DEBOUNCE_MS);
|
||||
else this.states.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private remainingMaxWaitMs(state: AutoSyncState): number {
|
||||
if (state.firstPendingAtMs === null) return READWISE_AUTO_SYNC_DEBOUNCE_MS;
|
||||
const elapsedMs = Date.now() - state.firstPendingAtMs;
|
||||
return Math.max(0, READWISE_AUTO_SYNC_MAX_WAIT_MS - elapsedMs);
|
||||
}
|
||||
|
||||
private enqueue(userId: number, task: () => Promise<void>): Promise<void> {
|
||||
const previous = this.userSyncQueues.get(userId) ?? Promise.resolve();
|
||||
const current = previous.catch(() => undefined).then(task);
|
||||
this.userSyncQueues.set(userId, current);
|
||||
current
|
||||
.finally(() => {
|
||||
if (this.userSyncQueues.get(userId) === current) this.userSyncQueues.delete(userId);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return current;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { ReadwiseClientService, ReadwiseUnauthorizedError, type ReadwiseHighlight } from './readwise-client.service';
|
||||
import { ReadwiseQueueService } from './readwise-queue.service';
|
||||
import { READWISE_HIGHLIGHTS_URL } from './readwise.constants';
|
||||
|
||||
vi.mock('./readwise-queue.service');
|
||||
|
||||
const mockQueueService = {
|
||||
throttle: vi.fn().mockResolvedValue(undefined),
|
||||
resetUser: vi.fn(),
|
||||
};
|
||||
|
||||
function makeFetchResponse(status: number): Response {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function makeHighlight(text = 'a note'): ReadwiseHighlight {
|
||||
return {
|
||||
text,
|
||||
title: 'A Book',
|
||||
author: 'An Author',
|
||||
source_type: 'bookorbit',
|
||||
category: 'books',
|
||||
};
|
||||
}
|
||||
|
||||
describe('ReadwiseClientService', () => {
|
||||
let service: ReadwiseClientService;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockQueueService.throttle.mockClear();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
service = new ReadwiseClientService(mockQueueService as unknown as ReadwiseQueueService);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('validateToken', () => {
|
||||
it('returns true on 204', async () => {
|
||||
fetchMock.mockResolvedValueOnce(makeFetchResponse(204));
|
||||
await expect(service.validateToken(1, 'tok')).resolves.toBe(true);
|
||||
expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('returns false on 401', async () => {
|
||||
fetchMock.mockResolvedValueOnce(makeFetchResponse(401));
|
||||
await expect(service.validateToken(1, 'tok')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when fetch throws', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
await expect(service.validateToken(1, 'tok')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHighlights', () => {
|
||||
it('returns early without fetching when highlights array is empty', async () => {
|
||||
await service.createHighlights(1, 'tok', []);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mockQueueService.throttle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts to the highlights URL with correct headers and body', async () => {
|
||||
fetchMock.mockResolvedValueOnce(makeFetchResponse(200));
|
||||
const highlights = [makeHighlight()];
|
||||
await service.createHighlights(1, 'tok', highlights);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(READWISE_HIGHLIGHTS_URL);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers.Authorization).toBe('Token tok');
|
||||
expect(init.headers['Content-Type']).toBe('application/json');
|
||||
expect(init.body).toBe(JSON.stringify({ highlights }));
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('calls queue.throttle before fetching', async () => {
|
||||
fetchMock.mockResolvedValueOnce(makeFetchResponse(200));
|
||||
await service.createHighlights(7, 'tok', [makeHighlight()]);
|
||||
expect(mockQueueService.throttle).toHaveBeenCalledWith(7);
|
||||
const throttleOrder = mockQueueService.throttle.mock.invocationCallOrder[0];
|
||||
const fetchOrder = fetchMock.mock.invocationCallOrder[0];
|
||||
expect(throttleOrder).toBeLessThan(fetchOrder);
|
||||
});
|
||||
|
||||
it('backs off and retries on 429, giving up after max retries', async () => {
|
||||
fetchMock.mockResolvedValue(makeFetchResponse(429));
|
||||
const p = service.createHighlights(1, 'tok', [makeHighlight()]);
|
||||
const expectation = expect(p).rejects.toThrow('Readwise rate limit exceeded');
|
||||
await vi.runAllTimersAsync();
|
||||
await expectation;
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4); // 1 + 3 retries
|
||||
});
|
||||
|
||||
it('succeeds when a 429 is followed by a 200', async () => {
|
||||
fetchMock.mockResolvedValueOnce(makeFetchResponse(429)).mockResolvedValueOnce(makeFetchResponse(200));
|
||||
const p = service.createHighlights(1, 'tok', [makeHighlight()]);
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(p).resolves.toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws ReadwiseUnauthorizedError on 401', async () => {
|
||||
fetchMock.mockResolvedValueOnce(makeFetchResponse(401));
|
||||
await expect(service.createHighlights(1, 'tok', [makeHighlight()])).rejects.toBeInstanceOf(ReadwiseUnauthorizedError);
|
||||
});
|
||||
|
||||
it('throws on 500', async () => {
|
||||
fetchMock.mockResolvedValueOnce(makeFetchResponse(500));
|
||||
await expect(service.createHighlights(1, 'tok', [makeHighlight()])).rejects.toThrow('Readwise API error: 500');
|
||||
});
|
||||
|
||||
it('rethrows network errors', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('Network failure'));
|
||||
await expect(service.createHighlights(1, 'tok', [makeHighlight()])).rejects.toThrow('Network failure');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { READWISE_AUTH_URL, READWISE_HIGHLIGHTS_URL, READWISE_MAX_RETRIES, READWISE_REQUEST_TIMEOUT_MS } from './readwise.constants';
|
||||
import { ReadwiseQueueService } from './readwise-queue.service';
|
||||
|
||||
export interface ReadwiseHighlight {
|
||||
text: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
note?: string;
|
||||
image_url?: string;
|
||||
highlighted_at?: string;
|
||||
highlight_url?: string;
|
||||
source_type: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReadwiseClientService {
|
||||
private readonly logger = new Logger(ReadwiseClientService.name);
|
||||
|
||||
constructor(private readonly queue: ReadwiseQueueService) {}
|
||||
|
||||
async validateToken(userId: number, token: string): Promise<boolean> {
|
||||
const startedAtMs = Date.now();
|
||||
try {
|
||||
const res = await fetch(READWISE_AUTH_URL, {
|
||||
headers: { Authorization: `Token ${token}` },
|
||||
signal: AbortSignal.timeout(READWISE_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
return res.status === 204;
|
||||
} catch (err) {
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
|
||||
this.logger.warn(
|
||||
`[readwise.client.validate_token] [fail] userId=${userId} durationMs=${Date.now() - startedAtMs} errorClass=${errorClass} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - token validation failed`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async createHighlights(userId: number, token: string, highlights: ReadwiseHighlight[], attempt = 0): Promise<void> {
|
||||
if (highlights.length === 0) return;
|
||||
await this.queue.throttle(userId);
|
||||
|
||||
const startedAtMs = Date.now();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(READWISE_HIGHLIGHTS_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Token ${token}`,
|
||||
'User-Agent': 'BookOrbit Readwise Sync',
|
||||
},
|
||||
body: JSON.stringify({ highlights }),
|
||||
signal: AbortSignal.timeout(READWISE_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
|
||||
this.logger.error(
|
||||
`[readwise.client.create_highlights] [fail] userId=${userId} attempt=${attempt} durationMs=${Date.now() - startedAtMs} errorClass=${errorClass} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - Readwise highlights create request failed`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
if (attempt >= READWISE_MAX_RETRIES) throw new Error('Readwise rate limit exceeded');
|
||||
const backoffMs = Math.pow(2, attempt + 1) * 1000;
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
||||
return this.createHighlights(userId, token, highlights, attempt + 1);
|
||||
}
|
||||
if (res.status === 401) throw new ReadwiseUnauthorizedError();
|
||||
if (!res.ok) throw new Error(`Readwise API error: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class ReadwiseUnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super('Readwise token unauthorized');
|
||||
this.name = 'ReadwiseUnauthorizedError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ACHIEVEMENT_EVENT_ANNOTATION_CREATED, AchievementEventsService } from '../achievement/achievement-events.service';
|
||||
import { ReadwiseAutoSyncSchedulerService } from './readwise-auto-sync-scheduler.service';
|
||||
import { ReadwiseEventListener } from './readwise-event-listener.service';
|
||||
|
||||
const mockScheduler = {
|
||||
requestSync: vi.fn(),
|
||||
};
|
||||
|
||||
function makeListener() {
|
||||
const events = new AchievementEventsService();
|
||||
const listener = new ReadwiseEventListener(events, mockScheduler as unknown as ReadwiseAutoSyncSchedulerService);
|
||||
listener.onModuleInit();
|
||||
return { events };
|
||||
}
|
||||
|
||||
describe('ReadwiseEventListener', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('requests a sync for the user when an annotation is created', () => {
|
||||
const { events } = makeListener();
|
||||
|
||||
events.emit(ACHIEVEMENT_EVENT_ANNOTATION_CREATED, { userId: 7, bookId: 1, annotationId: 42 });
|
||||
|
||||
expect(mockScheduler.requestSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockScheduler.requestSync).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
ACHIEVEMENT_EVENT_ANNOTATION_CREATED,
|
||||
AchievementEventsService,
|
||||
type AnnotationCreatedPayload,
|
||||
} from '../achievement/achievement-events.service';
|
||||
import { ReadwiseAutoSyncSchedulerService } from './readwise-auto-sync-scheduler.service';
|
||||
|
||||
@Injectable()
|
||||
export class ReadwiseEventListener implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly achievementEvents: AchievementEventsService,
|
||||
private readonly scheduler: ReadwiseAutoSyncSchedulerService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.achievementEvents.on(ACHIEVEMENT_EVENT_ANNOTATION_CREATED, (payload: AnnotationCreatedPayload) => {
|
||||
this.scheduler.requestSync(payload.userId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { ReadwiseQueueService } from './readwise-queue.service';
|
||||
import { READWISE_MIN_INTERVAL_MS } from './readwise.constants';
|
||||
|
||||
describe('ReadwiseQueueService', () => {
|
||||
let service: ReadwiseQueueService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new ReadwiseQueueService();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not wait on first request', async () => {
|
||||
const spy = vi.spyOn(global, 'setTimeout');
|
||||
const p = service.throttle(1);
|
||||
vi.runAllTimers();
|
||||
await p;
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throttle within the interval', async () => {
|
||||
// first call - no wait
|
||||
vi.setSystemTime(1000);
|
||||
await service.throttle(1);
|
||||
|
||||
// second call immediately - should wait
|
||||
vi.setSystemTime(1050);
|
||||
const p = service.throttle(1);
|
||||
await vi.runAllTimersAsync();
|
||||
await p;
|
||||
// Just verifying it resolves without throwing
|
||||
});
|
||||
|
||||
it('should not wait when interval has passed', async () => {
|
||||
vi.setSystemTime(1000);
|
||||
await service.throttle(1);
|
||||
vi.setSystemTime(1000 + READWISE_MIN_INTERVAL_MS + 1);
|
||||
await service.throttle(1); // should resolve immediately
|
||||
});
|
||||
|
||||
it('should track different users independently', async () => {
|
||||
vi.setSystemTime(1000);
|
||||
await service.throttle(1);
|
||||
vi.setSystemTime(1050);
|
||||
// user 2 has no prior request, should not wait
|
||||
await service.throttle(2);
|
||||
});
|
||||
|
||||
it('should reset user state', async () => {
|
||||
vi.setSystemTime(1000);
|
||||
await service.throttle(1);
|
||||
service.resetUser(1);
|
||||
vi.setSystemTime(1010);
|
||||
// after reset, should not wait
|
||||
await service.throttle(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { READWISE_MIN_INTERVAL_MS } from './readwise.constants';
|
||||
|
||||
@Injectable()
|
||||
export class ReadwiseQueueService {
|
||||
private readonly lastRequestAt = new Map<number, number>();
|
||||
|
||||
async throttle(userId: number): Promise<void> {
|
||||
const last = this.lastRequestAt.get(userId);
|
||||
if (last !== undefined) {
|
||||
const elapsed = Date.now() - last;
|
||||
if (elapsed < READWISE_MIN_INTERVAL_MS) {
|
||||
const wait = READWISE_MIN_INTERVAL_MS - elapsed;
|
||||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
}
|
||||
}
|
||||
this.lastRequestAt.set(userId, Date.now());
|
||||
}
|
||||
|
||||
resetUser(userId: number): void {
|
||||
this.lastRequestAt.delete(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { ReadwiseSettingsService } from './readwise-settings.service';
|
||||
|
||||
const mockRepo = {
|
||||
findSettings: vi.fn(),
|
||||
userHasReadwiseSyncPermission: vi.fn(),
|
||||
findLatestAnnotationId: vi.fn(),
|
||||
upsertSettings: vi.fn(),
|
||||
};
|
||||
|
||||
const mockClient = {
|
||||
validateToken: vi.fn(),
|
||||
};
|
||||
|
||||
const mockScheduler = {
|
||||
requestSync: vi.fn(),
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
return new ReadwiseSettingsService(mockRepo as any, mockClient as any, mockScheduler as any);
|
||||
}
|
||||
|
||||
describe('ReadwiseSettingsService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRepo.userHasReadwiseSyncPermission.mockResolvedValue(true);
|
||||
mockRepo.findLatestAnnotationId.mockResolvedValue(42);
|
||||
mockRepo.upsertSettings.mockResolvedValue(undefined);
|
||||
mockScheduler.requestSync.mockClear();
|
||||
});
|
||||
|
||||
describe('getSettings', () => {
|
||||
it('returns defaults when no settings row exists', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(undefined);
|
||||
const result = await makeService().getSettings(1);
|
||||
expect(result.tokenConfigured).toBe(false);
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.effectiveEnabled).toBe(false);
|
||||
expect(result.disabledReason).toBe('missing_token');
|
||||
expect(result.lastSyncedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('composes effectiveEnabled = hasPermission && tokenConfigured && enabled (all true)', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue({ apiToken: 'secret', enabled: true, disabledReason: null, lastSyncedAt: null });
|
||||
const result = await makeService().getSettings(1);
|
||||
expect(result.effectiveEnabled).toBe(true);
|
||||
expect(result.disabledReason).toBeNull();
|
||||
});
|
||||
|
||||
it('effectiveEnabled is false when permission is denied even if token+enabled', async () => {
|
||||
mockRepo.userHasReadwiseSyncPermission.mockResolvedValue(false);
|
||||
mockRepo.findSettings.mockResolvedValue({ apiToken: 'secret', enabled: true, disabledReason: null, lastSyncedAt: null });
|
||||
const result = await makeService().getSettings(1);
|
||||
expect(result.effectiveEnabled).toBe(false);
|
||||
expect(result.disabledReason).toBe('permission_denied');
|
||||
});
|
||||
|
||||
it('effectiveEnabled is false when token configured+permission but not enabled', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue({ apiToken: 'secret', enabled: false, disabledReason: null, lastSyncedAt: null });
|
||||
const result = await makeService().getSettings(1);
|
||||
expect(result.effectiveEnabled).toBe(false);
|
||||
expect(result.disabledReason).toBe('user_disabled');
|
||||
});
|
||||
|
||||
it('returns the STORED disabledReason over the derived one (round-trip)', async () => {
|
||||
// enabled=false would normally derive 'user_disabled', but a stored 'invalid_token' must win.
|
||||
mockRepo.findSettings.mockResolvedValue({ apiToken: 'secret', enabled: false, disabledReason: 'invalid_token', lastSyncedAt: null });
|
||||
const result = await makeService().getSettings(1);
|
||||
expect(result.disabledReason).toBe('invalid_token');
|
||||
});
|
||||
|
||||
it('derives missing_token when no token and stored reason is null', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue({ apiToken: null, enabled: false, disabledReason: null, lastSyncedAt: null });
|
||||
const result = await makeService().getSettings(1);
|
||||
expect(result.disabledReason).toBe('missing_token');
|
||||
});
|
||||
|
||||
it('serialises lastSyncedAt to ISO string', async () => {
|
||||
const when = new Date('2026-01-02T03:04:05.000Z');
|
||||
mockRepo.findSettings.mockResolvedValue({ apiToken: 'secret', enabled: true, disabledReason: null, lastSyncedAt: when });
|
||||
const result = await makeService().getSettings(1);
|
||||
expect(result.lastSyncedAt).toBe('2026-01-02T03:04:05.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertSettings', () => {
|
||||
it('throws BadRequestException when no existing token and no token in payload', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(undefined);
|
||||
await expect(makeService().upsertSettings(1, { enabled: true })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mockRepo.upsertSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves existing token when payload omits it (only enabled changes)', async () => {
|
||||
mockRepo.findSettings
|
||||
.mockResolvedValueOnce({ apiToken: 'existing', enabled: true, disabledReason: null, lastSyncedAt: null })
|
||||
.mockResolvedValueOnce({ apiToken: 'existing', enabled: false, disabledReason: null, lastSyncedAt: null });
|
||||
await makeService().upsertSettings(1, { enabled: false });
|
||||
const data = mockRepo.upsertSettings.mock.calls[0][1];
|
||||
expect(data.apiToken).toBeUndefined();
|
||||
expect(data.enabled).toBe(false);
|
||||
expect(mockScheduler.requestSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets disabledReason null and trims when a new token is provided', async () => {
|
||||
mockRepo.findSettings
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({ apiToken: 'fresh-token', enabled: true, disabledReason: null, lastSyncedAt: null });
|
||||
await makeService().upsertSettings(1, { apiToken: ' fresh-token ' });
|
||||
const data = mockRepo.upsertSettings.mock.calls[0][1];
|
||||
expect(data.apiToken).toBe('fresh-token');
|
||||
expect(data.disabledReason).toBeNull();
|
||||
expect(data.lastSyncedAnnotationId).toBe(42);
|
||||
expect(mockScheduler.requestSync).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('seeds the watermark when enabling an existing disabled integration', async () => {
|
||||
mockRepo.findSettings
|
||||
.mockResolvedValueOnce({ apiToken: 'existing', enabled: false, disabledReason: null, lastSyncedAt: null })
|
||||
.mockResolvedValueOnce({ apiToken: 'existing', enabled: true, disabledReason: null, lastSyncedAt: null });
|
||||
await makeService().upsertSettings(1, { enabled: true });
|
||||
const data = mockRepo.upsertSettings.mock.calls[0][1];
|
||||
expect(data.enabled).toBe(true);
|
||||
expect(data.lastSyncedAnnotationId).toBe(42);
|
||||
expect(mockScheduler.requestSync).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not seed the watermark when recovering from invalid_token auto-disable', async () => {
|
||||
mockRepo.findSettings
|
||||
.mockResolvedValueOnce({ apiToken: 'existing', enabled: false, disabledReason: 'invalid_token', lastSyncedAt: null })
|
||||
.mockResolvedValueOnce({ apiToken: 'fresh-token', enabled: true, disabledReason: null, lastSyncedAt: null });
|
||||
await makeService().upsertSettings(1, { apiToken: 'fresh-token', enabled: true });
|
||||
const data = mockRepo.upsertSettings.mock.calls[0][1];
|
||||
expect(data.apiToken).toBe('fresh-token');
|
||||
expect('lastSyncedAnnotationId' in data).toBe(false);
|
||||
expect(mockScheduler.requestSync).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('sets disabledReason null when enabled:true is provided', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue({ apiToken: 'existing', enabled: false, disabledReason: 'invalid_token', lastSyncedAt: null });
|
||||
await makeService().upsertSettings(1, { enabled: true });
|
||||
const data = mockRepo.upsertSettings.mock.calls[0][1];
|
||||
expect(data.enabled).toBe(true);
|
||||
expect(data.disabledReason).toBeNull();
|
||||
});
|
||||
|
||||
it('does not clear disabledReason when only disabling (enabled:false)', async () => {
|
||||
mockRepo.findSettings
|
||||
.mockResolvedValueOnce({ apiToken: 'existing', enabled: true, disabledReason: null, lastSyncedAt: null })
|
||||
.mockResolvedValueOnce({ apiToken: 'existing', enabled: false, disabledReason: null, lastSyncedAt: null });
|
||||
await makeService().upsertSettings(1, { enabled: false });
|
||||
const data = mockRepo.upsertSettings.mock.calls[0][1];
|
||||
expect('disabledReason' in data).toBe(false);
|
||||
expect(mockScheduler.requestSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns fresh settings via getSettings after upsert', async () => {
|
||||
mockRepo.findSettings
|
||||
.mockResolvedValueOnce({ apiToken: 'existing', enabled: false, disabledReason: null, lastSyncedAt: null })
|
||||
.mockResolvedValueOnce({ apiToken: 'existing', enabled: true, disabledReason: null, lastSyncedAt: null });
|
||||
const result = await makeService().upsertSettings(1, { enabled: true });
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.effectiveEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToken', () => {
|
||||
it('uses the passed token when present', async () => {
|
||||
mockClient.validateToken.mockResolvedValue(true);
|
||||
const result = await makeService().validateToken(1, ' passed-token ');
|
||||
expect(mockClient.validateToken).toHaveBeenCalledWith(1, 'passed-token');
|
||||
expect(mockRepo.findSettings).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('falls back to the stored token when none passed', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue({ apiToken: 'stored-token' });
|
||||
mockClient.validateToken.mockResolvedValue(false);
|
||||
const result = await makeService().validateToken(1);
|
||||
expect(mockClient.validateToken).toHaveBeenCalledWith(1, 'stored-token');
|
||||
expect(result).toEqual({ valid: false });
|
||||
});
|
||||
|
||||
it('returns {valid:false} when neither passed nor stored token exists', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(undefined);
|
||||
const result = await makeService().validateToken(1);
|
||||
expect(result).toEqual({ valid: false });
|
||||
expect(mockClient.validateToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { ReadwiseSettings, ReadwiseSyncDisabledReason, ReadwiseTokenValidationResult, UpsertReadwiseSettingsPayload } from '@bookorbit/types';
|
||||
|
||||
import type { ReadwiseUserSetting } from '../../db/schema';
|
||||
import { ReadwiseAutoSyncSchedulerService } from './readwise-auto-sync-scheduler.service';
|
||||
import { ReadwiseClientService } from './readwise-client.service';
|
||||
import { ReadwiseRepository } from './readwise.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ReadwiseSettingsService {
|
||||
constructor(
|
||||
private readonly repo: ReadwiseRepository,
|
||||
private readonly client: ReadwiseClientService,
|
||||
private readonly scheduler: ReadwiseAutoSyncSchedulerService,
|
||||
) {}
|
||||
|
||||
async getSettings(userId: number): Promise<ReadwiseSettings> {
|
||||
const [row, hasPermission] = await Promise.all([this.repo.findSettings(userId), this.repo.userHasReadwiseSyncPermission(userId)]);
|
||||
const tokenConfigured = Boolean(row?.apiToken);
|
||||
const enabled = row?.enabled ?? false;
|
||||
|
||||
return {
|
||||
tokenConfigured,
|
||||
enabled,
|
||||
effectiveEnabled: hasPermission && tokenConfigured && enabled,
|
||||
// Prefer a stored auto-disable reason (e.g. bad token) over the derived one,
|
||||
// so the UI can distinguish "we disabled you" from "you turned it off".
|
||||
disabledReason:
|
||||
(row?.disabledReason as ReadwiseSyncDisabledReason | null | undefined) ??
|
||||
this.resolveDisabledReason({ hasPermission, tokenConfigured, enabled }),
|
||||
lastSyncedAt: row?.lastSyncedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async upsertSettings(userId: number, payload: UpsertReadwiseSettingsPayload): Promise<ReadwiseSettings> {
|
||||
const existing = await this.repo.findSettings(userId);
|
||||
const rawToken = payload.apiToken !== undefined ? payload.apiToken.trim() : undefined;
|
||||
if (!existing?.apiToken && !rawToken) {
|
||||
throw new BadRequestException('API token is required to connect Readwise');
|
||||
}
|
||||
const data: Parameters<ReadwiseRepository['upsertSettings']>[1] = {};
|
||||
if (rawToken) {
|
||||
data.apiToken = rawToken;
|
||||
data.disabledReason = null; // clear auto-disable reason on new token
|
||||
}
|
||||
if (payload.enabled !== undefined) {
|
||||
data.enabled = payload.enabled;
|
||||
if (payload.enabled) data.disabledReason = null; // user re-enabling clears stored reason
|
||||
}
|
||||
if (this.shouldSeedWatermark(existing, rawToken, payload)) {
|
||||
data.lastSyncedAnnotationId = await this.repo.findLatestAnnotationId(userId);
|
||||
}
|
||||
await this.repo.upsertSettings(userId, data);
|
||||
const settings = await this.getSettings(userId);
|
||||
if (settings.effectiveEnabled) this.scheduler.requestSync(userId);
|
||||
return settings;
|
||||
}
|
||||
|
||||
async validateToken(userId: number, token?: string): Promise<ReadwiseTokenValidationResult> {
|
||||
const effective = token?.trim() || (await this.repo.findSettings(userId))?.apiToken;
|
||||
if (!effective) return { valid: false };
|
||||
return { valid: await this.client.validateToken(userId, effective) };
|
||||
}
|
||||
|
||||
private shouldSeedWatermark(
|
||||
existing: ReadwiseUserSetting | undefined,
|
||||
rawToken: string | undefined,
|
||||
payload: UpsertReadwiseSettingsPayload,
|
||||
): boolean {
|
||||
const nextTokenConfigured = Boolean(rawToken || existing?.apiToken);
|
||||
const nextEnabled = payload.enabled ?? existing?.enabled ?? true;
|
||||
if (!nextTokenConfigured || !nextEnabled) return false;
|
||||
if (!existing) return true;
|
||||
if (!existing.apiToken) return true;
|
||||
if (!existing.enabled && payload.enabled === true && existing.disabledReason !== 'invalid_token') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private resolveDisabledReason(input: { hasPermission: boolean; tokenConfigured: boolean; enabled: boolean }): ReadwiseSyncDisabledReason | null {
|
||||
if (!input.hasPermission) return 'permission_denied';
|
||||
if (!input.tokenConfigured) return 'missing_token';
|
||||
if (!input.enabled) return 'user_disabled';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { ReadwiseUnauthorizedError } from './readwise-client.service';
|
||||
import { READWISE_BATCH_SIZE, READWISE_MAX } from './readwise.constants';
|
||||
import { ReadwiseSyncService } from './readwise-sync.service';
|
||||
import type { NewHighlightRow } from './readwise.repository';
|
||||
|
||||
const mockRepo = {
|
||||
findSettings: vi.fn(),
|
||||
userHasReadwiseSyncPermission: vi.fn(),
|
||||
findNewHighlights: vi.fn(),
|
||||
upsertSettings: vi.fn(),
|
||||
};
|
||||
|
||||
const mockClient = {
|
||||
createHighlights: vi.fn(),
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
return new ReadwiseSyncService(mockRepo as any, mockClient as any, { appUrl: 'https://bookorbit.example/' } as any);
|
||||
}
|
||||
|
||||
function makeRow(overrides: Partial<NewHighlightRow> = {}): NewHighlightRow {
|
||||
return {
|
||||
annotationId: 1,
|
||||
bookId: 10,
|
||||
text: 'a highlight',
|
||||
note: 'a note',
|
||||
createdAt: new Date('2026-01-02T03:04:05.000Z'),
|
||||
title: 'A Book',
|
||||
author: 'An Author',
|
||||
isbn13: null,
|
||||
isbn10: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function enabledSettings(overrides: Record<string, unknown> = {}) {
|
||||
return { apiToken: 'tok', enabled: true, lastSyncedAnnotationId: 0, ...overrides };
|
||||
}
|
||||
|
||||
describe('ReadwiseSyncService', () => {
|
||||
beforeEach(() => {
|
||||
// resetAllMocks (not clearAllMocks) so any unconsumed mock*Once queue is dropped between tests.
|
||||
vi.resetAllMocks();
|
||||
mockRepo.userHasReadwiseSyncPermission.mockResolvedValue(true);
|
||||
mockClient.createHighlights.mockResolvedValue(undefined);
|
||||
mockRepo.upsertSettings.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('flush guards', () => {
|
||||
it('skips when no settings row', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(undefined);
|
||||
await makeService().flush(1);
|
||||
expect(mockClient.createHighlights).not.toHaveBeenCalled();
|
||||
expect(mockRepo.findNewHighlights).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips when token missing', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings({ apiToken: null }));
|
||||
await makeService().flush(1);
|
||||
expect(mockClient.createHighlights).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips when enabled=false', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings({ enabled: false }));
|
||||
await makeService().flush(1);
|
||||
expect(mockClient.createHighlights).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips when no permission', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings());
|
||||
mockRepo.userHasReadwiseSyncPermission.mockResolvedValue(false);
|
||||
await makeService().flush(1);
|
||||
expect(mockClient.createHighlights).not.toHaveBeenCalled();
|
||||
expect(mockRepo.findNewHighlights).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlight mapping', () => {
|
||||
it('maps a row to a ReadwiseHighlight with fixed source_type/category and ISO highlighted_at', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings());
|
||||
mockRepo.findNewHighlights.mockResolvedValueOnce([makeRow({ annotationId: 5 })]).mockResolvedValueOnce([]);
|
||||
await makeService().flush(1);
|
||||
const [, , payload] = mockClient.createHighlights.mock.calls[0];
|
||||
expect(payload[0]).toEqual({
|
||||
text: 'a highlight',
|
||||
title: 'A Book',
|
||||
author: 'An Author',
|
||||
note: 'a note',
|
||||
highlighted_at: '2026-01-02T03:04:05.000Z',
|
||||
highlight_url: 'https://bookorbit.example/book/10?tab=highlights&annotationId=5',
|
||||
source_type: 'bookorbit',
|
||||
category: 'books',
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates text/title/author/note to their max lengths', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings());
|
||||
const row = makeRow({
|
||||
annotationId: 5,
|
||||
text: 'x'.repeat(READWISE_MAX.TEXT + 100),
|
||||
title: 't'.repeat(READWISE_MAX.TITLE + 100),
|
||||
author: 'a'.repeat(READWISE_MAX.AUTHOR + 100),
|
||||
note: 'n'.repeat(READWISE_MAX.NOTE + 100),
|
||||
});
|
||||
mockRepo.findNewHighlights.mockResolvedValueOnce([row]).mockResolvedValueOnce([]);
|
||||
await makeService().flush(1);
|
||||
const h = mockClient.createHighlights.mock.calls[0][2][0];
|
||||
expect(h.text.length).toBe(READWISE_MAX.TEXT);
|
||||
expect(h.title.length).toBe(READWISE_MAX.TITLE);
|
||||
expect(h.author.length).toBe(READWISE_MAX.AUTHOR);
|
||||
expect(h.note.length).toBe(READWISE_MAX.NOTE);
|
||||
});
|
||||
|
||||
it('omits note when null and omits title/author when empty', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings());
|
||||
mockRepo.findNewHighlights.mockResolvedValueOnce([makeRow({ annotationId: 5, note: null, title: null, author: '' })]).mockResolvedValueOnce([]);
|
||||
await makeService().flush(1);
|
||||
const h = mockClient.createHighlights.mock.calls[0][2][0];
|
||||
expect('note' in h).toBe(false);
|
||||
expect('title' in h).toBe(false);
|
||||
expect('author' in h).toBe(false);
|
||||
});
|
||||
|
||||
it('sets image_url from the OpenLibrary cover for isbn13', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings());
|
||||
mockRepo.findNewHighlights.mockResolvedValueOnce([makeRow({ annotationId: 5, isbn13: '9781234567897' })]).mockResolvedValueOnce([]);
|
||||
await makeService().flush(1);
|
||||
const h = mockClient.createHighlights.mock.calls[0][2][0];
|
||||
expect(h.image_url).toBe('https://covers.openlibrary.org/b/isbn/9781234567897-L.jpg?default=false');
|
||||
});
|
||||
|
||||
it('falls back to isbn10 when isbn13 is absent', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings());
|
||||
mockRepo.findNewHighlights.mockResolvedValueOnce([makeRow({ annotationId: 5, isbn13: null, isbn10: '1234567890' })]).mockResolvedValueOnce([]);
|
||||
await makeService().flush(1);
|
||||
const h = mockClient.createHighlights.mock.calls[0][2][0];
|
||||
expect(h.image_url).toBe('https://covers.openlibrary.org/b/isbn/1234567890-L.jpg?default=false');
|
||||
});
|
||||
|
||||
it('omits image_url when the book has no ISBN', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings());
|
||||
mockRepo.findNewHighlights.mockResolvedValueOnce([makeRow({ annotationId: 5, isbn13: null, isbn10: null })]).mockResolvedValueOnce([]);
|
||||
await makeService().flush(1);
|
||||
const h = mockClient.createHighlights.mock.calls[0][2][0];
|
||||
expect('image_url' in h).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('watermark', () => {
|
||||
it('advances the watermark to the max annotationId of a confirmed batch', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings());
|
||||
mockRepo.findNewHighlights.mockResolvedValueOnce([makeRow({ annotationId: 3 }), makeRow({ annotationId: 7 })]).mockResolvedValueOnce([]);
|
||||
await makeService().flush(1);
|
||||
expect(mockRepo.upsertSettings).toHaveBeenCalledTimes(1);
|
||||
const data = mockRepo.upsertSettings.mock.calls[0][1];
|
||||
expect(data.lastSyncedAnnotationId).toBe(7);
|
||||
expect(data.lastSyncedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('loops again on a full batch, stops on a partial batch', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings());
|
||||
const fullBatch = Array.from({ length: READWISE_BATCH_SIZE }, (_, i) => makeRow({ annotationId: i + 1 }));
|
||||
mockRepo.findNewHighlights.mockResolvedValueOnce(fullBatch).mockResolvedValueOnce([makeRow({ annotationId: READWISE_BATCH_SIZE + 5 })]);
|
||||
await makeService().flush(1);
|
||||
|
||||
expect(mockRepo.findNewHighlights).toHaveBeenCalledTimes(2);
|
||||
// Second fetch uses the watermark advanced from the first full batch.
|
||||
expect(mockRepo.findNewHighlights.mock.calls[1][1]).toBe(READWISE_BATCH_SIZE);
|
||||
expect(mockClient.createHighlights).toHaveBeenCalledTimes(2);
|
||||
expect(mockRepo.upsertSettings).toHaveBeenCalledTimes(2);
|
||||
expect(mockRepo.upsertSettings.mock.calls[1][1].lastSyncedAnnotationId).toBe(READWISE_BATCH_SIZE + 5);
|
||||
});
|
||||
|
||||
it('stops when the first fetch returns an empty batch (nothing to sync)', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings());
|
||||
mockRepo.findNewHighlights.mockResolvedValueOnce([]);
|
||||
await makeService().flush(1);
|
||||
expect(mockClient.createHighlights).not.toHaveBeenCalled();
|
||||
expect(mockRepo.upsertSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('failure handling', () => {
|
||||
it('does not advance the watermark and propagates a generic push error for retry', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings());
|
||||
mockRepo.findNewHighlights.mockResolvedValueOnce([makeRow({ annotationId: 9 })]);
|
||||
mockClient.createHighlights.mockRejectedValueOnce(new Error('boom'));
|
||||
await expect(makeService().flush(1)).rejects.toThrow('boom');
|
||||
expect(mockRepo.upsertSettings).not.toHaveBeenCalled();
|
||||
expect(mockRepo.findNewHighlights).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('auto-disables on ReadwiseUnauthorizedError and does not advance watermark', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue(enabledSettings());
|
||||
mockRepo.findNewHighlights.mockResolvedValueOnce([makeRow({ annotationId: 9 })]);
|
||||
mockClient.createHighlights.mockRejectedValueOnce(new ReadwiseUnauthorizedError());
|
||||
await makeService().flush(1);
|
||||
expect(mockRepo.upsertSettings).toHaveBeenCalledTimes(1);
|
||||
expect(mockRepo.upsertSettings).toHaveBeenCalledWith(1, { disabledReason: 'invalid_token', enabled: false });
|
||||
// no watermark advance
|
||||
const calledWithWatermark = mockRepo.upsertSettings.mock.calls.some((c) => 'lastSyncedAnnotationId' in c[1]);
|
||||
expect(calledWithWatermark).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { appConfig } from '../../config/config';
|
||||
import {
|
||||
bookOrbitReadwiseHighlightUrl,
|
||||
openLibraryCoverUrl,
|
||||
READWISE_BATCH_SIZE,
|
||||
READWISE_CATEGORY,
|
||||
READWISE_MAX,
|
||||
READWISE_SOURCE_TYPE,
|
||||
} from './readwise.constants';
|
||||
import { ReadwiseClientService, ReadwiseUnauthorizedError, type ReadwiseHighlight } from './readwise-client.service';
|
||||
import { ReadwiseRepository, type NewHighlightRow } from './readwise.repository';
|
||||
|
||||
const SYNC_EVENT = 'readwise.sync';
|
||||
|
||||
function truncate(value: string, max: number): string {
|
||||
return value.length > max ? value.slice(0, max) : value;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReadwiseSyncService {
|
||||
private readonly logger = new Logger(ReadwiseSyncService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repo: ReadwiseRepository,
|
||||
private readonly client: ReadwiseClientService,
|
||||
@Inject(appConfig.KEY) private readonly appConfiguration: ConfigType<typeof appConfig>,
|
||||
) {}
|
||||
|
||||
async flush(userId: number): Promise<void> {
|
||||
const startedAtMs = Date.now();
|
||||
const settings = await this.repo.findSettings(userId);
|
||||
if (!settings?.apiToken || !settings.enabled) return;
|
||||
const hasPermission = await this.repo.userHasReadwiseSyncPermission(userId);
|
||||
if (!hasPermission) return;
|
||||
|
||||
let watermark = settings.lastSyncedAnnotationId;
|
||||
let syncedHighlights = 0;
|
||||
let batches = 0;
|
||||
this.logger.log(
|
||||
`[${SYNC_EVENT}] [start] userId=${userId} afterAnnotationId=${watermark} batchSize=${READWISE_BATCH_SIZE} - Readwise sync started`,
|
||||
);
|
||||
|
||||
for (;;) {
|
||||
const rows = await this.repo.findNewHighlights(userId, watermark, READWISE_BATCH_SIZE);
|
||||
if (rows.length === 0) break;
|
||||
const payload = rows.map((r) => this.toHighlight(userId, r));
|
||||
try {
|
||||
await this.client.createHighlights(userId, settings.apiToken, payload);
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startedAtMs;
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
|
||||
if (err instanceof ReadwiseUnauthorizedError) {
|
||||
await this.repo.upsertSettings(userId, { disabledReason: 'invalid_token', enabled: false });
|
||||
this.logger.warn(
|
||||
`[${SYNC_EVENT}] [fail] userId=${userId} durationMs=${durationMs} errorClass=${errorClass} error="${sanitizeLogValue(err.message)}" - Readwise sync disabled after token rejection`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.warn(
|
||||
`[${SYNC_EVENT}] [fail] userId=${userId} durationMs=${durationMs} errorClass=${errorClass} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - Readwise sync push failed`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
watermark = rows[rows.length - 1]!.annotationId;
|
||||
await this.repo.upsertSettings(userId, { lastSyncedAnnotationId: watermark, lastSyncedAt: new Date() });
|
||||
syncedHighlights += rows.length;
|
||||
batches += 1;
|
||||
if (rows.length < READWISE_BATCH_SIZE) break;
|
||||
}
|
||||
this.logger.log(
|
||||
`[${SYNC_EVENT}] [end] userId=${userId} durationMs=${Date.now() - startedAtMs} batches=${batches} syncedHighlights=${syncedHighlights} watermark=${watermark} - Readwise sync completed`,
|
||||
);
|
||||
}
|
||||
|
||||
private toHighlight(userId: number, r: NewHighlightRow): ReadwiseHighlight {
|
||||
const isbn = r.isbn13 || r.isbn10;
|
||||
return {
|
||||
text: truncate(r.text, READWISE_MAX.TEXT),
|
||||
...(r.title ? { title: truncate(r.title, READWISE_MAX.TITLE) } : {}),
|
||||
...(r.author ? { author: truncate(r.author, READWISE_MAX.AUTHOR) } : {}),
|
||||
...(r.note ? { note: truncate(r.note, READWISE_MAX.NOTE) } : {}),
|
||||
...(isbn ? { image_url: openLibraryCoverUrl(isbn) } : {}),
|
||||
highlighted_at: r.createdAt.toISOString(),
|
||||
highlight_url: truncate(bookOrbitReadwiseHighlightUrl(this.appConfiguration.appUrl, r.bookId, r.annotationId), READWISE_MAX.HIGHLIGHT_URL),
|
||||
source_type: READWISE_SOURCE_TYPE,
|
||||
category: READWISE_CATEGORY,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export const READWISE_BASE_URL = 'https://readwise.io/api/v2';
|
||||
export const READWISE_HIGHLIGHTS_URL = `${READWISE_BASE_URL}/highlights/`;
|
||||
export const READWISE_AUTH_URL = `${READWISE_BASE_URL}/auth/`;
|
||||
|
||||
// Rate limit is 240 req/min for highlights CREATE → ~250ms between requests, keep margin.
|
||||
export const READWISE_MIN_INTERVAL_MS = 300;
|
||||
export const READWISE_MAX_RETRIES = 3;
|
||||
export const READWISE_AUTO_SYNC_DEBOUNCE_MS = 1000;
|
||||
export const READWISE_AUTO_SYNC_MAX_WAIT_MS = 5000;
|
||||
export const READWISE_AUTO_SYNC_RETRY_BASE_MS = 30_000;
|
||||
export const READWISE_AUTO_SYNC_MAX_RETRIES = 5;
|
||||
export const READWISE_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
// Readwise field length caps (from readwise.io/api_deets).
|
||||
export const READWISE_MAX = {
|
||||
TEXT: 8191,
|
||||
TITLE: 511,
|
||||
AUTHOR: 1024,
|
||||
NOTE: 8191,
|
||||
HIGHLIGHT_URL: 4095,
|
||||
} as const;
|
||||
|
||||
export const READWISE_SOURCE_TYPE = 'bookorbit';
|
||||
export const READWISE_CATEGORY = 'books';
|
||||
|
||||
export function bookOrbitReadwiseHighlightUrl(appUrl: string, bookId: number, annotationId: number): string {
|
||||
return `${appUrl.replace(/\/+$/, '')}/book/${bookId}?tab=highlights&annotationId=${annotationId}`;
|
||||
}
|
||||
|
||||
// Public cover URL by ISBN. `?default=false` makes OpenLibrary return 404 for a
|
||||
// missing cover (Readwise then stores no image) instead of a blank placeholder pixel.
|
||||
export const OPENLIBRARY_COVER_BASE = 'https://covers.openlibrary.org/b/isbn';
|
||||
export function openLibraryCoverUrl(isbn: string): string {
|
||||
return `${OPENLIBRARY_COVER_BASE}/${isbn}-L.jpg?default=false`;
|
||||
}
|
||||
|
||||
// How many highlights to send per POST (Readwise accepts large batches; keep bounded).
|
||||
export const READWISE_BATCH_SIZE = 100;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { ReadwiseController } from './readwise.controller';
|
||||
|
||||
const mockSettingsService = {
|
||||
getSettings: vi.fn(),
|
||||
upsertSettings: vi.fn(),
|
||||
validateToken: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUser = { id: 7, isSuperuser: false, permissions: [] };
|
||||
|
||||
function makeController() {
|
||||
return new ReadwiseController(mockSettingsService as any);
|
||||
}
|
||||
|
||||
describe('ReadwiseController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('getSettings delegates to service', async () => {
|
||||
mockSettingsService.getSettings.mockResolvedValue({ tokenConfigured: false });
|
||||
const result = await makeController().getSettings(mockUser as any);
|
||||
expect(result).toEqual({ tokenConfigured: false });
|
||||
expect(mockSettingsService.getSettings).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('upsertSettings delegates to service', async () => {
|
||||
mockSettingsService.upsertSettings.mockResolvedValue({ tokenConfigured: true });
|
||||
const dto = { apiToken: 'tok', enabled: true };
|
||||
const result = await makeController().upsertSettings(mockUser as any, dto);
|
||||
expect(result).toEqual({ tokenConfigured: true });
|
||||
expect(mockSettingsService.upsertSettings).toHaveBeenCalledWith(7, dto);
|
||||
});
|
||||
|
||||
it('validateToken delegates to service', async () => {
|
||||
mockSettingsService.validateToken.mockResolvedValue({ valid: true });
|
||||
const result = await makeController().validateToken(mockUser as any, { token: 'tok' });
|
||||
expect(result).toEqual({ valid: true });
|
||||
expect(mockSettingsService.validateToken).toHaveBeenCalledWith(7, 'tok');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { Body, Controller, Get, Patch, Post } from '@nestjs/common';
|
||||
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { UpsertReadwiseSettingsDto, ValidateReadwiseTokenDto } from './dto';
|
||||
import { ReadwiseSettingsService } from './readwise-settings.service';
|
||||
|
||||
@Controller('readwise')
|
||||
@RequirePermission(Permission.ReadwiseSync)
|
||||
export class ReadwiseController {
|
||||
constructor(private readonly settingsService: ReadwiseSettingsService) {}
|
||||
|
||||
@Get('settings')
|
||||
getSettings(@CurrentUser() user: RequestUser) {
|
||||
return this.settingsService.getSettings(user.id);
|
||||
}
|
||||
|
||||
@Patch('settings')
|
||||
upsertSettings(@CurrentUser() user: RequestUser, @Body() dto: UpsertReadwiseSettingsDto) {
|
||||
return this.settingsService.upsertSettings(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('validate-token')
|
||||
validateToken(@CurrentUser() user: RequestUser, @Body() dto: ValidateReadwiseTokenDto) {
|
||||
return this.settingsService.validateToken(user.id, dto.token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AchievementModule } from '../achievement/achievement.module';
|
||||
import { ReadwiseController } from './readwise.controller';
|
||||
import { ReadwiseAutoSyncSchedulerService } from './readwise-auto-sync-scheduler.service';
|
||||
import { ReadwiseClientService } from './readwise-client.service';
|
||||
import { ReadwiseEventListener } from './readwise-event-listener.service';
|
||||
import { ReadwiseQueueService } from './readwise-queue.service';
|
||||
import { ReadwiseRepository } from './readwise.repository';
|
||||
import { ReadwiseSettingsService } from './readwise-settings.service';
|
||||
import { ReadwiseSyncService } from './readwise-sync.service';
|
||||
|
||||
@Module({
|
||||
imports: [AchievementModule],
|
||||
controllers: [ReadwiseController],
|
||||
providers: [
|
||||
ReadwiseQueueService,
|
||||
ReadwiseClientService,
|
||||
ReadwiseRepository,
|
||||
ReadwiseSettingsService,
|
||||
ReadwiseSyncService,
|
||||
ReadwiseAutoSyncSchedulerService,
|
||||
ReadwiseEventListener,
|
||||
],
|
||||
exports: [ReadwiseSettingsService, ReadwiseSyncService],
|
||||
})
|
||||
export class ReadwiseModule {}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
|
||||
import { ReadwiseRepository } from './readwise.repository';
|
||||
|
||||
function makeReturningChain(row: unknown) {
|
||||
const chain = {
|
||||
values: vi.fn(),
|
||||
onConflictDoUpdate: vi.fn(),
|
||||
returning: vi.fn(),
|
||||
};
|
||||
chain.values.mockReturnValue(chain);
|
||||
chain.onConflictDoUpdate.mockReturnValue(chain);
|
||||
chain.returning.mockResolvedValue([row]);
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeSelectChain(rows: unknown[]) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const method of ['from', 'leftJoin', 'where', 'groupBy', 'orderBy']) {
|
||||
chain[method] = vi.fn().mockReturnValue(chain);
|
||||
}
|
||||
chain.limit = vi.fn().mockResolvedValue(rows);
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeRepository() {
|
||||
const settingsRow = {
|
||||
id: 1,
|
||||
userId: 7,
|
||||
apiToken: 'tok',
|
||||
enabled: true,
|
||||
lastSyncedAnnotationId: 0,
|
||||
disabledReason: null,
|
||||
lastSyncedAt: null,
|
||||
};
|
||||
|
||||
const settingsQuery = { findFirst: vi.fn().mockResolvedValue(settingsRow) };
|
||||
const settingsInsert = makeReturningChain(settingsRow);
|
||||
|
||||
const permissionLimit = vi.fn().mockResolvedValue([{ isSuperuser: false, permissionName: Permission.ReadwiseSync }]);
|
||||
const permissionWhere = vi.fn().mockReturnValue({ limit: permissionLimit });
|
||||
const permissionLeftJoin = vi.fn().mockReturnValue({ where: permissionWhere });
|
||||
const permissionFrom = vi.fn().mockReturnValue({ leftJoin: permissionLeftJoin });
|
||||
|
||||
const db = {
|
||||
query: {
|
||||
readwiseUserSettings: settingsQuery,
|
||||
},
|
||||
insert: vi.fn().mockReturnValue(settingsInsert),
|
||||
select: vi.fn().mockReturnValue({ from: permissionFrom }),
|
||||
};
|
||||
|
||||
return {
|
||||
repo: new ReadwiseRepository(db as never),
|
||||
db,
|
||||
settingsQuery,
|
||||
settingsInsert,
|
||||
permissionFrom,
|
||||
permissionLimit,
|
||||
settingsRow,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ReadwiseRepository', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('findSettings returns the user settings row', async () => {
|
||||
const { repo, settingsQuery, settingsRow } = makeRepository();
|
||||
|
||||
await expect(repo.findSettings(7)).resolves.toEqual(settingsRow);
|
||||
expect(settingsQuery.findFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('upsertSettings inserts or updates settings for a user', async () => {
|
||||
const { repo, db, settingsInsert, settingsRow } = makeRepository();
|
||||
db.insert.mockReset();
|
||||
db.insert.mockReturnValue(settingsInsert);
|
||||
|
||||
await expect(repo.upsertSettings(7, { apiToken: 'tok' })).resolves.toEqual(settingsRow);
|
||||
expect(settingsInsert.values).toHaveBeenCalledWith({ userId: 7, apiToken: 'tok' });
|
||||
expect(settingsInsert.onConflictDoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: expect.anything(),
|
||||
set: expect.objectContaining({ apiToken: 'tok', updatedAt: expect.any(Date) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('findLatestAnnotationId returns the max user annotation id', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
db.select.mockReturnValueOnce(makeSelectChain([{ latestAnnotationId: 99 }]));
|
||||
|
||||
await expect(repo.findLatestAnnotationId(7)).resolves.toBe(99);
|
||||
});
|
||||
|
||||
it('userHasReadwiseSyncPermission returns true for a superuser', async () => {
|
||||
const { repo, db, permissionFrom, permissionLimit } = makeRepository();
|
||||
permissionLimit.mockResolvedValueOnce([{ isSuperuser: true, permissionName: null }]);
|
||||
db.select.mockReturnValueOnce({ from: permissionFrom });
|
||||
|
||||
await expect(repo.userHasReadwiseSyncPermission(7)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('userHasReadwiseSyncPermission returns true for a user with the permission', async () => {
|
||||
const { repo, db, permissionFrom } = makeRepository();
|
||||
db.select.mockReturnValueOnce({ from: permissionFrom });
|
||||
|
||||
await expect(repo.userHasReadwiseSyncPermission(7)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('userHasReadwiseSyncPermission returns false without a matching permission row', async () => {
|
||||
const { repo, db, permissionFrom, permissionLimit } = makeRepository();
|
||||
permissionLimit.mockResolvedValueOnce([{ isSuperuser: false, permissionName: null }]);
|
||||
db.select.mockReturnValueOnce({ from: permissionFrom });
|
||||
|
||||
await expect(repo.userHasReadwiseSyncPermission(7)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('findNewHighlights maps rows and joins the authors csv into a single author string', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
const createdAt = new Date('2026-01-01T00:00:00Z');
|
||||
const rows = [
|
||||
{
|
||||
annotationId: 10,
|
||||
bookId: 42,
|
||||
text: 'A highlight',
|
||||
note: 'my note',
|
||||
createdAt,
|
||||
title: 'Dune',
|
||||
isbn13: '9780441172719',
|
||||
isbn10: '0441172717',
|
||||
authorsCsv: 'Frank Herbert||Brian Herbert',
|
||||
},
|
||||
{
|
||||
annotationId: 11,
|
||||
bookId: 43,
|
||||
text: 'Another highlight',
|
||||
note: null,
|
||||
createdAt,
|
||||
title: null,
|
||||
isbn13: null,
|
||||
isbn10: null,
|
||||
authorsCsv: '',
|
||||
},
|
||||
];
|
||||
db.select.mockReturnValueOnce(makeSelectChain(rows));
|
||||
|
||||
await expect(repo.findNewHighlights(7, 0, 100)).resolves.toEqual([
|
||||
{
|
||||
annotationId: 10,
|
||||
bookId: 42,
|
||||
text: 'A highlight',
|
||||
note: 'my note',
|
||||
createdAt,
|
||||
title: 'Dune',
|
||||
isbn13: '9780441172719',
|
||||
isbn10: '0441172717',
|
||||
author: 'Frank Herbert, Brian Herbert',
|
||||
},
|
||||
{
|
||||
annotationId: 11,
|
||||
bookId: 43,
|
||||
text: 'Another highlight',
|
||||
note: null,
|
||||
createdAt,
|
||||
title: null,
|
||||
isbn13: null,
|
||||
isbn10: null,
|
||||
author: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { and, asc, eq, gt, isNull, ne, sql } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import type { NewReadwiseUserSetting, ReadwiseUserSetting } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
export interface NewHighlightRow {
|
||||
annotationId: number;
|
||||
bookId: number;
|
||||
text: string;
|
||||
note: string | null;
|
||||
createdAt: Date;
|
||||
title: string | null;
|
||||
author: string; // joined, may be ''
|
||||
isbn13: string | null;
|
||||
isbn10: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReadwiseRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async findSettings(userId: number): Promise<ReadwiseUserSetting | undefined> {
|
||||
return this.db.query.readwiseUserSettings.findFirst({
|
||||
where: eq(schema.readwiseUserSettings.userId, userId),
|
||||
});
|
||||
}
|
||||
|
||||
async findLatestAnnotationId(userId: number): Promise<number> {
|
||||
const [row] = await this.db
|
||||
.select({ latestAnnotationId: sql<number>`coalesce(max(${schema.annotations.id}), 0)::int` })
|
||||
.from(schema.annotations)
|
||||
.where(eq(schema.annotations.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return row?.latestAnnotationId ?? 0;
|
||||
}
|
||||
|
||||
async upsertSettings(
|
||||
userId: number,
|
||||
data: Partial<Omit<ReadwiseUserSetting, 'id' | 'userId' | 'createdAt' | 'updatedAt'>>,
|
||||
): Promise<ReadwiseUserSetting> {
|
||||
const [row] = await this.db
|
||||
.insert(schema.readwiseUserSettings)
|
||||
.values({ userId, ...data } as NewReadwiseUserSetting)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.readwiseUserSettings.userId,
|
||||
set: { ...data, updatedAt: new Date() },
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
async userHasReadwiseSyncPermission(userId: number): Promise<boolean> {
|
||||
const [row] = await this.db
|
||||
.select({
|
||||
isSuperuser: schema.users.isSuperuser,
|
||||
permissionName: schema.userPermissions.permissionName,
|
||||
})
|
||||
.from(schema.users)
|
||||
.leftJoin(
|
||||
schema.userPermissions,
|
||||
and(eq(schema.userPermissions.userId, schema.users.id), eq(schema.userPermissions.permissionName, Permission.ReadwiseSync)),
|
||||
)
|
||||
.where(and(eq(schema.users.id, userId), eq(schema.users.active, true)))
|
||||
.limit(1);
|
||||
return row?.isSuperuser === true || row?.permissionName === Permission.ReadwiseSync;
|
||||
}
|
||||
|
||||
async findNewHighlights(userId: number, afterId: number, limit: number): Promise<NewHighlightRow[]> {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
annotationId: schema.annotations.id,
|
||||
bookId: schema.annotations.bookId,
|
||||
text: schema.annotations.text,
|
||||
note: schema.annotations.note,
|
||||
createdAt: schema.annotations.createdAt,
|
||||
title: schema.bookMetadata.title,
|
||||
isbn13: schema.bookMetadata.isbn13,
|
||||
isbn10: schema.bookMetadata.isbn10,
|
||||
authorsCsv: sql<string>`coalesce(string_agg(${schema.authors.name}, '||' order by ${schema.bookAuthors.displayOrder}, ${schema.bookAuthors.authorId}), '')`,
|
||||
})
|
||||
.from(schema.annotations)
|
||||
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.annotations.bookId))
|
||||
.leftJoin(schema.bookAuthors, eq(schema.bookAuthors.bookId, schema.annotations.bookId))
|
||||
.leftJoin(schema.authors, eq(schema.authors.id, schema.bookAuthors.authorId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.annotations.userId, userId),
|
||||
gt(schema.annotations.id, afterId),
|
||||
ne(schema.annotations.text, ''),
|
||||
isNull(schema.annotations.deletedAt),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
schema.annotations.id,
|
||||
schema.annotations.bookId,
|
||||
schema.annotations.text,
|
||||
schema.annotations.note,
|
||||
schema.annotations.createdAt,
|
||||
schema.bookMetadata.title,
|
||||
schema.bookMetadata.isbn13,
|
||||
schema.bookMetadata.isbn10,
|
||||
)
|
||||
.orderBy(asc(schema.annotations.id))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((r) => ({
|
||||
annotationId: r.annotationId,
|
||||
bookId: r.bookId,
|
||||
text: r.text,
|
||||
note: r.note,
|
||||
createdAt: r.createdAt,
|
||||
title: r.title,
|
||||
isbn13: r.isbn13,
|
||||
isbn10: r.isbn10,
|
||||
author: r.authorsCsv
|
||||
? r.authorsCsv
|
||||
.split('||')
|
||||
.filter((a) => a.length > 0)
|
||||
.join(', ')
|
||||
: '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -426,6 +426,11 @@ describe('Authorization matrix (e2e)', () => {
|
||||
path: '/hardcover/settings',
|
||||
token: 'allPerms',
|
||||
},
|
||||
[Permission.ReadwiseSync]: {
|
||||
method: 'GET',
|
||||
path: '/readwise/settings',
|
||||
token: 'allPerms',
|
||||
},
|
||||
[Permission.DemoRestricted]: {
|
||||
method: 'GET',
|
||||
path: '/notifications',
|
||||
|
||||
Reference in New Issue
Block a user