Deux changements de modele en une seule migration, plus la recherche qui en depend. Le statut de lecture devient personnel. Il etait une colonne de Livre, donc partage par tout le foyer, alors que deux membres lisent le meme exemplaire a des rythmes differents. Il vit desormais dans une table (LivreId, Utilisateur, Statut) avec unicite sur le couple. L'absence de ligne vaut « non commence » : sur une bibliotheque de foyer la plupart des couples n'ont aucun statut, et les materialiser tous multiplierait les lignes par le nombre de comptes pour n'exprimer qu'un vide. Rien n'est donc ecrit a la creation d'un livre. Piege rencontre : un Dictionary<int, Statut> renvoyait la valeur 0 de l'enumeration — « À lire » — pour un livre sans ligne, rendant « non commence » indiscernable d'un choix explicite. Le dictionnaire est desormais typé Statut?. L'auteur devient une table. Deux formes normalisees y cohabitent, et ce n'est pas une redondance : NomNormalise garde l'ordre de saisie pour la recherche en sous-chaine, CleRegroupement trie les mots et porte l'index unique, donc l'invariant « un auteur, une fiche ». Les initiales echappent a la cle et sont traitees en memoire, sur une table qui compte au plus quelques centaines de lignes. Un livre peut avoir plusieurs auteurs — le lookup ISBN en renvoie quatre pour Introduction to Algorithms — d'ou la table de liaison, avec une position qui conserve l'ordre de la couverture. Les rapprochements ambigus ne sont jamais appliques seuls : l'API les liste, l'utilisateur accepte ou refuse, et les refus sont memorises pour que la suggestion ne revienne pas. Le couple refuse est range par identifiant croissant, donc un refus vaut dans les deux sens. Reprise des donnees existantes. L'ancien statut, commun, est rattache a AjoutePar — seule personne que la base associe au livre. Les statuts des livres sans AjoutePar sont perdus : les attribuer serait une invention. Les trois valeurs sont reprises telles quelles, « À lire » compris, parce que c'est ce que l'ancienne interface affichait. L'ancien champ auteur devient une fiche par valeur distincte. La migration ne peut pas tout faire : lower() de SQLite ne retire pas les accents, donc « Émile Zola » et « emile zola » y restent deux fiches. ServiceRenormalisation finit le travail en C# au demarrage, reunit ces variantes, applique aussi la regle des initiales — sans quoi une base heritee resterait eclatee la ou une saisie neuve aurait ete reunie d'emblee — et garde le nom d'affichage le plus presentable. Il est idempotent, et sert de filet si les regles de normalisation changent. L'ordre de la migration compte : les colonnes condamnees sont recopiees dans une table de transit avant d'etre supprimees, parce que supprimer une colonne sous SQLite reconstruit la table. Verifie sur une base a l'ancien schema contenant 9 livres, 2 prets et trois variantes de Zola : prets intacts, statuts rattaches, les trois Zola reunis sous « Émile Zola », « P.F. Hamilton » absorbe par « Peter F. Hamilton », « Hamilton » seul laisse en suggestion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
324 lines
12 KiB
C#
324 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>Issue d'une écriture : soit le livre, soit un message d'erreur destiné à l'utilisateur.</summary>
|
|
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);
|
|
|
|
/// <summary>Ni livre ni erreur : la ressource demandée n'existe pas.</summary>
|
|
public static readonly ResultatEcriture Introuvable = new(null, null);
|
|
|
|
public bool EstIntrouvable => Livre is null && Erreur is null;
|
|
}
|
|
|
|
public interface IServiceCatalogue
|
|
{
|
|
Task<IReadOnlyList<LivreDto>> ListerAsync(
|
|
CritereLivres criteres, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<LivreDto?> ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<ResultatEcriture> CreerAsync(
|
|
EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<ResultatEcriture> ModifierAsync(
|
|
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<LivreDto?> ChangerStatutAsync(
|
|
int id, Statut? statut, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CRUD du catalogue de livres — physiques et numériques confondus, distingués par
|
|
/// <see cref="Format"/>. Il n'y a qu'un seul catalogue.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Le paramètre <c>utilisateur</c> ne restreint jamais la liste des livres.</b> Il sert
|
|
/// uniquement à savoir de quel statut de lecture on parle : la bibliothèque reste commune, seule
|
|
/// la lecture est personnelle.
|
|
/// </remarks>
|
|
public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteurs) : IServiceCatalogue
|
|
{
|
|
public async Task<IReadOnlyList<LivreDto>> 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 statuts = await StatutsAsync(livres.Select(l => l.Id).ToList(), utilisateur, ct);
|
|
|
|
return livres.Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id))).ToList();
|
|
}
|
|
|
|
public async Task<LivreDto?> 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);
|
|
return Projeter(livre, statuts.GetValueOrDefault(id));
|
|
}
|
|
|
|
public async Task<ResultatEcriture> 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<ResultatEcriture> 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<LivreDto?> 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<bool> 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;
|
|
}
|
|
|
|
/// <summary>Statut de <paramref name="utilisateur"/> pour chacun des livres demandés.</summary>
|
|
private async Task<Dictionary<int, Statut?>> StatutsAsync(
|
|
IReadOnlyList<int> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pose, met à jour ou retire le statut de lecture d'une personne pour un livre.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre.</summary>
|
|
private async Task RattacherAuteursAsync(
|
|
Livre livre, IEnumerable<string> 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 });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Contrôles minimaux : un titre, et un ISBN qui tienne debout s'il est fourni.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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) => 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,
|
|
};
|
|
}
|