1099 lines
41 KiB
C#
1099 lines
41 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 });
|
|
}
|
|
}
|
|
|
|
// ❌ 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 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();
|
|
|
|
// ✅ 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 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 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))
|
|
.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 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()
|
|
{
|
|
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();
|
|
|
|
var recettes = _context.Recettes
|
|
.AsNoTracking()
|
|
.Select(r => new
|
|
{
|
|
r.Id,
|
|
r.Name,
|
|
r.Image,
|
|
r.TempsDePreparation,
|
|
r.Tags,
|
|
IngredientsToHad = r.IngredientsToHad
|
|
})
|
|
.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);
|
|
}
|
|
}
|