mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -38,3 +38,4 @@ export * from "./dictionary";
|
||||
export * from "./achievement";
|
||||
export * from "./reading-session";
|
||||
export * from "./annotation";
|
||||
export * from "./hardcover";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user