Sortir le statut de lecture et l'auteur de la table Livre
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>
This commit is contained in:
@@ -15,6 +15,11 @@ public static class LivresEndpoints
|
||||
/// <b>Aucune lecture ne filtre sur <c>AjoutePar</c></b> : la bibliothèque est commune.
|
||||
/// Ce champ n'est même pas acceptable en paramètre de requête, précisément pour qu'on ne
|
||||
/// puisse pas s'en servir comme d'un cloisonnement par inadvertance.
|
||||
/// <para>
|
||||
/// Le statut de lecture, lui, est <b>personnel</b> : il est toujours celui de l'appelant,
|
||||
/// déduit des en-têtes SSOwat. Aucun point d'entrée n'accepte un nom d'utilisateur — on ne
|
||||
/// consulte ni ne modifie la lecture de quelqu'un d'autre.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IEndpointRouteBuilder MapLivresEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
@@ -24,7 +29,9 @@ public static class LivresEndpoints
|
||||
string? recherche,
|
||||
Format? format,
|
||||
Statut? statut,
|
||||
int? auteurId,
|
||||
IServiceCatalogue service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var criteres = new CritereLivres
|
||||
@@ -32,20 +39,28 @@ public static class LivresEndpoints
|
||||
Recherche = recherche,
|
||||
Format = format,
|
||||
Statut = statut,
|
||||
AuteurId = auteurId,
|
||||
};
|
||||
|
||||
return Results.Ok(await service.ListerAsync(criteres, ct));
|
||||
var utilisateur = utilisateurs.Obtenir().Identifiant;
|
||||
return Results.Ok(await service.ListerAsync(criteres, utilisateur, ct));
|
||||
})
|
||||
.WithName("ListerLivres")
|
||||
.WithSummary("Liste le catalogue, filtrable par format et statut, cherchable sur titre et auteur.")
|
||||
.WithSummary("Liste le catalogue, filtrable par format, statut et auteur ; cherchable sur titre et auteurs.")
|
||||
.WithDescription(
|
||||
"Catalogue unique : physiques et numériques cohabitent, distingués par leur format. "
|
||||
+ "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant.")
|
||||
+ "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant. "
|
||||
+ "Le filtre « statut » porte en revanche sur le statut de lecture de l'appelant, "
|
||||
+ "qui lui est propre. La recherche ignore la casse et les accents.")
|
||||
.Produces<IReadOnlyList<LivreDto>>();
|
||||
|
||||
groupe.MapGet("/{id:int}", async (int id, IServiceCatalogue service, CancellationToken ct) =>
|
||||
groupe.MapGet("/{id:int}", async (
|
||||
int id,
|
||||
IServiceCatalogue service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var livre = await service.ObtenirAsync(id, ct);
|
||||
var livre = await service.ObtenirAsync(id, utilisateurs.Obtenir().Identifiant, ct);
|
||||
return livre is null ? Results.NotFound() : Results.Ok(livre);
|
||||
})
|
||||
.WithName("ObtenirLivre")
|
||||
@@ -75,9 +90,11 @@ public static class LivresEndpoints
|
||||
int id,
|
||||
EnregistrementLivre saisie,
|
||||
IServiceCatalogue service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resultat = await service.ModifierAsync(id, saisie, ct);
|
||||
var resultat = await service.ModifierAsync(
|
||||
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
if (resultat.EstIntrouvable)
|
||||
{
|
||||
@@ -89,7 +106,7 @@ public static class LivresEndpoints
|
||||
: Results.Ok(resultat.Livre);
|
||||
})
|
||||
.WithName("ModifierLivre")
|
||||
.WithSummary("Modifie un livre. « AjoutePar » et « DateAjout » restent inchangés.")
|
||||
.WithSummary("Modifie un livre. « AjoutePar » et « DateAjout » restent inchangés ; le statut posé est celui de l'appelant.")
|
||||
.Produces<LivreDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
@@ -98,13 +115,17 @@ public static class LivresEndpoints
|
||||
int id,
|
||||
ChangementStatut changement,
|
||||
IServiceCatalogue service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var livre = await service.ChangerStatutAsync(id, changement.Statut, ct);
|
||||
var livre = await service.ChangerStatutAsync(
|
||||
id, changement.Statut, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
return livre is null ? Results.NotFound() : Results.Ok(livre);
|
||||
})
|
||||
.WithName("ChangerStatutLivre")
|
||||
.WithSummary("Change le seul statut de lecture — l'action la plus fréquente.")
|
||||
.WithSummary("Change le seul statut de lecture de l'appelant — l'action la plus fréquente.")
|
||||
.WithDescription("Un statut nul remet le livre à « non commencé » : la ligne est supprimée.")
|
||||
.Produces<LivreDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
@@ -120,6 +141,65 @@ public static class LivresEndpoints
|
||||
return routes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consultation des auteurs et arbitrage des rapprochements ambigus.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Les variantes sûres sont réunies automatiquement à l'écriture d'un livre et n'atteignent
|
||||
/// jamais ces points d'entrée. Ce qui arrive ici est ce que la machine <b>refuse</b> de
|
||||
/// trancher : un patronyme seul face à un nom complet, où seul l'utilisateur sait s'il
|
||||
/// s'agit d'une même personne.
|
||||
/// </remarks>
|
||||
public static IEndpointRouteBuilder MapAuteursEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var groupe = routes.MapGroup("/api/auteurs").WithTags("Auteurs");
|
||||
|
||||
groupe.MapGet("/", async (IServiceAuteurs service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListerAsync(ct)))
|
||||
.WithName("ListerAuteurs")
|
||||
.WithSummary("Tous les auteurs du catalogue, avec leur nombre de livres.")
|
||||
.Produces<IReadOnlyList<AuteurDto>>();
|
||||
|
||||
groupe.MapGet("/{id:int}", async (int id, IServiceAuteurs service, CancellationToken ct) =>
|
||||
{
|
||||
var auteur = await service.ObtenirAsync(id, ct);
|
||||
return auteur is null ? Results.NotFound() : Results.Ok(auteur);
|
||||
})
|
||||
.WithName("ObtenirAuteur")
|
||||
.Produces<AuteurDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapGet("/rapprochements", async (IServiceAuteurs service, CancellationToken ct) =>
|
||||
Results.Ok(await service.SuggestionsAsync(ct)))
|
||||
.WithName("ListerRapprochements")
|
||||
.WithSummary("Rapprochements ambigus proposés, refus déjà écartés.")
|
||||
.Produces<IReadOnlyList<SuggestionRapprochement>>();
|
||||
|
||||
groupe.MapPost("/rapprochements/accepter", async (
|
||||
DecisionRapprochement decision, IServiceAuteurs service, CancellationToken ct) =>
|
||||
{
|
||||
var fusionne = await service.FusionnerAsync(decision.SourceId, decision.CibleId, ct);
|
||||
return fusionne ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("AccepterRapprochement")
|
||||
.WithSummary("Fusionne les deux auteurs : « source » disparaît au profit de « cible ».")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapPost("/rapprochements/refuser", async (
|
||||
DecisionRapprochement decision, IServiceAuteurs service, CancellationToken ct) =>
|
||||
{
|
||||
var refuse = await service.RefuserAsync(decision.SourceId, decision.CibleId, ct);
|
||||
return refuse ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("RefuserRapprochement")
|
||||
.WithSummary("Mémorise que ces deux auteurs sont bien deux personnes : la suggestion ne reviendra plus.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/// <summary>Expose l'identité de la personne connectée, pour affichage côté client.</summary>
|
||||
public static IEndpointRouteBuilder MapIdentiteEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user