Files
mabibli/MaBibli.Api/Services/Series/ServiceSeries.cs
T
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

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

483 lines
17 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);
Task<ResultatSerie> ReordonnerSousSeriesAsync(
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<ResultatSerie> ReordonnerSousSeriesAsync(
int serieId, IReadOnlyList<int> 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<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.
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(),
};
}