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:
@ -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")
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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()
|
||||
},
|
||||
|
||||
29
apps/api/src/progress/progress.controller.test.ts
Normal file
29
apps/api/src/progress/progress.controller.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
158
apps/api/src/progress/progress.service.test.ts
Normal file
158
apps/api/src/progress/progress.service.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user