Files
mabibli/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs
T
mathieuandClaude Opus 5 8b8fe3be6e Ajouter la liste d'envies personnelle, son export et la bibliographie par auteur
La liste d'envies vit dans une table séparée (LivreSouhaite) plutôt que dans un
statut de plus sur Livre : un livre souhaité n'est pas possédé, et le loger dans
Livres l'aurait fait entrer dans le catalogue, les compteurs et les prêts, au
prix d'un « et qui n'est pas souhaité » à répéter dans chaque lecture. La portée
est personnelle, comme le statut de lecture — mais ici Utilisateur est une vraie
frontière : toute lecture filtre dessus.

Bibliographie : SRU BnF interrogé par bib.author, vérifié le 2026-08-18. Deux
filtres mesurés sur des réponses réelles sont indispensables — le type de
document (l'index mêle livres audio, jeux et spectacles) et surtout l'auteur
réel de la notice, « all » rapprochant les mots sur l'ensemble des auteurs :
« Émile Zola » remonte sinon toute l'œuvre de sa fille Denise Le Blond-Zola.
Les rééditions sont regroupées par clé d'œuvre (183 notices Werber -> 51
œuvres). Le rapprochement avec l'étagère se fait par titre, pas par ISBN, qui
désigne une édition et non une œuvre ; ses limites sont dites à l'écran.

Export en deux formats, tous deux du texte sans dépendance : .txt groupé par
auteur pour la librairie, .csv à séparateur point-virgule et BOM pour le
tableur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:59:06 +02:00

171 lines
6.1 KiB
C#

using MaBibli.Api.Data;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Souhaits;
/// <summary>Issue d'un ajout d'envie : soit la ligne créée, soit un message pour l'utilisateur.</summary>
public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur)
{
public static ResultatSouhait Ok(SouhaitDto souhait) => new(souhait, null);
public static ResultatSouhait Invalide(string message) => new(null, message);
}
public interface IServiceSouhaits
{
Task<IReadOnlyList<SouhaitDto>> ListerAsync(string? utilisateur, CancellationToken ct = default);
Task<ResultatSouhait> AjouterAsync(
EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default);
}
/// <summary>
/// La liste d'envies, <b>personnelle</b>.
/// </summary>
/// <remarks>
/// ⚠️ <b>Toute méthode filtre sur <paramref name="utilisateur"/>, sans exception.</b> C'est la
/// différence de fond avec <c>ServiceCatalogue</c>, dont les lectures ne filtrent jamais sur
/// <c>AjoutePar</c> : le catalogue est commun, la liste d'envies ne l'est pas. Une envie qui
/// fuiterait vers un autre membre du foyer gâcherait exactement ce que la fonctionnalité sert
/// à préparer.
/// <para>
/// Sans identité (ni en-tête SSOwat ni utilisateur simulé), il n'y a pas de liste : on ne rend
/// rien et on n'écrit rien, plutôt que de rattacher des envies à un propriétaire inventé.
/// </para>
/// </remarks>
public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
{
public async Task<IReadOnlyList<SouhaitDto>> ListerAsync(
string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return [];
}
var souhaits = await db.LivresSouhaites
.AsNoTracking()
.Where(s => s.Utilisateur == utilisateur)
.OrderBy(s => s.AuteurNormalise == string.Empty)
.ThenBy(s => s.AuteurNormalise)
.ThenBy(s => s.TitreNormalise)
.ToListAsync(ct);
return souhaits.Select(Projeter).ToList();
}
public async Task<ResultatSouhait> AjouterAsync(
EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return ResultatSouhait.Invalide(
"Impossible d'ajouter une envie sans savoir à qui elle appartient.");
}
if (string.IsNullOrWhiteSpace(saisie.Titre))
{
return ResultatSouhait.Invalide("Le titre est obligatoire.");
}
// L'ISBN reste facultatif — on souhaite souvent une œuvre sans avoir choisi son édition —
// mais s'il est saisi il doit tenir debout, comme pour un livre du catalogue.
string? isbn = null;
if (!string.IsNullOrWhiteSpace(saisie.Isbn))
{
var normalise = IsbnUtils.Normaliser(saisie.Isbn);
if (!IsbnUtils.EstValide(normalise))
{
return ResultatSouhait.Invalide(
$"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si vous ne l'avez pas.");
}
isbn = normalise;
}
var souhait = new LivreSouhaite
{
Utilisateur = utilisateur,
Titre = saisie.Titre,
Auteur = saisie.Auteur,
Editeur = Vide(saisie.Editeur),
Annee = Vide(saisie.Annee),
Isbn = isbn,
CoverUrl = Vide(saisie.CoverUrl),
Note = Vide(saisie.Note),
DateAjout = DateTime.UtcNow,
};
souhait.RecalculerFormes();
// Le doublon est refusé avec un message plutôt que laissé à l'index unique : l'écran de
// bibliographie rend le double clic facile, et « UNIQUE constraint failed » ne veut rien
// dire pour l'utilisateur.
var deja = await db.LivresSouhaites.AnyAsync(
s => s.Utilisateur == utilisateur
&& s.TitreNormalise == souhait.TitreNormalise
&& s.AuteurNormalise == souhait.AuteurNormalise,
ct);
if (deja)
{
return ResultatSouhait.Invalide($"« {souhait.Titre} » est déjà dans votre liste d'envies.");
}
db.LivresSouhaites.Add(souhait);
await db.SaveChangesAsync(ct);
return ResultatSouhait.Ok(Projeter(souhait));
}
public async Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return false;
}
// Le filtre sur l'utilisateur fait partie de la CLÉ de recherche, pas d'une vérification
// ultérieure : ainsi personne ne peut supprimer l'envie d'un autre en devinant son
// identifiant. Une envie inexistante et une envie appartenant à un autre sont
// indiscernables de l'extérieur, ce qui est bien le comportement voulu.
var souhait = await db.LivresSouhaites
.FirstOrDefaultAsync(s => s.Id == id && s.Utilisateur == utilisateur, ct);
if (souhait is null)
{
return false;
}
db.LivresSouhaites.Remove(souhait);
await db.SaveChangesAsync(ct);
return true;
}
private static string? Vide(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
/// <summary>
/// Projection vers le DTO. <b>Sans le champ <c>Utilisateur</c></b> : il ne sort jamais de
/// l'API, pour qu'aucun client ne puisse croire qu'il désigne de qui il parle.
/// </summary>
internal static SouhaitDto Projeter(LivreSouhaite souhait) => new()
{
Id = souhait.Id,
Titre = souhait.Titre,
Auteur = souhait.Auteur,
Editeur = souhait.Editeur,
Annee = souhait.Annee,
Isbn = souhait.Isbn,
CoverUrl = souhait.CoverUrl,
Note = souhait.Note,
DateAjout = souhait.DateAjout,
};
}