Files
mabibli/MaBibli.Api/Endpoints/SeriesEndpoints.cs
T
mathieu 42dec0dbd1 - Catalogage rapide (douchette, code-barres)
- 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é.
2026-09-09 00:05:26 +02:00

175 lines
8.4 KiB
C#

using MaBibli.Api.Services.Identite;
using MaBibli.Api.Services.Series;
using MaBibli.Shared.Dtos;
namespace MaBibli.Api.Endpoints;
public static class SeriesEndpoints
{
/// <summary>
/// Sagas, cycles et séries : ce qui se lit dans un ordre, et ce qui manque pour le lire.
/// </summary>
/// <remarks>
/// ⚠️ <b>Aucun point d'entrée n'accepte ni ne rend d'identité</b>, à une exception près :
/// la mise en envies. Les séries sont <b>communes au foyer</b>, comme le catalogue et les
/// prêts — l'ordre de lecture d'une saga ne dépend pas de qui regarde. La liste d'envies,
/// elle, reste personnelle, et c'est le serveur qui sait à qui il parle.
/// <para>
/// La lecture est <b>unique</b> : <c>GET /api/series</c> rend tout l'arbre à plat. Le détail
/// d'une série s'y lit sans second appel, ce qui donne un seul instantané hors-ligne à
/// tenir à jour plutôt qu'un par série consultée.
/// </para>
/// </remarks>
public static IEndpointRouteBuilder MapSeriesEndpoints(this IEndpointRouteBuilder routes)
{
var groupe = routes.MapGroup("/api/series").WithTags("Séries");
groupe.MapGet("/", async (IServiceSeries service, CancellationToken ct) =>
Results.Ok(await service.ListerAsync(ct)))
.WithName("ListerSeries")
.WithSummary("Toutes les séries, à plat, chacune avec ses tomes dans l'ordre de lecture.")
.WithDescription(
"Le client rebâtit l'arbre à partir de « serieParenteId ». Un tome sans "
+ "« livreId » est un tome qu'on ne possède pas : c'est ce que l'écran montre.")
.Produces<IReadOnlyList<SerieDto>>();
groupe.MapPost("/", async (
EnregistrementSerie saisie,
IServiceSeries service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.CreerAsync(saisie, utilisateurs.Obtenir().Identifiant, ct);
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Created($"/api/series/{resultat.Serie!.Id}", resultat.Serie);
})
.WithName("CreerSerie")
.WithSummary("Crée une série. « AjoutePar » est déterminé par le serveur, et n'est qu'une trace.")
.Produces<SerieDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/{id:int}", async (
int id, EnregistrementSerie saisie, IServiceSeries service, CancellationToken ct) =>
Repondre(await service.ModifierAsync(id, saisie, ct)))
.WithName("ModifierSerie")
.WithSummary("Renomme la série, ou la range dans un cycle.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapDelete("/{id:int}", async (int id, IServiceSeries service, CancellationToken ct) =>
await service.SupprimerAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithName("SupprimerSerie")
.WithSummary("Supprime la série. Ses sous-séries remontent à la racine, elles ne sont pas détruites.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPost("/{id:int}/elements", async (
int id, AjoutElementSerie saisie, IServiceSeries service, CancellationToken ct) =>
Repondre(await service.AjouterElementAsync(id, saisie, ct)))
.WithName("AjouterElementSerie")
.WithSummary("Ajoute un tome en fin de série : un livre du catalogue, ou un simple titre.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/{id:int}/ordre", async (
int id, OrdreElementsSerie ordre, IServiceSeries service, CancellationToken ct) =>
Repondre(await service.ReordonnerAsync(id, ordre.Ids, ct)))
.WithName("ReordonnerSerie")
.WithSummary("Fixe l'ordre de lecture — la liste entière des tomes, dans l'ordre voulu.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/{id:int}/sous-series/ordre", async (
int id, OrdreSeries ordre, IServiceSeries service, CancellationToken ct) =>
Repondre(await service.ReordonnerSousSeriesAsync(id, ordre.Ids, ct)))
.WithName("ReordonnerSousSeries")
.WithSummary("Fixe l'ordre de lecture des séries directement contenues dans un cycle.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/elements/{elementId:int}/livre", async (
int elementId,
RattachementLivre rattachement,
IServiceSeries service,
CancellationToken ct) =>
Repondre(await service.RattacherLivreAsync(elementId, rattachement.LivreId, ct)))
.WithName("RattacherLivreSerie")
.WithSummary("Rattache un livre possédé à une place, ou l'en détache (« livreId » nul).")
.WithDescription(
"Détacher laisse un trou NOMMÉ : le titre du tome est conservé, il ne dépend "
+ "pas de l'exemplaire.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/elements/{elementId:int}", async (
int elementId,
AjoutElementSerie saisie,
IServiceSeries service,
CancellationToken ct) =>
Repondre(await service.ModifierElementAsync(elementId, saisie, ct)))
.WithName("ModifierElementSerie")
.WithSummary("Corrige le titre et le numéro d'un tome, sans toucher au livre rattaché.")
.WithDescription(
"Le numéro est celui imprimé sur le livre (« 7 », « Hors-série »), à ne pas "
+ "confondre avec la position, qui est le rang dans l'ordre de lecture.")
.Produces<SerieDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapDelete("/elements/{elementId:int}", async (
int elementId, IServiceSeries service, CancellationToken ct) =>
await service.RetirerElementAsync(elementId, ct)
? Results.NoContent()
: Results.NotFound())
.WithName("RetirerElementSerie")
.WithSummary("Retire une place de la série. Le livre, lui, reste au catalogue.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPost("/elements/{elementId:int}/souhait", async (
int elementId,
IServiceSeries service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
// ⚠️ Le seul endroit où une série (commune) touche à une liste d'envies
// (personnelle). L'envie créée est celle de l'appelant, jamais celle du foyer.
var resultat = await service.MettreEnEnviesAsync(
elementId, utilisateurs.Obtenir().Identifiant, ct);
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Ok(resultat.Souhait);
})
.WithName("SouhaiterTomeManquant")
.WithSummary("Met un tome manquant dans la liste d'envies de l'appelant.")
.Produces<SouhaitDto>()
.Produces(StatusCodes.Status400BadRequest);
return routes;
}
private static IResult Repondre(ResultatSerie resultat)
{
if (resultat.EstIntrouvable)
{
return Results.NotFound();
}
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Ok(resultat.Serie);
}
}
/// <summary>Charge utile du rattachement d'un livre à une place. <c>null</c> détache.</summary>
public record RattachementLivre
{
public int? LivreId { get; set; }
}