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 { /// /// La liste d'envies et son export. /// /// /// Aucun point d'entrée n'accepte de nom d'utilisateur, 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. /// 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>(); 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(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() .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>(); 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(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() .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; } /// /// Produit le fichier d'export et le renvoie en téléchargement. /// /// /// Le nom de fichier passé à Results.File pose un Content-Disposition: /// attachment : 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 ». /// /// 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. /// /// private static async Task 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)); } /// Bibliographie d'un auteur du catalogue, d'après la BnF. 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() .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() .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; } }