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)
+ {
+
@_erreur
+ }
+
+ @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)
{
+
+
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.
+
+ }