1109 lines
42 KiB
C#
1109 lines
42 KiB
C#
// 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 l’IA 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 l’expression 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 d’utilisateurs 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 l’essentiel 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);
|
||
}
|
||
} |