mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: implement reading session tracking for epub, pdf, and cbz readers
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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'.
|
||||
|
||||
Executable
+140
@@ -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."
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1773989484660,
|
||||
"when": 1774017368689,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user