feat: hardcover sync for per user reading status progress and rating (#53)

* feat(server): sync reading progress with Hardcover

* fix(server): update Hardcover sync pending state

* test(server): cover Hardcover admin and repository
This commit is contained in:
Chris Mansell
2026-05-19 10:49:15 -06:00
committed by GitHub
parent 402d1cde5d
commit 418314352e
50 changed files with 14625 additions and 7 deletions
@@ -0,0 +1,114 @@
import { api } from '@/lib/api'
import type {
HardcoverAdminSettings,
HardcoverActiveSyncStatus,
HardcoverSyncPendingSummary,
HardcoverSettings,
HardcoverTokenValidationResult,
UpsertHardcoverSettingsPayload,
} from '@bookorbit/types'
const BASE = '/api/v1/hardcover'
export async function fetchHardcoverSettings(): Promise<HardcoverSettings> {
const res = await api(`${BASE}/settings`)
if (!res.ok) throw new Error('Failed to fetch Hardcover settings')
return res.json()
}
export async function upsertHardcoverSettings(payload: UpsertHardcoverSettingsPayload): Promise<HardcoverSettings> {
const res = await api(`${BASE}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as { message?: string }).message ?? 'Failed to save settings')
}
return res.json()
}
export async function disconnectHardcover(): Promise<void> {
const res = await api(`${BASE}/settings`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to disconnect Hardcover')
}
export async function validateHardcoverToken(token?: string): Promise<HardcoverTokenValidationResult> {
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('Failed to validate token')
return res.json()
}
export async function startHardcoverSync(): Promise<{ runId: number }> {
const res = await api(`${BASE}/sync`, { method: 'POST' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as { message?: string }).message ?? 'Failed to start sync')
}
return res.json()
}
export async function cancelHardcoverSync(): Promise<void> {
await api(`${BASE}/sync`, { method: 'DELETE' })
}
export async function fetchHardcoverSyncStatus(): Promise<HardcoverActiveSyncStatus | null> {
const res = await api(`${BASE}/sync/status`)
if (!res.ok) return null
return res.json()
}
export async function streamHardcoverSyncStatus(onStatus: (status: HardcoverActiveSyncStatus | null) => void, signal?: AbortSignal): Promise<void> {
const res = await api(`${BASE}/sync/stream`, { signal })
if (!res.ok || !res.body) throw new Error('Failed to stream Hardcover sync status')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop() ?? ''
for (const event of events) {
const line = event.split('\n').find((entry) => entry.startsWith('data:'))
if (!line) continue
try {
const payload = JSON.parse(line.slice(5).trim()) as { activeSyncStatus: HardcoverActiveSyncStatus | null }
onStatus(payload.activeSyncStatus)
} catch {
// Ignore malformed SSE payloads.
}
}
}
}
export async function fetchHardcoverSyncPendingSummary(): Promise<HardcoverSyncPendingSummary> {
const res = await api(`${BASE}/sync/pending`)
if (!res.ok) return { totalBooks: 0, pendingBooks: 0 }
return res.json()
}
export async function fetchHardcoverAdminSettings(): Promise<HardcoverAdminSettings> {
const res = await api('/api/v1/admin/hardcover/settings')
if (!res.ok) throw new Error('Failed to fetch admin settings')
return res.json()
}
export async function setHardcoverFeatureEnabled(enabled: boolean): Promise<void> {
const res = await api('/api/v1/admin/hardcover/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (!res.ok) throw new Error('Failed to update Hardcover feature flag')
}
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { Link, Save, CheckCircle2, AlertCircle, Info, Loader2, Unlink } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { useHardcoverSettings } from '../composables/useHardcoverSettings'
const { settings, saving, validating, error, fetchSettings, saveSettings, disconnect, validateToken } = useHardcoverSettings()
const tokenInput = ref('')
const tokenVisible = ref(false)
const validationResult = ref<{ valid: boolean; username?: string } | null>(null)
const form = reactive({
enabled: true,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: 3,
})
onMounted(async () => {
await fetchSettings()
if (settings.value) {
form.enabled = settings.value.enabled
form.autoSyncOnStatusChange = settings.value.autoSyncOnStatusChange
form.autoSyncOnProgressUpdate = settings.value.autoSyncOnProgressUpdate
form.autoSyncOnRatingChange = settings.value.autoSyncOnRatingChange
form.privacySettingId = settings.value.privacySettingId
}
})
async function handleValidateToken() {
if (!tokenInput.value.trim()) {
toast.error('Enter your Hardcover API token first')
return
}
const result = await validateToken(tokenInput.value.trim())
validationResult.value = result
}
async function handleSave() {
const ok = await saveSettings({
...(tokenInput.value.trim() ? { apiToken: tokenInput.value.trim() } : {}),
enabled: form.enabled,
autoSyncOnStatusChange: form.autoSyncOnStatusChange,
autoSyncOnProgressUpdate: form.autoSyncOnProgressUpdate,
autoSyncOnRatingChange: form.autoSyncOnRatingChange,
privacySettingId: form.privacySettingId,
})
if (ok) {
tokenInput.value = ''
toast.success('Hardcover settings saved')
} else {
toast.error(error.value ?? 'Failed to save settings')
}
}
async function handleDisconnect() {
await disconnect()
tokenInput.value = ''
validationResult.value = null
toast.success('Hardcover disconnected')
}
function toggleTokenVisible() {
tokenVisible.value = !tokenVisible.value
}
const privacyOptions = [
{ id: 1, label: 'Public' },
{ id: 2, label: 'Followers only' },
{ id: 3, label: 'Private' },
]
</script>
<template>
<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 Hardcover account to sync reading data.</p>
</div>
<div v-if="settings?.tokenConfigured" class="ml-auto flex items-center gap-1.5 text-xs text-green-600">
<CheckCircle2 class="size-3.5" />
Connected
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider"> API Token </label>
<div class="flex gap-2">
<input
v-model="tokenInput"
:type="tokenVisible ? 'text' : 'password'"
placeholder="Paste your Hardcover API 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://hardcover.app/account/api" target="_blank" rel="noopener" class="text-primary underline underline-offset-2"
>hardcover.app/account/api</a
>.
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
:disabled="validating || !tokenInput.trim()"
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" />
Validate token
</button>
<span
v-if="validationResult !== null"
class="flex items-center gap-1 text-xs"
:class="validationResult.valid ? 'text-green-600' : 'text-destructive'"
>
<CheckCircle2 v-if="validationResult.valid" class="size-3.5" />
<AlertCircle v-else class="size-3.5" />
{{ validationResult.valid ? `Valid (${validationResult.username})` : 'Invalid token' }}
</span>
</div>
<div v-if="settings?.tokenConfigured" class="space-y-3 pt-2 border-t border-border">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Sync options</p>
<div 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">
Sync runs only for books that are not unread. This applies to status, progress, and rating triggers. These switches control when a sync
runs.
</p>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm">Enable sync</p>
<p class="text-xs text-muted-foreground mt-0.5">Pause all Hardcover syncing without disconnecting.</p>
</div>
<ToggleSwitch v-model="form.enabled" />
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm">Sync on status change</p>
<p class="text-xs text-muted-foreground mt-0.5">Push updates when you mark a book as reading, read, etc.</p>
</div>
<ToggleSwitch v-model="form.autoSyncOnStatusChange" :disabled="!form.enabled" />
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm">Sync on progress update</p>
<p class="text-xs text-muted-foreground mt-0.5">Push reading progress and dates when a session is saved.</p>
</div>
<ToggleSwitch v-model="form.autoSyncOnProgressUpdate" :disabled="!form.enabled" />
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm">Sync on rating change</p>
<p class="text-xs text-muted-foreground mt-0.5">Push your star ratings to Hardcover.</p>
</div>
<ToggleSwitch v-model="form.autoSyncOnRatingChange" :disabled="!form.enabled" />
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm">Privacy</p>
<p class="text-xs text-muted-foreground mt-0.5">Default visibility for synced books on Hardcover.</p>
</div>
<select
v-model="form.privacySettingId"
class="rounded-md border border-border bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
:disabled="!form.enabled"
>
<option v-for="opt in privacyOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
</select>
</div>
</div>
<div class="flex items-center justify-between pt-2 border-t border-border gap-2">
<button
v-if="settings?.tokenConfigured"
type="button"
:disabled="saving"
class="flex items-center gap-1.5 text-xs text-destructive hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
@click="handleDisconnect"
>
<Unlink class="size-3.5" />
Disconnect
</button>
<div class="flex-1" />
<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>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
import HardcoverConnectionCard from '../components/HardcoverConnectionCard.vue'
import HardcoverSyncProgress from '../components/HardcoverSyncProgress.vue'
import { useHardcoverSettings } from '../composables/useHardcoverSettings'
import { useHardcoverSync } from '../composables/useHardcoverSync'
const { settings } = useHardcoverSettings()
const { fetchStatus, stopSyncTracking } = useHardcoverSync()
onMounted(async () => {
await fetchStatus()
})
onUnmounted(() => {
stopSyncTracking()
})
</script>
<template>
<div class="space-y-6">
<SettingsPageHeader title="Hardcover" subtitle="Sync your reading progress, status, and ratings to Hardcover." />
<HardcoverConnectionCard />
<HardcoverSyncProgress v-if="settings?.tokenConfigured" />
</div>
</template>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RefreshCw, XCircle, Loader2 } from 'lucide-vue-next'
import { useHardcoverSync } from '../composables/useHardcoverSync'
import { useHardcoverSettings } from '../composables/useHardcoverSettings'
const { activeSyncStatus, syncing, isSyncing, syncProgress, pendingSummary, loadingPending, startSync, cancelSync } = useHardcoverSync()
const { settings } = useHardcoverSettings()
const progressLabel = computed(() => {
const s = activeSyncStatus.value
if (!s) return ''
return `${s.syncedBooks} / ${s.totalBooks} books`
})
const hasPending = computed(() => pendingSummary.value.pendingBooks > 0)
const syncButtonDisabled = computed(() => syncing.value || loadingPending.value || !hasPending.value)
const syncButtonLabel = computed(() => (hasPending.value ? `Sync now (${pendingSummary.value.pendingBooks})` : 'Sync now'))
const lastSyncedLabel = computed(() => {
const iso = settings.value?.lastSyncedAt
if (!iso) return null
const diff = Date.now() - new Date(iso).getTime()
const minutes = Math.floor(diff / 60_000)
const hours = Math.floor(diff / 3_600_000)
const days = Math.floor(diff / 86_400_000)
if (minutes < 1) return 'Just now'
if (minutes < 60) return `${minutes} min ago`
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
return `${days} day${days === 1 ? '' : 's'} ago`
})
</script>
<template>
<div class="border border-border rounded-lg bg-card px-4 py-4 md:px-5 md:py-5 shadow-xs space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="font-medium text-sm">Manual sync</p>
<p class="text-xs text-muted-foreground mt-0.5">Push all your reading data to Hardcover now.</p>
<p v-if="!isSyncing" class="text-xs text-muted-foreground mt-1">
<template v-if="loadingPending">Checking pending items...</template>
<template v-else-if="hasPending">{{ pendingSummary.pendingBooks }} pending of {{ pendingSummary.totalBooks }} books.</template>
<template v-else-if="pendingSummary.totalBooks === 0">No eligible books to sync.</template>
<template v-else>All books are already synced.</template>
</p>
<p v-if="lastSyncedLabel && !isSyncing" class="text-xs text-muted-foreground mt-0.5">Last synced: {{ lastSyncedLabel }}</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="!isSyncing"
type="button"
:disabled="syncButtonDisabled"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@click="startSync"
>
<Loader2 v-if="syncing" class="size-3.5 animate-spin" />
<RefreshCw v-else class="size-3.5" />
{{ syncButtonLabel }}
</button>
<button
v-else
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-border bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
@click="cancelSync"
>
<XCircle class="size-3.5" />
Cancel
</button>
</div>
</div>
<div v-if="isSyncing" class="space-y-1.5">
<div class="flex justify-between text-xs text-muted-foreground">
<span>Syncing...</span>
<span>{{ progressLabel }}</span>
</div>
<div class="h-1.5 rounded-full bg-muted overflow-hidden">
<div class="h-full rounded-full bg-primary transition-all duration-300" :style="{ width: `${syncProgress}%` }" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HardcoverSettings, HardcoverTokenValidationResult, UpsertHardcoverSettingsPayload } from '@bookorbit/types'
vi.mock('../../api/hardcover.api', () => ({
fetchHardcoverSettings: vi.fn<() => Promise<HardcoverSettings>>(),
upsertHardcoverSettings: vi.fn<(payload: UpsertHardcoverSettingsPayload) => Promise<HardcoverSettings>>(),
disconnectHardcover: vi.fn<() => Promise<void>>(),
validateHardcoverToken: vi.fn<(token?: string) => Promise<HardcoverTokenValidationResult>>(),
}))
import { disconnectHardcover, fetchHardcoverSettings, upsertHardcoverSettings, validateHardcoverToken } from '../../api/hardcover.api'
const mockFetchSettings = vi.mocked(fetchHardcoverSettings)
const mockUpsert = vi.mocked(upsertHardcoverSettings)
const mockDisconnect = vi.mocked(disconnectHardcover)
const mockValidate = vi.mocked(validateHardcoverToken)
const SETTINGS: HardcoverSettings = {
tokenConfigured: true,
enabled: true,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: 3,
lastSyncedAt: null,
}
async function loadComposable() {
const { useHardcoverSettings } = await import('../useHardcoverSettings')
return useHardcoverSettings()
}
describe('useHardcoverSettings', () => {
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 on success', async () => {
const payload: UpsertHardcoverSettingsPayload = {
apiToken: 'tok',
enabled: true,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: 3,
}
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)
})
it('saveSettings 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')
})
it('disconnect clears settings', async () => {
mockDisconnect.mockResolvedValue(undefined)
mockFetchSettings.mockResolvedValue(SETTINGS)
const c = await loadComposable()
await c.fetchSettings()
await c.disconnect()
expect(c.settings.value).toBeNull()
})
it('validateToken returns valid true on success', async () => {
const result: HardcoverTokenValidationResult = { valid: true, hardcoverUsername: 'alice' }
mockValidate.mockResolvedValue(result)
const c = await loadComposable()
const r = await c.validateToken()
expect(r.valid).toBe(true)
expect(r.username).toBe('alice')
})
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)
})
})
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HardcoverActiveSyncStatus, HardcoverSettings, HardcoverSyncPendingSummary } from '@bookorbit/types'
vi.mock('../../api/hardcover.api', () => ({
startHardcoverSync: vi.fn<() => Promise<{ runId: number }>>(),
cancelHardcoverSync: vi.fn<() => Promise<void>>(),
fetchHardcoverSyncStatus: vi.fn<() => Promise<HardcoverActiveSyncStatus | null>>(),
fetchHardcoverSyncPendingSummary: vi.fn<() => Promise<HardcoverSyncPendingSummary>>(),
streamHardcoverSyncStatus: vi.fn<(onStatus: (s: HardcoverActiveSyncStatus | null) => void, signal?: AbortSignal) => Promise<void>>(),
fetchHardcoverSettings: vi.fn<() => Promise<HardcoverSettings>>(),
}))
import {
cancelHardcoverSync,
fetchHardcoverSettings,
fetchHardcoverSyncStatus,
fetchHardcoverSyncPendingSummary,
startHardcoverSync,
streamHardcoverSyncStatus,
} from '../../api/hardcover.api'
const mockStart = vi.mocked(startHardcoverSync)
const mockCancel = vi.mocked(cancelHardcoverSync)
const mockStatus = vi.mocked(fetchHardcoverSyncStatus)
const mockPendingSummary = vi.mocked(fetchHardcoverSyncPendingSummary)
const mockStream = vi.mocked(streamHardcoverSyncStatus)
const mockFetchSettings = vi.mocked(fetchHardcoverSettings)
const RUNNING_STATUS: HardcoverActiveSyncStatus = {
runId: 1,
status: 'running',
totalBooks: 10,
syncedBooks: 3,
}
async function loadComposable() {
const { useHardcoverSync } = await import('../useHardcoverSync')
return useHardcoverSync()
}
describe('useHardcoverSync', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
vi.useFakeTimers()
mockStatus.mockResolvedValue(null)
mockPendingSummary.mockResolvedValue({ totalBooks: 0, pendingBooks: 0 })
mockStream.mockResolvedValue(undefined)
mockFetchSettings.mockResolvedValue(null as unknown as HardcoverSettings)
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it('startSync sets activeSyncStatus and starts stream', async () => {
mockStart.mockResolvedValue({ runId: 1 })
const c = await loadComposable()
await c.startSync()
expect(c.activeSyncStatus.value).toEqual({
runId: 1,
status: 'running',
syncedBooks: 0,
totalBooks: 0,
})
expect(c.isSyncing.value).toBe(true)
c.stopSyncTracking()
})
it('startSync sets error on failure', async () => {
mockStart.mockRejectedValue(new Error('Server busy'))
const c = await loadComposable()
await c.startSync()
expect(c.error.value).toBe('Server busy')
})
it('cancelSync clears status and stops stream tracking', async () => {
mockCancel.mockResolvedValue(undefined)
const c = await loadComposable()
c.activeSyncStatus.value = RUNNING_STATUS
await c.cancelSync()
expect(c.activeSyncStatus.value).toBeNull()
})
it('fetchStatus starts stream if sync is running', async () => {
mockStatus.mockResolvedValue({ runId: 2, status: 'running', totalBooks: 10, syncedBooks: 3 })
const c = await loadComposable()
await c.fetchStatus()
expect(c.isSyncing.value).toBe(true)
expect(c.activeSyncStatus.value?.runId).toBe(2)
c.stopSyncTracking()
})
it('fetchStatus loads pending summary', async () => {
mockPendingSummary.mockResolvedValue({ totalBooks: 6, pendingBooks: 2 })
const c = await loadComposable()
await c.fetchStatus()
expect(c.pendingSummary.value).toEqual({ totalBooks: 6, pendingBooks: 2 })
})
it('syncProgress is 0 when no sync', async () => {
const c = await loadComposable()
expect(c.syncProgress.value).toBe(0)
})
it('syncProgress is calculated correctly', async () => {
const c = await loadComposable()
c.activeSyncStatus.value = RUNNING_STATUS
expect(c.syncProgress.value).toBe(30)
c.stopSyncTracking()
})
it('ignores stale stream responses after stream stops', async () => {
mockStart.mockResolvedValue({ runId: 1 })
let onStatusFromStream: ((status: HardcoverActiveSyncStatus | null) => void | Promise<void>) | null = null
mockStream.mockImplementationOnce(async (onStatus) => {
onStatusFromStream = onStatus
return new Promise<void>(() => {
// keep stream open
})
})
const c = await loadComposable()
await c.startSync()
expect(onStatusFromStream).not.toBeNull()
c.stopSyncTracking()
c.activeSyncStatus.value = null
const streamCallback = onStatusFromStream as ((status: HardcoverActiveSyncStatus | null) => void | Promise<void>) | null
if (streamCallback) {
await streamCallback(RUNNING_STATUS)
}
expect(c.activeSyncStatus.value).toBeNull()
})
})
@@ -0,0 +1,74 @@
import { ref } from 'vue'
import type { HardcoverSettings, UpsertHardcoverSettingsPayload } from '@bookorbit/types'
import { disconnectHardcover, fetchHardcoverSettings, upsertHardcoverSettings, validateHardcoverToken } from '../api/hardcover.api'
const settings = ref<HardcoverSettings | null>(null)
const loading = ref(false)
const saving = ref(false)
const validating = ref(false)
const error = ref<string | null>(null)
export function useHardcoverSettings() {
async function fetchSettings(): Promise<void> {
loading.value = true
error.value = null
try {
settings.value = await fetchHardcoverSettings()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load settings'
} finally {
loading.value = false
}
}
async function saveSettings(payload: UpsertHardcoverSettingsPayload): Promise<boolean> {
saving.value = true
error.value = null
try {
settings.value = await upsertHardcoverSettings(payload)
return true
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to save settings'
return false
} finally {
saving.value = false
}
}
async function disconnect(): Promise<void> {
saving.value = true
error.value = null
try {
await disconnectHardcover()
settings.value = null
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to disconnect'
} finally {
saving.value = false
}
}
async function validateToken(token?: string): Promise<{ valid: boolean; username?: string }> {
validating.value = true
try {
const result = await validateHardcoverToken(token)
return { valid: result.valid, username: result.hardcoverUsername }
} catch {
return { valid: false }
} finally {
validating.value = false
}
}
return {
settings,
loading,
saving,
validating,
error,
fetchSettings,
saveSettings,
disconnect,
validateToken,
}
}
@@ -0,0 +1,127 @@
import { computed, ref } from 'vue'
import type { HardcoverActiveSyncStatus, HardcoverSyncPendingSummary } from '@bookorbit/types'
import {
cancelHardcoverSync,
fetchHardcoverSyncPendingSummary,
fetchHardcoverSyncStatus,
startHardcoverSync,
streamHardcoverSyncStatus,
} from '../api/hardcover.api'
import { useHardcoverSettings } from './useHardcoverSettings'
const activeSyncStatus = ref<HardcoverActiveSyncStatus | null>(null)
const pendingSummary = ref<HardcoverSyncPendingSummary>({ totalBooks: 0, pendingBooks: 0 })
const syncing = ref(false)
const loadingPending = ref(true)
const error = ref<string | null>(null)
let streamAbortController: AbortController | null = null
let streamGeneration = 0
const isSyncing = computed(() => activeSyncStatus.value?.status === 'running')
const syncProgress = computed(() => {
const s = activeSyncStatus.value
if (!s || s.totalBooks === 0) return 0
return Math.round((s.syncedBooks / s.totalBooks) * 100)
})
export function useHardcoverSync() {
function startSyncTracking(): void {
stopSyncTracking()
const generation = ++streamGeneration
const controller = new AbortController()
streamAbortController = controller
void streamHardcoverSyncStatus(async (status) => {
if (generation !== streamGeneration) return
activeSyncStatus.value = status
if (status?.status !== 'running') {
stopSyncTracking()
await fetchPendingSummary()
await useHardcoverSettings().fetchSettings()
}
}, controller.signal)
.then(async () => {
if (generation !== streamGeneration) return
await fetchPendingSummary()
await useHardcoverSettings().fetchSettings()
})
.catch(async () => {
if (generation !== streamGeneration) return
if (controller.signal.aborted) return
await fetchPendingSummary()
if (activeSyncStatus.value?.status === 'running') {
startSyncTracking()
}
})
}
function stopSyncTracking(): void {
streamGeneration++
if (streamAbortController !== null) {
streamAbortController.abort()
streamAbortController = null
}
}
async function startSync(): Promise<void> {
syncing.value = true
error.value = null
try {
const { runId } = await startHardcoverSync()
activeSyncStatus.value = {
runId,
status: 'running',
syncedBooks: 0,
totalBooks: 0,
}
startSyncTracking()
await fetchPendingSummary()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to start sync'
} finally {
syncing.value = false
}
}
async function cancelSync(): Promise<void> {
await cancelHardcoverSync().catch(() => null)
stopSyncTracking()
activeSyncStatus.value = null
await fetchPendingSummary()
}
async function fetchStatus(): Promise<void> {
const [status] = await Promise.all([fetchHardcoverSyncStatus(), fetchPendingSummary()])
activeSyncStatus.value = status
if (status?.status === 'running') {
startSyncTracking()
}
}
async function fetchPendingSummary(): Promise<void> {
loadingPending.value = true
try {
pendingSummary.value = await fetchHardcoverSyncPendingSummary()
} catch {
// silent
} finally {
loadingPending.value = false
}
}
return {
activeSyncStatus,
pendingSummary,
syncing,
loadingPending,
error,
isSyncing,
syncProgress,
startSync,
cancelSync,
fetchStatus,
fetchPendingSummary,
stopSyncTracking,
}
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Check, RefreshCw, Sparkles, ArrowUpFromLine, CheckCircle2, AlertCircle, Loader2, Bell, Award } from 'lucide-vue-next'
import { Check, RefreshCw, Sparkles, ArrowUpFromLine, CheckCircle2, AlertCircle, Loader2, Bell, Award, BookHeart } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
@@ -20,6 +20,8 @@ const migrationLoading = ref(true)
const updateCheckEnabled = ref(true)
const updateCheckLoading = ref(false)
const hardcoverEnabled = ref(false)
const hardcoverLoading = ref(false)
const achievementBackfillRunning = ref(false)
const achievementBackfillError = ref<string | null>(null)
const achievementBackfillResult = ref<{ usersProcessed: number; awardsGranted: number } | null>(null)
@@ -51,6 +53,8 @@ onMounted(async () => {
const settings: { key: string; value: string }[] = await res.json()
const row = settings.find((s) => s.key === 'update_check_enabled')
if (row) updateCheckEnabled.value = row.value === 'true'
const hcRow = settings.find((s) => s.key === 'hardcover_enabled')
if (hcRow) hardcoverEnabled.value = hcRow.value === 'true'
}
} catch {
// non-fatal
@@ -91,6 +95,27 @@ async function toggleUpdateCheck(newVal: boolean) {
}
}
async function toggleHardcoverEnabled(newVal: boolean) {
hardcoverLoading.value = true
try {
const res = await api('/api/v1/admin/hardcover/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newVal }),
})
if (res.ok) {
hardcoverEnabled.value = newVal
toast.success(`Hardcover sync ${newVal ? 'enabled' : 'disabled'}`)
} else {
toast.error('Failed to update setting')
}
} catch {
toast.error('Failed to update setting')
} finally {
hardcoverLoading.value = false
}
}
async function rebuildEmbeddings() {
running.value = true
queued.value = null
@@ -320,5 +345,24 @@ function formatDate(iso: string | null | undefined): string {
</div>
</div>
</div>
<!-- Integrations -->
<div>
<p class="settings-group-label">Integrations</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<BookHeart :size="16" class="text-primary" />
</div>
<div class="min-w-0">
<p class="settings-label">Hardcover sync</p>
<p class="settings-hint">Allow users to connect their Hardcover account and sync reading status, progress, and ratings.</p>
</div>
</div>
<ToggleSwitch :model-value="hardcoverEnabled" :disabled="hardcoverLoading" @update:model-value="toggleHardcoverEnabled" />
</div>
</div>
</div>
</div>
</template>
@@ -56,6 +56,7 @@ const sections = computed<Section[]>(() => {
if (su || perms.includes('koreader_sync')) {
result.push({ label: 'KOReader', routeName: 'settings-koreader' })
}
result.push({ label: 'Hardcover', routeName: 'settings-hardcover' })
if (su || perms.includes('manage_app_settings')) {
result.push({ label: 'Maintenance', routeName: 'settings-admin-maintenance' })
}
+6
View File
@@ -107,6 +107,12 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/features/settings/KoreaderSettings.vue'),
meta: { maxWidth: 'max-w-3xl', title: 'KOReader Sync' },
},
{
path: 'hardcover',
name: 'settings-hardcover',
component: () => import('@/features/hardcover/components/HardcoverSettings.vue'),
meta: { maxWidth: 'max-w-3xl', title: 'Hardcover' },
},
{
path: 'email',
name: 'settings-email',
+43
View File
@@ -0,0 +1,43 @@
export interface HardcoverSettings {
tokenConfigured: boolean;
enabled: boolean;
autoSyncOnStatusChange: boolean;
autoSyncOnProgressUpdate: boolean;
autoSyncOnRatingChange: boolean;
privacySettingId: number;
lastSyncedAt: string | null;
}
export interface HardcoverAdminSettings {
featureEnabled: boolean;
}
export interface UpsertHardcoverSettingsPayload {
apiToken?: string;
enabled?: boolean;
autoSyncOnStatusChange?: boolean;
autoSyncOnProgressUpdate?: boolean;
autoSyncOnRatingChange?: boolean;
privacySettingId?: number;
}
export interface HardcoverTokenValidationResult {
valid: boolean;
hardcoverUsername?: string;
}
export type HardcoverSyncRunStatus = "running" | "completed" | "failed" | "cancelled";
export interface HardcoverSyncPendingSummary {
totalBooks: number;
pendingBooks: number;
}
export interface HardcoverActiveSyncStatus {
runId: number;
syncedBooks: number;
totalBooks: number;
status: HardcoverSyncRunStatus;
}
export type HardcoverPrivacySetting = 1 | 2 | 3;
+1
View File
@@ -38,3 +38,4 @@ export * from "./dictionary";
export * from "./achievement";
export * from "./reading-session";
export * from "./annotation";
export * from "./hardcover";
+2
View File
@@ -59,6 +59,7 @@ import { FontModule } from './modules/font/font.module';
import { KoreaderModule } from './modules/koreader/koreader.module';
import { AppInfoModule } from './modules/app-info/app-info.module';
import { AchievementModule } from './modules/achievement/achievement.module';
import { HardcoverModule } from './modules/hardcover/hardcover.module';
@Module({
imports: [
@@ -126,6 +127,7 @@ import { AchievementModule } from './modules/achievement/achievement.module';
KoreaderModule,
AppInfoModule,
AchievementModule,
HardcoverModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
@@ -22,6 +22,7 @@ export const APP_SETTING_KEYS = {
AUDIT_RETENTION_DAYS: 'audit_retention_days',
INITIAL_SETUP_COMPLETED_AT: 'initial_setup_completed_at',
UPDATE_CHECK_ENABLED: 'update_check_enabled',
HARDCOVER_ENABLED: 'hardcover_enabled',
} as const;
export const DEFAULT_AUDIT_RETENTION_DAYS = 90;
+2 -2
View File
@@ -19,10 +19,10 @@ describe('loggerConfig', () => {
vi.unstubAllEnvs();
});
it('uses debug level and pretty transport by default in development', async () => {
it('uses info level and pretty transport by default in development', async () => {
const config = await loadLoggerConfig('development');
expect(config.pinoHttp?.level).toBe('debug');
expect(config.pinoHttp?.level).toBe('info');
expect(config.pinoHttp).toEqual(
expect.objectContaining({
transport: expect.objectContaining({
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Params } from 'nestjs-pino';
import type { IncomingMessage, ServerResponse } from 'http';
const isDev = process.env.NODE_ENV !== 'production';
const logLevel = process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info');
const logLevel = process.env.LOG_LEVEL ?? 'info';
const FRAMEWORK_CONTEXTS = new Set(['InstanceLoader', 'RouterExplorer', 'RoutesResolver']);
@@ -0,0 +1,40 @@
CREATE TABLE "hardcover_book_state" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"book_id" integer NOT NULL,
"hardcover_book_id" integer,
"hardcover_edition_id" integer,
"hardcover_user_book_id" integer,
"hardcover_read_id" integer,
"match_method" varchar(10),
"match_error" text,
"last_synced_at" timestamp with time zone,
"last_synced_status" varchar(20),
"last_synced_progress" real,
"last_synced_rating" integer,
"last_synced_started_at" varchar(10),
"last_synced_finished_at" varchar(10),
"sync_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hardcover_book_state_user_book_uidx" UNIQUE("user_id","book_id")
);
--> statement-breakpoint
CREATE TABLE "hardcover_user_settings" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"api_token" varchar(2048) NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"auto_sync_on_status_change" boolean DEFAULT true NOT NULL,
"auto_sync_on_progress_update" boolean DEFAULT true NOT NULL,
"auto_sync_on_rating_change" boolean DEFAULT true NOT NULL,
"privacy_setting_id" integer DEFAULT 3 NOT NULL,
"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 "hardcover_user_settings_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
ALTER TABLE "hardcover_book_state" ADD CONSTRAINT "hardcover_book_state_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hardcover_book_state" ADD CONSTRAINT "hardcover_book_state_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hardcover_user_settings" ADD CONSTRAINT "hardcover_user_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
@@ -36,6 +36,13 @@
"when": 1779128487353,
"tag": "0004_add_tls_reject_unauthorized_to_email_providers",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1779204354944,
"tag": "0005_add_hardcover_tables",
"breakpoints": true
}
]
}
+61
View File
@@ -0,0 +1,61 @@
import { boolean, integer, pgTable, real, serial, text, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
import { books } from './books';
import { users } from './auth';
export const hardcoverUserSettings = pgTable('hardcover_user_settings', {
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' })
.unique(),
apiToken: varchar('api_token', { length: 2048 }).notNull(),
enabled: boolean('enabled').notNull().default(true),
autoSyncOnStatusChange: boolean('auto_sync_on_status_change').notNull().default(true),
autoSyncOnProgressUpdate: boolean('auto_sync_on_progress_update').notNull().default(true),
autoSyncOnRatingChange: boolean('auto_sync_on_rating_change').notNull().default(true),
privacySettingId: integer('privacy_setting_id').notNull().default(3),
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 const hardcoverBookState = pgTable(
'hardcover_book_state',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
hardcoverBookId: integer('hardcover_book_id'),
hardcoverEditionId: integer('hardcover_edition_id'),
hardcoverUserBookId: integer('hardcover_user_book_id'),
hardcoverReadId: integer('hardcover_read_id'),
matchMethod: varchar('match_method', { length: 10 }),
matchError: text('match_error'),
lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }),
lastSyncedStatus: varchar('last_synced_status', { length: 20 }),
lastSyncedProgress: real('last_synced_progress'),
lastSyncedRating: integer('last_synced_rating'),
lastSyncedStartedAt: varchar('last_synced_started_at', { length: 10 }),
lastSyncedFinishedAt: varchar('last_synced_finished_at', { length: 10 }),
syncError: text('sync_error'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [unique('hardcover_book_state_user_book_uidx').on(t.userId, t.bookId)],
);
export type HardcoverUserSetting = typeof hardcoverUserSettings.$inferSelect;
export type NewHardcoverUserSetting = typeof hardcoverUserSettings.$inferInsert;
export type HardcoverBookState = typeof hardcoverBookState.$inferSelect;
export type NewHardcoverBookState = typeof hardcoverBookState.$inferInsert;
+1
View File
@@ -25,3 +25,4 @@ export * from './tools';
export * from './fonts';
export * from './koreader';
export * from './achievements';
export * from './hardcover';
@@ -8,6 +8,7 @@ export const ACHIEVEMENT_EVENT_COLLECTION_CREATED = 'collection.created';
export const ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED = 'library.catalog-changed';
export const ACHIEVEMENT_EVENT_BACKFILL = 'achievement.backfill';
export const ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED = 'achievement.awarded';
export const ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED = 'book.rating-changed';
export interface ReadingSessionSavedPayload {
userId: number;
@@ -43,12 +44,19 @@ export interface LibraryCatalogChangedPayload {
libraryId: number;
}
export interface BookRatingChangedPayload {
userId: number;
bookIds: number[];
rating: number | null;
}
export type AchievementEventPayload =
| ReadingSessionSavedPayload
| BookStatusChangedPayload
| AnnotationCreatedPayload
| CollectionCreatedPayload
| LibraryCatalogChangedPayload;
| LibraryCatalogChangedPayload
| BookRatingChangedPayload;
@Injectable()
export class AchievementEventsService extends EventEmitter {}
@@ -227,6 +227,15 @@ export class AppSettingsService {
const row = await this.repo.findByKey(APP_SETTING_KEYS.UPDATE_CHECK_ENABLED);
return parseBooleanSetting(row?.value, true);
}
async isHardcoverEnabled(): Promise<boolean> {
const row = await this.repo.findByKey(APP_SETTING_KEYS.HARDCOVER_ENABLED);
return parseBooleanSetting(row?.value, false);
}
async setHardcoverEnabled(enabled: boolean): Promise<void> {
await this.repo.upsert(APP_SETTING_KEYS.HARDCOVER_ENABLED, String(enabled));
}
}
function parseAutoFinalizeMetadataMode(value: string | undefined): BookDockAutoFinalizeMetadataMode {
+2
View File
@@ -1,5 +1,6 @@
import { Module, forwardRef } from '@nestjs/common';
import { AchievementModule } from '../achievement/achievement.module';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { EmbeddingModule } from '../embedding/embedding.module';
import { FileWriteModule } from '../file-write/file-write.module';
@@ -29,6 +30,7 @@ import { BookService } from './book.service';
MetadataScoreModule,
NarratorModule,
UserBookStatusModule,
AchievementModule,
],
controllers: [BookController],
providers: [BookService, BookRepository, BookReadService, BookSortBuilder, BookQueryBuilder],
+27 -1
View File
@@ -9,6 +9,7 @@ import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../m
import { parseFb2File } from '../metadata/lib/fb2-parser';
import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile } from '../metadata/lib/pdf-parser';
import { ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED } from '../achievement/achievement-events.service';
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
import { BookQueryBuilder } from './book-query-builder.service';
import { BookService } from './book.service';
@@ -143,6 +144,9 @@ function makeService() {
const fileWriteService = {
scheduleWrite: vi.fn(),
};
const achievementEvents = {
emit: vi.fn(),
};
const narratorService = {
replaceForBook: vi.fn().mockResolvedValue(undefined),
};
@@ -190,6 +194,7 @@ function makeService() {
bookMetadataLockService as never,
embedder as never,
fileWriteService as never,
achievementEvents as never,
);
return {
@@ -205,6 +210,7 @@ function makeService() {
userBookStatusService,
embedder,
fileWriteService,
achievementEvents,
narratorService,
comicMetadataService,
bookMetadataLockService,
@@ -1202,7 +1208,7 @@ describe('BookService', () => {
});
it('updateMetadata clears rating to null', async () => {
const { service, bookRepo } = makeService();
const { service, bookRepo, achievementEvents } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
@@ -1210,6 +1216,26 @@ describe('BookService', () => {
await service.updateMetadata(5, { rating: null }, user);
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([5], null, user.id);
expect(achievementEvents.emit).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
userId: user.id,
bookIds: [5],
rating: null,
});
});
it('updateMetadata emits rating changed event when rating is updated', async () => {
const { service, achievementEvents } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
await service.updateMetadata(5, { rating: 4 }, user);
expect(achievementEvents.emit).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
userId: user.id,
bookIds: [5],
rating: 4,
});
});
it('updateMetadata replaces genres array only', async () => {
+14 -1
View File
@@ -45,6 +45,7 @@ import type { MetadataSearchParams } from '../metadata-fetch/providers/metadata-
import { FileWriteService } from '../file-write/file-write.service';
import { NarratorService } from '../narrator/narrator.service';
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED } from '../achievement/achievement-events.service';
import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-lock.service';
import { BookQueryBuilder } from './book-query-builder.service';
import { BookRepository } from './book.repository';
@@ -180,6 +181,7 @@ export class BookService {
private readonly bookMetadataLockService: BookMetadataLockService,
@Optional() private readonly embedder: BookEmbedderService,
@Optional() private readonly fileWriteService: FileWriteService,
@Optional() private readonly achievementEvents: AchievementEventsService,
) {
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
}
@@ -1120,7 +1122,13 @@ export class BookService {
this.metadataService.emitAuthorsReplaced(id, replacedAuthorIds);
if (dto.rating !== undefined) {
await this.bookRepo.bulkSetRating([id], dto.rating ?? null, user.id);
const rating = dto.rating ?? null;
await this.bookRepo.bulkSetRating([id], rating, user.id);
this.achievementEvents?.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
userId: user.id,
bookIds: [id],
rating,
});
}
this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`));
@@ -1318,6 +1326,11 @@ export class BookService {
if (updatableIds.length > 0) {
await this.bookRepo.bulkSetRating(updatableIds, rating, user.id);
this.triggerPostMetadataUpdateEffects(updatableIds, user.id);
this.achievementEvents?.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
userId: user.id,
bookIds: updatableIds,
rating,
});
}
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} updated=${updatableIds.length} skippedLocked=${lockedIds.size} durationMs=${Date.now() - startedAt} - bulk set rating completed`,
@@ -0,0 +1,41 @@
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpsertHardcoverSettingsDto {
@IsOptional()
@IsString()
@MaxLength(2048)
apiToken?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsBoolean()
autoSyncOnStatusChange?: boolean;
@IsOptional()
@IsBoolean()
autoSyncOnProgressUpdate?: boolean;
@IsOptional()
@IsBoolean()
autoSyncOnRatingChange?: boolean;
@IsOptional()
@IsInt()
@IsIn([1, 2, 3])
privacySettingId?: number;
}
export class ValidateHardcoverTokenDto {
@IsOptional()
@IsString()
@MaxLength(2048)
token?: string;
}
export class SetHardcoverFeatureEnabledDto {
@IsBoolean()
enabled!: boolean;
}
@@ -0,0 +1 @@
export * from './hardcover.dto';
@@ -0,0 +1,33 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { HardcoverAdminController } from './hardcover-admin.controller';
const mockSettingsService = {
getAdminSettings: vi.fn(),
setFeatureEnabled: vi.fn(),
};
function makeController() {
return new HardcoverAdminController(mockSettingsService as any);
}
describe('HardcoverAdminController', () => {
beforeEach(() => vi.clearAllMocks());
it('getAdminSettings delegates to the settings service', async () => {
mockSettingsService.getAdminSettings.mockResolvedValue({ featureEnabled: true });
const result = await makeController().getAdminSettings();
expect(result).toEqual({ featureEnabled: true });
expect(mockSettingsService.getAdminSettings).toHaveBeenCalledTimes(1);
});
it('setFeatureEnabled delegates the enabled flag', async () => {
mockSettingsService.setFeatureEnabled.mockResolvedValue(undefined);
await makeController().setFeatureEnabled({ enabled: false });
expect(mockSettingsService.setFeatureEnabled).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,23 @@
import { Body, Controller, Get, Patch } from '@nestjs/common';
import { Permission } from '@bookorbit/types';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { SetHardcoverFeatureEnabledDto } from './dto';
import { HardcoverSettingsService } from './hardcover-settings.service';
@Controller('admin/hardcover')
export class HardcoverAdminController {
constructor(private readonly settingsService: HardcoverSettingsService) {}
@Get('settings')
@RequirePermission(Permission.ManageAppSettings)
getAdminSettings() {
return this.settingsService.getAdminSettings();
}
@Patch('settings')
@RequirePermission(Permission.ManageAppSettings)
setFeatureEnabled(@Body() dto: SetHardcoverFeatureEnabledDto) {
return this.settingsService.setFeatureEnabled(dto.enabled);
}
}
@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HardcoverBookMatchService } from './hardcover-book-match.service';
const mockRepo = {
findBookState: vi.fn(),
upsertBookState: vi.fn(),
};
const mockClient = {
query: vi.fn(),
};
function makeService() {
return new HardcoverBookMatchService(mockRepo as any, mockClient as any);
}
const baseBook = {
bookId: 42,
isbn13: '9781234567890',
isbn10: null,
title: 'Test Book',
authorName: 'Test Author',
hardcoverMetadataId: null,
status: 'reading',
startedAt: null,
finishedAt: null,
rating: null,
progress: null,
};
describe('HardcoverBookMatchService', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRepo.upsertBookState.mockResolvedValue({});
});
it('returns cached match when state exists and no error', async () => {
mockRepo.findBookState.mockResolvedValue({
hardcoverBookId: 100,
hardcoverEditionId: 200,
matchError: null,
});
mockClient.query.mockResolvedValue({
books: [{ id: 100, editions: [{ id: 200, pages: 320 }] }],
});
const result = await makeService().matchBook(1, 'tok', baseBook);
expect(result).toEqual({ hardcoverBookId: 100, hardcoverEditionId: 200, editionPages: 320, matchMethod: 'cached' });
expect(mockClient.query).toHaveBeenCalledTimes(1);
expect(mockRepo.upsertBookState).not.toHaveBeenCalled();
});
it('updates cached edition when the current cached edition has no pages', async () => {
mockRepo.findBookState.mockResolvedValue({
hardcoverBookId: 100,
hardcoverEditionId: 200,
matchError: null,
});
mockClient.query.mockResolvedValue({
books: [
{
id: 100,
editions: [
{ id: 200, pages: null },
{ id: 201, pages: 544 },
],
},
],
});
const result = await makeService().matchBook(1, 'tok', baseBook);
expect(result).toEqual({ hardcoverBookId: 100, hardcoverEditionId: 201, editionPages: 544, matchMethod: 'cached' });
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(
expect.objectContaining({
userId: 1,
bookId: 42,
hardcoverBookId: 100,
hardcoverEditionId: 201,
matchMethod: 'cached',
matchError: null,
}),
);
});
it('re-queries when cached state has match error', async () => {
mockRepo.findBookState.mockResolvedValue({ hardcoverBookId: null, matchError: 'no_match' });
mockClient.query.mockResolvedValue({ books: [{ id: 99, editions: [{ id: 55 }] }] });
const result = await makeService().matchBook(1, 'tok', baseBook);
expect(result?.hardcoverBookId).toBe(99);
expect(result?.matchMethod).toBe('isbn');
});
it('matches by metadata_id when hardcoverMetadataId is set', async () => {
mockRepo.findBookState.mockResolvedValue(undefined);
mockClient.query.mockResolvedValue({ books: [{ id: 77 }] });
const book = { ...baseBook, hardcoverMetadataId: '77', isbn13: null };
const result = await makeService().matchBook(1, 'tok', book);
expect(result?.matchMethod).toBe('metadata_id');
expect(result?.hardcoverBookId).toBe(77);
});
it('falls back to isbn13 when metadata_id fails', async () => {
mockRepo.findBookState.mockResolvedValue(undefined);
mockClient.query.mockRejectedValueOnce(new Error('not found')).mockResolvedValueOnce({ books: [{ id: 88, editions: [{ id: 11 }] }] });
const book = { ...baseBook, hardcoverMetadataId: '99' };
const result = await makeService().matchBook(1, 'tok', book);
expect(result?.hardcoverBookId).toBe(88);
expect(result?.matchMethod).toBe('isbn');
});
it('falls back to title+author when isbn fails', async () => {
mockRepo.findBookState.mockResolvedValue(undefined);
mockClient.query
.mockResolvedValueOnce({ books: [] })
.mockResolvedValueOnce({ search: { ids: [123] } })
.mockResolvedValueOnce({ books: [{ id: 123 }] });
const result = await makeService().matchBook(1, 'tok', baseBook);
expect(result?.matchMethod).toBe('title');
expect(result?.hardcoverBookId).toBe(123);
expect(mockClient.query).toHaveBeenNthCalledWith(
2,
1,
'tok',
expect.stringContaining('query SearchBooks'),
expect.objectContaining({ query: 'Test Book Test Author' }),
);
});
it('preserves Hardcover search ranking when hydrating title matches', async () => {
mockRepo.findBookState.mockResolvedValue(undefined);
mockClient.query
.mockResolvedValueOnce({ books: [] })
.mockResolvedValueOnce({ search: { ids: [123, 456] } })
.mockResolvedValueOnce({
books: [
{ id: 456, editions: [{ id: 40 }] },
{ id: 123, editions: [{ id: 30 }] },
],
});
const result = await makeService().matchBook(1, 'tok', baseBook);
expect(result).toEqual({ hardcoverBookId: 123, hardcoverEditionId: 30, editionPages: null, matchMethod: 'title' });
});
it('returns null and stores no_match when all strategies fail', async () => {
mockRepo.findBookState.mockResolvedValue(undefined);
mockClient.query.mockResolvedValue({ books: [] });
const result = await makeService().matchBook(1, 'tok', baseBook);
expect(result).toBeNull();
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(expect.objectContaining({ matchError: 'no_match' }));
});
it('stores match result to cache on success', async () => {
mockRepo.findBookState.mockResolvedValue(undefined);
mockClient.query.mockResolvedValue({ books: [{ id: 5, editions: [{ id: 6 }] }] });
await makeService().matchBook(1, 'tok', baseBook);
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(
expect.objectContaining({
userId: 1,
bookId: 42,
hardcoverBookId: 5,
hardcoverEditionId: 6,
matchMethod: 'isbn',
}),
);
});
});
@@ -0,0 +1,281 @@
import { Injectable, Logger } from '@nestjs/common';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import type { BookSyncData } from './hardcover.repository';
import { HardcoverClientService } from './hardcover-client.service';
import { HardcoverRepository } from './hardcover.repository';
export interface HardcoverBookMatch {
hardcoverBookId: number;
hardcoverEditionId: number | null;
editionPages: number | null;
matchMethod: 'isbn' | 'title' | 'cached' | 'metadata_id';
}
const FIND_BOOK_BY_ISBN13_QUERY = `
query FindBookByISBN13($isbn: String!) {
books(where: { editions: { isbn_13: { _eq: $isbn } } }, limit: 1) {
id
editions(where: { isbn_13: { _eq: $isbn } }, limit: 1) {
id
pages
}
}
}`;
const FIND_BOOK_BY_ISBN10_QUERY = `
query FindBookByISBN10($isbn: String!) {
books(where: { editions: { isbn_10: { _eq: $isbn } } }, limit: 1) {
id
editions(where: { isbn_10: { _eq: $isbn } }, limit: 1) {
id
pages
}
}
}`;
const SEARCH_BOOKS_QUERY = `
query SearchBooks($query: String!) {
search(
query: $query
query_type: "Book"
per_page: 5
page: 1
fields: "title,author_names,alternative_titles"
weights: "5,2,1"
) {
ids
}
}`;
const FIND_BOOKS_BY_IDS_QUERY = `
query FindBooksByIds($ids: [Int!]!) {
books(where: { id: { _in: $ids } }, limit: 5) {
id
editions(limit: 1) {
id
pages
}
}
}`;
const FIND_BOOK_BY_HARDCOVER_ID_QUERY = `
query FindBookById($id: Int!) {
books(where: { id: { _eq: $id } }, limit: 1) {
id
editions(limit: 1) {
id
pages
}
}
}`;
const FIND_BOOK_EDITIONS_BY_HARDCOVER_ID_QUERY = `
query FindBookEditionsById($id: Int!) {
books(where: { id: { _eq: $id } }, limit: 1) {
id
editions(limit: 50) {
id
pages
}
}
}`;
interface BooksQueryResult {
books: Array<{
id: number;
editions?: Array<{ id: number; pages?: number | null }>;
}>;
}
interface SearchBooksResult {
search?: {
ids?: number[];
} | null;
}
@Injectable()
export class HardcoverBookMatchService {
private readonly logger = new Logger(HardcoverBookMatchService.name);
constructor(
private readonly repo: HardcoverRepository,
private readonly client: HardcoverClientService,
) {}
async matchBook(userId: number, token: string, book: BookSyncData): Promise<HardcoverBookMatch | null> {
const cached = await this.repo.findBookState(userId, book.bookId);
if (cached?.hardcoverBookId && !cached.matchError) {
const cachedMatch = await this.resolveCachedMatch(userId, token, book.bookId, cached.hardcoverBookId, cached.hardcoverEditionId ?? null);
if ((cached.hardcoverEditionId ?? null) !== cachedMatch.hardcoverEditionId) {
await this.repo.upsertBookState({
userId,
bookId: book.bookId,
hardcoverBookId: cached.hardcoverBookId,
hardcoverEditionId: cachedMatch.hardcoverEditionId,
matchMethod: 'cached',
matchError: null,
});
}
return {
hardcoverBookId: cached.hardcoverBookId,
hardcoverEditionId: cachedMatch.hardcoverEditionId,
editionPages: cachedMatch.editionPages,
matchMethod: 'cached',
};
}
let match: HardcoverBookMatch | null = null;
if (book.hardcoverMetadataId) {
const id = parseInt(book.hardcoverMetadataId, 10);
if (!isNaN(id)) {
match = await this.matchByHardcoverId(userId, token, id, book.bookId);
}
}
if (!match && book.isbn13) {
match = await this.matchByIsbn(userId, token, book.isbn13, book.bookId, 13);
}
if (!match && book.isbn10) {
match = await this.matchByIsbn(userId, token, book.isbn10, book.bookId, 10);
}
if (!match && book.title && book.authorName) {
match = await this.matchByTitleAuthor(userId, token, book.title, book.authorName, book.bookId);
}
if (match) {
await this.repo.upsertBookState({
userId,
bookId: book.bookId,
hardcoverBookId: match.hardcoverBookId,
hardcoverEditionId: match.hardcoverEditionId ?? null,
matchMethod: match.matchMethod,
matchError: null,
});
} else {
await this.repo.upsertBookState({
userId,
bookId: book.bookId,
hardcoverBookId: null,
matchError: 'no_match',
});
}
return match;
}
private async matchByHardcoverId(userId: number, token: string, id: number, bookId: number): Promise<HardcoverBookMatch | null> {
try {
const data = await this.client.query<BooksQueryResult>(userId, token, FIND_BOOK_BY_HARDCOVER_ID_QUERY, { id });
const book = data.books?.[0];
if (!book) return null;
return {
hardcoverBookId: book.id,
hardcoverEditionId: book.editions?.[0]?.id ?? null,
editionPages: book.editions?.[0]?.pages ?? null,
matchMethod: 'metadata_id',
};
} catch (err) {
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[hardcover.book_match] [fail] userId=${userId} bookId=${bookId} method=metadata_id error="${error}" - metadata_id lookup failed`,
);
return null;
}
}
private async matchByIsbn(userId: number, token: string, isbn: string, bookId: number, version: 10 | 13): Promise<HardcoverBookMatch | null> {
const query = version === 13 ? FIND_BOOK_BY_ISBN13_QUERY : FIND_BOOK_BY_ISBN10_QUERY;
try {
const data = await this.client.query<BooksQueryResult>(userId, token, query, { isbn });
const book = data.books?.[0];
if (!book) return null;
return {
hardcoverBookId: book.id,
hardcoverEditionId: book.editions?.[0]?.id ?? null,
editionPages: book.editions?.[0]?.pages ?? null,
matchMethod: 'isbn',
};
} catch (err) {
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(`[hardcover.book_match] [fail] userId=${userId} bookId=${bookId} method=isbn${version} error="${error}" - ISBN lookup failed`);
return null;
}
}
private async matchByTitleAuthor(userId: number, token: string, title: string, author: string, bookId: number): Promise<HardcoverBookMatch | null> {
try {
const searchData = await this.client.query<SearchBooksResult>(userId, token, SEARCH_BOOKS_QUERY, {
query: `${title} ${author}`,
});
const ids = searchData.search?.ids?.filter((id) => Number.isInteger(id)).slice(0, 5) ?? [];
if (ids.length === 0) return null;
const data = await this.client.query<BooksQueryResult>(userId, token, FIND_BOOKS_BY_IDS_QUERY, { ids });
const booksById = new Map((data.books ?? []).map((book) => [book.id, book]));
const book = ids.map((id) => booksById.get(id)).find((candidate): candidate is BooksQueryResult['books'][number] => candidate != null);
if (!book) return null;
return {
hardcoverBookId: book.id,
hardcoverEditionId: book.editions?.[0]?.id ?? null,
editionPages: book.editions?.[0]?.pages ?? null,
matchMethod: 'title',
};
} catch (err) {
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(`[hardcover.book_match] [fail] userId=${userId} bookId=${bookId} method=title_author error="${error}" - title lookup failed`);
return null;
}
}
private async resolveCachedMatch(
userId: number,
token: string,
bookId: number,
hardcoverBookId: number,
cachedEditionId: number | null,
): Promise<{ hardcoverEditionId: number | null; editionPages: number | null }> {
try {
const data = await this.client.query<BooksQueryResult>(userId, token, FIND_BOOK_EDITIONS_BY_HARDCOVER_ID_QUERY, {
id: hardcoverBookId,
});
const hardcoverBook = data.books?.[0];
if (!hardcoverBook) {
return { hardcoverEditionId: cachedEditionId, editionPages: null };
}
const editions = hardcoverBook.editions ?? [];
const cachedEdition = cachedEditionId != null ? editions.find((edition) => edition.id === cachedEditionId) : undefined;
const cachedEditionPages = this.normalizeEditionPages(cachedEdition?.pages);
if (cachedEditionPages != null) {
return { hardcoverEditionId: cachedEditionId, editionPages: cachedEditionPages };
}
const fallbackEdition = editions.find((edition) => this.normalizeEditionPages(edition.pages) != null);
if (!fallbackEdition) {
return { hardcoverEditionId: cachedEditionId, editionPages: null };
}
return {
hardcoverEditionId: fallbackEdition.id,
editionPages: this.normalizeEditionPages(fallbackEdition.pages),
};
} catch (err) {
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[hardcover.book_match] [fail] userId=${userId} bookId=${bookId} method=cached_pages error="${error}" - cached edition pages lookup failed`,
);
return { hardcoverEditionId: cachedEditionId, editionPages: null };
}
}
private normalizeEditionPages(pages: number | null | undefined): number | null {
if (typeof pages !== 'number' || !Number.isFinite(pages) || pages <= 0) return null;
return Math.round(pages);
}
}
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HardcoverClientService } from './hardcover-client.service';
import { HardcoverQueueService } from './hardcover-queue.service';
vi.mock('./hardcover-queue.service');
const mockQueueService = {
throttle: vi.fn().mockResolvedValue(undefined),
resetUser: vi.fn(),
};
function makeFetchResponse(status: number, body: unknown): Response {
return {
status,
ok: status >= 200 && status < 300,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
describe('HardcoverClientService', () => {
let service: HardcoverClientService;
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
service = new HardcoverClientService(mockQueueService as unknown as HardcoverQueueService);
fetchSpy = vi.spyOn(global, 'fetch');
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should return data on success', async () => {
fetchSpy.mockResolvedValueOnce(makeFetchResponse(200, { data: { me: [{ id: 1 }] } }));
const result = await service.query<{ me: { id: number }[] }>(1, 'token', '{ me { id } }');
expect(result).toEqual({ me: [{ id: 1 }] });
expect(fetchSpy).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': expect.stringContaining('BookOrbit'),
}),
}),
);
});
it('should throw on GraphQL errors', async () => {
fetchSpy.mockResolvedValueOnce(makeFetchResponse(200, { errors: [{ message: 'Unauthorized' }] }));
await expect(service.query(1, 'token', '{ me { id } }')).rejects.toThrow('Hardcover GraphQL error: Unauthorized');
});
it('should throw on non-OK HTTP status', async () => {
fetchSpy.mockResolvedValueOnce(makeFetchResponse(500, {}));
await expect(service.query(1, 'token', '{ me { id } }')).rejects.toThrow('Hardcover API error: 500');
});
it('should retry on 429 with backoff', async () => {
fetchSpy.mockResolvedValueOnce(makeFetchResponse(429, {})).mockResolvedValueOnce(makeFetchResponse(200, { data: { me: [] } }));
const p = service.query(1, 'token', '{ me { id } }');
const expectation = expect(p).resolves.toEqual({ me: [] });
await vi.runAllTimersAsync();
await expectation;
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('should give up after max retries on 429', async () => {
fetchSpy.mockResolvedValue(makeFetchResponse(429, {}));
const p = service.query(1, 'token', '{ me { id } }');
const expectation = expect(p).rejects.toThrow('Hardcover rate limit exceeded');
await vi.runAllTimersAsync();
await expectation;
expect(fetchSpy).toHaveBeenCalledTimes(4); // 1 + 3 retries
});
it('should throw on network error', async () => {
fetchSpy.mockRejectedValueOnce(new Error('Network failure'));
await expect(service.query(1, 'token', '{ me { id } }')).rejects.toThrow('Network failure');
});
});
@@ -0,0 +1,80 @@
import { Injectable, Logger } from '@nestjs/common';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { HARDCOVER_GRAPHQL_URL, HARDCOVER_MAX_RETRIES } from './hardcover.constants';
import { HardcoverQueueService } from './hardcover-queue.service';
export interface HardcoverGraphQLResponse<T> {
data?: T;
errors?: { message: string }[];
}
@Injectable()
export class HardcoverClientService {
private readonly logger = new Logger(HardcoverClientService.name);
constructor(private readonly queue: HardcoverQueueService) {}
async query<T>(userId: number, token: string, query: string, variables?: Record<string, unknown>): Promise<T> {
return this.executeWithRetry<T>(userId, token, query, variables, 0);
}
private async executeWithRetry<T>(
userId: number,
token: string,
query: string,
variables: Record<string, unknown> | undefined,
attempt: number,
): Promise<T> {
await this.queue.throttle(userId);
let response: Response;
try {
response = await fetch(HARDCOVER_GRAPHQL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'User-Agent': 'BookOrbit Hardcover Sync (https://bookorbit.app)',
},
body: JSON.stringify({ query, variables }),
});
} catch (err) {
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.error(`[hardcover.client] [fail] userId=${userId} attempt=${attempt} errorClass=${errorClass} error="${error}" - fetch failed`);
throw err;
}
if (response.status === 429) {
if (attempt >= HARDCOVER_MAX_RETRIES) {
this.logger.error(
`[hardcover.client] [fail] userId=${userId} attempt=${attempt} errorClass=RateLimitError error="rate limit exceeded after retries" - giving up`,
);
throw new Error('Hardcover rate limit exceeded');
}
const backoffMs = Math.pow(2, attempt + 1) * 1000;
this.logger.warn(`[hardcover.client] userId=${userId} attempt=${attempt} backoffMs=${backoffMs} - rate limited, backing off`);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
return this.executeWithRetry<T>(userId, token, query, variables, attempt + 1);
}
if (!response.ok) {
const errorClass = `HttpError${response.status}`;
this.logger.error(
`[hardcover.client] [fail] userId=${userId} status=${response.status} errorClass=${errorClass} error="unexpected HTTP status" - request failed`,
);
throw new Error(`Hardcover API error: ${response.status}`);
}
const json = (await response.json()) as HardcoverGraphQLResponse<T>;
if (json.errors?.length) {
const error = sanitizeLogValue(json.errors[0].message);
this.logger.error(`[hardcover.client] [fail] userId=${userId} errorClass=GraphQLError error="${error}" - GraphQL error`);
throw new Error(`Hardcover GraphQL error: ${json.errors[0].message}`);
}
return json.data as T;
}
}
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED,
ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
AchievementEventsService,
} from '../achievement/achievement-events.service';
import { HardcoverEventListener } from './hardcover-event-listener.service';
const mockSettingsService = {
getSettings: vi.fn(),
isFeatureEnabled: vi.fn(),
};
const mockSyncService = {
syncBook: vi.fn(),
};
const mockRepository = {
findBookIdByFileId: vi.fn().mockResolvedValue(42),
};
function makeListener() {
const events = new AchievementEventsService();
const listener = new HardcoverEventListener(events, mockSettingsService as any, mockSyncService as any, mockRepository as any);
listener.onModuleInit();
return { events, listener };
}
const enabledSettings = {
tokenConfigured: true,
enabled: true,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: 3,
};
describe('HardcoverEventListener', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSettingsService.isFeatureEnabled.mockResolvedValue(true);
mockSyncService.syncBook.mockResolvedValue(undefined);
mockRepository.findBookIdByFileId.mockResolvedValue(42);
});
describe('status changed event', () => {
it('syncs book on status change when enabled', async () => {
mockSettingsService.getSettings.mockResolvedValue(enabledSettings);
const { events } = makeListener();
events.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 10, newStatus: 'reading', previousStatus: 'unread' });
await new Promise((r) => setTimeout(r, 10));
expect(mockSyncService.syncBook).toHaveBeenCalledWith(1, 10);
});
it('skips when feature disabled', async () => {
mockSettingsService.getSettings.mockResolvedValue(enabledSettings);
mockSettingsService.isFeatureEnabled.mockResolvedValue(false);
const { events } = makeListener();
events.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 10, newStatus: 'reading', previousStatus: 'unread' });
await new Promise((r) => setTimeout(r, 10));
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
});
it('skips when token not configured', async () => {
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, tokenConfigured: false });
const { events } = makeListener();
events.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 10, newStatus: 'reading', previousStatus: 'unread' });
await new Promise((r) => setTimeout(r, 10));
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
});
it('skips when autoSyncOnStatusChange disabled', async () => {
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, autoSyncOnStatusChange: false });
const { events } = makeListener();
events.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 10, newStatus: 'reading', previousStatus: 'unread' });
await new Promise((r) => setTimeout(r, 10));
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
});
it('does not throw when sync fails', async () => {
mockSettingsService.getSettings.mockResolvedValue(enabledSettings);
mockSyncService.syncBook.mockRejectedValue(new Error('API error'));
const { events } = makeListener();
events.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 10, newStatus: 'reading', previousStatus: 'unread' });
await new Promise((r) => setTimeout(r, 10));
});
});
describe('reading session saved event', () => {
it('resolves bookId from bookFileId and syncs', async () => {
mockSettingsService.getSettings.mockResolvedValue(enabledSettings);
const { events } = makeListener();
events.emit(ACHIEVEMENT_EVENT_READING_SESSION_SAVED, {
userId: 1,
bookFileId: 5,
durationSeconds: 300,
startedAt: new Date(),
endedAt: new Date(),
progressDelta: 10,
endProgress: 50,
timezone: 'UTC',
});
await new Promise((r) => setTimeout(r, 10));
expect(mockSyncService.syncBook).toHaveBeenCalledWith(1, 42);
});
it('skips when autoSyncOnProgressUpdate disabled', async () => {
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, autoSyncOnProgressUpdate: false });
const { events } = makeListener();
events.emit(ACHIEVEMENT_EVENT_READING_SESSION_SAVED, {
userId: 1,
bookFileId: 5,
durationSeconds: 300,
startedAt: new Date(),
endedAt: new Date(),
progressDelta: 10,
endProgress: 50,
timezone: 'UTC',
});
await new Promise((r) => setTimeout(r, 10));
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
});
});
describe('rating changed event', () => {
it('syncs each book when rating changes', async () => {
mockSettingsService.getSettings.mockResolvedValue(enabledSettings);
const { events } = makeListener();
events.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, { userId: 1, bookIds: [1, 2, 3], rating: 4 });
await new Promise((r) => setTimeout(r, 10));
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(3);
});
it('skips when autoSyncOnRatingChange disabled', async () => {
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, autoSyncOnRatingChange: false });
const { events } = makeListener();
events.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, { userId: 1, bookIds: [1], rating: 4 });
await new Promise((r) => setTimeout(r, 10));
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,83 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED,
ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
AchievementEventsService,
type BookRatingChangedPayload,
type BookStatusChangedPayload,
type ReadingSessionSavedPayload,
} from '../achievement/achievement-events.service';
import { HardcoverRepository } from './hardcover.repository';
import { HardcoverSettingsService } from './hardcover-settings.service';
import { HardcoverSyncService } from './hardcover-sync.service';
@Injectable()
export class HardcoverEventListener implements OnModuleInit {
private readonly logger = new Logger(HardcoverEventListener.name);
constructor(
private readonly achievementEvents: AchievementEventsService,
private readonly settingsService: HardcoverSettingsService,
private readonly syncService: HardcoverSyncService,
private readonly repository: HardcoverRepository,
) {}
onModuleInit() {
this.achievementEvents.on(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, (payload: BookStatusChangedPayload) => {
void this.handleStatusChanged(payload);
});
this.achievementEvents.on(ACHIEVEMENT_EVENT_READING_SESSION_SAVED, (payload: ReadingSessionSavedPayload) => {
void this.handleReadingSessionSaved(payload);
});
this.achievementEvents.on(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, (payload: BookRatingChangedPayload) => {
void this.handleRatingChanged(payload);
});
}
private async handleStatusChanged(payload: BookStatusChangedPayload): Promise<void> {
const settings = await this.settingsService.getSettings(payload.userId);
if (!settings.tokenConfigured || !settings.enabled || !settings.autoSyncOnStatusChange) return;
if (!(await this.settingsService.isFeatureEnabled())) return;
try {
await this.syncService.syncBook(payload.userId, payload.bookId);
} catch {
this.logger.warn(`[hardcover.event_listener] userId=${payload.userId} bookId=${payload.bookId} event=status_changed - sync failed silently`);
}
}
private async handleReadingSessionSaved(payload: ReadingSessionSavedPayload): Promise<void> {
const settings = await this.settingsService.getSettings(payload.userId);
if (!settings.tokenConfigured || !settings.enabled || !settings.autoSyncOnProgressUpdate) return;
if (!(await this.settingsService.isFeatureEnabled())) return;
const bookId = await this.repository.findBookIdByFileId(payload.bookFileId);
if (!bookId) return;
try {
await this.syncService.syncBook(payload.userId, bookId);
} catch {
this.logger.warn(
`[hardcover.event_listener] userId=${payload.userId} bookFileId=${payload.bookFileId} event=reading_session_saved - sync failed silently`,
);
}
}
private async handleRatingChanged(payload: BookRatingChangedPayload): Promise<void> {
const settings = await this.settingsService.getSettings(payload.userId);
if (!settings.tokenConfigured || !settings.enabled || !settings.autoSyncOnRatingChange) return;
if (!(await this.settingsService.isFeatureEnabled())) return;
for (const bookId of payload.bookIds) {
try {
await this.syncService.syncBook(payload.userId, bookId);
} catch {
this.logger.warn(`[hardcover.event_listener] userId=${payload.userId} bookId=${bookId} event=rating_changed - sync failed silently`);
}
}
}
}
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HardcoverQueueService } from './hardcover-queue.service';
import { HARDCOVER_MIN_INTERVAL_MS } from './hardcover.constants';
describe('HardcoverQueueService', () => {
let service: HardcoverQueueService;
beforeEach(() => {
service = new HardcoverQueueService();
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 + HARDCOVER_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,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { HARDCOVER_MIN_INTERVAL_MS } from './hardcover.constants';
@Injectable()
export class HardcoverQueueService {
private readonly logger = new Logger(HardcoverQueueService.name);
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 < HARDCOVER_MIN_INTERVAL_MS) {
const wait = HARDCOVER_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,212 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HardcoverSettingsService } from './hardcover-settings.service';
const mockRepo = {
findSettings: vi.fn(),
upsertSettings: vi.fn(),
deleteSettings: vi.fn(),
};
const mockClient = {
query: vi.fn(),
};
const mockAppSettings = {
isHardcoverEnabled: vi.fn(),
setHardcoverEnabled: vi.fn(),
};
function makeService() {
return new HardcoverSettingsService(mockRepo as any, mockClient as any, mockAppSettings as any);
}
describe('HardcoverSettingsService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('isFeatureEnabled', () => {
it('returns true when enabled', async () => {
mockAppSettings.isHardcoverEnabled.mockResolvedValue(true);
expect(await makeService().isFeatureEnabled()).toBe(true);
});
it('returns false by default', async () => {
mockAppSettings.isHardcoverEnabled.mockResolvedValue(false);
expect(await makeService().isFeatureEnabled()).toBe(false);
});
});
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.privacySettingId).toBe(3);
});
it('returns settings without token when row exists', async () => {
mockRepo.findSettings.mockResolvedValue({
apiToken: 'secret',
enabled: true,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: false,
autoSyncOnRatingChange: true,
privacySettingId: 1,
lastSyncedAt: null,
});
const result = await makeService().getSettings(1);
expect(result.tokenConfigured).toBe(true);
expect(result.enabled).toBe(true);
expect(result.autoSyncOnProgressUpdate).toBe(false);
expect((result as any).apiToken).toBeUndefined();
expect(result.lastSyncedAt).toBeNull();
});
it('returns lastSyncedAt as ISO string when set', async () => {
const syncedAt = new Date('2025-05-01T10:00:00Z');
mockRepo.findSettings.mockResolvedValue({
apiToken: 'secret',
enabled: true,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: 3,
lastSyncedAt: syncedAt,
});
const result = await makeService().getSettings(1);
expect(result.lastSyncedAt).toBe(syncedAt.toISOString());
});
it('returns null lastSyncedAt when no settings row exists', async () => {
mockRepo.findSettings.mockResolvedValue(undefined);
const result = await makeService().getSettings(1);
expect(result.tokenConfigured).toBe(false);
expect(result.lastSyncedAt).toBeNull();
});
});
describe('upsertSettings', () => {
it('throws BadRequestException for invalid privacySettingId', async () => {
await expect(makeService().upsertSettings(1, { privacySettingId: 99 })).rejects.toThrow(BadRequestException);
});
it('throws BadRequestException when setting empty token without existing settings', async () => {
mockRepo.findSettings.mockResolvedValue(undefined);
await expect(makeService().upsertSettings(1, { apiToken: ' ' })).rejects.toThrow(BadRequestException);
});
it('throws BadRequestException when saving settings without token and no existing row', async () => {
mockRepo.findSettings.mockResolvedValue(undefined);
await expect(makeService().upsertSettings(1, { enabled: true })).rejects.toThrow(BadRequestException);
});
it('upserts and returns settings', async () => {
mockRepo.findSettings.mockResolvedValueOnce(undefined).mockResolvedValue({
apiToken: 'token',
enabled: true,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: 3,
});
mockRepo.upsertSettings.mockResolvedValue({});
const result = await makeService().upsertSettings(1, { apiToken: 'token' });
expect(result.tokenConfigured).toBe(true);
expect(mockRepo.upsertSettings).toHaveBeenCalledWith(1, { apiToken: 'token' });
});
it('carries existing token forward when updating settings without a new token', async () => {
const existing = {
apiToken: 'saved-token',
enabled: true,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: 3,
};
mockRepo.findSettings.mockResolvedValue(existing);
mockRepo.upsertSettings.mockResolvedValue({});
await makeService().upsertSettings(1, { enabled: false });
expect(mockRepo.upsertSettings).toHaveBeenCalledWith(1, {
apiToken: 'saved-token',
enabled: false,
});
});
});
describe('disconnectUser', () => {
it('throws NotFoundException when not configured', async () => {
mockRepo.findSettings.mockResolvedValue(undefined);
await expect(makeService().disconnectUser(1)).rejects.toThrow(NotFoundException);
});
it('deletes settings when configured', async () => {
mockRepo.findSettings.mockResolvedValue({ apiToken: 'token' });
mockRepo.deleteSettings.mockResolvedValue(undefined);
await makeService().disconnectUser(1);
expect(mockRepo.deleteSettings).toHaveBeenCalledWith(1);
});
});
describe('validateToken', () => {
it('returns invalid when no settings', async () => {
mockRepo.findSettings.mockResolvedValue(undefined);
expect(await makeService().validateToken(1)).toEqual({ valid: false });
});
it('returns valid with username on success', async () => {
mockRepo.findSettings.mockResolvedValue({ apiToken: 'tok' });
mockClient.query.mockResolvedValue({ me: [{ username: 'neonsolstice' }] });
const result = await makeService().validateToken(1);
expect(result).toEqual({ valid: true, hardcoverUsername: 'neonsolstice' });
});
it('strips Bearer prefix from inline token before querying', async () => {
mockClient.query.mockResolvedValue({ me: [{ username: 'neonsolstice' }] });
await makeService().validateToken(1, 'Bearer myrawtoken');
expect(mockClient.query).toHaveBeenCalledWith(1, 'myrawtoken', expect.any(String));
});
it('strips Bearer prefix when saving via upsertSettings', async () => {
mockRepo.findSettings.mockResolvedValue({ apiToken: 'existing' });
mockRepo.upsertSettings.mockResolvedValue(undefined);
await makeService().upsertSettings(1, { apiToken: 'Bearer myrawtoken' });
expect(mockRepo.upsertSettings).toHaveBeenCalledWith(1, expect.objectContaining({ apiToken: 'myrawtoken' }));
});
it('returns invalid when query throws', async () => {
mockRepo.findSettings.mockResolvedValue({ apiToken: 'tok' });
mockClient.query.mockRejectedValue(new Error('Unauthorized'));
const result = await makeService().validateToken(1);
expect(result).toEqual({ valid: false });
});
it('returns invalid when me is empty array', async () => {
mockRepo.findSettings.mockResolvedValue({ apiToken: 'tok' });
mockClient.query.mockResolvedValue({ me: [] });
const result = await makeService().validateToken(1);
expect(result).toEqual({ valid: false });
});
});
describe('getTokenForUser', () => {
it('returns null when no settings', async () => {
mockRepo.findSettings.mockResolvedValue(undefined);
expect(await makeService().getTokenForUser(1)).toBeNull();
});
it('returns null when disabled', async () => {
mockRepo.findSettings.mockResolvedValue({ apiToken: 'tok', enabled: false });
expect(await makeService().getTokenForUser(1)).toBeNull();
});
it('returns token when enabled', async () => {
mockRepo.findSettings.mockResolvedValue({ apiToken: 'tok', enabled: true });
expect(await makeService().getTokenForUser(1)).toBe('tok');
});
});
});
@@ -0,0 +1,126 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { HardcoverAdminSettings, HardcoverSettings, HardcoverTokenValidationResult, UpsertHardcoverSettingsPayload } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { HARDCOVER_PRIVACY } from './hardcover.constants';
import { HardcoverClientService } from './hardcover-client.service';
import { HardcoverRepository } from './hardcover.repository';
const VALID_PRIVACY_IDS = [HARDCOVER_PRIVACY.PUBLIC, HARDCOVER_PRIVACY.FOLLOWS, HARDCOVER_PRIVACY.PRIVATE];
@Injectable()
export class HardcoverSettingsService {
private readonly logger = new Logger(HardcoverSettingsService.name);
constructor(
private readonly repo: HardcoverRepository,
private readonly client: HardcoverClientService,
private readonly appSettings: AppSettingsService,
) {}
async isFeatureEnabled(): Promise<boolean> {
return this.appSettings.isHardcoverEnabled();
}
async setFeatureEnabled(enabled: boolean): Promise<void> {
await this.appSettings.setHardcoverEnabled(enabled);
this.logger.log(`[hardcover.admin_toggle] [end] enabled=${enabled} - feature toggled`);
}
async getAdminSettings(): Promise<HardcoverAdminSettings> {
return { featureEnabled: await this.isFeatureEnabled() };
}
async getSettings(userId: number): Promise<HardcoverSettings> {
const row = await this.repo.findSettings(userId);
if (!row) {
return {
tokenConfigured: false,
enabled: false,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: HARDCOVER_PRIVACY.PRIVATE,
lastSyncedAt: null,
};
}
return {
tokenConfigured: true,
enabled: row.enabled,
autoSyncOnStatusChange: row.autoSyncOnStatusChange,
autoSyncOnProgressUpdate: row.autoSyncOnProgressUpdate,
autoSyncOnRatingChange: row.autoSyncOnRatingChange,
privacySettingId: row.privacySettingId,
lastSyncedAt: row.lastSyncedAt?.toISOString() ?? null,
};
}
async upsertSettings(userId: number, payload: UpsertHardcoverSettingsPayload): Promise<HardcoverSettings> {
if (payload.privacySettingId !== undefined && !VALID_PRIVACY_IDS.includes(payload.privacySettingId as 1 | 2 | 3)) {
throw new BadRequestException(`Invalid privacySettingId: ${payload.privacySettingId}`);
}
const existing = await this.repo.findSettings(userId);
const rawToken = payload.apiToken !== undefined ? this.stripBearerPrefix(payload.apiToken.trim()) : undefined;
if (!existing && !rawToken) {
throw new BadRequestException('API token is required to connect Hardcover');
}
const data: Parameters<typeof this.repo.upsertSettings>[1] = {};
// Always carry a token into the INSERT side so the NOT NULL constraint is satisfied.
// PostgreSQL checks NOT NULL before ON CONFLICT, so even on a conflict-update path
// the INSERT values must be valid.
data.apiToken = rawToken ?? existing!.apiToken;
if (payload.enabled !== undefined) data.enabled = payload.enabled;
if (payload.autoSyncOnStatusChange !== undefined) data.autoSyncOnStatusChange = payload.autoSyncOnStatusChange;
if (payload.autoSyncOnProgressUpdate !== undefined) data.autoSyncOnProgressUpdate = payload.autoSyncOnProgressUpdate;
if (payload.autoSyncOnRatingChange !== undefined) data.autoSyncOnRatingChange = payload.autoSyncOnRatingChange;
if (payload.privacySettingId !== undefined) data.privacySettingId = payload.privacySettingId;
await this.repo.upsertSettings(userId, data);
return this.getSettings(userId);
}
async disconnectUser(userId: number): Promise<void> {
const existing = await this.repo.findSettings(userId);
if (!existing) throw new NotFoundException('Hardcover integration not configured');
await this.repo.deleteSettings(userId);
this.logger.log(`[hardcover.disconnect] [end] userId=${userId} - user disconnected`);
}
async validateToken(userId: number, token?: string): Promise<HardcoverTokenValidationResult> {
let resolvedToken = this.stripBearerPrefix(token?.trim());
if (!resolvedToken) {
const settings = await this.repo.findSettings(userId);
if (!settings) return { valid: false };
resolvedToken = settings.apiToken;
}
try {
const data = await this.client.query<{ me: { username: string }[] }>(userId, resolvedToken, `query { me { username } }`);
const username = data.me?.[0]?.username;
if (!username) return { valid: false };
return { valid: true, hardcoverUsername: username };
} catch (err) {
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(`[hardcover.validate_token] [fail] userId=${userId} error="${error}" - token validation failed`);
return { valid: false };
}
}
async getTokenForUser(userId: number): Promise<string | null> {
const settings = await this.repo.findSettings(userId);
if (!settings || !settings.enabled) return null;
return settings.apiToken;
}
private stripBearerPrefix(token: string | undefined): string | undefined {
if (!token) return token;
const lower = token.toLowerCase();
return lower.startsWith('bearer ') ? token.slice(7).trim() : token;
}
}
@@ -0,0 +1,337 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HardcoverSyncService } from './hardcover-sync.service';
const mockRepo = {
findBookState: vi.fn(),
findBookStatesByBookIds: vi.fn(),
upsertBookState: vi.fn(),
updateLastSyncedAt: vi.fn(),
findSyncableBooks: vi.fn(),
findSyncableBook: vi.fn(),
};
const mockClient = {
query: vi.fn(),
};
const mockMatchService = {
matchBook: vi.fn(),
};
const mockSettingsService = {
getTokenForUser: vi.fn(),
getSettings: vi.fn(),
};
function makeService() {
return new HardcoverSyncService(mockRepo as any, mockClient as any, mockMatchService as any, mockSettingsService as any);
}
const defaultSettings = {
tokenConfigured: true,
enabled: true,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: 3,
};
const readingBook = {
bookId: 1,
isbn13: '9781234567890',
isbn10: null,
title: 'Book One',
authorName: 'Author One',
hardcoverMetadataId: null,
status: 'reading',
startedAt: new Date('2024-01-01'),
finishedAt: null,
rating: null,
progress: 42,
};
describe('HardcoverSyncService', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRepo.upsertBookState.mockResolvedValue({});
mockRepo.updateLastSyncedAt.mockResolvedValue(undefined);
mockSettingsService.getSettings.mockResolvedValue(defaultSettings);
});
describe('syncBook', () => {
it('does nothing when no token', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue(null);
await makeService().syncBook(1, 1);
expect(mockRepo.findSyncableBook).not.toHaveBeenCalled();
});
it('does nothing when book not found', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue(null);
await makeService().syncBook(1, 1);
expect(mockMatchService.matchBook).not.toHaveBeenCalled();
});
it('does nothing for unread status', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue({ ...readingBook, status: 'unread' });
await makeService().syncBook(1, 1);
expect(mockMatchService.matchBook).not.toHaveBeenCalled();
});
it('stores no_match error when match fails', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
mockMatchService.matchBook.mockResolvedValue(null);
mockRepo.findBookState.mockResolvedValue(null);
await makeService().syncBook(1, 1);
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(
expect.objectContaining({
syncError: 'no_match',
lastSyncedAt: expect.any(Date),
lastSyncedStatus: 'reading',
lastSyncedProgress: 42,
lastSyncedStartedAt: '2024-01-01',
}),
);
});
it('syncs book successfully', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
mockRepo.findBookState.mockResolvedValue(null);
mockMatchService.matchBook.mockResolvedValue({ hardcoverBookId: 10, hardcoverEditionId: 20, matchMethod: 'isbn' });
mockClient.query
.mockResolvedValueOnce({ insert_user_book: { user_book: { id: 55 }, error: null } })
.mockResolvedValueOnce({ update_user_book: { user_book: { id: 55 }, error: null } })
.mockResolvedValueOnce({ user_book_reads: [] })
.mockResolvedValueOnce({ insert_user_book_read: { user_book_read: { id: 77 }, error: null } });
await makeService().syncBook(1, 1);
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(
expect.objectContaining({
hardcoverUserBookId: 55,
hardcoverReadId: 77,
lastSyncedStatus: 'reading',
}),
);
});
it('updates the active unfinished read when cached read id is stale', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
mockRepo.findBookState.mockResolvedValue({ hardcoverReadId: 501 });
mockMatchService.matchBook.mockResolvedValue({ hardcoverBookId: 10, hardcoverEditionId: 20, editionPages: 300, matchMethod: 'cached' });
mockClient.query
.mockResolvedValueOnce({ insert_user_book: { user_book: { id: 55 }, error: null } })
.mockResolvedValueOnce({ update_user_book: { user_book: { id: 55 }, error: null } })
.mockResolvedValueOnce({
user_book_reads: [
{ id: 777, started_at: '2024-01-01', finished_at: null, progress_pages: null },
{ id: 501, started_at: '2024-01-01', finished_at: '2024-01-02', progress_pages: 12 },
],
})
.mockResolvedValueOnce({ update_user_book_read: { user_book_read: { id: 777 }, error: null } });
await makeService().syncBook(1, 1);
expect(mockClient.query).toHaveBeenNthCalledWith(
4,
1,
'tok',
expect.stringContaining('mutation UpdateUserBookRead'),
expect.objectContaining({ id: 777 }),
);
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(expect.objectContaining({ hardcoverReadId: 777 }));
});
it('syncs progress to sibling unfinished reads to avoid page 0 in Hardcover UI', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
mockRepo.findBookState.mockResolvedValue({ hardcoverReadId: 900 });
mockMatchService.matchBook.mockResolvedValue({ hardcoverBookId: 10, hardcoverEditionId: 20, editionPages: 300, matchMethod: 'cached' });
mockClient.query
.mockResolvedValueOnce({ insert_user_book: { user_book: { id: 55 }, error: null } })
.mockResolvedValueOnce({ update_user_book: { user_book: { id: 55 }, error: null } })
.mockResolvedValueOnce({
user_book_reads: [
{ id: 900, started_at: '2024-01-01', finished_at: null, progress_pages: null },
{ id: 899, started_at: '2024-01-01', finished_at: null, progress_pages: null },
],
})
.mockResolvedValueOnce({ update_user_book_read: { user_book_read: { id: 900 }, error: null } })
.mockResolvedValueOnce({ update_user_book_read: { user_book_read: { id: 899 }, error: null } });
await makeService().syncBook(1, 1);
expect(mockClient.query).toHaveBeenNthCalledWith(
5,
1,
'tok',
expect.stringContaining('mutation UpdateUserBookRead'),
expect.objectContaining({ id: 899, object: expect.objectContaining({ progress_pages: 126 }) }),
);
});
it('keeps progress pending when edition pages are unavailable', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
mockRepo.findBookState.mockResolvedValue(null);
mockMatchService.matchBook.mockResolvedValue({ hardcoverBookId: 10, hardcoverEditionId: 20, editionPages: null, matchMethod: 'cached' });
mockClient.query
.mockResolvedValueOnce({ insert_user_book: { user_book: { id: 55 }, error: null } })
.mockResolvedValueOnce({ update_user_book: { user_book: { id: 55 }, error: null } })
.mockResolvedValueOnce({ user_book_reads: [] })
.mockResolvedValueOnce({ insert_user_book_read: { user_book_read: { id: 77 }, error: null } });
await makeService().syncBook(1, 1);
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(expect.objectContaining({ lastSyncedProgress: null }));
});
it('stores error on API failure without throwing', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
mockMatchService.matchBook.mockResolvedValue({ hardcoverBookId: 10, hardcoverEditionId: null, matchMethod: 'isbn' });
mockClient.query.mockRejectedValue(new Error('timeout'));
await makeService().syncBook(1, 1);
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(expect.objectContaining({ syncError: 'timeout' }));
});
it('skips books with no status mapping', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue({ ...readingBook, status: 'invalid_status' });
await makeService().syncBook(1, 1);
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(
expect.objectContaining({
syncError: expect.stringContaining('no_status_mapping'),
lastSyncedAt: expect.any(Date),
lastSyncedStatus: 'invalid_status',
}),
);
});
});
describe('syncAll', () => {
it('returns existing run id if already running', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBooks.mockResolvedValue([readingBook]);
mockRepo.findBookState.mockReturnValue(new Promise(() => {})); // blocks runSyncAll
const svc = makeService();
const id1 = await svc.syncAll(1);
const id2 = await svc.syncAll(1);
expect(id1).toBe(id2);
expect(mockRepo.findSyncableBooks).toHaveBeenCalledTimes(1);
});
it('creates in-memory run and returns run id', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBooks.mockResolvedValue([]);
const id = await makeService().syncAll(1);
expect(id).toBeGreaterThan(0);
});
it('returns 0 when no token', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue(null);
const id = await makeService().syncAll(1);
expect(id).toBe(0);
});
it('calls updateLastSyncedAt on successful completion', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBooks.mockResolvedValue([]);
const svc = makeService();
await svc.syncAll(1);
await Promise.resolve(); // flush runSyncAll microtasks
await Promise.resolve();
expect(mockRepo.updateLastSyncedAt).toHaveBeenCalledWith(1, expect.any(Date));
});
it('clears active sync and does not call updateLastSyncedAt when runSyncAll crashes', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBooks.mockResolvedValue([readingBook]);
mockRepo.findBookState.mockRejectedValue(new Error('DB crash'));
const svc = makeService();
await svc.syncAll(1);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(svc.getSyncStatus(1)).toBeNull();
expect(mockRepo.updateLastSyncedAt).not.toHaveBeenCalled();
});
});
describe('getSyncStatus', () => {
it('returns null when no active run', () => {
expect(makeService().getSyncStatus(1)).toBeNull();
});
it('returns status after syncAll is called', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBooks.mockResolvedValue([readingBook]);
mockRepo.findBookState.mockReturnValue(new Promise(() => {})); // blocks runSyncAll
const svc = makeService();
await svc.syncAll(1);
const result = svc.getSyncStatus(1);
expect(result).not.toBeNull();
expect(result?.status).toBe('running');
});
});
describe('getSyncPendingSummary', () => {
it('returns zero when user has no token', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue(null);
const result = await makeService().getSyncPendingSummary(1);
expect(result).toEqual({ totalBooks: 0, pendingBooks: 0 });
expect(mockRepo.findSyncableBooks).not.toHaveBeenCalled();
});
it('counts only books with unsynced changes', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBooks.mockResolvedValue([
{ ...readingBook, bookId: 10, progress: 42 },
{ ...readingBook, bookId: 11, progress: 88 },
]);
mockRepo.findBookStatesByBookIds.mockResolvedValue([
{
bookId: 10,
lastSyncedAt: new Date('2024-02-01T00:00:00Z'),
lastSyncedStatus: 'reading',
lastSyncedProgress: 42,
lastSyncedRating: null,
lastSyncedStartedAt: '2024-01-01',
lastSyncedFinishedAt: null,
},
{
bookId: 11,
lastSyncedAt: new Date('2024-02-01T00:00:00Z'),
lastSyncedStatus: 'reading',
lastSyncedProgress: 10,
lastSyncedRating: null,
lastSyncedStartedAt: '2024-01-01',
lastSyncedFinishedAt: null,
},
]);
const result = await makeService().getSyncPendingSummary(1);
expect(result).toEqual({ totalBooks: 2, pendingBooks: 1 });
expect(mockRepo.findBookStatesByBookIds).toHaveBeenCalledWith(1, [10, 11]);
});
});
describe('cancelSync', () => {
it('does nothing if no active run', () => {
makeService().cancelSync(1);
expect(mockRepo.updateLastSyncedAt).not.toHaveBeenCalled();
});
it('clears active run when cancelled', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBooks.mockResolvedValue([]);
const svc = makeService();
await svc.syncAll(1);
svc.cancelSync(1);
expect(svc.getSyncStatus(1)).toBeNull();
});
});
});
@@ -0,0 +1,577 @@
import type { ReadStatus } from '@bookorbit/types';
import type { HardcoverActiveSyncStatus, HardcoverSyncPendingSummary } from '@bookorbit/types';
import { Injectable, Logger } from '@nestjs/common';
import { distinctUntilChanged, filter, map, merge, Observable, of, Subject } from 'rxjs';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { HARDCOVER_STATUS } from './hardcover.constants';
import { HardcoverBookMatchService } from './hardcover-book-match.service';
import { HardcoverClientService } from './hardcover-client.service';
import { type BookSyncData, HardcoverRepository } from './hardcover.repository';
import { HardcoverSettingsService } from './hardcover-settings.service';
const STATUS_MAP: Partial<Record<ReadStatus, number>> = {
want_to_read: HARDCOVER_STATUS.WANT_TO_READ,
reading: HARDCOVER_STATUS.CURRENTLY_READING,
rereading: HARDCOVER_STATUS.CURRENTLY_READING,
on_hold: HARDCOVER_STATUS.PAUSED,
read: HARDCOVER_STATUS.READ,
skimmed: HARDCOVER_STATUS.READ,
abandoned: HARDCOVER_STATUS.DNF,
};
const INSERT_USER_BOOK_MUTATION = `
mutation InsertUserBook($object: UserBookCreateInput!) {
insert_user_book(object: $object) {
user_book {
id
}
error
}
}`;
const FIND_USER_BOOK_QUERY = `
query FindUserBook($bookId: Int!) {
me {
user_books(where: { book_id: { _eq: $bookId } }, limit: 1) {
id
}
}
}`;
const UPDATE_USER_BOOK_MUTATION = `
mutation UpdateUserBook($id: Int!, $object: UserBookUpdateInput!) {
update_user_book(id: $id, object: $object) {
user_book {
id
}
error
}
}`;
const INSERT_USER_BOOK_READ_MUTATION = `
mutation InsertUserBookRead($userBookId: Int!, $object: DatesReadInput!) {
insert_user_book_read(user_book_id: $userBookId, user_book_read: $object) {
user_book_read {
id
}
error
}
}`;
const UPDATE_USER_BOOK_READ_MUTATION = `
mutation UpdateUserBookRead($id: Int!, $object: DatesReadInput!) {
update_user_book_read(id: $id, object: $object) {
user_book_read {
id
}
error
}
}`;
const FIND_USER_BOOK_READS_QUERY = `
query FindUserBookReads($userBookId: Int!) {
user_book_reads(where: { user_book_id: { _eq: $userBookId } }, order_by: [{ id: desc }], limit: 20) {
id
started_at
finished_at
progress_pages
}
}`;
type InsertUserBookResult = { insert_user_book: { user_book: { id: number } | null; error: string | null } };
type UpdateUserBookResult = { update_user_book: { user_book: { id: number } | null; error: string | null } };
type FindUserBookResult = { me: { user_books: { id: number }[] } };
type InsertUserBookReadResult = { insert_user_book_read: { user_book_read: { id: number } | null; error: string | null } };
type UpdateUserBookReadResult = { update_user_book_read: { user_book_read: { id: number } | null; error: string | null } };
type FindUserBookReadsResult = {
user_book_reads: Array<{ id: number; started_at: string | null; finished_at: string | null; progress_pages: number | null }>;
};
function toDateString(d: Date | null | undefined): string | null {
if (!d) return null;
return d instanceof Date ? d.toISOString().split('T')[0]! : null;
}
@Injectable()
export class HardcoverSyncService {
private readonly logger = new Logger(HardcoverSyncService.name);
private readonly cancelRequests = new Set<number>();
private readonly syncStatusEvents = new Subject<{ userId: number; status: HardcoverActiveSyncStatus | null }>();
private readonly activeSyncs = new Map<number, HardcoverActiveSyncStatus>();
private syncRunCounter = 0;
constructor(
private readonly repo: HardcoverRepository,
private readonly client: HardcoverClientService,
private readonly matchService: HardcoverBookMatchService,
private readonly settingsService: HardcoverSettingsService,
) {}
async syncBook(userId: number, bookId: number): Promise<void> {
const token = await this.settingsService.getTokenForUser(userId);
if (!token) return;
const book = await this.repo.findSyncableBook(userId, bookId);
if (!book) return;
if (book.status === 'unread') return;
await this.syncSingleBook(userId, token, book);
}
async syncAll(userId: number): Promise<number> {
const token = await this.settingsService.getTokenForUser(userId);
if (!token) return 0;
const existing = this.activeSyncs.get(userId);
if (existing) {
this.logger.warn(`[hardcover.sync_all] userId=${userId} runId=${existing.runId} - sync already running`);
this.emitSyncStatus(userId, existing);
return existing.runId;
}
const books = await this.repo.findSyncableBooks(userId);
// Re-check: a concurrent syncAll may have won the race during findSyncableBooks
const recheck = this.activeSyncs.get(userId);
if (recheck) {
this.logger.warn(`[hardcover.sync_all] userId=${userId} runId=${recheck.runId} - sync started concurrently`);
this.emitSyncStatus(userId, recheck);
return recheck.runId;
}
const runId = ++this.syncRunCounter;
const status: HardcoverActiveSyncStatus = {
runId,
syncedBooks: 0,
totalBooks: books.length,
status: 'running',
};
this.activeSyncs.set(userId, status);
this.logger.log(`[hardcover.sync_all] [start] userId=${userId} runId=${runId} totalBooks=${books.length} - sync all started`);
this.emitSyncStatus(userId, status);
this.runSyncAll(userId, token, books, runId).catch((err) => {
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.error(
`[hardcover.sync_all] [fail] userId=${userId} runId=${runId} errorClass=${err?.constructor?.name ?? 'Error'} error="${error}" - sync all crashed`,
);
this.cancelRequests.delete(userId);
this.activeSyncs.delete(userId);
this.emitSyncStatus(userId, null);
});
return runId;
}
cancelSync(userId: number): void {
const run = this.activeSyncs.get(userId);
if (!run) return;
this.cancelRequests.add(userId);
this.activeSyncs.delete(userId);
this.emitSyncStatus(userId, null);
this.logger.log(`[hardcover.sync_all] userId=${userId} runId=${run.runId} - sync cancelled`);
}
getSyncStatus(userId: number): HardcoverActiveSyncStatus | null {
return this.activeSyncs.get(userId) ?? null;
}
streamSyncStatus(userId: number): Observable<HardcoverActiveSyncStatus | null> {
return merge(
of(this.getSyncStatus(userId)),
this.syncStatusEvents.pipe(
filter((event) => event.userId === userId),
map((event) => event.status),
),
).pipe(distinctUntilChanged((prev, next) => this.isSameActiveStatus(prev, next)));
}
async getSyncPendingSummary(userId: number): Promise<HardcoverSyncPendingSummary> {
const token = await this.settingsService.getTokenForUser(userId);
if (!token) {
return { totalBooks: 0, pendingBooks: 0 };
}
const books = await this.repo.findSyncableBooks(userId);
if (books.length === 0) {
return { totalBooks: 0, pendingBooks: 0 };
}
const states = await this.repo.findBookStatesByBookIds(
userId,
books.map((book) => book.bookId),
);
const stateByBookId = new Map(states.map((state) => [state.bookId, state]));
let pendingBooks = 0;
for (const book of books) {
if (book.status === 'unread') continue;
if (this.hasChanges(book, stateByBookId.get(book.bookId))) {
pendingBooks++;
}
}
return {
totalBooks: books.length,
pendingBooks,
};
}
private async runSyncAll(userId: number, token: string, books: BookSyncData[], runId: number): Promise<void> {
const startedAt = Date.now();
let synced = 0;
let failed = 0;
let skipped = 0;
for (const book of books) {
if (this.cancelRequests.has(userId)) {
this.cancelRequests.delete(userId);
this.logger.log(`[hardcover.sync_all] userId=${userId} runId=${runId} - cancelled mid-run`);
return;
}
if (book.status === 'unread') {
skipped++;
this.emitProgress(userId, synced);
continue;
}
const state = await this.repo.findBookState(userId, book.bookId);
if (!this.hasChanges(book, state)) {
skipped++;
this.emitProgress(userId, synced);
continue;
}
const result = await this.syncSingleBook(userId, token, book);
if (result === 'synced') synced++;
else if (result === 'skipped') skipped++;
else failed++;
this.emitProgress(userId, synced);
}
// Handle cancel requested after the last book was processed (loop exited without hitting the top check)
if (this.cancelRequests.has(userId)) {
this.cancelRequests.delete(userId);
this.logger.log(`[hardcover.sync_all] userId=${userId} runId=${runId} - cancelled after last book`);
this.activeSyncs.delete(userId);
return;
}
const durationMs = Date.now() - startedAt;
this.logger.log(
`[hardcover.sync_all] [end] userId=${userId} runId=${runId} durationMs=${durationMs} syncedBooks=${synced} failedBooks=${failed} skippedBooks=${skipped} - sync all completed`,
);
await this.repo.updateLastSyncedAt(userId, new Date());
this.activeSyncs.delete(userId);
this.emitSyncStatus(userId, null);
}
private emitProgress(userId: number, synced: number): void {
const activeStatus = this.activeSyncs.get(userId);
if (activeStatus) activeStatus.syncedBooks = synced;
this.emitSyncStatus(userId, activeStatus ?? null);
}
private async syncSingleBook(userId: number, token: string, book: BookSyncData): Promise<'synced' | 'skipped' | 'failed'> {
const startedAt = Date.now();
const settings = await this.settingsService.getSettings(userId);
const hardcoverStatusId = STATUS_MAP[book.status as ReadStatus];
if (!hardcoverStatusId) {
await this.repo.upsertBookState({
userId,
bookId: book.bookId,
syncError: `no_status_mapping:${book.status}`,
...this.buildAttemptSnapshot(book),
});
return 'skipped';
}
const match = await this.matchService.matchBook(userId, token, book);
if (!match) {
const state = await this.repo.findBookState(userId, book.bookId);
await this.repo.upsertBookState({
userId,
bookId: book.bookId,
hardcoverBookId: state?.hardcoverBookId ?? null,
syncError: 'no_match',
...this.buildAttemptSnapshot(book),
});
return 'skipped';
}
const hardcoverRating = book.rating ?? null;
try {
const userBookId = await this.insertOrFindUserBook(userId, token, {
bookId: match.hardcoverBookId,
statusId: hardcoverStatusId,
privacySettingId: settings.privacySettingId,
editionId: match.hardcoverEditionId ?? undefined,
});
await this.updateUserBookFields(userId, token, userBookId, {
statusId: hardcoverStatusId,
privacySettingId: settings.privacySettingId,
rating: hardcoverRating,
editionId: match.hardcoverEditionId ?? undefined,
});
const state = await this.repo.findBookState(userId, book.bookId);
const startDate = toDateString(book.startedAt);
const endDate = toDateString(book.finishedAt);
let hardcoverReadId: number | null = state?.hardcoverReadId ?? null;
let progressSynced = true;
if (startDate || endDate || book.progress != null) {
const progressPages = book.progress != null && match.editionPages ? Math.round((book.progress / 100) * match.editionPages) : undefined;
progressSynced = book.progress == null || progressPages != null;
const reads = await this.findUserBookReads(userId, token, userBookId);
const targetReadId = this.resolveTargetReadId(hardcoverReadId, reads);
hardcoverReadId = await this.upsertUserBookRead(userId, token, {
userBookId,
existingReadId: targetReadId,
startedAt: startDate,
finishedAt: endDate,
progressPages,
editionId: match.hardcoverEditionId ?? undefined,
});
if (progressPages != null && hardcoverReadId != null) {
await this.updateAdditionalOpenReads(userId, token, {
reads,
primaryReadId: hardcoverReadId,
startedAt: startDate,
finishedAt: endDate,
progressPages,
editionId: match.hardcoverEditionId ?? undefined,
});
}
}
await this.repo.upsertBookState({
userId,
bookId: book.bookId,
hardcoverBookId: match.hardcoverBookId,
hardcoverEditionId: match.hardcoverEditionId,
hardcoverUserBookId: userBookId,
hardcoverReadId,
matchMethod: match.matchMethod,
matchError: null,
syncError: null,
lastSyncedAt: new Date(),
lastSyncedStatus: book.status,
lastSyncedProgress: progressSynced ? book.progress : null,
lastSyncedRating: book.rating,
lastSyncedStartedAt: startDate,
lastSyncedFinishedAt: endDate,
});
this.logger.log(
`[hardcover.sync_book] [end] userId=${userId} bookId=${book.bookId} hardcoverBookId=${match.hardcoverBookId} hardcoverEditionId=${match.hardcoverEditionId ?? null} hardcoverUserBookId=${userBookId} hardcoverReadId=${hardcoverReadId ?? null} durationMs=${Date.now() - startedAt} matchMethod=${match.matchMethod} statusId=${hardcoverStatusId} rating=${hardcoverRating ?? null} - synced`,
);
return 'synced';
} catch (err) {
const errorClass = err instanceof Error ? err.constructor.name : 'Error';
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.error(
`[hardcover.sync_book] [fail] userId=${userId} bookId=${book.bookId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${error}" - sync failed`,
);
await this.repo.upsertBookState({
userId,
bookId: book.bookId,
hardcoverBookId: match.hardcoverBookId,
matchMethod: match.matchMethod,
syncError: error,
});
return 'failed';
}
}
private async insertOrFindUserBook(
userId: number,
token: string,
input: { bookId: number; statusId: number; privacySettingId: number; editionId?: number },
): Promise<number> {
const insertResult = await this.client.query<InsertUserBookResult>(userId, token, INSERT_USER_BOOK_MUTATION, {
object: {
book_id: input.bookId,
status_id: input.statusId,
privacy_setting_id: input.privacySettingId,
...(input.editionId != null && { edition_id: input.editionId }),
},
});
const userBookId = insertResult.insert_user_book?.user_book?.id;
if (userBookId) return userBookId;
const findResult = await this.client.query<FindUserBookResult>(userId, token, FIND_USER_BOOK_QUERY, {
bookId: input.bookId,
});
const existingId = findResult.me?.user_books?.[0]?.id;
if (!existingId) throw new Error(`Book ${input.bookId} not found in Hardcover library after insert`);
return existingId;
}
private async updateUserBookFields(
userId: number,
token: string,
userBookId: number,
fields: { statusId: number; privacySettingId: number; rating: number | null; editionId?: number },
): Promise<void> {
try {
const result = await this.client.query<UpdateUserBookResult>(userId, token, UPDATE_USER_BOOK_MUTATION, {
id: userBookId,
object: {
status_id: fields.statusId,
privacy_setting_id: fields.privacySettingId,
...(fields.rating != null && { rating: fields.rating }),
...(fields.editionId != null && { edition_id: fields.editionId }),
},
});
if (result.update_user_book?.error) {
throw new Error(result.update_user_book.error);
}
} catch (err) {
this.logger.warn(
`[hardcover.sync_book] update_user_book failed for userBookId=${userBookId}: ${sanitizeLogValue(err instanceof Error ? err.message : String(err))}`,
);
}
}
private async upsertUserBookRead(
userId: number,
token: string,
input: {
userBookId: number;
existingReadId: number | null;
startedAt?: string | null;
finishedAt?: string | null;
progressPages?: number;
editionId?: number;
},
): Promise<number | null> {
const object: Record<string, unknown> = {};
if (input.startedAt) object['started_at'] = input.startedAt;
if (input.finishedAt) object['finished_at'] = input.finishedAt;
if (input.progressPages != null) object['progress_pages'] = input.progressPages;
if (input.editionId != null) object['edition_id'] = input.editionId;
if (input.existingReadId) {
return this.updateUserBookRead(userId, token, input.existingReadId, object);
}
const result = await this.client.query<InsertUserBookReadResult>(userId, token, INSERT_USER_BOOK_READ_MUTATION, {
userBookId: input.userBookId,
object,
});
if (result.insert_user_book_read?.error) {
throw new Error(result.insert_user_book_read.error);
}
return result.insert_user_book_read?.user_book_read?.id ?? null;
}
private async updateUserBookRead(userId: number, token: string, readId: number, object: Record<string, unknown>): Promise<number> {
const result = await this.client.query<UpdateUserBookReadResult>(userId, token, UPDATE_USER_BOOK_READ_MUTATION, {
id: readId,
object,
});
if (result.update_user_book_read?.error) {
throw new Error(result.update_user_book_read.error);
}
return result.update_user_book_read?.user_book_read?.id ?? readId;
}
private async findUserBookReads(userId: number, token: string, userBookId: number): Promise<FindUserBookReadsResult['user_book_reads']> {
try {
const result = await this.client.query<FindUserBookReadsResult>(userId, token, FIND_USER_BOOK_READS_QUERY, { userBookId });
return result.user_book_reads ?? [];
} catch {
return [];
}
}
private resolveTargetReadId(existingReadId: number | null, reads: FindUserBookReadsResult['user_book_reads']): number | null {
const openRead = reads.find((read) => !read.finished_at);
if (openRead) return openRead.id;
if (existingReadId) return existingReadId;
return reads[0]?.id ?? null;
}
private async updateAdditionalOpenReads(
userId: number,
token: string,
input: {
reads: FindUserBookReadsResult['user_book_reads'];
primaryReadId: number;
startedAt?: string | null;
finishedAt?: string | null;
progressPages: number;
editionId?: number;
},
): Promise<void> {
const siblingReadIds = input.reads.filter((read) => read.id !== input.primaryReadId && !read.finished_at).map((read) => read.id);
if (siblingReadIds.length === 0) return;
await Promise.all(
siblingReadIds.map((readId) =>
this.updateUserBookRead(userId, token, readId, {
...(input.startedAt ? { started_at: input.startedAt } : {}),
...(input.finishedAt ? { finished_at: input.finishedAt } : {}),
progress_pages: input.progressPages,
...(input.editionId != null ? { edition_id: input.editionId } : {}),
}).catch(() => undefined),
),
);
}
private hasChanges(book: BookSyncData, state: Awaited<ReturnType<HardcoverRepository['findBookState']>>): boolean {
if (!state?.lastSyncedAt) return true;
if (book.status !== state.lastSyncedStatus) return true;
if (book.progress !== state.lastSyncedProgress) return true;
if (book.rating !== state.lastSyncedRating) return true;
const startDate = toDateString(book.startedAt);
const endDate = toDateString(book.finishedAt);
if (startDate !== state.lastSyncedStartedAt) return true;
if (endDate !== state.lastSyncedFinishedAt) return true;
return false;
}
private buildAttemptSnapshot(book: BookSyncData) {
return {
lastSyncedAt: new Date(),
lastSyncedStatus: book.status,
lastSyncedProgress: book.progress,
lastSyncedRating: book.rating,
lastSyncedStartedAt: toDateString(book.startedAt),
lastSyncedFinishedAt: toDateString(book.finishedAt),
};
}
private isSameActiveStatus(prev: HardcoverActiveSyncStatus | null, next: HardcoverActiveSyncStatus | null): boolean {
if (prev === next) return true;
if (!prev || !next) return false;
return prev.runId === next.runId && prev.status === next.status && prev.syncedBooks === next.syncedBooks && prev.totalBooks === next.totalBooks;
}
private emitSyncStatus(userId: number, status: HardcoverActiveSyncStatus | null): void {
this.syncStatusEvents.next({ userId, status });
}
}
@@ -0,0 +1,22 @@
export const HARDCOVER_GRAPHQL_URL = 'https://api.hardcover.app/v1/graphql';
export const HARDCOVER_MIN_INTERVAL_MS = 1100;
export const HARDCOVER_MAX_RETRIES = 3;
export const HARDCOVER_STATUS = {
WANT_TO_READ: 1,
CURRENTLY_READING: 2,
READ: 3,
PAUSED: 4,
DNF: 5,
IGNORED: 6,
} as const;
export const HARDCOVER_PRIVACY = {
PUBLIC: 1,
FOLLOWS: 2,
PRIVATE: 3,
} as const;
export const HARDCOVER_EVENT = {
RATING_CHANGED: 'hardcover.rating_changed',
} as const;
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { of } from 'rxjs';
import { HardcoverController } from './hardcover.controller';
const mockSettingsService = {
getSettings: vi.fn(),
upsertSettings: vi.fn(),
disconnectUser: vi.fn(),
validateToken: vi.fn(),
};
const mockSyncService = {
syncAll: vi.fn(),
cancelSync: vi.fn(),
getSyncStatus: vi.fn(),
streamSyncStatus: vi.fn(),
getSyncPendingSummary: vi.fn(),
};
const mockUser = { id: 1, isSuperuser: false, permissions: [] };
function makeController() {
return new HardcoverController(mockSettingsService as any, mockSyncService as any);
}
describe('HardcoverController', () => {
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(1);
});
it('upsertSettings delegates to service', async () => {
mockSettingsService.upsertSettings.mockResolvedValue({ tokenConfigured: true });
const result = await makeController().upsertSettings(mockUser as any, { apiToken: 'tok' });
expect(result).toEqual({ tokenConfigured: true });
});
it('disconnectUser delegates to service', async () => {
mockSettingsService.disconnectUser.mockResolvedValue(undefined);
await makeController().disconnectUser(mockUser as any);
expect(mockSettingsService.disconnectUser).toHaveBeenCalledWith(1);
});
it('validateToken delegates to service', async () => {
mockSettingsService.validateToken.mockResolvedValue({ valid: true, hardcoverUsername: 'neon' });
const result = await makeController().validateToken(mockUser as any, {});
expect(result).toEqual({ valid: true, hardcoverUsername: 'neon' });
});
it('startSync returns runId', async () => {
mockSyncService.syncAll.mockResolvedValue(42);
const result = await makeController().startSync(mockUser as any);
expect(result).toEqual({ runId: 42 });
});
it('getSyncStatus delegates to service', () => {
mockSyncService.getSyncStatus.mockReturnValue(null);
const result = makeController().getSyncStatus(mockUser as any);
expect(result).toBeNull();
});
it('getSyncStatusStream delegates to service', async () => {
mockSyncService.streamSyncStatus.mockReturnValue(of(null));
const stream = makeController().getSyncStatusStream(mockUser as any);
const emitted = await new Promise((resolve) => stream.subscribe((event) => resolve(event)));
expect(emitted).toEqual({ data: { activeSyncStatus: null } });
expect(mockSyncService.streamSyncStatus).toHaveBeenCalledWith(1);
});
it('getSyncPendingSummary delegates to service', async () => {
mockSyncService.getSyncPendingSummary.mockResolvedValue({ totalBooks: 10, pendingBooks: 2 });
const result = await makeController().getSyncPendingSummary(mockUser as any);
expect(result).toEqual({ totalBooks: 10, pendingBooks: 2 });
expect(mockSyncService.getSyncPendingSummary).toHaveBeenCalledWith(1);
});
});
@@ -0,0 +1,61 @@
import { Body, Controller, Delete, Get, MessageEvent, Patch, Post, Sse } from '@nestjs/common';
import { map, Observable } from 'rxjs';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { UpsertHardcoverSettingsDto, ValidateHardcoverTokenDto } from './dto';
import { HardcoverSettingsService } from './hardcover-settings.service';
import { HardcoverSyncService } from './hardcover-sync.service';
@Controller('hardcover')
export class HardcoverController {
constructor(
private readonly settingsService: HardcoverSettingsService,
private readonly syncService: HardcoverSyncService,
) {}
@Get('settings')
getSettings(@CurrentUser() user: RequestUser) {
return this.settingsService.getSettings(user.id);
}
@Patch('settings')
upsertSettings(@CurrentUser() user: RequestUser, @Body() dto: UpsertHardcoverSettingsDto) {
return this.settingsService.upsertSettings(user.id, dto);
}
@Delete('settings')
disconnectUser(@CurrentUser() user: RequestUser) {
return this.settingsService.disconnectUser(user.id);
}
@Post('validate-token')
validateToken(@CurrentUser() user: RequestUser, @Body() dto: ValidateHardcoverTokenDto) {
return this.settingsService.validateToken(user.id, dto.token);
}
@Post('sync')
startSync(@CurrentUser() user: RequestUser) {
return this.syncService.syncAll(user.id).then((runId) => ({ runId }));
}
@Delete('sync')
cancelSync(@CurrentUser() user: RequestUser) {
return this.syncService.cancelSync(user.id);
}
@Get('sync/status')
getSyncStatus(@CurrentUser() user: RequestUser) {
return this.syncService.getSyncStatus(user.id);
}
@Sse('sync/stream')
getSyncStatusStream(@CurrentUser() user: RequestUser): Observable<MessageEvent> {
return this.syncService.streamSyncStatus(user.id).pipe(map((status) => ({ data: { activeSyncStatus: status } })));
}
@Get('sync/pending')
getSyncPendingSummary(@CurrentUser() user: RequestUser) {
return this.syncService.getSyncPendingSummary(user.id);
}
}
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { AchievementModule } from '../achievement/achievement.module';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { HardcoverAdminController } from './hardcover-admin.controller';
import { HardcoverBookMatchService } from './hardcover-book-match.service';
import { HardcoverClientService } from './hardcover-client.service';
import { HardcoverController } from './hardcover.controller';
import { HardcoverEventListener } from './hardcover-event-listener.service';
import { HardcoverQueueService } from './hardcover-queue.service';
import { HardcoverRepository } from './hardcover.repository';
import { HardcoverSettingsService } from './hardcover-settings.service';
import { HardcoverSyncService } from './hardcover-sync.service';
@Module({
imports: [AchievementModule, AppSettingsModule],
controllers: [HardcoverController, HardcoverAdminController],
providers: [
HardcoverQueueService,
HardcoverClientService,
HardcoverRepository,
HardcoverSettingsService,
HardcoverBookMatchService,
HardcoverSyncService,
HardcoverEventListener,
],
exports: [HardcoverSyncService, HardcoverSettingsService],
})
export class HardcoverModule {}
@@ -0,0 +1,153 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { HardcoverRepository } from './hardcover.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 makeWhereChain(result: unknown) {
return {
where: vi.fn().mockResolvedValue(result),
};
}
function makeRepository() {
const settingsRow = { id: 1, userId: 7, apiToken: 'tok', enabled: true };
const bookStateRow = { id: 2, userId: 7, bookId: 42, hardcoverBookId: 99 };
const settingsQuery = { findFirst: vi.fn().mockResolvedValue(settingsRow) };
const bookStateQuery = {
findFirst: vi.fn().mockResolvedValue(bookStateRow),
findMany: vi.fn().mockResolvedValue([bookStateRow]),
};
const settingsInsert = makeReturningChain(settingsRow);
const bookStateInsert = makeReturningChain(bookStateRow);
const deleteChain = makeWhereChain(undefined);
const updateChain = { set: vi.fn().mockReturnValue(makeWhereChain(undefined)) };
const bookIdLimit = vi.fn().mockResolvedValue([{ bookId: 42 }]);
const bookIdWhere = vi.fn().mockReturnValue({ limit: bookIdLimit });
const bookIdFrom = vi.fn().mockReturnValue({ where: bookIdWhere });
const db = {
query: {
hardcoverUserSettings: settingsQuery,
hardcoverBookState: bookStateQuery,
},
insert: vi.fn().mockReturnValueOnce(settingsInsert).mockReturnValueOnce(bookStateInsert),
delete: vi.fn().mockReturnValue(deleteChain),
update: vi.fn().mockReturnValue(updateChain),
select: vi.fn().mockReturnValue({ from: bookIdFrom }),
};
return {
repo: new HardcoverRepository(db as never),
db,
settingsQuery,
bookStateQuery,
settingsInsert,
bookStateInsert,
deleteChain,
updateChain,
bookIdLimit,
bookIdWhere,
settingsRow,
bookStateRow,
};
}
describe('HardcoverRepository', () => {
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({ set: expect.objectContaining({ apiToken: 'tok' }) }));
});
it('deleteSettings deletes settings for a user', async () => {
const { repo, deleteChain } = makeRepository();
await repo.deleteSettings(7);
expect(deleteChain.where).toHaveBeenCalledTimes(1);
});
it('findBookState returns one book state row', async () => {
const { repo, bookStateQuery, bookStateRow } = makeRepository();
await expect(repo.findBookState(7, 42)).resolves.toEqual(bookStateRow);
expect(bookStateQuery.findFirst).toHaveBeenCalledTimes(1);
});
it('findBookStatesByBookIds short-circuits for an empty list', async () => {
const { repo, bookStateQuery } = makeRepository();
await expect(repo.findBookStatesByBookIds(7, [])).resolves.toEqual([]);
expect(bookStateQuery.findMany).not.toHaveBeenCalled();
});
it('findBookStatesByBookIds returns matching state rows', async () => {
const { repo, bookStateQuery, bookStateRow } = makeRepository();
await expect(repo.findBookStatesByBookIds(7, [42])).resolves.toEqual([bookStateRow]);
expect(bookStateQuery.findMany).toHaveBeenCalledTimes(1);
});
it('upsertBookState inserts or updates per-book state', async () => {
const { repo, db, bookStateInsert, bookStateRow } = makeRepository();
db.insert.mockReset();
db.insert.mockReturnValue(bookStateInsert);
await expect(repo.upsertBookState({ userId: 7, bookId: 42, hardcoverBookId: 99 })).resolves.toEqual(bookStateRow);
expect(bookStateInsert.values).toHaveBeenCalledWith({ userId: 7, bookId: 42, hardcoverBookId: 99 });
expect(bookStateInsert.onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({ set: expect.objectContaining({ hardcoverBookId: 99 }) }),
);
});
it('updateLastSyncedAt updates the settings timestamp', async () => {
const { repo, updateChain } = makeRepository();
const syncedAt = new Date('2026-01-01T00:00:00Z');
await repo.updateLastSyncedAt(7, syncedAt);
expect(updateChain.set).toHaveBeenCalledWith({ lastSyncedAt: syncedAt });
});
it('findSyncableBook returns a book from findSyncableBooks', async () => {
const { repo } = makeRepository();
vi.spyOn(repo, 'findSyncableBooks').mockResolvedValue([{ bookId: 42 } as any]);
await expect(repo.findSyncableBook(7, 42)).resolves.toEqual({ bookId: 42 });
await expect(repo.findSyncableBook(7, 99)).resolves.toBeNull();
});
it('findBookIdByFileId returns the first matching book id', async () => {
const { repo, bookIdLimit, bookIdWhere } = makeRepository();
await expect(repo.findBookIdByFileId(5)).resolves.toBe(42);
expect(bookIdLimit).toHaveBeenCalledWith(1);
expect(bookIdWhere).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,153 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, inArray, 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 { HardcoverBookState, HardcoverUserSetting, NewHardcoverBookState, NewHardcoverUserSetting } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
export interface BookSyncData {
bookId: number;
isbn13: string | null;
isbn10: string | null;
title: string | null;
authorName: string | null;
hardcoverMetadataId: string | null;
status: string;
startedAt: Date | null;
finishedAt: Date | null;
rating: number | null;
progress: number | null;
}
@Injectable()
export class HardcoverRepository {
constructor(@Inject(DB) private readonly db: Db) {}
// ---- User Settings ----
async findSettings(userId: number): Promise<HardcoverUserSetting | undefined> {
return this.db.query.hardcoverUserSettings.findFirst({
where: eq(schema.hardcoverUserSettings.userId, userId),
});
}
async upsertSettings(
userId: number,
data: Partial<Omit<HardcoverUserSetting, 'id' | 'userId' | 'createdAt' | 'updatedAt'>>,
): Promise<HardcoverUserSetting> {
const [row] = await this.db
.insert(schema.hardcoverUserSettings)
.values({ userId, ...data } as NewHardcoverUserSetting)
.onConflictDoUpdate({
target: schema.hardcoverUserSettings.userId,
set: { ...data, updatedAt: new Date() },
})
.returning();
return row!;
}
async deleteSettings(userId: number): Promise<void> {
await this.db.delete(schema.hardcoverUserSettings).where(eq(schema.hardcoverUserSettings.userId, userId));
}
// ---- Book State ----
async findBookState(userId: number, bookId: number): Promise<HardcoverBookState | undefined> {
return this.db.query.hardcoverBookState.findFirst({
where: and(eq(schema.hardcoverBookState.userId, userId), eq(schema.hardcoverBookState.bookId, bookId)),
});
}
async findBookStatesByBookIds(userId: number, bookIds: number[]): Promise<HardcoverBookState[]> {
if (bookIds.length === 0) return [];
return this.db.query.hardcoverBookState.findMany({
where: and(eq(schema.hardcoverBookState.userId, userId), inArray(schema.hardcoverBookState.bookId, bookIds)),
});
}
async upsertBookState(data: NewHardcoverBookState): Promise<HardcoverBookState> {
const [row] = await this.db
.insert(schema.hardcoverBookState)
.values(data)
.onConflictDoUpdate({
target: [schema.hardcoverBookState.userId, schema.hardcoverBookState.bookId],
set: { ...data, updatedAt: new Date() },
})
.returning();
return row!;
}
// ---- Sync Settings ----
async updateLastSyncedAt(userId: number, at: Date): Promise<void> {
await this.db.update(schema.hardcoverUserSettings).set({ lastSyncedAt: at }).where(eq(schema.hardcoverUserSettings.userId, userId));
}
// ---- Books for sync ----
async findSyncableBooks(userId: number): Promise<BookSyncData[]> {
const maxProgressSq = this.db
.select({
bookId: schema.books.id,
maxProgress: sql<number>`max(${schema.readingProgress.percentage})`.as('max_progress'),
})
.from(schema.books)
.innerJoin(schema.bookFiles, eq(schema.bookFiles.bookId, schema.books.id))
.innerJoin(schema.readingProgress, and(eq(schema.readingProgress.bookFileId, schema.bookFiles.id), eq(schema.readingProgress.userId, userId)))
.groupBy(schema.books.id)
.as('max_progress_sq');
const firstAuthorSq = this.db
.select({
bookId: schema.bookAuthors.bookId,
authorName: sql<string>`min(${schema.authors.name})`.as('author_name'),
})
.from(schema.bookAuthors)
.innerJoin(schema.authors, eq(schema.authors.id, schema.bookAuthors.authorId))
.groupBy(schema.bookAuthors.bookId)
.as('first_author_sq');
const rows = await this.db
.select({
bookId: schema.books.id,
isbn13: schema.bookMetadata.isbn13,
isbn10: schema.bookMetadata.isbn10,
title: schema.bookMetadata.title,
authorName: firstAuthorSq.authorName,
hardcoverMetadataId: schema.bookMetadata.hardcoverId,
status: schema.userBookStatus.status,
startedAt: schema.userBookStatus.startedAt,
finishedAt: schema.userBookStatus.finishedAt,
rating: schema.userBookRatings.rating,
progress: maxProgressSq.maxProgress,
})
.from(schema.books)
.innerJoin(
schema.userBookStatus,
and(eq(schema.userBookStatus.bookId, schema.books.id), eq(schema.userBookStatus.userId, userId), ne(schema.userBookStatus.status, 'unread')),
)
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
.leftJoin(schema.userBookRatings, and(eq(schema.userBookRatings.bookId, schema.books.id), eq(schema.userBookRatings.userId, userId)))
.leftJoin(maxProgressSq, eq(maxProgressSq.bookId, schema.books.id))
.leftJoin(firstAuthorSq, eq(firstAuthorSq.bookId, schema.books.id));
return rows as BookSyncData[];
}
async findSyncableBook(userId: number, bookId: number): Promise<BookSyncData | null> {
const rows = await this.findSyncableBooks(userId);
return rows.find((r) => r.bookId === bookId) ?? null;
}
async findBookIdByFileId(bookFileId: number): Promise<number | null> {
const [row] = await this.db
.select({ bookId: schema.bookFiles.bookId })
.from(schema.bookFiles)
.where(eq(schema.bookFiles.id, bookFileId))
.limit(1);
return row?.bookId ?? null;
}
}
+1
View File
@@ -40,6 +40,7 @@ const DEFAULT_APP_SETTINGS: Array<typeof schema.appSettings.$inferInsert> = [
{ key: APP_SETTING_KEYS.AUDIT_RETENTION_DAYS, value: String(DEFAULT_AUDIT_RETENTION_DAYS) },
{ key: APP_SETTING_KEYS.OIDC_CONFIG, value: JSON.stringify(DEFAULT_OIDC_CONFIG) },
{ key: APP_SETTING_KEYS.UPDATE_CHECK_ENABLED, value: 'true' },
{ key: APP_SETTING_KEYS.HARDCOVER_ENABLED, value: 'false' },
];
@Injectable()