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

@ -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";
}