- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne. - Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais. Séries et sagas - Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles). - Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un. - Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence. - Filtre catalogue « sans couverture » pour repérer les livres à illustrer. Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total). - Corrections directes sur la fiche - Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ». - Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification. - Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe. - La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper. - Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
262 lines
12 KiB
C#
262 lines
12 KiB
C#
using MaBibli.Api.Services.Catalogue;
|
|
using MaBibli.Api.Services.Identite;
|
|
using MaBibli.Shared.Catalogue;
|
|
using MaBibli.Shared.Dtos;
|
|
using MaBibli.Shared.Entites;
|
|
|
|
namespace MaBibli.Api.Endpoints;
|
|
|
|
public static class LivresEndpoints
|
|
{
|
|
/// <summary>
|
|
/// CRUD du catalogue.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <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)
|
|
{
|
|
var groupe = routes.MapGroup("/api/livres").WithTags("Livres");
|
|
|
|
groupe.MapGet("/", async (
|
|
string? recherche,
|
|
Format? format,
|
|
TypeDocument? typeDocument,
|
|
Statut? statut,
|
|
int? auteurId,
|
|
bool? prete,
|
|
bool? sansCouverture,
|
|
IServiceCatalogue service,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
{
|
|
var criteres = new CritereLivres
|
|
{
|
|
Recherche = recherche,
|
|
Format = format,
|
|
TypeDocument = typeDocument,
|
|
Statut = statut,
|
|
AuteurId = auteurId,
|
|
Prete = prete,
|
|
SansCouverture = sansCouverture,
|
|
};
|
|
|
|
var utilisateur = utilisateurs.Obtenir().Identifiant;
|
|
return Results.Ok(await service.ListerAsync(criteres, utilisateur, ct));
|
|
})
|
|
.WithName("ListerLivres")
|
|
.WithSummary("Liste le catalogue, filtrable par format, type de document, statut, auteur et prêt en cours ; 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. "
|
|
+ "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,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
{
|
|
var livre = await service.ObtenirAsync(id, utilisateurs.Obtenir().Identifiant, ct);
|
|
return livre is null ? Results.NotFound() : Results.Ok(livre);
|
|
})
|
|
.WithName("ObtenirLivre")
|
|
.Produces<LivreDto>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
groupe.MapPost("/", async (
|
|
EnregistrementLivre saisie,
|
|
bool? confirmerDoublon,
|
|
IServiceCatalogue service,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
{
|
|
// AjoutePar vient du serveur, pas de la charge utile.
|
|
var utilisateur = utilisateurs.Obtenir();
|
|
var resultat = await service.CreerAsync(
|
|
saisie, utilisateur.Identifiant, confirmerDoublon ?? false, ct);
|
|
|
|
if (resultat.Erreur is not null)
|
|
{
|
|
return Results.BadRequest(new { erreur = resultat.Erreur });
|
|
}
|
|
|
|
// 409 et non 400 : la saisie est valide, c'est le catalogue qui contient déjà
|
|
// quelque chose de semblable. Le même appel avec « confirmerDoublon » aboutira.
|
|
return resultat.Doublons is { } doublons
|
|
? Results.Conflict(doublons)
|
|
: Results.Created($"/api/livres/{resultat.Livre!.Id}", resultat.Livre);
|
|
})
|
|
.WithName("CreerLivre")
|
|
.WithSummary("Ajoute un livre. « AjoutePar » est déterminé par le serveur.")
|
|
.WithDescription(
|
|
"Répond 409 quand le catalogue contient déjà un livre de même ISBN, ou de même "
|
|
+ "titre et même auteur, en donnant les fiches en cause. Ce n'est pas un refus : "
|
|
+ "posséder deux exemplaires est légitime, et « confirmerDoublon=true » "
|
|
+ "enregistre la même saisie sans plus rien demander.")
|
|
.Produces<LivreDto>(StatusCodes.Status201Created)
|
|
.Produces<DoublonsLivre>(StatusCodes.Status409Conflict)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
groupe.MapPut("/{id:int}", async (
|
|
int id,
|
|
EnregistrementLivre saisie,
|
|
IServiceCatalogue service,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
{
|
|
var resultat = await service.ModifierAsync(
|
|
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
|
|
|
|
if (resultat.EstIntrouvable)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
return resultat.Erreur is not null
|
|
? Results.BadRequest(new { erreur = resultat.Erreur })
|
|
: Results.Ok(resultat.Livre);
|
|
})
|
|
.WithName("ModifierLivre")
|
|
.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);
|
|
|
|
groupe.MapPatch("/{id:int}/statut", async (
|
|
int id,
|
|
ChangementStatut changement,
|
|
IServiceCatalogue service,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken 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 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);
|
|
|
|
groupe.MapDelete("/{id:int}", async (int id, IServiceCatalogue service, CancellationToken ct) =>
|
|
{
|
|
var supprime = await service.SupprimerAsync(id, ct);
|
|
return supprime ? Results.NoContent() : Results.NotFound();
|
|
})
|
|
.WithName("SupprimerLivre")
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
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.MapPut("/{id:int}", async (
|
|
int id,
|
|
RenommerAuteur demande,
|
|
bool? confirmerFusion,
|
|
IServiceAuteurs service,
|
|
CancellationToken ct) =>
|
|
{
|
|
var resultat = await service.RenommerAsync(
|
|
id, demande.Nom, confirmerFusion ?? false, ct);
|
|
|
|
if (resultat.Auteur is null && resultat.Collision is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
return resultat.Collision is not null
|
|
? Results.Conflict(resultat)
|
|
: Results.Ok(resultat);
|
|
})
|
|
.WithName("RenommerAuteur")
|
|
.WithSummary("Renomme un auteur ou fusionne sa fiche avec une fiche existante.")
|
|
.Produces<ResultatRenommageAuteur>()
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status409Conflict);
|
|
|
|
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)
|
|
{
|
|
routes.MapGet("/api/moi", (IFournisseurUtilisateur utilisateurs) =>
|
|
Results.Ok(utilisateurs.Obtenir()))
|
|
.WithName("UtilisateurCourant")
|
|
.WithTags("Identité")
|
|
.WithSummary("Identité lue dans les en-têtes SSOwat, ou utilisateur simulé en développement.")
|
|
.Produces<UtilisateurCourant>();
|
|
|
|
return routes;
|
|
}
|
|
}
|