Files
mabibli/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs
T
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
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>
2026-08-22 22:36:16 +02:00

362 lines
17 KiB
C#

using System.Text;
using MaBibli.Api.Services.Catalogue;
using MaBibli.Api.Services.Identite;
using MaBibli.Api.Services.Souhaits;
using MaBibli.Shared.Dtos;
namespace MaBibli.Api.Endpoints;
public static class SouhaitsEndpoints
{
/// <summary>
/// La liste d'envies et son export.
/// </summary>
/// <remarks>
/// <b>Aucun point d'entrée n'accepte de nom d'utilisateur</b>, et il ne doit jamais y en
/// avoir : la liste rendue est toujours celle de l'appelant, identifié par les en-têtes
/// SSOwat. C'est la même règle que pour les statuts de lecture, et elle est ici encore plus
/// nette — le sens de la fonctionnalité est de préparer un cadeau sans que l'autre le voie.
/// </remarks>
public static IEndpointRouteBuilder MapSouhaitsEndpoints(this IEndpointRouteBuilder routes)
{
var groupe = routes.MapGroup("/api/souhaits").WithTags("Liste d'envies");
groupe.MapGet("/", async (
IServiceSouhaits service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
Results.Ok(await service.ListerAsync(utilisateurs.Obtenir().Identifiant, ct)))
.WithName("ListerSouhaits")
.WithSummary("La liste d'envies de l'appelant, du plus désiré au moins désiré.")
.WithDescription(
"Personnelle : deux membres du foyer obtiennent deux listes différentes. "
+ "Sans identité, la liste est vide. L'ordre est celui que le propriétaire a "
+ "choisi ; il n'est pas alphabétique.")
.Produces<IReadOnlyList<SouhaitDto>>();
groupe.MapPut("/ordre", async (
int[] ids,
IServiceSouhaits service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var fait = await service.ReordonnerAsync(
ids, utilisateurs.Obtenir().Identifiant, ct);
return fait ? Results.NoContent() : Results.NotFound();
})
.WithName("ReordonnerSouhaits")
.WithSummary("Fixe l'ordre de la liste d'envies de l'appelant.")
.WithDescription(
"Attend la liste ENTIÈRE des identifiants, dans l'ordre voulu — c'est ce dont a "
+ "besoin le glisser-déposer, et les flèches monter/descendre s'y ramènent. "
+ "Les identifiants inconnus ou appartenant à quelqu'un d'autre sont ignorés ; "
+ "les envies absentes de la liste envoyée sont conservées, à la suite. "
+ "404 si l'appelant n'a aucune envie.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPost("/", async (
EnregistrementSouhait saisie,
IServiceSouhaits service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.AjouterAsync(
saisie, utilisateurs.Obtenir().Identifiant, ct);
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Created($"/api/souhaits/{resultat.Souhait!.Id}", resultat.Souhait);
})
.WithName("AjouterSouhait")
.WithSummary("Ajoute un livre à la liste d'envies de l'appelant.")
.Produces<SouhaitDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/{id:int}", async (
int id,
EnregistrementSouhait saisie,
IServiceSouhaits service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.ModifierAsync(
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
if (resultat.NExistePas)
{
return Results.NotFound();
}
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Ok(resultat.Souhait);
})
.WithName("ModifierSouhait")
.WithSummary("Corrige une envie de l'appelant.")
.WithDescription(
"404 si l'envie n'existe pas OU appartient à quelqu'un d'autre : les deux cas "
+ "sont volontairement indiscernables. 400 — et non 409 — quand la correction "
+ "ferait doublon avec une autre envie : l'unicité l'interdit, il n'y a rien à "
+ "confirmer, contrairement au doublon du catalogue. Le rang n'est pas touché : "
+ "l'ordre a son propre point d'entrée.")
.Produces<SouhaitDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapDelete("/{id:int}", async (
int id,
IServiceSouhaits service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var supprime = await service.SupprimerAsync(
id, utilisateurs.Obtenir().Identifiant, ct);
return supprime ? Results.NoContent() : Results.NotFound();
})
.WithName("SupprimerSouhait")
.WithSummary("Retire un livre de la liste d'envies de l'appelant.")
.WithDescription(
"404 si l'envie n'existe pas OU appartient à quelqu'un d'autre : les deux cas "
+ "sont volontairement indiscernables.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
// ─────────────────────────────────────────────────────────────────────
// Envies de revues et de numéros.
//
// Table sœur, donc points d'entrée sœurs : mêmes règles de portée, même 404 sur ce qui
// appartient à un autre. Le préfixe littéral « revues » ne peut pas être confondu avec
// « /{id:int} », qui exige un entier.
// ─────────────────────────────────────────────────────────────────────
groupe.MapGet("/revues", async (
IServiceRevuesSouhaitees service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
Results.Ok(await service.ListerAsync(utilisateurs.Obtenir().Identifiant, ct)))
.WithName("ListerRevuesSouhaitees")
.WithSummary("Les revues et numéros souhaités par l'appelant.")
.WithDescription(
"Section distincte de la liste d'envies : les rangs des deux tables sont "
+ "numérotés indépendamment, les entrelacer produirait un ordre que personne "
+ "n'a choisi.")
.Produces<IReadOnlyList<RevueSouhaiteeDto>>();
groupe.MapPost("/revues", async (
EnregistrementRevueSouhaitee saisie,
IServiceRevuesSouhaitees service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.AjouterAsync(
saisie, utilisateurs.Obtenir().Identifiant, ct);
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Created($"/api/souhaits/revues/{resultat.Envie!.Id}", resultat.Envie);
})
.WithName("AjouterRevueSouhaitee")
.WithSummary("Ajoute une revue — ou un numéro précis — à la liste d'envies.")
.WithDescription(
"Le numéro est facultatif : on souhaite parfois un numéro vu en kiosque, "
+ "parfois le titre entier. L'ISSN est canonisé avec son tiret.")
.Produces<RevueSouhaiteeDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/revues/{id:int}", async (
int id,
EnregistrementRevueSouhaitee saisie,
IServiceRevuesSouhaitees service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.ModifierAsync(
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
if (resultat.NExistePas)
{
return Results.NotFound();
}
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Ok(resultat.Envie);
})
.WithName("ModifierRevueSouhaitee")
.WithSummary("Corrige une envie de revue de l'appelant.")
.Produces<RevueSouhaiteeDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapDelete("/revues/{id:int}", async (
int id,
IServiceRevuesSouhaitees service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var supprime = await service.SupprimerAsync(
id, utilisateurs.Obtenir().Identifiant, ct);
return supprime ? Results.NoContent() : Results.NotFound();
})
.WithName("SupprimerRevueSouhaitee")
.WithSummary("Retire une revue de la liste d'envies de l'appelant.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
// Deux formats parce que les deux usages décrits dans CLAUDE.md diffèrent : le .txt
// s'emporte en librairie et se lit tel quel, le .csv s'ouvre dans un tableur pour se
// répartir des achats. Ce sont deux fichiers texte : aucune dépendance ni pour les
// produire, ni pour les lire.
groupe.MapGet("/export.txt", (
IServiceSouhaits service,
IServiceRevuesSouhaitees revues,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
ExporterAsync(FormatExportSouhaits.Texte, service, revues, utilisateurs, ct))
.WithName("ExporterSouhaitsTexte")
.WithSummary("Liste d'envies en texte lisible, groupée par auteur.")
.ExcludeFromDescription();
groupe.MapGet("/export.csv", (
IServiceSouhaits service,
IServiceRevuesSouhaitees revues,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
ExporterAsync(FormatExportSouhaits.Csv, service, revues, utilisateurs, ct))
.WithName("ExporterSouhaitsCsv")
.WithSummary("Liste d'envies en CSV, ouvrable dans un tableur.")
.ExcludeFromDescription();
return routes;
}
/// <summary>
/// Produit le fichier d'export et le renvoie en <b>téléchargement</b>.
/// </summary>
/// <remarks>
/// Le nom de fichier passé à <c>Results.File</c> pose un <c>Content-Disposition:
/// attachment</c> : le navigateur enregistre au lieu d'afficher, y compris sur mobile où un
/// .txt s'ouvrirait sinon dans l'onglet. C'est ce qui rend l'export réellement
/// « emportable ».
/// <para>
/// Le fichier est produit ici plutôt que côté client : le client Blazor n'a pas la liste
/// complète sous la main, et un export généré par le serveur reste correct même si l'écran
/// affiche une vue filtrée.
/// </para>
/// </remarks>
private static async Task<IResult> ExporterAsync(
FormatExportSouhaits format,
IServiceSouhaits service,
IServiceRevuesSouhaitees revues,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct)
{
var utilisateur = utilisateurs.Obtenir();
var souhaits = await service.ListerAsync(utilisateur.Identifiant, ct);
// ⚠️ Les deux tables partent ensemble, sans quoi les envies de revues n'existeraient
// pas dans le fichier qu'on emporte — c'est-à-dire là où elles servent.
var revuesSouhaitees = await revues.ListerAsync(utilisateur.Identifiant, ct);
var maintenant = DateTime.Now;
var contenu = ExportSouhaits.Produire(
format, souhaits, revuesSouhaitees, utilisateur.Affichage, maintenant);
// Le BOM n'est mis QUE sur le CSV, où il est indispensable : sans lui, Excel lit le
// fichier en codage hérité et affiche « Émile Zola ». Le .txt s'en passe — il est fait
// pour être lu tel quel, et certains lecteurs simples affichent le BOM comme un
// caractère parasite en tête de la première ligne.
var corps = Encoding.UTF8.GetBytes(contenu);
var octets = format == FormatExportSouhaits.Csv
? [.. ExportSouhaits.Bom, .. corps]
: corps;
return Results.File(
octets,
ExportSouhaits.TypeMime(format),
ExportSouhaits.NomFichier(format, maintenant));
}
/// <summary>Bibliographie d'un auteur du catalogue, d'après la BnF.</summary>
public static IEndpointRouteBuilder MapBibliographieEndpoints(this IEndpointRouteBuilder routes)
{
routes.MapGet("/api/auteurs/{id:int}/bibliographie", async (
int id,
IServiceBibliographie service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var bibliographie = await service.ObtenirAsync(
id, utilisateurs.Obtenir().Identifiant, ct);
return bibliographie is null ? Results.NotFound() : Results.Ok(bibliographie);
})
.WithName("BibliographieAuteur")
.WithTags("Auteurs")
.WithSummary("Ce que la BnF connaît de cet auteur, confronté à l'étagère.")
.WithDescription(
"« Possédé » vaut pour tout le foyer, « souhaité » seulement pour l'appelant. "
+ "Le rapprochement se fait par titre normalisé : il peut manquer un livre "
+ "possédé sous un autre titre, d'où le fait qu'aucune ligne ne soit masquée.")
.Produces<BibliographieDto>()
.Produces(StatusCodes.Status404NotFound);
routes.MapGet("/api/auteurs/{id:int}/bibliographie/nouveautes", async (
int id,
IServiceBibliographie service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var bibliographie = await service.ObtenirNouveautesAsync(
id, utilisateurs.Obtenir().Identifiant, ct);
return bibliographie is null ? Results.NotFound() : Results.Ok(bibliographie);
})
.WithName("NouveautesAuteur")
.WithTags("Auteurs")
.WithSummary("Les œuvres de cet auteur parues depuis le livre le plus récent possédé.")
.WithDescription(
"La date du représentant d'une œuvre est celle de l'édition la plus ancienne ; "
+ "la sélection compare donc la date la plus récente des éditions connues. "
+ "La recherche étendue est bornée à 1000 notices BnF.")
.Produces<BibliographieDto>()
.Produces(StatusCodes.Status404NotFound);
routes.MapPost("/api/auteurs/{id:int}/bibliographie/masque", async (
int id,
MasquageBibliographie saisie,
IServiceBibliographie service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var fait = await service.MasquerAsync(
id, saisie.Titre, utilisateurs.Obtenir().Identifiant, ct);
return fait ? Results.NoContent() : Results.BadRequest();
})
.WithName("MasquerOeuvreBibliographie")
.WithSummary("Masque une œuvre de la bibliographie pour l'appelant.")
.Produces(StatusCodes.Status204NoContent);
routes.MapDelete("/api/auteurs/{id:int}/bibliographie/masque", async (
int id,
string titre,
IServiceBibliographie service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var fait = await service.DemasquerAsync(
id, titre, utilisateurs.Obtenir().Identifiant, ct);
return fait ? Results.NoContent() : Results.NotFound();
})
.WithName("DemasquerOeuvreBibliographie")
.WithSummary("Réaffiche une œuvre masquée de la bibliographie.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
return routes;
}
}