L'entite Pret existait depuis le squelette sans jamais etre exploitee. Cette
phase la met en service de bout en bout.
API
- GET /api/prets/en-cours ce qui n'est pas a la maison, du plus ancien
au plus recent : on cherche le livre oublie,
pas celui prete hier
- POST /api/livres/{id}/prets preter
- GET /api/livres/{id}/prets historique complet, du plus recent au plus
ancien
- POST /api/prets/{id}/retour clore le pret sans le supprimer
Un livre deja sorti ne peut pas etre prete une seconde fois. Le service le
verifie et nomme celui qui l'a deja, mais entre sa verification et l'insertion
il reste une fenetre : un index unique PARTIEL (LivreId WHERE DateRetour IS
NULL) la ferme, tout en laissant l'historique accumuler autant de prets clos
que necessaire sur le meme livre.
Les ebooks sont refuses cote API, pas seulement grises dans l'interface : une
fiche n'a pas d'exemplaire a confier.
Les prets sont COMMUNS au foyer, symetrique inverse du statut de lecture. Le
service ne recoit meme pas d'identite, pour qu'on ne puisse pas s'en servir
par inadvertance.
Interface, pensee mobile d'abord
- ecran « Prets en cours » avec bouton « Rendu » a meme la liste
- bloc pret sur la fiche d'un livre : etat courant, action, puis historique
- etiquette « Prete a X » dans le catalogue
- la barre d'actions passe sur deux lignes plutot que de comprimer ses
libelles maintenant qu'elle compte quatre entrees
Toutes les dates sont en UTC ; le client convertit la date locale du
<input type="date"> avant l'envoi, faute de quoi le pret se decalerait d'un
jour pour la moitie du globe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
242 lines
9.6 KiB
C#
242 lines
9.6 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using MaBibli.Shared.Catalogue;
|
|
using MaBibli.Shared.Dtos;
|
|
using MaBibli.Shared.Entites;
|
|
|
|
namespace MaBibli.Client.Services;
|
|
|
|
/// <summary>
|
|
/// Accès à l'API depuis le client Blazor.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Aucun cache : la phase 3 suppose le réseau disponible. Le cache hors-ligne est la phase 6.
|
|
/// </remarks>
|
|
public sealed class ServiceLivresApi(HttpClient http)
|
|
{
|
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
|
|
|
public async Task<IReadOnlyList<LivreDto>> ListerAsync(CritereLivres criteres, CancellationToken ct = default)
|
|
{
|
|
var parametres = new List<string>();
|
|
|
|
if (!string.IsNullOrWhiteSpace(criteres.Recherche))
|
|
{
|
|
parametres.Add($"recherche={Uri.EscapeDataString(criteres.Recherche.Trim())}");
|
|
}
|
|
|
|
if (criteres.Format is { } format)
|
|
{
|
|
parametres.Add($"format={format}");
|
|
}
|
|
|
|
if (criteres.Statut is { } statut)
|
|
{
|
|
parametres.Add($"statut={statut}");
|
|
}
|
|
|
|
if (criteres.AuteurId is { } auteurId)
|
|
{
|
|
parametres.Add($"auteurId={auteurId}");
|
|
}
|
|
|
|
var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty);
|
|
|
|
return await http.GetFromJsonAsync<List<LivreDto>>(url, Json, ct) ?? [];
|
|
}
|
|
|
|
public async Task<LivreDto?> ObtenirAsync(int id, CancellationToken ct = default)
|
|
{
|
|
var reponse = await http.GetAsync($"api/livres/{id}", ct);
|
|
return reponse.StatusCode == HttpStatusCode.NotFound
|
|
? null
|
|
: await LireAsync<LivreDto>(reponse, ct);
|
|
}
|
|
|
|
public async Task<ResultatApi<LivreDto>> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default)
|
|
{
|
|
var reponse = await http.PostAsJsonAsync("api/livres", saisie, Json, ct);
|
|
return await InterpreterAsync<LivreDto>(reponse, ct);
|
|
}
|
|
|
|
public async Task<ResultatApi<LivreDto>> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default)
|
|
{
|
|
var reponse = await http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct);
|
|
return await InterpreterAsync<LivreDto>(reponse, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Change le statut de lecture <b>de l'utilisateur courant</b> ; <c>null</c> le remet à
|
|
/// « non commencé ». Le serveur sait qui appelle : le client ne transmet jamais d'identité.
|
|
/// </summary>
|
|
public async Task<ResultatApi<LivreDto>> ChangerStatutAsync(
|
|
int id, Statut? statut, CancellationToken ct = default)
|
|
{
|
|
var reponse = await http.PatchAsJsonAsync(
|
|
$"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct);
|
|
return await InterpreterAsync<LivreDto>(reponse, ct);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<AuteurDto>> ListerAuteursAsync(CancellationToken ct = default) =>
|
|
await http.GetFromJsonAsync<List<AuteurDto>>("api/auteurs", Json, ct) ?? [];
|
|
|
|
public async Task<AuteurDto?> ObtenirAuteurAsync(int id, CancellationToken ct = default)
|
|
{
|
|
var reponse = await http.GetAsync($"api/auteurs/{id}", ct);
|
|
return reponse.StatusCode == HttpStatusCode.NotFound
|
|
? null
|
|
: await LireAsync<AuteurDto>(reponse, ct);
|
|
}
|
|
|
|
/// <summary>Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul.</summary>
|
|
public async Task<IReadOnlyList<SuggestionRapprochement>> ListerRapprochementsAsync(
|
|
CancellationToken ct = default) =>
|
|
await http.GetFromJsonAsync<List<SuggestionRapprochement>>("api/auteurs/rapprochements", Json, ct) ?? [];
|
|
|
|
public Task<string?> AccepterRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) =>
|
|
DeciderAsync("accepter", sourceId, cibleId, ct);
|
|
|
|
public Task<string?> RefuserRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) =>
|
|
DeciderAsync("refuser", sourceId, cibleId, ct);
|
|
|
|
private async Task<string?> DeciderAsync(string action, int sourceId, int cibleId, CancellationToken ct)
|
|
{
|
|
var reponse = await http.PostAsJsonAsync(
|
|
$"api/auteurs/rapprochements/{action}",
|
|
new DecisionRapprochement { SourceId = sourceId, CibleId = cibleId },
|
|
Json,
|
|
ct);
|
|
|
|
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
|
|
}
|
|
|
|
/// <summary>Les livres actuellement dehors, du prêt le plus ancien au plus récent.</summary>
|
|
public async Task<IReadOnlyList<PretDto>> ListerPretsEnCoursAsync(CancellationToken ct = default) =>
|
|
await http.GetFromJsonAsync<List<PretDto>>("api/prets/en-cours", Json, ct) ?? [];
|
|
|
|
/// <summary>
|
|
/// Historique complet des prêts d'un livre, du plus récent au plus ancien.
|
|
/// </summary>
|
|
/// <remarks>Liste vide = jamais prêté ; <c>null</c> = livre inconnu.</remarks>
|
|
public async Task<IReadOnlyList<PretDto>?> ListerPretsLivreAsync(int livreId, CancellationToken ct = default)
|
|
{
|
|
var reponse = await http.GetAsync($"api/livres/{livreId}/prets", ct);
|
|
return reponse.StatusCode == HttpStatusCode.NotFound
|
|
? null
|
|
: await LireAsync<List<PretDto>>(reponse, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prête un livre. <paramref name="datePret"/> est une date <b>locale</b>, convertie ici en
|
|
/// UTC : tout ce que l'API stocke est en UTC, comme le reste des dates du projet.
|
|
/// </summary>
|
|
public async Task<ResultatApi<PretDto>> PreterAsync(
|
|
int livreId, string emprunteur, DateTime? datePret = null, CancellationToken ct = default)
|
|
{
|
|
var saisie = new EnregistrementPret
|
|
{
|
|
Emprunteur = emprunteur,
|
|
DatePret = EnUtc(datePret),
|
|
};
|
|
|
|
var reponse = await http.PostAsJsonAsync($"api/livres/{livreId}/prets", saisie, Json, ct);
|
|
return await InterpreterAsync<PretDto>(reponse, ct);
|
|
}
|
|
|
|
/// <summary>Clôt un prêt. Le prêt n'est pas supprimé : il devient l'historique du livre.</summary>
|
|
public async Task<ResultatApi<PretDto>> RendreAsync(
|
|
int pretId, DateTime? dateRetour = null, CancellationToken ct = default)
|
|
{
|
|
var reponse = await http.PostAsJsonAsync(
|
|
$"api/prets/{pretId}/retour", new RetourPret { DateRetour = EnUtc(dateRetour) }, Json, ct);
|
|
return await InterpreterAsync<PretDto>(reponse, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convertit une date issue d'un <input type="date"> en instant UTC.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Le navigateur produit une date sans fuseau (minuit, « Unspecified ») : l'envoyer telle
|
|
/// quelle décalerait le prêt d'un jour pour la moitié du globe. On la déclare locale avant
|
|
/// de la convertir, ce qui est bien ce que l'utilisateur voulait dire en la choisissant.
|
|
/// </remarks>
|
|
private static DateTime? EnUtc(DateTime? date) =>
|
|
date is null ? null : DateTime.SpecifyKind(date.Value, DateTimeKind.Local).ToUniversalTime();
|
|
|
|
public async Task<string?> SupprimerAsync(int id, CancellationToken ct = default)
|
|
{
|
|
var reponse = await http.DeleteAsync($"api/livres/{id}", ct);
|
|
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
|
|
}
|
|
|
|
public async Task<ResultatLookupIsbn?> ChercherIsbnAsync(string isbn, CancellationToken ct = default)
|
|
{
|
|
var reponse = await http.GetAsync($"api/isbn/{Uri.EscapeDataString(isbn)}", ct);
|
|
return reponse.IsSuccessStatusCode
|
|
? await LireAsync<ResultatLookupIsbn>(reponse, ct)
|
|
: null;
|
|
}
|
|
|
|
public async Task<UtilisateurCourant> ObtenirUtilisateurAsync(CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
return await http.GetFromJsonAsync<UtilisateurCourant>("api/moi", Json, ct)
|
|
?? UtilisateurCourant.Anonyme;
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
return UtilisateurCourant.Anonyme;
|
|
}
|
|
}
|
|
|
|
private static async Task<T?> LireAsync<T>(HttpResponseMessage reponse, CancellationToken ct)
|
|
{
|
|
reponse.EnsureSuccessStatusCode();
|
|
return await reponse.Content.ReadFromJsonAsync<T>(Json, ct);
|
|
}
|
|
|
|
private static async Task<ResultatApi<T>> InterpreterAsync<T>(HttpResponseMessage reponse, CancellationToken ct)
|
|
{
|
|
if (reponse.IsSuccessStatusCode)
|
|
{
|
|
var valeur = await reponse.Content.ReadFromJsonAsync<T>(Json, ct);
|
|
return new ResultatApi<T>(valeur, null);
|
|
}
|
|
|
|
return new ResultatApi<T>(default, await MessageErreurAsync(reponse, ct));
|
|
}
|
|
|
|
/// <summary>Récupère le message d'erreur de l'API pour l'afficher tel quel à l'utilisateur.</summary>
|
|
private static async Task<string> MessageErreurAsync(HttpResponseMessage reponse, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var corps = await reponse.Content.ReadFromJsonAsync<ErreurApi>(Json, ct);
|
|
if (!string.IsNullOrWhiteSpace(corps?.Erreur))
|
|
{
|
|
return corps.Erreur;
|
|
}
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Corps illisible ou vide : on retombe sur le code HTTP.
|
|
}
|
|
|
|
return reponse.StatusCode switch
|
|
{
|
|
HttpStatusCode.NotFound => "Ce livre n'existe plus.",
|
|
_ => $"L'enregistrement a échoué ({(int)reponse.StatusCode}).",
|
|
};
|
|
}
|
|
|
|
private sealed record ErreurApi(string? Erreur);
|
|
}
|
|
|
|
/// <summary>Résultat d'un appel d'écriture : la valeur, ou un message d'erreur affichable.</summary>
|
|
public readonly record struct ResultatApi<T>(T? Valeur, string? Erreur)
|
|
{
|
|
public bool EstOk => Erreur is null;
|
|
}
|