// 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."); // ✅ Base query : uniquement les recettes avec un PDF non vide var q = _context.Recettes .AsNoTracking() .Where(r => r.Pdf != null && r.Pdf != "") // éviter IsNullOrWhiteSpace pour une traduction SQL sûre .AsQueryable(); if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); q = q.Where(r => EF.Functions.Like(r.Name, $"%{s}%")); // Optionnel : chercher aussi dans les tags // q = q.Where(r => EF.Functions.Like(r.Name, $"%{s}%") || (r.Tags != null && EF.Functions.Like(r.Tags, $"%{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 }); } } // ❌ Supprime la limite de 3 portions [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); _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é."); static string Key(string? s) => (s ?? "").Trim().ToLowerInvariant(); if (isUserImportant) { 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 => { 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 { 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 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(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, HttpContext.RequestAborted); 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}"); await using var upstream = await resp.Content.ReadAsStreamAsync(HttpContext.RequestAborted); var ms = new MemoryStream(); await upstream.CopyToAsync(ms, HttpContext.RequestAborted); ms.Position = 0; Response.Headers["Cache-Control"] = "public, max-age=3600"; Response.Headers["Content-Disposition"] = "inline; filename=\"recette.pdf\""; // ✅ Range activé → scroll multi-pages iOS/iframe/pdf.js OK return new FileStreamResult(ms, "application/pdf") { EnableRangeProcessing = true }; } 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 AggregateIngredients(IEnumerable items, HashSet owned) { var map = new Dictionary(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(); 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 scopeIdsOwned = PantryScopeUserIds(); var ownedNames = _context.Ingredients .AsNoTracking() .Where(i => scopeIdsOwned.Contains(i.UserId)) // 👈 union Mae+Byakuya .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()); var recettes = _context.Recettes .Where(r => perRecipe.Select(x => x.RecetteId).Contains(r.Id)) .Select(r => new { r.Id, r.Ingredients }) .ToList(); // ✅ on ne borne plus à 4 pour le calcul; si >4, on prend la table "4" et on scale var portionsById = perRecipe.ToDictionary(x => x.RecetteId, x => Math.Max(1, x.Portions)); var rawItems = new List(); foreach (var r in recettes) { if (!portionsById.TryGetValue(r.Id, out var portions)) portions = 1; int basePortions = Math.Min(4, Math.Max(1, portions)); double scale = portions <= 4 ? 1.0 : (double)portions / 4.0; try { using var doc = JsonDocument.Parse(r.Ingredients ?? "{}"); if (!doc.RootElement.TryGetProperty(basePortions.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; // ✅ scale si >4 et quantité numérique if (scale != 1.0 && !string.IsNullOrWhiteSpace(qty)) { var parsed = ParseQuantity(qty); if (parsed.Value is double v) { var scaled = v * scale; var qtyStr = ((scaled % 1.0) == 0.0) ? scaled.ToString("0", System.Globalization.CultureInfo.InvariantCulture) : scaled.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture); qty = string.IsNullOrWhiteSpace(parsed.Unit) ? qtyStr : $"{qtyStr} {parsed.Unit}"; } } 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 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 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é."); var scopeIdsOwned = PantryScopeUserIds(); var ownedNames = _context.Ingredients .AsNoTracking() .Where(i => scopeIdsOwned.Contains(i.UserId)) // 👈 union Mae+Byakuya .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(), 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 portionsById = perRecipe.ToDictionary(x => x.RecetteId, x => Math.Max(1, x.Portions)); var rawItems = new List(); foreach (var r in recettes) { if (!portionsById.TryGetValue(r.Id, out var portions)) portions = 1; int basePortions = Math.Min(4, Math.Max(1, portions)); double scale = portions <= 4 ? 1.0 : (double)portions / 4.0; try { using var doc = JsonDocument.Parse(r.Ingredients ?? "{}"); if (!doc.RootElement.TryGetProperty(basePortions.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; if (scale != 1.0 && !string.IsNullOrWhiteSpace(qty)) { var parsed = ParseQuantity(qty); if (parsed.Value is double v) { var scaled = v * scale; var qtyStr = ((scaled % 1.0) == 0.0) ? scaled.ToString("0", System.Globalization.CultureInfo.InvariantCulture) : scaled.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture); qty = string.IsNullOrWhiteSpace(parsed.Unit) ? qtyStr : $"{qtyStr} {parsed.Unit}"; } } 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(); var client = httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(60); 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) { 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(); 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(); 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(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) && !string.IsNullOrEmpty(r.Pdf)) // 👈 filtre: PDF non vide .Select(r => new { id = r.Id, name = r.Name, image = r.Image, difficulte = r.Difficulte, ingredientsJson = r.Ingredients, pdf = r.Pdf }) .ToList(); return Ok(recipes); } [HttpPost("ToggleOwnedIngredient")] public IActionResult ToggleOwnedIngredient([FromBody] string ingredientName) { try { var userId = HttpContext.Session.GetInt32("UserId"); if (userId == null) return Unauthorized("Utilisateur non connecté."); var name = (ingredientName ?? "").Trim(); if (string.IsNullOrWhiteSpace(name)) return BadRequest("Nom d'ingrédient invalide."); var existing = _context.Ingredients .FirstOrDefault(i => i.UserId == userId.Value && i.NameOwnedIngredients == name); if (existing != null) { _context.Ingredients.Remove(existing); _context.SaveChanges(); return Ok(new { status = "removed" }); } else { _context.Ingredients.Add(new Ingredient { UserId = userId.Value, // 👈 lie l’ingrédient au user courant NameOwnedIngredients = name }); _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 scopeIds = PantryScopeUserIds(); if (scopeIds.Count == 0) return Unauthorized("Utilisateur non connecté."); var names = _context.Ingredients .AsNoTracking() .Where(i => scopeIds.Contains(i.UserId)) // 👈 union Mae+Byakuya, sinon user seul .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() { 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(); // 👇 Ne garder que les recettes avec un PDF non vide var recettes = _context.Recettes .AsNoTracking() .Where(r => r.Pdf != null && r.Pdf != "") // équiv. à !string.IsNullOrEmpty(r.Pdf) .Select(r => new { r.Id, r.Name, r.Image, r.TempsDePreparation, r.Tags, IngredientsToHad = r.IngredientsToHad, r.Pdf // utilisé pour le filtre (pas renvoyé ensuite) }) .ToList(); 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(); 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, User = u?.Username }; 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(), 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é."); 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(); 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()); 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(); if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim().ToLowerInvariant(); joined = joined.Where(x => (x.Name ?? "").ToLowerInvariant().Contains(s)).ToList(); } var userNames = new Dictionary(); 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}"); } 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() }) .ToList(); 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 { 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() }); var groups = selections .GroupBy(s => s.IdSavingRecette) .Select(g => new { RecetteId = g.Key, Portions = g.Count() }) .ToList(); var validIds = _context.Recettes .AsNoTracking() .Select(r => r.Id) .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 }); 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(); var toInsert = new List(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, UserId = userId.Value, DateHistorique = todayParis }); } } 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); _context.SaveChanges(); tx.Commit(); var archivedRecipes = validGroups.Count; var archivedPieces = validGroups.Sum(g => g.Portions); return Ok(new { message = "archived_all", archivedPortions = archivedPieces, archivedRecipes, skippedIds }); } catch (DbUpdateException dbex) { 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] public async Task HasHistory([FromBody] string recipeId) { var sessionUserId = HttpContext.Session.GetInt32("UserId"); if (sessionUserId == null) return Unauthorized("Utilisateur non connecté."); if (string.IsNullOrWhiteSpace(recipeId)) return Ok(new { exists = false }); 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(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(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é."); 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()); 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(); 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); } // GET /HelloFresh/GetRecipesOwnedByUsers?names=Mae,Byakuya [HttpGet("GetRecipesOwnedByUsers")] public IActionResult GetRecipesOwnedByUsers([FromQuery] string names) { var raw = (names ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(s => s.Trim()) .Where(s => s.Length > 0) .ToList(); if (raw.Count == 0) return Ok(new List()); // usernames -> ids var users = _layout.Users.AsNoTracking() .Where(u => raw.Contains(u.Username)) .Select(u => new { u.Id, u.Username }) .ToList(); if (users.Count == 0) return Ok(new List()); var userIds = users.Select(u => u.Id).ToList(); var idToName = users.ToDictionary(u => u.Id, u => u.Username); // portions par (Recette, User) var counts = _context.SavingRecettes.AsNoTracking() .Where(s => userIds.Contains(s.UserId)) .GroupBy(s => new { s.IdSavingRecette, s.UserId }) .Select(g => new { RecetteId = g.Key.IdSavingRecette, UserId = g.Key.UserId, Portions = g.Count() }) .ToList(); if (counts.Count == 0) return Ok(new List()); var recetteIds = counts.Select(c => c.RecetteId).Distinct().ToList(); var recs = _context.Recettes.AsNoTracking() .Where(r => recetteIds.Contains(r.Id)) .Select(r => new { r.Id, r.Name, r.Image, r.TempsDePreparation, r.Tags, r.IngredientsToHad }) .ToList(); var recDict = recs.ToDictionary(r => r.Id, r => r, StringComparer.Ordinal); // Agrégation par recette limitée aux users demandés var result = counts .GroupBy(c => c.RecetteId) .Select(g => { recDict.TryGetValue(g.Key, out var r); return new { id = g.Key, name = r?.Name ?? g.Key, image = r?.Image, tempsDePreparation = r?.TempsDePreparation ?? 0, portions = g.Sum(x => x.Portions), tags = r?.Tags, users = g.Select(x => idToName.TryGetValue(x.UserId, out var un) ? un : null) .Where(un => !string.IsNullOrWhiteSpace(un)) .Distinct() .ToList(), ingredientsToHad = r?.IngredientsToHad }; }) .OrderBy(x => x.name, StringComparer.Create(new System.Globalization.CultureInfo("fr-FR"), true)) .ToList(); return Ok(result); } // --- Scope "pantry" (ingrédients possédés) --- // Mae & Byakuya partagent leur garde-manger ; les autres sont en solo. private List PantryScopeUserIds() { var sessionUserId = HttpContext.Session.GetInt32("UserId"); if (sessionUserId == null) return new List(); var me = _layout.Users.AsNoTracking().FirstOrDefault(u => u.Id == sessionUserId.Value); if (me == null) return new List { sessionUserId.Value }; // Liste blanche des prénoms qui partagent entre eux var core = new[] { "Mae", "Byakuya" }; var isCore = core.Any(n => string.Equals(n, me.Username, StringComparison.OrdinalIgnoreCase)); if (!isCore) return new List { me.Id }; // Récupère les IDs de Mae & Byakuya return _layout.Users.AsNoTracking() .Where(u => core.Contains(u.Username)) .Select(u => u.Id) .ToList(); } }