Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
12 KiB
C#
260 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,
|
|
IServiceCatalogue service,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
{
|
|
var criteres = new CritereLivres
|
|
{
|
|
Recherche = recherche,
|
|
Format = format,
|
|
TypeDocument = typeDocument,
|
|
Statut = statut,
|
|
AuteurId = auteurId,
|
|
Prete = prete,
|
|
};
|
|
|
|
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;
|
|
}
|
|
}
|