import { Injectable, NotFoundException } from "@nestjs/common"; import { desc, eq, sql } from "drizzle-orm"; import { UpdateProgressDto, UpdateReadStatusDto } from "@readabook/shared"; import { DatabaseService } from "../database/database.service.js"; import { books, progress } from "../database/schema.js"; const READ_COMPLETION_THRESHOLD = 95; @Injectable() export class ProgressService { constructor(private readonly database: DatabaseService) {} upsert(userId: number, bookId: number, input: UpdateProgressDto) { this.assertBookExists(bookId); const now = this.database.now(); const percent = Math.round(input.percent); const current = this.find(userId, bookId); const readStatus = statusFromProgressPercent(percent); const completedAt = readStatus === "read" ? current?.completedAt ?? now : null; this.database.sqlite .prepare( ` INSERT INTO progress(user_id, book_id, locator, percent, read_status, completed_at, read_status_source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'progress', ?, ?) ON CONFLICT(user_id, book_id) DO UPDATE SET locator = excluded.locator, percent = excluded.percent, read_status = excluded.read_status, completed_at = excluded.completed_at, read_status_source = excluded.read_status_source, updated_at = excluded.updated_at ` ) .run(userId, bookId, input.locator, percent, readStatus, completedAt, now, now); return this.get(userId, bookId); } markReadStatus(userId: number, bookId: number, input: UpdateReadStatusDto) { this.assertBookExists(bookId); const now = this.database.now(); const current = this.find(userId, bookId); const status = input.status; const percent = status === "read" ? 100 : 0; const locator = status === "read" ? current?.locator ?? "manual:read" : "manual:unread"; const completedAt = status === "read" ? current?.completedAt ?? now : null; this.database.sqlite .prepare( ` INSERT INTO progress(user_id, book_id, locator, percent, read_status, completed_at, read_status_source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'manual', ?, ?) ON CONFLICT(user_id, book_id) DO UPDATE SET locator = excluded.locator, percent = excluded.percent, read_status = excluded.read_status, completed_at = excluded.completed_at, read_status_source = excluded.read_status_source, updated_at = excluded.updated_at ` ) .run(userId, bookId, locator, percent, status, completedAt, now, now); return this.get(userId, bookId); } get(userId: number, bookId: number) { const row = this.find(userId, bookId); if (!row) { throw new NotFoundException("Progress not found"); } return row; } find(userId: number, bookId: number) { const row = this.database.db .select({ bookId: progress.bookId, locator: progress.locator, percent: progress.percent, readStatus: progress.readStatus, completedAt: progress.completedAt, readStatusSource: progress.readStatusSource, updatedAt: progress.updatedAt }) .from(progress) .where(sql`${progress.userId} = ${userId} AND ${progress.bookId} = ${bookId}`) .get(); return row ?? null; } continueReading(userId: number) { return this.database.db .select({ book: books, progress: { bookId: progress.bookId, locator: progress.locator, percent: progress.percent, readStatus: progress.readStatus, completedAt: progress.completedAt, readStatusSource: progress.readStatusSource, updatedAt: progress.updatedAt } }) .from(progress) .innerJoin(books, eq(progress.bookId, books.id)) .where(sql`${progress.userId} = ${userId} AND ${progress.readStatus} = 'reading'`) .orderBy(desc(progress.updatedAt)) .limit(20) .all(); } private assertBookExists(bookId: number): void { const book = this.database.db.select({ id: books.id }).from(books).where(eq(books.id, bookId)).get(); if (!book) { throw new NotFoundException("Book not found"); } } } function statusFromProgressPercent(percent: number): "unread" | "reading" | "read" { if (percent >= READ_COMPLETION_THRESHOLD) return "read"; if (percent > 0) return "reading"; return "unread"; }