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,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { BootstrapAdminSchema, BookQuerySchema } from "./index.js";
import { BookSchema, BootstrapAdminSchema, BookQuerySchema, ProgressSchema, UpdateReadStatusSchema } from "./index.js";
describe("shared contracts", () => {
it("validates bootstrap admin payloads", () => {
@ -12,4 +12,43 @@ describe("shared contracts", () => {
expect(BookQuerySchema.parse({}).limit).toBeUndefined();
expect(BookQuerySchema.parse({ limit: "50" }).limit).toBe(50);
});
it("exposes read state on book and progress contracts", () => {
expect(
BookSchema.parse({
id: 1,
libraryId: 1,
title: "Daredevil",
author: null,
description: null,
isbn: null,
isbn13: null,
language: null,
publisher: null,
publishedDate: null,
format: "cbz",
filePath: "/library/Daredevil.cbz",
coverPath: null,
readStatus: "unread",
completedAt: null,
readStatusSource: null,
fileSize: 42,
fileMtime: "2026-08-30T00:00:00.000Z",
createdAt: "2026-08-30T00:00:00.000Z",
updatedAt: "2026-08-30T00:00:00.000Z"
})
).toMatchObject({ readStatus: "unread", completedAt: null, readStatusSource: null });
expect(
ProgressSchema.parse({
bookId: 1,
locator: "epubcfi(/6/2)",
percent: 95,
readStatus: "read",
completedAt: "2026-08-30T00:00:00.000Z",
readStatusSource: "progress",
updatedAt: "2026-08-30T00:00:00.000Z"
})
).toMatchObject({ readStatus: "read", readStatusSource: "progress" });
expect(UpdateReadStatusSchema.parse({ status: "unread" })).toEqual({ status: "unread" });
});
});

View File

@ -95,6 +95,19 @@ export const SeriesSchema = z.object({
});
export type SeriesDto = z.infer<typeof SeriesSchema>;
export const ReadStatusSchema = z.enum(["unread", "reading", "read"]);
export type ReadStatus = z.infer<typeof ReadStatusSchema>;
export const ReadStatusSourceSchema = z.enum(["progress", "manual"]);
export type ReadStatusSource = z.infer<typeof ReadStatusSourceSchema>;
export const BookReadStateSchema = z.object({
readStatus: ReadStatusSchema.optional(),
completedAt: z.string().nullable().optional(),
readStatusSource: ReadStatusSourceSchema.nullable().optional()
});
export type BookReadStateDto = z.infer<typeof BookReadStateSchema>;
export const BookSchema = z.object({
id: z.number().int().positive(),
libraryId: z.number().int().positive(),
@ -117,6 +130,9 @@ export const BookSchema = z.object({
series: SeriesSchema.nullable().optional(),
scanStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
enrichmentStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
readStatus: ReadStatusSchema.optional(),
completedAt: z.string().nullable().optional(),
readStatusSource: ReadStatusSourceSchema.nullable().optional(),
fileSize: z.number().int().nonnegative(),
fileMtime: z.string(),
createdAt: z.string(),
@ -137,6 +153,9 @@ export const ProgressSchema = z.object({
bookId: z.number().int().positive(),
locator: z.string().min(1),
percent: z.number().min(0).max(100),
readStatus: ReadStatusSchema.optional(),
completedAt: z.string().nullable().optional(),
readStatusSource: ReadStatusSourceSchema.nullable().optional(),
updatedAt: z.string()
});
export type ProgressDto = z.infer<typeof ProgressSchema>;
@ -147,6 +166,11 @@ export const UpdateProgressSchema = z.object({
});
export type UpdateProgressDto = z.infer<typeof UpdateProgressSchema>;
export const UpdateReadStatusSchema = z.object({
status: z.enum(["read", "unread"])
});
export type UpdateReadStatusDto = z.infer<typeof UpdateReadStatusSchema>;
export const ReaderPreferencesSchema = z.object({
mode: z.enum(["paged", "scrolled", "horizontal", "vertical"]).default("paged"),
fit: z.enum(["page", "width", "height", "auto"]).nullable().default(null),