Gerer les prets : API, ecrans et contrainte en base

L'entite Pret existait depuis le squelette sans jamais etre exploitee. Cette
phase la met en service de bout en bout.

API
- GET  /api/prets/en-cours       ce qui n'est pas a la maison, du plus ancien
                                 au plus recent : on cherche le livre oublie,
                                 pas celui prete hier
- POST /api/livres/{id}/prets    preter
- GET  /api/livres/{id}/prets    historique complet, du plus recent au plus
                                 ancien
- POST /api/prets/{id}/retour    clore le pret sans le supprimer

Un livre deja sorti ne peut pas etre prete une seconde fois. Le service le
verifie et nomme celui qui l'a deja, mais entre sa verification et l'insertion
il reste une fenetre : un index unique PARTIEL (LivreId WHERE DateRetour IS
NULL) la ferme, tout en laissant l'historique accumuler autant de prets clos
que necessaire sur le meme livre.

Les ebooks sont refuses cote API, pas seulement grises dans l'interface : une
fiche n'a pas d'exemplaire a confier.

Les prets sont COMMUNS au foyer, symetrique inverse du statut de lecture. Le
service ne recoit meme pas d'identite, pour qu'on ne puisse pas s'en servir
par inadvertance.

Interface, pensee mobile d'abord
- ecran « Prets en cours » avec bouton « Rendu » a meme la liste
- bloc pret sur la fiche d'un livre : etat courant, action, puis historique
- etiquette « Prete a X » dans le catalogue
- la barre d'actions passe sur deux lignes plutot que de comprimer ses
  libelles maintenant qu'elle compte quatre entrees

Toutes les dates sont en UTC ; le client convertit la date locale du
<input type="date"> avant l'envoi, faute de quoi le pret se decalerait d'un
jour pour la moitie du globe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-18 02:45:01 +02:00
co-authored by Claude Opus 5
parent ab878b4d3c
commit 2a3dfb88de
18 changed files with 1656 additions and 13 deletions
+216
View File
@@ -0,0 +1,216 @@
using MaBibli.Api.Data;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Prets;
/// <summary>Issue d'une écriture de prêt : soit le prêt, soit un message destiné à l'utilisateur.</summary>
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);
/// <summary>Ni prêt ni erreur : la ressource demandée n'existe pas.</summary>
public static readonly ResultatPret Introuvable = new(null, null);
public bool EstIntrouvable => Pret is null && Erreur is null;
}
public interface IServicePrets
{
/// <summary>Tous les livres actuellement sortis, du plus ancien prêt au plus récent.</summary>
Task<IReadOnlyList<PretDto>> EnCoursAsync(CancellationToken ct = default);
/// <summary>Historique complet d'un livre, du plus récent au plus ancien. <c>null</c> si le livre n'existe pas.</summary>
Task<IReadOnlyList<PretDto>?> HistoriqueAsync(int livreId, CancellationToken ct = default);
Task<ResultatPret> PreterAsync(int livreId, EnregistrementPret saisie, CancellationToken ct = default);
Task<ResultatPret> RendreAsync(int pretId, RetourPret retour, CancellationToken ct = default);
}
/// <summary>
/// Gestion des prêts. Un prêt est <b>ouvert</b> tant que sa date de retour est nulle ; le rendre
/// ne supprime rien, il pose la date. C'est ce qui conserve l'historique.
/// </summary>
/// <remarks>
/// <b>Aucune lecture ni écriture ne filtre sur l'utilisateur courant.</b> 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.
/// </remarks>
public sealed class ServicePrets(MaBibliDbContext db) : IServicePrets
{
/// <summary>
/// Tolérance sur une date de prêt future.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static readonly TimeSpan MargeFutur = TimeSpan.FromDays(1);
public async Task<IReadOnlyList<PretDto>> 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<IReadOnlyList<PretDto>?> 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<ResultatPret> 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<ResultatPret> 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<PretDto?> ObtenirAsync(int pretId, CancellationToken ct)
{
var pret = await Requete().FirstOrDefaultAsync(p => p.Id == pretId, ct);
return pret is null ? null : Projeter(pret);
}
private IQueryable<Pret> Requete() =>
db.Prets.AsNoTracking().Include(p => p.Livre!).ThenInclude(l => l.Auteurs).ThenInclude(la => la.Auteur);
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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 <see cref="LivreDto"/> complet traînerait derrière lui
/// le statut de lecture, qui est personnel et n'a aucun sens ici.
/// </remarks>
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),
};
}
}