diff --git a/client/src/features/reader/ReaderView.vue b/client/src/features/reader/ReaderView.vue index ae9e17762..85b20100a 100644 --- a/client/src/features/reader/ReaderView.vue +++ b/client/src/features/reader/ReaderView.vue @@ -3,6 +3,7 @@ import { nextTick, onMounted, onUnmounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useFoliate, type RelocateDetail } from './epub/composables/useFoliate' import { useReaderProgress } from './shared/composables/useReaderProgress' +import { useReadingSession } from './shared/composables/useReadingSession' import { useReaderState } from './epub/composables/useReaderState' import { useReaderSettings } from './shared/composables/useReaderSettings' import { useVisibility } from './shared/composables/useVisibility' @@ -66,6 +67,12 @@ const { const progress = useReaderProgress(bookId, fileId) const { cfi, chapterTitle, sectionIndex, totalSections, fraction, updateHeadsFeet } = progress +const { onActivity } = useReadingSession(fileId, () => ({ + percentage: progress.percentage.value, + cfi: progress.cfi.value, + pageNumber: progress.pageNumber.value, +})) + const visibility = useVisibility() const { headerVisible, footerVisible, handleMiddleTap, showHeader, showFooter } = visibility @@ -82,6 +89,7 @@ const selection = useReaderSelection() function onRelocateHandler(detail: RelocateDetail) { progress.onRelocate(detail) + onActivity() bookmarks.setCfi(detail?.cfi ?? null) toc.setActiveHref(detail?.tocItem?.href ?? '') const renderer = getRenderer() diff --git a/client/src/features/reader/cbz/CbzReaderView.vue b/client/src/features/reader/cbz/CbzReaderView.vue index d1c6e1258..caa0bee8d 100644 --- a/client/src/features/reader/cbz/CbzReaderView.vue +++ b/client/src/features/reader/cbz/CbzReaderView.vue @@ -4,6 +4,7 @@ import { useRouter } from 'vue-router' import { ArrowLeft, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings } from 'lucide-vue-next' import { useVisibility } from '../shared/composables/useVisibility' import { useReaderProgress } from '../shared/composables/useReaderProgress' +import { useReadingSession } from '../shared/composables/useReadingSession' import { useCbz } from './composables/useCbz' import { useCbzSettings } from './composables/useCbzSettings' import { useReaderSettings } from '../shared/composables/useReaderSettings' @@ -16,6 +17,11 @@ const router = useRouter() const progress = useReaderProgress(props.bookId, props.fileId) const { headerVisible, footerVisible, handleMiddleTap, showHeader, showFooter } = useVisibility() + +const { onActivity } = useReadingSession(props.fileId, () => ({ + percentage: progress.percentage.value, + pageNumber: progress.pageNumber.value, +})) const { pageCount, bookTitle, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId) const { fitMode, viewMode, scrollMode, direction, bgColor, bgValue, isTwoPage, imgFitClass } = useCbzSettings() const bookSettings = useReaderSettings(props.fileId, 'cbz') @@ -198,6 +204,7 @@ let saveTimer: ReturnType | null = null watch(currentPage, (page) => { schedulePreload(page) + onActivity() if (saveTimer) clearTimeout(saveTimer) saveTimer = setTimeout(() => { progress.pageNumber.value = page + 1 diff --git a/client/src/features/reader/pdf/PdfReaderView.vue b/client/src/features/reader/pdf/PdfReaderView.vue index ecd4e36ef..ca428a8d4 100644 --- a/client/src/features/reader/pdf/PdfReaderView.vue +++ b/client/src/features/reader/pdf/PdfReaderView.vue @@ -9,6 +9,7 @@ import { usePdfRenderer } from './composables/usePdfRenderer' import { usePdfFind } from './composables/usePdfFind' import { usePdfOutline } from './composables/usePdfOutline' import { useReaderProgress } from '../shared/composables/useReaderProgress' +import { useReadingSession } from '../shared/composables/useReadingSession' import { useReaderSettings } from '../shared/composables/useReaderSettings' import PdfToolbar from './components/PdfToolbar.vue' import PdfFindBar from './components/FindBar.vue' @@ -23,6 +24,11 @@ const { pdfDoc, totalPages, loading, error, load, getPageDim, startRenderPage, g const progress = useReaderProgress(props.bookId, props.fileId) const bookSettings = useReaderSettings(props.fileId, 'pdf') +const { onActivity } = useReadingSession(props.fileId, () => ({ + percentage: progress.percentage.value, + pageNumber: progress.pageNumber.value, +})) + // ── Layout container ────────────────────────────────────────────────────────── const scrollRef = ref(null) const containerW = ref(0) @@ -137,6 +143,7 @@ watch([spread, scrollMode], async () => { let saveTimer: ReturnType | null = null watch(currentPage, (page) => { + onActivity() if (saveTimer) clearTimeout(saveTimer) saveTimer = setTimeout(() => { progress.pageNumber.value = page diff --git a/client/src/features/reader/shared/composables/useReaderProgress.ts b/client/src/features/reader/shared/composables/useReaderProgress.ts index 581692a76..d50119ad6 100644 --- a/client/src/features/reader/shared/composables/useReaderProgress.ts +++ b/client/src/features/reader/shared/composables/useReaderProgress.ts @@ -1,4 +1,4 @@ -import { ref } from 'vue' +import { onUnmounted, ref } from 'vue' import { api } from '@/lib/api' import type { FoliateRenderer, RelocateDetail } from '../../epub/composables/useFoliate' @@ -10,13 +10,12 @@ export function useReaderProgress(bookId: number, fileId: number) { const sectionIndex = ref(0) const totalSections = ref(0) const fraction = ref(0) - const sessionId = - typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` let saveTimer: ReturnType | null = null - let eventSequence = 0 + + onUnmounted(() => { + if (saveTimer) clearTimeout(saveTimer) + }) async function load() { const res = await api(`/api/v1/books/files/${fileId}/progress`) @@ -40,7 +39,6 @@ export function useReaderProgress(bookId: number, fileId: number) { } async function save() { - eventSequence += 1 await api(`/api/v1/books/files/${fileId}/progress`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -48,8 +46,6 @@ export function useReaderProgress(bookId: number, fileId: number) { cfi: cfi.value, pageNumber: pageNumber.value, percentage: percentage.value, - eventKey: `${sessionId}:${eventSequence}`, - source: 'reader-web', }), }) } diff --git a/client/src/features/reader/shared/composables/useReadingSession.ts b/client/src/features/reader/shared/composables/useReadingSession.ts new file mode 100644 index 000000000..d9acce8bc --- /dev/null +++ b/client/src/features/reader/shared/composables/useReadingSession.ts @@ -0,0 +1,132 @@ +import { onUnmounted } from 'vue' +import { api } from '@/lib/api' + +export interface ProgressSnapshot { + percentage: number + cfi?: string | null + pageNumber?: number | null +} + +const IDLE_TIMEOUT_MS = 5 * 60 * 1000 +const MIN_SESSION_MS = 10 * 1000 + +function generateSessionId(): string { + return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +export function useReadingSession(bookFileId: number, getProgress: () => ProgressSnapshot) { + let sessionId = generateSessionId() + let startedAt: Date | null = null + let activeMs = 0 + let activeStart: number | null = null + let idleTimer: ReturnType | null = null + let startProgress: number | null = null + let ended = false + + function startSession() { + startedAt = new Date() + activeStart = Date.now() + activeMs = 0 + ended = false + startProgress = getProgress().percentage + resetIdleTimer() + } + + function pauseTimer() { + if (activeStart !== null) { + activeMs += Date.now() - activeStart + activeStart = null + } + clearIdleTimer() + } + + function resumeTimer() { + if (activeStart !== null) return + activeStart = Date.now() + resetIdleTimer() + } + + function resetIdleTimer() { + clearIdleTimer() + idleTimer = setTimeout(endSession, IDLE_TIMEOUT_MS) + } + + function clearIdleTimer() { + if (idleTimer !== null) { + clearTimeout(idleTimer) + idleTimer = null + } + } + + function onActivity() { + // No active session or previous session ended (e.g. after idle timeout) — start fresh. + if (!startedAt || ended) { + sessionId = generateSessionId() + startSession() + return + } + if (activeStart === null) resumeTimer() + resetIdleTimer() + } + + function endSession(useBeacon = false) { + if (ended || !startedAt) return + ended = true + clearIdleTimer() + + if (activeStart !== null) { + activeMs += Date.now() - activeStart + activeStart = null + } + + if (activeMs < MIN_SESSION_MS) return + + const endedAt = new Date() + const snap = getProgress() + const durationSeconds = Math.floor(activeMs / 1000) + const progressDelta = startProgress !== null ? Number((snap.percentage - startProgress).toFixed(4)) : null + const endProgress = snap.percentage + + const payload = JSON.stringify({ + sessionId, + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + durationSeconds, + progressDelta, + endProgress, + }) + + const url = `/api/v1/books/files/${bookFileId}/sessions` + + if (useBeacon && typeof navigator.sendBeacon === 'function') { + navigator.sendBeacon(url, new Blob([payload], { type: 'application/json' })) + } else { + api(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload }).catch(() => {}) + } + } + + function onVisibilityChange() { + if (document.visibilityState === 'hidden') { + pauseTimer() + } else if (startedAt && !ended) { + resumeTimer() + } + } + + function onBeforeUnload() { + endSession(true) + } + + document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('beforeunload', onBeforeUnload) + + onUnmounted(() => { + endSession() + document.removeEventListener('visibilitychange', onVisibilityChange) + window.removeEventListener('beforeunload', onBeforeUnload) + }) + + return { onActivity, endSession } +} diff --git a/client/typecheck-baseline.txt b/client/typecheck-baseline.txt index c1ec7d3a3..d23bb8bca 100644 --- a/client/typecheck-baseline.txt +++ b/client/typecheck-baseline.txt @@ -24,9 +24,9 @@ src/features/reader/pdf/composables/usePdfRenderer.ts(97,17): error TS18048: 'e' src/features/reader/pdf/composables/usePdfRenderer.ts(98,34): error TS18048: 'f' is possibly 'undefined'. src/features/reader/pdf/composables/usePdfRenderer.ts(99,31): error TS18048: 'a' is possibly 'undefined'. src/features/reader/pdf/composables/usePdfRenderer.ts(99,50): error TS18048: 'b' is possibly 'undefined'. -src/features/reader/ReaderView.vue(149,15): error TS2345: Argument of type 'unknown[]' is not assignable to parameter of type 'RawTocItem[]'. -src/features/reader/ReaderView.vue(241,12): error TS2345: Argument of type '{}' is not assignable to parameter of type 'FoliateView'. -src/features/reader/ReaderView.vue(253,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'FoliateView | null'. +src/features/reader/ReaderView.vue(157,15): error TS2345: Argument of type 'unknown[]' is not assignable to parameter of type 'RawTocItem[]'. +src/features/reader/ReaderView.vue(249,12): error TS2345: Argument of type '{}' is not assignable to parameter of type 'FoliateView'. +src/features/reader/ReaderView.vue(261,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'FoliateView | null'. src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,25): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'. src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'. src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,21): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'. diff --git a/scripts/seed/reading-sessions.sh b/scripts/seed/reading-sessions.sh new file mode 100755 index 000000000..25c91ffae --- /dev/null +++ b/scripts/seed/reading-sessions.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Seeds synthetic reading sessions and daily stats for the first user so that +# all user-statistics charts (heatmap, peak hours, completion timeline, etc.) +# show realistic data out of the box. +# +# Safe to run multiple times - sessions are deduplicated by session_id and +# daily stats are upserted. Running again adds more data on top. +# +# Usage: ./scripts/seed/reading-sessions.sh [USER_ID] +# USER_ID defaults to 1. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +POSTGRES_USER="${POSTGRES_USER:-projectx}" +POSTGRES_DB="${POSTGRES_DB:-projectx}" +TARGET_USER_ID="${1:-1}" + +echo "Seeding reading sessions for user_id=${TARGET_USER_ID}..." + +docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" << EOSQL + +DO \$\$ +DECLARE + v_user_id int := ${TARGET_USER_ID}; + v_day date; + v_file_id int; + v_library_id int; + v_sessions int; + v_i int; + v_hour int; + v_duration int; + v_started timestamptz; + v_ended timestamptz; + v_end_pct real; + v_delta real; + v_session_id text; + v_dow int; + v_file_ids int[]; + v_total_sessions int := 0; + v_total_completions int := 0; +BEGIN + -- Verify user exists + IF NOT EXISTS (SELECT 1 FROM users WHERE id = v_user_id) THEN + RAISE EXCEPTION 'User with id % not found', v_user_id; + END IF; + + -- Verify there are book files to work with + IF NOT EXISTS (SELECT 1 FROM book_files LIMIT 1) THEN + RAISE EXCEPTION 'No book files found - scan a library first'; + END IF; + + -- Pick up to 40 real file ids to spread sessions across + SELECT array_agg(id ORDER BY random()) INTO v_file_ids + FROM (SELECT id FROM book_files ORDER BY random() LIMIT 40) t; + + -- Spread books.added_at across the past 3 years so Format Share Over Time + -- chart has data across multiple months instead of a single spike today. + UPDATE books SET added_at = now() - (random() * interval '3 years') + WHERE added_at > now() - interval '7 days'; + + -- Insert sessions across the past 365 days + FOR v_day IN + SELECT generate_series( + current_date - interval '364 days', + current_date, + interval '1 day' + )::date + LOOP + v_dow := extract(dow from v_day); -- 0=Sun 6=Sat + + -- Weekends: 1-3 sessions. Weekdays: 0-2 (30% chance of 0). + IF v_dow IN (0, 6) THEN + v_sessions := 1 + floor(random() * 3)::int; + ELSIF random() < 0.30 THEN + v_sessions := 0; + ELSE + v_sessions := 1 + floor(random() * 2)::int; + END IF; + + FOR v_i IN 1..v_sessions LOOP + v_file_id := v_file_ids[1 + floor(random() * array_length(v_file_ids, 1))::int]; + + SELECT b.library_id INTO v_library_id + FROM book_files bf JOIN books b ON b.id = bf.book_id + WHERE bf.id = v_file_id; + + -- Realistic hour distribution: mostly evenings, some mornings/afternoons + IF random() < 0.60 THEN + v_hour := 18 + floor(random() * 6)::int; -- 18-23 evening + ELSIF random() < 0.50 THEN + v_hour := 7 + floor(random() * 3)::int; -- 7-9 morning + ELSE + v_hour := 12 + floor(random() * 4)::int; -- 12-15 afternoon + END IF; + + v_started := (v_day + make_interval(hours => v_hour, mins => floor(random() * 60)::int))::timestamptz; + v_duration := 600 + floor(random() * 3000)::int; -- 10-60 min + v_ended := v_started + make_interval(secs => v_duration); + v_delta := round((1 + random() * 8)::numeric, 4); -- 1-9% progress + -- ~15% of sessions are completions (every 7th deterministically by loop) + IF (v_total_sessions % 7) = 0 THEN + v_end_pct := 100; + v_total_completions := v_total_completions + 1; + ELSE + v_end_pct := round((10 + random() * 80)::numeric, 2); + END IF; + + v_session_id := md5(v_user_id::text || v_file_id::text || v_started::text || v_i::text); + + INSERT INTO reading_sessions + (user_id, book_file_id, session_id, started_at, ended_at, duration_seconds, progress_delta, end_progress) + VALUES + (v_user_id, v_file_id, v_session_id, v_started, v_ended, v_duration, v_delta, v_end_pct) + ON CONFLICT (session_id) DO NOTHING; + + -- Real-time upsert of daily stats (mirrors what the server does on session save) + INSERT INTO user_reading_daily_stats + (user_id, library_id, day, reading_seconds, progress_delta, sessions_count, updated_at) + VALUES + (v_user_id, v_library_id, v_day, v_duration, v_delta, 1, now()) + ON CONFLICT (user_id, library_id, day) DO UPDATE SET + reading_seconds = user_reading_daily_stats.reading_seconds + excluded.reading_seconds, + progress_delta = user_reading_daily_stats.progress_delta + excluded.progress_delta, + sessions_count = user_reading_daily_stats.sessions_count + 1, + updated_at = now(); + + v_total_sessions := v_total_sessions + 1; + END LOOP; + END LOOP; + + RAISE NOTICE 'Done: % sessions inserted (% completions)', v_total_sessions, v_total_completions; +END; +\$\$; + +EOSQL + +echo "Reading session seed complete." diff --git a/server/package.json b/server/package.json index 14634629e..568ef8202 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,7 @@ "db:migrate": "drizzle-kit migrate", "db:migrate:runtime": "node dist/scripts/migrate.js", "db:studio": "drizzle-kit studio", + "db:seed": "tsx src/scripts/db-seed.ts", "pretype-check": "pnpm --filter @projectx/types build", "type-check": "tsc --noEmit -p tsconfig.build.json", "lint": "pnpm run lint:check", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index b8c0a966c..f71783974 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -47,6 +47,7 @@ import { BookMetadataFetchModule } from './modules/book-metadata-fetch/book-meta import { MetadataScoreModule } from './modules/metadata-score/metadata-score.module'; 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 { AuditModule } from './modules/audit/audit.module'; @Module({ @@ -94,6 +95,7 @@ import { AuditModule } from './modules/audit/audit.module'; MetadataScoreModule, StatisticsModule, UserStatisticsModule, + ReadingSessionModule, AuditModule, ], providers: [ diff --git a/server/src/db/migrations/0000_baseline.sql b/server/src/db/migrations/0000_baseline.sql index ec456a363..d4103b48b 100644 --- a/server/src/db/migrations/0000_baseline.sql +++ b/server/src/db/migrations/0000_baseline.sql @@ -1,5 +1,3 @@ -CREATE EXTENSION IF NOT EXISTS vector;--> statement-breakpoint -CREATE EXTENSION IF NOT EXISTS pg_trgm;--> statement-breakpoint CREATE TYPE "public"."library_access_level" AS ENUM('viewer', 'editor', 'owner');--> statement-breakpoint CREATE TYPE "public"."opds_sort_order" AS ENUM('recent', 'title_asc', 'title_desc', 'author_asc', 'author_desc', 'series_asc', 'series_desc');--> statement-breakpoint CREATE TABLE "audit_log" ( @@ -325,20 +323,16 @@ CREATE TABLE "reading_progress" ( CONSTRAINT "reading_progress_book_file_id_user_id_pk" PRIMARY KEY("book_file_id","user_id") ); --> statement-breakpoint -CREATE TABLE "reading_session_events" ( +CREATE TABLE "reading_sessions" ( "id" serial PRIMARY KEY NOT NULL, "user_id" integer NOT NULL, "book_file_id" integer NOT NULL, - "event_key" varchar(120) NOT NULL, - "recorded_at" timestamp DEFAULT now() NOT NULL, - "percentage" real NOT NULL, - "percentage_delta" real DEFAULT 0 NOT NULL, - "page_number" integer, - "page_delta" integer DEFAULT 0 NOT NULL, - "delta_seconds" integer DEFAULT 0 NOT NULL, - "source" varchar(40) DEFAULT 'reader' NOT NULL, - "synthetic" boolean DEFAULT false NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL + "session_id" varchar(64) NOT NULL, + "started_at" timestamp NOT NULL, + "ended_at" timestamp NOT NULL, + "duration_seconds" integer NOT NULL, + "progress_delta" real, + "end_progress" real ); --> statement-breakpoint CREATE TABLE "user_reading_daily_stats" ( @@ -347,7 +341,7 @@ CREATE TABLE "user_reading_daily_stats" ( "day" date NOT NULL, "reading_seconds" integer DEFAULT 0 NOT NULL, "progress_delta" real DEFAULT 0 NOT NULL, - "events_count" integer DEFAULT 0 NOT NULL, + "sessions_count" integer DEFAULT 0 NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL, CONSTRAINT "user_reading_daily_stats_user_id_library_id_day_pk" PRIMARY KEY("user_id","library_id","day") ); @@ -605,8 +599,8 @@ ALTER TABLE "reader_preferences" ADD CONSTRAINT "reader_preferences_user_id_user ALTER TABLE "reader_preferences" ADD CONSTRAINT "reader_preferences_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "reading_session_events" ADD CONSTRAINT "reading_session_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "reading_session_events" ADD CONSTRAINT "reading_session_events_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reading_sessions" ADD CONSTRAINT "reading_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reading_sessions" ADD CONSTRAINT "reading_sessions_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_reading_daily_stats" ADD CONSTRAINT "user_reading_daily_stats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_reading_daily_stats" ADD CONSTRAINT "user_reading_daily_stats_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "oidc_sessions" ADD CONSTRAINT "oidc_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -664,9 +658,10 @@ CREATE INDEX "bookmarks_user_id_idx" ON "bookmarks" USING btree ("user_id");--> CREATE UNIQUE INDEX "rdp_user_format_idx" ON "reader_default_preferences" USING btree ("user_id","format_group");--> statement-breakpoint CREATE UNIQUE INDEX "rp_user_file_idx" ON "reader_preferences" USING btree ("user_id","book_file_id");--> statement-breakpoint CREATE INDEX "reading_progress_user_id_idx" ON "reading_progress" USING btree ("user_id");--> statement-breakpoint -CREATE UNIQUE INDEX "rse_event_key_uidx" ON "reading_session_events" USING btree ("event_key");--> statement-breakpoint -CREATE INDEX "rse_user_recorded_at_idx" ON "reading_session_events" USING btree ("user_id","recorded_at");--> statement-breakpoint -CREATE INDEX "rse_file_recorded_at_idx" ON "reading_session_events" USING btree ("book_file_id","recorded_at");--> statement-breakpoint +CREATE UNIQUE INDEX "rs_session_id_uidx" ON "reading_sessions" USING btree ("session_id");--> statement-breakpoint +CREATE INDEX "rs_user_started_at_idx" ON "reading_sessions" USING btree ("user_id","started_at");--> statement-breakpoint +CREATE INDEX "rs_book_file_started_at_idx" ON "reading_sessions" USING btree ("book_file_id","started_at");--> statement-breakpoint +CREATE INDEX "rs_user_book_file_idx" ON "reading_sessions" USING btree ("user_id","book_file_id");--> statement-breakpoint CREATE INDEX "urds_user_day_idx" ON "user_reading_daily_stats" USING btree ("user_id","day");--> statement-breakpoint CREATE INDEX "urds_user_library_day_idx" ON "user_reading_daily_stats" USING btree ("user_id","library_id","day");--> statement-breakpoint CREATE INDEX "oidc_sessions_user_id_idx" ON "oidc_sessions" USING btree ("user_id");--> statement-breakpoint @@ -674,4 +669,4 @@ CREATE INDEX "oidc_sessions_subject_issuer_idx" ON "oidc_sessions" USING btree ( CREATE INDEX "oidc_sessions_sid_idx" ON "oidc_sessions" USING btree ("oidc_session_id");--> statement-breakpoint CREATE INDEX "staging_files_status_idx" ON "staging_files" USING btree ("status");--> statement-breakpoint CREATE INDEX "fwl_book_id_idx" ON "file_write_log" USING btree ("book_id");--> statement-breakpoint -CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null; +CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null; \ No newline at end of file diff --git a/server/src/db/migrations/meta/0000_snapshot.json b/server/src/db/migrations/meta/0000_snapshot.json index 48c317e01..ec9186725 100644 --- a/server/src/db/migrations/meta/0000_snapshot.json +++ b/server/src/db/migrations/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "bcba426a-a81b-4b4b-a3a1-e3f2e14c3819", + "id": "527f8ce7-3ed2-4f6d-aa0d-4fd227a23118", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -2597,8 +2597,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.reading_session_events": { - "name": "reading_session_events", + "public.reading_sessions": { + "name": "reading_sessions", "schema": "", "columns": { "id": { @@ -2619,80 +2619,49 @@ "primaryKey": false, "notNull": true }, - "event_key": { - "name": "event_key", - "type": "varchar(120)", + "session_id": { + "name": "session_id", + "type": "varchar(64)", "primaryKey": false, "notNull": true }, - "recorded_at": { - "name": "recorded_at", + "started_at": { + "name": "started_at", "type": "timestamp", "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": true }, - "percentage": { - "name": "percentage", - "type": "real", + "ended_at": { + "name": "ended_at", + "type": "timestamp", "primaryKey": false, "notNull": true }, - "percentage_delta": { - "name": "percentage_delta", - "type": "real", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "page_number": { - "name": "page_number", + "duration_seconds": { + "name": "duration_seconds", "type": "integer", "primaryKey": false, + "notNull": true + }, + "progress_delta": { + "name": "progress_delta", + "type": "real", + "primaryKey": false, "notNull": false }, - "page_delta": { - "name": "page_delta", - "type": "integer", + "end_progress": { + "name": "end_progress", + "type": "real", "primaryKey": false, - "notNull": true, - "default": 0 - }, - "delta_seconds": { - "name": "delta_seconds", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "source": { - "name": "source", - "type": "varchar(40)", - "primaryKey": false, - "notNull": true, - "default": "'reader'" - }, - "synthetic": { - "name": "synthetic", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": false } }, "indexes": { - "rse_event_key_uidx": { - "name": "rse_event_key_uidx", + "rs_session_id_uidx": { + "name": "rs_session_id_uidx", "columns": [ { - "expression": "event_key", + "expression": "session_id", "isExpression": false, "asc": true, "nulls": "last" @@ -2703,8 +2672,8 @@ "method": "btree", "with": {} }, - "rse_user_recorded_at_idx": { - "name": "rse_user_recorded_at_idx", + "rs_user_started_at_idx": { + "name": "rs_user_started_at_idx", "columns": [ { "expression": "user_id", @@ -2713,7 +2682,7 @@ "nulls": "last" }, { - "expression": "recorded_at", + "expression": "started_at", "isExpression": false, "asc": true, "nulls": "last" @@ -2724,8 +2693,8 @@ "method": "btree", "with": {} }, - "rse_file_recorded_at_idx": { - "name": "rse_file_recorded_at_idx", + "rs_book_file_started_at_idx": { + "name": "rs_book_file_started_at_idx", "columns": [ { "expression": "book_file_id", @@ -2734,7 +2703,28 @@ "nulls": "last" }, { - "expression": "recorded_at", + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rs_user_book_file_idx": { + "name": "rs_user_book_file_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "book_file_id", "isExpression": false, "asc": true, "nulls": "last" @@ -2747,18 +2737,18 @@ } }, "foreignKeys": { - "reading_session_events_user_id_users_id_fk": { - "name": "reading_session_events_user_id_users_id_fk", - "tableFrom": "reading_session_events", + "reading_sessions_user_id_users_id_fk": { + "name": "reading_sessions_user_id_users_id_fk", + "tableFrom": "reading_sessions", "tableTo": "users", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, - "reading_session_events_book_file_id_book_files_id_fk": { - "name": "reading_session_events_book_file_id_book_files_id_fk", - "tableFrom": "reading_session_events", + "reading_sessions_book_file_id_book_files_id_fk": { + "name": "reading_sessions_book_file_id_book_files_id_fk", + "tableFrom": "reading_sessions", "tableTo": "book_files", "columnsFrom": ["book_file_id"], "columnsTo": ["id"], @@ -2808,8 +2798,8 @@ "notNull": true, "default": 0 }, - "events_count": { - "name": "events_count", + "sessions_count": { + "name": "sessions_count", "type": "integer", "primaryKey": false, "notNull": true, diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index e19876d32..e00bd53c9 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -5,7 +5,7 @@ { "idx": 0, "version": "7", - "when": 1773989484660, + "when": 1774017368689, "tag": "0000_baseline", "breakpoints": true } diff --git a/server/src/db/schema/reader.test.ts b/server/src/db/schema/reader.test.ts index 272d0a104..b3515a9f5 100644 --- a/server/src/db/schema/reader.test.ts +++ b/server/src/db/schema/reader.test.ts @@ -6,7 +6,7 @@ import { readerDefaultPreferences, readerPreferences, readingProgress, - readingSessionEvents, + readingSessions, userReadingDailyStats, } from './reader'; @@ -35,19 +35,18 @@ describe('reader schema', () => { expect(readingProgress.updatedAt.onUpdateFn?.()).toBeInstanceOf(Date); }); - it('stores append-only reading session events with idempotency and time indexes', () => { - const config = getTableConfig(readingSessionEvents); + it('stores reading sessions with idempotency and time indexes', () => { + const config = getTableConfig(readingSessions); const uniqueIndexes = config.indexes.filter((idx) => idx.config.unique); const indexNames = config.indexes.map((idx) => idx.config.name); - const fkMap = fkByColumn(readingSessionEvents); + const fkMap = fkByColumn(readingSessions); - expect(uniqueIndexes.some((idx) => idx.config.name === 'rse_event_key_uidx')).toBe(true); - expect(indexNames).toContain('rse_user_recorded_at_idx'); - expect(indexNames).toContain('rse_file_recorded_at_idx'); + expect(uniqueIndexes.some((idx) => idx.config.name === 'rs_session_id_uidx')).toBe(true); + expect(indexNames).toContain('rs_user_started_at_idx'); + expect(indexNames).toContain('rs_book_file_started_at_idx'); + expect(indexNames).toContain('rs_user_book_file_idx'); expect(fkMap.get('user_id')?.onDelete).toBe('cascade'); expect(fkMap.get('book_file_id')?.onDelete).toBe('cascade'); - expect(readingSessionEvents.source.default).toBe('reader'); - expect(readingSessionEvents.synthetic.default).toBe(false); }); it('stores user daily reading aggregates keyed by user, library, and day', () => { diff --git a/server/src/db/schema/reader.ts b/server/src/db/schema/reader.ts index b63d1e328..65ee61ba0 100644 --- a/server/src/db/schema/reader.ts +++ b/server/src/db/schema/reader.ts @@ -1,4 +1,4 @@ -import { boolean, date, index, integer, jsonb, pgTable, primaryKey, real, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core'; +import { date, index, integer, jsonb, pgTable, primaryKey, real, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core'; import { bookFiles, books } from './books'; import { libraries } from './libraries'; @@ -29,8 +29,8 @@ export const readingProgress = pgTable( export type ReadingProgress = typeof readingProgress.$inferSelect; export type NewReadingProgress = typeof readingProgress.$inferInsert; -export const readingSessionEvents = pgTable( - 'reading_session_events', +export const readingSessions = pgTable( + 'reading_sessions', { id: serial('id').primaryKey(), userId: integer('user_id') @@ -39,27 +39,26 @@ export const readingSessionEvents = pgTable( bookFileId: integer('book_file_id') .notNull() .references(() => bookFiles.id, { onDelete: 'cascade' }), - // Idempotency key from the client save call; retries must reuse this value. - eventKey: varchar('event_key', { length: 120 }).notNull(), - recordedAt: timestamp('recorded_at').notNull().defaultNow(), - percentage: real('percentage').notNull(), - percentageDelta: real('percentage_delta').notNull().default(0), - pageNumber: integer('page_number'), - pageDelta: integer('page_delta').notNull().default(0), - deltaSeconds: integer('delta_seconds').notNull().default(0), - source: varchar('source', { length: 40 }).notNull().default('reader'), - synthetic: boolean('synthetic').notNull().default(false), - createdAt: timestamp('created_at').notNull().defaultNow(), + // Client-generated UUID; used for idempotent retries. + sessionId: varchar('session_id', { length: 64 }).notNull(), + startedAt: timestamp('started_at').notNull(), + endedAt: timestamp('ended_at').notNull(), + // Server-computed from endedAt - startedAt; client-provided timestamps are untrusted for duration. + durationSeconds: integer('duration_seconds').notNull(), + // Nullable: CBX with no percentage tracking may omit these. + progressDelta: real('progress_delta'), + endProgress: real('end_progress'), }, (t) => [ - uniqueIndex('rse_event_key_uidx').on(t.eventKey), - index('rse_user_recorded_at_idx').on(t.userId, t.recordedAt), - index('rse_file_recorded_at_idx').on(t.bookFileId, t.recordedAt), + uniqueIndex('rs_session_id_uidx').on(t.sessionId), + index('rs_user_started_at_idx').on(t.userId, t.startedAt), + index('rs_book_file_started_at_idx').on(t.bookFileId, t.startedAt), + index('rs_user_book_file_idx').on(t.userId, t.bookFileId), ], ); -export type ReadingSessionEvent = typeof readingSessionEvents.$inferSelect; -export type NewReadingSessionEvent = typeof readingSessionEvents.$inferInsert; +export type ReadingSession = typeof readingSessions.$inferSelect; +export type NewReadingSession = typeof readingSessions.$inferInsert; export const userReadingDailyStats = pgTable( 'user_reading_daily_stats', @@ -73,7 +72,7 @@ export const userReadingDailyStats = pgTable( day: date('day', { mode: 'string' }).notNull(), readingSeconds: integer('reading_seconds').notNull().default(0), progressDelta: real('progress_delta').notNull().default(0), - eventsCount: integer('events_count').notNull().default(0), + sessionsCount: integer('sessions_count').notNull().default(0), updatedAt: timestamp('updated_at') .notNull() .defaultNow() diff --git a/server/src/modules/book/book.repository.test.ts b/server/src/modules/book/book.repository.test.ts index bea0c37bd..c9a3a54d5 100644 --- a/server/src/modules/book/book.repository.test.ts +++ b/server/src/modules/book/book.repository.test.ts @@ -195,102 +195,30 @@ describe('BookRepository', () => { expect(db.select).not.toHaveBeenCalled(); }); - it('upserts reading progress and appends an idempotent session event', async () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(new Date('2026-01-01T00:00:10.000Z')); - - const selectLimit = vi.fn().mockResolvedValue([{ percentage: 70, pageNumber: 6, updatedAt: new Date('2026-01-01T00:00:05.000Z') }]); - const selectWhere = vi.fn().mockReturnValue({ limit: selectLimit }); - const selectFrom = vi.fn().mockReturnValue({ where: selectWhere }); - const select = vi.fn().mockReturnValue({ from: selectFrom }); - - const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); - const valuesProgress = vi.fn().mockReturnValue({ onConflictDoUpdate }); - - const returningEvent = vi.fn().mockResolvedValue([{ id: 1 }]); - const onConflictDoNothing = vi.fn().mockReturnValue({ returning: returningEvent }); - const valuesEvent = vi.fn().mockReturnValue({ onConflictDoNothing }); - - const insert = vi.fn().mockReturnValueOnce({ values: valuesEvent }).mockReturnValueOnce({ values: valuesProgress }); - const execute = vi.fn().mockResolvedValue(undefined); - - const tx = { execute, select, insert }; - const transaction = vi.fn(async (fn: (trx: typeof tx) => Promise) => fn(tx)); - const db = { transaction }; - const repo = new BookRepository(db as never); - - await repo.upsertProgress(5, 9, 'epubcfi(/6/2)', 7, 80, 'session-1:1', 'reader-web'); - - expect(transaction).toHaveBeenCalledTimes(1); - expect(execute).toHaveBeenCalledTimes(1); - expect(insert).toHaveBeenCalledTimes(2); - expect(valuesProgress).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 5, - bookFileId: 9, - cfi: 'epubcfi(/6/2)', - pageNumber: 7, - percentage: 80, - }), - ); - expect(onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - target: expect.any(Array), - set: expect.objectContaining({ - cfi: 'epubcfi(/6/2)', - pageNumber: 7, - percentage: 80, - }), - }), - ); - expect(returningEvent).toHaveBeenCalledWith(expect.objectContaining({ id: expect.anything() })); - expect(valuesEvent).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 5, - bookFileId: 9, - eventKey: 'session-1:1', - percentage: 80, - percentageDelta: 10, - pageNumber: 7, - pageDelta: 1, - deltaSeconds: 5, - source: 'reader-web', - synthetic: false, - }), - ); - expect(onConflictDoNothing).toHaveBeenCalledWith(expect.objectContaining({ target: expect.any(Array) })); - } finally { - vi.useRealTimers(); - } - }); - - it('does not mutate canonical progress when an event key already exists', async () => { - const selectLimit = vi.fn().mockResolvedValue([{ percentage: 70, pageNumber: 6, updatedAt: new Date('2026-01-01T00:00:05.000Z') }]); - const selectWhere = vi.fn().mockReturnValue({ limit: selectLimit }); - const selectFrom = vi.fn().mockReturnValue({ where: selectWhere }); - const select = vi.fn().mockReturnValue({ from: selectFrom }); - - const returningEvent = vi.fn().mockResolvedValue([]); - const onConflictDoNothing = vi.fn().mockReturnValue({ returning: returningEvent }); - const valuesEvent = vi.fn().mockReturnValue({ onConflictDoNothing }); - + it('upserts reading progress with an idempotent conflict update', async () => { const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); - const valuesProgress = vi.fn().mockReturnValue({ onConflictDoUpdate }); - - const insert = vi.fn().mockReturnValueOnce({ values: valuesEvent }).mockReturnValueOnce({ values: valuesProgress }); - const execute = vi.fn().mockResolvedValue(undefined); - - const tx = { execute, select, insert }; - const transaction = vi.fn(async (fn: (trx: typeof tx) => Promise) => fn(tx)); - const db = { transaction }; + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = vi.fn().mockReturnValue({ values }); + const db = { insert }; const repo = new BookRepository(db as never); - await repo.upsertProgress(5, 9, 'epubcfi(/6/2)', 7, 80, 'session-1:1', 'reader-web'); + await repo.upsertProgress(5, 9, 'epubcfi(/6/2)', 7, 80); - expect(execute).toHaveBeenCalledTimes(1); - expect(valuesEvent).toHaveBeenCalledTimes(1); - expect(valuesProgress).not.toHaveBeenCalled(); - expect(onConflictDoUpdate).not.toHaveBeenCalled(); + expect(insert).toHaveBeenCalledTimes(1); + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 5, + bookFileId: 9, + cfi: 'epubcfi(/6/2)', + pageNumber: 7, + percentage: 80, + }), + ); + expect(onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.any(Array), + set: expect.objectContaining({ cfi: 'epubcfi(/6/2)', pageNumber: 7, percentage: 80 }), + }), + ); }); }); diff --git a/server/src/modules/book/book.repository.ts b/server/src/modules/book/book.repository.ts index 12a3c9e71..f61657075 100644 --- a/server/src/modules/book/book.repository.ts +++ b/server/src/modules/book/book.repository.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { SQL, and, count, eq, inArray, isNotNull, or, sql } from 'drizzle-orm'; -import { randomUUID } from 'crypto'; import { SUPPORTED_BOOK_FORMATS } from '../upload/upload-validator.service'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; @@ -21,13 +20,11 @@ import { koboReadingStates, koboSnapshotBooks, libraries, - readingSessionEvents, readingProgress, tags, } from '../../db/schema'; type Db = NodePgDatabase; -const MAX_ACTIVE_GAP_SECONDS = 30 * 60; type PatternMetadataRow = { bookId: number; title: string | null; @@ -384,70 +381,14 @@ export class BookRepository { await this.db.update(bookMetadata).set(fields).where(eq(bookMetadata.bookId, bookId)); } - async upsertProgress( - userId: number, - fileId: number, - cfi: string | null, - pageNumber: number | null, - percentage: number, - eventKey: string | null = null, - source: string | null = null, - ) { + async upsertProgress(userId: number, fileId: number, cfi: string | null, pageNumber: number | null, percentage: number) { const now = new Date(); - - await this.db.transaction(async (tx) => { - await tx.execute(sql`select pg_advisory_xact_lock(${userId}, ${fileId})`); - - const [previous] = await tx - .select({ - percentage: readingProgress.percentage, - pageNumber: readingProgress.pageNumber, - updatedAt: readingProgress.updatedAt, - }) - .from(readingProgress) - .where(and(eq(readingProgress.bookFileId, fileId), eq(readingProgress.userId, userId))) - .limit(1); - - const previousUpdatedAt = previous?.updatedAt ?? null; - const rawGapSeconds = previousUpdatedAt ? Math.floor((now.getTime() - previousUpdatedAt.getTime()) / 1000) : 0; - const deltaSeconds = rawGapSeconds > 0 && rawGapSeconds <= MAX_ACTIVE_GAP_SECONDS ? rawGapSeconds : 0; - - const percentageDelta = previous ? Number((percentage - previous.percentage).toFixed(4)) : 0; - const pageDelta = previous && pageNumber != null && previous.pageNumber != null ? pageNumber - previous.pageNumber : 0; - const effectiveSource = source?.trim() ? source.trim() : 'reader'; - const normalizedEventKey = eventKey?.trim(); - const effectiveEventKey = normalizedEventKey ? normalizedEventKey : `srv:${userId}:${fileId}:${now.getTime()}:${randomUUID()}`; - - const insertedEvents = await tx - .insert(readingSessionEvents) - .values({ - userId, - bookFileId: fileId, - eventKey: effectiveEventKey, - recordedAt: now, - percentage, - percentageDelta, - pageNumber, - pageDelta, - deltaSeconds, - source: effectiveSource, - synthetic: false, - createdAt: now, - }) - .onConflictDoNothing({ target: [readingSessionEvents.eventKey] }) - .returning({ id: readingSessionEvents.id }); - - // Client retries must be idempotent: if this event key already exists, do not - // mutate canonical progress timestamps or deltas. - if (insertedEvents.length === 0) return; - - await tx - .insert(readingProgress) - .values({ userId, bookFileId: fileId, cfi, pageNumber, percentage, updatedAt: now }) - .onConflictDoUpdate({ - target: [readingProgress.bookFileId, readingProgress.userId], - set: { cfi, pageNumber, percentage, updatedAt: now }, - }); - }); + await this.db + .insert(readingProgress) + .values({ userId, bookFileId: fileId, cfi, pageNumber, percentage, updatedAt: now }) + .onConflictDoUpdate({ + target: [readingProgress.bookFileId, readingProgress.userId], + set: { cfi, pageNumber, percentage, updatedAt: now }, + }); } } diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index ad44bcf12..341def00b 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -362,15 +362,7 @@ export class BookService { async saveProgress(userId: number, fileId: number, dto: SaveProgressDto, user: RequestUser) { await this.verifyFileAccess(fileId, user); - await this.bookRepo.upsertProgress( - userId, - fileId, - dto.cfi ?? null, - dto.pageNumber ?? null, - dto.percentage, - dto.eventKey ?? null, - dto.source ?? null, - ); + await this.bookRepo.upsertProgress(userId, fileId, dto.cfi ?? null, dto.pageNumber ?? null, dto.percentage); } async getKoboState(id: number, user: RequestUser): Promise { diff --git a/server/src/modules/book/dto/book-dto-validation.test.ts b/server/src/modules/book/dto/book-dto-validation.test.ts index ffb7d6b0d..c0f6a4b5e 100644 --- a/server/src/modules/book/dto/book-dto-validation.test.ts +++ b/server/src/modules/book/dto/book-dto-validation.test.ts @@ -46,25 +46,11 @@ describe('Book DTO validation', () => { it('validates SaveProgressDto percentage bounds and conditional field types', async () => { expect((await errorsFor(SaveProgressDto, { percentage: 0 })).length).toBe(0); - expect( - ( - await errorsFor(SaveProgressDto, { - percentage: 100, - cfi: 'epubcfi(/6/2)', - pageNumber: 5, - eventKey: 'session-1:9', - source: 'reader-web', - }) - ).length, - ).toBe(0); + expect((await errorsFor(SaveProgressDto, { percentage: 100, cfi: 'epubcfi(/6/2)', pageNumber: 5 })).length).toBe(0); expect((await errorsFor(SaveProgressDto, { percentage: -1 })).length).toBeGreaterThan(0); expect((await errorsFor(SaveProgressDto, { percentage: 50, cfi: 123 })).length).toBeGreaterThan(0); expect((await errorsFor(SaveProgressDto, { percentage: 50, pageNumber: 'five' })).length).toBeGreaterThan(0); - expect((await errorsFor(SaveProgressDto, { percentage: 50, eventKey: 123 })).length).toBeGreaterThan(0); - expect((await errorsFor(SaveProgressDto, { percentage: 50, eventKey: '' })).length).toBeGreaterThan(0); - expect((await errorsFor(SaveProgressDto, { percentage: 50, eventKey: ' ' })).length).toBeGreaterThan(0); - expect((await errorsFor(SaveProgressDto, { percentage: 50, source: 123 })).length).toBeGreaterThan(0); }); it('requires non-empty search text and bounds search limit', async () => { diff --git a/server/src/modules/book/dto/save-progress.dto.ts b/server/src/modules/book/dto/save-progress.dto.ts index 049dcac2b..4a3bb3b4f 100644 --- a/server/src/modules/book/dto/save-progress.dto.ts +++ b/server/src/modules/book/dto/save-progress.dto.ts @@ -1,5 +1,4 @@ -import { Transform } from 'class-transformer'; -import { IsInt, IsNumber, IsString, Max, MaxLength, Min, MinLength, ValidateIf } from 'class-validator'; +import { IsNumber, IsString, Max, Min, ValidateIf } from 'class-validator'; export class SaveProgressDto { @ValidateIf((o: SaveProgressDto) => o.cfi != null) @@ -7,24 +6,11 @@ export class SaveProgressDto { cfi?: string | null; @ValidateIf((o: SaveProgressDto) => o.pageNumber != null) - @IsInt() + @IsNumber() pageNumber?: number | null; @IsNumber() @Min(0) @Max(100) percentage!: number; - - @ValidateIf((o: SaveProgressDto) => o.eventKey != null) - @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) - @IsString() - @MinLength(1) - @MaxLength(120) - eventKey?: string | null; - - @ValidateIf((o: SaveProgressDto) => o.source != null) - @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) - @IsString() - @MaxLength(40) - source?: string | null; } diff --git a/server/src/modules/reading-session/dto/save-reading-session.dto.ts b/server/src/modules/reading-session/dto/save-reading-session.dto.ts new file mode 100644 index 000000000..48834d17d --- /dev/null +++ b/server/src/modules/reading-session/dto/save-reading-session.dto.ts @@ -0,0 +1,31 @@ +import { IsDateString, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator'; + +export class SaveReadingSessionDto { + @IsString() + @MinLength(1) + @MaxLength(64) + sessionId!: string; + + @IsDateString() + startedAt!: string; + + @IsDateString() + endedAt!: string; + + // Client-computed from active reading time (excludes idle and tab-hidden periods). + // Server validates it does not exceed the wall-clock span of the session. + @IsInt() + @Min(0) + durationSeconds!: number; + + // Nullable: can be negative if the user navigated backward. + @IsOptional() + @IsNumber() + progressDelta?: number | null; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + endProgress?: number | null; +} diff --git a/server/src/modules/reading-session/reading-session.controller.ts b/server/src/modules/reading-session/reading-session.controller.ts new file mode 100644 index 000000000..e63a81541 --- /dev/null +++ b/server/src/modules/reading-session/reading-session.controller.ts @@ -0,0 +1,17 @@ +import { Body, Controller, HttpCode, Param, ParseIntPipe, Post } from '@nestjs/common'; + +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { RequestUser } from '../../common/types/request-user'; +import { SaveReadingSessionDto } from './dto/save-reading-session.dto'; +import { ReadingSessionService } from './reading-session.service'; + +@Controller('books/files') +export class ReadingSessionController { + constructor(private readonly service: ReadingSessionService) {} + + @Post(':fileId/sessions') + @HttpCode(202) + async saveSession(@Param('fileId', ParseIntPipe) fileId: number, @Body() dto: SaveReadingSessionDto, @CurrentUser() user: RequestUser) { + await this.service.save(fileId, dto, user); + } +} diff --git a/server/src/modules/reading-session/reading-session.module.ts b/server/src/modules/reading-session/reading-session.module.ts new file mode 100644 index 000000000..6079562f3 --- /dev/null +++ b/server/src/modules/reading-session/reading-session.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { BookModule } from '../book/book.module'; +import { ReadingSessionController } from './reading-session.controller'; +import { ReadingSessionRepository } from './reading-session.repository'; +import { ReadingSessionService } from './reading-session.service'; + +@Module({ + imports: [BookModule], + controllers: [ReadingSessionController], + providers: [ReadingSessionService, ReadingSessionRepository], + exports: [ReadingSessionRepository], +}) +export class ReadingSessionModule {} diff --git a/server/src/modules/reading-session/reading-session.repository.ts b/server/src/modules/reading-session/reading-session.repository.ts new file mode 100644 index 000000000..c14328a5e --- /dev/null +++ b/server/src/modules/reading-session/reading-session.repository.ts @@ -0,0 +1,107 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { eq, sql } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { DB } from '../../db'; +import * as schema from '../../db/schema'; +import { bookFiles, books, readingSessions, userReadingDailyStats } from '../../db/schema'; + +type Db = NodePgDatabase; + +const MIN_SESSION_SECONDS = 10; + +@Injectable() +export class ReadingSessionRepository { + constructor(@Inject(DB) private readonly db: Db) {} + + async saveSession( + userId: number, + bookFileId: number, + sessionId: string, + startedAt: Date, + endedAt: Date, + durationSeconds: number, + progressDelta: number | null, + endProgress: number | null, + ): Promise { + if (durationSeconds < MIN_SESSION_SECONDS) return; + + const [fileRow] = await this.db + .select({ libraryId: books.libraryId }) + .from(bookFiles) + .innerJoin(books, eq(books.id, bookFiles.bookId)) + .where(eq(bookFiles.id, bookFileId)) + .limit(1); + + if (!fileRow) return; + + const { libraryId } = fileRow; + const day = startedAt.toISOString().slice(0, 10); + + await this.db.transaction(async (tx) => { + const inserted = await tx + .insert(readingSessions) + .values({ userId, bookFileId, sessionId, startedAt, endedAt, durationSeconds, progressDelta, endProgress }) + .onConflictDoNothing({ target: [readingSessions.sessionId] }) + .returning({ id: readingSessions.id }); + + if (inserted.length === 0) return; + + await tx + .insert(userReadingDailyStats) + .values({ + userId, + libraryId, + day, + readingSeconds: durationSeconds, + progressDelta: progressDelta ?? 0, + sessionsCount: 1, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [userReadingDailyStats.userId, userReadingDailyStats.libraryId, userReadingDailyStats.day], + set: { + readingSeconds: sql`${userReadingDailyStats.readingSeconds} + ${durationSeconds}`, + progressDelta: sql`${userReadingDailyStats.progressDelta} + ${progressDelta ?? 0}`, + sessionsCount: sql`${userReadingDailyStats.sessionsCount} + 1`, + updatedAt: new Date(), + }, + }); + }); + } + + async recomputeRecentDailyStats(since: Date): Promise<{ deleted: number; inserted: number }> { + const sinceDay = since.toISOString().slice(0, 10); + + return this.db.transaction(async (tx) => { + const deleteResult = await tx.execute(sql`delete from user_reading_daily_stats where day >= ${sinceDay}::date`); + + const insertResult = await tx.execute(sql` + insert into user_reading_daily_stats (user_id, library_id, day, reading_seconds, progress_delta, sessions_count, updated_at) + select + rs.user_id, + b.library_id, + date_trunc('day', rs.started_at)::date as day, + coalesce(sum(rs.duration_seconds), 0)::int as reading_seconds, + coalesce(sum(rs.progress_delta), 0)::real as progress_delta, + count(*)::int as sessions_count, + now() as updated_at + from reading_sessions rs + inner join book_files bf on bf.id = rs.book_file_id + inner join books b on b.id = bf.book_id + where rs.started_at >= ${since} + group by rs.user_id, b.library_id, date_trunc('day', rs.started_at)::date + on conflict (user_id, library_id, day) do update set + reading_seconds = excluded.reading_seconds, + progress_delta = excluded.progress_delta, + sessions_count = excluded.sessions_count, + updated_at = excluded.updated_at + `); + + return { + deleted: Number((deleteResult as { rowCount?: number }).rowCount ?? 0), + inserted: Number((insertResult as { rowCount?: number }).rowCount ?? 0), + }; + }); + } +} diff --git a/server/src/modules/reading-session/reading-session.service.ts b/server/src/modules/reading-session/reading-session.service.ts new file mode 100644 index 000000000..ba53ed038 --- /dev/null +++ b/server/src/modules/reading-session/reading-session.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; + +import type { RequestUser } from '../../common/types/request-user'; +import { BookService } from '../book/book.service'; +import type { SaveReadingSessionDto } from './dto/save-reading-session.dto'; +import { ReadingSessionRepository } from './reading-session.repository'; + +@Injectable() +export class ReadingSessionService { + constructor( + private readonly repo: ReadingSessionRepository, + private readonly bookService: BookService, + ) {} + + async save(fileId: number, dto: SaveReadingSessionDto, user: RequestUser): Promise { + await this.bookService.verifyFileAccess(fileId, user); + + const startedAt = new Date(dto.startedAt); + const endedAt = new Date(dto.endedAt); + const wallClockSeconds = Math.max(0, Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000)); + + // Client sends active reading time (excludes idle/hidden periods); cap it at the wall-clock + // span to prevent the client from reporting more time than physically elapsed. + const durationSeconds = Math.min(dto.durationSeconds, wallClockSeconds); + + await this.repo.saveSession( + user.id, + fileId, + dto.sessionId, + startedAt, + endedAt, + durationSeconds, + dto.progressDelta ?? null, + dto.endProgress ?? null, + ); + } +} diff --git a/server/src/modules/user-statistics/user-statistics.repository.ts b/server/src/modules/user-statistics/user-statistics.repository.ts index 5545cc09f..4eed2b8f3 100644 --- a/server/src/modules/user-statistics/user-statistics.repository.ts +++ b/server/src/modules/user-statistics/user-statistics.repository.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm'; +import { and, eq, gte, inArray, isNotNull, lt, sql } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import type { @@ -13,7 +13,7 @@ import type { import { DB } from '../../db'; import * as schema from '../../db/schema'; -import { bookFiles, books, readingProgress, readingSessionEvents, userLibraryAccess, userReadingDailyStats } from '../../db/schema'; +import { bookFiles, books, readingProgress, readingSessions, userLibraryAccess, userReadingDailyStats } from '../../db/schema'; type Db = NodePgDatabase; const RECENT_DAILY_AGGREGATION_DAYS = 2; @@ -104,7 +104,7 @@ export class UserStatisticsRepository { day: userReadingDailyStats.day, readingSeconds: sql`coalesce(sum(${userReadingDailyStats.readingSeconds}), 0)::int`, progressDelta: sql`coalesce(sum(${userReadingDailyStats.progressDelta}), 0)::float`, - eventsCount: sql`coalesce(sum(${userReadingDailyStats.eventsCount}), 0)::int`, + eventsCount: sql`coalesce(sum(${userReadingDailyStats.sessionsCount}), 0)::int`, }) .from(userReadingDailyStats) .where(and(eq(userReadingDailyStats.userId, userId), gte(userReadingDailyStats.day, sinceDay), libraryFilter)) @@ -116,18 +116,18 @@ export class UserStatisticsRepository { const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); const since = this.sinceDateForDays(days); - const hourExpr = sql`extract(hour from ${readingSessionEvents.recordedAt})::int`; + const hourExpr = sql`extract(hour from ${readingSessions.startedAt})::int`; return this.db .select({ hour: hourExpr, - readingSeconds: sql`coalesce(sum(case when ${readingSessionEvents.deltaSeconds} > 0 and ${readingSessionEvents.deltaSeconds} <= 1800 then ${readingSessionEvents.deltaSeconds} else 0 end), 0)::int`, + readingSeconds: sql`coalesce(sum(${readingSessions.durationSeconds}), 0)::int`, eventsCount: sql`count(*)::int`, }) - .from(readingSessionEvents) - .innerJoin(bookFiles, eq(bookFiles.id, readingSessionEvents.bookFileId)) + .from(readingSessions) + .innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId)) .innerJoin(books, eq(books.id, bookFiles.bookId)) - .where(and(eq(readingSessionEvents.userId, userId), gte(readingSessionEvents.recordedAt, since), libraryFilter)) + .where(and(eq(readingSessions.userId, userId), gte(readingSessions.startedAt, since), libraryFilter)) .groupBy(hourExpr) .orderBy(hourExpr); } @@ -136,18 +136,18 @@ export class UserStatisticsRepository { const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); const since = this.sinceDateForDays(days); - const dayOfWeekExpr = sql`extract(dow from ${readingSessionEvents.recordedAt})::int`; + const dayOfWeekExpr = sql`extract(dow from ${readingSessions.startedAt})::int`; return this.db .select({ dayOfWeek: dayOfWeekExpr, - readingSeconds: sql`coalesce(sum(case when ${readingSessionEvents.deltaSeconds} > 0 and ${readingSessionEvents.deltaSeconds} <= 1800 then ${readingSessionEvents.deltaSeconds} else 0 end), 0)::int`, + readingSeconds: sql`coalesce(sum(${readingSessions.durationSeconds}), 0)::int`, eventsCount: sql`count(*)::int`, }) - .from(readingSessionEvents) - .innerJoin(bookFiles, eq(bookFiles.id, readingSessionEvents.bookFileId)) + .from(readingSessions) + .innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId)) .innerJoin(books, eq(books.id, bookFiles.bookId)) - .where(and(eq(readingSessionEvents.userId, userId), gte(readingSessionEvents.recordedAt, since), libraryFilter)) + .where(and(eq(readingSessions.userId, userId), gte(readingSessions.startedAt, since), libraryFilter)) .groupBy(dayOfWeekExpr) .orderBy(dayOfWeekExpr); } @@ -165,12 +165,12 @@ export class UserStatisticsRepository { const firstCompletion = this.db .select({ bookId: bookFiles.bookId, - firstCompletedAt: sql`min(${readingSessionEvents.recordedAt})`.as('first_completed_at'), + firstCompletedAt: sql`min(${readingSessions.endedAt})`.as('first_completed_at'), }) - .from(readingSessionEvents) - .innerJoin(bookFiles, eq(bookFiles.id, readingSessionEvents.bookFileId)) + .from(readingSessions) + .innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId)) .innerJoin(books, eq(books.id, bookFiles.bookId)) - .where(and(eq(readingSessionEvents.userId, userId), gte(readingSessionEvents.percentage, 100), libraryFilter)) + .where(and(eq(readingSessions.userId, userId), gte(readingSessions.endProgress, 100), libraryFilter)) .groupBy(bookFiles.bookId) .as('first_completion'); @@ -208,18 +208,18 @@ export class UserStatisticsRepository { const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); const timeFilter = untilExclusive - ? and(gte(readingSessionEvents.recordedAt, since), lt(readingSessionEvents.recordedAt, untilExclusive)) - : gte(readingSessionEvents.recordedAt, since); + ? and(gte(readingSessions.startedAt, since), lt(readingSessions.startedAt, untilExclusive)) + : gte(readingSessions.startedAt, since); const perBookProgress = this.db .select({ bookId: bookFiles.bookId, - maxPercentage: sql`max(${readingSessionEvents.percentage})`.as('max_percentage'), + maxPercentage: sql`max(${readingSessions.endProgress})`.as('max_percentage'), }) - .from(readingSessionEvents) - .innerJoin(bookFiles, eq(bookFiles.id, readingSessionEvents.bookFileId)) + .from(readingSessions) + .innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId)) .innerJoin(books, eq(books.id, bookFiles.bookId)) - .where(and(eq(readingSessionEvents.userId, userId), timeFilter, libraryFilter)) + .where(and(eq(readingSessions.userId, userId), timeFilter, libraryFilter, isNotNull(readingSessions.endProgress))) .groupBy(bookFiles.bookId) .as('per_book_progress'); @@ -250,33 +250,24 @@ export class UserStatisticsRepository { const completedInWindow = this.db .select({ bookId: bookFiles.bookId, - completedAt: sql`min(${readingSessionEvents.recordedAt})`.as('completed_at'), + completedAt: sql`min(${readingSessions.endedAt})`.as('completed_at'), }) - .from(readingSessionEvents) - .innerJoin(bookFiles, eq(bookFiles.id, readingSessionEvents.bookFileId)) + .from(readingSessions) + .innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId)) .innerJoin(books, eq(books.id, bookFiles.bookId)) - .where( - and( - eq(readingSessionEvents.userId, userId), - gte(readingSessionEvents.recordedAt, since), - gte(readingSessionEvents.percentage, 100), - libraryFilter, - ), - ) + .where(and(eq(readingSessions.userId, userId), gte(readingSessions.startedAt, since), gte(readingSessions.endProgress, 100), libraryFilter)) .groupBy(bookFiles.bookId) .as('completed_in_window'); const startedAndCompleted = this.db .select({ completedAt: completedInWindow.completedAt, - startedAt: sql`min(case when ${readingSessionEvents.percentage} > 0 then ${readingSessionEvents.recordedAt} end)`.as( - 'started_at', - ), + startedAt: sql`min(${readingSessions.startedAt})`.as('started_at'), }) - .from(readingSessionEvents) - .innerJoin(bookFiles, eq(bookFiles.id, readingSessionEvents.bookFileId)) + .from(readingSessions) + .innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId)) .innerJoin(completedInWindow, eq(completedInWindow.bookId, bookFiles.bookId)) - .where(eq(readingSessionEvents.userId, userId)) + .where(eq(readingSessions.userId, userId)) .groupBy(completedInWindow.bookId, completedInWindow.completedAt) .as('started_and_completed'); @@ -298,30 +289,22 @@ export class UserStatisticsRepository { return this.db.transaction(async (tx) => { const deleteResult = await tx.execute(sql`delete from user_reading_daily_stats where day >= ${sinceDay}::date`); + const insertResult = await tx.execute(sql` - insert into user_reading_daily_stats (user_id, library_id, day, reading_seconds, progress_delta, events_count, updated_at) + insert into user_reading_daily_stats (user_id, library_id, day, reading_seconds, progress_delta, sessions_count, updated_at) select - ${readingSessionEvents.userId} as user_id, - ${books.libraryId} as library_id, - date_trunc('day', ${readingSessionEvents.recordedAt})::date as day, - coalesce( - sum( - case - when ${readingSessionEvents.deltaSeconds} > 0 and ${readingSessionEvents.deltaSeconds} <= 1800 - then ${readingSessionEvents.deltaSeconds} - else 0 - end - ), - 0 - )::int as reading_seconds, - coalesce(sum(${readingSessionEvents.percentageDelta}), 0)::real as progress_delta, - count(*)::int as events_count, + rs.user_id, + b.library_id, + date_trunc('day', rs.started_at)::date as day, + coalesce(sum(rs.duration_seconds), 0)::int as reading_seconds, + coalesce(sum(rs.progress_delta), 0)::real as progress_delta, + count(*)::int as sessions_count, now() as updated_at - from ${readingSessionEvents} - inner join ${bookFiles} on ${bookFiles.id} = ${readingSessionEvents.bookFileId} - inner join ${books} on ${books.id} = ${bookFiles.bookId} - where ${readingSessionEvents.recordedAt} >= ${since} - group by ${readingSessionEvents.userId}, ${books.libraryId}, date_trunc('day', ${readingSessionEvents.recordedAt})::date + from reading_sessions rs + inner join book_files bf on bf.id = rs.book_file_id + inner join books b on b.id = bf.book_id + where rs.started_at >= ${since} + group by rs.user_id, b.library_id, date_trunc('day', rs.started_at)::date `); const deleted = Number((deleteResult as { rowCount?: number }).rowCount ?? 0);