Phase 3 : CRUD des livres, statuts de lecture et identite SSOwat

API
- CRUD complet sur /api/livres : liste filtrable (format, statut) et
  cherchable (titre, auteur), consultation, creation, modification,
  changement de statut isole, suppression.
- Aucune lecture ne filtre sur AjoutePar : la bibliotheque est commune au
  foyer. Le critere n'existe meme pas dans CritereLivres, pour qu'on ne
  puisse pas s'en servir par inadvertance. Deux tests verrouillent
  l'invariant, dont un par reflexion sur le type des criteres.
- AjoutePar est renseigne par le serveur a la creation, jamais par le
  client, et n'est pas reecrit a l'edition (pas plus que DateAjout).
- Identite lue dans les en-tetes SSOwat (YNH_USER, YNH_USER_EMAIL,
  YNH_USER_FULLNAME), avec repli sur un utilisateur simule configure en
  developpement uniquement. Sans en-tete ni simulation, l'identite reste
  inconnue plutot qu'inventee. Expose par GET /api/moi.
- Migration appliquee au demarrage : pas d'etape manuelle a l'installation.

Interface Blazor, pensee mobile d'abord
- Catalogue unique (physiques et numeriques melanges, distingues par le
  format) avec recherche, filtres et changement de statut directement dans
  la liste.
- Ajout par ISBN : quand la cascade rend plusieurs notices, un ecran de
  choix les presente en mettant en avant editeur et annee, seuls elements
  qui les departagent. Le formulaire reste ensuite entierement modifiable.
- Ajout manuel, edition, suppression avec confirmation explicite.
- Couvertures : l'URL OpenLibrary n'est jamais validee par un appel
  prealable (502 intermittents mesures en phase 2) ; un attribut onerror
  masque l'image cassee et laisse apparaitre un substitut.

Decisions prises la ou CLAUDE.md etait muet
- Tri alphabetique par titre.
- Un ISBN saisi doit etre valide, mais reste facultatif.
- Ecran de choix affiche seulement a partir de deux candidats.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-17 22:55:07 +02:00
co-authored by Claude Opus 5
parent aed03e6b6f
commit fd6438b912
29 changed files with 2430 additions and 203 deletions
+23
View File
@@ -0,0 +1,23 @@
using MaBibli.Shared.Entites;
namespace MaBibli.Shared.Catalogue;
/// <summary>
/// Critères de consultation du catalogue.
/// </summary>
/// <remarks>
/// <b>Il n'existe volontairement aucun critère sur <c>AjoutePar</c>.</b> La bibliothèque est
/// commune à tout le foyer : filtrer les lectures sur ce champ serait un bug de conception.
/// L'absence de ce critère est l'endroit où l'invariant est le plus visible.
/// </remarks>
public record CritereLivres
{
/// <summary>Texte cherché dans le titre <i>ou</i> l'auteur. Insensible à la casse.</summary>
public string? Recherche { get; init; }
/// <summary>Physique ou numérique. <c>null</c> = les deux : le catalogue est unique.</summary>
public Format? Format { get; init; }
/// <summary>Statut de lecture. <c>null</c> = tous.</summary>
public Statut? Statut { get; init; }
}
+55
View File
@@ -0,0 +1,55 @@
using MaBibli.Shared.Entites;
namespace MaBibli.Shared.Catalogue;
/// <summary>
/// Filtrage, recherche et tri du catalogue.
/// </summary>
/// <remarks>
/// Écrit sur <see cref="IQueryable{T}"/> pour être exécuté par EF Core côté base <b>et</b>
/// testable en mémoire sur une simple liste, sans base ni réseau.
/// <para>
/// La comparaison passe par <c>ToLower()</c> : EF Core la traduit en SQL, et LINQ-to-Objects
/// lui donne le même comportement insensible à la casse. Les accents ne sont pas normalisés
/// (« Emile » ne trouve pas « Émile ») — limitation assumée à ce stade.
/// </para>
/// </remarks>
public static class FiltreLivres
{
public static IQueryable<Livre> Appliquer(IQueryable<Livre> source, CritereLivres criteres)
{
// AUCUN filtre sur AjoutePar : la bibliothèque est commune (CLAUDE.md).
// Ce commentaire est là pour qu'une régression future saute aux yeux en relecture.
if (criteres.Format is { } format)
{
source = source.Where(l => l.Format == format);
}
if (criteres.Statut is { } statut)
{
source = source.Where(l => l.Statut == statut);
}
var terme = criteres.Recherche?.Trim();
if (!string.IsNullOrEmpty(terme))
{
var recherche = terme.ToLower();
source = source.Where(l =>
l.Titre.ToLower().Contains(recherche)
|| (l.Auteur != null && l.Auteur.ToLower().Contains(recherche)));
}
return Trier(source);
}
/// <summary>
/// Tri par titre, puis par identifiant pour rendre l'ordre déterministe.
/// </summary>
/// <remarks>
/// CLAUDE.md ne tranche pas le tri. L'ordre alphabétique a été retenu parce qu'une
/// bibliothèque se parcourt par titre ; l'ordre d'ajout n'a d'intérêt que le jour de la saisie.
/// </remarks>
public static IQueryable<Livre> Trier(IQueryable<Livre> source) =>
source.OrderBy(l => l.Titre).ThenBy(l => l.Id);
}