feat(books): marquage des livres comme lus — statut lu/non lu par utilisateur sur progress (colonnes read_status/completed_at/read_status_source, migration idempotente avec backfill à 95 %), endpoint PUT /progress/:bookId/status, complétion automatique au fil de la progression, état lu exposé sur catalogue/recherche/fiche/séries, étagère « continuer » limitée aux lectures en cours, badge Lu sur cartes/fiche/accueil et bouton Marquer comme lu/non lu sur la fiche, avec tests api/shared/web — évolution #52

This commit is contained in:
Git Agent
2026-08-30 10:37:42 +02:00
parent 3faa7ca44b
commit 05f5f1cd16
20 changed files with 664 additions and 43 deletions

View File

@ -3,6 +3,7 @@ import { FastifyReply } from "fastify";
import { lookup } from "mime-types";
import { BookQueryDto, BookQuerySchema } from "@readabook/shared";
import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { BooksService } from "./books.service.js";
@ -12,18 +13,18 @@ export class BooksController {
constructor(private readonly books: BooksService) {}
@Get()
list(@Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
return this.books.list(query);
list(@CurrentUserParam() user: CurrentUser, @Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
return this.books.list(query, user.id);
}
@Get("search")
search(@Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
return query.q ? this.books.search(query.q, query.limit, query.offset) : [];
search(@CurrentUserParam() user: CurrentUser, @Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
return query.q ? this.books.search(query.q, query.limit, query.offset, user.id) : [];
}
@Get(":id")
get(@Param("id") id: string) {
return this.books.get(Number(id));
get(@CurrentUserParam() user: CurrentUser, @Param("id") id: string) {
return this.books.get(Number(id), user.id);
}
@Get(":id/pages")

View File

@ -4,7 +4,7 @@ import { extname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { BookQuerySchema } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries, series } from "../database/schema.js";
import { books, libraries, progress, series, users } from "../database/schema.js";
import { BooksService } from "./books.service.js";
const previousDatabasePath = process.env.DATABASE_PATH;
@ -149,6 +149,89 @@ describe("BooksService", () => {
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("exposes user read status on catalogue, search and book details", () => {
const database = createDatabase();
const service = new BooksService(database);
const now = database.now();
const user = database.db
.insert(users)
.values({
email: "reader@example.com",
name: "Reader",
passwordHash: "hash",
role: "user",
createdAt: now,
updatedAt: now
})
.returning()
.get();
const library = database.db
.insert(libraries)
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const book = database.db
.insert(books)
.values({
libraryId: library.id,
seriesId: null,
title: "Daredevil Read State",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: null,
localMetadataJson: null,
language: null,
publisher: null,
publishedDate: null,
volumeNumber: null,
volumeLabel: null,
format: "cbz",
filePath: "/library/Daredevil Read State.cbz",
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: null,
scanStatus: "succeeded",
enrichmentStatus: "idle",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
database.db
.insert(progress)
.values({
userId: user.id,
bookId: book.id,
locator: "manual:read",
percent: 100,
readStatus: "read",
completedAt: now,
readStatusSource: "manual",
createdAt: now,
updatedAt: now
})
.run();
expect(service.get(book.id)).toMatchObject({ readStatus: "unread", completedAt: null, readStatusSource: null });
expect(service.get(book.id, user.id)).toMatchObject({ readStatus: "read", completedAt: now, readStatusSource: "manual" });
expect(service.list({ limit: 50, offset: 0 }, user.id)[0]).toMatchObject({
readStatus: "read",
completedAt: now,
readStatusSource: "manual"
});
expect(service.search("Daredevil", undefined, 0, user.id)[0]).toMatchObject({
readStatus: "read",
completedAt: now,
readStatusSource: "manual"
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("lists a series with distinct books sharing the same volume", () => {
const database = createDatabase();
const service = new BooksService(database);

View File

@ -1,24 +1,32 @@
import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common";
import { createReadStream, existsSync, statSync } from "node:fs";
import { extname } from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { BookQueryDto } from "@readabook/shared";
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
import { DatabaseService } from "../database/database.service.js";
import { books, series } from "../database/schema.js";
import { books, progress, series } from "../database/schema.js";
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
type ReadState = {
readStatus: "unread" | "reading" | "read";
completedAt: string | null;
readStatusSource: "progress" | "manual" | null;
};
const unreadState: ReadState = { readStatus: "unread", completedAt: null, readStatusSource: null };
@Injectable()
export class BooksService {
constructor(private readonly database: DatabaseService) {}
list(query: BookQueryDto) {
list(query: BookQueryDto, userId?: number) {
const filters = [];
if (query.format) filters.push(eq(books.format, query.format));
if (query.libraryId) filters.push(eq(books.libraryId, query.libraryId));
if (query.q) {
return this.search(query.q, query.limit, query.offset);
return this.search(query.q, query.limit, query.offset, userId);
}
const statement = this.database.db
.select()
@ -27,11 +35,11 @@ export class BooksService {
.orderBy(books.title);
const rows =
query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all();
return rows
.map((book) => this.mapBookSelect(book));
const states = this.readStatesFor(userId, rows.map((book) => book.id));
return rows.map((book) => this.mapBookSelect(book, states.get(book.id)));
}
search(q: string, limit?: number, offset = 0) {
search(q: string, limit?: number, offset = 0, userId?: number) {
if (limit === undefined) {
const rows = this.database.sqlite
.prepare(
@ -44,7 +52,7 @@ export class BooksService {
`
)
.all(`${q.replace(/"/g, '""')}*`);
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
return this.mapBookRows(rows as Array<Record<string, unknown>>, userId);
}
const rows = this.database.sqlite
@ -59,18 +67,18 @@ export class BooksService {
`
)
.all(`${q.replace(/"/g, '""')}*`, limit, offset);
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
return this.mapBookRows(rows as Array<Record<string, unknown>>, userId);
}
get(id: number) {
return this.mapBookSelect(this.getRecord(id));
get(id: number, userId?: number) {
return this.mapBookSelect(this.getRecord(id), this.readStatesFor(userId, [id]).get(id));
}
listSeries() {
return this.database.db.select().from(series).orderBy(series.title).all();
}
getSeries(id: number) {
getSeries(id: number, userId?: number) {
const row = this.database.db.select().from(series).where(eq(series.id, id)).get();
if (!row) throw new NotFoundException("Series not found");
const seriesBooks = this.database.db
@ -78,9 +86,10 @@ export class BooksService {
.from(books)
.where(eq(books.seriesId, id))
.orderBy(books.volumeNumber, books.title)
.all()
.map((book) => this.mapBookSelect(book));
return { ...row, books: seriesBooks };
.all();
const states = this.readStatesFor(userId, seriesBooks.map((book) => book.id));
const mappedBooks = seriesBooks.map((book) => this.mapBookSelect(book, states.get(book.id)));
return { ...row, books: mappedBooks };
}
streamFile(id: number, range?: string) {
@ -155,7 +164,15 @@ export class BooksService {
}
}
private mapBookRow(row: Record<string, unknown>) {
private mapBookRows(rows: Array<Record<string, unknown>>, userId?: number) {
const states = this.readStatesFor(
userId,
rows.map((row) => Number(row.id))
);
return rows.map((row) => this.mapBookRow(row, states.get(Number(row.id))));
}
private mapBookRow(row: Record<string, unknown>, readState = unreadState) {
const seriesId = nullable(row.series_id);
return {
id: Number(row.id),
@ -179,6 +196,7 @@ export class BooksService {
series: seriesId ? this.getSeriesRecord(Number(seriesId)) : null,
scanStatus: statusValue(row.scan_status),
enrichmentStatus: statusValue(row.enrichment_status),
...readState,
fileSize: Number(row.file_size),
fileMtime: String(row.file_mtime),
createdAt: String(row.created_at),
@ -186,19 +204,44 @@ export class BooksService {
};
}
private mapBookSelect(row: typeof books.$inferSelect) {
private mapBookSelect(row: typeof books.$inferSelect, readState = unreadState) {
const { metadataProvenanceJson: _metadataProvenanceJson, ...book } = row;
return {
...book,
publishedDate: normalizePublishedDate(row.publishedDate),
metadataProvenance: parseObject(row.metadataProvenanceJson),
series: row.seriesId ? this.getSeriesRecord(row.seriesId) : null
series: row.seriesId ? this.getSeriesRecord(row.seriesId) : null,
...readState
};
}
private getSeriesRecord(id: number) {
return this.database.db.select().from(series).where(eq(series.id, id)).get() ?? null;
}
private readStatesFor(userId: number | undefined, bookIds: number[]): Map<number, ReadState> {
if (userId === undefined || bookIds.length === 0) return new Map();
const rows = this.database.db
.select({
bookId: progress.bookId,
readStatus: progress.readStatus,
completedAt: progress.completedAt,
readStatusSource: progress.readStatusSource
})
.from(progress)
.where(and(eq(progress.userId, userId), inArray(progress.bookId, bookIds)))
.all();
return new Map(
rows.map((row) => [
row.bookId,
{
readStatus: readStatusValue(row.readStatus),
completedAt: row.completedAt,
readStatusSource: readStatusSourceValue(row.readStatusSource)
}
])
);
}
}
function parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } {
@ -254,6 +297,14 @@ function metadataStatusValue(value: unknown): "enriched" | "partial" | "none" {
return value === "enriched" || value === "partial" ? value : "none";
}
function readStatusValue(value: unknown): "unread" | "reading" | "read" {
return value === "reading" || value === "read" ? value : "unread";
}
function readStatusSourceValue(value: unknown): "progress" | "manual" | null {
return value === "progress" || value === "manual" ? value : null;
}
function parseObject(value: unknown): Record<string, string> {
if (typeof value !== "string") return {};
try {

View File

@ -1,5 +1,6 @@
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
import { BooksService } from "./books.service.js";
@Controller("series")
@ -13,7 +14,7 @@ export class SeriesController {
}
@Get(":id")
get(@Param("id") id: string) {
return this.books.getSeries(Number(id));
get(@CurrentUserParam() user: CurrentUser, @Param("id") id: string) {
return this.books.getSeries(Number(id), user.id);
}
}

View File

@ -89,6 +89,8 @@ describe("database migrations", () => {
INSERT INTO libraries (id, name, path, enabled, created_at, updated_at)
VALUES (1, 'Corpus', '/library', 1, '${now}', '${now}');
INSERT INTO users (id, email, name, password_hash, role, created_at, updated_at)
VALUES (1, 'reader@example.com', 'Reader', 'hash', 'user', '${now}', '${now}');
INSERT INTO books (
library_id, title, author, description, isbn, language, publisher, published_date,
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
@ -97,6 +99,8 @@ describe("database migrations", () => {
(1, 'Solo Leveling T03', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Solo Leveling T03.cbz', NULL, 42, '${now}', '${now}', '${now}'),
(1, 'Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz.cbz', NULL, 42, '${now}', '${now}', '${now}'),
(1, 'Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz', NULL, 42, '${now}', '${now}', '${now}');
INSERT INTO progress (user_id, book_id, locator, percent, created_at, updated_at)
VALUES (1, 1, 'epubcfi(/6/2)', 97, '${now}', '${now}');
`);
legacy.close();
@ -105,6 +109,7 @@ describe("database migrations", () => {
const database = new DatabaseService();
const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
const progressColumns = database.sqlite.prepare("PRAGMA table_info(progress)").all() as Array<{ name: string }>;
const bookIndexes = database.sqlite.prepare("PRAGMA index_list(books)").all() as Array<{ name: string }>;
const metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>;
const seriesRows = database.sqlite
@ -126,6 +131,9 @@ describe("database migrations", () => {
const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as
| { id: number; isbn_priority_enabled: number }
| undefined;
const progressRow = database.sqlite
.prepare("SELECT read_status, completed_at, read_status_source FROM progress WHERE user_id = 1 AND book_id = 1")
.get() as { read_status: string; completed_at: string | null; read_status_source: string | null };
expect(bookColumns.map((column) => column.name)).toContain("isbn13");
expect(bookColumns.map((column) => column.name)).toContain("identifiers_json");
@ -134,6 +142,9 @@ describe("database migrations", () => {
expect(bookColumns.map((column) => column.name)).toContain("series_id");
expect(bookColumns.map((column) => column.name)).toContain("volume_number");
expect(bookColumns.map((column) => column.name)).toContain("volume_label");
expect(progressColumns.map((column) => column.name)).toContain("read_status");
expect(progressColumns.map((column) => column.name)).toContain("completed_at");
expect(progressColumns.map((column) => column.name)).toContain("read_status_source");
expect(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx");
expect(bookIndexes.map((index) => index.name)).toContain("books_series_idx");
expect(seriesRows).toEqual([
@ -161,6 +172,7 @@ describe("database migrations", () => {
]);
expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]);
expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 });
expect(progressRow).toEqual({ read_status: "read", completed_at: now, read_status_source: "progress" });
database.onModuleDestroy();
});

View File

@ -95,6 +95,9 @@ export class DatabaseService implements OnModuleDestroy {
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
locator TEXT NOT NULL,
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
read_status TEXT NOT NULL DEFAULT 'unread' CHECK (read_status IN ('unread','reading','read')),
completed_at TEXT,
read_status_source TEXT CHECK (read_status_source IN ('progress','manual')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, book_id)
@ -165,6 +168,7 @@ export class DatabaseService implements OnModuleDestroy {
this.ensureBooksMetadataColumns();
this.ensureSeriesModel();
this.ensureReaderPreferencesTable();
this.ensureProgressReadStatusColumns();
this.ensureMetadataSourceConfigSupportsComicProviders();
this.ensureMetadataSourceConfigColumns();
this.ensureAutomationSettingsColumns();
@ -375,6 +379,35 @@ export class DatabaseService implements OnModuleDestroy {
`);
}
private ensureProgressReadStatusColumns(): void {
const names = this.columnNames("progress");
if (!names.has("read_status")) {
this.sqlite.exec("ALTER TABLE progress ADD COLUMN read_status TEXT NOT NULL DEFAULT 'unread'");
}
if (!names.has("completed_at")) {
this.sqlite.exec("ALTER TABLE progress ADD COLUMN completed_at TEXT");
}
if (!names.has("read_status_source")) {
this.sqlite.exec("ALTER TABLE progress ADD COLUMN read_status_source TEXT");
}
this.sqlite.exec(`
UPDATE progress
SET
read_status = CASE
WHEN percent >= 95 THEN 'read'
WHEN percent > 0 THEN 'reading'
ELSE 'unread'
END,
completed_at = CASE
WHEN percent >= 95 THEN COALESCE(completed_at, updated_at)
ELSE NULL
END,
read_status_source = COALESCE(read_status_source, 'progress')
WHERE read_status_source IS NULL
`);
this.sqlite.exec("CREATE INDEX IF NOT EXISTS progress_user_status_idx ON progress(user_id, read_status)");
}
private ensureMetadataSourceConfigColumns(): void {
const names = this.columnNames("metadata_source_config");
const now = sqlString(this.now());

View File

@ -102,6 +102,9 @@ export const progress = sqliteTable(
.references(() => books.id, { onDelete: "cascade" }),
locator: text("locator").notNull(),
percent: integer("percent").notNull(),
readStatus: text("read_status", { enum: ["unread", "reading", "read"] }).notNull().default("unread"),
completedAt: text("completed_at"),
readStatusSource: text("read_status_source", { enum: ["progress", "manual"] }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},

View File

@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { ProgressController } from "./progress.controller.js";
describe("ProgressController read status endpoint", () => {
it("exposes PUT /progress/:bookId/status and delegates manual read status changes", () => {
const source = readFileSync(new URL("./progress.controller.ts", import.meta.url), "utf8");
const markReadStatus = vi.fn().mockReturnValue({
bookId: 42,
locator: "manual:read",
percent: 100,
readStatus: "read",
completedAt: "2026-08-30T00:00:00.000Z",
readStatusSource: "manual",
updatedAt: "2026-08-30T00:00:00.000Z"
});
const controller = new ProgressController({ markReadStatus } as never);
expect(source).toContain('@Put(":bookId/status")');
expect(source).toContain("new ZodValidationPipe(UpdateReadStatusSchema)");
expect(controller.updateStatus({ id: 7, email: "reader@example.com", role: "user" }, "42", { status: "read" })).toMatchObject({
bookId: 42,
readStatus: "read",
readStatusSource: "manual"
});
expect(markReadStatus).toHaveBeenCalledWith(7, 42, { status: "read" });
});
});

View File

@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
import { UpdateProgressDto, UpdateProgressSchema } from "@readabook/shared";
import { UpdateProgressDto, UpdateProgressSchema, UpdateReadStatusDto, UpdateReadStatusSchema } from "@readabook/shared";
import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
@ -28,4 +28,13 @@ export class ProgressController {
) {
return this.progress.upsert(user.id, Number(bookId), body);
}
@Put(":bookId/status")
updateStatus(
@CurrentUserParam() user: CurrentUser,
@Param("bookId") bookId: string,
@Body(new ZodValidationPipe(UpdateReadStatusSchema)) body: UpdateReadStatusDto
) {
return this.progress.markReadStatus(user.id, Number(bookId), body);
}
}

View File

@ -0,0 +1,158 @@
import Database from "better-sqlite3";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries, users } from "../database/schema.js";
import { ProgressService } from "./progress.service.js";
const previousDatabasePath = process.env.DATABASE_PATH;
const previousStorageDir = process.env.STORAGE_DIR;
const tempDirs: string[] = [];
afterEach(() => {
process.env.DATABASE_PATH = previousDatabasePath;
process.env.STORAGE_DIR = previousStorageDir;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("ProgressService", () => {
it.runIf(canLoadBetterSqlite())("marks a book read automatically when progress reaches the completion threshold", () => {
const { database, service, userId, bookId } = createService();
try {
const result = service.upsert(userId, bookId, { locator: "epubcfi(/6/2)", percent: 95 });
expect(result).toMatchObject({
bookId,
locator: "epubcfi(/6/2)",
percent: 95,
readStatus: "read",
readStatusSource: "progress"
});
expect(result.completedAt).toEqual(expect.any(String));
expect(service.continueReading(userId)).toHaveLength(0);
} finally {
database.onModuleDestroy();
}
});
it.runIf(canLoadBetterSqlite())("keeps reading progress below the completion threshold in continue reading", () => {
const { database, service, userId, bookId } = createService();
try {
service.upsert(userId, bookId, { locator: "epubcfi(/6/2)", percent: 42 });
expect(service.get(userId, bookId)).toMatchObject({
percent: 42,
readStatus: "reading",
completedAt: null,
readStatusSource: "progress"
});
expect(service.continueReading(userId)).toHaveLength(1);
} finally {
database.onModuleDestroy();
}
});
it.runIf(canLoadBetterSqlite())("allows manual read and unread status changes", () => {
const { database, service, userId, bookId } = createService();
try {
const read = service.markReadStatus(userId, bookId, { status: "read" });
expect(read).toMatchObject({
locator: "manual:read",
percent: 100,
readStatus: "read",
readStatusSource: "manual"
});
expect(read.completedAt).toEqual(expect.any(String));
const unread = service.markReadStatus(userId, bookId, { status: "unread" });
expect(unread).toMatchObject({
locator: "manual:unread",
percent: 0,
readStatus: "unread",
completedAt: null,
readStatusSource: "manual"
});
expect(service.continueReading(userId)).toHaveLength(0);
} finally {
database.onModuleDestroy();
}
});
});
function createService(): { database: DatabaseService; service: ProgressService; userId: number; bookId: number } {
const database = createDatabase();
const service = new ProgressService(database);
const now = database.now();
const user = database.db
.insert(users)
.values({
email: "reader@example.com",
name: "Reader",
passwordHash: "hash",
role: "user",
createdAt: now,
updatedAt: now
})
.returning()
.get();
const library = database.db
.insert(libraries)
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const book = database.db
.insert(books)
.values({
libraryId: library.id,
seriesId: null,
title: "Daredevil",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: null,
localMetadataJson: null,
language: null,
publisher: null,
publishedDate: null,
volumeNumber: null,
volumeLabel: null,
format: "cbz",
filePath: "/library/Daredevil.cbz",
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: null,
scanStatus: "succeeded",
enrichmentStatus: "idle",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
return { database, service, userId: user.id, bookId: book.id };
}
function createDatabase(): DatabaseService {
const dir = mkdtempSync(join(tmpdir(), "readabook-progress-service-"));
tempDirs.push(dir);
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
process.env.STORAGE_DIR = join(dir, "storage");
return new DatabaseService();
}
function canLoadBetterSqlite(): boolean {
try {
new Database(":memory:").close();
return true;
} catch {
return false;
}
}

View File

@ -1,31 +1,63 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { desc, eq, sql } from "drizzle-orm";
import { UpdateProgressDto } from "@readabook/shared";
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) {
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");
}
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, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
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, Math.round(input.percent), now, now);
.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);
}
@ -43,6 +75,9 @@ export class ProgressService {
bookId: progress.bookId,
locator: progress.locator,
percent: progress.percent,
readStatus: progress.readStatus,
completedAt: progress.completedAt,
readStatusSource: progress.readStatusSource,
updatedAt: progress.updatedAt
})
.from(progress)
@ -59,14 +94,30 @@ export class ProgressService {
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(eq(progress.userId, userId))
.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";
}