fix(books): clear reading progress and history when resetting book

Provide a new endpoint and user interface to reset the reading state of a book. Delete all related reading sessions, progress, audiobook progress, and KOReader progress. Clear lifecycle dates when setting the book status back to unread or want to read, preventing the book from persisting on the continue reading shelf.

Closes #606
This commit is contained in:
neon
2026-07-09 21:54:18 +00:00
parent 5541e1acdc
commit 0f37e197ad
23 changed files with 1245 additions and 34 deletions
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { AlertTriangle, RotateCcw } from '@lucide/vue'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
const props = defineProps<{
open: boolean
resetting: boolean
error: string | null
}>()
const emit = defineEmits<{
close: []
confirm: []
}>()
function handleOpenChange(open: boolean) {
if (!open) emit('close')
}
function handleClose() {
emit('close')
}
function handleConfirm() {
emit('confirm')
}
</script>
<template>
<DialogRoot :open="props.open" @update:open="handleOpenChange">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-50 bg-foreground/20" />
<DialogContent
class="fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card p-5 shadow-xl"
>
<div class="flex gap-3">
<div class="flex size-9 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<AlertTriangle class="size-4" />
</div>
<div class="min-w-0">
<DialogTitle class="text-base font-semibold text-foreground">Reset reading state?</DialogTitle>
<p class="mt-1.5 text-sm leading-6 text-muted-foreground">
This clears BookOrbit and synced Kobo/KOReader progress, reading sessions, and reading dates for this book. Highlights, notes, and book
files are kept.
</p>
</div>
</div>
<p v-if="props.error" class="mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{{ props.error }}
</p>
<div class="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
class="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
:disabled="props.resetting"
@click="handleClose"
>
Cancel
</button>
<button
class="inline-flex h-9 items-center justify-center gap-2 rounded-md bg-destructive px-3 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="props.resetting"
@click="handleConfirm"
>
<RotateCcw class="size-3.5" />
{{ props.resetting ? 'Resetting...' : 'Reset reading state' }}
</button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -70,6 +70,7 @@ const globalStubs = {
DialogClose: { template: '<button><slot /></button>' },
AddToCollectionSheet: true,
DeleteBookDialog: true,
ResetReadingStateDialog: true,
},
}
@@ -9,7 +9,7 @@ import {
Library,
Headphones,
Lock,
MoreHorizontal,
MoreVertical,
Pencil,
RotateCcw,
Send,
@@ -40,7 +40,9 @@ import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
import { useMetadataLocks } from '@/features/book/composables/useMetadataLocks'
import { usePersonalNote, PERSONAL_NOTE_MAX_LENGTH } from '@/features/book/composables/usePersonalNote'
import { useResetReadingState } from '@/features/book/composables/useResetReadingState'
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import ResetReadingStateDialog from '@/features/book/components/ResetReadingStateDialog.vue'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
import MetadataScoreBadge from '@/features/metadata-score/components/MetadataScoreBadge.vue'
@@ -287,6 +289,15 @@ const isRatingLocked = computed(() => isLocked('rating'))
const canViewKobo = computed(() => hasPermission('kobo_sync'))
const canViewKoreader = computed(() => hasPermission('koreader_sync'))
const canEditMetadata = computed(() => hasPermission('library_edit_metadata'))
const resetReadingStateBookId = computed(() => props.book.id)
const {
open: resetReadingStateDialogOpen,
resetting: resettingReadingState,
error: resetReadingStateError,
openDialog: openResetReadingStateDialog,
closeDialog: closeResetReadingStateDialog,
resetReadingState,
} = useResetReadingState(resetReadingStateBookId)
const coverSeed = computed(() => props.book.title ?? props.book.folderPath.split('/').pop() ?? String(props.book.id))
const coverPlaceholderTitle = computed(() => props.book.title ?? props.book.folderPath.split('/').pop() ?? null)
@@ -903,6 +914,20 @@ function handleSendFromMenu() {
showSendDialog.value = true
}
function handleOpenResetReadingState() {
moreMenuOpen.value = false
mobileMoreMenuOpen.value = false
openResetReadingStateDialog()
}
async function handleResetReadingState() {
const result = await resetReadingState()
if (!result) return
await loadSupplemental()
emit('saved', { ...props.book, readStatus: result.readStatus })
}
function handleCoverLoad(ratio: number | null) {
coverLoaded.value = true
coverFailed.value = false
@@ -1345,24 +1370,34 @@ watch(
>
<Library class="size-3.5" />
</button>
<button
v-if="hasPermission('email_send')"
class="flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-muted transition-colors"
aria-label="Send via Email"
@click="handleSendFromMenu"
>
<Send class="size-3.5" />
</button>
<Popover
v-if="hasPermission('library_delete_books') || hasPermission('email_send')"
v-if="canEditMetadata || hasPermission('library_delete_books')"
:open="mobileMoreMenuOpen"
@update:open="(v) => (mobileMoreMenuOpen = v)"
>
<PopoverTrigger as-child>
<button class="flex items-center justify-center h-9 w-9 rounded-md border border-input bg-background hover:bg-muted transition-colors">
<MoreHorizontal class="size-3.5" />
<button
class="flex items-center justify-center h-9 w-9 rounded-md border border-destructive/30 bg-background text-destructive hover:bg-destructive/10 transition-colors"
>
<MoreVertical class="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent class="w-44 p-1" align="end">
<button
v-if="hasPermission('email_send')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-foreground hover:bg-muted transition-colors"
@click="handleSendFromMenu"
v-if="canEditMetadata"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-destructive hover:bg-destructive/10 transition-colors"
@click="handleOpenResetReadingState"
>
<Send class="size-3.5" />
Send via Email
<RotateCcw class="size-3.5" />
Reset reading state
</button>
<button
v-if="hasPermission('library_delete_books')"
@@ -1498,26 +1533,30 @@ watch(
>
<Library class="size-3.5" />
</button>
<Popover
v-if="hasPermission('library_delete_books') || hasPermission('email_send')"
:open="moreMenuOpen"
@update:open="(v) => (moreMenuOpen = v)"
<button
v-if="hasPermission('email_send')"
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
aria-label="Send via Email"
@click="handleSendFromMenu"
>
<Send class="size-3.5" />
</button>
<Popover v-if="canEditMetadata || hasPermission('library_delete_books')" :open="moreMenuOpen" @update:open="(v) => (moreMenuOpen = v)">
<PopoverTrigger as-child>
<button
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
class="flex flex-1 items-center justify-center h-9 rounded-md border border-destructive/30 bg-background text-destructive hover:bg-destructive/10 transition-colors"
>
<MoreHorizontal class="size-3.5" />
<MoreVertical class="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent class="w-40 p-1" align="end">
<PopoverContent class="w-44 p-1" align="end">
<button
v-if="hasPermission('email_send')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-foreground hover:bg-muted transition-colors"
@click="handleSendFromMenu"
v-if="canEditMetadata"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-destructive hover:bg-destructive/10 transition-colors"
@click="handleOpenResetReadingState"
>
<Send class="size-3.5" />
Send via Email
<RotateCcw class="size-3.5" />
Reset reading state
</button>
<button
v-if="hasPermission('library_delete_books')"
@@ -1525,7 +1564,7 @@ watch(
@click="handleDeleteFromMenu"
>
<Trash2 class="size-3.5" />
Delete
Delete book
</button>
</PopoverContent>
</Popover>
@@ -2171,6 +2210,14 @@ watch(
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
<ResetReadingStateDialog
:open="resetReadingStateDialogOpen"
:resetting="resettingReadingState"
:error="resetReadingStateError"
@close="closeResetReadingStateDialog"
@confirm="handleResetReadingState"
/>
<!-- Cover lightbox -->
<DialogRoot :open="coverLightboxOpen" @update:open="coverLightboxOpen = $event">
<DialogPortal>
@@ -1,7 +1,11 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { BookDetail, UserBookStatus } from '@bookorbit/types'
import { RotateCcw } from '@lucide/vue'
import { Permission, type BookDetail, type UserBookStatus } from '@bookorbit/types'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useBookReadingLog, type AddReadingSessionPayload } from '@/features/book/composables/useBookReadingLog'
import { useResetReadingState } from '@/features/book/composables/useResetReadingState'
import ResetReadingStateDialog from '@/features/book/components/ResetReadingStateDialog.vue'
import ReadingLogHero from './ReadingLogHero.vue'
import ReadingLogSourceSplit from './ReadingLogSourceSplit.vue'
import ReadingLogJourneyChart from './ReadingLogJourneyChart.vue'
@@ -29,12 +33,24 @@ const {
hasMore,
deleteSession,
addSession,
reload,
exportAll,
loadMore,
setSort,
setFilters,
} = useBookReadingLog(bookIdRef)
const { hasPermission } = usePermissions()
const canResetReadingState = computed(() => hasPermission(Permission.LibraryEditMetadata))
const {
open: resetDialogOpen,
resetting: resettingReadingState,
error: resetReadingStateError,
openDialog: openResetReadingStateDialog,
closeDialog: closeResetReadingStateDialog,
resetReadingState,
} = useResetReadingState(bookIdRef)
type QuickFilter = 'all' | 'last30' | 'last90' | 'thisYear'
const activeQuick = ref<QuickFilter>('all')
const selectedFormat = ref<string | undefined>(undefined)
@@ -83,6 +99,17 @@ function handleHeroSaved(readStatus: UserBookStatus) {
emit('saved', { ...props.book, readStatus })
}
function handleOpenResetReadingState() {
openResetReadingStateDialog()
}
async function handleResetReadingState() {
const result = await resetReadingState()
if (!result) return
await reload()
emit('saved', { ...props.book, readStatus: result.readStatus })
}
const addDialogOpen = ref(false)
const addSaving = ref(false)
const addError = ref<string | null>(null)
@@ -155,6 +182,14 @@ const quickFilters: { label: string; value: QuickFilter }[] = [
</select>
<ReadingLogExportMenu :book-title="bookTitle" :total="total" :export-all="exportAll" />
<button
v-if="canResetReadingState"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-destructive/30 px-2.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
@click="handleOpenResetReadingState"
>
<RotateCcw class="size-3.5" />
Reset reading state
</button>
</div>
</div>
@@ -189,5 +224,13 @@ const quickFilters: { label: string; value: QuickFilter }[] = [
@close="handleCloseAddSession"
@submit="handleAddSessionSubmit"
/>
<ResetReadingStateDialog
:open="resetDialogOpen"
:resetting="resettingReadingState"
:error="resetReadingStateError"
@close="closeResetReadingStateDialog"
@confirm="handleResetReadingState"
/>
</div>
</template>
@@ -402,13 +402,13 @@ describe('DetailsTab cover surface', () => {
expect(storygraphItem.props('bookId')).toBe(12)
})
it('renders Send via Email button and opens dialog when user has email_send permission', async () => {
it('renders a Send via Email action button and opens dialog when user has email_send permission', async () => {
mocks.hasPermission.mockImplementation((perm) => perm === 'email_send')
const wrapper = mountDetails(makeBook())
await flushPromises()
const sendButtons = wrapper.findAll('button').filter((b) => b.text().includes('Send via Email'))
expect(sendButtons.length).toBeGreaterThan(0) // Desktop and mobile menus
const sendButtons = wrapper.findAll('button[aria-label="Send via Email"]')
expect(sendButtons).toHaveLength(2)
// Find the stubbed SendBookDialog
const sendDialog = wrapper.findComponent({ name: 'SendBookDialog' })
@@ -424,7 +424,77 @@ describe('DetailsTab cover surface', () => {
const wrapper = mountDetails(makeBook())
await flushPromises()
const sendButtons = wrapper.findAll('button').filter((b) => b.text().includes('Send via Email'))
const sendButtons = wrapper.findAll('button[aria-label="Send via Email"]')
expect(sendButtons.length).toBe(0)
})
it('offers reset in both overflow menus and refreshes supplemental reading state after confirmation', async () => {
mocks.hasPermission.mockImplementation(
(permission) => permission === 'library_edit_metadata' || permission === 'kobo_sync' || permission === 'koreader_sync',
)
const wrapper = mountDetails(makeBook())
await flushPromises()
const resetButtons = wrapper.findAll('button').filter((button) => button.text().includes('Reset reading state'))
expect(resetButtons).toHaveLength(2)
const resetDialog = wrapper.findComponent({ name: 'ResetReadingStateDialog' })
expect(resetDialog.exists()).toBe(true)
expect(resetDialog.props('open')).toBe(false)
await resetButtons[0]!.trigger('click')
expect(resetDialog.props('open')).toBe(true)
mocks.api.mockClear()
mocks.api.mockImplementation(async (input) => {
const url = String(input)
if (url.endsWith('/reset-reading-state')) {
return response({
readStatus: {
status: 'unread',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: '2026-07-09T12:00:00.000Z',
},
})
}
if (url.includes('/collections/membership')) return response([])
if (url.includes('/kobo-state')) {
return response({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] })
}
if (url.includes('/koreader/books/')) return response(null)
if (url.includes('/progress')) return response([])
return response({})
})
resetDialog.vm.$emit('confirm')
await flushPromises()
expect(mocks.api).toHaveBeenNthCalledWith(1, '/api/v1/books/12/reset-reading-state', { method: 'POST' })
expect(mocks.api).toHaveBeenCalledWith('/api/v1/books/12/progress')
expect(mocks.api).toHaveBeenCalledWith('/api/v1/books/12/kobo-state')
expect(mocks.api).toHaveBeenCalledWith('/api/v1/koreader/books/12/progress')
expect(wrapper.emitted('saved')).toEqual([
[
expect.objectContaining({
readStatus: {
status: 'unread',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: '2026-07-09T12:00:00.000Z',
},
}),
],
])
})
it('hides reset reading state when the user cannot edit metadata', async () => {
mocks.hasPermission.mockReturnValue(false)
const wrapper = mountDetails(makeBook())
await flushPromises()
expect(wrapper.findAll('button').some((button) => button.text().includes('Reset reading state'))).toBe(false)
})
})
@@ -4,17 +4,23 @@ import { createPinia } from 'pinia'
const mocks = vi.hoisted(() => ({
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
hasPermission: vi.fn<(...args: unknown[]) => boolean>(),
}))
vi.mock('@/lib/api', () => ({
api: mocks.api,
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: mocks.hasPermission }),
}))
vi.mock('vue-echarts', () => ({
default: { name: 'VChart', template: '<div />' },
}))
import ReadingLogTab from '../ReadingLogTab.vue'
import ResetReadingStateDialog from '@/features/book/components/ResetReadingStateDialog.vue'
function makeBook(overrides = {}) {
return {
@@ -89,6 +95,8 @@ describe('ReadingLogTab', () => {
beforeEach(() => {
mocks.api.mockReset()
mocks.api.mockResolvedValue(makeListResponse())
mocks.hasPermission.mockReset()
mocks.hasPermission.mockReturnValue(true)
})
it('renders quick filter buttons', async () => {
@@ -217,4 +225,60 @@ describe('ReadingLogTab', () => {
const last30Btn = wrapper.findAll('button').find((b) => b.text() === 'Last 30 days')
expect(last30Btn?.classes()).not.toContain('bg-primary')
})
it('opens the reset dialog and reloads the reading log after a successful reset', async () => {
const wrapper = mount(ReadingLogTab, { props: { book: makeBook() }, global: { plugins: [createPinia()] } })
await flushPromises()
const resetButton = wrapper.findAll('button').find((button) => button.text() === 'Reset reading state')
expect(resetButton).toBeDefined()
await resetButton!.trigger('click')
const dialog = wrapper.findComponent(ResetReadingStateDialog)
expect(dialog.props('open')).toBe(true)
mocks.api.mockReset()
mocks.api
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
readStatus: {
status: 'unread',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: '2026-07-09T12:00:00.000Z',
},
}),
} as Response)
.mockResolvedValueOnce(makeListResponse())
dialog.vm.$emit('confirm')
await flushPromises()
expect(mocks.api).toHaveBeenNthCalledWith(1, '/api/v1/books/10/reset-reading-state', { method: 'POST' })
expect(mocks.api).toHaveBeenNthCalledWith(2, expect.stringContaining('/api/v1/books/10/sessions?'))
expect(wrapper.emitted('saved')).toEqual([
[
expect.objectContaining({
readStatus: {
status: 'unread',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: '2026-07-09T12:00:00.000Z',
},
}),
],
])
})
it('hides the reset action without metadata-edit permission', async () => {
mocks.hasPermission.mockReturnValue(false)
const wrapper = mount(ReadingLogTab, { props: { book: makeBook() }, global: { plugins: [createPinia()] } })
await flushPromises()
expect(wrapper.findAll('button').some((button) => button.text() === 'Reset reading state')).toBe(false)
})
})
@@ -0,0 +1,88 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
}))
vi.mock('@/lib/api', () => ({
api: mocks.api,
}))
import { useResetReadingState } from '../useResetReadingState'
function response(data: unknown, options: { ok?: boolean; status?: number } = {}): Response {
const { ok = true, status = ok ? 200 : 500 } = options
return {
ok,
status,
json: async () => data,
} as Response
}
const resetResult = {
readStatus: {
status: 'unread' as const,
source: 'manual' as const,
startedAt: null,
finishedAt: null,
updatedAt: '2026-07-09T12:00:00.000Z',
},
}
describe('useResetReadingState', () => {
beforeEach(() => {
mocks.api.mockReset()
})
it('posts the book reset endpoint, returns the server status, and closes the dialog', async () => {
mocks.api.mockResolvedValueOnce(response(resetResult))
const { open, resetting, error, openDialog, resetReadingState } = useResetReadingState(ref(42))
openDialog()
const result = await resetReadingState()
expect(mocks.api).toHaveBeenCalledWith('/api/v1/books/42/reset-reading-state', { method: 'POST' })
expect(result).toEqual(resetResult)
expect(open.value).toBe(false)
expect(resetting.value).toBe(false)
expect(error.value).toBeNull()
})
it('keeps the dialog open and exposes API validation errors', async () => {
mocks.api.mockResolvedValueOnce(response({ message: ['Book is locked', 'Try again later'] }, { ok: false, status: 409 }))
const { open, error, openDialog, resetReadingState } = useResetReadingState(ref(42))
openDialog()
await expect(resetReadingState()).resolves.toBeNull()
expect(open.value).toBe(true)
expect(error.value).toBe('Book is locked, Try again later')
})
it('prevents duplicate requests while resetting and recovers from network errors', async () => {
let resolveRequest: ((value: Response) => void) | undefined
mocks.api.mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveRequest = resolve
}),
)
const { open, resetting, error, openDialog, resetReadingState } = useResetReadingState(ref(42))
openDialog()
const pending = resetReadingState()
expect(resetting.value).toBe(true)
await expect(resetReadingState()).resolves.toBeNull()
expect(mocks.api).toHaveBeenCalledOnce()
resolveRequest?.(response(resetResult))
await expect(pending).resolves.toEqual(resetResult)
mocks.api.mockRejectedValueOnce(new Error('Network unavailable'))
openDialog()
await expect(resetReadingState()).resolves.toBeNull()
expect(open.value).toBe(true)
expect(resetting.value).toBe(false)
expect(error.value).toBe('Network unavailable')
})
})
@@ -127,6 +127,11 @@ export function useBookReadingLog(bookIdRef: Ref<number>) {
await fetchSessions()
}
async function reload() {
page.value = 1
await fetchSessions()
}
async function exportAll(): Promise<BookReadingSession[]> {
const bookId = bookIdRef.value
const items: BookReadingSession[] = []
@@ -189,6 +194,7 @@ export function useBookReadingLog(bookIdRef: Ref<number>) {
hasMore,
deleteSession,
addSession,
reload,
exportAll,
loadMore,
setSort,
@@ -0,0 +1,56 @@
import { ref, type Ref } from 'vue'
import type { ResetBookReadingStateResponse } from '@bookorbit/types'
import { api } from '@/lib/api'
async function getErrorMessage(response: Response): Promise<string> {
try {
const body = (await response.json()) as { message?: string | string[] }
if (Array.isArray(body.message)) return body.message.join(', ')
if (body.message) return body.message
} catch {
// Use the generic fallback when the response has no JSON error body.
}
return 'Failed to reset reading state'
}
export function useResetReadingState(bookId: Ref<number>) {
const open = ref(false)
const resetting = ref(false)
const error = ref<string | null>(null)
function openDialog() {
error.value = null
open.value = true
}
function closeDialog() {
if (resetting.value) return
open.value = false
error.value = null
}
async function resetReadingState(): Promise<ResetBookReadingStateResponse | null> {
if (resetting.value) return null
resetting.value = true
error.value = null
try {
const response = await api(`/api/v1/books/${bookId.value}/reset-reading-state`, { method: 'POST' })
if (!response.ok) {
error.value = await getErrorMessage(response)
return null
}
const result = (await response.json()) as ResetBookReadingStateResponse
open.value = false
return result
} catch (caught) {
error.value = caught instanceof Error ? caught.message : 'Failed to reset reading state'
return null
} finally {
resetting.value = false
}
}
return { open, resetting, error, openDialog, closeDialog, resetReadingState }
}
+1
View File
@@ -53,6 +53,7 @@ export enum AuditAction {
BookBulkUpdateTags = "book.bulk.update_tags",
BookBulkSetMetadataLock = "book.bulk.set_metadata_lock",
BookBulkEditMetadata = "book.bulk.edit_metadata",
BookReadingStateReset = "book.reading_state.reset",
BookWriteAndRename = "book.write_and_rename",
+4
View File
@@ -30,6 +30,10 @@ export type UserBookStatus = {
updatedAt: string;
};
export type ResetBookReadingStateResponse = {
readStatus: UserBookStatus;
};
export type BookFileRef = {
id: number;
format: string | null;
+2
View File
@@ -52,6 +52,7 @@ import { MetadataScoreModule } from './modules/metadata-score/metadata-score.mod
import { StatisticsModule } from './modules/statistics/statistics.module';
import { UserStatisticsModule } from './modules/user-statistics/user-statistics.module';
import { ReadingSessionModule } from './modules/reading-session/reading-session.module';
import { ReadingStateModule } from './modules/reading-state/reading-state.module';
import { UserBookStatusModule } from './modules/user-book-status/user-book-status.module';
import { AuditModule } from './modules/audit/audit.module';
import { MigrationModule } from './modules/migration/migration.module';
@@ -129,6 +130,7 @@ import { CustomIconModule } from './modules/custom-icon/custom-icon.module';
StatisticsModule,
UserStatisticsModule,
ReadingSessionModule,
ReadingStateModule,
UserBookStatusModule,
AuditModule,
MigrationModule,
@@ -64,7 +64,7 @@ describe('DashboardRepository', () => {
expect(listChain.limit).toHaveBeenCalledWith(2);
});
it('maps continue-reading rows to id list and excludes terminal read statuses', async () => {
it('maps continue-reading rows to id list and excludes unread plus terminal read statuses', async () => {
const listChain = makeLimitChain([{ id: 40 }, { id: 9 }]);
const db = { select: vi.fn().mockReturnValue(listChain) };
const repo = new DashboardRepository(db as never);
@@ -79,7 +79,7 @@ describe('DashboardRepository', () => {
expect(listChain.leftJoin.mock.calls[1]?.[0]).toBe(readingProgress);
expect(listChain.leftJoin.mock.calls[2]?.[0]).toBe(userBookStatus);
expect(listChain.leftJoin.mock.calls.some((call) => call[0] === audiobookProgress)).toBe(false);
expect(whereValues).toEqual(expect.arrayContaining(['read', 'skimmed', 'abandoned']));
expect(whereValues).toEqual(expect.arrayContaining(['unread', 'read', 'skimmed', 'abandoned']));
expect(listChain.innerJoin).not.toHaveBeenCalled();
expect(listChain.orderBy).toHaveBeenCalledTimes(1);
expect(listChain.limit).toHaveBeenCalledWith(10);
@@ -11,7 +11,7 @@ import { buildContentFilterClauses } from '../../common/utils/content-filter-sql
type Db = NodePgDatabase<typeof schema>;
type UpNextInSeriesRow = { id: number };
const AUDIO_FORMATS = BOOK_FORMATS.filter(isAudioFormat);
const CONTINUE_READING_EXCLUDED_READ_STATUSES = ['read', 'skimmed', 'abandoned'] as const satisfies readonly ReadStatus[];
const CONTINUE_READING_EXCLUDED_READ_STATUSES = ['unread', 'read', 'skimmed', 'abandoned'] as const satisfies readonly ReadStatus[];
const DISCOVERY_EXCLUDED_READ_STATUSES = ['reading', 'rereading', 'on_hold', 'read', 'skimmed', 'abandoned'] as const satisfies readonly ReadStatus[];
@Injectable()
@@ -0,0 +1,73 @@
import { EMPTY_CONTENT_FILTER_RULES, AuditAction, AuditResource, Permission } from '@bookorbit/types';
import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator';
import { PERMISSION_KEY } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { ReadingStateController } from './reading-state.controller';
import { ReadingStateService } from './reading-state.service';
const user: RequestUser = {
id: 8,
username: 'reader',
name: 'Reader',
email: null,
active: true,
isSuperuser: false,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
permissions: [],
contentFilters: EMPTY_CONTENT_FILTER_RULES,
};
function makeController() {
const service = {
resetBookReadingState: vi.fn(),
};
return {
controller: new ReadingStateController(service as unknown as ReadingStateService),
service,
};
}
describe('ReadingStateController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('delegates the reset action with the parsed book id and current user', async () => {
const { controller, service } = makeController();
const expected = {
readStatus: {
status: 'unread',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: '2026-07-09T12:00:00.000Z',
},
};
service.resetBookReadingState.mockResolvedValue(expected);
await expect(controller.resetBookReadingState(42, user)).resolves.toEqual(expected);
expect(service.resetBookReadingState).toHaveBeenCalledWith(42, user);
});
it('requires metadata-edit permission and defines the expected book audit event', () => {
const method = ReadingStateController.prototype.resetBookReadingState;
const permission = Reflect.getMetadata(PERMISSION_KEY, method);
const audit = Reflect.getMetadata(AUDITABLE_KEY, method) as {
action: AuditAction;
resource: AuditResource;
getResourceId: (request: { params: Record<string, string> }, response: unknown) => number | undefined;
description: (request: { params: Record<string, string> }, response: unknown) => string;
};
expect(permission).toBe(Permission.LibraryEditMetadata);
expect(audit).toMatchObject({ action: AuditAction.BookReadingStateReset, resource: AuditResource.Book });
expect(audit.getResourceId({ params: { bookId: '42' } }, null)).toBe(42);
expect(audit.description({ params: { bookId: '42' } }, null)).toBe('Reset reading state for book #42');
});
});
@@ -0,0 +1,25 @@
import { Controller, Param, ParseIntPipe, Post } from '@nestjs/common';
import { AuditAction, AuditResource, Permission } from '@bookorbit/types';
import { Auditable } from '../../common/decorators/auditable.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { ReadingStateService } from './reading-state.service';
@Controller('books')
export class ReadingStateController {
constructor(private readonly service: ReadingStateService) {}
@Post(':bookId/reset-reading-state')
@RequirePermission(Permission.LibraryEditMetadata)
@Auditable({
action: AuditAction.BookReadingStateReset,
resource: AuditResource.Book,
getResourceId: (req) => parseInt(req.params['bookId'] as string, 10),
description: (req) => `Reset reading state for book #${req.params['bookId']}`,
})
resetBookReadingState(@Param('bookId', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser) {
return this.service.resetBookReadingState(bookId, user);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { BookModule } from '../book/book.module';
import { ReadingStateController } from './reading-state.controller';
import { ReadingStateRepository } from './reading-state.repository';
import { ReadingStateService } from './reading-state.service';
@Module({
imports: [BookModule],
controllers: [ReadingStateController],
providers: [ReadingStateService, ReadingStateRepository],
})
export class ReadingStateModule {}
@@ -0,0 +1,163 @@
import { NotFoundException } from '@nestjs/common';
import {
audiobookProgress,
koreaderDeviceProgress,
koreaderPageStats,
koboReadingStates,
readingProgress,
readingSessions,
userBookStatus,
userReadingDailyStats,
} from '../../db/schema';
import { ReadingStateRepository } from './reading-state.repository';
function makeSelectChain(result: unknown) {
const chain: Record<string, ReturnType<typeof vi.fn>> & { then?: PromiseLike<unknown>['then']; getSQL?: ReturnType<typeof vi.fn> } = {};
for (const method of ['from', 'where', 'orderBy', 'innerJoin', 'leftJoin']) {
chain[method] = vi.fn().mockReturnValue(chain);
}
chain.limit = vi.fn().mockResolvedValue(result);
chain.getSQL = vi.fn();
chain.then = (resolve, reject) => Promise.resolve(result).then(resolve, reject);
return chain;
}
function makeHarness(selectResults: unknown[]) {
const selectChains: ReturnType<typeof makeSelectChain>[] = [];
const select = vi.fn(() => {
const chain = makeSelectChain(selectResults.shift() ?? []);
selectChains.push(chain);
return chain;
});
const deleteWhere = vi.fn().mockResolvedValue(undefined);
const deleteFn = vi.fn(() => ({ where: deleteWhere }));
const statusConflict = vi.fn().mockResolvedValue(undefined);
const statusValues = vi.fn(() => ({ onConflictDoUpdate: statusConflict }));
const dailyConflict = vi.fn().mockResolvedValue(undefined);
const dailyValues = vi.fn(() => ({ onConflictDoUpdate: dailyConflict }));
const insert = vi.fn((table: unknown) => {
if (table === userBookStatus) return { values: statusValues };
if (table === userReadingDailyStats) return { values: dailyValues };
throw new Error('Unexpected table in insert');
});
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateSet = vi.fn(() => ({ where: updateWhere }));
const update = vi.fn(() => ({ set: updateSet }));
const execute = vi.fn().mockResolvedValue(undefined);
const tx = { select, delete: deleteFn, insert, update, execute };
const transaction = vi.fn(async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx));
const db = { transaction };
return {
repo: new ReadingStateRepository(db as never),
transaction,
tx,
selectChains,
deleteFn,
statusValues,
statusConflict,
dailyValues,
dailyConflict,
update,
updateSet,
};
}
describe('ReadingStateRepository', () => {
it('atomically clears every persisted reading source, resets Kobo, and rebuilds affected daily totals', async () => {
const deletedSession = {
id: 11,
startedAt: new Date('2026-04-15T10:00:00.000Z'),
endedAt: new Date('2026-04-15T10:20:00.000Z'),
durationSeconds: 1200,
progressDelta: 4,
};
const remainingSession = {
id: 99,
startedAt: new Date('2026-04-15T12:00:00.000Z'),
endedAt: new Date('2026-04-15T12:30:00.000Z'),
durationSeconds: 1800,
progressDelta: 6,
};
const { repo, transaction, tx, deleteFn, statusValues, statusConflict, dailyValues, dailyConflict, update, updateSet } = makeHarness([
[{ libraryId: 3 }],
[deletedSession],
[],
[{ total: 2 }],
[{ total: 1 }],
[{ total: 3 }],
[{ total: 4 }],
[{ id: 88 }],
[remainingSession],
]);
const result = await repo.resetBookReadingState(7, 42, 'UTC');
expect(transaction).toHaveBeenCalledOnce();
expect(deleteFn).toHaveBeenCalledWith(readingProgress);
expect(deleteFn).toHaveBeenCalledWith(audiobookProgress);
expect(deleteFn).toHaveBeenCalledWith(koreaderDeviceProgress);
expect(deleteFn).toHaveBeenCalledWith(koreaderPageStats);
expect(deleteFn).toHaveBeenCalledWith(readingSessions);
expect(deleteFn).toHaveBeenCalledWith(userReadingDailyStats);
expect(statusValues).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7,
bookId: 42,
status: 'unread',
source: 'manual',
startedAt: null,
finishedAt: null,
}),
);
expect(statusConflict).toHaveBeenCalledWith(
expect.objectContaining({
target: [userBookStatus.userId, userBookStatus.bookId],
set: expect.objectContaining({ status: 'unread', source: 'manual', startedAt: null, finishedAt: null }),
}),
);
expect(update).toHaveBeenCalledWith(koboReadingStates);
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
currentBookmark: expect.objectContaining({ ProgressPercent: 0 }),
statusInfo: expect.objectContaining({ Status: 'ReadyToRead', TimesStartedReading: 0 }),
}),
);
expect(tx.execute).toHaveBeenCalledTimes(2);
expect(dailyValues).toHaveBeenCalledWith([
expect.objectContaining({
userId: 7,
libraryId: 3,
day: '2026-04-15',
readingSeconds: 1800,
progressDelta: 6,
sessionsCount: 1,
}),
]);
expect(dailyConflict).toHaveBeenCalledOnce();
expect(result).toMatchObject({
readStatus: { status: 'unread', source: 'manual', startedAt: null, finishedAt: null },
sessionsDeleted: 1,
progressDeleted: 2,
audioProgressDeleted: 1,
koreaderDeviceProgressDeleted: 3,
koreaderPageStatsDeleted: 4,
koboStateReset: true,
});
});
it('fails before destructive work when the book no longer exists', async () => {
const { repo, transaction, tx } = makeHarness([[]]);
await expect(repo.resetBookReadingState(7, 404, 'UTC')).rejects.toThrow(NotFoundException);
expect(transaction).toHaveBeenCalledOnce();
expect(tx.execute).not.toHaveBeenCalled();
expect(tx.delete).not.toHaveBeenCalled();
expect(tx.insert).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,305 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { and, asc, count, eq, gt, inArray, lt, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { UserBookStatus } from '@bookorbit/types';
import {
addDateKeyDays,
getDayRangeForDateKeys,
getReadingSessionDayKeys,
splitReadingSessionByDay,
type ReadingDailyStatsSegment,
} from '../../common/utils/reading-daily-stats.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import {
audiobookProgress,
bookFiles,
books,
koboLibrarySnapshots,
koboReadingStates,
koboSnapshotBooks,
koreaderDeviceProgress,
koreaderPageStats,
readingProgress,
readingSessions,
userBookStatus,
userReadingDailyStats,
} from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
const RESET_BATCH_SIZE = 250;
export interface ResetReadingStateResult {
readStatus: UserBookStatus;
sessionsDeleted: number;
progressDeleted: number;
audioProgressDeleted: number;
koreaderDeviceProgressDeleted: number;
koreaderPageStatsDeleted: number;
koboStateReset: boolean;
}
@Injectable()
export class ReadingStateRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async resetBookReadingState(userId: number, bookId: number, timeZone: string): Promise<ResetReadingStateResult> {
return this.db.transaction(async (tx) => {
const [book] = await tx.select({ libraryId: books.libraryId }).from(books).where(eq(books.id, bookId)).limit(1);
if (!book) throw new NotFoundException(`Book ${bookId} not found`);
await this.lockDailyStats(tx, userId, book.libraryId);
const { sessionsDeleted, affectedDays } = await this.collectAffectedSessionDays(tx, userId, bookId, timeZone);
const fileIds = tx.select({ id: bookFiles.id }).from(bookFiles).where(eq(bookFiles.bookId, bookId));
const [progressCount, audioProgressCount, koreaderProgressCount, koreaderPageStatsCount, koboState] = await Promise.all([
this.countRows(tx, readingProgress, and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, fileIds))),
this.countRows(tx, audiobookProgress, and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.bookId, bookId))),
this.countRows(
tx,
koreaderDeviceProgress,
and(eq(koreaderDeviceProgress.userId, userId), inArray(koreaderDeviceProgress.bookFileId, fileIds)),
),
this.countRows(tx, koreaderPageStats, and(eq(koreaderPageStats.userId, userId), inArray(koreaderPageStats.bookFileId, fileIds))),
tx
.select({ id: koboReadingStates.id })
.from(koboReadingStates)
.where(and(eq(koboReadingStates.userId, userId), eq(koboReadingStates.bookId, bookId)))
.limit(1),
]);
const now = new Date();
const nowIso = now.toISOString();
await Promise.all([
tx.delete(readingProgress).where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, fileIds))),
tx.delete(audiobookProgress).where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.bookId, bookId))),
tx.delete(koreaderDeviceProgress).where(and(eq(koreaderDeviceProgress.userId, userId), inArray(koreaderDeviceProgress.bookFileId, fileIds))),
tx.delete(koreaderPageStats).where(and(eq(koreaderPageStats.userId, userId), inArray(koreaderPageStats.bookFileId, fileIds))),
tx.delete(readingSessions).where(and(eq(readingSessions.userId, userId), eq(readingSessions.bookId, bookId))),
tx
.insert(userBookStatus)
.values({ userId, bookId, status: 'unread', source: 'manual', startedAt: null, finishedAt: null, updatedAt: now })
.onConflictDoUpdate({
target: [userBookStatus.userId, userBookStatus.bookId],
set: { status: 'unread', source: 'manual', startedAt: null, finishedAt: null, updatedAt: now },
}),
]);
const koboStateReset = koboState.length > 0;
if (koboStateReset) {
await Promise.all([
tx
.update(koboReadingStates)
.set({
lastModifiedKobo: nowIso,
priorityTimestamp: nowIso,
currentBookmark: { LastModified: nowIso, ProgressPercent: 0 },
statistics: { LastModified: nowIso },
statusInfo: { LastModified: nowIso, Status: 'ReadyToRead', TimesStartedReading: 0 },
updatedAt: now,
})
.where(and(eq(koboReadingStates.userId, userId), eq(koboReadingStates.bookId, bookId))),
tx.execute(sql`
UPDATE ${koboSnapshotBooks} AS sb
SET synced = false,
is_new = false
FROM ${koboLibrarySnapshots} AS snap
WHERE snap.id = sb.snapshot_id
AND snap.user_id = ${userId}
AND sb.book_id = ${bookId}
AND sb.pending_delete = false
AND sb.removed_by_device = false
`),
]);
}
await this.recomputeDailyStats(tx, userId, book.libraryId, affectedDays, timeZone);
return {
readStatus: {
status: 'unread',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: nowIso,
},
sessionsDeleted,
progressDeleted: progressCount,
audioProgressDeleted: audioProgressCount,
koreaderDeviceProgressDeleted: koreaderProgressCount,
koreaderPageStatsDeleted: koreaderPageStatsCount,
koboStateReset,
};
});
}
private async countRows(tx: Tx, table: typeof readingProgress, where: ReturnType<typeof and>): Promise<number>;
private async countRows(tx: Tx, table: typeof audiobookProgress, where: ReturnType<typeof and>): Promise<number>;
private async countRows(tx: Tx, table: typeof koreaderDeviceProgress, where: ReturnType<typeof and>): Promise<number>;
private async countRows(tx: Tx, table: typeof koreaderPageStats, where: ReturnType<typeof and>): Promise<number>;
private async countRows(
tx: Tx,
table: typeof readingProgress | typeof audiobookProgress | typeof koreaderDeviceProgress | typeof koreaderPageStats,
where: ReturnType<typeof and>,
) {
const [row] = await tx.select({ total: count() }).from(table).where(where);
return Number(row?.total ?? 0);
}
private async collectAffectedSessionDays(
tx: Tx,
userId: number,
bookId: number,
timeZone: string,
): Promise<{ sessionsDeleted: number; affectedDays: string[] }> {
const affectedDays = new Set<string>();
let sessionsDeleted = 0;
let lastId = 0;
while (true) {
const rows = await tx
.select({
id: readingSessions.id,
startedAt: readingSessions.startedAt,
endedAt: readingSessions.endedAt,
durationSeconds: readingSessions.durationSeconds,
progressDelta: readingSessions.progressDelta,
})
.from(readingSessions)
.where(and(eq(readingSessions.userId, userId), eq(readingSessions.bookId, bookId), gt(readingSessions.id, lastId)))
.orderBy(asc(readingSessions.id))
.limit(RESET_BATCH_SIZE);
if (rows.length === 0) break;
for (const row of rows) {
sessionsDeleted += 1;
for (const day of getReadingSessionDayKeys(row, timeZone)) {
affectedDays.add(day);
}
}
lastId = rows[rows.length - 1]!.id;
if (rows.length < RESET_BATCH_SIZE) break;
}
return { sessionsDeleted, affectedDays: [...affectedDays].sort() };
}
private async recomputeDailyStats(tx: Tx, userId: number, libraryId: number, days: string[], timeZone: string): Promise<void> {
for (const dayBatch of this.groupDays(days)) {
await tx
.delete(userReadingDailyStats)
.where(
and(eq(userReadingDailyStats.userId, userId), eq(userReadingDailyStats.libraryId, libraryId), inArray(userReadingDailyStats.day, dayBatch)),
);
const range = getDayRangeForDateKeys(dayBatch, timeZone);
if (!range) continue;
const daySet = new Set(dayBatch);
const segmentsByDay = new Map<string, ReadingDailyStatsSegment>();
let lastId = 0;
while (true) {
const rows = await tx
.select({
id: readingSessions.id,
startedAt: readingSessions.startedAt,
endedAt: readingSessions.endedAt,
durationSeconds: readingSessions.durationSeconds,
progressDelta: readingSessions.progressDelta,
})
.from(readingSessions)
.innerJoin(books, eq(books.id, readingSessions.bookId))
.where(
and(
eq(readingSessions.userId, userId),
eq(books.libraryId, libraryId),
lt(readingSessions.startedAt, range.end),
gt(readingSessions.endedAt, range.start),
gt(readingSessions.id, lastId),
),
)
.orderBy(asc(readingSessions.id))
.limit(RESET_BATCH_SIZE);
if (rows.length === 0) break;
for (const row of rows) {
for (const segment of splitReadingSessionByDay(row, timeZone)) {
if (!daySet.has(segment.day)) continue;
const current = segmentsByDay.get(segment.day);
if (current) {
current.readingSeconds += segment.readingSeconds;
current.progressDelta += segment.progressDelta;
current.sessionsCount += segment.sessionsCount;
} else {
segmentsByDay.set(segment.day, { ...segment });
}
}
}
lastId = rows[rows.length - 1]!.id;
if (rows.length < RESET_BATCH_SIZE) break;
}
await this.insertDailyStatsSegments(tx, userId, libraryId, [...segmentsByDay.values()]);
}
}
private groupDays(days: string[]): string[][] {
const sorted = [...new Set(days)].sort();
const groups: string[][] = [];
let current: string[] = [];
for (const day of sorted) {
const previous = current[current.length - 1];
if (previous && (day !== addDateKeyDays(previous, 1) || current.length === RESET_BATCH_SIZE)) {
groups.push(current);
current = [];
}
current.push(day);
}
if (current.length > 0) groups.push(current);
return groups;
}
private async insertDailyStatsSegments(tx: Tx, userId: number, libraryId: number, segments: ReadingDailyStatsSegment[]): Promise<void> {
for (let index = 0; index < segments.length; index += RESET_BATCH_SIZE) {
const batch = segments.slice(index, index + RESET_BATCH_SIZE);
const now = new Date();
await tx
.insert(userReadingDailyStats)
.values(
batch.map((segment) => ({
userId,
libraryId,
day: segment.day,
readingSeconds: segment.readingSeconds,
progressDelta: segment.progressDelta,
sessionsCount: segment.sessionsCount,
updatedAt: now,
})),
)
.onConflictDoUpdate({
target: [userReadingDailyStats.userId, userReadingDailyStats.libraryId, userReadingDailyStats.day],
set: {
readingSeconds: sql`excluded.reading_seconds`,
progressDelta: sql`excluded.progress_delta`,
sessionsCount: sql`excluded.sessions_count`,
updatedAt: now,
},
});
}
}
private async lockDailyStats(tx: Tx, userId: number, libraryId: number): Promise<void> {
await tx.execute(sql`select pg_advisory_xact_lock(${userId}::int, ${libraryId}::int)`);
}
}
@@ -0,0 +1,99 @@
import { ForbiddenException, Logger } from '@nestjs/common';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { BookService } from '../book/book.service';
import { ReadingStateRepository, type ResetReadingStateResult } from './reading-state.repository';
import { ReadingStateService } from './reading-state.service';
function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
return {
id: 7,
username: 'reader',
name: 'Reader',
email: null,
active: true,
isSuperuser: false,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
permissions: [],
contentFilters: EMPTY_CONTENT_FILTER_RULES,
...overrides,
};
}
function makeResult(overrides: Partial<ResetReadingStateResult> = {}): ResetReadingStateResult {
return {
readStatus: {
status: 'unread',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: '2026-07-09T12:00:00.000Z',
},
sessionsDeleted: 3,
progressDeleted: 2,
audioProgressDeleted: 1,
koreaderDeviceProgressDeleted: 4,
koreaderPageStatsDeleted: 5,
koboStateReset: true,
...overrides,
};
}
function makeService() {
const bookService = {
verifyBookAccess: vi.fn().mockResolvedValue(undefined),
};
const repo = {
resetBookReadingState: vi.fn().mockResolvedValue(makeResult()),
};
return {
service: new ReadingStateService(bookService as unknown as BookService, repo as unknown as ReadingStateRepository),
bookService,
repo,
};
}
describe('ReadingStateService', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
it('verifies book access, uses the user timezone, and returns only the response contract', async () => {
const { service, bookService, repo } = makeService();
const user = makeUser({ settings: { timezone: 'America/Denver' } });
const result = await service.resetBookReadingState(42, user);
expect(bookService.verifyBookAccess).toHaveBeenCalledWith(42, user);
expect(repo.resetBookReadingState).toHaveBeenCalledWith(7, 42, 'America/Denver');
expect(result).toEqual({ readStatus: makeResult().readStatus });
});
it('falls back to UTC for an invalid stored timezone', async () => {
const { service, repo } = makeService();
await service.resetBookReadingState(42, makeUser({ settings: { timezone: 'not/a-timezone' } }));
expect(repo.resetBookReadingState).toHaveBeenCalledWith(7, 42, 'UTC');
});
it('propagates access failures, skips the repository, and emits a sanitized failure log', async () => {
const { service, bookService, repo } = makeService();
const warnSpy = vi.spyOn(Logger.prototype, 'warn');
const error = new ForbiddenException('cannot reset "another user"\nstate');
bookService.verifyBookAccess.mockRejectedValue(error);
await expect(service.resetBookReadingState(42, makeUser())).rejects.toThrow(error);
expect(repo.resetBookReadingState).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('error="cannot reset \\"another user\\" state" - reset reading state failed'));
});
});
@@ -0,0 +1,41 @@
import { Injectable, Logger } from '@nestjs/common';
import type { ResetBookReadingStateResponse } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { resolveTimeZone } from '../../common/utils/timezone.utils';
import type { RequestUser } from '../../common/types/request-user';
import { BookService } from '../book/book.service';
import { ReadingStateRepository } from './reading-state.repository';
@Injectable()
export class ReadingStateService {
private readonly logger = new Logger(ReadingStateService.name);
constructor(
private readonly bookService: BookService,
private readonly repo: ReadingStateRepository,
) {}
async resetBookReadingState(bookId: number, user: RequestUser): Promise<ResetBookReadingStateResponse> {
const event = 'book.reading_state.reset';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] bookId=${bookId} userId=${user.id} - reset reading state started`);
try {
await this.bookService.verifyBookAccess(bookId, user);
const timeZone = resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC');
const result = await this.repo.resetBookReadingState(user.id, bookId, timeZone);
this.logger.log(
`[${event}] [end] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} sessionsDeleted=${result.sessionsDeleted} progressDeleted=${result.progressDeleted} audioProgressDeleted=${result.audioProgressDeleted} koreaderDeviceProgressDeleted=${result.koreaderDeviceProgressDeleted} koreaderPageStatsDeleted=${result.koreaderPageStatsDeleted} koboStateReset=${result.koboStateReset} - reset reading state completed`,
);
return { readStatus: result.readStatus };
} catch (error) {
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
const errorMessage = sanitizeLogValue(error instanceof Error ? error.message : 'unknown error');
this.logger.warn(
`[${event}] [fail] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - reset reading state failed`,
);
throw error;
}
}
}
@@ -181,7 +181,7 @@ describe('updateManual', () => {
expect(mockAchievementEvents.emit).not.toHaveBeenCalled();
});
it('does not seed dates or fetch boundaries for unread and want_to_read statuses', async () => {
it('clears existing lifecycle dates and does not fetch boundaries for unread and want_to_read statuses', async () => {
mockRepo.findSessionBoundariesForBook.mockResolvedValue({
firstStartedAt: new Date('2024-06-01T10:00:00.000Z'),
lastEndedAt: new Date('2024-12-01T22:00:00.000Z'),
@@ -189,7 +189,14 @@ describe('updateManual', () => {
for (const status of ['unread', 'want_to_read'] as const) {
vi.clearAllMocks();
mockRepo.findOne.mockResolvedValue(makeRow({ startedAt: null, finishedAt: null, status: 'reading', source: 'auto' }));
mockRepo.findOne.mockResolvedValue(
makeRow({
startedAt: new Date('2024-06-01T10:00:00.000Z'),
finishedAt: new Date('2024-12-01T22:00:00.000Z'),
status: 'reading',
source: 'auto',
}),
);
mockRepo.upsertState.mockResolvedValue(undefined);
mockRepo.findSessionBoundariesForBook.mockResolvedValue({
firstStartedAt: new Date('2024-06-01T10:00:00.000Z'),
@@ -199,6 +206,11 @@ describe('updateManual', () => {
const result = await service.updateManual(1, 10, { status });
expect(mockRepo.findSessionBoundariesForBook).not.toHaveBeenCalled();
expect(mockRepo.upsertState).toHaveBeenCalledWith(
1,
10,
expect.objectContaining({ status, source: 'manual', startedAt: null, finishedAt: null }),
);
expect(result.startedAt).toBeNull();
expect(result.finishedAt).toBeNull();
}
@@ -316,6 +328,25 @@ describe('bulkSetManual', () => {
expect.objectContaining({ userId: 1, bookId: 12, previousStatus: null, newStatus: 'reading' }),
);
});
it.each(['unread', 'want_to_read'] as const)('clears lifecycle dates when bulk-setting %s', async (status) => {
const startedAt = new Date('2026-04-01T00:00:00.000Z');
const finishedAt = new Date('2026-04-12T00:00:00.000Z');
mockRepo.findByBookIds.mockResolvedValue([makeRow({ bookId: 10, status: 'reading', startedAt, finishedAt })]);
await service.bulkSetManual(1, [10, 11], status);
expect(mockRepo.upsertState).toHaveBeenCalledWith(
1,
10,
expect.objectContaining({ status, source: 'manual', startedAt: null, finishedAt: null }),
);
expect(mockRepo.upsertState).toHaveBeenCalledWith(
1,
11,
expect.objectContaining({ status, source: 'manual', startedAt: null, finishedAt: null }),
);
});
});
describe('autoUpdate with default thresholds', () => {
@@ -38,6 +38,11 @@ export class UserBookStatusService {
let nextStartedAt = hasStartedAt ? (patch.startedAt ?? null) : (existing?.startedAt ?? null);
let nextFinishedAt = hasFinishedAt ? (patch.finishedAt ?? null) : (existing?.finishedAt ?? null);
if (nextStatus === 'unread' || nextStatus === 'want_to_read') {
nextStartedAt = null;
nextFinishedAt = null;
}
// When dates were not explicitly provided and would be null, seed from session history.
// Explicit null patches (user clearing a date) are preserved as-is.
// Mirror the same status guard used in repo.upsert(): unread/want_to_read always have null dates.
@@ -93,11 +98,12 @@ export class UserBookStatusService {
await Promise.all(
bookIds.map((bookId) => {
const current = existingMap.get(bookId) ?? null;
const clearsLifecycle = status === 'unread' || status === 'want_to_read';
return this.repo.upsertState(userId, bookId, {
status,
source: 'manual',
startedAt: current?.startedAt ?? null,
finishedAt: current?.finishedAt ?? null,
startedAt: clearsLifecycle ? null : (current?.startedAt ?? null),
finishedAt: clearsLifecycle ? null : (current?.finishedAt ?? null),
updatedAt: now,
});
}),