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>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user