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:
@ -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