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); + } +}