Files
administration/Controllers/HelloFresh/HelloFreshController.cs
2025-09-16 22:20:21 +02:00

1232 lines
47 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.");
// ✅ 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<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, 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<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 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<IngredientItemDto>());
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<IngredientItemDto>();
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<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é.");
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<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 portionsById = perRecipe.ToDictionary(x => x.RecetteId, x => Math.Max(1, x.Portions));
var rawItems = new List<IngredientItemDto>();
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<string>();
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<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) && !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 lingré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<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}");
}
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();
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<string>() });
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<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,
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<IActionResult> 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<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é.");
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>());
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<object>());
// 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<object>());
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<object>());
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<int> PantryScopeUserIds()
{
var sessionUserId = HttpContext.Session.GetInt32("UserId");
if (sessionUserId == null) return new List<int>();
var me = _layout.Users.AsNoTracking().FirstOrDefault(u => u.Id == sessionUserId.Value);
if (me == null) return new List<int> { 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<int> { 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();
}
}