Files
mabibli/MaBibli.Api/Services/Series/ServiceSeries.cs
T
mathieuandClaude Opus 5 4e372ab694 Ajouter les sagas et cycles, avec leurs tomes manquants
Première relation entre livres du projet. Une série contient des séries
(un cycle EST une série de séries) et des PLACES dans l'ordre de lecture —
pas des livres : une place sans livre est le trou qu'on vient voir.

L'ordre est stocké, jamais déduit d'une année : une préquelle se lit avant
le livre paru dix ans plus tôt. Les tomes non possédés se saisissent à la
main, aucune source ne donnant l'ordre de lecture d'une saga.

Le titre de la place survit à la suppression du livre (SetNull, pas de
cascade), sans quoi perdre un exemplaire effacerait le tome 3 de la série.

Les séries sont communes au foyer. Seule exception : la mise en envies d'un
tome manquant, qui écrit dans une liste personnelle — c'est le seul point
où les deux portées se rencontrent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:03:08 +02:00

451 lines
16 KiB
C#

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;
/// <summary>Issue d'une écriture sur une série : la série, ou un message pour l'utilisateur.</summary>
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);
/// <summary>Ni série ni erreur : la ressource demandée n'existe pas.</summary>
public static readonly ResultatSerie Introuvable = new(null, null);
public bool EstIntrouvable => Serie is null && Erreur is null;
}
public interface IServiceSeries
{
Task<IReadOnlyList<SerieDto>> ListerAsync(CancellationToken ct = default);
Task<ResultatSerie> CreerAsync(
EnregistrementSerie saisie, string? utilisateur, CancellationToken ct = default);
Task<ResultatSerie> ModifierAsync(
int id, EnregistrementSerie saisie, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
Task<ResultatSerie> AjouterElementAsync(
int serieId, AjoutElementSerie saisie, CancellationToken ct = default);
Task<ResultatSerie> RattacherLivreAsync(int elementId, int? livreId, CancellationToken ct = default);
Task<bool> RetirerElementAsync(int elementId, CancellationToken ct = default);
Task<ResultatSerie> ReordonnerAsync(
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default);
/// <summary>Bascule un tome manquant vers la liste d'envies <b>de l'appelant</b>.</summary>
Task<ResultatSouhait> MettreEnEnviesAsync(
int elementId, string? utilisateur, CancellationToken ct = default);
}
/// <summary>
/// Sagas, cycles et séries : ce qui se lit dans un ordre, et ce qui manque pour le lire.
/// </summary>
/// <remarks>
/// ⚠️ <b>Portée COMMUNE au foyer</b>, 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 <see cref="MettreEnEnviesAsync"/>, qui écrit dans
/// une liste d'envies, et qui est donc <b>obligée</b> de savoir de qui elle parle : c'est là que
/// les deux portées se rencontrent, et nulle part ailleurs.
/// </remarks>
public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits) : IServiceSeries
{
public async Task<IReadOnlyList<SerieDto>> 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<int>()
.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<ResultatSerie> 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<ResultatSerie> 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<bool> 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<ResultatSerie> 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<ResultatSerie> 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<bool> 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<ResultatSerie> ReordonnerAsync(
int serieId, IReadOnlyList<int> 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<ResultatSouhait> 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.
// Aucun auteur n'est transmis : une position dans une saga n'en porte pas, et en
// inventer un fausserait le rapprochement avec le catalogue.
return await souhaits.AjouterAsync(
new EnregistrementSouhait { Titre = element.Titre }, utilisateur, ct);
}
/// <summary>Prêts en cours des livres concernés. Communs au foyer, comme partout ailleurs.</summary>
private async Task<Dictionary<int, string>> PretsOuvertsAsync(
IReadOnlyList<int> 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<int> 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;
}
/// <summary><paramref name="candidate"/> est-elle <paramref name="serieId"/> ou l'une de ses filles ?</summary>
private async Task<bool> 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<ResultatSerie> 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<int>().ToList(), ct);
return ResultatSerie.Ok(Projeter(serie, prets));
}
private static SerieDto Projeter(Serie serie, IReadOnlyDictionary<int, string> 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(),
};
}