Files
administration/Controllers/HelloFresh/HelloFreshController.cs
2025-09-03 20:17:50 +02:00

1109 lines
42 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Controllers/HelloFreshController.cs
using Microsoft.AspNetCore.Mvc;
using administration.Models;
using administration.Models.HelloFresh;
using Microsoft.AspNetCore.Authorization;
using System.Text.RegularExpressions;
using System.Text.Json;
using administration.Models.HelloFresh.DTO;
using administration.Models.DTO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using static Azure.Core.HttpHeader;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace administration.Controllers;
[Authorize]
[Route("HelloFresh")]
public class HelloFreshController : AppController
{
private readonly HelloFreshContext _context;
private readonly LayoutDataContext _layout;
private readonly IHttpClientFactory _httpFactory;
[ActivatorUtilitiesConstructor]
public HelloFreshController(HelloFreshContext context, LayoutDataContext layout, IHttpClientFactory httpClientFactory)
{
_context = context;
_layout = layout;
_httpFactory = httpClientFactory;
}
[HttpGet("")]
public IActionResult Index() => View();
[HttpGet("ingredients")]
public IActionResult Ingredients() => View();
[HttpGet("historique")]
public IActionResult Historique() => View(); // Views/HelloFresh/History.cshtml
[HttpGet("cuisine")]
public IActionResult Cuisine() => View(); // Views/HelloFresh/History.cshtml
#region Recettes
[HttpGet("GetRecipesByPage")]
public IActionResult GetRecipesByPage([FromQuery] int page = 1, [FromQuery] int count = 12, [FromQuery] string? search = null)
{
try
{
if (page < 1 || count < 1)
return BadRequest("Les paramètres 'page' et 'count' doivent être supérieurs à 0.");
var q = _context.Recettes.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLowerInvariant();
q = q.Where(r => r.Name.ToLower().Contains(s));
}
var totalItems = q.Count();
var lastPage = Math.Max(1, (int)Math.Ceiling((double)totalItems / count));
if (page > lastPage && totalItems > 0) page = lastPage;
var recettes = q.OrderByDescending(r => r.Id)
.Skip((page - 1) * count)
.Take(count)
.ToList();
return Ok(new
{
recipes = recettes,
currentPage = page,
lastPage,
totalItems,
pageSize = count
});
}
catch (Exception ex)
{
return StatusCode(500, new { message = "Erreur serveur", error = ex.Message });
}
}
[HttpPost("SaveRecipe")]
public IActionResult SaveRecipe([FromBody] string idSavingRecette)
{
try
{
var userId = HttpContext.Session.GetInt32("UserId");
if (userId == null) return Unauthorized("Utilisateur non connecté.");
if (string.IsNullOrWhiteSpace(idSavingRecette)) return BadRequest("Id invalide.");
var current = _context.SavingRecettes.Count(s => s.UserId == userId && s.IdSavingRecette == idSavingRecette);
if (current >= 3) return Ok(new { message = "cap", qty = 3 });
_context.SavingRecettes.Add(new SavingRecette { IdSavingRecette = idSavingRecette, UserId = userId.Value });
_context.SaveChanges();
return Ok(new { message = "added", qty = current + 1 });
}
catch (Exception ex)
{
return StatusCode(500, new { message = "Erreur serveur", error = ex.Message });
}
}
[HttpGet("GetRecipesOwned")]
public IActionResult GetRecipesOwned([FromQuery] bool isUserImportant = true)
{
try
{
var sessionUserId = HttpContext.Session.GetInt32("UserId");
if (sessionUserId == null) return Unauthorized("Utilisateur non connecté.");
// petite fonction pour normaliser les clés de regroupement par nom
static string Key(string? s) => (s ?? "").Trim().ToLowerInvariant();
if (isUserImportant)
{
// Mes recettes : on inner-join sur Recettes, on matérialise puis on regroupe par NOM
var rows = (
from s in _context.SavingRecettes.AsNoTracking()
where s.UserId == sessionUserId
join r in _context.Recettes.AsNoTracking() on s.IdSavingRecette equals r.Id
select new
{
r.Id,
r.Name,
r.Image,
r.TempsDePreparation,
r.Tags,
r.Pdf,
Portion = 1
}
).ToList();
var result = rows
.GroupBy(x => Key(x.Name))
.Select(g =>
{
// on choisit un représentant qui a un PDF si possible
var withPdf = g.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.Pdf));
var pick = withPdf ?? g.OrderByDescending(x => x.Id).First();
return new
{
id = pick.Id,
name = pick.Name,
image = pick.Image,
tempsDePreparation = pick.TempsDePreparation,
portions = g.Sum(x => x.Portion),
tags = pick.Tags,
pdf = pick.Pdf
};
})
.OrderBy(x => x.name)
.ToList();
return Ok(result);
}
else
{
// Tous les utilisateurs : on joint aussi sur Users, puis on regroupe par NOM
var rows = (
from s in _context.SavingRecettes.AsNoTracking()
join r in _context.Recettes.AsNoTracking() on s.IdSavingRecette equals r.Id
join u in _layout.Users.AsNoTracking() on s.UserId equals u.Id
select new
{
r.Id,
r.Name,
r.Image,
r.TempsDePreparation,
r.Tags,
r.Pdf,
User = u.Username,
Portion = 1
}
).ToList();
var result = rows
.GroupBy(x => Key(x.Name))
.Select(g =>
{
var withPdf = g.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.Pdf));
var pick = withPdf ?? g.OrderByDescending(x => x.Id).First();
return new
{
id = pick.Id,
name = pick.Name,
image = pick.Image,
tempsDePreparation = pick.TempsDePreparation,
portions = g.Sum(x => x.Portion),
tags = pick.Tags,
pdf = pick.Pdf,
users = g.Select(x => x.User).Distinct().ToList()
};
})
.OrderBy(x => x.name)
.ToList();
return Ok(result);
}
}
catch (Exception ex)
{
return StatusCode(500, new { message = "Erreur serveur", error = ex.Message });
}
}
[HttpPost("DeleteRecipesOwned")]
public IActionResult DeleteRecipesOwned([FromBody] string idSavingRecette)
{
try
{
var userId = HttpContext.Session.GetInt32("UserId");
if (userId == null) return Unauthorized("Utilisateur non connecté.");
var row = _context.SavingRecettes
.Where(s => s.UserId == userId && s.IdSavingRecette == idSavingRecette)
.OrderByDescending(s => s.Id)
.FirstOrDefault();
if (row == null) return Ok(new { message = "empty", qty = 0 });
_context.SavingRecettes.Remove(row);
_context.SaveChanges();
var remaining = _context.SavingRecettes.Count(s => s.UserId == userId && s.IdSavingRecette == idSavingRecette);
return Ok(new { message = "removed", qty = remaining });
}
catch (Exception ex)
{
return StatusCode(500, new { message = "Erreur serveur", error = ex.Message });
}
}
[HttpPost("ClearRecipeOwned")]
public IActionResult ClearRecipeOwned([FromBody] string idSavingRecette)
{
try
{
var userId = HttpContext.Session.GetInt32("UserId");
if (userId == null) return Unauthorized("Utilisateur non connecté.");
if (string.IsNullOrWhiteSpace(idSavingRecette)) return BadRequest("Id invalide.");
var rows = _context.SavingRecettes.Where(s => s.UserId == userId && s.IdSavingRecette == idSavingRecette).ToList();
if (rows.Count == 0) return Ok(new { message = "empty", qty = 0 });
_context.SavingRecettes.RemoveRange(rows);
_context.SaveChanges();
return Ok(new { message = "cleared", qty = 0 });
}
catch (Exception ex)
{
return StatusCode(500, new { message = "Erreur serveur", error = ex.Message });
}
}
#endregion
[AllowAnonymous]
[HttpGet("ProxyPdf")]
public async Task<IActionResult> ProxyPdf([FromQuery] string url, [FromServices] IHttpClientFactory httpClientFactory)
{
if (string.IsNullOrWhiteSpace(url)) return BadRequest("URL manquante.");
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return BadRequest("URL invalide.");
var allowedHosts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"www.hellofresh.fr","hellofresh.fr","img.hellofresh.com","assets.hellofresh.com",
"cdn.hellofresh.com","images.ctfassets.net"
};
if (!allowedHosts.Contains(uri.Host)) return BadRequest($"Domaine non autorisé: {uri.Host}");
try
{
var client = httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36");
client.DefaultRequestHeaders.Accept.ParseAdd("application/pdf");
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("fr-FR,fr;q=0.9,en;q=0.8");
using var resp = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)
{
var preview = await resp.Content.ReadAsStringAsync();
if (preview?.Length > 500) preview = preview[..500];
return StatusCode((int)resp.StatusCode,
$"Téléchargement impossible. Code distant: {(int)resp.StatusCode}. Aperçu: {preview}");
}
var mediaType = resp.Content.Headers.ContentType?.MediaType ?? "application/pdf";
var isPdf = mediaType.Contains("pdf", StringComparison.OrdinalIgnoreCase)
|| uri.AbsolutePath.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase);
if (!isPdf) return BadRequest($"Le document récupéré n'est pas un PDF. Content-Type: {mediaType}");
var ms = new MemoryStream();
await resp.Content.CopyToAsync(ms);
ms.Position = 0;
if (resp.Content.Headers.ContentLength is long len)
Response.ContentLength = len;
Response.Headers["Cache-Control"] = "public, max-age=3600";
Response.Headers["Content-Disposition"] = "inline; filename=\"recette.pdf\"";
return File(ms, "application/pdf");
}
catch (TaskCanceledException)
{
return StatusCode(504, "Timeout lors de la récupération du PDF.");
}
catch (Exception ex)
{
return StatusCode(500, $"Erreur ProxyPdf: {ex.Message}");
}
}
// ------------------------
// Ingrédients (agrégation)
// ------------------------
private sealed record QtyParse(double? Value, string Unit);
private static QtyParse ParseQuantity(string? q)
{
if (string.IsNullOrWhiteSpace(q)) return new QtyParse(null, "");
var cleaned = q.Trim().Replace('\u00A0', ' ').Replace(',', '.');
var m = Regex.Match(cleaned, @"^\s*([0-9]+(?:\.[0-9]+)?)(?:\s+([A-Za-zéèêàùûïîç%()\/\.-]+))?\s*$");
if (!m.Success) return new QtyParse(null, "");
var val = double.TryParse(m.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)
? v : (double?)null;
var unit = m.Groups[2].Success ? m.Groups[2].Value.Trim().ToLowerInvariant() : "";
if (Regex.IsMatch(unit, @"pi[eè]ce", RegexOptions.IgnoreCase)) unit = "pièce(s)";
return new QtyParse(val, unit);
}
private static List<IngredientItemDto> AggregateIngredients(IEnumerable<IngredientItemDto> items, HashSet<string> owned)
{
var map = new Dictionary<string, (double? sum, string unit, IngredientItemDto item)>(StringComparer.Ordinal);
foreach (var it in items)
{
var name = (it.Name ?? "").Trim();
if (string.IsNullOrEmpty(name)) continue;
var kName = name.ToLowerInvariant();
var (val, unit) = ParseQuantity(it.Quantity);
var unitKey = (unit ?? "").ToLowerInvariant();
if (val is null)
{
var k = $"{kName}||{it.Quantity}";
if (!map.ContainsKey(k))
map[k] = (null, unitKey, new IngredientItemDto { Name = name, Quantity = it.Quantity, Image = it.Image, Owned = owned.Contains(kName) });
continue;
}
var key = $"{kName}##{unitKey}";
if (!map.TryGetValue(key, out var acc))
{
map[key] = (val, unitKey, new IngredientItemDto { Name = name, Quantity = it.Quantity, Image = it.Image, Owned = owned.Contains(kName) });
}
else
{
var newSum = (acc.sum ?? 0) + val.Value;
var keep = acc.item;
if (string.IsNullOrWhiteSpace(keep.Image) && !string.IsNullOrWhiteSpace(it.Image))
keep.Image = it.Image;
map[key] = (newSum, unitKey, keep);
}
}
var outList = new List<IngredientItemDto>();
foreach (var kv in map)
{
var (sum, unit, item) = kv.Value;
if (sum is not null)
{
var qty = ((sum % 1.0) == 0.0)
? sum.Value.ToString("0", System.Globalization.CultureInfo.InvariantCulture)
: sum.Value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
item.Quantity = string.IsNullOrWhiteSpace(unit) ? qty : $"{qty} {unit}";
}
outList.Add(item);
}
return outList;
}
[HttpGet("AggregatedIngredients")]
public IActionResult AggregatedIngredients()
{
var userId = HttpContext.Session.GetInt32("UserId");
if (userId == null) return Unauthorized("Utilisateur non connecté.");
var ownedNames = _context.Ingredients
.Select(i => i.NameOwnedIngredients)
.ToList()
.Select(s => (s ?? "").Trim().ToLowerInvariant())
.ToHashSet();
var perRecipe = _context.SavingRecettes
.GroupBy(s => s.IdSavingRecette)
.Select(g => new { RecetteId = g.Key, Portions = g.Count() })
.ToList();
if (perRecipe.Count == 0) return Ok(new List<IngredientItemDto>());
var recettes = _context.Recettes
.Where(r => perRecipe.Select(x => x.RecetteId).Contains(r.Id))
.Select(r => new { r.Id, r.Ingredients })
.ToList();
var portionById = perRecipe.ToDictionary(x => x.RecetteId, x => Math.Min(4, Math.Max(1, x.Portions)));
var rawItems = new List<IngredientItemDto>();
foreach (var r in recettes)
{
if (!portionById.TryGetValue(r.Id, out var portions)) portions = 1;
try
{
using var doc = JsonDocument.Parse(r.Ingredients ?? "{}");
if (!doc.RootElement.TryGetProperty(portions.ToString(), out var arr) || arr.ValueKind != JsonValueKind.Array)
continue;
foreach (var el in arr.EnumerateArray())
{
var name = el.TryGetProperty("Name", out var n) ? n.GetString() ?? "" : "";
var qty = el.TryGetProperty("Quantity", out var q) ? q.GetString() : null;
var img = el.TryGetProperty("Image", out var im) ? im.GetString() : null;
if (string.IsNullOrWhiteSpace(name)) continue;
rawItems.Add(new IngredientItemDto
{
Name = name.Trim(),
Quantity = qty,
Image = img,
Owned = ownedNames.Contains(name.Trim().ToLowerInvariant())
});
}
}
catch { /* ignore */ }
}
var aggregated = AggregateIngredients(rawItems, ownedNames);
var sorted = aggregated
.OrderBy(a => a.Owned ? 1 : 0)
.ThenBy(a => a.Name, StringComparer.Create(new System.Globalization.CultureInfo("fr-FR"), ignoreCase: true))
.ToList();
return Ok(sorted);
}
// ------------- IA SmartSearch : passe par /api/ai/select -------------
public sealed class AiRequestDto { public string Query { get; set; } = ""; }
public sealed class SmartSearchResponse
{
public List<IngredientItemDto> Items { get; set; } = new();
public SmartSearchDebug? Debug { get; set; }
}
public sealed class SmartSearchDebug
{
public object? Sent { get; set; }
public string? Llm { get; set; }
}
[HttpPost("SmartSearch")]
public async Task<IActionResult> SmartSearch(
[FromBody] AiRequestDto body,
[FromServices] IHttpClientFactory httpClientFactory,
[FromQuery] bool debug = false)
{
var userId = HttpContext.Session.GetInt32("UserId");
if (userId == null) return Unauthorized("Utilisateur non connecté.");
// 1) Reconstituer la liste agrégée comme dans AggregatedIngredients
var ownedNames = _context.Ingredients.Select(i => i.NameOwnedIngredients)
.ToList()
.Select(s => (s ?? "").Trim().ToLowerInvariant())
.ToHashSet();
var perRecipe = _context.SavingRecettes
.Where(s => s.UserId == userId)
.GroupBy(s => s.IdSavingRecette)
.Select(g => new { RecetteId = g.Key, Portions = g.Count() })
.ToList();
if (perRecipe.Count == 0)
return Ok(new { items = Array.Empty<IngredientItemDto>(), debug = (object?)null });
var recettes = _context.Recettes
.Where(r => perRecipe.Select(x => x.RecetteId).Contains(r.Id))
.Select(r => new { r.Id, r.Ingredients })
.ToList();
var portionById = perRecipe.ToDictionary(x => x.RecetteId, x => Math.Min(4, Math.Max(1, x.Portions)));
var rawItems = new List<IngredientItemDto>();
foreach (var r in recettes)
{
if (!portionById.TryGetValue(r.Id, out var portions)) portions = 1;
try
{
using var doc = JsonDocument.Parse(r.Ingredients ?? "{}");
if (!doc.RootElement.TryGetProperty(portions.ToString(), out var arr) || arr.ValueKind != JsonValueKind.Array)
continue;
foreach (var el in arr.EnumerateArray())
{
var name = el.TryGetProperty("Name", out var n) ? n.GetString() ?? "" : "";
var qty = el.TryGetProperty("Quantity", out var q) ? q.GetString() : null;
var img = el.TryGetProperty("Image", out var im) ? im.GetString() : null;
if (string.IsNullOrWhiteSpace(name)) continue;
rawItems.Add(new IngredientItemDto
{
Name = name.Trim(),
Quantity = qty,
Image = img,
Owned = ownedNames.Contains(name.Trim().ToLowerInvariant())
});
}
}
catch { /* ignore per-recette */ }
}
var aggregated = AggregateIngredients(rawItems, ownedNames);
var allowedNames = aggregated.Select(a => a.Name).Distinct().ToList();
// 2) Appel interne à notre IA selector /api/ai/select (même host)
var client = httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(60); // on laisse confortable
var baseUrl = $"{Request.Scheme}://{Request.Host}";
var reqBody = new
{
query = body?.Query ?? "",
allowed = allowedNames,
debug = debug
};
var req = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/api/ai/select");
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Content = new StringContent(JsonSerializer.Serialize(reqBody), Encoding.UTF8, "application/json");
try
{
using var resp = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, HttpContext?.RequestAborted ?? default);
var raw = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
// fallback live (contient)
var nq = (body?.Query ?? "").Trim().ToLowerInvariant();
var fallback = string.IsNullOrEmpty(nq)
? aggregated
: aggregated.Where(a => (a.Name ?? "").ToLowerInvariant().Contains(nq)).ToList();
return Ok(new
{
items = fallback,
debug = debug ? new { selectorError = new { status = (int)resp.StatusCode, raw, url = $"{baseUrl}/api/ai/select" } } : null
});
}
var parsed = JsonDocument.Parse(raw).RootElement;
var names = parsed.TryGetProperty("names", out var nEl) && nEl.ValueKind == JsonValueKind.Array
? nEl.EnumerateArray().Select(x => x.GetString() ?? "").Where(s => !string.IsNullOrWhiteSpace(s)).ToList()
: new List<string>();
// 3) Filtrer/ordonner selon la réponse IA
var order = names.Select((n, i) => new { n = n.Trim().ToLowerInvariant(), i })
.ToDictionary(x => x.n, x => x.i);
var selected = aggregated
.Where(a => order.ContainsKey((a.Name ?? "").Trim().ToLowerInvariant()))
.OrderBy(a => order[(a.Name ?? "").Trim().ToLowerInvariant()])
.ToList();
// Fallback si lIA ne renvoie rien
if (selected.Count == 0)
{
var nq = (body?.Query ?? "").Trim().ToLowerInvariant();
selected = string.IsNullOrEmpty(nq)
? aggregated
: aggregated.Where(a => (a.Name ?? "").ToLowerInvariant().Contains(nq)).ToList();
}
return Ok(new
{
items = selected,
debug = debug ? JsonSerializer.Deserialize<object>(raw) : null
});
}
catch (TaskCanceledException)
{
return StatusCode(504, new { error = "Timeout IA selector" });
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.Message });
}
}
[HttpGet("GetRecipesDetails")]
public IActionResult GetRecipesDetails([FromQuery] string ids)
{
if (string.IsNullOrWhiteSpace(ids)) return BadRequest("ids manquant");
var idList = ids.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToList();
var recipes = _context.Recettes
.Where(r => idList.Contains(r.Id))
.Select(r => new
{
id = r.Id,
name = r.Name,
image = r.Image,
difficulte = r.Difficulte,
ingredientsJson = r.Ingredients,
pdf = r.Pdf // 🟢 important pour le fallback
})
.ToList();
return Ok(recipes);
}
[HttpPost("ToggleOwnedIngredient")]
public IActionResult ToggleOwnedIngredient([FromBody] string ingredientName)
{
try
{
var existing = _context.Ingredients
.FirstOrDefault(i => i.NameOwnedIngredients == ingredientName);
if (existing != null)
{
_context.Ingredients.Remove(existing);
_context.SaveChanges();
return Ok(new { status = "removed" });
}
else
{
_context.Ingredients.Add(new Ingredient { NameOwnedIngredients = ingredientName });
_context.SaveChanges();
return Ok(new { status = "added" });
}
}
catch (Exception ex)
{
return StatusCode(500, new { message = "Erreur serveur", error = ex.Message });
}
}
[HttpGet("GetOwnedIngredients")]
public IActionResult GetOwnedIngredients()
{
try
{
var names = _context.Ingredients
.Select(i => i.NameOwnedIngredients)
.Distinct()
.ToList();
return Ok(names);
}
catch (Exception ex)
{
return StatusCode(500, new { message = "Erreur serveur", error = ex.Message });
}
}
[HttpGet("GetAllRecipes")]
public IActionResult GetAllRecipes()
{
// 1) Comptages par recette et utilisateur
var counts = _context.SavingRecettes
.AsNoTracking()
.GroupBy(s => new { s.IdSavingRecette, s.UserId })
.Select(g => new
{
RecetteId = g.Key.IdSavingRecette,
UserId = g.Key.UserId,
Portions = g.Count()
})
.ToList();
// 2) Recettes ✅ AJOUTE le champ JSON "IngredientsToHad"
// ⚠️ adapte "r.IngredientsToHad" si ton modèle a un autre nom (ex: IngredientsToHaveAtHome / Pantry / etc.)
var recettes = _context.Recettes
.AsNoTracking()
.Select(r => new
{
r.Id,
r.Name,
r.Image,
r.TempsDePreparation,
r.Tags,
IngredientsToHad = r.IngredientsToHad // <— ICI
})
.ToList();
// 3) Users
var neededUserIds = counts.Select(x => x.UserId).Distinct().ToList();
var users = _layout.Users
.AsNoTracking()
.Where(u => neededUserIds.Contains(u.Id))
.Select(u => new { u.Id, u.Username })
.ToList();
// 4) Join en mémoire ✅ propage "IngredientsToHad"
var baseRows =
from c in counts
join r in recettes on c.RecetteId equals r.Id
join u in users on c.UserId equals u.Id into gj
from u in gj.DefaultIfEmpty()
select new
{
RecetteId = r.Id,
r.Name,
r.Image,
r.TempsDePreparation,
c.Portions,
r.Tags,
r.IngredientsToHad, // <— ICI
User = u?.Username
};
// 5) Regroupement final par RecetteId
var result = baseRows
.GroupBy(x => x.RecetteId)
.Select(g => new
{
id = g.Key,
name = g.First().Name,
image = g.First().Image,
tempsDePreparation = g.First().TempsDePreparation,
portions = g.Sum(x => x.Portions),
tags = g.First().Tags,
Users = g.Where(x => x.User != null)
.Select(x => x.User)
.Distinct()
.ToList(),
// ✅ expose le JSON côté API en camelCase pour le front
ingredientsToHad = g.First().IngredientsToHad
})
.ToList();
return Ok(result);
}
[HttpGet("/HelloFresh/GetAllRecipesWithUsers")]
public IActionResult GetAllRecipesWithUsers()
{
var data = _context.SavingRecettes
.GroupBy(s => new { s.IdSavingRecette, s.UserId })
.Select(g => new
{
RecetteId = g.Key.IdSavingRecette,
UserId = g.Key.UserId,
Portions = g.Count()
})
.Join(_context.Recettes,
g => g.RecetteId, r => r.Id,
(g, r) => new
{
id = r.Id,
name = r.Name,
image = r.Image,
tempsDePreparation = r.TempsDePreparation,
tags = r.Tags,
userId = g.UserId,
portions = g.Portions
})
.ToList();
return Ok(data);
}
[HttpGet("GetHistory")]
public IActionResult GetHistory([FromQuery] bool isUserImportant = true, [FromQuery] string? search = null)
{
try
{
var sessionUserId = HttpContext.Session.GetInt32("UserId");
if (isUserImportant && sessionUserId == null)
return Unauthorized("Utilisateur non connecté.");
// 1) Historique (matérialisé)
var histQ = _context.HistoriqueRecettes.AsNoTracking();
if (isUserImportant) histQ = histQ.Where(h => h.UserId == sessionUserId);
var hist = histQ
.Select(h => new { h.DateHistorique, h.RecetteId, h.UserId })
.ToList();
// 2) Dictionnaire Recettes avec clé normalisée (trim+lower) pour éviter tout souci de casse/espaces
string K(string? s) => (s ?? "").Trim().ToLowerInvariant();
var recDict = _context.Recettes.AsNoTracking()
.Select(r => new { r.Id, r.Name, r.Image, r.TempsDePreparation })
.ToList()
.GroupBy(r => K(r.Id))
.ToDictionary(g => g.Key, g => g.First());
// 3) “Join” en mémoire (pas de ?. dans lexpression EF)
var joined = hist.Select(h =>
{
recDict.TryGetValue(K(h.RecetteId), out var r);
return new
{
Date = h.DateHistorique,
RecetteId = h.RecetteId,
Name = r != null ? r.Name : null,
Image = r != null ? r.Image : null,
TempsDePreparation = r != null ? r.TempsDePreparation : (int?)null,
UserId = h.UserId
};
}).ToList();
// 4) Filtre recherche serveur (optionnel)
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLowerInvariant();
joined = joined.Where(x => (x.Name ?? "").ToLowerInvariant().Contains(s)).ToList();
}
// 5) Noms dutilisateurs si “tous”
var userNames = new Dictionary<int, string>();
if (!isUserImportant)
{
var ids = joined.Select(x => x.UserId).Distinct().ToList();
userNames = _layout.Users.AsNoTracking()
.Where(u => ids.Contains(u.Id))
.Select(u => new { u.Id, u.Username })
.ToList()
.GroupBy(x => x.Id)
.ToDictionary(g => g.Key, g => g.First().Username ?? $"User#{g.Key}");
}
// 6) Regroupement par Date + Recette
var rows = joined
.GroupBy(x => new { x.Date, x.RecetteId })
.Select(g => new
{
g.Key.Date,
id = g.Key.RecetteId,
name = g.Select(x => x.Name).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)) ?? $"Recette {g.Key.RecetteId}",
image = g.Select(x => x.Image).FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)),
tempsDePreparation = g.Select(x => x.TempsDePreparation).FirstOrDefault() ?? 0,
portions = g.Count(),
Users = !isUserImportant
? g.Select(x => userNames.TryGetValue(x.UserId, out var u) ? u : $"User#{x.UserId}")
.Distinct()
.ToList()
: new List<string>()
})
.ToList();
// 7) Mise en forme par date
var culture = new System.Globalization.CultureInfo("fr-FR");
var result = rows
.GroupBy(x => x.Date)
.OrderByDescending(g => g.Key)
.Select(g => new
{
date = g.Key.ToString("dd MMMM yyyy", culture),
rawDate = g.Key,
items = g.OrderBy(x => x.name, StringComparer.Create(culture, true)).ToList()
})
.ToList();
return Ok(result);
}
catch (Exception ex)
{
return StatusCode(500, new { message = "history_failed", error = ex.Message });
}
}
[HttpPost("ArchiveAll")]
public IActionResult ArchiveAll()
{
var userId = HttpContext.Session.GetInt32("UserId");
if (userId == null) return Unauthorized("Utilisateur non connecté.");
try
{
// 1) Récup selections de l'utilisateur
var selections = _context.SavingRecettes
.Where(s => s.UserId == userId)
.AsNoTracking()
.ToList();
if (selections.Count == 0)
return Ok(new { message = "empty", archivedPortions = 0, archivedRecipes = 0, skippedIds = Array.Empty<string>() });
// 2) Groupes par recette (id string) = nb de portions
var groups = selections
.GroupBy(s => s.IdSavingRecette)
.Select(g => new { RecetteId = g.Key, Portions = g.Count() })
.ToList();
// 3) Filtrer sur ids valides (évite FK/erreurs)
var validIds = _context.Recettes
.AsNoTracking()
.Select(r => r.Id) // string
.ToHashSet(StringComparer.Ordinal);
var validGroups = groups.Where(g => validIds.Contains(g.RecetteId)).ToList();
var skippedIds = groups.Where(g => !validIds.Contains(g.RecetteId)).Select(g => g.RecetteId).Distinct().ToList();
if (validGroups.Count == 0)
return Ok(new { message = "nothing_valid", archivedPortions = 0, archivedRecipes = 0, skippedIds });
// 4) Date Paris (DateOnly)
DateOnly todayParis;
try
{
var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Paris");
todayParis = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz));
}
catch { todayParis = DateOnly.FromDateTime(DateTime.Now); }
using var tx = _context.Database.BeginTransaction();
// 5) Préparer les insertions (1 ligne par portion)
var toInsert = new List<HistoriqueRecette>(capacity: validGroups.Sum(x => x.Portions));
foreach (var g in validGroups)
{
for (int i = 0; i < g.Portions; i++)
{
toInsert.Add(new HistoriqueRecette
{
RecetteId = g.RecetteId, // string
UserId = userId.Value,
DateHistorique = todayParis
});
}
}
// 6) Insert + Delete (seulement les rows correspondants aux ids valides)
if (toInsert.Count > 0)
_context.HistoriqueRecettes.AddRange(toInsert);
var rowsToDelete = _context.SavingRecettes
.Where(s => s.UserId == userId && validIds.Contains(s.IdSavingRecette))
.ToList();
_context.SavingRecettes.RemoveRange(rowsToDelete);
var archivedPortions = _context.SaveChanges(); // compte total opérations (insert + delete)
tx.Commit();
// NB: archivedPortions = insertions + suppressions (EF renvoie le nb total de rows affectées)
// on renvoie plutôt nos compteurs fiables :
var archivedRecipes = validGroups.Count;
var archivedPieces = validGroups.Sum(g => g.Portions);
return Ok(new
{
message = "archived_all",
archivedPortions = archivedPieces,
archivedRecipes,
skippedIds
});
}
catch (DbUpdateException dbex)
{
// remonte lessentiel pour debug front/logs
return StatusCode(500, new
{
message = "archive_failed",
reason = "db_update_exception",
error = dbex.GetBaseException().Message
});
}
catch (Exception ex)
{
return StatusCode(500, new
{
message = "archive_failed",
reason = "exception",
error = ex.Message
});
}
}
// POST /HelloFresh/HasHistory
[HttpPost("HasHistory")]
[IgnoreAntiforgeryToken] // garde si tu postes depuis du JS sans token
public async Task<IActionResult> HasHistory([FromBody] string recipeId)
{
// même logique que le reste de ton contrôleur : user via Session
var sessionUserId = HttpContext.Session.GetInt32("UserId");
if (sessionUserId == null) return Unauthorized("Utilisateur non connecté.");
if (string.IsNullOrWhiteSpace(recipeId))
return Ok(new { exists = false });
// ⚠️ compare sur la bonne colonne (RecetteId) et type string
bool exists = await _context.HistoriqueRecettes
.AsNoTracking()
.AnyAsync(h => h.RecetteId == recipeId && h.UserId == sessionUserId.Value);
return Ok(new { exists });
}
[HttpGet("GetAllIngredients")]
public IActionResult GetAllIngredients()
{
var names = new HashSet<string>(StringComparer.Create(new System.Globalization.CultureInfo("fr-FR"), true));
foreach (var json in _context.Recettes.AsNoTracking().Select(r => r.Ingredients ?? "{}"))
{
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object) continue;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (prop.Value.ValueKind != JsonValueKind.Array) continue;
foreach (var el in prop.Value.EnumerateArray())
{
var n = el.TryGetProperty("Name", out var nEl) ? (nEl.GetString() ?? "").Trim() : "";
if (!string.IsNullOrWhiteSpace(n)) names.Add(n);
}
}
}
catch { /* ignore */ }
}
var list = names.OrderBy(s => s, StringComparer.Create(new System.Globalization.CultureInfo("fr-FR"), true)).ToList();
return Ok(list);
}
[HttpGet("GetAllTags")]
public IActionResult GetAllTags()
{
var tags = new HashSet<string>(StringComparer.Create(new System.Globalization.CultureInfo("fr-FR"), true));
foreach (var row in _context.Recettes.AsNoTracking().Select(r => r.Tags))
{
if (string.IsNullOrWhiteSpace(row)) continue;
foreach (var t in Regex.Split(row, @"[,•]"))
{
var v = (t ?? "").Trim();
if (string.IsNullOrEmpty(v) || v == "•") continue;
tags.Add(v);
}
}
var list = tags.OrderBy(s => s, StringComparer.Create(new System.Globalization.CultureInfo("fr-FR"), true)).ToList();
return Ok(list);
}
// API: recettes choisies distinctes par nom, avec PDF
[HttpGet("GetOwnedForReader")]
public IActionResult GetOwnedForReader()
{
var userId = HttpContext.Session.GetInt32("UserId");
if (userId == null) return Unauthorized("Utilisateur non connecté.");
// 1) portions par recette de l'utilisateur
var owned = _context.SavingRecettes
.Where(s => s.UserId == userId)
.GroupBy(s => s.IdSavingRecette)
.Select(g => new { RecetteId = g.Key, Portions = g.Count() })
.ToList();
if (owned.Count == 0) return Ok(Array.Empty<object>());
// 2) join recettes (on ramène le PDF)
var joined = owned
.Join(_context.Recettes,
g => g.RecetteId,
r => r.Id,
(g, r) => new
{
id = r.Id,
name = r.Name,
image = r.Image,
pdf = r.Pdf,
portions = g.Portions,
prep = r.TempsDePreparation
})
.ToList();
// 3) dédoublonnage par nom (insensible à la casse/espaces) — on garde celui avec + de portions puis nom ASC
string Key(string s) => (s ?? "").Trim().ToLowerInvariant();
var distinct = joined
.GroupBy(x => Key(x.name))
.Select(g => g.OrderByDescending(x => x.portions).ThenBy(x => x.name).First())
.OrderBy(x => x.name)
.ToList();
return Ok(distinct);
}
}