Ajouter les sagas et cycles, avec leurs tomes manquants

Première relation entre livres du projet. Une série contient des séries
(un cycle EST une série de séries) et des PLACES dans l'ordre de lecture —
pas des livres : une place sans livre est le trou qu'on vient voir.

L'ordre est stocké, jamais déduit d'une année : une préquelle se lit avant
le livre paru dix ans plus tôt. Les tomes non possédés se saisissent à la
main, aucune source ne donnant l'ordre de lecture d'une saga.

Le titre de la place survit à la suppression du livre (SetNull, pas de
cascade), sans quoi perdre un exemplaire effacerait le tome 3 de la série.

Les séries sont communes au foyer. Seule exception : la mise en envies d'un
tome manquant, qui écrit dans une liste personnelle — c'est le seul point
où les deux portées se rencontrent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-19 22:03:08 +02:00
co-authored by Claude Opus 5
parent 49eb8e1c98
commit 4e372ab694
20 changed files with 2768 additions and 36 deletions
+151
View File
@@ -0,0 +1,151 @@
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("/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.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; }
}