diff --git a/client/src/features/hardcover/api/hardcover.api.ts b/client/src/features/hardcover/api/hardcover.api.ts new file mode 100644 index 000000000..3aeb0801c --- /dev/null +++ b/client/src/features/hardcover/api/hardcover.api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + await api(`${BASE}/sync`, { method: 'DELETE' }) +} + +export async function fetchHardcoverSyncStatus(): Promise { + 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 { + 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 { + const res = await api(`${BASE}/sync/pending`) + if (!res.ok) return { totalBooks: 0, pendingBooks: 0 } + return res.json() +} + +export async function fetchHardcoverAdminSettings(): Promise { + 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 { + 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') +} diff --git a/client/src/features/hardcover/components/HardcoverConnectionCard.vue b/client/src/features/hardcover/components/HardcoverConnectionCard.vue new file mode 100644 index 000000000..b5646f9d0 --- /dev/null +++ b/client/src/features/hardcover/components/HardcoverConnectionCard.vue @@ -0,0 +1,219 @@ + + + diff --git a/client/src/features/hardcover/components/HardcoverSettings.vue b/client/src/features/hardcover/components/HardcoverSettings.vue new file mode 100644 index 000000000..08a8bf896 --- /dev/null +++ b/client/src/features/hardcover/components/HardcoverSettings.vue @@ -0,0 +1,29 @@ + + + diff --git a/client/src/features/hardcover/components/HardcoverSyncProgress.vue b/client/src/features/hardcover/components/HardcoverSyncProgress.vue new file mode 100644 index 000000000..be1b7e747 --- /dev/null +++ b/client/src/features/hardcover/components/HardcoverSyncProgress.vue @@ -0,0 +1,83 @@ + + + diff --git a/client/src/features/hardcover/composables/__tests__/useHardcoverSettings.test.ts b/client/src/features/hardcover/composables/__tests__/useHardcoverSettings.test.ts new file mode 100644 index 000000000..cefa2ffb8 --- /dev/null +++ b/client/src/features/hardcover/composables/__tests__/useHardcoverSettings.test.ts @@ -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>(), + upsertHardcoverSettings: vi.fn<(payload: UpsertHardcoverSettingsPayload) => Promise>(), + disconnectHardcover: vi.fn<() => Promise>(), + validateHardcoverToken: vi.fn<(token?: string) => Promise>(), +})) + +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) + }) +}) diff --git a/client/src/features/hardcover/composables/__tests__/useHardcoverSync.test.ts b/client/src/features/hardcover/composables/__tests__/useHardcoverSync.test.ts new file mode 100644 index 000000000..a50788051 --- /dev/null +++ b/client/src/features/hardcover/composables/__tests__/useHardcoverSync.test.ts @@ -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>(), + fetchHardcoverSyncStatus: vi.fn<() => Promise>(), + fetchHardcoverSyncPendingSummary: vi.fn<() => Promise>(), + streamHardcoverSyncStatus: vi.fn<(onStatus: (s: HardcoverActiveSyncStatus | null) => void, signal?: AbortSignal) => Promise>(), + fetchHardcoverSettings: vi.fn<() => Promise>(), +})) + +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) | null = null + mockStream.mockImplementationOnce(async (onStatus) => { + onStatusFromStream = onStatus + return new Promise(() => { + // 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) | null + if (streamCallback) { + await streamCallback(RUNNING_STATUS) + } + + expect(c.activeSyncStatus.value).toBeNull() + }) +}) diff --git a/client/src/features/hardcover/composables/useHardcoverSettings.ts b/client/src/features/hardcover/composables/useHardcoverSettings.ts new file mode 100644 index 000000000..6fc0559d5 --- /dev/null +++ b/client/src/features/hardcover/composables/useHardcoverSettings.ts @@ -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(null) +const loading = ref(false) +const saving = ref(false) +const validating = ref(false) +const error = ref(null) + +export function useHardcoverSettings() { + async function fetchSettings(): Promise { + 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 { + 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 { + 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, + } +} diff --git a/client/src/features/hardcover/composables/useHardcoverSync.ts b/client/src/features/hardcover/composables/useHardcoverSync.ts new file mode 100644 index 000000000..f93258ead --- /dev/null +++ b/client/src/features/hardcover/composables/useHardcoverSync.ts @@ -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(null) +const pendingSummary = ref({ totalBooks: 0, pendingBooks: 0 }) +const syncing = ref(false) +const loadingPending = ref(true) +const error = ref(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 { + 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 { + await cancelHardcoverSync().catch(() => null) + stopSyncTracking() + activeSyncStatus.value = null + await fetchPendingSummary() + } + + async function fetchStatus(): Promise { + const [status] = await Promise.all([fetchHardcoverSyncStatus(), fetchPendingSummary()]) + activeSyncStatus.value = status + if (status?.status === 'running') { + startSyncTracking() + } + } + + async function fetchPendingSummary(): Promise { + 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, + } +} diff --git a/client/src/features/settings/MaintenanceSettings.vue b/client/src/features/settings/MaintenanceSettings.vue index 2758114c2..a13546585 100644 --- a/client/src/features/settings/MaintenanceSettings.vue +++ b/client/src/features/settings/MaintenanceSettings.vue @@ -1,6 +1,6 @@