using MaBibli.Api.Data;
using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Catalogue;
/// Issue d'une écriture : soit le livre, soit un message d'erreur destiné à l'utilisateur.
public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur)
{
public static ResultatEcriture Ok(LivreDto livre) => new(livre, null);
public static ResultatEcriture Invalide(string message) => new(null, message);
/// Ni livre ni erreur : la ressource demandée n'existe pas.
public static readonly ResultatEcriture Introuvable = new(null, null);
public bool EstIntrouvable => Livre is null && Erreur is null;
}
public interface IServiceCatalogue
{
Task> ListerAsync(
CritereLivres criteres, string? utilisateur, CancellationToken ct = default);
Task ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default);
Task CreerAsync(
EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
Task ModifierAsync(
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
Task ChangerStatutAsync(
int id, Statut? statut, string? utilisateur, CancellationToken ct = default);
Task SupprimerAsync(int id, CancellationToken ct = default);
}
///
/// CRUD du catalogue de livres — physiques et numériques confondus, distingués par
/// . Il n'y a qu'un seul catalogue.
///
///
/// Le paramètre utilisateur ne restreint jamais la liste des livres. Il sert
/// uniquement à savoir de quel statut de lecture on parle : la bibliothèque reste commune, seule
/// la lecture est personnelle.
///
public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteurs) : IServiceCatalogue
{
public async Task> ListerAsync(
CritereLivres criteres, string? utilisateur, CancellationToken ct = default)
{
// AsNoTracking : lecture pure. Et surtout : AUCUN filtre sur AjoutePar,
// la bibliothèque est commune à tout le foyer (CLAUDE.md).
var requete = FiltreLivres.Appliquer(
db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur),
criteres,
utilisateur);
var livres = await requete.ToListAsync(ct);
var ids = livres.Select(l => l.Id).ToList();
var statuts = await StatutsAsync(ids, utilisateur, ct);
var prets = await PretsOuvertsAsync(ids, ct);
return livres
.Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id), prets.GetValueOrDefault(l.Id)))
.ToList();
}
public async Task ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default)
{
var livre = await db.Livres
.AsNoTracking()
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
.FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
{
return null;
}
var statuts = await StatutsAsync([id], utilisateur, ct);
var prets = await PretsOuvertsAsync([id], ct);
return Projeter(livre, statuts.GetValueOrDefault(id), prets.GetValueOrDefault(id));
}
public async Task CreerAsync(
EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
{
if (Valider(saisie, out var isbn, out var erreur) is false)
{
return ResultatEcriture.Invalide(erreur!);
}
var livre = new Livre
{
Isbn = isbn,
Titre = saisie.Titre,
Editeur = Vide(saisie.Editeur),
Format = saisie.Format,
CoverUrl = Vide(saisie.CoverUrl),
DateAjout = DateTime.UtcNow,
// Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client.
AjoutePar = utilisateur,
};
livre.RecalculerFormes();
db.Livres.Add(livre);
await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
await db.SaveChangesAsync(ct);
// Le statut n'est écrit que si l'utilisateur en a explicitement posé un : un livre sans
// ligne est « non commencé », et c'est le cas de départ le plus fréquent.
await AppliquerStatutAsync(livre.Id, saisie.Statut, utilisateur, ct);
return ResultatEcriture.Ok((await ObtenirAsync(livre.Id, utilisateur, ct))!);
}
public async Task ModifierAsync(
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
{
var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
{
return ResultatEcriture.Introuvable;
}
if (Valider(saisie, out var isbn, out var erreur) is false)
{
return ResultatEcriture.Invalide(erreur!);
}
livre.Isbn = isbn;
livre.Titre = saisie.Titre;
livre.Editeur = Vide(saisie.Editeur);
livre.Format = saisie.Format;
livre.CoverUrl = Vide(saisie.CoverUrl);
livre.RecalculerFormes();
// DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie
// d'origine, pas des champs éditables.
await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
await db.SaveChangesAsync(ct);
// Le statut modifié est celui de la personne qui édite, pas de celle qui a saisi le livre.
await AppliquerStatutAsync(livre.Id, saisie.Statut, utilisateur, ct);
await auteurs.SupprimerOrphelinsAsync(ct);
return ResultatEcriture.Ok((await ObtenirAsync(livre.Id, utilisateur, ct))!);
}
public async Task ChangerStatutAsync(
int id, Statut? statut, string? utilisateur, CancellationToken ct = default)
{
if (!await db.Livres.AnyAsync(l => l.Id == id, ct))
{
return null;
}
await AppliquerStatutAsync(id, statut, utilisateur, ct);
return await ObtenirAsync(id, utilisateur, ct);
}
public async Task SupprimerAsync(int id, CancellationToken ct = default)
{
var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
{
return false;
}
// Les liens vers les auteurs partent en cascade, mais les fiches auteur restent : elles
// sont peut-être partagées avec d'autres livres. Le nettoyage d'après tranche.
db.Livres.Remove(livre);
await db.SaveChangesAsync(ct);
await auteurs.SupprimerOrphelinsAsync(ct);
return true;
}
/// Statut de pour chacun des livres demandés.
private async Task> StatutsAsync(
IReadOnlyList livreIds, string? utilisateur, CancellationToken ct)
{
// Sans identité, personne n'a de statut : on ne montre surtout pas celui d'un autre.
if (utilisateur is null || livreIds.Count == 0)
{
return [];
}
// Le dictionnaire est volontairement typé « Statut? » : sans ça, l'absence de ligne
// ressortirait comme la valeur 0 de l'énumération — c'est-à-dire « À lire » — et
// « non commencé » deviendrait indiscernable d'un choix explicite.
return await db.StatutsLecture
.AsNoTracking()
.Where(s => s.Utilisateur == utilisateur && livreIds.Contains(s.LivreId))
.ToDictionaryAsync(s => s.LivreId, s => (Statut?)s.Statut, ct);
}
///
/// Prêt en cours de chacun des livres demandés, quand il y en a un.
///
///
/// Aucun paramètre « utilisateur » ici, volontairement : contrairement au statut de
/// lecture, un livre absent l'est pour tout le foyer. La question « où est ce livre ? » a une
/// seule réponse, la même pour tout le monde.
///
/// L'index unique partiel sur Prets garantit qu'il n'y a qu'un prêt ouvert par livre :
/// ce dictionnaire ne peut donc pas avoir à départager deux candidats.
///
///
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, ct);
}
///
/// Pose, met à jour ou retire le statut de lecture d'une personne pour un livre.
///
private async Task AppliquerStatutAsync(
int livreId, Statut? statut, string? utilisateur, CancellationToken ct)
{
if (utilisateur is null)
{
// Aucune identité : on n'invente pas de propriétaire pour ce statut. Le livre reste
// enregistré, simplement sans lecture rattachée à personne.
return;
}
var ligne = await db.StatutsLecture
.FirstOrDefaultAsync(s => s.LivreId == livreId && s.Utilisateur == utilisateur, ct);
if (statut is null)
{
if (ligne is not null)
{
// Retour à « non commencé » : on supprime la ligne au lieu d'inventer une
// quatrième valeur d'énumération qui aurait fallu migrer.
db.StatutsLecture.Remove(ligne);
await db.SaveChangesAsync(ct);
}
return;
}
if (ligne is null)
{
db.StatutsLecture.Add(new StatutLecture
{
LivreId = livreId,
Utilisateur = utilisateur,
Statut = statut.Value,
DateMaj = DateTime.UtcNow,
});
}
else
{
ligne.Statut = statut.Value;
ligne.DateMaj = DateTime.UtcNow;
}
await db.SaveChangesAsync(ct);
}
/// Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre.
private async Task RattacherAuteursAsync(
Livre livre, IEnumerable noms, CancellationToken ct)
{
var resolus = await auteurs.ResoudreAsync(noms, ct);
// Les liens existants sont retirés puis reconstruits : c'est la façon la plus simple de
// gérer à la fois l'ajout, le retrait et le réordonnancement des auteurs.
if (livre.Auteurs.Count > 0)
{
db.LivreAuteurs.RemoveRange(livre.Auteurs);
livre.Auteurs.Clear();
}
for (var i = 0; i < resolus.Count; i++)
{
livre.Auteurs.Add(new LivreAuteur { Auteur = resolus[i], Position = i });
}
}
///
/// Contrôles minimaux : un titre, et un ISBN qui tienne debout s'il est fourni.
///
///
/// L'ISBN est facultatif (livres anciens, tirages sans ISBN) mais, s'il est saisi, il doit
/// être valide : un ISBN faux est pire que pas d'ISBN, il empêcherait tout rapprochement
/// futur avec les catalogues.
///
private static bool Valider(EnregistrementLivre saisie, out string? isbn, out string? erreur)
{
isbn = null;
erreur = null;
if (string.IsNullOrWhiteSpace(saisie.Titre))
{
erreur = "Le titre est obligatoire.";
return false;
}
if (!string.IsNullOrWhiteSpace(saisie.Isbn))
{
var normalise = IsbnUtils.Normaliser(saisie.Isbn);
if (!IsbnUtils.EstValide(normalise))
{
erreur = $"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si le livre n'en a pas.";
return false;
}
isbn = normalise;
}
return true;
}
private static string? Vide(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
private static LivreDto Projeter(Livre livre, Statut? statut, Pret? pretOuvert = null) => new()
{
Id = livre.Id,
Isbn = livre.Isbn,
Titre = livre.Titre,
Auteurs = livre.Auteurs
.OrderBy(la => la.Position)
.Where(la => la.Auteur is not null)
.Select(la => new AuteurDto { Id = la.Auteur!.Id, Nom = la.Auteur.Nom })
.ToList(),
Editeur = livre.Editeur,
Format = livre.Format,
Statut = statut,
CoverUrl = livre.CoverUrl,
DateAjout = livre.DateAjout,
AjoutePar = livre.AjoutePar,
PreteA = pretOuvert?.Emprunteur,
PreteDepuis = pretOuvert?.DatePret,
};
}