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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user