124 lines
4.4 KiB
TypeScript
124 lines
4.4 KiB
TypeScript
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";
|
|
}
|