using MaBibli.Api.Data; using MaBibli.Shared.Catalogue; using MaBibli.Shared.Dtos; using MaBibli.Shared.Entites; using MaBibli.Shared.Isbn; using Microsoft.EntityFrameworkCore; namespace MaBibli.Api.Services.Catalogue; /// Issue d'une écriture : soit le livre, soit un message d'erreur destiné à l'utilisateur. public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur) { public static ResultatEcriture Ok(LivreDto livre) => new(livre, null); public static ResultatEcriture Invalide(string message) => new(null, message); /// Ni livre ni erreur : la ressource demandée n'existe pas. public static readonly ResultatEcriture Introuvable = new(null, null); public bool EstIntrouvable => Livre is null && Erreur is null; } public interface IServiceCatalogue { Task> ListerAsync( CritereLivres criteres, string? utilisateur, CancellationToken ct = default); Task ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default); Task CreerAsync( EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default); Task ModifierAsync( int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default); Task ChangerStatutAsync( int id, Statut? statut, string? utilisateur, CancellationToken ct = default); Task SupprimerAsync(int id, CancellationToken ct = default); } /// /// CRUD du catalogue de livres — physiques et numériques confondus, distingués par /// . Il n'y a qu'un seul catalogue. /// /// /// Le paramètre utilisateur ne restreint jamais la liste des livres. Il sert /// uniquement à savoir de quel statut de lecture on parle : la bibliothèque reste commune, seule /// la lecture est personnelle. /// public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteurs) : IServiceCatalogue { public async Task> ListerAsync( CritereLivres criteres, string? utilisateur, CancellationToken ct = default) { // AsNoTracking : lecture pure. Et surtout : AUCUN filtre sur AjoutePar, // la bibliothèque est commune à tout le foyer (CLAUDE.md). var requete = FiltreLivres.Appliquer( db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur), criteres, utilisateur); var livres = await requete.ToListAsync(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), prets.GetValueOrDefault(l.Id))) .ToList(); } public async Task ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default) { var livre = await db.Livres .AsNoTracking() .Include(l => l.Auteurs).ThenInclude(la => la.Auteur) .FirstOrDefaultAsync(l => l.Id == id, ct); if (livre is null) { return null; } var statuts = await StatutsAsync([id], utilisateur, ct); var prets = await PretsOuvertsAsync([id], ct); return Projeter(livre, statuts.GetValueOrDefault(id), prets.GetValueOrDefault(id)); } public async Task CreerAsync( EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default) { if (Valider(saisie, out var isbn, out var erreur) is false) { return ResultatEcriture.Invalide(erreur!); } var livre = new Livre { Isbn = isbn, Titre = saisie.Titre, Editeur = Vide(saisie.Editeur), Format = saisie.Format, CoverUrl = Vide(saisie.CoverUrl), DateAjout = DateTime.UtcNow, // Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client. AjoutePar = utilisateur, }; livre.RecalculerFormes(); db.Livres.Add(livre); await RattacherAuteursAsync(livre, saisie.Auteurs, ct); await db.SaveChangesAsync(ct); // Le statut n'est écrit que si l'utilisateur en a explicitement posé un : un livre sans // ligne est « non commencé », et c'est le cas de départ le plus fréquent. await AppliquerStatutAsync(livre.Id, saisie.Statut, utilisateur, ct); return ResultatEcriture.Ok((await ObtenirAsync(livre.Id, utilisateur, ct))!); } public async Task ModifierAsync( int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default) { var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct); if (livre is null) { return ResultatEcriture.Introuvable; } if (Valider(saisie, out var isbn, out var erreur) is false) { return ResultatEcriture.Invalide(erreur!); } livre.Isbn = isbn; livre.Titre = saisie.Titre; livre.Editeur = Vide(saisie.Editeur); livre.Format = saisie.Format; livre.CoverUrl = Vide(saisie.CoverUrl); livre.RecalculerFormes(); // DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie // d'origine, pas des champs éditables. await RattacherAuteursAsync(livre, saisie.Auteurs, ct); await db.SaveChangesAsync(ct); // Le statut modifié est celui de la personne qui édite, pas de celle qui a saisi le livre. await AppliquerStatutAsync(livre.Id, saisie.Statut, utilisateur, ct); await auteurs.SupprimerOrphelinsAsync(ct); return ResultatEcriture.Ok((await ObtenirAsync(livre.Id, utilisateur, ct))!); } public async Task ChangerStatutAsync( int id, Statut? statut, string? utilisateur, CancellationToken ct = default) { if (!await db.Livres.AnyAsync(l => l.Id == id, ct)) { return null; } await AppliquerStatutAsync(id, statut, utilisateur, ct); return await ObtenirAsync(id, utilisateur, ct); } public async Task SupprimerAsync(int id, CancellationToken ct = default) { var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct); if (livre is null) { return false; } // Les liens vers les auteurs partent en cascade, mais les fiches auteur restent : elles // sont peut-être partagées avec d'autres livres. Le nettoyage d'après tranche. db.Livres.Remove(livre); await db.SaveChangesAsync(ct); await auteurs.SupprimerOrphelinsAsync(ct); return true; } /// Statut de pour chacun des livres demandés. private async Task> StatutsAsync( IReadOnlyList livreIds, string? utilisateur, CancellationToken ct) { // Sans identité, personne n'a de statut : on ne montre surtout pas celui d'un autre. if (utilisateur is null || livreIds.Count == 0) { return []; } // Le dictionnaire est volontairement typé « Statut? » : sans ça, l'absence de ligne // ressortirait comme la valeur 0 de l'énumération — c'est-à-dire « À lire » — et // « non commencé » deviendrait indiscernable d'un choix explicite. return await db.StatutsLecture .AsNoTracking() .Where(s => s.Utilisateur == utilisateur && livreIds.Contains(s.LivreId)) .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. /// private async Task AppliquerStatutAsync( int livreId, Statut? statut, string? utilisateur, CancellationToken ct) { if (utilisateur is null) { // Aucune identité : on n'invente pas de propriétaire pour ce statut. Le livre reste // enregistré, simplement sans lecture rattachée à personne. return; } var ligne = await db.StatutsLecture .FirstOrDefaultAsync(s => s.LivreId == livreId && s.Utilisateur == utilisateur, ct); if (statut is null) { if (ligne is not null) { // Retour à « non commencé » : on supprime la ligne au lieu d'inventer une // quatrième valeur d'énumération qui aurait fallu migrer. db.StatutsLecture.Remove(ligne); await db.SaveChangesAsync(ct); } return; } if (ligne is null) { db.StatutsLecture.Add(new StatutLecture { LivreId = livreId, Utilisateur = utilisateur, Statut = statut.Value, DateMaj = DateTime.UtcNow, }); } else { ligne.Statut = statut.Value; ligne.DateMaj = DateTime.UtcNow; } await db.SaveChangesAsync(ct); } /// Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre. private async Task RattacherAuteursAsync( Livre livre, IEnumerable noms, CancellationToken ct) { var resolus = await auteurs.ResoudreAsync(noms, ct); // Les liens existants sont retirés puis reconstruits : c'est la façon la plus simple de // gérer à la fois l'ajout, le retrait et le réordonnancement des auteurs. if (livre.Auteurs.Count > 0) { db.LivreAuteurs.RemoveRange(livre.Auteurs); livre.Auteurs.Clear(); } for (var i = 0; i < resolus.Count; i++) { livre.Auteurs.Add(new LivreAuteur { Auteur = resolus[i], Position = i }); } } /// /// Contrôles minimaux : un titre, et un ISBN qui tienne debout s'il est fourni. /// /// /// L'ISBN est facultatif (livres anciens, tirages sans ISBN) mais, s'il est saisi, il doit /// être valide : un ISBN faux est pire que pas d'ISBN, il empêcherait tout rapprochement /// futur avec les catalogues. /// private static bool Valider(EnregistrementLivre saisie, out string? isbn, out string? erreur) { isbn = null; erreur = null; if (string.IsNullOrWhiteSpace(saisie.Titre)) { erreur = "Le titre est obligatoire."; return false; } if (!string.IsNullOrWhiteSpace(saisie.Isbn)) { var normalise = IsbnUtils.Normaliser(saisie.Isbn); if (!IsbnUtils.EstValide(normalise)) { erreur = $"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si le livre n'en a pas."; return false; } isbn = normalise; } return true; } private static string? Vide(string? valeur) => string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim(); private static LivreDto Projeter(Livre livre, Statut? statut, Pret? pretOuvert = null) => new() { Id = livre.Id, Isbn = livre.Isbn, Titre = livre.Titre, Auteurs = livre.Auteurs .OrderBy(la => la.Position) .Where(la => la.Auteur is not null) .Select(la => new AuteurDto { Id = la.Auteur!.Id, Nom = la.Auteur.Nom }) .ToList(), Editeur = livre.Editeur, Format = livre.Format, Statut = statut, CoverUrl = livre.CoverUrl, DateAjout = livre.DateAjout, AjoutePar = livre.AjoutePar, PreteA = pretOuvert?.Emprunteur, PreteDepuis = pretOuvert?.DatePret, }; }