using MaBibli.Api.Data; using MaBibli.Shared.Dtos; using MaBibli.Shared.Entites; using MaBibli.Shared.Isbn; using MaBibli.Shared.Textes; using Microsoft.EntityFrameworkCore; namespace MaBibli.Api.Services.Souhaits; /// Issue d'une écriture d'envie : la ligne, un message, ou rien du tout. /// /// ⚠️ « Introuvable » est un troisième état, et pas un message d'erreur de plus : c'est /// la seule issue qui doive produire un 404. L'édition de l'envie de quelqu'un d'autre passe par /// là — l'inexistence et l'appartenance à autrui sont volontairement indiscernables. /// public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur, bool NExistePas = false) { public static readonly ResultatSouhait Introuvable = new(null, null, true); public static ResultatSouhait Ok(SouhaitDto souhait) => new(souhait, null); public static ResultatSouhait Invalide(string message) => new(null, message); } public interface IServiceSouhaits { Task> ListerAsync(string? utilisateur, CancellationToken ct = default); Task AjouterAsync( EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default); /// /// Corrige une envie existante de l'appelant. /// /// /// ⚠️ Rang n'y est pas touché : l'ordre a son propre point d'entrée, et corriger un /// titre n'est pas dire qu'on le veut davantage. /// Task ModifierAsync( int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default); Task SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default); /// /// Réordonne la liste de l'appelant selon la suite d'identifiants fournie. /// /// /// Prend la liste entière plutôt qu'un déplacement unitaire : c'est ce dont a besoin /// le glisser-déposer, et les flèches monter/descendre s'y ramènent sans effort. L'opération /// est idempotente — réenvoyer le même ordre ne change rien. /// Task ReordonnerAsync( IReadOnlyList idsOrdonnes, string? utilisateur, CancellationToken ct = default); } /// /// La liste d'envies, personnelle. /// /// /// ⚠️ Toute méthode filtre sur , sans exception. C'est la /// différence de fond avec ServiceCatalogue, dont les lectures ne filtrent jamais sur /// AjoutePar : le catalogue est commun, la liste d'envies ne l'est pas. Une envie qui /// fuiterait vers un autre membre du foyer gâcherait exactement ce que la fonctionnalité sert /// à préparer. /// /// Sans identité (ni en-tête SSOwat ni utilisateur simulé), il n'y a pas de liste : on ne rend /// rien et on n'écrit rien, plutôt que de rattacher des envies à un propriétaire inventé. /// /// public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits { public async Task> ListerAsync( string? utilisateur, CancellationToken ct = default) { if (utilisateur is null) { return []; } // L'ordre est celui que l'utilisateur a choisi, du plus désiré au moins désiré. Il a // remplacé un tri par auteur puis titre : classer une liste d'envies par ordre // alphabétique répondait à une question que personne ne se pose. // L'Id départage les rangs égaux — deux lignes de même rang ne devraient pas exister, // mais un tri instable ferait sautiller la liste entre deux affichages. var souhaits = await db.LivresSouhaites .AsNoTracking() .Where(s => s.Utilisateur == utilisateur) .OrderBy(s => s.Rang) .ThenBy(s => s.Id) .ToListAsync(ct); var possedes = await CorrespondancesAsync(souhaits, ct); return souhaits.Select(s => Projeter(s, Correspondant(possedes, s.Id))).ToList(); } public async Task AjouterAsync( EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default) { if (utilisateur is null) { return ResultatSouhait.Invalide( "Impossible d'ajouter une envie sans savoir à qui elle appartient."); } if (string.IsNullOrWhiteSpace(saisie.Titre)) { return ResultatSouhait.Invalide("Le titre est obligatoire."); } // L'ISBN reste facultatif — on souhaite souvent une œuvre sans avoir choisi son édition — // mais s'il est saisi il doit tenir debout, comme pour un livre du catalogue. string? isbn = null; if (!string.IsNullOrWhiteSpace(saisie.Isbn)) { var normalise = IsbnUtils.Normaliser(saisie.Isbn); if (!IsbnUtils.EstValide(normalise)) { return ResultatSouhait.Invalide( $"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si vous ne l'avez pas."); } isbn = normalise; } var souhait = new LivreSouhaite { Utilisateur = utilisateur, Titre = saisie.Titre, Auteur = saisie.Auteur, Editeur = Vide(saisie.Editeur), Annee = Vide(saisie.Annee), Isbn = isbn, CoverUrl = Vide(saisie.CoverUrl), Note = Vide(saisie.Note), DateAjout = DateTime.UtcNow, }; souhait.RecalculerFormes(); // Le doublon est refusé avec un message plutôt que laissé à l'index unique : l'écran de // bibliographie rend le double clic facile, et « UNIQUE constraint failed » ne veut rien // dire pour l'utilisateur. var deja = await db.LivresSouhaites.AnyAsync( s => s.Utilisateur == utilisateur && s.TitreNormalise == souhait.TitreNormalise && s.AuteurNormalise == souhait.AuteurNormalise, ct); if (deja) { return ResultatSouhait.Invalide($"« {souhait.Titre} » est déjà dans votre liste d'envies."); } // En fin de liste : une envie qu'on vient de noter n'est pas déclarée plus désirable // que celles déjà classées. La poser en tête déclasserait le choix de l'utilisateur // à chaque ajout. var dernierRang = await db.LivresSouhaites .Where(s => s.Utilisateur == utilisateur) .Select(s => (int?)s.Rang) .MaxAsync(ct); souhait.Rang = (dernierRang ?? -1) + 1; db.LivresSouhaites.Add(souhait); await db.SaveChangesAsync(ct); var possede = await CorrespondancesAsync([souhait], ct); return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id))); } public async Task ModifierAsync( int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default) { if (utilisateur is null) { return ResultatSouhait.Introuvable; } // Le filtre sur l'utilisateur fait partie de la CLÉ, comme pour la suppression : on ne // vérifie pas l'appartenance après coup, on ne trouve tout simplement pas l'envie d'un // autre. C'est ce qui rend le 404 exact plutôt que poli. var souhait = await db.LivresSouhaites .FirstOrDefaultAsync(s => s.Id == id && s.Utilisateur == utilisateur, ct); if (souhait is null) { return ResultatSouhait.Introuvable; } if (string.IsNullOrWhiteSpace(saisie.Titre)) { return ResultatSouhait.Invalide("Le titre est obligatoire."); } string? isbn = null; if (!string.IsNullOrWhiteSpace(saisie.Isbn)) { var normalise = IsbnUtils.Normaliser(saisie.Isbn); if (!IsbnUtils.EstValide(normalise)) { return ResultatSouhait.Invalide( $"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si vous ne l'avez pas."); } isbn = normalise; } souhait.Titre = saisie.Titre; souhait.Auteur = saisie.Auteur; souhait.Editeur = Vide(saisie.Editeur); souhait.Annee = Vide(saisie.Annee); souhait.Isbn = isbn; souhait.Note = Vide(saisie.Note); // ⚠️ La couverture n'est écrite QUE si la charge utile en porte une, contrairement aux // autres champs. Raison : aucun écran n'offre de champ « URL de couverture » pour une // envie — décision actée, un tel champ inviterait à coller des liens morts. Un // remplacement inconditionnel effacerait donc, à chaque correction de titre, l'image // que la bibliographie avait fournie. Il n'y a rien à « vider » puisqu'il n'y a rien à // saisir, et la lecture retombe de toute façon sur la formule OpenLibrary à ISBN connu. souhait.CoverUrl = Vide(saisie.CoverUrl) ?? souhait.CoverUrl; // ⚠️ Indispensable : TitreNormalise est la CLÉ D'ŒUVRE, celle qui sert au rapprochement // « déjà au catalogue ». Sans ce recalcul, corriger un titre laisserait le rapprochement // se faire sur l'ancienne forme — et le signalement mentirait sans que rien ne le dise. souhait.RecalculerFormes(); // ⚠️ Une édition peut heurter l'unicité (Utilisateur, TitreNormalise, AuteurNormalise), // contrairement à un ajout que l'utilisateur vient de composer : renommer une envie en // une autre qu'on a déjà est une fausse manœuvre plausible. On répond par un message // plutôt que de laisser remonter « UNIQUE constraint failed », qui ne veut rien dire. // // 400 et non 409, contrairement au doublon du CATALOGUE : là-bas posséder deux // exemplaires est légitime et l'appel se reconfirme, ici l'index l'interdit et il n'y a // rien à confirmer. C'est une saisie à corriger, pas un choix à trancher. var deja = await db.LivresSouhaites.AnyAsync( s => s.Id != souhait.Id && s.Utilisateur == utilisateur && s.TitreNormalise == souhait.TitreNormalise && s.AuteurNormalise == souhait.AuteurNormalise, ct); if (deja) { return ResultatSouhait.Invalide( $"« {souhait.Titre} » est déjà dans votre liste d'envies."); } await db.SaveChangesAsync(ct); var possede = await CorrespondancesAsync([souhait], ct); return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id))); } public async Task SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default) { if (utilisateur is null) { return false; } // Le filtre sur l'utilisateur fait partie de la CLÉ de recherche, pas d'une vérification // ultérieure : ainsi personne ne peut supprimer l'envie d'un autre en devinant son // identifiant. Une envie inexistante et une envie appartenant à un autre sont // indiscernables de l'extérieur, ce qui est bien le comportement voulu. var souhait = await db.LivresSouhaites .FirstOrDefaultAsync(s => s.Id == id && s.Utilisateur == utilisateur, ct); if (souhait is null) { return false; } db.LivresSouhaites.Remove(souhait); await db.SaveChangesAsync(ct); return true; } public async Task ReordonnerAsync( IReadOnlyList idsOrdonnes, string? utilisateur, CancellationToken ct = default) { if (utilisateur is null) { return false; } // ⚠️ On relit TOUTE la liste de l'appelant, et on ne se fie pas à ce que le client // envoie. Deux raisons : un identifiant appartenant à quelqu'un d'autre ne doit pas // pouvoir être renuméroté (le filtre fait partie de la clé, comme pour la suppression), // et la liste du client peut être périmée — une envie ajoutée depuis un autre appareil // n'y figure pas, et la perdre serait pire que de mal la classer. var siennes = await db.LivresSouhaites .Where(s => s.Utilisateur == utilisateur) .ToListAsync(ct); if (siennes.Count == 0) { return false; } var parId = siennes.ToDictionary(s => s.Id); var rang = 0; foreach (var id in idsOrdonnes.Distinct()) { if (parId.Remove(id, out var souhait)) { souhait.Rang = rang++; } // Un identifiant inconnu — supprimé entre-temps, ou appartenant à un autre — est // simplement ignoré : réordonner n'est pas une occasion de découvrir des erreurs. } // Ce que le client ne connaissait pas se range à la suite, dans son ordre précédent, // plutôt que d'être renuméroté au hasard. foreach (var oublie in parId.Values.OrderBy(s => s.Rang).ThenBy(s => s.Id)) { oublie.Rang = rang++; } await db.SaveChangesAsync(ct); return true; } /// /// Pour chaque envie, le livre du catalogue qui lui correspond — quand il y en a un. /// /// /// Ce qui se croise ici, ce sont deux portées différentes : la liste d'envies est /// personnelle, le catalogue est commun. Le signalement dit donc « ce livre est dans la /// maison », pas « vous l'avez acheté » — et c'est bien l'information utile avant /// d'acheter à nouveau. /// /// Deux critères, comme pour les doublons du catalogue : un ISBN identique tranche seul (il /// désigne une édition précise), sinon il faut la même clé d'œuvre et un auteur /// commun. Le titre seul confondrait deux recueils homonymes. /// /// /// ⚠️ Le catalogue est chargé en entier, en projection minimale. C'est assumé à l'échelle /// d'un foyer et cohérent avec l'instantané hors-ligne, qui l'emporte déjà tout entier dans /// le navigateur. Le jour où la bibliothèque compterait des milliers de fiches, c'est ici /// qu'il faudrait dégrossir en SQL — pas renoncer au signalement. /// /// private async Task> CorrespondancesAsync( IReadOnlyList souhaits, CancellationToken ct) { if (souhaits.Count == 0) { return []; } var livres = await db.Livres .AsNoTracking() .Select(l => new { l.Id, l.Titre, l.Isbn, Auteurs = l.Auteurs.OrderBy(la => la.Position).Select(la => la.Auteur!.Nom).ToList(), }) .ToListAsync(ct); var parCle = livres .Select(l => (Cle: CleOeuvre.Cle(l.Titre), Livre: l)) .Where(x => x.Cle.Length > 0) .GroupBy(x => x.Cle) .ToDictionary(g => g.Key, g => g.Select(x => x.Livre).ToList()); var parIsbn = new Dictionary(); foreach (var livre in livres.Where(l => !string.IsNullOrWhiteSpace(l.Isbn))) { parIsbn.TryAdd(livre.Isbn!, livre.Id); } var correspondances = new Dictionary(); foreach (var souhait in souhaits) { if (!string.IsNullOrWhiteSpace(souhait.Isbn) && parIsbn.TryGetValue(souhait.Isbn, out var idIsbn)) { correspondances[souhait.Id] = idIsbn; continue; } // TitreNormalise EST la clé d'œuvre pour une envie (voir LivreSouhaite) : rien à // recalculer de ce côté-là. if (!parCle.TryGetValue(souhait.TitreNormalise, out var homonymes)) { continue; } var trouve = homonymes.FirstOrDefault( l => RapprochementAuteurs.PartagentUnAuteur(l.Auteurs, [souhait.Auteur])); if (trouve is not null) { correspondances[souhait.Id] = trouve.Id; } } return correspondances; } /// /// Identifiant du livre correspondant, ou null. /// /// /// ⚠️ Surtout pas GetValueOrDefault : sur un dictionnaire de int, l'absence /// rend zéro, pas null — et toute envie se serait dite possédée. Le défaut a /// été pris par les tests, pas par le compilateur. /// private static int? Correspondant(IReadOnlyDictionary correspondances, int souhaitId) => correspondances.TryGetValue(souhaitId, out var livreId) ? livreId : null; private static string? Vide(string? valeur) => string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim(); /// /// Projection vers le DTO. Sans le champ Utilisateur : il ne sort jamais de /// l'API, pour qu'aucun client ne puisse croire qu'il désigne de qui il parle. /// internal static SouhaitDto Projeter(LivreSouhaite souhait, int? livreId = null) => new() { Id = souhait.Id, Titre = souhait.Titre, Auteur = souhait.Auteur, Editeur = souhait.Editeur, Annee = souhait.Annee, Isbn = souhait.Isbn, // ⚠️ La couverture se déduit de l'ISBN quand elle n'a pas été fournie. // // Constaté en usage : aucune vignette dans la liste d'envies. La cause n'était pas le // composant Couverture mais le chemin d'ajout — seul l'écran « ajouter une envie » // remplissait CoverUrl, alors que la plupart des envies arrivent par la bibliographie // d'un auteur ou par un tome manquant d'une série, qui transmettent bien un ISBN mais // aucune image. // // Le repli est posé ICI, à la lecture, et non à l'écriture : il vaut alors aussi pour // les envies DÉJÀ enregistrées, sans migration ni rattrapage. La règle « sans ISBN, // pas de couverture, et on n'en invente pas » est intacte — on n'invente rien, on // applique la formule OpenLibrary habituelle à un ISBN qu'on possède déjà. CoverUrl = souhait.CoverUrl ?? (souhait.Isbn is null ? null : IsbnUtils.UrlCouverture(souhait.Isbn)), Note = souhait.Note, DateAjout = souhait.DateAjout, Possede = livreId is not null, LivreId = livreId, }; }