Files
mabibli/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs
T
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

456 lines
19 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'une écriture d'envie : la ligne, un message, ou rien du tout.</summary>
/// <remarks>
/// ⚠️ « Introuvable » est un <b>troisième état</b>, et pas un message d'erreur de plus : c'est
/// la seule issue qui doive produire un 404. L'édition de l'envie de quelqu'un d'autre passe par
/// là — l'inexistence et l'appartenance à autrui sont volontairement indiscernables.
/// </remarks>
public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur, bool NExistePas = false)
{
public static readonly ResultatSouhait Introuvable = new(null, null, true);
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);
/// <summary>
/// Corrige une envie existante de l'appelant.
/// </summary>
/// <remarks>
/// ⚠️ <c>Rang</c> n'y est pas touché : l'ordre a son propre point d'entrée, et corriger un
/// titre n'est pas dire qu'on le veut davantage.
/// </remarks>
Task<ResultatSouhait> ModifierAsync(
int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default);
/// <summary>
/// Réordonne la liste de l'appelant selon la suite d'identifiants fournie.
/// </summary>
/// <remarks>
/// Prend la liste <b>entière</b> plutôt qu'un déplacement unitaire : c'est ce dont a besoin
/// le glisser-déposer, et les flèches monter/descendre s'y ramènent sans effort. L'opération
/// est idempotente — réenvoyer le même ordre ne change rien.
/// </remarks>
Task<bool> ReordonnerAsync(
IReadOnlyList<int> idsOrdonnes, 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 [];
}
// L'ordre est celui que l'utilisateur a choisi, du plus désiré au moins désiré. Il a
// remplacé un tri par auteur puis titre : classer une liste d'envies par ordre
// alphabétique répondait à une question que personne ne se pose.
// L'Id départage les rangs égaux — deux lignes de même rang ne devraient pas exister,
// mais un tri instable ferait sautiller la liste entre deux affichages.
var souhaits = await db.LivresSouhaites
.AsNoTracking()
.Where(s => s.Utilisateur == utilisateur)
.OrderBy(s => s.Rang)
.ThenBy(s => s.Id)
.ToListAsync(ct);
var possedes = await CorrespondancesAsync(souhaits, ct);
return souhaits.Select(s => Projeter(s, Correspondant(possedes, s.Id))).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.");
}
// En fin de liste : une envie qu'on vient de noter n'est pas déclarée plus désirable
// que celles déjà classées. La poser en tête déclasserait le choix de l'utilisateur
// à chaque ajout.
var dernierRang = await db.LivresSouhaites
.Where(s => s.Utilisateur == utilisateur)
.Select(s => (int?)s.Rang)
.MaxAsync(ct);
souhait.Rang = (dernierRang ?? -1) + 1;
db.LivresSouhaites.Add(souhait);
await db.SaveChangesAsync(ct);
var possede = await CorrespondancesAsync([souhait], ct);
return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id)));
}
public async Task<ResultatSouhait> ModifierAsync(
int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return ResultatSouhait.Introuvable;
}
// Le filtre sur l'utilisateur fait partie de la CLÉ, comme pour la suppression : on ne
// vérifie pas l'appartenance après coup, on ne trouve tout simplement pas l'envie d'un
// autre. C'est ce qui rend le 404 exact plutôt que poli.
var souhait = await db.LivresSouhaites
.FirstOrDefaultAsync(s => s.Id == id && s.Utilisateur == utilisateur, ct);
if (souhait is null)
{
return ResultatSouhait.Introuvable;
}
if (string.IsNullOrWhiteSpace(saisie.Titre))
{
return ResultatSouhait.Invalide("Le titre est obligatoire.");
}
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;
}
souhait.Titre = saisie.Titre;
souhait.Auteur = saisie.Auteur;
souhait.Editeur = Vide(saisie.Editeur);
souhait.Annee = Vide(saisie.Annee);
souhait.Isbn = isbn;
souhait.Note = Vide(saisie.Note);
// ⚠️ La couverture n'est écrite QUE si la charge utile en porte une, contrairement aux
// autres champs. Raison : aucun écran n'offre de champ « URL de couverture » pour une
// envie — décision actée, un tel champ inviterait à coller des liens morts. Un
// remplacement inconditionnel effacerait donc, à chaque correction de titre, l'image
// que la bibliographie avait fournie. Il n'y a rien à « vider » puisqu'il n'y a rien à
// saisir, et la lecture retombe de toute façon sur la formule OpenLibrary à ISBN connu.
souhait.CoverUrl = Vide(saisie.CoverUrl) ?? souhait.CoverUrl;
// ⚠️ Indispensable : TitreNormalise est la CLÉ D'ŒUVRE, celle qui sert au rapprochement
// « déjà au catalogue ». Sans ce recalcul, corriger un titre laisserait le rapprochement
// se faire sur l'ancienne forme — et le signalement mentirait sans que rien ne le dise.
souhait.RecalculerFormes();
// ⚠️ Une édition peut heurter l'unicité (Utilisateur, TitreNormalise, AuteurNormalise),
// contrairement à un ajout que l'utilisateur vient de composer : renommer une envie en
// une autre qu'on a déjà est une fausse manœuvre plausible. On répond par un message
// plutôt que de laisser remonter « UNIQUE constraint failed », qui ne veut rien dire.
//
// 400 et non 409, contrairement au doublon du CATALOGUE : là-bas posséder deux
// exemplaires est légitime et l'appel se reconfirme, ici l'index l'interdit et il n'y a
// rien à confirmer. C'est une saisie à corriger, pas un choix à trancher.
var deja = await db.LivresSouhaites.AnyAsync(
s => s.Id != souhait.Id
&& 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.");
}
await db.SaveChangesAsync(ct);
var possede = await CorrespondancesAsync([souhait], ct);
return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id)));
}
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;
}
public async Task<bool> ReordonnerAsync(
IReadOnlyList<int> idsOrdonnes, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return false;
}
// ⚠️ On relit TOUTE la liste de l'appelant, et on ne se fie pas à ce que le client
// envoie. Deux raisons : un identifiant appartenant à quelqu'un d'autre ne doit pas
// pouvoir être renuméroté (le filtre fait partie de la clé, comme pour la suppression),
// et la liste du client peut être périmée — une envie ajoutée depuis un autre appareil
// n'y figure pas, et la perdre serait pire que de mal la classer.
var siennes = await db.LivresSouhaites
.Where(s => s.Utilisateur == utilisateur)
.ToListAsync(ct);
if (siennes.Count == 0)
{
return false;
}
var parId = siennes.ToDictionary(s => s.Id);
var rang = 0;
foreach (var id in idsOrdonnes.Distinct())
{
if (parId.Remove(id, out var souhait))
{
souhait.Rang = rang++;
}
// Un identifiant inconnu — supprimé entre-temps, ou appartenant à un autre — est
// simplement ignoré : réordonner n'est pas une occasion de découvrir des erreurs.
}
// Ce que le client ne connaissait pas se range à la suite, dans son ordre précédent,
// plutôt que d'être renuméroté au hasard.
foreach (var oublie in parId.Values.OrderBy(s => s.Rang).ThenBy(s => s.Id))
{
oublie.Rang = rang++;
}
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>
/// Pour chaque envie, le livre du catalogue qui lui correspond — quand il y en a un.
/// </summary>
/// <remarks>
/// <b>Ce qui se croise ici, ce sont deux portées différentes</b> : la liste d'envies est
/// personnelle, le catalogue est commun. Le signalement dit donc « ce livre est <i>dans la
/// maison</i> », pas « vous l'avez acheté » — et c'est bien l'information utile avant
/// d'acheter à nouveau.
/// <para>
/// Deux critères, comme pour les doublons du catalogue : un ISBN identique tranche seul (il
/// désigne une édition précise), sinon il faut la même clé d'œuvre <b>et</b> un auteur
/// commun. Le titre seul confondrait deux recueils homonymes.
/// </para>
/// <para>
/// ⚠️ Le catalogue est chargé en entier, en projection minimale. C'est assumé à l'échelle
/// d'un foyer et cohérent avec l'instantané hors-ligne, qui l'emporte déjà tout entier dans
/// le navigateur. Le jour où la bibliothèque compterait des milliers de fiches, c'est ici
/// qu'il faudrait dégrossir en SQL — pas renoncer au signalement.
/// </para>
/// </remarks>
private async Task<Dictionary<int, int>> CorrespondancesAsync(
IReadOnlyList<LivreSouhaite> souhaits, CancellationToken ct)
{
if (souhaits.Count == 0)
{
return [];
}
var livres = await db.Livres
.AsNoTracking()
.Select(l => new
{
l.Id,
l.Titre,
l.Isbn,
Auteurs = l.Auteurs.OrderBy(la => la.Position).Select(la => la.Auteur!.Nom).ToList(),
})
.ToListAsync(ct);
var parCle = livres
.Select(l => (Cle: CleOeuvre.Cle(l.Titre), Livre: l))
.Where(x => x.Cle.Length > 0)
.GroupBy(x => x.Cle)
.ToDictionary(g => g.Key, g => g.Select(x => x.Livre).ToList());
var parIsbn = new Dictionary<string, int>();
foreach (var livre in livres.Where(l => !string.IsNullOrWhiteSpace(l.Isbn)))
{
parIsbn.TryAdd(livre.Isbn!, livre.Id);
}
var correspondances = new Dictionary<int, int>();
foreach (var souhait in souhaits)
{
if (!string.IsNullOrWhiteSpace(souhait.Isbn) && parIsbn.TryGetValue(souhait.Isbn, out var idIsbn))
{
correspondances[souhait.Id] = idIsbn;
continue;
}
// TitreNormalise EST la clé d'œuvre pour une envie (voir LivreSouhaite) : rien à
// recalculer de ce côté-là.
if (!parCle.TryGetValue(souhait.TitreNormalise, out var homonymes))
{
continue;
}
var trouve = homonymes.FirstOrDefault(
l => RapprochementAuteurs.PartagentUnAuteur(l.Auteurs, [souhait.Auteur]));
if (trouve is not null)
{
correspondances[souhait.Id] = trouve.Id;
}
}
return correspondances;
}
/// <summary>
/// Identifiant du livre correspondant, ou <c>null</c>.
/// </summary>
/// <remarks>
/// ⚠️ Surtout pas <c>GetValueOrDefault</c> : sur un dictionnaire de <c>int</c>, l'absence
/// rend <b>zéro</b>, pas <c>null</c> — et toute envie se serait dite possédée. Le défaut a
/// été pris par les tests, pas par le compilateur.
/// </remarks>
private static int? Correspondant(IReadOnlyDictionary<int, int> correspondances, int souhaitId) =>
correspondances.TryGetValue(souhaitId, out var livreId) ? livreId : null;
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, int? livreId = null) => new()
{
Id = souhait.Id,
Titre = souhait.Titre,
Auteur = souhait.Auteur,
Editeur = souhait.Editeur,
Annee = souhait.Annee,
Isbn = souhait.Isbn,
// ⚠️ La couverture se déduit de l'ISBN quand elle n'a pas été fournie.
//
// Constaté en usage : aucune vignette dans la liste d'envies. La cause n'était pas le
// composant Couverture mais le chemin d'ajout — seul l'écran « ajouter une envie »
// remplissait CoverUrl, alors que la plupart des envies arrivent par la bibliographie
// d'un auteur ou par un tome manquant d'une série, qui transmettent bien un ISBN mais
// aucune image.
//
// Le repli est posé ICI, à la lecture, et non à l'écriture : il vaut alors aussi pour
// les envies DÉJÀ enregistrées, sans migration ni rattrapage. La règle « sans ISBN,
// pas de couverture, et on n'en invente pas » est intacte — on n'invente rien, on
// applique la formule OpenLibrary habituelle à un ISBN qu'on possède déjà.
CoverUrl = souhait.CoverUrl
?? (souhait.Isbn is null ? null : IsbnUtils.UrlCouverture(souhait.Isbn)),
Note = souhait.Note,
DateAjout = souhait.DateAjout,
Possede = livreId is not null,
LivreId = livreId,
};
}