using MaBibli.Api.Data; using MaBibli.Api.Services.Souhaits; using MaBibli.Shared.Dtos; using MaBibli.Shared.Entites; using MaBibli.Shared.Textes; using Microsoft.EntityFrameworkCore; namespace MaBibli.Api.Services.Series; /// Issue d'une écriture sur une série : la série, ou un message pour l'utilisateur. public readonly record struct ResultatSerie(SerieDto? Serie, string? Erreur) { public static ResultatSerie Ok(SerieDto serie) => new(serie, null); public static ResultatSerie Invalide(string message) => new(null, message); /// Ni série ni erreur : la ressource demandée n'existe pas. public static readonly ResultatSerie Introuvable = new(null, null); public bool EstIntrouvable => Serie is null && Erreur is null; } public interface IServiceSeries { Task> ListerAsync(CancellationToken ct = default); Task CreerAsync( EnregistrementSerie saisie, string? utilisateur, CancellationToken ct = default); Task ModifierAsync( int id, EnregistrementSerie saisie, CancellationToken ct = default); Task SupprimerAsync(int id, CancellationToken ct = default); Task AjouterElementAsync( int serieId, AjoutElementSerie saisie, CancellationToken ct = default); Task RattacherLivreAsync(int elementId, int? livreId, CancellationToken ct = default); Task RetirerElementAsync(int elementId, CancellationToken ct = default); Task ReordonnerAsync( int serieId, IReadOnlyList idsOrdonnes, CancellationToken ct = default); Task ReordonnerSousSeriesAsync( int serieId, IReadOnlyList idsOrdonnes, CancellationToken ct = default); /// Bascule un tome manquant vers la liste d'envies de l'appelant. Task MettreEnEnviesAsync( int elementId, string? utilisateur, CancellationToken ct = default); } /// /// Sagas, cycles et séries : ce qui se lit dans un ordre, et ce qui manque pour le lire. /// /// /// ⚠️ Portée COMMUNE au foyer, comme le catalogue et les prêts. Aucune méthode ne prend /// d'utilisateur pour lire ou écrire une série — l'ordre de lecture d'une saga ne change pas /// selon qui regarde. La seule exception est , qui écrit dans /// une liste d'envies, et qui est donc obligée de savoir de qui elle parle : c'est là que /// les deux portées se rencontrent, et nulle part ailleurs. /// public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits) : IServiceSeries { public async Task> ListerAsync(CancellationToken ct = default) { // AUCUN filtre sur AjoutePar : les séries sont communes, comme le catalogue. var series = await db.Series .AsNoTracking() .Include(s => s.Elements).ThenInclude(e => e.Livre).ThenInclude(l => l!.Auteurs) .ThenInclude(la => la.Auteur) .ToListAsync(ct); var livreIds = series .SelectMany(s => s.Elements) .Select(e => e.LivreId) .OfType() .ToList(); var prets = await PretsOuvertsAsync(livreIds, ct); return series .OrderBy(s => s.Position) .ThenBy(s => s.TitreNormalise, StringComparer.Ordinal) .Select(s => Projeter(s, prets)) .ToList(); } public async Task CreerAsync( EnregistrementSerie saisie, string? utilisateur, CancellationToken ct = default) { var titre = saisie.Titre?.Trim(); if (string.IsNullOrWhiteSpace(titre)) { return ResultatSerie.Invalide("Le titre de la série est obligatoire."); } var normalise = NormalisationTexte.Normaliser(titre); if (await db.Series.AnyAsync(s => s.TitreNormalise == normalise, ct)) { // Message plutôt qu'index unique brut : « UNIQUE constraint failed » ne veut rien // dire pour l'utilisateur. L'index reste là comme garantie de dernier recours. return ResultatSerie.Invalide($"La série « {titre} » existe déjà."); } if (saisie.SerieParenteId is { } parenteId && !await db.Series.AnyAsync(s => s.Id == parenteId, ct)) { return ResultatSerie.Invalide("La série parente n'existe pas."); } var serie = new Serie { Titre = titre, SerieParenteId = saisie.SerieParenteId, DateAjout = DateTime.UtcNow, // Trace de saisie, jamais un cloisonnement — comme Livre.AjoutePar. AjoutePar = utilisateur, }; serie.RecalculerFormes(); serie.Position = await ProchainePositionAsync(saisie.SerieParenteId, ct); db.Series.Add(serie); await db.SaveChangesAsync(ct); return await RelireAsync(serie.Id, ct); } public async Task ModifierAsync( int id, EnregistrementSerie saisie, CancellationToken ct = default) { var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id, ct); if (serie is null) { return ResultatSerie.Introuvable; } var titre = saisie.Titre?.Trim(); if (string.IsNullOrWhiteSpace(titre)) { return ResultatSerie.Invalide("Le titre de la série est obligatoire."); } var normalise = NormalisationTexte.Normaliser(titre); if (await db.Series.AnyAsync(s => s.Id != id && s.TitreNormalise == normalise, ct)) { return ResultatSerie.Invalide($"La série « {titre} » existe déjà."); } if (saisie.SerieParenteId is { } parenteId) { if (!await db.Series.AnyAsync(s => s.Id == parenteId, ct)) { return ResultatSerie.Invalide("La série parente n'existe pas."); } if (await EstDescendanteAsync(parenteId, id, ct)) { // Sans ce garde-fou, une série deviendrait sa propre ancêtre : l'arbre se // refermerait sur lui-même et l'affichage boucherait à l'infini. return ResultatSerie.Invalide( "Une série ne peut pas être rangée dans elle-même, ni dans l'une des siennes."); } } if (serie.SerieParenteId != saisie.SerieParenteId) { serie.SerieParenteId = saisie.SerieParenteId; serie.Position = await ProchainePositionAsync(saisie.SerieParenteId, ct); } serie.Titre = titre; serie.RecalculerFormes(); await db.SaveChangesAsync(ct); return await RelireAsync(id, ct); } public async Task SupprimerAsync(int id, CancellationToken ct = default) { var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id, ct); if (serie is null) { return false; } // Les éléments partent en cascade — ce ne sont que des positions. Les sous-séries, elles, // remontent à la racine (SetNull) : ce sont de vraies séries de vrais livres. db.Series.Remove(serie); await db.SaveChangesAsync(ct); return true; } public async Task AjouterElementAsync( int serieId, AjoutElementSerie saisie, CancellationToken ct = default) { var serie = await db.Series.Include(s => s.Elements).FirstOrDefaultAsync(s => s.Id == serieId, ct); if (serie is null) { return ResultatSerie.Introuvable; } var titre = saisie.Titre?.Trim(); if (saisie.LivreId is { } livreId) { var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == livreId, ct); if (livre is null) { return ResultatSerie.Invalide("Ce livre n'existe plus."); } if (serie.Elements.Any(e => e.LivreId == livreId)) { return ResultatSerie.Invalide($"« {livre.Titre} » est déjà dans cette série."); } // Le titre du tome est copié du livre : il doit survivre à sa suppression, faute de // quoi la saga se retrouverait avec une place muette. titre = string.IsNullOrWhiteSpace(titre) ? livre.Titre : titre; } if (string.IsNullOrWhiteSpace(titre)) { return ResultatSerie.Invalide("Le titre du tome est obligatoire."); } var element = new ElementSerie { SerieId = serieId, LivreId = saisie.LivreId, Titre = titre, Position = serie.Elements.Count == 0 ? 0 : serie.Elements.Max(e => e.Position) + 1, }; element.RecalculerFormes(); db.ElementsSerie.Add(element); await db.SaveChangesAsync(ct); return await RelireAsync(serieId, ct); } public async Task RattacherLivreAsync( int elementId, int? livreId, CancellationToken ct = default) { var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct); if (element is null) { return ResultatSerie.Introuvable; } if (livreId is { } id) { var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id, ct); if (livre is null) { return ResultatSerie.Invalide("Ce livre n'existe plus."); } var deja = await db.ElementsSerie.AnyAsync( e => e.SerieId == element.SerieId && e.LivreId == id && e.Id != elementId, ct); if (deja) { return ResultatSerie.Invalide($"« {livre.Titre} » occupe déjà une place dans cette série."); } } // Le titre saisi est conservé : c'est celui du tome, pas celui de l'exemplaire. Détacher // un livre laisse donc un trou nommé, et non une ligne vide. element.LivreId = livreId; await db.SaveChangesAsync(ct); return await RelireAsync(element.SerieId, ct); } public async Task RetirerElementAsync(int elementId, CancellationToken ct = default) { var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct); if (element is null) { return false; } db.ElementsSerie.Remove(element); await db.SaveChangesAsync(ct); return true; } public async Task ReordonnerAsync( int serieId, IReadOnlyList idsOrdonnes, CancellationToken ct = default) { var elements = await db.ElementsSerie.Where(e => e.SerieId == serieId).ToListAsync(ct); if (elements.Count == 0) { return await db.Series.AnyAsync(s => s.Id == serieId, ct) ? await RelireAsync(serieId, ct) : ResultatSerie.Introuvable; } // Même prudence que pour la liste d'envies : on relit les éléments DE CETTE SÉRIE et on // ne se fie pas à ce que le client envoie. Un identifiant étranger est ignoré, et un // tome absent de l'ordre reçu est conservé à la suite — la liste du client peut être // périmée, et mal classé vaut infiniment mieux que disparu. var parId = elements.ToDictionary(e => e.Id); var position = 0; foreach (var id in idsOrdonnes.Distinct()) { if (parId.Remove(id, out var element)) { element.Position = position++; } } foreach (var oublie in parId.Values.OrderBy(e => e.Position).ThenBy(e => e.Id)) { oublie.Position = position++; } await db.SaveChangesAsync(ct); return await RelireAsync(serieId, ct); } public async Task ReordonnerSousSeriesAsync( int serieId, IReadOnlyList idsOrdonnes, CancellationToken ct = default) { if (!await db.Series.AnyAsync(s => s.Id == serieId, ct)) { return ResultatSerie.Introuvable; } var enfants = await db.Series .Where(s => s.SerieParenteId == serieId) .ToListAsync(ct); var parId = enfants.ToDictionary(s => s.Id); var position = 0; foreach (var id in idsOrdonnes.Distinct()) { if (parId.Remove(id, out var enfant)) { enfant.Position = position++; } } foreach (var oublie in parId.Values.OrderBy(s => s.Position).ThenBy(s => s.Id)) { oublie.Position = position++; } await db.SaveChangesAsync(ct); return await RelireAsync(serieId, ct); } public async Task MettreEnEnviesAsync( int elementId, string? utilisateur, CancellationToken ct = default) { var element = await db.ElementsSerie .AsNoTracking() .FirstOrDefaultAsync(e => e.Id == elementId, ct); if (element is null) { return ResultatSouhait.Invalide("Ce tome n'existe plus."); } if (element.LivreId is not null) { // Souhaiter ce qu'on a déjà n'aurait aucun sens, et la liste d'envies le signalerait // aussitôt comme « déjà au catalogue ». return ResultatSouhait.Invalide($"« {element.Titre} » est déjà dans votre bibliothèque."); } // ⚠️ Ici et seulement ici, une série (commune) écrit dans une liste d'envies // (personnelle). L'envie est donc celle de l'APPELANT, jamais celle du foyer : deux // membres peuvent vouloir le même tome manquant, chacun dans sa liste. return await souhaits.AjouterAsync( new EnregistrementSouhait { Titre = element.Titre }, utilisateur, ct); } /// Prêts en cours des livres concernés. Communs au foyer, comme partout ailleurs. 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, p => p.Emprunteur, ct); } private async Task ProchainePositionAsync(int? parenteId, CancellationToken ct) { var derniere = await db.Series .Where(s => s.SerieParenteId == parenteId) .Select(s => (int?)s.Position) .MaxAsync(ct); return (derniere ?? -1) + 1; } /// est-elle ou l'une de ses filles ? private async Task EstDescendanteAsync(int candidate, int serieId, CancellationToken ct) { var courante = (int?)candidate; // Remontée bornée par le nombre de séries : même si une boucle existait déjà en base, // cette fonction s'arrête au lieu de tourner indéfiniment. var garde = await db.Series.CountAsync(ct) + 1; while (courante is { } id && garde-- > 0) { if (id == serieId) { return true; } courante = await db.Series .Where(s => s.Id == id) .Select(s => s.SerieParenteId) .FirstOrDefaultAsync(ct); } return false; } private async Task RelireAsync(int serieId, CancellationToken ct) { var serie = await db.Series .AsNoTracking() .Include(s => s.Elements).ThenInclude(e => e.Livre).ThenInclude(l => l!.Auteurs) .ThenInclude(la => la.Auteur) .FirstOrDefaultAsync(s => s.Id == serieId, ct); if (serie is null) { return ResultatSerie.Introuvable; } var prets = await PretsOuvertsAsync( serie.Elements.Select(e => e.LivreId).OfType().ToList(), ct); return ResultatSerie.Ok(Projeter(serie, prets)); } private static SerieDto Projeter(Serie serie, IReadOnlyDictionary prets) => new() { Id = serie.Id, Titre = serie.Titre, SerieParenteId = serie.SerieParenteId, Position = serie.Position, AjoutePar = serie.AjoutePar, Elements = serie.Elements .OrderBy(e => e.Position) .ThenBy(e => e.Id) .Select(e => new ElementSerieDto { Id = e.Id, Position = e.Position, // Le titre du livre prime quand il est là : c'est lui qui a pu être corrigé // depuis la saisie du tome. Le titre stocké reste le filet en cas de suppression. Titre = e.Livre?.Titre ?? e.Titre, LivreId = e.LivreId, Auteurs = e.Livre is null || e.Livre.Auteurs.Count == 0 ? null : string.Join(", ", e.Livre.Auteurs .OrderBy(la => la.Position) .Select(la => la.Auteur?.Nom) .Where(nom => !string.IsNullOrWhiteSpace(nom))), PreteA = e.LivreId is { } id && prets.TryGetValue(id, out var qui) ? qui : null, }) .ToList(), }; }