Files
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

209 lines
7.3 KiB
C#

using MaBibli.Api.Data;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Souhaits;
/// <summary>Issue d'une écriture d'envie de revue : la ligne, un message, ou rien du tout.</summary>
public readonly record struct ResultatRevueSouhaitee(
RevueSouhaiteeDto? Envie, string? Erreur, bool NExistePas = false)
{
public static readonly ResultatRevueSouhaitee Introuvable = new(null, null, true);
public static ResultatRevueSouhaitee Ok(RevueSouhaiteeDto envie) => new(envie, null);
public static ResultatRevueSouhaitee Invalide(string message) => new(null, message);
}
public interface IServiceRevuesSouhaitees
{
Task<IReadOnlyList<RevueSouhaiteeDto>> ListerAsync(
string? utilisateur, CancellationToken ct = default);
Task<ResultatRevueSouhaitee> AjouterAsync(
EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default);
Task<ResultatRevueSouhaitee> ModifierAsync(
int id, EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default);
}
/// <summary>
/// Les envies de revues et de numéros, <b>personnelles</b>.
/// </summary>
/// <remarks>
/// Mêmes règles que <see cref="ServiceSouhaits"/>, sans exception : toute méthode filtre sur
/// l'appelant, aucun point d'entrée n'accepte de nom d'utilisateur, et l'envie d'un autre est
/// simplement introuvable.
/// <para>
/// ⚠️ Il n'y a <b>pas</b> de rapprochement avec les revues possédées, contrairement aux envies
/// de livres. Ce n'est pas un oubli : « je possède la revue mais pas ce numéro » et « je possède
/// ce numéro » demandent deux réponses différentes, et un signalement approximatif sur une liste
/// d'envies coûte plus qu'il ne rapporte.
/// </para>
/// </remarks>
public sealed class ServiceRevuesSouhaitees(MaBibliDbContext db) : IServiceRevuesSouhaitees
{
public async Task<IReadOnlyList<RevueSouhaiteeDto>> ListerAsync(
string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return [];
}
var envies = await db.RevuesSouhaitees
.AsNoTracking()
.Where(e => e.Utilisateur == utilisateur)
.OrderBy(e => e.Rang)
.ThenBy(e => e.Id)
.ToListAsync(ct);
return envies.Select(Projeter).ToList();
}
public async Task<ResultatRevueSouhaitee> AjouterAsync(
EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return ResultatRevueSouhaitee.Invalide(
"Impossible d'ajouter une envie sans savoir à qui elle appartient.");
}
if (string.IsNullOrWhiteSpace(saisie.Titre))
{
return ResultatRevueSouhaitee.Invalide("Le titre de la revue est obligatoire.");
}
var envie = new RevueSouhaitee
{
Utilisateur = utilisateur,
Titre = saisie.Titre,
Numero = saisie.Numero,
Issn = saisie.Issn,
Note = Vide(saisie.Note),
DateAjout = DateTime.UtcNow,
};
// ⚠️ Canonise l'ISSN au passage : « 24666718 » devient « 2466-6718 », la seule forme
// rangée en base et la seule que la BnF interroge.
envie.RecalculerFormes();
if (await ExisteDejaAsync(envie, null, ct))
{
return ResultatRevueSouhaitee.Invalide(Deja(envie));
}
// En fin de section, comme une envie de livre : on vient de la noter, on n'a pas dit
// qu'on la voulait plus que les autres.
var dernierRang = await db.RevuesSouhaitees
.Where(e => e.Utilisateur == utilisateur)
.Select(e => (int?)e.Rang)
.MaxAsync(ct);
envie.Rang = (dernierRang ?? -1) + 1;
db.RevuesSouhaitees.Add(envie);
await db.SaveChangesAsync(ct);
return ResultatRevueSouhaitee.Ok(Projeter(envie));
}
public async Task<ResultatRevueSouhaitee> ModifierAsync(
int id, EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return ResultatRevueSouhaitee.Introuvable;
}
// Le filtre fait partie de la clé de recherche : l'envie d'un autre n'est pas refusée,
// elle n'est pas trouvée.
var envie = await db.RevuesSouhaitees
.FirstOrDefaultAsync(e => e.Id == id && e.Utilisateur == utilisateur, ct);
if (envie is null)
{
return ResultatRevueSouhaitee.Introuvable;
}
if (string.IsNullOrWhiteSpace(saisie.Titre))
{
return ResultatRevueSouhaitee.Invalide("Le titre de la revue est obligatoire.");
}
envie.Titre = saisie.Titre;
envie.Numero = saisie.Numero;
envie.Issn = saisie.Issn;
envie.Note = Vide(saisie.Note);
envie.RecalculerFormes();
if (await ExisteDejaAsync(envie, envie.Id, ct))
{
return ResultatRevueSouhaitee.Invalide(Deja(envie));
}
// Rang inchangé : corriger un numéro ne dit rien de l'ordre voulu.
await db.SaveChangesAsync(ct);
return ResultatRevueSouhaitee.Ok(Projeter(envie));
}
public async Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return false;
}
var envie = await db.RevuesSouhaitees
.FirstOrDefaultAsync(e => e.Id == id && e.Utilisateur == utilisateur, ct);
if (envie is null)
{
return false;
}
db.RevuesSouhaitees.Remove(envie);
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>
/// Le doublon est refusé <b>avec un message</b>, jamais laissé à l'index unique.
/// </summary>
/// <remarks>
/// ⚠️ L'entité est déjà rattachée au contexte lors d'une modification : d'où l'exclusion
/// explicite de sa propre ligne, sans quoi toute édition se dirait doublon d'elle-même.
/// </remarks>
private Task<bool> ExisteDejaAsync(RevueSouhaitee envie, int? saufId, CancellationToken ct) =>
db.RevuesSouhaitees.AnyAsync(
e => e.Utilisateur == envie.Utilisateur
&& e.TitreNormalise == envie.TitreNormalise
&& e.NumeroNormalise == envie.NumeroNormalise
&& (saufId == null || e.Id != saufId),
ct);
private static string Deja(RevueSouhaitee envie) =>
envie.Numero is null
? $"« {envie.Titre} » est déjà dans votre liste d'envies."
: $"« {envie.Titre} n° {envie.Numero} » est déjà dans votre liste d'envies.";
private static string? Vide(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
/// <summary>Projection vers le DTO, <b>sans</b> le champ <c>Utilisateur</c>.</summary>
private static RevueSouhaiteeDto Projeter(RevueSouhaitee envie) => new()
{
Id = envie.Id,
Titre = envie.Titre,
Numero = envie.Numero,
Issn = envie.Issn,
Note = envie.Note,
DateAjout = envie.DateAjout,
};
}