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
@@ -0,0 +1,195 @@
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, CancellationToken ct = default);
Task<LivreDto?> ObtenirAsync(int id, CancellationToken ct = default);
Task<ResultatEcriture> CreerAsync(EnregistrementLivre saisie, string? ajoutePar, CancellationToken ct = default);
Task<ResultatEcriture> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default);
Task<LivreDto?> ChangerStatutAsync(int id, Statut statut, 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>
public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue
{
public async Task<IReadOnlyList<LivreDto>> ListerAsync(
CritereLivres criteres, 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(), criteres);
var livres = await requete.ToListAsync(ct);
return livres.Select(Projeter).ToList();
}
public async Task<LivreDto?> ObtenirAsync(int id, CancellationToken ct = default)
{
var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id, ct);
return livre is null ? null : Projeter(livre);
}
public async Task<ResultatEcriture> CreerAsync(
EnregistrementLivre saisie, string? ajoutePar, 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.Trim(),
Auteur = Vide(saisie.Auteur),
Editeur = Vide(saisie.Editeur),
Format = saisie.Format,
Statut = saisie.Statut,
CoverUrl = Vide(saisie.CoverUrl),
DateAjout = DateTime.UtcNow,
// Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client.
AjoutePar = ajoutePar,
};
db.Livres.Add(livre);
await db.SaveChangesAsync(ct);
return ResultatEcriture.Ok(Projeter(livre));
}
public async Task<ResultatEcriture> ModifierAsync(
int id, EnregistrementLivre saisie, CancellationToken ct = default)
{
var livre = await db.Livres.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.Trim();
livre.Auteur = Vide(saisie.Auteur);
livre.Editeur = Vide(saisie.Editeur);
livre.Format = saisie.Format;
livre.Statut = saisie.Statut;
livre.CoverUrl = Vide(saisie.CoverUrl);
// DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie
// d'origine, pas des champs éditables.
await db.SaveChangesAsync(ct);
return ResultatEcriture.Ok(Projeter(livre));
}
public async Task<LivreDto?> ChangerStatutAsync(int id, Statut statut, CancellationToken ct = default)
{
var livre = await db.Livres.FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
{
return null;
}
livre.Statut = statut;
await db.SaveChangesAsync(ct);
return Projeter(livre);
}
public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default)
{
var livre = await db.Livres.FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
{
return false;
}
db.Livres.Remove(livre);
await db.SaveChangesAsync(ct);
return true;
}
/// <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) => new()
{
Id = livre.Id,
Isbn = livre.Isbn,
Titre = livre.Titre,
Auteur = livre.Auteur,
Editeur = livre.Editeur,
Format = livre.Format,
Statut = livre.Statut,
CoverUrl = livre.CoverUrl,
DateAjout = livre.DateAjout,
AjoutePar = livre.AjoutePar,
};
}
@@ -0,0 +1,97 @@
using MaBibli.Shared.Dtos;
namespace MaBibli.Api.Services.Identite;
public interface IFournisseurUtilisateur
{
/// <summary>Identité de la requête en cours, jamais <c>null</c> (au pire <see cref="UtilisateurCourant.Anonyme"/>).</summary>
UtilisateurCourant Obtenir();
}
/// <summary>
/// Options de l'identité, section <c>Identite</c> de la configuration.
/// </summary>
public sealed class OptionsIdentite
{
public const string Section = "Identite";
/// <summary>
/// Utilisateur simulé en développement, où SSOwat n'existe pas.
/// </summary>
/// <remarks>
/// CLAUDE.md : « prévoir un utilisateur simulé plutôt que de désactiver l'auth ».
/// Il n'est utilisé que si les en-têtes SSOwat sont absents, et n'est renseigné que dans
/// <c>appsettings.Development.json</c> — en production, aucune valeur, donc aucun repli.
/// </remarks>
public string? UtilisateurSimule { get; set; }
public string? EmailSimule { get; set; }
public string? NomCompletSimule { get; set; }
}
/// <summary>
/// Lit l'identité dans les en-têtes HTTP injectés par SSOwat.
/// </summary>
/// <remarks>
/// nginx authentifie le visiteur via le portail YunoHost puis pousse <c>YNH_USER</c>,
/// <c>YNH_USER_EMAIL</c> et <c>YNH_USER_FULLNAME</c>. L'application se contente de les lire.
/// <para>
/// ⚠️ Ces en-têtes ne sont dignes de confiance que parce que le service n'écoute que sur
/// <c>127.0.0.1</c> derrière nginx : exposé directement, n'importe qui pourrait forger
/// <c>YNH_USER</c>. Cette contrainte se règle dans <c>systemd.service</c> / <c>nginx.conf</c>,
/// qui vivent dans le dépôt <c>mabibli_ynh</c> (phase 7) — pas ici.
/// </para>
/// </remarks>
public sealed class FournisseurUtilisateurSsowat(
IHttpContextAccessor accesseur,
Microsoft.Extensions.Options.IOptions<OptionsIdentite> options) : IFournisseurUtilisateur
{
public const string EnteteUtilisateur = "YNH_USER";
public const string EnteteEmail = "YNH_USER_EMAIL";
public const string EnteteNomComplet = "YNH_USER_FULLNAME";
public UtilisateurCourant Obtenir()
{
var entetes = accesseur.HttpContext?.Request.Headers;
var identifiant = Valeur(entetes, EnteteUtilisateur);
if (identifiant is not null)
{
return new UtilisateurCourant
{
Identifiant = identifiant,
Email = Valeur(entetes, EnteteEmail),
NomComplet = Valeur(entetes, EnteteNomComplet),
Simule = false,
};
}
var simule = options.Value;
if (!string.IsNullOrWhiteSpace(simule.UtilisateurSimule))
{
return new UtilisateurCourant
{
Identifiant = simule.UtilisateurSimule,
Email = simule.EmailSimule,
NomComplet = simule.NomCompletSimule,
Simule = true,
};
}
// Ni en-tête, ni utilisateur simulé : on n'invente pas d'identité.
// Le livre sera enregistré sans traçabilité plutôt que sous un faux nom.
return UtilisateurCourant.Anonyme;
}
private static string? Valeur(IHeaderDictionary? entetes, string nom)
{
if (entetes is null || !entetes.TryGetValue(nom, out var valeurs))
{
return null;
}
var valeur = valeurs.ToString().Trim();
return valeur.Length == 0 ? null : valeur;
}
}