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>
171 lines
6.1 KiB
C#
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,
|
|
};
|
|
}
|