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>
483 lines
17 KiB
C#
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(),
|
|
};
|
|
}
|