using MaBibli.Api.Data; using MaBibli.Shared.Catalogue; using MaBibli.Shared.Dtos; using MaBibli.Shared.Entites; using MaBibli.Shared.Isbn; using MaBibli.Shared.Textes; using Microsoft.EntityFrameworkCore; namespace MaBibli.Api.Services.Catalogue; /// /// Issue d'une écriture : le livre, un message d'erreur, ou des doublons à faire confirmer. /// public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons) { public static ResultatEcriture Ok(LivreDto livre) => new(livre, null, null); public static ResultatEcriture Invalide(string message) => new(null, message, null); /// /// L'écriture n'a pas eu lieu : elle ressemble trop à ce qui est déjà là. /// /// /// Ce n'est pas une erreur — la même saisie, confirmée, s'enregistrera telle quelle. /// D'où un troisième état plutôt qu'un message dans Erreur : l'écran doit proposer /// « ajouter quand même », ce qu'un texte rouge ne suggère pas. /// public static ResultatEcriture Doublon(DoublonsLivre doublons) => new(null, null, doublons); /// Ni livre, ni erreur, ni doublon : la ressource demandée n'existe pas. public static readonly ResultatEcriture Introuvable = new(null, null, null); public bool EstIntrouvable => Livre is null && Erreur is null && Doublons 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, bool confirmerDoublon = false, 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) .Include(l => l.Themes).ThenInclude(lt => lt.Theme), 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) .Include(l => l.Themes).ThenInclude(lt => lt.Theme) .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, bool confirmerDoublon = false, CancellationToken ct = default) { if (Valider(saisie, out var isbn, out var erreur) is false) { return ResultatEcriture.Invalide(erreur!); } if (!confirmerDoublon) { var deja = await DoublonsAsync(isbn, saisie, utilisateur, ct); if (deja is not null) { return ResultatEcriture.Doublon(deja); } } var livre = new Livre { Isbn = isbn, Titre = saisie.Titre, Editeur = Vide(saisie.Editeur), Format = saisie.Format, TypeDocument = saisie.TypeDocument, NombrePages = saisie.NombrePages, CoverUrl = Vide(saisie.CoverUrl), UrlNotice = Vide(saisie.UrlNotice), 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 RattacherThemesAsync(livre, saisie.Themes, 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) .Include(l => l.Themes).ThenInclude(lt => lt.Theme) .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.TypeDocument = saisie.TypeDocument; livre.NombrePages = saisie.NombrePages; livre.CoverUrl = Vide(saisie.CoverUrl); livre.UrlNotice = Vide(saisie.UrlNotice); 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 RattacherThemesAsync(livre, saisie.Themes, 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; } /// /// Cherche ce que le catalogue contient déjà de semblable, ou null s'il n'y a rien. /// /// /// Deux critères, et aucun n'est une clé. L'ISBN identique attrape le cas visé — le /// même livre scanné deux fois — mais il manque à beaucoup de fiches, puisqu'il est /// facultatif. La clé d'œuvre attrape la saisie manuelle sans ISBN, au prix de confondre le /// poche et le grand format. C'est précisément parce qu'aucun des deux ne tranche que le /// résultat est un avertissement et non un refus : un index unique interdirait le /// second exemplaire, qui est un cas parfaitement normal. /// /// Le titre seul ne suffit pas : il faut aussi un auteur commun, au sens de /// — sinon deux « Nouvelles » sans rapport se /// signaleraient l'une l'autre. /// /// private async Task DoublonsAsync( string? isbn, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct) { // Dictionnaire et non liste : un livre trouvé par son ISBN ET par son titre ne doit // apparaître qu'une fois. var candidats = new Dictionary(); if (isbn is not null) { foreach (var livre in await AvecAuteurs().Where(l => l.Isbn == isbn).ToListAsync(ct)) { candidats[livre.Id] = livre; } } var cle = CleOeuvre.Cle(saisie.Titre); if (cle.Length > 0) { // La clé d'œuvre est un PRÉFIXE de TitreNormalise : la troncature du sous-titre // précède la normalisation, qui préserve l'ordre des caractères. SQLite dégrossit // donc avec la colonne indexée, et l'égalité des clés se vérifie ensuite en mémoire // — sans quoi il faudrait charger tout le catalogue à chaque ajout. var parTitre = await AvecAuteurs() .Where(l => l.TitreNormalise == cle || l.TitreNormalise.StartsWith(cle + " ")) .ToListAsync(ct); foreach (var livre in parTitre) { if (CleOeuvre.Cle(livre.Titre) == cle && MemeAuteur(livre, saisie.Auteurs)) { candidats[livre.Id] = livre; } } } if (candidats.Count == 0) { return null; } var trouves = candidats.Values.OrderBy(l => l.Id).ToList(); var ids = trouves.Select(l => l.Id).ToList(); var statuts = await StatutsAsync(ids, utilisateur, ct); var prets = await PretsOuvertsAsync(ids, ct); return new DoublonsLivre { Message = Avertir(trouves), Livres = trouves .Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id), prets.GetValueOrDefault(l.Id))) .ToList(), }; } private IQueryable AvecAuteurs() => db.Livres.AsNoTracking() .Include(l => l.Auteurs).ThenInclude(la => la.Auteur) .Include(l => l.Themes).ThenInclude(lt => lt.Theme); /// Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ? /// Le rôle n'entre pas dans la comparaison : c'est la personne qui compte ici. private static bool MemeAuteur(Livre livre, IReadOnlyList saisis) => RapprochementAuteurs.PartagentUnAuteur( livre.Auteurs.Select(la => la.Auteur?.Nom), saisis.Select(a => a.Nom)); /// Phrase d'avertissement, écrite pour être lue telle quelle par l'utilisateur. /// /// Elle nomme ce qu'on possède déjà et invite à passer outre : c'est un doute soumis à /// l'utilisateur, pas un reproche. Les fiches elles-mêmes sont affichées à côté, donc le /// texte n'a pas à les décrire. /// private static string Avertir(IReadOnlyList trouves) { var debut = trouves.Count == 1 ? $"« {trouves[0].Titre} » est déjà au catalogue." : $"{trouves.Count} livres du catalogue ressemblent à celui-ci."; return debut + " Ajoutez quand même s'il s'agit d'un autre exemplaire ou d'une autre édition."; } /// 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 et leur rôle. /// /// /// ⚠️ Le rôle vit sur le lien, pas sur la fiche de l'auteur : le même dessinateur /// peut avoir scénarisé un autre album, et il ne doit pas exister en deux fiches pour /// autant. C'est pour cela que ResoudreAsync ne reçoit que des noms. /// private async Task RattacherAuteursAsync( Livre livre, IReadOnlyList saisis, CancellationToken ct) { var resolus = await auteurs.ResoudreAsync(saisis.Select(a => a.Nom), 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(); } // ResoudreAsync écarte les noms vides et fusionne les variantes : sa liste peut être // plus courte que la saisie. Les rôles se retrouvent donc par NOM RÉSOLU, jamais par // position — sans quoi un nom vide au milieu décalerait tous les rôles suivants. var roles = new Dictionary(StringComparer.Ordinal); foreach (var saisi in saisis) { roles.TryAdd(RapprochementAuteurs.Cle(saisi.Nom), saisi.Role); } for (var i = 0; i < resolus.Count; i++) { livre.Auteurs.Add(new LivreAuteur { Auteur = resolus[i], Position = i, Role = roles.GetValueOrDefault(resolus[i].CleRegroupement, RoleAuteur.NonPrecise), }); } } private async Task RattacherThemesAsync( Livre livre, IReadOnlyList saisis, CancellationToken ct) { var themes = saisis .Where(t => !string.IsNullOrWhiteSpace(t)) .Select(t => t.Trim()) .GroupBy(NormalisationTexte.Normaliser, StringComparer.Ordinal) .Select(g => g.First()) .ToList(); if (livre.Themes.Count > 0) { db.LivreThemes.RemoveRange(livre.Themes); livre.Themes.Clear(); } foreach (var nom in themes) { var normalise = NormalisationTexte.Normaliser(nom); var theme = await db.Themes.FirstOrDefaultAsync(t => t.NomNormalise == normalise, ct); if (theme is null) { theme = new Theme { Nom = nom }; theme.RecalculerFormes(); db.Themes.Add(theme); } livre.Themes.Add(new LivreTheme { Livre = livre, Theme = theme }); } } /// /// 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; } // ⚠️ On refuse « 0 page » plutôt que de l'enregistrer : la colonne est nullable // précisément pour que l'absence ait sa propre valeur, et un zéro stocké se lirait // comme une donnée. Laisser le champ vide est la façon de dire « je ne sais pas ». if (saisie.NombrePages is { } pages && pages <= 0) { erreur = "Le nombre de pages doit être positif. Laissez le champ vide si vous ne l'avez pas."; return false; } 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, Role = la.Role }) .ToList(), Themes = livre.Themes .Where(lt => lt.Theme is not null) .Select(lt => lt.Theme!.Nom) .OrderBy(nom => NormalisationTexte.Normaliser(nom), StringComparer.Ordinal) .ToList(), Editeur = livre.Editeur, Format = livre.Format, TypeDocument = livre.TypeDocument, NombrePages = livre.NombrePages, Statut = statut, CoverUrl = livre.CoverUrl, UrlNotice = livre.UrlNotice, DateAjout = livre.DateAjout, AjoutePar = livre.AjoutePar, PreteA = pretOuvert?.Emprunteur, PreteDepuis = pretOuvert?.DatePret, }; }