feat: implement reading session tracking for epub, pdf, and cbz readers

This commit is contained in:
neon
2026-03-19 20:18:09 -06:00
parent 069fa1488c
commit a7df587c1e
25 changed files with 693 additions and 395 deletions
@@ -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()
@@ -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<typeof setTimeout> | null = null
watch(currentPage, (page) => {
schedulePreload(page)
onActivity()
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
progress.pageNumber.value = page + 1
@@ -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<HTMLElement | null>(null)
const containerW = ref(0)
@@ -137,6 +143,7 @@ watch([spread, scrollMode], async () => {
let saveTimer: ReturnType<typeof setTimeout> | null = null
watch(currentPage, (page) => {
onActivity()
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
progress.pageNumber.value = page
@@ -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<typeof setTimeout> | 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',
}),
})
}
@@ -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<typeof setTimeout> | 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 }
}
+3 -3
View File
@@ -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'.
+140
View File
@@ -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."
+1
View File
@@ -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",
+2
View File
@@ -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: [
+14 -19
View File
@@ -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
@@ -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,
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1773989484660,
"when": 1774017368689,
"tag": "0000_baseline",
"breakpoints": true
}
+8 -9
View File
@@ -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', () => {
+19 -20
View File
@@ -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()
+21 -93
View File
@@ -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<void>) => 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<void>) => 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 }),
}),
);
});
});
+8 -67
View File
@@ -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<typeof schema>;
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 },
});
}
}
+1 -9
View File
@@ -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<BookKoboState> {
@@ -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 () => {
@@ -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;
}
@@ -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;
}
@@ -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);
}
}
@@ -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 {}
@@ -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<typeof schema>;
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<void> {
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),
};
});
}
}
@@ -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<void> {
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,
);
}
}
@@ -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<typeof schema>;
const RECENT_DAILY_AGGREGATION_DAYS = 2;
@@ -104,7 +104,7 @@ export class UserStatisticsRepository {
day: userReadingDailyStats.day,
readingSeconds: sql<number>`coalesce(sum(${userReadingDailyStats.readingSeconds}), 0)::int`,
progressDelta: sql<number>`coalesce(sum(${userReadingDailyStats.progressDelta}), 0)::float`,
eventsCount: sql<number>`coalesce(sum(${userReadingDailyStats.eventsCount}), 0)::int`,
eventsCount: sql<number>`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<number>`extract(hour from ${readingSessionEvents.recordedAt})::int`;
const hourExpr = sql<number>`extract(hour from ${readingSessions.startedAt})::int`;
return this.db
.select({
hour: hourExpr,
readingSeconds: sql<number>`coalesce(sum(case when ${readingSessionEvents.deltaSeconds} > 0 and ${readingSessionEvents.deltaSeconds} <= 1800 then ${readingSessionEvents.deltaSeconds} else 0 end), 0)::int`,
readingSeconds: sql<number>`coalesce(sum(${readingSessions.durationSeconds}), 0)::int`,
eventsCount: sql<number>`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<number>`extract(dow from ${readingSessionEvents.recordedAt})::int`;
const dayOfWeekExpr = sql<number>`extract(dow from ${readingSessions.startedAt})::int`;
return this.db
.select({
dayOfWeek: dayOfWeekExpr,
readingSeconds: sql<number>`coalesce(sum(case when ${readingSessionEvents.deltaSeconds} > 0 and ${readingSessionEvents.deltaSeconds} <= 1800 then ${readingSessionEvents.deltaSeconds} else 0 end), 0)::int`,
readingSeconds: sql<number>`coalesce(sum(${readingSessions.durationSeconds}), 0)::int`,
eventsCount: sql<number>`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<Date>`min(${readingSessionEvents.recordedAt})`.as('first_completed_at'),
firstCompletedAt: sql<Date>`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<number>`max(${readingSessionEvents.percentage})`.as('max_percentage'),
maxPercentage: sql<number>`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<Date>`min(${readingSessionEvents.recordedAt})`.as('completed_at'),
completedAt: sql<Date>`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<Date | null>`min(case when ${readingSessionEvents.percentage} > 0 then ${readingSessionEvents.recordedAt} end)`.as(
'started_at',
),
startedAt: sql<Date | null>`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);