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