From 2a3dfb88dea109d43f553f36571cb6bb8397ac58 Mon Sep 17 00:00:00 2001 From: mathieu Date: Tue, 18 Aug 2026 02:45:01 +0200 Subject: [PATCH] Gerer les prets : API, ecrans et contrainte en base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'entite Pret existait depuis le squelette sans jamais etre exploitee. Cette phase la met en service de bout en bout. API - GET /api/prets/en-cours ce qui n'est pas a la maison, du plus ancien au plus recent : on cherche le livre oublie, pas celui prete hier - POST /api/livres/{id}/prets preter - GET /api/livres/{id}/prets historique complet, du plus recent au plus ancien - POST /api/prets/{id}/retour clore le pret sans le supprimer Un livre deja sorti ne peut pas etre prete une seconde fois. Le service le verifie et nomme celui qui l'a deja, mais entre sa verification et l'insertion il reste une fenetre : un index unique PARTIEL (LivreId WHERE DateRetour IS NULL) la ferme, tout en laissant l'historique accumuler autant de prets clos que necessaire sur le meme livre. Les ebooks sont refuses cote API, pas seulement grises dans l'interface : une fiche n'a pas d'exemplaire a confier. Les prets sont COMMUNS au foyer, symetrique inverse du statut de lecture. Le service ne recoit meme pas d'identite, pour qu'on ne puisse pas s'en servir par inadvertance. Interface, pensee mobile d'abord - ecran « Prets en cours » avec bouton « Rendu » a meme la liste - bloc pret sur la fiche d'un livre : etat courant, action, puis historique - etiquette « Prete a X » dans le catalogue - la barre d'actions passe sur deux lignes plutot que de comprimer ses libelles maintenant qu'elle compte quatre entrees Toutes les dates sont en UTC ; le client convertit la date locale du avant l'envoi, faute de quoi le pret se decalerait d'un jour pour la moitie du globe. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 77 +++- MaBibli.Api/Data/MaBibliDbContext.cs | 18 +- ...0260818003150_IndexPretEnCours.Designer.cs | 246 +++++++++++++ .../20260818003150_IndexPretEnCours.cs | 29 ++ .../MaBibliDbContextModelSnapshot.cs | 6 +- MaBibli.Api/Endpoints/PretsEndpoints.cs | 99 +++++ MaBibli.Api/Program.cs | 3 + .../Services/Catalogue/ServiceCatalogue.cs | 41 ++- MaBibli.Api/Services/Prets/ServicePrets.cs | 216 +++++++++++ MaBibli.Client/Composants/PretsLivre.razor | 232 ++++++++++++ MaBibli.Client/Pages/Catalogue.razor | 9 + MaBibli.Client/Pages/EditionLivre.razor | 16 + MaBibli.Client/Pages/Prets.razor | 107 ++++++ MaBibli.Client/Services/ServiceLivresApi.cs | 53 +++ MaBibli.Client/wwwroot/css/app.css | 80 +++- MaBibli.Shared/Dtos/LivreDto.cs | 20 +- MaBibli.Shared/Dtos/PretDto.cs | 69 ++++ MaBibli.Tests/ServicePretsTests.cs | 348 ++++++++++++++++++ 18 files changed, 1656 insertions(+), 13 deletions(-) create mode 100644 MaBibli.Api/Data/Migrations/20260818003150_IndexPretEnCours.Designer.cs create mode 100644 MaBibli.Api/Data/Migrations/20260818003150_IndexPretEnCours.cs create mode 100644 MaBibli.Api/Endpoints/PretsEndpoints.cs create mode 100644 MaBibli.Api/Services/Prets/ServicePrets.cs create mode 100644 MaBibli.Client/Composants/PretsLivre.razor create mode 100644 MaBibli.Client/Pages/Prets.razor create mode 100644 MaBibli.Shared/Dtos/PretDto.cs create mode 100644 MaBibli.Tests/ServicePretsTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index dd73ab9..06f3981 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ Application self-hosted de gestion de bibliothèque personnelle, à héberger su | Auth / multi-utilisateur | SSO YunoHost via **en-têtes SSOwat** (`YNH_USER`) | Pas de login custom. OIDC **écarté** : non documenté par YunoHost (vérifié le 2026-08-17). Voir « Intégration SSO » | | Portée des données | **Collection commune** à tous les utilisateurs, avec traçabilité de qui a ajouté chaque livre | Usage familial : une bibliothèque de foyer, pas des collections étanches. Laisse la possibilité de cloisonner plus tard sans migration lourde | | Statut de lecture | **Par utilisateur**, pas commun (décidé le 2026-08-17, après la phase 3) | Le livre est commun, sa lecture est personnelle : deux membres du foyer lisent le même exemplaire à des rythmes différents. Sort `Statut` de `Livre` vers une table dédiée | +| Prêts | **Communs au foyer**, jamais filtrés par utilisateur ; un seul prêt ouvert par livre, garanti par index unique partiel | Un livre absent l'est pour tout le monde, et n'importe qui doit pouvoir noter son retour. Voir « Prêts » | | Auteurs | **Table dédiée** avec nom normalisé, remplaçant le champ texte libre | Nécessaire au regroupement par auteur et à la recherche insensible aux accents. Regroupement **automatique seulement quand c'est sûr**, sinon proposé à l'utilisateur | | Ebooks | **Fiches uniquement**, pas de stockage de fichiers | Inventaire, pas hébergement. Évite l'espace disque YunoHost, les sauvegardes lourdes, et garde le cache hors-ligne léger | | Architecture serveur | **x86_64** → publish `linux-x64` | Serveur PC/VPS confirmé par l'utilisateur | @@ -354,10 +355,12 @@ RapprochementRefuse (mémoire des « non » de l'utilisateur) Pret ├── Id -├── LivreId (FK vers Livre) -├── Emprunteur (nom, texte libre) -├── DatePret -└── DateRetour (nullable — NULL tant que non rendu) +├── LivreId (FK Livre, cascade — index simple pour l'historique) +├── Emprunteur (nom, texte libre) +├── DatePret (UTC) +├── DateRetour (UTC, nullable — NULL tant que non rendu) +└── UNIQUE (LivreId) WHERE DateRetour IS NULL + un seul prêt ouvert par livre ; les prêts clos se répètent librement ``` Garder `Pret` comme table séparée (pas un champ sur `Livre`) pour conserver l'historique complet des prêts passés, pas juste l'état actuel. @@ -390,6 +393,72 @@ La migration recopie l'ancien contenu avec les moyens du bord, puis **`ServiceRe **Les prêts ne concernent en pratique que les livres physiques** — les ebooks étant de simples fiches, il n'y a pas d'objet à prêter. `Emprunteur` reste un **texte libre**, sans lien avec les comptes YunoHost : on suit les prêts à des personnes extérieures au foyer, pas les échanges entre utilisateurs de l'app. +## Prêts — implémenté le 2026-08-18 + +### Un prêt ouvert par livre, garanti par la base + +Un exemplaire sorti ne se prête pas une seconde fois. Le service le vérifie et renvoie un message +nommant celui qui l'a déjà (« Ce livre est déjà prêté à Marie »), mais entre cette vérification et +l'insertion il reste une fenêtre. Elle est fermée par un **index unique partiel** : + +```sql +CREATE UNIQUE INDEX IX_Prets_LivreId_EnCours ON Prets (LivreId) WHERE DateRetour IS NULL; +``` + +Le filtre est ce qui rend la chose possible : seules les lignes ouvertes sont indexées, donc +l'historique reste libre d'accumuler autant de prêts clos que nécessaire sur le même livre. +Vérifié sur base réelle : un second `INSERT` ouvert échoue en `UNIQUE constraint failed`, un +second prêt **clos** passe. + +⚠️ Deux index cohabitent sur `Prets.LivreId` et ce n'est pas une redondance : `IX_Prets_LivreId` +sert l'historique (toutes les lignes), l'index partiel sert la contrainte. **Ils doivent porter un +nom explicite** — EF Core identifie un index par ses colonnes, et sans nom distinct le second +déclaré *remplace* purement et simplement le premier dans la migration générée. + +### Le prêt est commun, contrairement au statut de lecture + +Symétrique inverse de la décision sur les statuts, et il faut tenir les deux : + +| | Statut de lecture | Prêt | +|---|---|---| +| Portée | **personnel** (par `YNH_USER`) | **commun au foyer** | +| Pourquoi | deux personnes lisent le même exemplaire à leur rythme | un livre absent l'est pour tout le monde | + +Aucun point d'entrée des prêts ne reçoit d'identité — le service n'en prend même pas en +paramètre, pour qu'on ne puisse pas s'en servir par inadvertance. N'importe qui doit pouvoir +noter le retour d'un livre qu'il a récupéré. + +`LivreDto.PreteA` / `PreteDepuis` portent l'état courant pour l'étiquette du catalogue ; +l'historique complet se demande à part (`GET /api/livres/{id}/prets`) pour ne pas alourdir chaque +liste. + +### Points d'entrée + +| Méthode | Route | Rôle | +|---|---|---| +| `GET` | `/api/prets/en-cours` | Ce qui n'est pas à la maison, **du prêt le plus ancien au plus récent** | +| `POST` | `/api/livres/{id}/prets` | Prêter (400 si déjà sorti, numérique, ou emprunteur vide) | +| `GET` | `/api/livres/{id}/prets` | Historique, du plus récent au plus ancien (404 si livre inconnu, `[]` si jamais prêté) | +| `POST` | `/api/prets/{id}/retour` | Clore le prêt (400 s'il l'est déjà) | + +L'ordre croissant des prêts en cours est délibéré : ce qu'on cherche dans cette vue, c'est le +livre sorti depuis six mois qu'on avait oublié, pas celui prêté hier. + +### Décisions prises là où CLAUDE.md était muet + +- **Supprimer un livre emporte son historique de prêts** (cascade, déjà déclarée, désormais + couverte par un test). Un historique orphelin — « quelqu'un a emprunté quelque chose » — ne se + lit plus. L'écran de suppression avertit en plus quand le livre est actuellement dehors. +- **La date de prêt est modifiable, la date de retour non.** On note souvent un prêt après coup + (« Paul a mon Zola depuis Noël ») ; un retour se constate au moment où il a lieu. Les deux + refusent une date future, et un retour antérieur à son prêt. +- **Rendre un prêt déjà clos est refusé** plutôt que silencieusement ignoré : réécrire la date + remplacerait une information exacte par une approximative. +- **Toutes les dates sont stockées en UTC**, comme `DateAjout`. Le `` produit + une date sans fuseau : le client la déclare **locale** avant de la convertir + (`ServiceLivresApi.EnUtc`). Sans cela le prêt se décalerait d'un jour pour la moitié du globe. + Vérifié en exécution : saisie du 12/08 → `2026-08-11 22:00` en base (CEST). + ## Historique du projet (pourquoi ces choix) L'utilisateur a testé deux solutions existantes avant de se lancer dans un projet custom : diff --git a/MaBibli.Api/Data/MaBibliDbContext.cs b/MaBibli.Api/Data/MaBibliDbContext.cs index 09f2187..49ed2b7 100644 --- a/MaBibli.Api/Data/MaBibliDbContext.cs +++ b/MaBibli.Api/Data/MaBibliDbContext.cs @@ -93,7 +93,23 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo modelBuilder.Entity(pret => { pret.Property(p => p.Emprunteur).IsRequired(); - pret.HasIndex(p => p.LivreId); + + // Deux index sur la même colonne, et ce n'est pas une redondance. Ils doivent porter + // un nom explicite : EF identifie un index par ses colonnes, et sans nom distinct le + // second remplacerait purement et simplement le premier. + + // Celui-ci sert l'historique d'un livre — toutes ses lignes, closes comprises. + pret.HasIndex(p => p.LivreId, "IX_Prets_LivreId"); + + // Celui-là est une contrainte : il ne peut y avoir qu'UN prêt ouvert par livre, on + // ne prête pas deux fois un exemplaire qui n'est pas revenu. Le service le vérifie, + // mais entre sa vérification et son insertion il y a une fenêtre ; l'index la ferme. + // Le filtre est ce qui rend la chose possible : seules les lignes ouvertes sont + // indexées, les prêts clos restent libres de se répéter autant que nécessaire. + // Il sert accessoirement la vue « prêts en cours », dont c'est exactement le critère. + pret.HasIndex(p => p.LivreId, "IX_Prets_LivreId_EnCours") + .IsUnique() + .HasFilter("\"DateRetour\" IS NULL"); }); } } diff --git a/MaBibli.Api/Data/Migrations/20260818003150_IndexPretEnCours.Designer.cs b/MaBibli.Api/Data/Migrations/20260818003150_IndexPretEnCours.Designer.cs new file mode 100644 index 0000000..dcbb3bb --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260818003150_IndexPretEnCours.Designer.cs @@ -0,0 +1,246 @@ +// +using System; +using MaBibli.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + [DbContext(typeof(MaBibliDbContext))] + [Migration("20260818003150_IndexPretEnCours")] + partial class IndexPretEnCours + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleRegroupement") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NomNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CleRegroupement") + .IsUnique(); + + b.HasIndex("NomNormalise"); + + b.ToTable("Auteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Isbn"); + + b.HasIndex("TitreNormalise"); + + b.ToTable("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "AuteurId"); + + b.HasIndex("AuteurId"); + + b.ToTable("LivreAuteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatePret") + .HasColumnType("TEXT"); + + b.Property("DateRetour") + .HasColumnType("TEXT"); + + b.Property("Emprunteur") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours") + .IsUnique() + .HasFilter("\"DateRetour\" IS NULL"); + + b.ToTable("Prets"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurAId") + .HasColumnType("INTEGER"); + + b.Property("AuteurBId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuteurAId", "AuteurBId") + .IsUnique(); + + b.ToTable("RapprochementsRefuses"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateMaj") + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Statut") + .HasColumnType("INTEGER"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur"); + + b.HasIndex("LivreId", "Utilisateur") + .IsUnique(); + + b.ToTable("StatutsLecture"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur") + .WithMany("Livres") + .HasForeignKey("AuteurId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Auteurs") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Auteur"); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Prets") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Statuts") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Navigation("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Navigation("Auteurs"); + + b.Navigation("Prets"); + + b.Navigation("Statuts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260818003150_IndexPretEnCours.cs b/MaBibli.Api/Data/Migrations/20260818003150_IndexPretEnCours.cs new file mode 100644 index 0000000..2952e0a --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260818003150_IndexPretEnCours.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + public partial class IndexPretEnCours : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Prets_LivreId_EnCours", + table: "Prets", + column: "LivreId", + unique: true, + filter: "\"DateRetour\" IS NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Prets_LivreId_EnCours", + table: "Prets"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index 418e5c9..9ba0d29 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -125,7 +125,11 @@ namespace MaBibli.Api.Data.Migrations b.HasKey("Id"); - b.HasIndex("LivreId"); + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours") + .IsUnique() + .HasFilter("\"DateRetour\" IS NULL"); b.ToTable("Prets"); }); diff --git a/MaBibli.Api/Endpoints/PretsEndpoints.cs b/MaBibli.Api/Endpoints/PretsEndpoints.cs new file mode 100644 index 0000000..120a8e9 --- /dev/null +++ b/MaBibli.Api/Endpoints/PretsEndpoints.cs @@ -0,0 +1,99 @@ +using MaBibli.Api.Services.Prets; +using MaBibli.Shared.Dtos; + +namespace MaBibli.Api.Endpoints; + +public static class PretsEndpoints +{ + /// + /// Prêts : qui a quoi, depuis quand, et l'historique de ce qui est revenu. + /// + /// + /// Aucun point d'entrée ne filtre sur l'utilisateur courant, et aucun n'enregistre qui + /// a saisi le prêt. La bibliothèque est commune : tout le foyer voit les mêmes prêts, et + /// n'importe qui peut noter le retour d'un livre qu'il a récupéré. + /// + /// L'emprunteur est du texte libre : ce sont des personnes extérieures au foyer, sans + /// compte YunoHost. Ne jamais le rapprocher d'un identifiant d'utilisateur. + /// + /// + public static IEndpointRouteBuilder MapPretsEndpoints(this IEndpointRouteBuilder routes) + { + var prets = routes.MapGroup("/api/prets").WithTags("Prêts"); + + prets.MapGet("/en-cours", async (IServicePrets service, CancellationToken ct) => + Results.Ok(await service.EnCoursAsync(ct))) + .WithName("ListerPretsEnCours") + .WithSummary("Les livres qui ne sont pas à la maison, du prêt le plus ancien au plus récent.") + .WithDescription( + "L'ordre est volontairement croissant : ce qu'on cherche ici, c'est le livre " + + "sorti depuis des mois qu'on avait oublié, pas celui prêté hier.") + .Produces>(); + + prets.MapPost("/{id:int}/retour", async ( + int id, + RetourPret retour, + IServicePrets service, + CancellationToken ct) => + { + var resultat = await service.RendreAsync(id, retour, ct); + + if (resultat.EstIntrouvable) + { + return Results.NotFound(); + } + + return resultat.Erreur is not null + ? Results.BadRequest(new { erreur = resultat.Erreur }) + : Results.Ok(resultat.Pret); + }) + .WithName("MarquerPretRendu") + .WithSummary("Clôt un prêt en posant sa date de retour. Le prêt n'est pas supprimé : il devient l'historique.") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + var parLivre = routes.MapGroup("/api/livres/{livreId:int}/prets").WithTags("Prêts"); + + parLivre.MapGet("/", async (int livreId, IServicePrets service, CancellationToken ct) => + { + var historique = await service.HistoriqueAsync(livreId, ct); + return historique is null ? Results.NotFound() : Results.Ok(historique); + }) + .WithName("HistoriquePretsLivre") + .WithSummary("Tous les prêts de ce livre, clos comme en cours, du plus récent au plus ancien.") + .WithDescription( + "Une liste vide signifie « jamais prêté » ; un livre inconnu répond 404. " + + "Les prêts clos ne disparaissent jamais : c'est la raison d'être de la table.") + .Produces>() + .Produces(StatusCodes.Status404NotFound); + + parLivre.MapPost("/", async ( + int livreId, + EnregistrementPret saisie, + IServicePrets service, + CancellationToken ct) => + { + var resultat = await service.PreterAsync(livreId, saisie, ct); + + if (resultat.EstIntrouvable) + { + return Results.NotFound(); + } + + return resultat.Erreur is not null + ? Results.BadRequest(new { erreur = resultat.Erreur }) + : Results.Created($"/api/prets/{resultat.Pret!.Id}", resultat.Pret); + }) + .WithName("PreterLivre") + .WithSummary("Prête ce livre à quelqu'un.") + .WithDescription( + "Refusé si le livre est déjà sorti (un exemplaire ne se prête pas deux fois) ou " + + "s'il est au format numérique (une fiche d'ebook n'a pas d'exemplaire à confier).") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + return routes; + } +} diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs index 0d9a47e..286cc1c 100644 --- a/MaBibli.Api/Program.cs +++ b/MaBibli.Api/Program.cs @@ -3,6 +3,7 @@ using MaBibli.Api.Endpoints; using MaBibli.Api.Services.Catalogue; using MaBibli.Api.Services.Identite; using MaBibli.Api.Services.Isbn; +using MaBibli.Api.Services.Prets; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -14,6 +15,7 @@ builder.Services.AddDbContext(options => builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Identité : lue dans les en-têtes injectés par SSOwat, avec repli sur un utilisateur simulé // configuré en développement (il n'y a pas de portail YunoHost en local). @@ -70,6 +72,7 @@ app.UseStaticFiles(); app.MapIsbnEndpoints(); app.MapLivresEndpoints(); app.MapAuteursEndpoints(); +app.MapPretsEndpoints(); app.MapIdentiteEndpoints(); app.MapFallbackToFile("index.html"); diff --git a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs index 80cb40b..ae2d970 100644 --- a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs +++ b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs @@ -62,9 +62,13 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur var livres = await requete.ToListAsync(ct); - var statuts = await StatutsAsync(livres.Select(l => l.Id).ToList(), utilisateur, ct); + var ids = livres.Select(l => l.Id).ToList(); + var statuts = await StatutsAsync(ids, utilisateur, ct); + var prets = await PretsOuvertsAsync(ids, ct); - return livres.Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id))).ToList(); + return livres + .Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id), prets.GetValueOrDefault(l.Id))) + .ToList(); } public async Task ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default) @@ -80,7 +84,8 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur } var statuts = await StatutsAsync([id], utilisateur, ct); - return Projeter(livre, statuts.GetValueOrDefault(id)); + var prets = await PretsOuvertsAsync([id], ct); + return Projeter(livre, statuts.GetValueOrDefault(id), prets.GetValueOrDefault(id)); } public async Task CreerAsync( @@ -198,6 +203,32 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur .ToDictionaryAsync(s => s.LivreId, s => (Statut?)s.Statut, ct); } + /// + /// Prêt en cours de chacun des livres demandés, quand il y en a un. + /// + /// + /// Aucun paramètre « utilisateur » ici, volontairement : contrairement au statut de + /// lecture, un livre absent l'est pour tout le foyer. La question « où est ce livre ? » a une + /// seule réponse, la même pour tout le monde. + /// + /// L'index unique partiel sur Prets garantit qu'il n'y a qu'un prêt ouvert par livre : + /// ce dictionnaire ne peut donc pas avoir à départager deux candidats. + /// + /// + private async Task> PretsOuvertsAsync( + IReadOnlyList livreIds, CancellationToken ct) + { + if (livreIds.Count == 0) + { + return []; + } + + return await db.Prets + .AsNoTracking() + .Where(p => p.DateRetour == null && livreIds.Contains(p.LivreId)) + .ToDictionaryAsync(p => p.LivreId, ct); + } + /// /// Pose, met à jour ou retire le statut de lecture d'une personne pour un livre. /// @@ -303,7 +334,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur private static string? Vide(string? valeur) => string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim(); - private static LivreDto Projeter(Livre livre, Statut? statut) => new() + private static LivreDto Projeter(Livre livre, Statut? statut, Pret? pretOuvert = null) => new() { Id = livre.Id, Isbn = livre.Isbn, @@ -319,5 +350,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur CoverUrl = livre.CoverUrl, DateAjout = livre.DateAjout, AjoutePar = livre.AjoutePar, + PreteA = pretOuvert?.Emprunteur, + PreteDepuis = pretOuvert?.DatePret, }; } diff --git a/MaBibli.Api/Services/Prets/ServicePrets.cs b/MaBibli.Api/Services/Prets/ServicePrets.cs new file mode 100644 index 0000000..1673b67 --- /dev/null +++ b/MaBibli.Api/Services/Prets/ServicePrets.cs @@ -0,0 +1,216 @@ +using MaBibli.Api.Data; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Api.Services.Prets; + +/// Issue d'une écriture de prêt : soit le prêt, soit un message destiné à l'utilisateur. +public readonly record struct ResultatPret(PretDto? Pret, string? Erreur) +{ + public static ResultatPret Ok(PretDto pret) => new(pret, null); + + public static ResultatPret Invalide(string message) => new(null, message); + + /// Ni prêt ni erreur : la ressource demandée n'existe pas. + public static readonly ResultatPret Introuvable = new(null, null); + + public bool EstIntrouvable => Pret is null && Erreur is null; +} + +public interface IServicePrets +{ + /// Tous les livres actuellement sortis, du plus ancien prêt au plus récent. + Task> EnCoursAsync(CancellationToken ct = default); + + /// Historique complet d'un livre, du plus récent au plus ancien. null si le livre n'existe pas. + Task?> HistoriqueAsync(int livreId, CancellationToken ct = default); + + Task PreterAsync(int livreId, EnregistrementPret saisie, CancellationToken ct = default); + + Task RendreAsync(int pretId, RetourPret retour, CancellationToken ct = default); +} + +/// +/// Gestion des prêts. Un prêt est ouvert tant que sa date de retour est nulle ; le rendre +/// ne supprime rien, il pose la date. C'est ce qui conserve l'historique. +/// +/// +/// Aucune lecture ni écriture ne filtre sur l'utilisateur courant. La bibliothèque est +/// commune : tout le foyer doit voir qui a emprunté quoi, et n'importe qui doit pouvoir noter le +/// retour d'un livre qu'il a récupéré. C'est aussi pourquoi ce service ne reçoit pas d'identité +/// du tout — il n'a rien à en faire, et ne peut donc pas s'en servir par inadvertance. +/// +public sealed class ServicePrets(MaBibliDbContext db) : IServicePrets +{ + /// + /// Tolérance sur une date de prêt future. + /// + /// + /// Le client convertit une date locale en UTC avant de l'envoyer, donc « aujourd'hui » ne + /// devrait jamais dépasser l'instant courant. Un décalage d'horloge d'un fuseau à l'autre + /// suffirait pourtant à faire rejeter une saisie parfaitement légitime : une journée de marge + /// absorbe ce cas sans laisser passer de vraie date future. + /// + private static readonly TimeSpan MargeFutur = TimeSpan.FromDays(1); + + public async Task> EnCoursAsync(CancellationToken ct = default) + { + var prets = await Requete() + .Where(p => p.DateRetour == null) + + // Du plus ancien au plus récent : ce qu'on cherche dans cette vue, c'est le livre + // parti depuis six mois qu'on avait oublié, pas celui prêté hier. + .OrderBy(p => p.DatePret) + .ThenBy(p => p.Id) + .ToListAsync(ct); + + return prets.Select(Projeter).ToList(); + } + + public async Task?> HistoriqueAsync(int livreId, CancellationToken ct = default) + { + // Distinguer « livre inconnu » de « livre jamais prêté » : le premier est un 404, le + // second une liste vide parfaitement normale. + if (!await db.Livres.AnyAsync(l => l.Id == livreId, ct)) + { + return null; + } + + var prets = await Requete() + .Where(p => p.LivreId == livreId) + .OrderByDescending(p => p.DatePret) + .ThenByDescending(p => p.Id) + .ToListAsync(ct); + + return prets.Select(Projeter).ToList(); + } + + public async Task PreterAsync( + int livreId, EnregistrementPret saisie, CancellationToken ct = default) + { + var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == livreId, ct); + if (livre is null) + { + return ResultatPret.Introuvable; + } + + // Un ebook n'est qu'une fiche : il n'y a pas d'objet à confier à quelqu'un (CLAUDE.md). + // Refusé côté serveur et pas seulement grisé dans l'interface, sans quoi la règle ne + // vaudrait que pour ceux qui passent par l'écran. + if (livre.Format == Format.Numerique) + { + return ResultatPret.Invalide( + "Un livre numérique ne se prête pas : ce n'est qu'une fiche, il n'y a pas d'exemplaire à confier."); + } + + var emprunteur = saisie.Emprunteur?.Trim(); + if (string.IsNullOrWhiteSpace(emprunteur)) + { + return ResultatPret.Invalide("Indiquez à qui vous prêtez ce livre."); + } + + var ouvert = await db.Prets + .AsNoTracking() + .FirstOrDefaultAsync(p => p.LivreId == livreId && p.DateRetour == null, ct); + + if (ouvert is not null) + { + return ResultatPret.Invalide( + $"Ce livre est déjà prêté à {ouvert.Emprunteur}. Marquez-le rendu avant de le prêter à quelqu'un d'autre."); + } + + var datePret = saisie.DatePret ?? DateTime.UtcNow; + if (datePret > DateTime.UtcNow + MargeFutur) + { + return ResultatPret.Invalide("La date de prêt ne peut pas être dans le futur."); + } + + var pret = new Pret + { + LivreId = livreId, + Emprunteur = emprunteur, + DatePret = datePret, + DateRetour = null, + }; + + db.Prets.Add(pret); + await db.SaveChangesAsync(ct); + + return ResultatPret.Ok((await ObtenirAsync(pret.Id, ct))!); + } + + public async Task RendreAsync( + int pretId, RetourPret retour, CancellationToken ct = default) + { + var pret = await db.Prets.FirstOrDefaultAsync(p => p.Id == pretId, ct); + if (pret is null) + { + return ResultatPret.Introuvable; + } + + if (pret.DateRetour is not null) + { + // Idempotent en apparence, mais on refuse quand même : réécrire la date écraserait + // une information exacte par une approximative, et deux personnes qui rangent le même + // livre ne doivent pas se marcher dessus en silence. + return ResultatPret.Invalide("Ce prêt est déjà clos : le livre a été rendu."); + } + + var dateRetour = retour.DateRetour ?? DateTime.UtcNow; + + if (dateRetour > DateTime.UtcNow + MargeFutur) + { + return ResultatPret.Invalide("La date de retour ne peut pas être dans le futur."); + } + + if (dateRetour < pret.DatePret) + { + return ResultatPret.Invalide("Le livre ne peut pas être revenu avant d'être parti."); + } + + pret.DateRetour = dateRetour; + await db.SaveChangesAsync(ct); + + return ResultatPret.Ok((await ObtenirAsync(pret.Id, ct))!); + } + + private async Task ObtenirAsync(int pretId, CancellationToken ct) + { + var pret = await Requete().FirstOrDefaultAsync(p => p.Id == pretId, ct); + return pret is null ? null : Projeter(pret); + } + + private IQueryable Requete() => + db.Prets.AsNoTracking().Include(p => p.Livre!).ThenInclude(l => l.Auteurs).ThenInclude(la => la.Auteur); + + /// + /// Mise en forme d'un prêt déjà chargé — la projection se fait en mémoire, après la requête : + /// la concaténation des auteurs n'a pas d'équivalent traduisible en SQL. + /// + /// + /// Le livre est recopié à plat plutôt qu'imbriqué : la vue des prêts en cours n'a besoin que + /// de l'identifier visuellement, et un complet traînerait derrière lui + /// le statut de lecture, qui est personnel et n'a aucun sens ici. + /// + private static PretDto Projeter(Pret pret) + { + var auteurs = pret.Livre?.Auteurs + .OrderBy(la => la.Position) + .Where(la => la.Auteur is not null) + .Select(la => la.Auteur!.Nom) + .ToList() ?? []; + + return new PretDto + { + Id = pret.Id, + LivreId = pret.LivreId, + Emprunteur = pret.Emprunteur, + DatePret = pret.DatePret, + DateRetour = pret.DateRetour, + LivreTitre = pret.Livre?.Titre, + LivreCoverUrl = pret.Livre?.CoverUrl, + LivreAuteurs = auteurs.Count == 0 ? null : string.Join(", ", auteurs), + }; + } +} diff --git a/MaBibli.Client/Composants/PretsLivre.razor b/MaBibli.Client/Composants/PretsLivre.razor new file mode 100644 index 0000000..e5dd8d8 --- /dev/null +++ b/MaBibli.Client/Composants/PretsLivre.razor @@ -0,0 +1,232 @@ +@inject ServiceLivresApi Api + +@* + Prêts d'un livre : l'état courant, l'action qui s'impose, puis l'historique. + + Mobile d'abord : une seule colonne, un seul bouton visible à la fois. Prêter et rendre sont + les deux gestes du quotidien — ils sont en haut, l'historique se déroule dessous. +*@ +
+

Prêt

+ + @if (Format == MaBibli.Shared.Entites.Format.Numerique) + { + @* CLAUDE.md : un ebook n'est qu'une fiche, il n'y a pas d'exemplaire à confier. + L'API le refuse aussi — ici on évite simplement de proposer un geste impossible. *@ +

+ Un livre numérique ne se prête pas : il n'y a pas d'exemplaire à confier. +

+ } + else if (_prets is null) + { +

Chargement…

+ } + else + { + @if (_erreur is not null) + { + + } + + @if (EnCours is { } pret) + { +

+ Prêté + à @pret.Emprunteur + depuis le @Jour(pret.DatePret) + (@Duree(pret.JoursEcoules)) +

+ +
+ +
+ } + else if (!_formulaireOuvert) + { +

Ce livre est à la maison.

+ +
+ +
+ } + else + { + @* Emprunteur en texte libre : ce sont des gens du dehors, sans compte YunoHost. *@ + + + + +
+ + +
+ } + + @if (Historique.Count > 0) + { + @* L'historique est la raison d'être de la table : un prêt rendu ne disparaît pas. *@ +

Prêts passés

+ +
    + @foreach (var passe in Historique) + { +
  • + @passe.Emprunteur + + du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value) + — @Duree(passe.JoursEcoules) + +
  • + } +
+ } + else if (EnCours is null) + { +

Jamais prêté.

+ } + } +
+ +@code { + [Parameter, EditorRequired] public int LivreId { get; set; } + + /// Format du livre : un ebook ne se prête pas. + [Parameter] public Format Format { get; set; } + + /// Prévient la page parente qu'un prêt a changé, pour qu'elle rafraîchisse sa fiche. + [Parameter] public EventCallback OnChangement { get; set; } + + private IReadOnlyList? _prets; + private bool _formulaireOuvert; + private bool _occupe; + private string _emprunteur = string.Empty; + private DateTime _datePret = DateTime.Today; + private string? _erreur; + private int _livreCharge; + + private PretDto? EnCours => _prets?.FirstOrDefault(p => p.EstEnCours); + + private IReadOnlyList Historique => + _prets?.Where(p => !p.EstEnCours).ToList() ?? []; + + protected override async Task OnParametersSetAsync() + { + if (_livreCharge != LivreId) + { + _livreCharge = LivreId; + _prets = null; + await ChargerAsync(); + } + } + + private async Task ChargerAsync() + { + try + { + _prets = await Api.ListerPretsLivreAsync(LivreId) ?? []; + } + catch (Exception ex) + { + _erreur = $"Impossible de charger les prêts : {ex.Message}"; + _prets = []; + } + } + + private void OuvrirFormulaire() + { + _emprunteur = string.Empty; + _datePret = DateTime.Today; + _erreur = null; + _formulaireOuvert = true; + } + + private async Task SurTouche(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_emprunteur)) + { + await PreterAsync(); + } + } + + private async Task PreterAsync() + { + _occupe = true; + _erreur = null; + + // La date n'est transmise que si elle diffère d'aujourd'hui : sinon on laisse le serveur + // horodater à l'instant présent, plus précis que « minuit ce matin ». + var date = _datePret.Date == DateTime.Today ? (DateTime?)null : _datePret; + var resultat = await Api.PreterAsync(LivreId, _emprunteur.Trim(), date); + + _occupe = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + return; + } + + _formulaireOuvert = false; + await ChargerAsync(); + await OnChangement.InvokeAsync(); + } + + private async Task RendreAsync() + { + _occupe = true; + _erreur = null; + + var resultat = await Api.RendreAsync(EnCours!.Id); + + _occupe = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + + // Le prêt a peut-être été clos ailleurs (autre membre du foyer, autre onglet) : + // recharger vaut mieux que laisser un bouton qui ne peut plus rien faire. + await ChargerAsync(); + return; + } + + await ChargerAsync(); + await OnChangement.InvokeAsync(); + } + + private static string Jour(DateTime instant) => + instant.ToLocalTime().ToString("dd/MM/yyyy"); + + /// Durée en clair : « 3 jours », « 2 mois ». Le nombre de jours exact ne parle plus au-delà. + internal static string Duree(int jours) => jours switch + { + 0 => "aujourd'hui", + 1 => "1 jour", + < 31 => $"{jours} jours", + < 365 => $"{jours / 30} mois", + < 730 => "plus d'un an", + _ => $"plus de {jours / 365} ans", + }; +} diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor index bd04811..71f28ca 100644 --- a/MaBibli.Client/Pages/Catalogue.razor +++ b/MaBibli.Client/Pages/Catalogue.razor @@ -85,6 +85,14 @@ else if (_livres is not null)

@Libelles.Format(livre.Format) + + @* Un livre dehors l'est pour tout le foyer : cette étiquette est la même + pour tout le monde, contrairement au statut de lecture juste dessous. *@ + @if (livre.PreteA is { } emprunteur) + { + Prêté à @emprunteur + } + @if (!string.IsNullOrWhiteSpace(livre.Editeur)) { @livre.Editeur @@ -105,6 +113,7 @@ else if (_livres is not null)

diff --git a/MaBibli.Client/Pages/EditionLivre.razor b/MaBibli.Client/Pages/EditionLivre.razor index 5650828..d204734 100644 --- a/MaBibli.Client/Pages/EditionLivre.razor +++ b/MaBibli.Client/Pages/EditionLivre.razor @@ -26,6 +26,8 @@ else @if (_livre is not null) { + +

Ajouté le @_livre.DateAjout.ToLocalTime().ToString("dd/MM/yyyy") @if (!string.IsNullOrWhiteSpace(_livre.AjoutePar)) @@ -47,6 +49,14 @@ else {

Supprimer « @_saisie.Titre » définitivement ? L'historique de ses prêts partira avec. + @* Choix assumé : les prêts n'ont d'intérêt qu'attachés à un livre. Un historique + orphelin (« quelqu'un a emprunté quelque chose ») ne se lit plus. *@ + @if (_livre?.PreteA is { } emprunteur) + { + + Attention : ce livre est actuellement prêté à @emprunteur. + + }

+
+ + } + +} + + + +@code { + private IReadOnlyList? _prets; + private string? _erreur; + private int? _enCours; + + protected override Task OnInitializedAsync() => ChargerAsync(); + + private async Task ChargerAsync() + { + _erreur = null; + + try + { + _prets = await Api.ListerPretsEnCoursAsync(); + } + catch (Exception ex) + { + _erreur = $"Impossible de charger les prêts : {ex.Message}"; + _prets = []; + } + } + + private async Task RendreAsync(PretDto pret) + { + _enCours = pret.Id; + var resultat = await Api.RendreAsync(pret.Id); + _enCours = null; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + } + + // Rechargement complet plutôt que retrait local : quelqu'un d'autre du foyer a pu rendre + // ou prêter un livre entre-temps, et cette vue est justement celle qu'on garde ouverte. + await ChargerAsync(); + } +} diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index 9671d6d..4f468f8 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -111,6 +111,59 @@ public sealed class ServiceLivresApi(HttpClient http) return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct); } + /// Les livres actuellement dehors, du prêt le plus ancien au plus récent. + public async Task> ListerPretsEnCoursAsync(CancellationToken ct = default) => + await http.GetFromJsonAsync>("api/prets/en-cours", Json, ct) ?? []; + + /// + /// Historique complet des prêts d'un livre, du plus récent au plus ancien. + /// + /// Liste vide = jamais prêté ; null = livre inconnu. + public async Task?> ListerPretsLivreAsync(int livreId, CancellationToken ct = default) + { + var reponse = await http.GetAsync($"api/livres/{livreId}/prets", ct); + return reponse.StatusCode == HttpStatusCode.NotFound + ? null + : await LireAsync>(reponse, ct); + } + + /// + /// Prête un livre. est une date locale, convertie ici en + /// UTC : tout ce que l'API stocke est en UTC, comme le reste des dates du projet. + /// + public async Task> PreterAsync( + int livreId, string emprunteur, DateTime? datePret = null, CancellationToken ct = default) + { + var saisie = new EnregistrementPret + { + Emprunteur = emprunteur, + DatePret = EnUtc(datePret), + }; + + var reponse = await http.PostAsJsonAsync($"api/livres/{livreId}/prets", saisie, Json, ct); + return await InterpreterAsync(reponse, ct); + } + + /// Clôt un prêt. Le prêt n'est pas supprimé : il devient l'historique du livre. + public async Task> RendreAsync( + int pretId, DateTime? dateRetour = null, CancellationToken ct = default) + { + var reponse = await http.PostAsJsonAsync( + $"api/prets/{pretId}/retour", new RetourPret { DateRetour = EnUtc(dateRetour) }, Json, ct); + return await InterpreterAsync(reponse, ct); + } + + /// + /// Convertit une date issue d'un <input type="date"> en instant UTC. + /// + /// + /// Le navigateur produit une date sans fuseau (minuit, « Unspecified ») : l'envoyer telle + /// quelle décalerait le prêt d'un jour pour la moitié du globe. On la déclare locale avant + /// de la convertir, ce qui est bien ce que l'utilisateur voulait dire en la choisissant. + /// + private static DateTime? EnUtc(DateTime? date) => + date is null ? null : DateTime.SpecifyKind(date.Value, DateTimeKind.Local).ToUniversalTime(); + public async Task SupprimerAsync(int id, CancellationToken ct = default) { var reponse = await http.DeleteAsync($"api/livres/{id}", ct); diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index 2393ebd..cefbfb8 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -297,14 +297,17 @@ body { right: 0; bottom: 0; display: flex; + flex-wrap: wrap; gap: 0.5rem; padding: 0.6rem 1rem calc(0.6rem + env(safe-area-inset-bottom)); background: rgba(255, 255, 255, 0.96); border-top: 1px solid var(--mb-bord); } +/* Base de 8rem : au-delà de trois entrées la barre passe sur deux lignes plutôt que de + comprimer chaque libellé sur trois lignes de texte. Mesuré sur 375 px de large. */ .actions-flottantes .bouton { - flex: 1; + flex: 1 1 8rem; } /* --- Couvertures --- */ @@ -450,6 +453,81 @@ body { } } +/* --- Prêts --- */ + +/* Le prêt est une information du foyer, pas de l'utilisateur : sa couleur est + volontairement différente de celles des statuts de lecture, qui sont personnels. */ +.etiquette-prete { + background: #fdecd8; + color: #7a4a00; + font-weight: 600; +} + +.bloc-prets { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--mb-bord); +} + +.titre-section { + font-size: 1.1rem; + margin: 0 0 0.5rem; +} + +.titre-sous-section { + font-size: 0.95rem; + margin: 1.25rem 0 0.35rem; + color: var(--mb-texte-doux); +} + +.bandeau-pret { + background: #fff8ef; + border-left: 3px solid #c98a2e; + border-radius: 4px; + padding: 0.6rem 0.75rem; + margin: 0.5rem 0; + font-size: 0.95rem; +} + +.pret-duree { + color: var(--mb-texte-doux); + font-size: 0.85rem; + white-space: nowrap; +} + +.avertissement-fort { + display: block; + margin-top: 0.35rem; + font-weight: 600; +} + +/* L'historique se lit, il ne se manipule pas : rien n'y est cliquable. */ +.liste-historique { + list-style: none; + margin: 0; + padding: 0; + font-size: 0.9rem; +} + +.liste-historique li { + padding: 0.4rem 0; + border-bottom: 1px solid var(--mb-bord); +} + +.historique-dates { + display: block; + color: var(--mb-texte-doux); + font-size: 0.85rem; +} + +/* Bouton d'action au sein d'une carte : il ne doit pas peser autant qu'un bouton de page, + tout en gardant une cible tactile utilisable. */ +.bouton-compact { + min-height: 2.25rem; + padding: 0.35rem 0.8rem; + font-size: 0.85rem; +} + /* --- Scan du code-barres --- */ .scanner-cadre { diff --git a/MaBibli.Shared/Dtos/LivreDto.cs b/MaBibli.Shared/Dtos/LivreDto.cs index 17c063e..2b2c415 100644 --- a/MaBibli.Shared/Dtos/LivreDto.cs +++ b/MaBibli.Shared/Dtos/LivreDto.cs @@ -15,8 +15,9 @@ public record AuteurDto } /// -/// Un livre tel qu'exposé par l'API. Volontairement sans la collection Prets : -/// les prêts sont la phase 4. +/// Un livre tel qu'exposé par l'API. Volontairement sans la collection de ses prêts : +/// le catalogue n'a besoin que de savoir si le livre est dehors, et l'historique complet se +/// demande à part (GET /api/livres/{id}/prets) pour ne pas alourdir chaque liste. /// public record LivreDto { @@ -53,6 +54,21 @@ public record LivreDto /// public string? AjoutePar { get; init; } + /// + /// Nom de la personne qui a le livre en ce moment, ou null s'il est à la maison. + /// + /// + /// Commun à tout le foyer, contrairement au statut de lecture : un livre absent l'est + /// pour tout le monde. Renseigné depuis le prêt ouvert du livre — il ne peut y en avoir qu'un. + /// + public string? PreteA { get; init; } + + /// Date du prêt en cours, s'il y en a un. + public DateTime? PreteDepuis { get; init; } + + [JsonIgnore] + public bool EstPrete => PreteA is not null; + /// Auteurs sur une ligne, pour un affichage compact. [JsonIgnore] public string? AuteursAffiches => diff --git a/MaBibli.Shared/Dtos/PretDto.cs b/MaBibli.Shared/Dtos/PretDto.cs new file mode 100644 index 0000000..c7167d8 --- /dev/null +++ b/MaBibli.Shared/Dtos/PretDto.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; + +namespace MaBibli.Shared.Dtos; + +/// +/// Un prêt tel qu'exposé par l'API — en cours ou clos, la table gardant l'historique complet. +/// +/// +/// Les champs Livre* recopient de quoi afficher le livre sans second appel : la vue +/// « prêts en cours » liste des prêts, mais ce que l'utilisateur y cherche est le livre +/// qui n'est plus à la maison. +/// +public record PretDto +{ + public required int Id { get; init; } + + public required int LivreId { get; init; } + + /// Nom de l'emprunteur, texte libre : aucun lien avec les comptes YunoHost. + public required string Emprunteur { get; init; } + + public required DateTime DatePret { get; init; } + + /// null tant que le livre n'a pas été rendu. + public DateTime? DateRetour { get; init; } + + public string? LivreTitre { get; init; } + + /// Auteurs du livre sur une ligne, déjà mis en forme par le serveur. + public string? LivreAuteurs { get; init; } + + public string? LivreCoverUrl { get; init; } + + [JsonIgnore] + public bool EstEnCours => DateRetour is null; + + /// Nombre de jours écoulés depuis le départ du livre — ou durée totale s'il est rendu. + [JsonIgnore] + public int JoursEcoules => + Math.Max(0, (int)((DateRetour ?? DateTime.UtcNow) - DatePret).TotalDays); +} + +/// Charge utile d'un nouveau prêt. +/// +/// Le livre n'y figure pas : il vient de l'URL. On ne prête pas « un livre quelconque », on prête +/// ce livre-ci, depuis sa fiche. +/// +public record EnregistrementPret +{ + /// Obligatoire : un prêt sans emprunteur ne se retrouve pas. + public string Emprunteur { get; set; } = string.Empty; + + /// + /// Date du prêt en UTC. null = maintenant. + /// + /// + /// Modifiable parce qu'on enregistre souvent un prêt après l'avoir fait — « tiens, + /// Paul a mon Zola depuis Noël ». Une date future est en revanche refusée : elle ne + /// correspondrait à rien de constatable. + /// + public DateTime? DatePret { get; set; } +} + +/// Charge utile du retour d'un livre. +public record RetourPret +{ + /// Date du retour en UTC. null = maintenant. + public DateTime? DateRetour { get; set; } +} diff --git a/MaBibli.Tests/ServicePretsTests.cs b/MaBibli.Tests/ServicePretsTests.cs new file mode 100644 index 0000000..af990bd --- /dev/null +++ b/MaBibli.Tests/ServicePretsTests.cs @@ -0,0 +1,348 @@ +using MaBibli.Api.Data; +using MaBibli.Api.Services.Catalogue; +using MaBibli.Api.Services.Prets; +using MaBibli.Shared.Catalogue; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Tests; + +/// +/// Prêts, sur une base SQLite en mémoire : même moteur qu'en production — ce qui compte +/// ici, l'index unique partiel « un seul prêt ouvert par livre » n'existant que côté base. +/// +public class ServicePretsTests : IDisposable +{ + private readonly SqliteConnection _connexion; + private readonly MaBibliDbContext _db; + private readonly ServicePrets _prets; + private readonly ServiceCatalogue _catalogue; + + public ServicePretsTests() + { + _connexion = new SqliteConnection("Data Source=:memory:"); + _connexion.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connexion) + .Options; + + _db = new MaBibliDbContext(options); + _db.Database.EnsureCreated(); + + _prets = new ServicePrets(_db); + _catalogue = new ServiceCatalogue(_db, new ServiceAuteurs(_db)); + } + + private async Task LivreAsync(string titre = "Germinal", Format format = Format.Physique) + { + var resultat = await _catalogue.CreerAsync( + new EnregistrementLivre { Titre = titre, Auteur = "Émile Zola", Format = format }, + "mathieu"); + + return resultat.Livre!.Id; + } + + [Fact] + public async Task Preter_ouvre_un_pret_sans_date_de_retour() + { + var livreId = await LivreAsync(); + + var resultat = await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" }); + + Assert.Null(resultat.Erreur); + var pret = resultat.Pret!; + Assert.Equal("Paul", pret.Emprunteur); + Assert.Null(pret.DateRetour); + Assert.True(pret.EstEnCours); + + // Le livre est recopié à plat pour que la vue « prêts en cours » n'ait qu'un appel à faire. + Assert.Equal("Germinal", pret.LivreTitre); + Assert.Equal("Émile Zola", pret.LivreAuteurs); + } + + [Fact] + public async Task Preter_ecarte_les_espaces_autour_de_lemprunteur() + { + var livreId = await LivreAsync(); + + var resultat = await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = " Paul " }); + + Assert.Equal("Paul", resultat.Pret!.Emprunteur); + } + + [Fact] + public async Task Preter_refuse_un_emprunteur_vide() + { + var livreId = await LivreAsync(); + + var resultat = await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = " " }); + + Assert.NotNull(resultat.Erreur); + Assert.Empty(await _db.Prets.ToListAsync()); + } + + [Fact] + public async Task Preter_refuse_un_livre_inconnu() + { + var resultat = await _prets.PreterAsync(999, new EnregistrementPret { Emprunteur = "Paul" }); + + Assert.True(resultat.EstIntrouvable); + } + + /// + /// CLAUDE.md : un ebook n'est qu'une fiche, il n'y a pas d'exemplaire à confier. La règle est + /// tenue par l'API, pas seulement par l'interface. + /// + [Fact] + public async Task Preter_refuse_un_livre_numerique() + { + var livreId = await LivreAsync("Effective Java", Format.Numerique); + + var resultat = await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" }); + + Assert.NotNull(resultat.Erreur); + Assert.Contains("numérique", resultat.Erreur); + Assert.Empty(await _db.Prets.ToListAsync()); + } + + [Fact] + public async Task Preter_refuse_un_livre_deja_sorti_et_nomme_qui_la() + { + var livreId = await LivreAsync(); + await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" }); + + var resultat = await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Marie" }); + + Assert.NotNull(resultat.Erreur); + Assert.Contains("Paul", resultat.Erreur); + Assert.Single(await _db.Prets.ToListAsync()); + } + + /// + /// Le service vérifie déjà le doublon, mais entre sa vérification et son insertion il y a une + /// fenêtre. Ce test contourne volontairement le service pour prouver que la base refuse aussi. + /// + [Fact] + public async Task La_base_refuse_deux_prets_ouverts_sur_le_meme_livre() + { + var livreId = await LivreAsync(); + await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" }); + + _db.Prets.Add(new Pret + { + LivreId = livreId, + Emprunteur = "Marie", + DatePret = DateTime.UtcNow, + DateRetour = null, + }); + + await Assert.ThrowsAsync(() => _db.SaveChangesAsync()); + } + + [Fact] + public async Task Preter_accepte_une_date_passee() + { + var livreId = await LivreAsync(); + var noel = new DateTime(2025, 12, 25, 0, 0, 0, DateTimeKind.Utc); + + var resultat = await _prets.PreterAsync( + livreId, new EnregistrementPret { Emprunteur = "Paul", DatePret = noel }); + + Assert.Null(resultat.Erreur); + Assert.Equal(noel, resultat.Pret!.DatePret); + } + + [Fact] + public async Task Preter_refuse_une_date_future() + { + var livreId = await LivreAsync(); + + var resultat = await _prets.PreterAsync( + livreId, + new EnregistrementPret { Emprunteur = "Paul", DatePret = DateTime.UtcNow.AddDays(30) }); + + Assert.NotNull(resultat.Erreur); + } + + [Fact] + public async Task Rendre_pose_la_date_sans_supprimer_le_pret() + { + var livreId = await LivreAsync(); + var pret = (await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" })).Pret!; + + var resultat = await _prets.RendreAsync(pret.Id, new RetourPret()); + + Assert.Null(resultat.Erreur); + Assert.NotNull(resultat.Pret!.DateRetour); + Assert.False(resultat.Pret.EstEnCours); + + // La ligne est toujours là : c'est toute la raison d'être d'une table séparée. + Assert.Single(await _db.Prets.ToListAsync()); + } + + [Fact] + public async Task Rendre_refuse_un_pret_deja_clos() + { + var livreId = await LivreAsync(); + var pret = (await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" })).Pret!; + await _prets.RendreAsync(pret.Id, new RetourPret()); + + var resultat = await _prets.RendreAsync(pret.Id, new RetourPret()); + + Assert.NotNull(resultat.Erreur); + } + + [Fact] + public async Task Rendre_refuse_un_pret_inconnu() + { + Assert.True((await _prets.RendreAsync(999, new RetourPret())).EstIntrouvable); + } + + [Fact] + public async Task Rendre_refuse_une_date_anterieure_au_pret() + { + var livreId = await LivreAsync(); + var pret = (await _prets.PreterAsync( + livreId, + new EnregistrementPret { Emprunteur = "Paul", DatePret = DateTime.UtcNow.AddDays(-3) })).Pret!; + + var resultat = await _prets.RendreAsync( + pret.Id, new RetourPret { DateRetour = DateTime.UtcNow.AddDays(-10) }); + + Assert.NotNull(resultat.Erreur); + } + + [Fact] + public async Task Un_livre_rendu_peut_etre_prete_a_quelquun_dautre() + { + var livreId = await LivreAsync(); + var premier = (await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" })).Pret!; + await _prets.RendreAsync(premier.Id, new RetourPret()); + + var second = await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Marie" }); + + Assert.Null(second.Erreur); + + var historique = await _prets.HistoriqueAsync(livreId); + Assert.Equal(2, historique!.Count); + + // Le prêt clos n'a pas bougé : l'historique conserve bien qui avait le livre avant. + Assert.Contains(historique, p => p.Emprunteur == "Paul" && !p.EstEnCours); + Assert.Contains(historique, p => p.Emprunteur == "Marie" && p.EstEnCours); + } + + [Fact] + public async Task Historique_va_du_plus_recent_au_plus_ancien() + { + var livreId = await LivreAsync(); + + foreach (var (nom, jours) in new[] { ("Paul", -90), ("Marie", -30) }) + { + var pret = (await _prets.PreterAsync( + livreId, + new EnregistrementPret { Emprunteur = nom, DatePret = DateTime.UtcNow.AddDays(jours) })).Pret!; + + await _prets.RendreAsync(pret.Id, new RetourPret { DateRetour = DateTime.UtcNow.AddDays(jours + 10) }); + } + + var historique = await _prets.HistoriqueAsync(livreId); + + Assert.Equal(["Marie", "Paul"], historique!.Select(p => p.Emprunteur)); + } + + [Fact] + public async Task Historique_distingue_livre_inconnu_et_livre_jamais_prete() + { + var livreId = await LivreAsync(); + + Assert.Null(await _prets.HistoriqueAsync(999)); + Assert.Empty((await _prets.HistoriqueAsync(livreId))!); + } + + [Fact] + public async Task EnCours_ne_liste_que_les_livres_dehors_du_plus_ancien_au_plus_recent() + { + var germinal = await LivreAsync("Germinal"); + var horla = await LivreAsync("Le Horla"); + var petitPrince = await LivreAsync("Le Petit Prince"); + + // Rendu : ne doit plus apparaître. + var rendu = (await _prets.PreterAsync(germinal, new EnregistrementPret { Emprunteur = "Paul" })).Pret!; + await _prets.RendreAsync(rendu.Id, new RetourPret()); + + await _prets.PreterAsync( + horla, new EnregistrementPret { Emprunteur = "Marie", DatePret = DateTime.UtcNow.AddDays(-2) }); + await _prets.PreterAsync( + petitPrince, new EnregistrementPret { Emprunteur = "Jean", DatePret = DateTime.UtcNow.AddDays(-200) }); + + var enCours = await _prets.EnCoursAsync(); + + // Le plus ancien d'abord : c'est le livre oublié qu'on cherche dans cette vue. + Assert.Equal(["Jean", "Marie"], enCours.Select(p => p.Emprunteur)); + Assert.DoesNotContain(enCours, p => p.LivreId == germinal); + } + + /// + /// Le catalogue doit signaler qu'un livre est dehors — et cette information est commune + /// au foyer, contrairement au statut de lecture. + /// + [Fact] + public async Task Le_catalogue_signale_un_livre_prete_a_tout_le_monde() + { + var livreId = await LivreAsync(); + await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" }); + + foreach (var utilisateur in new[] { "mathieu", "quelquun-dautre" }) + { + var livre = await _catalogue.ObtenirAsync(livreId, utilisateur); + Assert.Equal("Paul", livre!.PreteA); + Assert.True(livre.EstPrete); + Assert.NotNull(livre.PreteDepuis); + + var liste = await _catalogue.ListerAsync(new CritereLivres(), utilisateur); + Assert.Equal("Paul", liste.Single().PreteA); + } + } + + [Fact] + public async Task Le_catalogue_ne_signale_plus_rien_une_fois_le_livre_rendu() + { + var livreId = await LivreAsync(); + var pret = (await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" })).Pret!; + await _prets.RendreAsync(pret.Id, new RetourPret()); + + var livre = await _catalogue.ObtenirAsync(livreId, "mathieu"); + + Assert.Null(livre!.PreteA); + Assert.False(livre.EstPrete); + } + + /// + /// Choix assumé : supprimer un livre emporte ses prêts. Un historique orphelin — « quelqu'un + /// a emprunté quelque chose » — ne se lit plus. La cascade était déjà déclarée sur la relation, + /// ce test la fige pour qu'un changement de comportement ne passe pas inaperçu. + /// + [Fact] + public async Task Supprimer_un_livre_emporte_son_historique_de_prets() + { + var livreId = await LivreAsync(); + var pret = (await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" })).Pret!; + await _prets.RendreAsync(pret.Id, new RetourPret()); + await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Marie" }); + + Assert.True(await _catalogue.SupprimerAsync(livreId)); + + Assert.Empty(await _db.Prets.ToListAsync()); + Assert.Empty(await _prets.EnCoursAsync()); + } + + public void Dispose() + { + _db.Dispose(); + _connexion.Dispose(); + GC.SuppressFinalize(this); + } +}