fix(koreader): trigger hardcover auto-sync on progress update

KOReader progress updates were updating the database but failing to
emit the progress changed event, causing Hardcover auto-sync to be
skipped. This change adds the missing event emission to the KOReader
service so that external syncs trigger correctly, and patches a query
filter issue in the Hardcover repository.

Closes #258
This commit is contained in:
neon
2026-06-06 18:01:45 +00:00
parent 04a4fdd0eb
commit feec3c794a
35 changed files with 11463 additions and 384 deletions
@@ -1,6 +1,5 @@
import { api } from '@/lib/api'
import type {
HardcoverAdminSettings,
HardcoverActiveSyncStatus,
HardcoverSyncPendingSummary,
HardcoverSettings,
@@ -97,18 +96,3 @@ export async function fetchHardcoverSyncPendingSummary(): Promise<HardcoverSyncP
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')
}
@@ -164,7 +164,7 @@ const privacyOptions = [
<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>
<p class="text-xs text-muted-foreground mt-0.5">Push reading progress and dates when BookOrbit or KOReader progress changes.</p>
</div>
<ToggleSwitch v-model="form.autoSyncOnProgressUpdate" :disabled="!form.enabled" />
</div>
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RefreshCw, XCircle, Loader2 } from 'lucide-vue-next'
import { AlertCircle, 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 { activeSyncStatus, syncing, isSyncing, syncProgress, pendingSummary, loadingPending, error, startSync, cancelSync } = useHardcoverSync()
const { settings } = useHardcoverSettings()
const progressLabel = computed(() => {
@@ -14,8 +14,21 @@ const progressLabel = computed(() => {
})
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 syncUnavailableReason = computed(() => {
switch (settings.value?.disabledReason) {
case 'permission_denied':
return 'You do not have permission to use Hardcover sync.'
case 'user_disabled':
return 'Sync is paused in your Hardcover settings.'
default:
return null
}
})
const syncButtonDisabled = computed(() => syncing.value || loadingPending.value || !hasPending.value || syncUnavailableReason.value !== null)
const syncButtonLabel = computed(() => {
if (syncUnavailableReason.value) return 'Sync unavailable'
return hasPending.value ? `Sync now (${pendingSummary.value.pendingBooks})` : 'Sync now'
})
const lastSyncedLabel = computed(() => {
const iso = settings.value?.lastSyncedAt
@@ -39,11 +52,21 @@ const lastSyncedLabel = computed(() => {
<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="hasPending">
{{ pendingSummary.pendingBooks }} pending of {{ pendingSummary.totalBooks }} books.
<span v-if="syncUnavailableReason">{{ syncUnavailableReason }}</span>
</template>
<template v-else-if="syncUnavailableReason">{{ syncUnavailableReason }}</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>
<p v-if="lastSyncedLabel && !isSyncing" class="text-xs text-muted-foreground mt-0.5">
{{ syncUnavailableReason ? 'Last successful sync' : 'Last synced' }}: {{ lastSyncedLabel }}
</p>
<p v-if="error && !isSyncing" class="flex items-center gap-1 text-xs text-destructive mt-1">
<AlertCircle class="size-3.5" />
{{ error }}
</p>
</div>
<div class="flex items-center gap-2">
@@ -18,6 +18,8 @@ const mockValidate = vi.mocked(validateHardcoverToken)
const SETTINGS: HardcoverSettings = {
tokenConfigured: true,
enabled: true,
effectiveEnabled: true,
disabledReason: null,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
@@ -33,6 +33,18 @@ const RUNNING_STATUS: HardcoverActiveSyncStatus = {
syncedBooks: 3,
}
const SETTINGS: HardcoverSettings = {
tokenConfigured: true,
enabled: true,
effectiveEnabled: true,
disabledReason: null,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: 3,
lastSyncedAt: null,
}
async function loadComposable() {
const { useHardcoverSync } = await import('../useHardcoverSync')
return useHardcoverSync()
@@ -46,7 +58,7 @@ describe('useHardcoverSync', () => {
mockStatus.mockResolvedValue(null)
mockPendingSummary.mockResolvedValue({ totalBooks: 0, pendingBooks: 0 })
mockStream.mockResolvedValue(undefined)
mockFetchSettings.mockResolvedValue(null as unknown as HardcoverSettings)
mockFetchSettings.mockResolvedValue(SETTINGS)
})
afterEach(() => {
@@ -75,6 +87,15 @@ describe('useHardcoverSync', () => {
expect(c.error.value).toBe('Server busy')
})
it('startSync does not mark a run active when the server reports no run', async () => {
mockStart.mockResolvedValue({ runId: 0 })
const c = await loadComposable()
await c.startSync()
expect(c.activeSyncStatus.value).toBeNull()
expect(c.error.value).toBe('Hardcover sync is not available right now')
expect(mockFetchSettings).toHaveBeenCalled()
})
it('cancelSync clears status and stops stream tracking', async () => {
mockCancel.mockResolvedValue(undefined)
const c = await loadComposable()
@@ -69,6 +69,11 @@ export function useHardcoverSync() {
error.value = null
try {
const { runId } = await startHardcoverSync()
if (runId <= 0) {
error.value = 'Hardcover sync is not available right now'
await Promise.all([fetchPendingSummary(), useHardcoverSettings().fetchSettings()])
return
}
activeSyncStatus.value = {
runId,
status: 'running',
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Check, RefreshCw, Sparkles, ArrowUpFromLine, CheckCircle2, AlertCircle, Loader2, Bell, Award, BookHeart } from 'lucide-vue-next'
import { Check, RefreshCw, Sparkles, ArrowUpFromLine, CheckCircle2, AlertCircle, Loader2, Bell, Award } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
@@ -22,8 +22,6 @@ 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)
@@ -55,8 +53,6 @@ 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
@@ -97,27 +93,6 @@ 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
@@ -352,24 +327,5 @@ 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>
+4 -4
View File
@@ -1,6 +1,10 @@
export type HardcoverSyncDisabledReason = "permission_denied" | "missing_token" | "user_disabled";
export interface HardcoverSettings {
tokenConfigured: boolean;
enabled: boolean;
effectiveEnabled: boolean;
disabledReason: HardcoverSyncDisabledReason | null;
autoSyncOnStatusChange: boolean;
autoSyncOnProgressUpdate: boolean;
autoSyncOnRatingChange: boolean;
@@ -8,10 +12,6 @@ export interface HardcoverSettings {
lastSyncedAt: string | null;
}
export interface HardcoverAdminSettings {
featureEnabled: boolean;
}
export interface UpsertHardcoverSettingsPayload {
apiToken?: string;
enabled?: boolean;
@@ -23,7 +23,6 @@ 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;
@@ -0,0 +1 @@
ALTER TABLE "hardcover_book_state" ALTER COLUMN "match_method" SET DATA TYPE varchar(20);
File diff suppressed because it is too large Load Diff
@@ -113,6 +113,13 @@
"when": 1780678523024,
"tag": "0015_add_kobo_id",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1780768608213,
"tag": "0016_widen_hardcover_match_method",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -37,7 +37,7 @@ export const hardcoverBookState = pgTable(
hardcoverEditionId: integer('hardcover_edition_id'),
hardcoverUserBookId: integer('hardcover_user_book_id'),
hardcoverReadId: integer('hardcover_read_id'),
matchMethod: varchar('match_method', { length: 10 }),
matchMethod: varchar('match_method', { length: 20 }),
matchError: text('match_error'),
lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }),
lastSyncedStatus: varchar('last_synced_status', { length: 20 }),
@@ -9,6 +9,7 @@ export const ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED = 'library.catalog-change
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 const ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED = 'book.progress-changed';
export interface ReadingSessionSavedPayload {
userId: number;
@@ -50,13 +51,22 @@ export interface BookRatingChangedPayload {
rating: number | null;
}
export interface BookProgressChangedPayload {
userId: number;
bookId: number;
bookFileId: number;
progress: number;
source: 'koreader' | 'web_reader';
}
export type AchievementEventPayload =
| ReadingSessionSavedPayload
| BookStatusChangedPayload
| AnnotationCreatedPayload
| CollectionCreatedPayload
| LibraryCatalogChangedPayload
| BookRatingChangedPayload;
| BookRatingChangedPayload
| BookProgressChangedPayload;
@Injectable()
export class AchievementEventsService extends EventEmitter {}
@@ -238,15 +238,6 @@ 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 {
@@ -34,8 +34,3 @@ export class ValidateHardcoverTokenDto {
@MaxLength(2048)
token?: string;
}
export class SetHardcoverFeatureEnabledDto {
@IsBoolean()
enabled!: boolean;
}
@@ -1,33 +0,0 @@
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);
});
});
@@ -1,23 +0,0 @@
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,224 @@
import { Logger } from '@nestjs/common';
import type { HardcoverSettings } from '@bookorbit/types';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
HardcoverAutoSyncSchedulerService,
HARDCOVER_AUTO_SYNC_DEBOUNCE_MS,
type HardcoverAutoSyncReason,
} from './hardcover-auto-sync-scheduler.service';
import { HardcoverRepository } from './hardcover.repository';
import { HardcoverSettingsService } from './hardcover-settings.service';
import { HardcoverSyncService } from './hardcover-sync.service';
const mockSettingsService = {
getSettings: vi.fn(),
};
const mockSyncService = {
syncBook: vi.fn(),
};
const mockRepository = {
findBookIdByFileId: vi.fn(),
};
const enabledSettings: HardcoverSettings = {
tokenConfigured: true,
enabled: true,
effectiveEnabled: true,
disabledReason: null,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: 3,
lastSyncedAt: null,
};
function makeDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function advanceDebounce(ms = HARDCOVER_AUTO_SYNC_DEBOUNCE_MS) {
await vi.advanceTimersByTimeAsync(ms);
}
async function flushPromises() {
await Promise.resolve();
await Promise.resolve();
}
describe('HardcoverAutoSyncSchedulerService', () => {
let service: HardcoverAutoSyncSchedulerService;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mockSettingsService.getSettings.mockResolvedValue(enabledSettings);
mockSyncService.syncBook.mockResolvedValue(undefined);
mockRepository.findBookIdByFileId.mockResolvedValue(42);
warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
service = new HardcoverAutoSyncSchedulerService(
mockSettingsService as unknown as HardcoverSettingsService,
mockSyncService as unknown as HardcoverSyncService,
mockRepository as unknown as HardcoverRepository,
);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('coalesces status and progress events for the same user-book into one sync', async () => {
service.requestSync({ userId: 1, bookId: 10, reason: 'status' });
await vi.advanceTimersByTimeAsync(500);
service.requestSync({ userId: 1, bookId: 10, reason: 'progress' });
await vi.advanceTimersByTimeAsync(HARDCOVER_AUTO_SYNC_DEBOUNCE_MS - 1);
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(mockSettingsService.getSettings).toHaveBeenCalledTimes(1);
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
expect(mockSyncService.syncBook).toHaveBeenCalledWith(1, 10);
});
it.each([
['status', 'autoSyncOnStatusChange'],
['progress', 'autoSyncOnProgressUpdate'],
['rating', 'autoSyncOnRatingChange'],
] as Array<[HardcoverAutoSyncReason, keyof typeof enabledSettings]>)(
'skips %s sync when the matching toggle is disabled',
async (reason, settingKey) => {
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, [settingKey]: false });
service.requestSync({ userId: 1, bookId: 10, reason });
await advanceDebounce();
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
},
);
it('syncs when at least one coalesced reason has an enabled toggle', async () => {
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, autoSyncOnProgressUpdate: false });
service.requestSync({ userId: 1, bookId: 10, reason: 'progress' });
service.requestSync({ userId: 1, bookId: 10, reason: 'status' });
await advanceDebounce();
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
expect(mockSyncService.syncBook).toHaveBeenCalledWith(1, 10);
});
it('does not resolve reading-session book files when progress auto-sync is disabled', async () => {
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, autoSyncOnProgressUpdate: false });
service.requestSyncForBookFile({ userId: 1, bookFileId: 5, reason: 'progress' });
await flushPromises();
expect(mockRepository.findBookIdByFileId).not.toHaveBeenCalled();
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
});
it('resolves reading-session book files after settings pass and schedules progress sync', async () => {
service.requestSyncForBookFile({ userId: 1, bookFileId: 5, reason: 'progress' });
await flushPromises();
expect(mockRepository.findBookIdByFileId).toHaveBeenCalledWith(5);
await advanceDebounce();
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
expect(mockSyncService.syncBook).toHaveBeenCalledWith(1, 42);
});
it.each([
['permission denied', { effectiveEnabled: false, disabledReason: 'permission_denied' }],
['token missing', { tokenConfigured: false, effectiveEnabled: false, disabledReason: 'missing_token' }],
['integration disabled', { enabled: false, effectiveEnabled: false, disabledReason: 'user_disabled' }],
] as Array<[string, Partial<typeof enabledSettings>]>)('skips when %s', async (_name, settingsPatch) => {
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, ...settingsPatch });
service.requestSync({ userId: 1, bookId: 10, reason: 'progress' });
await advanceDebounce();
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
});
it('schedules one follow-up sync when a request arrives while the same user-book is in flight', async () => {
const firstSync = makeDeferred<void>();
mockSyncService.syncBook.mockReturnValueOnce(firstSync.promise).mockResolvedValueOnce(undefined);
service.requestSync({ userId: 1, bookId: 10, reason: 'progress' });
await advanceDebounce();
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
service.requestSync({ userId: 1, bookId: 10, reason: 'status' });
await advanceDebounce();
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
firstSync.resolve();
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(HARDCOVER_AUTO_SYNC_DEBOUNCE_MS - 1);
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(2);
expect(mockSyncService.syncBook).toHaveBeenNthCalledWith(2, 1, 10);
});
it('serializes syncs for different books belonging to the same user', async () => {
const firstSync = makeDeferred<void>();
mockSyncService.syncBook.mockReturnValueOnce(firstSync.promise).mockResolvedValueOnce(undefined);
service.requestSync({ userId: 1, bookId: 10, reason: 'rating' });
service.requestSync({ userId: 1, bookId: 11, reason: 'rating' });
await advanceDebounce();
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
expect(mockSyncService.syncBook).toHaveBeenCalledWith(1, 10);
firstSync.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(2);
expect(mockSyncService.syncBook).toHaveBeenNthCalledWith(2, 1, 11);
});
it('allows different users to sync independently', async () => {
const firstSync = makeDeferred<void>();
mockSyncService.syncBook.mockReturnValueOnce(firstSync.promise).mockResolvedValueOnce(undefined);
service.requestSync({ userId: 1, bookId: 10, reason: 'rating' });
service.requestSync({ userId: 2, bookId: 20, reason: 'rating' });
await advanceDebounce();
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(2);
expect(mockSyncService.syncBook).toHaveBeenNthCalledWith(1, 1, 10);
expect(mockSyncService.syncBook).toHaveBeenNthCalledWith(2, 2, 20);
firstSync.resolve();
});
it('swallows sync failures and logs a sanitized warning', async () => {
mockSyncService.syncBook.mockRejectedValue(new Error('API "bad"\nline'));
service.requestSync({ userId: 1, bookId: 10, reason: 'progress' });
await advanceDebounce();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[hardcover.auto_sync] [fail] userId=1 bookId=10 reasons=progress'));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('errorClass=Error error="API \\"bad\\" line"'));
});
});
@@ -0,0 +1,179 @@
import { Injectable, Logger } from '@nestjs/common';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { HardcoverRepository } from './hardcover.repository';
import { HardcoverSettingsService } from './hardcover-settings.service';
import { HardcoverSyncService } from './hardcover-sync.service';
export const HARDCOVER_AUTO_SYNC_DEBOUNCE_MS = 1000;
export type HardcoverAutoSyncReason = 'status' | 'progress' | 'rating';
type HardcoverSettingsSnapshot = Awaited<ReturnType<HardcoverSettingsService['getSettings']>>;
interface AutoSyncRequest {
userId: number;
bookId: number;
reason: HardcoverAutoSyncReason;
}
interface AutoSyncBookFileRequest {
userId: number;
bookFileId: number;
reason: Extract<HardcoverAutoSyncReason, 'progress'>;
}
interface AutoSyncState {
key: string;
userId: number;
bookId: number;
reasons: Set<HardcoverAutoSyncReason>;
timer: ReturnType<typeof setTimeout> | null;
inFlight: boolean;
}
const AUTO_SYNC_EVENT = 'hardcover.auto_sync';
const REASON_ORDER: HardcoverAutoSyncReason[] = ['status', 'progress', 'rating'];
@Injectable()
export class HardcoverAutoSyncSchedulerService {
private readonly logger = new Logger(HardcoverAutoSyncSchedulerService.name);
private readonly states = new Map<string, AutoSyncState>();
private readonly userSyncQueues = new Map<number, Promise<void>>();
constructor(
private readonly settingsService: HardcoverSettingsService,
private readonly syncService: HardcoverSyncService,
private readonly repository: HardcoverRepository,
) {}
requestSync(request: AutoSyncRequest): void {
const state = this.getOrCreateState(request.userId, request.bookId);
state.reasons.add(request.reason);
if (state.inFlight) return;
this.schedule(state);
}
requestSyncForBookFile(request: AutoSyncBookFileRequest): void {
void this.resolveBookFileAndRequestSync(request);
}
private getOrCreateState(userId: number, bookId: number): AutoSyncState {
const key = `${userId}:${bookId}`;
const existing = this.states.get(key);
if (existing) return existing;
const state: AutoSyncState = {
key,
userId,
bookId,
reasons: new Set(),
timer: null,
inFlight: false,
};
this.states.set(key, state);
return state;
}
private schedule(state: AutoSyncState): void {
if (state.timer) clearTimeout(state.timer);
state.timer = setTimeout(() => {
state.timer = null;
void this.runDueSync(state.key);
}, HARDCOVER_AUTO_SYNC_DEBOUNCE_MS);
}
private async runDueSync(key: string): Promise<void> {
const state = this.states.get(key);
if (!state || state.inFlight || state.reasons.size === 0) return;
const reasons = new Set(state.reasons);
state.reasons.clear();
state.inFlight = true;
try {
await this.runSync(state.userId, state.bookId, reasons);
} finally {
state.inFlight = false;
if (state.reasons.size > 0) {
this.schedule(state);
} else {
this.states.delete(key);
}
}
}
private async runSync(userId: number, bookId: number, reasons: Set<HardcoverAutoSyncReason>): Promise<void> {
const startedAt = Date.now();
const formattedReasons = this.formatReasons(reasons);
await this.enqueueUserSync(userId, async () => {
try {
if (!(await this.isAutoSyncEnabled(userId, reasons))) return;
await this.syncService.syncBook(userId, bookId);
} catch (err) {
this.logFailure({ userId, bookId, reasons: formattedReasons, startedAt, err });
}
});
}
private async resolveBookFileAndRequestSync(request: AutoSyncBookFileRequest): Promise<void> {
const startedAt = Date.now();
const reasons = new Set<HardcoverAutoSyncReason>([request.reason]);
const formattedReasons = this.formatReasons(reasons);
try {
if (!(await this.isAutoSyncEnabled(request.userId, reasons))) return;
const bookId = await this.repository.findBookIdByFileId(request.bookFileId);
if (!bookId) return;
this.requestSync({ userId: request.userId, bookId, reason: request.reason });
} catch (err) {
this.logFailure({ userId: request.userId, bookFileId: request.bookFileId, reasons: formattedReasons, startedAt, err });
}
}
private enqueueUserSync(userId: number, task: () => Promise<void>): Promise<void> {
const previous = this.userSyncQueues.get(userId) ?? Promise.resolve();
const current = previous.catch(() => undefined).then(task);
this.userSyncQueues.set(userId, current);
current
.finally(() => {
if (this.userSyncQueues.get(userId) === current) {
this.userSyncQueues.delete(userId);
}
})
.catch(() => undefined);
return current;
}
private async isAutoSyncEnabled(userId: number, reasons: Set<HardcoverAutoSyncReason>): Promise<boolean> {
const settings = await this.settingsService.getSettings(userId);
return settings.effectiveEnabled && this.hasEnabledReason(settings, reasons);
}
private hasEnabledReason(settings: HardcoverSettingsSnapshot, reasons: Set<HardcoverAutoSyncReason>): boolean {
if (reasons.has('status') && settings.autoSyncOnStatusChange) return true;
if (reasons.has('progress') && settings.autoSyncOnProgressUpdate) return true;
if (reasons.has('rating') && settings.autoSyncOnRatingChange) return true;
return false;
}
private formatReasons(reasons: Set<HardcoverAutoSyncReason>): string {
return REASON_ORDER.filter((reason) => reasons.has(reason)).join(',');
}
private logFailure(input: { userId: number; bookId?: number; bookFileId?: number; reasons: string; startedAt: number; err: unknown }): void {
const errorClass = input.err instanceof Error ? input.err.constructor.name : 'Error';
const error = sanitizeLogValue(input.err instanceof Error ? input.err.message : String(input.err));
const bookField = input.bookId !== undefined ? `bookId=${input.bookId}` : `bookFileId=${input.bookFileId}`;
this.logger.warn(
`[${AUTO_SYNC_EVENT}] [fail] userId=${input.userId} ${bookField} reasons=${input.reasons} durationMs=${Date.now() - input.startedAt} errorClass=${errorClass} error="${error}" - auto-sync failed`,
);
}
}
@@ -100,6 +100,22 @@ describe('HardcoverBookMatchService', () => {
expect(result?.hardcoverBookId).toBe(77);
});
it('matches by metadata slug when hardcoverMetadataId is not numeric', async () => {
mockRepo.findBookState.mockResolvedValue(undefined);
mockClient.query.mockResolvedValue({ books: [{ id: 686104, editions: [{ id: 30673405, pages: 382 }] }] });
const book = { ...baseBook, hardcoverMetadataId: 'fyrebirds', isbn13: null };
const result = await makeService().matchBook(1, 'tok', book);
expect(result).toEqual({ hardcoverBookId: 686104, hardcoverEditionId: 30673405, editionPages: 382, matchMethod: 'metadata_id' });
expect(mockClient.query).toHaveBeenCalledWith(
1,
'tok',
expect.stringContaining('query FindBookBySlug'),
expect.objectContaining({ slug: 'fyrebirds' }),
);
});
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 }] }] });
@@ -70,6 +70,17 @@ query FindBookById($id: Int!) {
}
}`;
const FIND_BOOK_BY_HARDCOVER_SLUG_QUERY = `
query FindBookBySlug($slug: String!) {
books(where: { slug: { _eq: $slug } }, 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) {
@@ -133,6 +144,8 @@ export class HardcoverBookMatchService {
const id = parseInt(book.hardcoverMetadataId, 10);
if (!isNaN(id)) {
match = await this.matchByHardcoverId(userId, token, id, book.bookId);
} else {
match = await this.matchByHardcoverSlug(userId, token, book.hardcoverMetadataId, book.bookId);
}
}
@@ -189,6 +202,26 @@ export class HardcoverBookMatchService {
}
}
private async matchByHardcoverSlug(userId: number, token: string, slug: string, bookId: number): Promise<HardcoverBookMatch | null> {
try {
const data = await this.client.query<BooksQueryResult>(userId, token, FIND_BOOK_BY_HARDCOVER_SLUG_QUERY, { slug });
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_slug error="${error}" - metadata_slug 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 {
@@ -1,144 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED,
ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
AchievementEventsService,
} from '../achievement/achievement-events.service';
import { HardcoverAutoSyncSchedulerService } from './hardcover-auto-sync-scheduler.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),
const mockScheduler = {
requestSync: vi.fn(),
requestSyncForBookFile: vi.fn(),
};
function makeListener() {
const events = new AchievementEventsService();
const listener = new HardcoverEventListener(events, mockSettingsService as any, mockSyncService as any, mockRepository as any);
const listener = new HardcoverEventListener(events, mockScheduler as unknown as HardcoverAutoSyncSchedulerService);
listener.onModuleInit();
return { events, listener };
return { events };
}
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('schedules status auto-sync on status change', () => {
const { events } = makeListener();
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();
});
events.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 10, newStatus: 'reading', previousStatus: 'unread' });
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));
});
expect(mockScheduler.requestSync).toHaveBeenCalledWith({ userId: 1, bookId: 10, reason: 'status' });
});
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('schedules file progress auto-sync for reading sessions', () => {
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',
});
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();
});
expect(mockScheduler.requestSyncForBookFile).toHaveBeenCalledWith({ userId: 1, bookFileId: 5, reason: 'progress' });
});
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('schedules progress auto-sync on book progress changes', () => {
const { events } = makeListener();
events.emit(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, {
userId: 1,
bookId: 10,
bookFileId: 5,
progress: 40,
source: 'koreader',
});
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();
});
expect(mockScheduler.requestSync).toHaveBeenCalledWith({ userId: 1, bookId: 10, reason: 'progress' });
});
it('schedules rating auto-sync for each affected book', () => {
const { events } = makeListener();
events.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, { userId: 1, bookIds: [1, 2, 3], rating: 4 });
expect(mockScheduler.requestSync).toHaveBeenCalledTimes(3);
expect(mockScheduler.requestSync).toHaveBeenNthCalledWith(1, { userId: 1, bookId: 1, reason: 'rating' });
expect(mockScheduler.requestSync).toHaveBeenNthCalledWith(2, { userId: 1, bookId: 2, reason: 'rating' });
expect(mockScheduler.requestSync).toHaveBeenNthCalledWith(3, { userId: 1, bookId: 3, reason: 'rating' });
});
});
@@ -1,27 +1,23 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import {
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED,
ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
AchievementEventsService,
type BookProgressChangedPayload,
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';
import { HardcoverAutoSyncSchedulerService } from './hardcover-auto-sync-scheduler.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,
private readonly scheduler: HardcoverAutoSyncSchedulerService,
) {}
onModuleInit() {
@@ -30,54 +26,33 @@ export class HardcoverEventListener implements OnModuleInit {
});
this.achievementEvents.on(ACHIEVEMENT_EVENT_READING_SESSION_SAVED, (payload: ReadingSessionSavedPayload) => {
void this.handleReadingSessionSaved(payload);
this.handleReadingSessionSaved(payload);
});
this.achievementEvents.on(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, (payload: BookProgressChangedPayload) => {
this.handleProgressChanged(payload);
});
this.achievementEvents.on(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, (payload: BookRatingChangedPayload) => {
void this.handleRatingChanged(payload);
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 handleStatusChanged(payload: BookStatusChangedPayload): void {
this.scheduler.requestSync({ userId: payload.userId, bookId: payload.bookId, reason: 'status' });
}
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 handleReadingSessionSaved(payload: ReadingSessionSavedPayload): void {
this.scheduler.requestSyncForBookFile({ userId: payload.userId, bookFileId: payload.bookFileId, reason: 'progress' });
}
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;
private handleProgressChanged(payload: BookProgressChangedPayload): void {
this.scheduler.requestSync({ userId: payload.userId, bookId: payload.bookId, reason: 'progress' });
}
private handleRatingChanged(payload: BookRatingChangedPayload): void {
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`);
}
this.scheduler.requestSync({ userId: payload.userId, bookId, reason: 'rating' });
}
}
}
@@ -5,6 +5,7 @@ import { HardcoverSettingsService } from './hardcover-settings.service';
const mockRepo = {
findSettings: vi.fn(),
userHasHardcoverSyncPermission: vi.fn(),
upsertSettings: vi.fn(),
deleteSettings: vi.fn(),
};
@@ -13,30 +14,14 @@ 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);
return new HardcoverSettingsService(mockRepo as any, mockClient 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);
});
mockRepo.userHasHardcoverSyncPermission.mockResolvedValue(true);
});
describe('getSettings', () => {
@@ -45,6 +30,8 @@ describe('HardcoverSettingsService', () => {
const result = await makeService().getSettings(1);
expect(result.tokenConfigured).toBe(false);
expect(result.enabled).toBe(false);
expect(result.effectiveEnabled).toBe(false);
expect(result.disabledReason).toBe('missing_token');
expect(result.privacySettingId).toBe(3);
});
@@ -61,11 +48,49 @@ describe('HardcoverSettingsService', () => {
const result = await makeService().getSettings(1);
expect(result.tokenConfigured).toBe(true);
expect(result.enabled).toBe(true);
expect(result.effectiveEnabled).toBe(true);
expect(result.disabledReason).toBeNull();
expect(result.autoSyncOnProgressUpdate).toBe(false);
expect((result as any).apiToken).toBeUndefined();
expect(result.lastSyncedAt).toBeNull();
});
it('returns user disabled effective state when configured sync is paused', async () => {
mockRepo.findSettings.mockResolvedValue({
apiToken: 'secret',
enabled: false,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: 3,
lastSyncedAt: null,
});
const result = await makeService().getSettings(1);
expect(result.enabled).toBe(false);
expect(result.effectiveEnabled).toBe(false);
expect(result.disabledReason).toBe('user_disabled');
});
it('returns permission denied effective state when the user lacks Hardcover sync permission', async () => {
mockRepo.userHasHardcoverSyncPermission.mockResolvedValue(false);
mockRepo.findSettings.mockResolvedValue({
apiToken: 'secret',
enabled: true,
autoSyncOnStatusChange: true,
autoSyncOnProgressUpdate: true,
autoSyncOnRatingChange: true,
privacySettingId: 3,
lastSyncedAt: null,
});
const result = await makeService().getSettings(1);
expect(result.effectiveEnabled).toBe(false);
expect(result.disabledReason).toBe('permission_denied');
});
it('returns lastSyncedAt as ISO string when set', async () => {
const syncedAt = new Date('2025-05-01T10:00:00Z');
mockRepo.findSettings.mockResolvedValue({
@@ -199,6 +224,12 @@ describe('HardcoverSettingsService', () => {
expect(await makeService().getTokenForUser(1)).toBeNull();
});
it('returns null when the user lacks Hardcover sync permission', async () => {
mockRepo.userHasHardcoverSyncPermission.mockResolvedValue(false);
mockRepo.findSettings.mockResolvedValue({ apiToken: 'tok', enabled: true });
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();
@@ -1,9 +1,13 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { HardcoverAdminSettings, HardcoverSettings, HardcoverTokenValidationResult, UpsertHardcoverSettingsPayload } from '@bookorbit/types';
import type {
HardcoverSettings,
HardcoverSyncDisabledReason,
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';
@@ -17,43 +21,23 @@ export class HardcoverSettingsService {
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,
};
}
const [row, hasSyncPermission] = await Promise.all([this.repo.findSettings(userId), this.repo.userHasHardcoverSyncPermission(userId)]);
const tokenConfigured = Boolean(row?.apiToken);
const enabled = row?.enabled ?? false;
return {
tokenConfigured: true,
enabled: row.enabled,
autoSyncOnStatusChange: row.autoSyncOnStatusChange,
autoSyncOnProgressUpdate: row.autoSyncOnProgressUpdate,
autoSyncOnRatingChange: row.autoSyncOnRatingChange,
privacySettingId: row.privacySettingId,
lastSyncedAt: row.lastSyncedAt?.toISOString() ?? null,
tokenConfigured,
enabled,
effectiveEnabled: hasSyncPermission && tokenConfigured && enabled,
disabledReason: this.resolveDisabledReason({ hasSyncPermission, tokenConfigured, enabled }),
autoSyncOnStatusChange: row?.autoSyncOnStatusChange ?? true,
autoSyncOnProgressUpdate: row?.autoSyncOnProgressUpdate ?? true,
autoSyncOnRatingChange: row?.autoSyncOnRatingChange ?? true,
privacySettingId: row?.privacySettingId ?? HARDCOVER_PRIVACY.PRIVATE,
lastSyncedAt: row?.lastSyncedAt?.toISOString() ?? null,
};
}
@@ -113,7 +97,8 @@ export class HardcoverSettingsService {
}
async getTokenForUser(userId: number): Promise<string | null> {
const settings = await this.repo.findSettings(userId);
const [settings, hasSyncPermission] = await Promise.all([this.repo.findSettings(userId), this.repo.userHasHardcoverSyncPermission(userId)]);
if (!hasSyncPermission) return null;
if (!settings || !settings.enabled) return null;
return settings.apiToken;
}
@@ -123,4 +108,15 @@ export class HardcoverSettingsService {
const lower = token.toLowerCase();
return lower.startsWith('bearer ') ? token.slice(7).trim() : token;
}
private resolveDisabledReason(input: {
hasSyncPermission: boolean;
tokenConfigured: boolean;
enabled: boolean;
}): HardcoverSyncDisabledReason | null {
if (!input.hasSyncPermission) return 'permission_denied';
if (!input.tokenConfigured) return 'missing_token';
if (!input.enabled) return 'user_disabled';
return null;
}
}
@@ -1,3 +1,4 @@
import { Logger } from '@nestjs/common';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HardcoverSyncService } from './hardcover-sync.service';
@@ -54,6 +55,10 @@ const readingBook = {
describe('HardcoverSyncService', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRepo.findBookState.mockResolvedValue(undefined);
mockRepo.findBookStatesByBookIds.mockResolvedValue([]);
mockRepo.findSyncableBooks.mockResolvedValue([]);
mockRepo.findSyncableBook.mockResolvedValue(null);
mockRepo.upsertBookState.mockResolvedValue({});
mockRepo.updateLastSyncedAt.mockResolvedValue(undefined);
mockSettingsService.getSettings.mockResolvedValue(defaultSettings);
@@ -76,16 +81,58 @@ describe('HardcoverSyncService', () => {
it('does nothing for unread status', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue({ ...readingBook, status: 'unread' });
await makeService().syncBook(1, 1);
await expect(makeService().syncBook(1, 1)).resolves.toBe('skipped');
expect(mockMatchService.matchBook).not.toHaveBeenCalled();
});
it('skips when the local sync snapshot has no changes', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
mockRepo.findBookState.mockResolvedValue({
lastSyncedAt: new Date('2024-02-01T00:00:00Z'),
lastSyncedStatus: 'reading',
lastSyncedProgress: 42,
lastSyncedRating: null,
lastSyncedStartedAt: '2024-01-01',
lastSyncedFinishedAt: null,
});
await expect(makeService().syncBook(1, 1)).resolves.toBe('skipped');
expect(mockMatchService.matchBook).not.toHaveBeenCalled();
expect(mockClient.query).not.toHaveBeenCalled();
});
it('retries an unchanged snapshot when a Hardcover metadata id was added after a failed match', async () => {
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const bookWithMetadataId = { ...readingBook, hardcoverMetadataId: 'fyrebirds' };
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue(bookWithMetadataId);
mockRepo.findBookState.mockResolvedValue({
hardcoverBookId: null,
syncError: 'no_match',
lastSyncedAt: new Date('2024-02-01T00:00:00Z'),
lastSyncedStatus: 'reading',
lastSyncedProgress: 42,
lastSyncedRating: null,
lastSyncedStartedAt: '2024-01-01',
lastSyncedFinishedAt: null,
});
mockMatchService.matchBook.mockResolvedValue(null);
await expect(makeService().syncBook(1, 1)).resolves.toBe('skipped');
expect(mockMatchService.matchBook).toHaveBeenCalledWith(1, 'tok', bookWithMetadataId);
warnSpy.mockRestore();
});
it('stores no_match error when match fails', async () => {
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
mockMatchService.matchBook.mockResolvedValue(null);
mockRepo.findBookState.mockResolvedValue(null);
await makeService().syncBook(1, 1);
await expect(makeService().syncBook(1, 1)).resolves.toBe('skipped');
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(
expect.objectContaining({
syncError: 'no_match',
@@ -95,6 +142,9 @@ describe('HardcoverSyncService', () => {
lastSyncedStartedAt: '2024-01-01',
}),
);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[hardcover.sync_book] [fail] userId=1 bookId=1'));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('errorClass=MatchError error="no_match"'));
warnSpy.mockRestore();
});
it('syncs book successfully', async () => {
@@ -107,7 +157,7 @@ describe('HardcoverSyncService', () => {
.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);
await expect(makeService().syncBook(1, 1)).resolves.toBe('synced');
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(
expect.objectContaining({
hardcoverUserBookId: 55,
@@ -146,6 +196,7 @@ describe('HardcoverSyncService', () => {
});
it('syncs progress to sibling unfinished reads to avoid page 0 in Hardcover UI', async () => {
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
mockRepo.findBookState.mockResolvedValue({ hardcoverReadId: 900 });
@@ -171,6 +222,9 @@ describe('HardcoverSyncService', () => {
expect.stringContaining('mutation UpdateUserBookRead'),
expect.objectContaining({ id: 899, object: expect.objectContaining({ progress_pages: 126 }) }),
);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[hardcover.sync_progress] [end] userId=1 bookId=1'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('progress=42 progressPages=126 - progress sent to Hardcover'));
logSpy.mockRestore();
});
it('keeps progress pending when edition pages are unavailable', async () => {
@@ -89,6 +89,9 @@ type FindUserBookReadsResult = {
user_book_reads: Array<{ id: number; started_at: string | null; finished_at: string | null; progress_pages: number | null }>;
};
export type HardcoverSyncBookResult = 'synced' | 'skipped' | 'failed';
type HardcoverBookStateSnapshot = Awaited<ReturnType<HardcoverRepository['findBookState']>>;
function toDateString(d: Date | null | undefined): string | null {
if (!d) return null;
return d instanceof Date ? d.toISOString().split('T')[0]! : null;
@@ -109,22 +112,22 @@ export class HardcoverSyncService {
private readonly settingsService: HardcoverSettingsService,
) {}
async syncBook(userId: number, bookId: number): Promise<void> {
async syncBook(userId: number, bookId: number): Promise<HardcoverSyncBookResult> {
const token = await this.settingsService.getTokenForUser(userId);
if (!token) return;
if (!token) return 'skipped';
const book = await this.repo.findSyncableBook(userId, bookId);
if (!book) return;
if (!book) return 'skipped';
if (book.status === 'unread') return;
if (book.status === 'unread') return 'skipped';
await this.syncSingleBook(userId, token, book);
const state = await this.repo.findBookState(userId, book.bookId);
if (!this.hasChanges(book, state)) return 'skipped';
return this.syncSingleBook(userId, token, book, state);
}
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`);
@@ -132,6 +135,9 @@ export class HardcoverSyncService {
return existing.runId;
}
const token = await this.settingsService.getTokenForUser(userId);
if (!token) return 0;
const books = await this.repo.findSyncableBooks(userId);
// Re-check: a concurrent syncAll may have won the race during findSyncableBooks
@@ -247,7 +253,7 @@ export class HardcoverSyncService {
continue;
}
const result = await this.syncSingleBook(userId, token, book);
const result = await this.syncSingleBook(userId, token, book, state);
if (result === 'synced') synced++;
else if (result === 'skipped') skipped++;
else failed++;
@@ -279,7 +285,12 @@ export class HardcoverSyncService {
this.emitSyncStatus(userId, activeStatus ?? null);
}
private async syncSingleBook(userId: number, token: string, book: BookSyncData): Promise<'synced' | 'skipped' | 'failed'> {
private async syncSingleBook(
userId: number,
token: string,
book: BookSyncData,
initialState?: HardcoverBookStateSnapshot,
): Promise<HardcoverSyncBookResult> {
const startedAt = Date.now();
const settings = await this.settingsService.getSettings(userId);
@@ -296,7 +307,7 @@ export class HardcoverSyncService {
const match = await this.matchService.matchBook(userId, token, book);
if (!match) {
const state = await this.repo.findBookState(userId, book.bookId);
const state = initialState ?? (await this.repo.findBookState(userId, book.bookId));
await this.repo.upsertBookState({
userId,
bookId: book.bookId,
@@ -304,6 +315,9 @@ export class HardcoverSyncService {
syncError: 'no_match',
...this.buildAttemptSnapshot(book),
});
this.logger.warn(
`[hardcover.sync_book] [fail] userId=${userId} bookId=${book.bookId} durationMs=${Date.now() - startedAt} errorClass=MatchError error="no_match" - Hardcover book match not found`,
);
return 'skipped';
}
@@ -325,9 +339,10 @@ export class HardcoverSyncService {
});
const state = await this.repo.findBookState(userId, book.bookId);
const syncState = initialState ?? state;
const startDate = toDateString(book.startedAt);
const endDate = toDateString(book.finishedAt);
let hardcoverReadId: number | null = state?.hardcoverReadId ?? null;
let hardcoverReadId: number | null = syncState?.hardcoverReadId ?? null;
let progressSynced = true;
if (startDate || endDate || book.progress != null) {
@@ -355,6 +370,10 @@ export class HardcoverSyncService {
progressPages,
editionId: match.hardcoverEditionId ?? undefined,
});
this.logger.log(
`[hardcover.sync_progress] [end] userId=${userId} bookId=${book.bookId} hardcoverBookId=${match.hardcoverBookId} hardcoverReadId=${hardcoverReadId} durationMs=${Date.now() - startedAt} progress=${book.progress} progressPages=${progressPages} - progress sent to Hardcover`,
);
}
}
@@ -544,6 +563,9 @@ export class HardcoverSyncService {
private hasChanges(book: BookSyncData, state: Awaited<ReturnType<HardcoverRepository['findBookState']>>): boolean {
if (!state?.lastSyncedAt) return true;
if (book.hardcoverMetadataId && state.syncError === 'no_match') return true;
const metadataHardcoverId = this.parseNumericHardcoverMetadataId(book.hardcoverMetadataId);
if (metadataHardcoverId !== null && metadataHardcoverId !== state.hardcoverBookId) return true;
if (book.status !== state.lastSyncedStatus) return true;
if (book.progress !== state.lastSyncedProgress) return true;
if (book.rating !== state.lastSyncedRating) return true;
@@ -554,6 +576,12 @@ export class HardcoverSyncService {
return false;
}
private parseNumericHardcoverMetadataId(value: string | null | undefined): number | null {
if (!value) return null;
const id = parseInt(value, 10);
return isNaN(id) ? null : id;
}
private buildAttemptSnapshot(book: BookSyncData) {
return {
lastSyncedAt: new Date(),
@@ -1,8 +1,7 @@
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 { HardcoverAutoSyncSchedulerService } from './hardcover-auto-sync-scheduler.service';
import { HardcoverBookMatchService } from './hardcover-book-match.service';
import { HardcoverClientService } from './hardcover-client.service';
import { HardcoverController } from './hardcover.controller';
@@ -13,8 +12,8 @@ import { HardcoverSettingsService } from './hardcover-settings.service';
import { HardcoverSyncService } from './hardcover-sync.service';
@Module({
imports: [AchievementModule, AppSettingsModule],
controllers: [HardcoverController, HardcoverAdminController],
imports: [AchievementModule],
controllers: [HardcoverController],
providers: [
HardcoverQueueService,
HardcoverClientService,
@@ -22,6 +21,7 @@ import { HardcoverSyncService } from './hardcover-sync.service';
HardcoverSettingsService,
HardcoverBookMatchService,
HardcoverSyncService,
HardcoverAutoSyncSchedulerService,
HardcoverEventListener,
],
exports: [HardcoverSyncService, HardcoverSettingsService],
@@ -1,4 +1,5 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { Permission } from '@bookorbit/types';
import { HardcoverRepository } from './hardcover.repository';
@@ -37,6 +38,10 @@ function makeRepository() {
const bookIdLimit = vi.fn().mockResolvedValue([{ bookId: 42 }]);
const bookIdWhere = vi.fn().mockReturnValue({ limit: bookIdLimit });
const bookIdFrom = vi.fn().mockReturnValue({ where: bookIdWhere });
const permissionLimit = vi.fn().mockResolvedValue([{ isSuperuser: false, permissionName: Permission.HardcoverSync }]);
const permissionWhere = vi.fn().mockReturnValue({ limit: permissionLimit });
const permissionLeftJoin = vi.fn().mockReturnValue({ where: permissionWhere });
const permissionFrom = vi.fn().mockReturnValue({ leftJoin: permissionLeftJoin });
const db = {
query: {
@@ -60,6 +65,8 @@ function makeRepository() {
updateChain,
bookIdLimit,
bookIdWhere,
permissionLimit,
permissionFrom,
settingsRow,
bookStateRow,
};
@@ -137,10 +144,13 @@ describe('HardcoverRepository', () => {
it('findSyncableBook returns a book from findSyncableBooks', async () => {
const { repo } = makeRepository();
vi.spyOn(repo, 'findSyncableBooks').mockResolvedValue([{ bookId: 42 } as any]);
const findSyncableBooksForUser = vi.spyOn(repo as any, 'findSyncableBooksForUser');
findSyncableBooksForUser.mockResolvedValueOnce([{ bookId: 42 }]).mockResolvedValueOnce([]);
await expect(repo.findSyncableBook(7, 42)).resolves.toEqual({ bookId: 42 });
await expect(repo.findSyncableBook(7, 99)).resolves.toBeNull();
expect(findSyncableBooksForUser).toHaveBeenNthCalledWith(1, 7, 42);
expect(findSyncableBooksForUser).toHaveBeenNthCalledWith(2, 7, 99);
});
it('findBookIdByFileId returns the first matching book id', async () => {
@@ -150,4 +160,27 @@ describe('HardcoverRepository', () => {
expect(bookIdLimit).toHaveBeenCalledWith(1);
expect(bookIdWhere).toHaveBeenCalledTimes(1);
});
it('userHasHardcoverSyncPermission returns true for a user with the permission', async () => {
const { repo, db, permissionFrom } = makeRepository();
db.select.mockReturnValueOnce({ from: permissionFrom });
await expect(repo.userHasHardcoverSyncPermission(7)).resolves.toBe(true);
});
it('userHasHardcoverSyncPermission returns true for a superuser', async () => {
const { repo, db, permissionFrom, permissionLimit } = makeRepository();
permissionLimit.mockResolvedValueOnce([{ isSuperuser: true, permissionName: null }]);
db.select.mockReturnValueOnce({ from: permissionFrom });
await expect(repo.userHasHardcoverSyncPermission(7)).resolves.toBe(true);
});
it('userHasHardcoverSyncPermission returns false without an active matching user permission row', async () => {
const { repo, db, permissionFrom, permissionLimit } = makeRepository();
permissionLimit.mockResolvedValueOnce([{ isSuperuser: false, permissionName: null }]);
db.select.mockReturnValueOnce({ from: permissionFrom });
await expect(repo.userHasHardcoverSyncPermission(7)).resolves.toBe(false);
});
});
@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { Permission } from '@bookorbit/types';
import { and, eq, inArray, ne, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
@@ -53,6 +54,23 @@ export class HardcoverRepository {
await this.db.delete(schema.hardcoverUserSettings).where(eq(schema.hardcoverUserSettings.userId, userId));
}
async userHasHardcoverSyncPermission(userId: number): Promise<boolean> {
const [row] = await this.db
.select({
isSuperuser: schema.users.isSuperuser,
permissionName: schema.userPermissions.permissionName,
})
.from(schema.users)
.leftJoin(
schema.userPermissions,
and(eq(schema.userPermissions.userId, schema.users.id), eq(schema.userPermissions.permissionName, Permission.HardcoverSync)),
)
.where(and(eq(schema.users.id, userId), eq(schema.users.active, true)))
.limit(1);
return row?.isSuperuser === true || row?.permissionName === Permission.HardcoverSync;
}
// ---- Book State ----
async findBookState(userId: number, bookId: number): Promise<HardcoverBookState | undefined> {
@@ -89,6 +107,17 @@ export class HardcoverRepository {
// ---- Books for sync ----
async findSyncableBooks(userId: number): Promise<BookSyncData[]> {
return this.findSyncableBooksForUser(userId);
}
async findSyncableBook(userId: number, bookId: number): Promise<BookSyncData | null> {
const [row] = await this.findSyncableBooksForUser(userId, bookId);
return row ?? null;
}
private async findSyncableBooksForUser(userId: number, bookId?: number): Promise<BookSyncData[]> {
const bookFilter = bookId !== undefined ? eq(schema.books.id, bookId) : undefined;
const maxProgressSq = this.db
.select({
bookId: schema.books.id,
@@ -97,6 +126,7 @@ export class HardcoverRepository {
.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)))
.where(bookFilter)
.groupBy(schema.books.id)
.as('max_progress_sq');
@@ -107,6 +137,7 @@ export class HardcoverRepository {
})
.from(schema.bookAuthors)
.innerJoin(schema.authors, eq(schema.authors.id, schema.bookAuthors.authorId))
.where(bookId !== undefined ? eq(schema.bookAuthors.bookId, bookId) : undefined)
.groupBy(schema.bookAuthors.bookId)
.as('first_author_sq');
@@ -132,16 +163,12 @@ export class HardcoverRepository {
.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));
.leftJoin(firstAuthorSq, eq(firstAuthorSq.bookId, schema.books.id))
.where(bookFilter);
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 })
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { CommonModule } from '../../common/common.module';
import { AchievementModule } from '../achievement/achievement.module';
import { UserModule } from '../user/user.module';
import { UserBookStatusModule } from '../user-book-status/user-book-status.module';
import { KoreaderAuthGuard } from './koreader-auth.guard';
@@ -11,7 +12,7 @@ import { KoreaderRepository } from './koreader.repository';
import { KoreaderService } from './koreader.service';
@Module({
imports: [CommonModule, UserModule, UserBookStatusModule],
imports: [CommonModule, UserModule, UserBookStatusModule, AchievementModule],
controllers: [KoreaderController],
providers: [KoreaderService, KoreaderRepository, KoreaderAuthGuard, KoreaderChapterService, KoreaderChapterExtractorService],
exports: [KoreaderService, KoreaderRepository],
@@ -16,6 +16,7 @@ vi.mock('crypto', () => ({
createHash: createHashMock,
}));
import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED } from '../achievement/achievement-events.service';
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
import { KoreaderChapterExtractorService } from './koreader-chapter-extractor.service';
import { KoreaderChapterService } from './koreader-chapter.service';
@@ -73,6 +74,9 @@ describe('KoreaderService', () => {
let mockUserBookStatusService: {
autoUpdate: ReturnType<typeof vi.fn>;
};
let mockAchievementEvents: {
emit: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.restoreAllMocks();
@@ -125,6 +129,10 @@ describe('KoreaderService', () => {
autoUpdate: vi.fn(),
};
mockAchievementEvents = {
emit: vi.fn(),
};
mockRepo.deleteKoreaderUser.mockResolvedValue(undefined);
mockRepo.updateKoreaderUser.mockResolvedValue(undefined);
mockRepo.upsertDeviceProgress.mockResolvedValue(undefined);
@@ -143,6 +151,7 @@ describe('KoreaderService', () => {
mockChapterService as unknown as KoreaderChapterService,
mockChapterExtractor as unknown as KoreaderChapterExtractorService,
mockUserBookStatusService as unknown as UserBookStatusService,
mockAchievementEvents as unknown as AchievementEventsService,
);
});
@@ -320,6 +329,16 @@ describe('KoreaderService', () => {
});
expect(mockRepo.upsertReadingProgress).toHaveBeenCalledWith(44, 12, 50);
expect(mockUserBookStatusService.autoUpdate).toHaveBeenCalledWith(12, 55, 50);
expect(mockAchievementEvents.emit).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, {
userId: 12,
bookId: 55,
bookFileId: 44,
progress: 50,
source: 'koreader',
});
expect(mockAchievementEvents.emit.mock.invocationCallOrder[0]!).toBeGreaterThan(
mockUserBookStatusService.autoUpdate.mock.invocationCallOrder[0]!,
);
expect(result).toEqual({
document: 'abcdef1234567890fedcba',
timestamp: 1700000000,
@@ -339,6 +358,7 @@ describe('KoreaderService', () => {
expect(mockRepo.upsertDeviceProgress).not.toHaveBeenCalled();
expect(mockRepo.upsertReadingProgress).not.toHaveBeenCalled();
expect(mockUserBookStatusService.autoUpdate).not.toHaveBeenCalled();
expect(mockAchievementEvents.emit).not.toHaveBeenCalled();
});
it('passes empty accessible library lists to hash resolution', async () => {
@@ -7,6 +7,7 @@ import { KoreaderRepository } from './koreader.repository';
import { KoreaderChapterService } from './koreader-chapter.service';
import { KoreaderChapterExtractorService } from './koreader-chapter-extractor.service';
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED } from '../achievement/achievement-events.service';
const BCRYPT_ROUNDS = 12;
const SYNC_EVENT = 'koreader.sync';
@@ -22,6 +23,7 @@ export class KoreaderService {
private readonly chapterService: KoreaderChapterService,
private readonly chapterExtractor: KoreaderChapterExtractorService,
private readonly userBookStatusService: UserBookStatusService,
private readonly achievementEvents: AchievementEventsService,
) {}
async createCredentials(userId: number, username: string, password: string) {
@@ -134,6 +136,13 @@ export class KoreaderService {
const bookorbitPercentage = toBookorbitPercentage(data.percentage);
await this.repo.upsertReadingProgress(bookFile.id, userId, bookorbitPercentage);
await this.userBookStatusService.autoUpdate(userId, bookFile.bookId, bookorbitPercentage);
this.achievementEvents.emit(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, {
userId,
bookId: bookFile.bookId,
bookFileId: bookFile.id,
progress: bookorbitPercentage,
source: 'koreader',
});
this.logger.debug(
`[${SYNC_EVENT}] [end] userId=${userId} bookFileId=${bookFile.id} device=${device} durationMs=${Date.now() - startedAt} percentage=${data.percentage} - save progress completed`,
-1
View File
@@ -40,7 +40,6 @@ 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' },
{ key: APP_SETTING_KEYS.CROSS_PLATFORM_PATH_SANITIZATION_ENABLED, value: 'true' },
];