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,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" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
Reference in New Issue
Block a user