diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8642c32 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# CS8600: Conversion de littéral ayant une valeur null ou d'une éventuelle valeur null en type non-nullable. +dotnet_diagnostic.CS8600.severity = silent diff --git a/Controllers/AccountController.cs b/Controllers/AccountController.cs new file mode 100644 index 0000000..79e17a2 --- /dev/null +++ b/Controllers/AccountController.cs @@ -0,0 +1,185 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using administration.Models.Finances; +using administration.Models; +using User = administration.Models.User; + +public class AccountController : Controller +{ + private readonly LayoutDataContext _db; + private readonly IPasswordHasher _hasher; + + public AccountController(LayoutDataContext db, IPasswordHasher hasher) + { + _db = db; + _hasher = hasher; + } + + // GET /Account/Login + [HttpGet] + public IActionResult Login(string? returnUrl = null) + { + ViewBag.ReturnUrl = returnUrl; + return View(); + } + + // POST /Account/Login + [HttpPost, ValidateAntiForgeryToken] + public async Task Login(string username, string password, string? returnUrl = null) + { + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + ModelState.AddModelError("", "Identifiants requis."); + return View(); + } + + var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == username); + if (user == null) + { + ModelState.AddModelError("", "Identifiants invalides."); + return View(); + } + + var verify = _hasher.VerifyHashedPassword(user, user.PasswordHash, password); + if (verify == PasswordVerificationResult.Failed) + { + ModelState.AddModelError("", "Identifiants invalides."); + return View(); + } + + // Claims pour cookie d’auth + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username) + }; + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); + + // Compatibilité avec ton code existant + HttpContext.Session.SetInt32("UserId", user.Id); + + if (!string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl)) + return Redirect(returnUrl); + + return RedirectToAction("Index", "Home"); + } + + // POST /Account/Logout + [HttpPost, ValidateAntiForgeryToken] + public async Task Logout() + { + await HttpContext.SignOutAsync(); + HttpContext.Session.Clear(); + return RedirectToAction("Login"); + } + + // GET /Account/ForgotPassword + [HttpGet] + public IActionResult ForgotPassword() => View(); + + // POST /Account/ForgotPassword + [HttpPost, ValidateAntiForgeryToken] + public async Task ForgotPassword(string usernameOrEmail) + { + if (string.IsNullOrWhiteSpace(usernameOrEmail)) + { + ModelState.AddModelError("", "Champ requis."); + return View(); + } + + var user = await _db.Users + .FirstOrDefaultAsync(u => u.Username == usernameOrEmail); + + // Par sécurité, on ne révèle pas si l’utilisateur existe + if (user != null) + { + // Génère un token simple (tu peux le hasher si tu veux) + var token = Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + .Replace("+", "-").Replace("/", "_").TrimEnd('='); + + user.ResetToken = token; + user.ResetTokenExpiresAt = DateTimeOffset.UtcNow.AddHours(1); + await _db.SaveChangesAsync(); + + var resetUrl = Url.Action("ResetPassword", "Account", new { token = token }, Request.Scheme); + // TODO: envoyer resetUrl par email. + // Temporaire : on l’affiche + TempData["ResetLink"] = resetUrl; + } + + TempData["Info"] = "Si un compte existe, un lien de réinitialisation a été envoyé."; + return RedirectToAction("ForgotPassword"); + } + + // GET /Account/ResetPassword?token=... + [HttpGet] + public async Task ResetPassword(string token) + { + if (string.IsNullOrWhiteSpace(token)) return RedirectToAction("Login"); + + var user = await _db.Users.FirstOrDefaultAsync(u => + u.ResetToken == token && u.ResetTokenExpiresAt > DateTimeOffset.UtcNow); + + if (user == null) + { + TempData["Error"] = "Lien invalide ou expiré."; + return RedirectToAction("ForgotPassword"); + } + + ViewBag.Token = token; + return View(); + } + + // POST /Account/ResetPassword + [HttpPost, ValidateAntiForgeryToken] + public async Task ResetPassword(string token, string newPassword, string confirmPassword) + { + if (string.IsNullOrWhiteSpace(token)) + { + TempData["Error"] = "Token manquant."; + return RedirectToAction("ForgotPassword"); + } + if (string.IsNullOrWhiteSpace(newPassword) || newPassword != confirmPassword) + { + ModelState.AddModelError("", "Les mots de passe ne correspondent pas."); + ViewBag.Token = token; + return View(); + } + + var user = await _db.Users.FirstOrDefaultAsync(u => + u.ResetToken == token && u.ResetTokenExpiresAt > DateTimeOffset.UtcNow); + + if (user == null) + { + TempData["Error"] = "Lien invalide ou expiré."; + return RedirectToAction("ForgotPassword"); + } + + user.PasswordHash = _hasher.HashPassword(user, newPassword); + user.ResetToken = null; + user.ResetTokenExpiresAt = null; + await _db.SaveChangesAsync(); + + TempData["Ok"] = "Mot de passe réinitialisé. Connecte-toi."; + return RedirectToAction("Login"); + } + + // (Optionnel) création rapide d’un compte admin si tu n’as rien en BDD + [HttpPost, ValidateAntiForgeryToken] + public async Task SeedAdmin(string username = "admin", string password = "ChangeMe!123") + { + if (await _db.Users.AnyAsync()) return BadRequest("Déjà des utilisateurs en BDD."); + var u = new User { Username = username }; + u.PasswordHash = _hasher.HashPassword(u, password); + _db.Users.Add(u); + await _db.SaveChangesAsync(); + return Ok("Admin créé."); + } +} diff --git a/Controllers/AppController.cs b/Controllers/AppController.cs new file mode 100644 index 0000000..896c418 --- /dev/null +++ b/Controllers/AppController.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +public abstract class AppController : Controller +{ + public override void OnActionExecuting(ActionExecutingContext context) + { + var uid = HttpContext.Session.GetInt32("UserId"); + var uname = HttpContext.Session.GetString("UserName"); + + ViewBag.UserId = uid; + ViewBag.UserName = uname; + + base.OnActionExecuting(context); + } +} diff --git a/Controllers/ConnectionsController.cs b/Controllers/ConnectionsController.cs new file mode 100644 index 0000000..b51a1de --- /dev/null +++ b/Controllers/ConnectionsController.cs @@ -0,0 +1,108 @@ +using System.Security.Claims; +using administration.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace administration.Controllers +{ + public class ConnectionsController : Controller + { + private readonly LayoutDataContext _db; + private readonly IPasswordHasher _hasher; + + public ConnectionsController(LayoutDataContext db, IPasswordHasher hasher) + { + _db = db; + _hasher = hasher; + } + + // --- PAGE DE LOGIN --- + [HttpGet] + [AllowAnonymous] + public IActionResult Login() => View(); + + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task Login(string username, string password, string? returnUrl = null) + { + // 1) Recherche de l'utilisateur + var user = _db.Users.FirstOrDefault(u => u.Username == username); + if (user == null) + { + ViewBag.Error = "Nom d’utilisateur ou mot de passe incorrect."; + return View(); + } + + // 2) Vérification du mot de passe + var verification = _hasher.VerifyHashedPassword(user, user.PasswordHash ?? "", password); + if (verification != PasswordVerificationResult.Success && + verification != PasswordVerificationResult.SuccessRehashNeeded) + { + ViewBag.Error = "Nom d’utilisateur ou mot de passe incorrect."; + return View(); + } + + // 3) Si rehash nécessaire → on sauvegarde + if (verification == PasswordVerificationResult.SuccessRehashNeeded) + { + user.PasswordHash = _hasher.HashPassword(user, password); + _db.SaveChanges(); + } + + // 4) Sauvegarde dans la session (si besoin pour ton code existant) + HttpContext.Session.SetInt32("UserId", user.Id); + HttpContext.Session.SetString("UserName", user.Username); + + // 5) Création du cookie d'authentification + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username) + }; + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity)); + + // 6) Redirection sécurisée + if (!string.IsNullOrWhiteSpace(returnUrl) && + Url.IsLocalUrl(returnUrl) && + !returnUrl.Contains("/Connections/Login", StringComparison.OrdinalIgnoreCase)) + { + return LocalRedirect(returnUrl); + } + + return RedirectToAction("Index", "Home"); + } + + // --- DECONNEXION --- + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Logout() + { + await HttpContext.SignOutAsync(); // supprime le cookie + HttpContext.Session.Clear(); // vide la session + return RedirectToAction("Login", "Connections"); + } + + // --- MISE À JOUR DU MOT DE PASSE --- + // ⚠️ On met [Authorize] pour éviter un accès public + [HttpPost] + [Authorize] + public IActionResult SetPassword(string username, string newPassword) + { + var user = _db.Users.FirstOrDefault(u => u.Username == username); + if (user == null) + return Content("❌ Utilisateur introuvable"); + + user.PasswordHash = _hasher.HashPassword(user, newPassword); + _db.SaveChanges(); + + return Content("✅ Mot de passe mis à jour avec succès"); + } + } +} diff --git a/Controllers/ExpenseController.cs b/Controllers/Finances/ExpenseController.cs similarity index 83% rename from Controllers/ExpenseController.cs rename to Controllers/Finances/ExpenseController.cs index cb30571..56642a3 100644 --- a/Controllers/ExpenseController.cs +++ b/Controllers/Finances/ExpenseController.cs @@ -1,10 +1,10 @@ - -using administration.Models; +using administration.Models.Finances; using administration.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace administration.Controllers +namespace administration.Controllers.Finances { /// /// Contrôleur API pour la gestion des dépenses de l'utilisateur. @@ -13,6 +13,7 @@ namespace administration.Controllers /// [ApiController] [Route("api/[controller]")] + [Authorize] public class ExpenseController : Controller { private readonly FinancesContext _context; @@ -112,6 +113,29 @@ namespace administration.Controllers return StatusCode(500, $"Erreur serveur : {ex.Message}, Méthode GetAdditionalRevenues"); } } + + + /// + /// Récupère les dépenses depuis la base de données avec l'id X. + /// + /// L'id de la dépense + /// La bonne dépense + [HttpGet("expense_by_id")] + public IActionResult GetExpenseByID(int id) + { + try + { + Expense expense = _context.Expenses + .Where(r => r.Id == id) + .FirstOrDefault(); + + return Ok(expense); + } + catch (Exception ex) + { + return StatusCode(500, $"Erreur serveur : {ex.Message}, Méthode GetRevenueByID"); + } + } } } diff --git a/Controllers/RevenueController.cs b/Controllers/Finances/FinancesController.cs similarity index 78% rename from Controllers/RevenueController.cs rename to Controllers/Finances/FinancesController.cs index 6066bba..64f9ee3 100644 --- a/Controllers/RevenueController.cs +++ b/Controllers/Finances/FinancesController.cs @@ -1,17 +1,21 @@ using Microsoft.AspNetCore.Mvc; -using administration.Models; using administration.Services; +using administration.Models.Finances; +using Microsoft.AspNetCore.Authorization; -namespace administration.Controllers +namespace administration.Controllers.Finances { - [ApiController] - [Route("api/[controller]")] - public class RevenueController : ControllerBase + [Authorize] + public class FinancesController : Controller { + public IActionResult Index() + { + return View(); + } private readonly FinancesContext _context; private readonly IUserSessionService _userSession; - public RevenueController(FinancesContext context, IUserSessionService userSession) + public FinancesController(FinancesContext context, IUserSessionService userSession) { _context = context; _userSession = userSession; @@ -122,5 +126,28 @@ namespace administration.Controllers } + /// + /// Récupère les revenues depuis la base de données avec l'id X. + /// + /// L'id du revenue + /// Le bon revenue + [HttpGet("revenues_by_id")] + public IActionResult GetRevenueByID(int id) + { + try + { + Revenue revenue = _context.Revenues + .Where(r => r.Id == id) + .FirstOrDefault(); + + return Ok(revenue); + } + catch (Exception ex) + { + return StatusCode(500, $"Erreur serveur : {ex.Message}, Méthode GetRevenueByID"); + } + } + + } } diff --git a/Controllers/HelloFresh/HelloFreshController.cs b/Controllers/HelloFresh/HelloFreshController.cs new file mode 100644 index 0000000..b129adf --- /dev/null +++ b/Controllers/HelloFresh/HelloFreshController.cs @@ -0,0 +1,1109 @@ +// 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 ProxyPdf([FromQuery] string url, [FromServices] IHttpClientFactory httpClientFactory) + { + if (string.IsNullOrWhiteSpace(url)) return BadRequest("URL manquante."); + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return BadRequest("URL invalide."); + + var allowedHosts = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "www.hellofresh.fr","hellofresh.fr","img.hellofresh.com","assets.hellofresh.com", + "cdn.hellofresh.com","images.ctfassets.net" + }; + if (!allowedHosts.Contains(uri.Host)) return BadRequest($"Domaine non autorisé: {uri.Host}"); + + try + { + var client = httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/pdf"); + client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("fr-FR,fr;q=0.9,en;q=0.8"); + + using var resp = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + 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 AggregateIngredients(IEnumerable items, HashSet owned) + { + var map = new Dictionary(StringComparer.Ordinal); + + foreach (var it in items) + { + var name = (it.Name ?? "").Trim(); + if (string.IsNullOrEmpty(name)) continue; + + var kName = name.ToLowerInvariant(); + var (val, unit) = ParseQuantity(it.Quantity); + var unitKey = (unit ?? "").ToLowerInvariant(); + + if (val is null) + { + var k = $"{kName}||{it.Quantity}"; + if (!map.ContainsKey(k)) + map[k] = (null, unitKey, new IngredientItemDto { Name = name, Quantity = it.Quantity, Image = it.Image, Owned = owned.Contains(kName) }); + continue; + } + + var key = $"{kName}##{unitKey}"; + if (!map.TryGetValue(key, out var acc)) + { + map[key] = (val, unitKey, new IngredientItemDto { Name = name, Quantity = it.Quantity, Image = it.Image, Owned = owned.Contains(kName) }); + } + else + { + var newSum = (acc.sum ?? 0) + val.Value; + var keep = acc.item; + if (string.IsNullOrWhiteSpace(keep.Image) && !string.IsNullOrWhiteSpace(it.Image)) + keep.Image = it.Image; + map[key] = (newSum, unitKey, keep); + } + } + + var outList = new List(); + foreach (var kv in map) + { + var (sum, unit, item) = kv.Value; + if (sum is not null) + { + var qty = ((sum % 1.0) == 0.0) + ? sum.Value.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + : sum.Value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture); + item.Quantity = string.IsNullOrWhiteSpace(unit) ? qty : $"{qty} {unit}"; + } + outList.Add(item); + } + return outList; + } + + [HttpGet("AggregatedIngredients")] + public IActionResult AggregatedIngredients() + { + var userId = HttpContext.Session.GetInt32("UserId"); + if (userId == null) return Unauthorized("Utilisateur non connecté."); + + var 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()); + + 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(); + 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 Items { get; set; } = new(); + public SmartSearchDebug? Debug { get; set; } + } + public sealed class SmartSearchDebug + { + public object? Sent { get; set; } + public string? Llm { get; set; } + } + + [HttpPost("SmartSearch")] + public async Task SmartSearch( + [FromBody] AiRequestDto body, + [FromServices] IHttpClientFactory httpClientFactory, + [FromQuery] bool debug = false) + { + var userId = HttpContext.Session.GetInt32("UserId"); + if (userId == null) return Unauthorized("Utilisateur non connecté."); + + // 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(), 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(); + 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(); + + // 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(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(); + 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() + }) + .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() }); + + // 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(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 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(StringComparer.Create(new System.Globalization.CultureInfo("fr-FR"), true)); + foreach (var json in _context.Recettes.AsNoTracking().Select(r => r.Ingredients ?? "{}")) + { + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Object) continue; + + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (prop.Value.ValueKind != JsonValueKind.Array) continue; + foreach (var el in prop.Value.EnumerateArray()) + { + var n = el.TryGetProperty("Name", out var nEl) ? (nEl.GetString() ?? "").Trim() : ""; + if (!string.IsNullOrWhiteSpace(n)) names.Add(n); + } + } + } + catch { /* ignore */ } + } + + var list = names.OrderBy(s => s, StringComparer.Create(new System.Globalization.CultureInfo("fr-FR"), true)).ToList(); + return Ok(list); + } + + [HttpGet("GetAllTags")] + public IActionResult GetAllTags() + { + var tags = new HashSet(StringComparer.Create(new System.Globalization.CultureInfo("fr-FR"), true)); + foreach (var row in _context.Recettes.AsNoTracking().Select(r => r.Tags)) + { + if (string.IsNullOrWhiteSpace(row)) continue; + foreach (var t in Regex.Split(row, @"[,•]")) + { + var v = (t ?? "").Trim(); + if (string.IsNullOrEmpty(v) || v == "•") continue; + tags.Add(v); + } + } + var list = tags.OrderBy(s => s, StringComparer.Create(new System.Globalization.CultureInfo("fr-FR"), true)).ToList(); + return Ok(list); + } + // API: recettes choisies distinctes par nom, avec PDF + [HttpGet("GetOwnedForReader")] + public IActionResult GetOwnedForReader() + { + var userId = HttpContext.Session.GetInt32("UserId"); + if (userId == null) return Unauthorized("Utilisateur non connecté."); + + // 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()); + + // 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); + } +} \ No newline at end of file diff --git a/Controllers/HelloFresh/HelloFreshScraperService.cs b/Controllers/HelloFresh/HelloFreshScraperService.cs new file mode 100644 index 0000000..2acaf45 --- /dev/null +++ b/Controllers/HelloFresh/HelloFreshScraperService.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using AngleSharp; +using AngleSharp.Dom; +using Newtonsoft.Json.Linq; + +namespace HelloFreshScraper.Services +{ + public class Recipe + { + public string Id { get; set; } + public string Name { get; set; } + public string Image { get; set; } + public string Pdf { get; set; } + public string PrepTime { get; set; } // ex: "30-35 min" + public string Difficulty { get; set; } // ex: "Intermédiaire" + public string Description { get; set; } // ex: "Accompagné de..." + public string Label { get; set; } + } + + public class HelloFreshScraperService + { + private readonly HttpClient _httpClient; + private readonly IBrowsingContext _context; + + public HelloFreshScraperService(HttpClient httpClient) + { + _httpClient = httpClient; + var config = Configuration.Default.WithDefaultLoader(); + _context = BrowsingContext.New(config); + } + + public async Task> GetRecipesAsync(string locale = "fr-fr", int startPage = 1, int pagesToLoad = 2) + { + var recipesDict = new Dictionary(); + var config = Configuration.Default.WithDefaultLoader(); + var context = BrowsingContext.New(config); + + for (int page = startPage; page < startPage + pagesToLoad; page++) + { + var url = $"https://hfresh.info/{locale}?page={page}"; + var html = await _httpClient.GetStringAsync(url); + + var document = await context.OpenAsync(req => req.Content(html)); + var rawData = document.QuerySelector("#app")?.GetAttribute("data-page"); + + if (string.IsNullOrWhiteSpace(rawData)) continue; + + var parsed = JObject.Parse(rawData); + var recipeArray = parsed.SelectToken("props.recipes.data") as JArray; + + foreach (var item in recipeArray ?? new JArray()) + { + var id = item["id"]?.ToString(); + var name = item["name"]?.ToString(); + var pdf = item["pdf"]?.ToString(); + + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(name) || string.IsNullOrEmpty(pdf)) + continue; + + if (recipesDict.ContainsKey(id)) continue; // éviter les doublons + + var nameKey = name.ToLower().Trim(); + if (recipesDict.Values.Any(r => r.Name.ToLower().Trim() == nameKey)) continue; + + + var recipe = new Recipe + { + Id = id, + Name = name, + Image = item["image"]?.ToString(), + Pdf = pdf, + Description = item["headline"]?.ToString(), + Label = item["label"]?.ToString() + }; + + recipesDict[id] = recipe; + } + } + + // 👉 Scrape en parallèle les pages de détail + var tasks = recipesDict.Values.Select(async recipe => + { + var slug = GenerateSlug(recipe.Name); + var url = $"https://www.hellofresh.fr/recipes/{slug}-{recipe.Id}"; + + try + { + var html = await _httpClient.GetStringAsync(url); + + // PrepTime (Regex simple, ex: "35 minutes") + var match = Regex.Match(html, @"(\d{1,3})\s*minutes?", RegexOptions.IgnoreCase); + if (match.Success) + recipe.PrepTime = match.Groups[1].Value; + + // Difficulty + var diffMatch = Regex.Match(html, @"]*data-translation-id=[""']recipe-detail\.level-number[^>]*>([^<]+)", RegexOptions.IgnoreCase); + if (diffMatch.Success) + recipe.Difficulty = diffMatch.Groups[1].Value.Trim(); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Erreur scraping {url} : {ex.Message}"); + } + }); + + await Task.WhenAll(tasks); // 🧠 Attendre que tous les détails soient récupérés + + return recipesDict.Values.ToList(); + } + + + + + + + private string GenerateSlug(string name) + { + var slug = name.ToLower() + .Replace("é", "e").Replace("è", "e").Replace("ê", "e") + .Replace("à", "a").Replace("â", "a").Replace("ù", "u") + .Replace("î", "i").Replace("ô", "o").Replace("ç", "c") + .Replace("œ", "oe").Replace("&", "et") + .Replace("’", "-").Replace("'", "-") + .Replace("\"", "").Replace(",", "").Replace(":", "") + .Replace("!", "").Replace("?", "").Replace("(", "").Replace(")", "") + .Replace(" ", " ").Replace(" ", "-"); + + while (slug.Contains("--")) + slug = slug.Replace("--", "-"); + + return slug.Trim('-'); + } + } +} diff --git a/Controllers/HomeController.cs b/Controllers/HomeController.cs index b0683da..df0d2c9 100644 --- a/Controllers/HomeController.cs +++ b/Controllers/HomeController.cs @@ -1,46 +1,63 @@ -using System.Diagnostics; -using Microsoft.AspNetCore.Mvc; +using System.Diagnostics; +using System.Security.Claims; using administration.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; -namespace administration.Controllers; - -public class HomeController : Controller +namespace administration.Controllers { - private readonly FinancesContext _context; - - public HomeController(FinancesContext context) + [Authorize] // toute la classe nécessite une connexion + public class HomeController : Controller { - _context = context; - } + private readonly ILogger _logger; - public IActionResult Index() - { - // Rcupre un utilisateur fictif pour lexemple - var user = _context.Users.Where(X => X.Id == 2).First(); // remplacer par un filtre rel (par ex. Email ou Id) - - if (user != null) + public HomeController(ILogger logger) { - // Stocker dans la session - HttpContext.Session.SetInt32("UserId", user.Id); - HttpContext.Session.SetString("UserName", user.Name); + _logger = logger; } - return View(); - } - public IActionResult Profile() - { - int? userId = HttpContext.Session.GetInt32("UserId"); - string userName = HttpContext.Session.GetString("UserName"); + public IActionResult Index() + { + EnsureSessionFromClaims(); + return View(); + } - ViewBag.UserId = userId; - ViewBag.UserName = userName; + public IActionResult Profile() + { + EnsureSessionFromClaims(); - return View(); - } + ViewBag.UserId = HttpContext.Session.GetInt32("UserId"); + ViewBag.UserName = HttpContext.Session.GetString("UserName"); - [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] - public IActionResult Error() - { - return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); + return View(); + } + + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + public IActionResult Error() + { + return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); + } + + /// + /// Si la session est vide, recharge UserId et UserName depuis les claims + /// + private void EnsureSessionFromClaims() + { + if (!HttpContext.Session.Keys.Contains("UserId") || !HttpContext.Session.Keys.Contains("UserName")) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + var userNameClaim = User.FindFirstValue(ClaimTypes.Name); + + if (!string.IsNullOrEmpty(userIdClaim) && int.TryParse(userIdClaim, out int userId)) + { + HttpContext.Session.SetInt32("UserId", userId); + } + + if (!string.IsNullOrEmpty(userNameClaim)) + { + HttpContext.Session.SetString("UserName", userNameClaim); + } + } + } } } diff --git a/Controllers/OllamaController.cs b/Controllers/OllamaController.cs new file mode 100644 index 0000000..77bd11d --- /dev/null +++ b/Controllers/OllamaController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc; +using IngredientsAI.Services; + +namespace IngredientsAI.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class OllamaController : ControllerBase + { + private readonly OllamaService _ollama; + + public OllamaController(OllamaService ollama) + { + _ollama = ollama; + } + + [HttpGet("models")] + public async Task GetModels() + { + try + { + var models = await _ollama.GetModelsAsync(); + return Ok(new { items = models }); + } + catch (Exception ex) + { + return BadRequest(new { error = ex.Message }); + } + } + } +} diff --git a/Controllers/OllamaService.cs b/Controllers/OllamaService.cs new file mode 100644 index 0000000..7418274 --- /dev/null +++ b/Controllers/OllamaService.cs @@ -0,0 +1,43 @@ +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; + +namespace IngredientsAI.Services +{ + public class OllamaService + { + private readonly HttpClient _http; + private readonly IConfiguration _config; + + public OllamaService(IConfiguration config) + { + _config = config; + var handler = new HttpClientHandler + { + // Ignore SSL errors (utile pour ton VPS si certificat pas reconnu en local) + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + + _http = new HttpClient(handler) + { + BaseAddress = new Uri(_config["Ollama:Url"] ?? "https://ollama.byakurepo.online") + }; + } + + // Exemple : récupère la liste des modèles disponibles + public async Task> GetModelsAsync() + { + var res = await _http.GetAsync("/api/tags"); + res.EnsureSuccessStatusCode(); + + var json = await res.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + return doc.RootElement + .GetProperty("models") + .EnumerateArray() + .Select(m => m.GetProperty("name").GetString() ?? string.Empty) + .ToList(); + } + } +} diff --git a/Controllers/PagesController.cs b/Controllers/PagesController.cs deleted file mode 100644 index fd9df2c..0000000 --- a/Controllers/PagesController.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ViewEngines; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.ViewFeatures; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using System.IO; - -namespace administration.Controllers -{ - public class PagesController : Controller - { - private readonly ICompositeViewEngine _viewEngine; - private readonly ITempDataProvider _tempDataProvider; - - public PagesController(ICompositeViewEngine viewEngine, ITempDataProvider tempDataProvider) - { - _viewEngine = viewEngine; - _tempDataProvider = tempDataProvider; - } - - [HttpGet("/pages/{folder}/{page}")] - public async Task Render(string folder, string page) - { - var allowedFolders = new[] { "Charts", "Components", "Connections", "Home", "Tables", "Utilities" }; - - var realFolder = allowedFolders.FirstOrDefault(f => string.Equals(f, folder, StringComparison.OrdinalIgnoreCase)); - if (realFolder == null) - return Content("Ce dossier n'est pas autorisé.", "text/plain; charset=utf-8"); - - // Trouver la vraie casse du fichier sur disque - var folderPath = Path.Combine(Directory.GetCurrentDirectory(), "Views", realFolder); - var cshtmlFile = Directory.GetFiles(folderPath, "*.cshtml") - .FirstOrDefault(f => string.Equals(Path.GetFileNameWithoutExtension(f), page, StringComparison.OrdinalIgnoreCase)); - - if (cshtmlFile == null) - return NotFound($"La vue ~/Views/{realFolder}/{page}.cshtml n'existe pas physiquement."); - - var razorViewPath = $"~/Views/{realFolder}/{Path.GetFileName(cshtmlFile)}"; - var result = _viewEngine.GetView(null, razorViewPath, true); - - if (!result.Success) - return NotFound($"Vue Razor non trouvée : {razorViewPath}"); - - - var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()); - var tempData = new TempDataDictionary(HttpContext, _tempDataProvider); - - using var sw = new StringWriter(); - var context = new ViewContext( - ControllerContext, - result.View, - viewData, - tempData, - sw, - new HtmlHelperOptions() - ); - - await result.View.RenderAsync(context); - return Content(sw.ToString(), "text/html"); - } - } -} diff --git a/Controllers/SearchController.cs b/Controllers/SearchController.cs new file mode 100644 index 0000000..e377865 --- /dev/null +++ b/Controllers/SearchController.cs @@ -0,0 +1,504 @@ +//// Controllers/SearchController.cs +//using Microsoft.AspNetCore.Mvc; +//using Microsoft.Extensions.Logging; +//using System.Net.Http.Headers; +//using System.Text; +//using System.Text.Json; +//using System.Text.Json.Serialization; +//using System.Text.RegularExpressions; + +//namespace Administration.Controllers +//{ +// [ApiController] +// [Route("api/ai")] +// public class SearchController : ControllerBase +// { +// private readonly IHttpClientFactory _httpFactory; +// private readonly IConfiguration _cfg; +// private readonly ILogger _log; + +// public SearchController(IHttpClientFactory httpFactory, IConfiguration cfg, ILogger log) +// { +// _httpFactory = httpFactory; +// _cfg = cfg; +// _log = log; +// } + +// // ========================= +// // DTOs +// // ========================= +// public sealed class AiSelectRequest +// { +// public string Query { get; set; } = ""; +// public List Allowed { get; set; } = new(); +// public bool Debug { get; set; } = false; +// } + +// public sealed class AiSelectResponse +// { +// [JsonPropertyName("names")] +// public List Names { get; set; } = new(); + +// [JsonPropertyName("debug")] +// public object? Debug { get; set; } +// } + +// // ========================= +// // POST /api/ai/select +// // ========================= +// [HttpPost("select")] +// public async Task Select([FromBody] AiSelectRequest body) +// { +// if (body is null) return BadRequest(new { error = "Body requis." }); +// if (string.IsNullOrWhiteSpace(body.Query)) return BadRequest(new { error = "Query requis." }); +// if (body.Allowed is null || body.Allowed.Count == 0) +// { +// _log.LogWarning("AI Select: Allowed vide pour query '{Query}'", body.Query); +// return Ok(new AiSelectResponse { Names = new() }); +// } + +// // Normalisation unique +// var allowed = body.Allowed +// .Where(a => !string.IsNullOrWhiteSpace(a)) +// .Select(a => a.Trim()) +// .Distinct(StringComparer.OrdinalIgnoreCase) +// .ToList(); + +// // --- 1) Raccourcis instantanés : catégories & synonymes riches +// var catHits = TryCategoryExpand(body.Query, allowed); +// if (catHits.Count > 0) +// return Ok(new AiSelectResponse { Names = SortByAllowedOrder(catHits, allowed) }); + +// // --- 2) Match "classique" tolérant (FR/EN, accents, pluriels) +// var classicHits = ClassicMatch(body.Query, allowed); +// if (classicHits.Count > 0) +// return Ok(new AiSelectResponse { Names = SortByAllowedOrder(classicHits, allowed) }); + +// // --- 3) Heuristique "prompt" -> on tente l'IA (court) +// var ollamaUrl = _cfg["Ollama:Url"] ?? "http://ollama:11434"; +// var model = _cfg["Ollama:Model"] ?? "qwen2.5:1.5b-instruct"; + +// // prompt ultra directif pour forcer un tableau JSON plat +// var prompt = +// $"Liste autorisée: [{string.Join(",", allowed.Select(a => $"\"{a}\""))}].\n" + +// $"Requête: \"{body.Query}\".\n" + +// "Réponds UNIQUEMENT par un tableau JSON plat des éléments EXACTEMENT issus de la liste autorisée. " + +// "Exemple: [\"Tomate\",\"Oignon\"]. Interdit: objets {name:...}, clés ingredients:, texte hors JSON."; + +// var client = _httpFactory.CreateClient(); +// client.Timeout = Timeout.InfiniteTimeSpan; // on contrôle avec CTS + +// var payload = new +// { +// model, +// prompt, +// stream = false, +// format = "json", +// keep_alive = "10m", +// options = new +// { +// temperature = 0.0, +// num_ctx = 64, +// num_predict = 64 +// } +// }; + +// var req = new HttpRequestMessage(HttpMethod.Post, $"{ollamaUrl.TrimEnd('/')}/api/generate"); +// req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); +// req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + +// _log.LogInformation("AI Select ▶ Query='{Query}', AllowedCount={AllowedCount}, Model={Model}, Url={Url}", +// body.Query, allowed.Count, model, ollamaUrl); +// if (body.Debug) +// { +// _log.LogDebug("AI Select ▶ Prompt envoyé:\n{Prompt}", payload.prompt); +// } + +// // Si la requête ressemble à une phrase libre -> 4s, sinon 2s +// bool prompty = body.Query.Length >= 18 +// || Regex.IsMatch(body.Query, @"\b(avec|sans|pour|idée|idées|recette|faire|préparer|quoi|envie)\b", RegexOptions.IgnoreCase); +// using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(prompty ? 4 : 2)); + +// try +// { +// using var resp = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token); +// var raw = await resp.Content.ReadAsStringAsync(); + +// _log.LogInformation("AI Select ◀ HTTP {Status} ({Reason})", (int)resp.StatusCode, resp.ReasonPhrase); +// _log.LogDebug("AI Select ◀ RAW:\n{Raw}", raw); + +// if (!resp.IsSuccessStatusCode) +// { +// _log.LogWarning("AI Select: statut non OK, on renvoie l’erreur au client."); +// return StatusCode((int)resp.StatusCode, new +// { +// error = "IA error", +// raw, +// url = $"{ollamaUrl.TrimEnd('/')}/api/generate" +// }); +// } + +// // Extraire "response" puis convertir en liste de chaînes +// using var doc = JsonDocument.Parse(raw); +// var responseText = doc.RootElement.TryGetProperty("response", out var rEl) +// ? (rEl.GetString() ?? "") +// : ""; + +// var iaNames = ExtractNames(responseText); +// var mapped = MapToAllowed(iaNames, allowed); + +// return Ok(new AiSelectResponse +// { +// Names = SortByAllowedOrder(mapped, allowed), +// Debug = body.Debug ? new { sent = payload, url = $"{ollamaUrl.TrimEnd('/')}/api/generate", raw } : null +// }); +// } +// catch (TaskCanceledException) +// { +// _log.LogWarning("AI Select: timeout côté Ollama ({Url})", ollamaUrl); +// return StatusCode(504, new { error = "Timeout IA" }); +// } +// catch (Exception ex) +// { +// _log.LogError(ex, "AI Select: exception pendant l’appel Ollama."); +// return StatusCode(500, new { error = ex.Message }); +// } +// } + +// // ========================= +// // Helpers — Normalisation +// // ========================= +// static string RemoveDiacritics(string s) +// { +// if (string.IsNullOrEmpty(s)) return ""; +// var formD = s.Normalize(NormalizationForm.FormD); +// var sb = new StringBuilder(formD.Length); +// foreach (var ch in formD) +// { +// var uc = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(ch); +// if (uc != System.Globalization.UnicodeCategory.NonSpacingMark) +// sb.Append(ch); +// } +// return sb.ToString().Normalize(NormalizationForm.FormC); +// } + +// static string Key(string? s) +// { +// s ??= ""; +// var noAcc = RemoveDiacritics(s).ToLowerInvariant(); +// // retire ponctuation + normalise espaces +// noAcc = Regex.Replace(noAcc, @"[^\p{L}\p{Nd}\s]", " "); +// noAcc = Regex.Replace(noAcc, @"\s+", " ").Trim(); +// // singulier approximatif (enlève 's' final si pertinent) +// if (noAcc.EndsWith("s") && noAcc.Length > 3) noAcc = noAcc[..^1]; +// return noAcc; +// } + +// static List SortByAllowedOrder(IEnumerable items, List allowed) +// { +// var set = new HashSet(items, StringComparer.OrdinalIgnoreCase); +// var ordered = new List(); +// foreach (var a in allowed) +// if (set.Contains(a)) ordered.Add(a); +// return ordered; +// } + +// // ========================= +// // Helpers — Matching local +// // ========================= + +// // Synonymes simples EN -> FR (complète librement) +// static readonly Dictionary EN_FR_SYNONYMS = new(StringComparer.OrdinalIgnoreCase) +// { +// ["meat"] = new[] { "viande", "viandes", "boeuf", "bœuf", "porc", "poulet", "agneau", "veau", "saucisse", "jambon" }, +// ["fish"] = new[] { "poisson", "saumon", "thon", "cabillaud", "lieu", "filet de lieu", "colin", "merlu" }, +// ["spicy"] = new[] { "piquant", "épicé", "piment", "harissa", "sriracha", "curry", "paprika", "poivre", "gingembre" }, +// ["fresh"] = new[] { "frais", "fraîche", "fraîches", "réfrigéré", "perissable", "périssable" }, +// ["dairy"] = new[] { "laitier", "laitiers", "lait", "beurre", "fromage", "crème", "yaourt" }, +// ["herbs"] = new[] { "herbes", "persil", "ciboulette", "basilic", "menthe", "aneth", "romarin", "thym" }, +// ["sauce"] = new[] { "sauce", "sauces", "condiment", "condiments", "mayonnaise", "ketchup", "moutarde", "soja", "sauce soja", "BBQ" }, +// ["veggie"] = new[] { "légume", "légumes", "tomate", "oignon", "carotte", "brocoli", "courgette", "poivron", "ail", "salade" }, +// ["protein"] = new[] { "protéine", "protéines", "oeuf", "œuf", "tofu", "tempeh", "pois chiches", "lentilles", "haricots", "poulet", "fromage" }, +// }; + +// static List ClassicMatch(string query, List allowed) +// { +// var nq = Key(query); +// var tokens = nq.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + +// // EN->FR élargit +// var expanded = new HashSet(tokens); +// foreach (var t in tokens) +// if (EN_FR_SYNONYMS.TryGetValue(t, out var frs)) +// foreach (var fr in frs) expanded.Add(Key(fr)); + +// var hits = new List(); +// foreach (var a in allowed) +// { +// if (AnyTokenMatches(a, expanded)) +// hits.Add(a); +// } + +// return hits.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); +// } + + +// // ========================= +// // Catégories & règles +// // ========================= +// sealed class CategoryRule +// { +// public string Name { get; init; } = ""; +// public string[] Triggers { get; init; } = Array.Empty(); +// public string[] Must { get; init; } = Array.Empty(); +// public string[] Any { get; init; } = Array.Empty(); +// public string[] Not { get; init; } = Array.Empty(); +// } + +// static readonly List CATEGORY_RULES = new() +// { +// new CategoryRule { +// Name = "legumes", +// Triggers = new[]{"legume","légume","légumes","vegetable","veggie","crudité"}, +// Any = new[]{ +// "oignon","carotte","pomme de terre","brocoli","courgette", +// "poivron","tomate","ail","salade","champignon","haricot vert","épinard" +// }, +// Not = new[]{ +// // viande/charcuterie +// "saucisse","porc","poulet","boeuf","bœuf","agneau","veau","jambon","lardon","charcuterie","filet", +// // poisson/mer +// "poisson","saumon","thon","lieu","cabillaud","merlu","colin","crevette","moule", +// // laitier/fromage/œuf +// "fromage","crème","lait","yaourt","œuf","oeuf","beurre" +// } +// }, +// new CategoryRule { +// Name = "fruits", +// Triggers = new[]{"fruit","fruits","dessert","sucré"}, +// Any = new[]{"pomme","banane","orange","citron","citron vert","fraise","poire","raisin","tomate"} +// }, +// new CategoryRule { +// Name = "epices", +// Triggers = new[]{"epice","épice","epices","épices","spice","spicy","assaisonnement"}, +// Any = new[]{"cumin","paprika","curcuma","vadouvan","curry","poivre","piment","gingembre","muscade","thym"} +// }, +// new CategoryRule { +// Name = "herbes", +// Triggers = new[]{"herbe","herbes","aromatique","aromatiques"}, +// Any = new[]{"persil","ciboulette","basilic","menthe","aneth","romarin","thym","coriandre"} +// }, +// new CategoryRule { +// Name = "laitiers", +// Triggers = new[]{"laitier","laitiers","dairy","fromager","crémier"}, +// Any = new[]{"lait","beurre","fromage","crème","yaourt","crème aigre","mozzarella","parmesan","lait de coco"} +// }, +// new CategoryRule { +// Name = "viandes", +// Triggers = new[]{"viande","viandes","carné","meat","steak","charcuterie"}, +// Any = new[]{"boeuf","bœuf","porc","poulet","dinde","agneau","veau","saucisse","jambon","lardons","filet de porc"} +// }, +// new CategoryRule { +// Name = "poissons", +// Triggers = new[]{"poisson","poissons","seafood","poissonnerie","poissonnier"}, +// Any = new[]{"saumon","thon","lieu","filet de lieu","cabillaud","merlu","colin","crevette","moule"} +// }, +// new CategoryRule { +// Name = "proteines", +// Triggers = new[]{"protéine","protéines","protein","proteins"}, +// Any = new[]{"oeuf","œuf","tofu","tempeh","pois chiches","lentilles","haricots","saucisse","jambon","poulet","filet de porc","fromage"} +// }, +// new CategoryRule { +// Name = "frais", +// Triggers = new[]{"frais","fraiche","fraîche","fraîches","réfrigéré","frigo","perissable","périssable"}, +// Any = new[]{"salade","tomate","concombre","yaourt","fromage","beurre","crème","herbes","viande","poisson"} +// }, +// new CategoryRule { +// Name = "piquant", +// Triggers = new[]{"piquant","pimenté","fort","épicé","spicy","hot"}, +// Any = new[]{"piment","harissa","sriracha","tabasco","curry","paprika","gingembre","poivre","pâte de curry","pâte de curry vert"} +// }, +// new CategoryRule { +// Name = "sauces", +// Triggers = new[]{"sauce","sauces","condiment","condiments","topping"}, +// Any = new[]{"mayonnaise","ketchup","moutarde","soja","sauce soja","worcestershire","BBQ","harissa","sriracha","pâte de curry","pâte de curry vert","lait de coco","crème"} +// }, +// new CategoryRule { +// Name = "condiments", +// Triggers = new[]{"condiment","condiments","pickles","vinaigre","cornichon"}, +// Any = new[]{"moutarde","vinaigre","huile","cornichon","câpres","poivre","sel"} +// }, +// new CategoryRule { +// Name = "cereales", +// Triggers = new[]{"céréale","céréales","grains","cereal"}, +// Any = new[]{"riz","quinoa","semoule","boulgour","avoine","farine"} +// }, +// new CategoryRule { +// Name = "pates", +// Triggers = new[]{"pate","pâtes","nouilles","pasta","spaghetti"}, +// Any = new[]{"pâtes","nouilles","nouilles de riz","spaghetti","penne","farfalle"} +// }, +// new CategoryRule { +// Name = "sandwich", +// Triggers = new[]{"sandwich","sandwichs","sandwiches","wrap","tacos","kebab","burger"}, +// Any = new[]{"pain","salade","tomate","oignon","fromage","jambon","saucisse","poulet","sauce","cornichon"} +// }, +// new CategoryRule { +// Name = "sucre", +// Triggers = new[]{"sucre","sucré","dessert","patisserie","pâtisserie","gâteau"}, +// Any = new[]{"sucre","chocolat","vanille","beurre","farine","lait","œuf","oeuf","pomme","banane","crème"} +// }, +// }; + +// static List TryCategoryExpand(string query, List allowed) +// { +// var nq = Key(query); +// var hits = new List(); + +// foreach (var rule in CATEGORY_RULES) +// { +// if (!rule.Triggers.Any(t => nq.Contains(Key(t)))) continue; + +// var allowedHits = allowed.Where(a => +// { +// // normalisé dans les helpers +// bool anyOk = rule.Any.Length == 0 || AnyTokenMatches(a, rule.Any); +// bool mustOk = rule.Must.Length == 0 || AllTokensMatch(a, rule.Must); +// bool notOk = rule.Not.Length > 0 && AnyTokenMatches(a, rule.Not); +// return anyOk && mustOk && !notOk; +// }); + +// hits.AddRange(allowedHits); +// } + +// return hits.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); +// } + +// // ========================= +// // Helpers — Parsing IA +// // ========================= +// static List ExtractNames(string responseText) +// { +// // 1) tableau JSON brut -> ["...","..."] +// try +// { +// var arr = JsonSerializer.Deserialize(responseText); +// if (arr is not null && arr.Length > 0) +// return arr.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); +// } +// catch { /* not an array */ } + +// // 2) objet { "ingredients": [...] } ou { "ingrédients": [...] } +// try +// { +// using var inner = JsonDocument.Parse(responseText); +// if (inner.RootElement.TryGetProperty("ingredients", out var arr1) && arr1.ValueKind == JsonValueKind.Array) +// return arr1.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); + +// if (inner.RootElement.TryGetProperty("ingrédients", out var arr2) && arr2.ValueKind == JsonValueKind.Array) +// return arr2.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); +// } +// catch { /* not an object */ } + +// // 3) JSON encodé dans une string (rare mais vu chez Qwen) +// var mObj = Regex.Match(responseText, @"\{[\s\S]*\}"); +// if (mObj.Success) +// { +// try +// { +// using var objDoc = JsonDocument.Parse(mObj.Value); +// if (objDoc.RootElement.TryGetProperty("ingredients", out var arr1) && arr1.ValueKind == JsonValueKind.Array) +// return arr1.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); + +// if (objDoc.RootElement.TryGetProperty("ingrédients", out var arr2) && arr2.ValueKind == JsonValueKind.Array) +// return arr2.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); +// } +// catch { /* ignore */ } +// } + +// // 4) fallback : extrait le premier [...] plausible +// var mArr = Regex.Match(responseText, @"\[[\s\S]*?\]"); +// if (mArr.Success) +// { +// try +// { +// var arr = JsonSerializer.Deserialize(mArr.Value); +// if (arr is not null && arr.Length > 0) +// return arr.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); +// } +// catch { /* ignore */ } +// } + +// return new List(); +// } + +// static List MapToAllowed(IEnumerable names, List allowed) +// { +// var mapAllowed = new Dictionary(StringComparer.OrdinalIgnoreCase); +// foreach (var a in allowed) +// { +// var k = Key(a); +// if (!mapAllowed.ContainsKey(k)) +// mapAllowed[k] = a; +// } + +// var result = new List(); +// var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + +// foreach (var n in names) +// { +// var k = Key(n); +// if (mapAllowed.TryGetValue(k, out var official)) +// { +// if (seen.Add(official)) +// result.Add(official); +// } +// } +// return result; +// } + +// // --- Helpers de matching précis --- +// static bool ContainsPhraseOrWord(string haystack, string token) +// { +// // les deux sont déjà normalisés via Key() avant appel +// if (string.IsNullOrWhiteSpace(token)) return false; + +// if (token.Contains(' ')) +// { +// // multi-mots : on garde le contains sur la phrase normalisée +// return haystack.Contains(token); +// } + +// // 1 mot : on exige un match sur "mot entier" +// // ex: "oignon" ne doit pas faire matcher "saucisse de porc ... aux oignons caramélisés" +// var words = haystack.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +// foreach (var w in words) +// if (w.Equals(token, StringComparison.Ordinal)) return true; + +// return false; +// } + +// static bool AnyTokenMatches(string haystack, IEnumerable tokens) +// { +// var h = Key(haystack); +// foreach (var t in tokens) +// { +// var tok = Key(t); +// if (ContainsPhraseOrWord(h, tok)) return true; +// } +// return false; +// } + +// static bool AllTokensMatch(string haystack, IEnumerable tokens) +// { +// var h = Key(haystack); +// foreach (var t in tokens) +// { +// var tok = Key(t); +// if (!ContainsPhraseOrWord(h, tok)) return false; +// } +// return true; +// } + +// } +//} diff --git a/Models/AdditionalSource.cs b/Models/Finances/AdditionalSource.cs similarity index 92% rename from Models/AdditionalSource.cs rename to Models/Finances/AdditionalSource.cs index d0bfe15..ad690cc 100644 --- a/Models/AdditionalSource.cs +++ b/Models/Finances/AdditionalSource.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace administration.Models; +namespace administration.Models.Finances; public partial class AdditionalSource { diff --git a/Models/Expense.cs b/Models/Finances/Expense.cs similarity index 92% rename from Models/Expense.cs rename to Models/Finances/Expense.cs index 9a25c55..42f83e3 100644 --- a/Models/Expense.cs +++ b/Models/Finances/Expense.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace administration.Models; +namespace administration.Models.Finances; public partial class Expense { diff --git a/Models/FinancesContext.cs b/Models/Finances/FinancesContext.cs similarity index 99% rename from Models/FinancesContext.cs rename to Models/Finances/FinancesContext.cs index 6c9f50e..a796a80 100644 --- a/Models/FinancesContext.cs +++ b/Models/Finances/FinancesContext.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using Microsoft.EntityFrameworkCore; -namespace administration.Models; +namespace administration.Models.Finances; public partial class FinancesContext : DbContext { diff --git a/Models/Logo.cs b/Models/Finances/Logo.cs similarity index 88% rename from Models/Logo.cs rename to Models/Finances/Logo.cs index c248409..8f39099 100644 --- a/Models/Logo.cs +++ b/Models/Finances/Logo.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace administration.Models; +namespace administration.Models.Finances; public partial class Logo { diff --git a/Models/Revenue.cs b/Models/Finances/Revenue.cs similarity index 85% rename from Models/Revenue.cs rename to Models/Finances/Revenue.cs index 5b89a16..97b29d3 100644 --- a/Models/Revenue.cs +++ b/Models/Finances/Revenue.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace administration.Models; +namespace administration.Models.Finances; public partial class Revenue { diff --git a/Models/Finances/User.cs b/Models/Finances/User.cs new file mode 100644 index 0000000..15ff4da --- /dev/null +++ b/Models/Finances/User.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace administration.Models.Finances; + +public partial class User +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; + + public virtual ICollection AdditionalSources { get; set; } = new List(); + + public virtual ICollection Expenses { get; set; } = new List(); + + public virtual ICollection Revenues { get; set; } = new List(); +} diff --git a/Models/HelloFresh/DTO/IngredientItemDto.cs b/Models/HelloFresh/DTO/IngredientItemDto.cs new file mode 100644 index 0000000..2c8c33f --- /dev/null +++ b/Models/HelloFresh/DTO/IngredientItemDto.cs @@ -0,0 +1,9 @@ +namespace administration.Models.HelloFresh.DTO; + +public class IngredientItemDto +{ + public string Name { get; set; } = ""; + public string? Quantity { get; set; } + public string? Image { get; set; } + public bool Owned { get; set; } +} diff --git a/Models/HelloFresh/DTO/SmartSearchResponse.cs b/Models/HelloFresh/DTO/SmartSearchResponse.cs new file mode 100644 index 0000000..c8cc52c --- /dev/null +++ b/Models/HelloFresh/DTO/SmartSearchResponse.cs @@ -0,0 +1,16 @@ +using administration.Models.HelloFresh.DTO; + +namespace administration.Models.DTO +{ + public class SmartSearchResponse + { + public List Items { get; set; } = new(); + public SmartSearchDebug Debug { get; set; } + } + + public class SmartSearchDebug + { + public object Sent { get; set; } + public string Llm { get; set; } + } +} diff --git a/Models/HelloFresh/HelloFreshContext.cs b/Models/HelloFresh/HelloFreshContext.cs new file mode 100644 index 0000000..aa6e6f7 --- /dev/null +++ b/Models/HelloFresh/HelloFreshContext.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; + +namespace administration.Models.HelloFresh; + +public partial class HelloFreshContext : DbContext +{ + public HelloFreshContext() + { + } + + public HelloFreshContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet HistoriqueRecettes { get; set; } + + public virtual DbSet Ingredients { get; set; } + + public virtual DbSet Recettes { get; set; } + + public virtual DbSet SavingRecettes { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) +#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see https://go.microsoft.com/fwlink/?LinkId=723263. + => optionsBuilder.UseSqlServer("Server=217.154.116.43;Database=HelloFresh;User Id=sa;Password=Mf33ksTRLrPKSqQ4cTXitgiSN6BPBt89;TrustServerCertificate=True;"); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.Property(e => e.Id).HasColumnName("id"); + // Map DateOnly -> date SQL + entity.Property(e => e.DateHistorique) + .HasConversion( + v => v.ToDateTime(TimeOnly.MinValue), // vers SQL + v => DateOnly.FromDateTime(v) // depuis SQL + ) + .HasColumnType("date"); + entity.Property(e => e.RecetteId).HasColumnName("recetteId"); + entity.Property(e => e.UserId).HasColumnName("userId"); + }); + + modelBuilder.Entity(entity => + { + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.NameOwnedIngredients).HasColumnName("nameOwnedIngredients"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + + entity.Property(e => e.Description).HasColumnName("description"); + entity.Property(e => e.Difficulte) + .HasMaxLength(50) + .HasColumnName("difficulte"); + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.Image).HasColumnName("image"); + entity.Property(e => e.Ingredients).HasColumnName("ingredients"); + entity.Property(e => e.IngredientsToHad).HasColumnName("ingredientsToHad"); + entity.Property(e => e.Kcal) + .HasMaxLength(10) + .IsFixedLength() + .HasColumnName("kcal"); + entity.Property(e => e.Name).HasColumnName("name"); + entity.Property(e => e.Pdf).HasColumnName("pdf"); + entity.Property(e => e.Preference).HasColumnName("preference"); + entity.Property(e => e.Proteines) + .HasMaxLength(10) + .IsFixedLength() + .HasColumnName("proteines"); + entity.Property(e => e.Slug).HasColumnName("slug"); + entity.Property(e => e.SupplementText).HasColumnName("supplementText"); + entity.Property(e => e.Tags).HasColumnName("tags"); + entity.Property(e => e.TempsDePreparation).HasColumnName("tempsDePreparation"); + }); + + modelBuilder.Entity(entity => + { + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.IdSavingRecette).HasColumnName("idSavingRecette"); + entity.Property(e => e.UserId).HasColumnName("userId"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/Models/HelloFresh/HistoriqueRecette.cs b/Models/HelloFresh/HistoriqueRecette.cs new file mode 100644 index 0000000..eec2206 --- /dev/null +++ b/Models/HelloFresh/HistoriqueRecette.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace administration.Models.HelloFresh; + +public partial class HistoriqueRecette +{ + public int Id { get; set; } + + public string RecetteId { get; set; } = ""; // <-- string pour matcher Recette.Id + + public int UserId { get; set; } + + public DateOnly DateHistorique { get; set; } +} diff --git a/Models/HelloFresh/Ingredient.cs b/Models/HelloFresh/Ingredient.cs new file mode 100644 index 0000000..0b86cc2 --- /dev/null +++ b/Models/HelloFresh/Ingredient.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace administration.Models.HelloFresh; + +public partial class Ingredient +{ + public int Id { get; set; } + + public string NameOwnedIngredients { get; set; } = null!; +} diff --git a/Models/HelloFresh/Recette.cs b/Models/HelloFresh/Recette.cs new file mode 100644 index 0000000..b0be181 --- /dev/null +++ b/Models/HelloFresh/Recette.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace administration.Models.HelloFresh; + +public partial class Recette +{ + public string Id { get; set; } = null!; + + public string? Name { get; set; } + + public string? SupplementText { get; set; } + + public string? Description { get; set; } + + public string? Tags { get; set; } + + public string? Ingredients { get; set; } + + public int? Preference { get; set; } + + public string? Image { get; set; } + + public string? Difficulte { get; set; } + + public int? TempsDePreparation { get; set; } + + public string? Kcal { get; set; } + + public string? Proteines { get; set; } + + public string? Slug { get; set; } + + public string? Pdf { get; set; } + + public string? IngredientsToHad { get; set; } +} diff --git a/Models/HelloFresh/SavingRecette.cs b/Models/HelloFresh/SavingRecette.cs new file mode 100644 index 0000000..618121d --- /dev/null +++ b/Models/HelloFresh/SavingRecette.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace administration.Models.HelloFresh; + +public partial class SavingRecette +{ + public int Id { get; set; } + + public string IdSavingRecette { get; set; } = null!; + + public int UserId { get; set; } +} diff --git a/Models/LayoutDataContext.cs b/Models/LayoutDataContext.cs new file mode 100644 index 0000000..e179891 --- /dev/null +++ b/Models/LayoutDataContext.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; + +namespace administration.Models; + +public partial class LayoutDataContext : DbContext +{ + public LayoutDataContext() + { + } + + public LayoutDataContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet Users { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) +#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see https://go.microsoft.com/fwlink/?LinkId=723263. + => optionsBuilder.UseSqlServer("Server=217.154.116.43,1433;Database=LayoutData;User Id=sa;Password=Mf33ksTRLrPKSqQ4cTXitgiSN6BPBt89;TrustServerCertificate=True;"); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("PK__Users__3214EC0774A20142"); + + entity.HasIndex(e => e.Username, "UX_Users_Username").IsUnique(); + + entity.Property(e => e.CreatedAt).HasDefaultValueSql("(sysdatetimeoffset())"); + entity.Property(e => e.PasswordHash).HasMaxLength(512); + entity.Property(e => e.ResetToken).HasMaxLength(256); + entity.Property(e => e.Username).HasMaxLength(100); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/Models/User.cs b/Models/User.cs index 5f12a8d..c883a28 100644 --- a/Models/User.cs +++ b/Models/User.cs @@ -7,11 +7,13 @@ public partial class User { public int Id { get; set; } - public string Name { get; set; } = null!; + public string Username { get; set; } = null!; - public virtual ICollection AdditionalSources { get; set; } = new List(); + public string PasswordHash { get; set; } = null!; - public virtual ICollection Expenses { get; set; } = new List(); + public string? ResetToken { get; set; } - public virtual ICollection Revenues { get; set; } = new List(); + public DateTimeOffset? ResetTokenExpiresAt { get; set; } + + public DateTimeOffset CreatedAt { get; set; } } diff --git a/Program.cs b/Program.cs index 9495d12..ed89239 100644 --- a/Program.cs +++ b/Program.cs @@ -1,8 +1,13 @@ using administration.Models; +using administration.Models.Finances; +using administration.Models.HelloFresh; using administration.Services; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; +using IngredientsAI.Services; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using User = administration.Models.User; namespace administration { @@ -10,51 +15,65 @@ namespace administration { public static void Main(string[] args) { - // Charger les variables d'environnement depuis .env DotNetEnv.Env.Load(); - var builder = WebApplication.CreateBuilder(args); // ============================================== - // 1️⃣ Configurer la base de données + // 1️⃣ Base de données // ============================================== var dbConnection = Environment.GetEnvironmentVariable("ADMIN_DB_CONNECTION"); if (string.IsNullOrEmpty(dbConnection)) - throw new Exception("❌ ADMIN_DB_CONNECTION est introuvable dans les variables d'environnement."); + throw new Exception("❌ ADMIN_DB_CONNECTION est introuvable."); builder.Services.AddDbContext(options => - options.UseSqlServer(dbConnection, sqlOptions => sqlOptions.EnableRetryOnFailure()) - ); + options.UseSqlServer(dbConnection, sqlOptions => sqlOptions.EnableRetryOnFailure())); + + builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("HelloFreshConnection"))); + + builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); AppSettings.Initialize(builder.Configuration); + builder.Services.AddSingleton(); // ============================================== - // 2️⃣ Ajouter la session + // 2️⃣ Session // ============================================== builder.Services.AddSession(options => { - options.IdleTimeout = TimeSpan.FromMinutes(300); // Expiration session + options.IdleTimeout = TimeSpan.FromHours(8); options.Cookie.HttpOnly = true; options.Cookie.IsEssential = true; }); // ============================================== - // 3️⃣ Ajouter l’authentification Basic + // 3️⃣ Authentification par cookie // ============================================== - builder.Services.AddAuthentication("BasicAuthentication") - .AddScheme("BasicAuthentication", null); + builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/Connections/Login"; + options.AccessDeniedPath = "/Connections/Login"; + options.SlidingExpiration = true; + options.ExpireTimeSpan = TimeSpan.FromDays(14); + }); builder.Services.AddAuthorization(); + builder.Services.AddControllers().AddJsonOptions(o => + { + o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }); // ============================================== - // 4️⃣ Ajouter MVC + dépendances + // 4️⃣ MVC + services // ============================================== builder.Services.AddControllersWithViews(); builder.Services.AddHttpContextAccessor(); - builder.Services.AddScoped(); + builder.Services.AddScoped, PasswordHasher>(); // ============================================== - // 5️⃣ CORS pour le frontend + // 5️⃣ CORS // ============================================== builder.Services.AddCors(options => { @@ -66,14 +85,31 @@ namespace administration }); }); - // ============================================== - // 6️⃣ Construire l'application - // ============================================== - var app = builder.Build(); + builder.Services.AddHttpClient(); // déjà présent chez toi + + builder.Services.AddHttpClient("ollama", (sp, client) => + { + var cfg = sp.GetRequiredService(); + var baseUrl = cfg["Ollama:Url"] ?? "http://ollama:11434"; + client.BaseAddress = new Uri(baseUrl); + client.Timeout = TimeSpan.FromSeconds(25); + }) +#if DEBUG + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + #endif + + // ... + // ... + builder.Logging.AddFilter("administration.Services.BasicAuthenticationHandler", LogLevel.Warning); + + var app = builder.Build(); // ============================================== - // 7️⃣ Middleware Pipeline - // ============================================== + // 6️⃣ Pipeline + if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Home/Error"); @@ -85,14 +121,11 @@ namespace administration app.UseRouting(); app.UseCors("AllowFrontend"); - app.UseSession(); - app.UseAuthentication(); + app.UseSession(); // ✅ toujours avant auth + app.UseAuthentication(); // ✅ s'applique à tout le site app.UseAuthorization(); - // ============================================== - // 8️⃣ Routes - // ============================================== app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); diff --git a/Services/BasicAuthenticationHandler.cs b/Services/BasicAuthenticationHandler.cs index df843ee..97ba1a8 100644 --- a/Services/BasicAuthenticationHandler.cs +++ b/Services/BasicAuthenticationHandler.cs @@ -18,8 +18,7 @@ namespace administration.Services protected override Task HandleAuthenticateAsync() { - if (!Request.Headers.ContainsKey("Authorization")) - return Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header")); + if (!Request.Headers.ContainsKey("Authorization")) return Task.FromResult(AuthenticateResult.NoResult()); try { diff --git a/Views/Charts/Charts.cshtml b/Views/Charts/Charts.cshtml deleted file mode 100644 index c654ac9..0000000 --- a/Views/Charts/Charts.cshtml +++ /dev/null @@ -1,492 +0,0 @@ - - - - - - - - - - - - SB Admin 2 - Charts - - - - - - - - - - - - - -
- - - - - - -
- - -
- - - - - - -
- - -

Charts

-

Chart.js is a third party plugin that is used to generate the charts in this theme. - The charts below have been customized - for further customization options, please visit the official Chart.js - documentation.

- - -
- -
- - -
-
-
Area Chart
-
-
-
- -
-
- Styling for the area chart can be found in the - /js/demo/chart-area-demo.js file. -
-
- - -
-
-
Bar Chart
-
-
-
- -
-
- Styling for the bar chart can be found in the - /js/demo/chart-bar-demo.js file. -
-
- -
- - -
-
- -
-
Donut Chart
-
- -
-
- -
-
- Styling for the donut chart can be found in the - /js/demo/chart-pie-demo.js file. -
-
-
-
- -
- - -
- - - -
-
- -
-
- - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Views/Components/Buttons.cshtml b/Views/Components/Buttons.cshtml deleted file mode 100644 index 5a3b4dc..0000000 --- a/Views/Components/Buttons.cshtml +++ /dev/null @@ -1,599 +0,0 @@ - - - - - - - - - - - - SB Admin 2 - Buttons - - - - - - - - - - - - - -
- - - - - - -
- - -
- - - - - - -
- - -

Buttons

- -
- -
- - -
-
-
Circle Buttons
-
-
-

Use Font Awesome Icons (included with this theme package) along with the circle - buttons as shown in the examples below!

- -
- .btn-circle -
- - - - - - - - - - - - - - - - -
- .btn-circle .btn-sm -
- - - - - - - - - - - - - - - - -
- .btn-circle .btn-lg -
- - - - - - - - - - - - - - - -
-
- - -
-
-
Brand Buttons
-
-
-

Google and Facebook buttons are available featuring each company's respective - brand color. They are used on the user login and registration pages.

-

You can create more custom buttons by adding a new color variable in the - _variables.scss file and then using the Bootstrap button variant - mixin to create a new style, as demonstrated in the _buttons.scss - file.

- - .btn-google - .btn-facebook - -
-
- -
- -
- -
-
-
Split Buttons with Icon
-
-
-

Works with any button colors, just use the .btn-icon-split class and - the markup in the examples below. The examples below also use the - .text-white-50 helper class on the icons for additional styling, - but it is not required.

- - - - - Split Button Primary - -
- - - - - Split Button Success - -
- - - - - Split Button Info - -
- - - - - Split Button Warning - -
- - - - - Split Button Danger - -
- - - - - Split Button Secondary - -
- - - - - Split Button Light - -
-

Also works with small and large button classes!

- - - - - Split Button Small - -
- - - - - Split Button Large - -
-
- -
- -
- -
- - -
- - - -
-
- -
-
- - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Views/Components/Cards.cshtml b/Views/Components/Cards.cshtml deleted file mode 100644 index d65c9e6..0000000 --- a/Views/Components/Cards.cshtml +++ /dev/null @@ -1,593 +0,0 @@ - - - - - - - - - - - - SB Admin 2 - Cards - - - - - - - - - - - - - -
- - - - - - -
- - -
- - - - - - -
- - -
-

Cards

-
- -
- - -
-
-
-
-
-
- Earnings (Monthly)
-
$40,000
-
-
- -
-
-
-
-
- - -
-
-
-
-
-
- Earnings (Annual)
-
$215,000
-
-
- -
-
-
-
-
- - -
-
-
-
-
-
Tasks -
-
-
-
50%
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
- - -
-
-
-
-
-
- Pending Requests
-
18
-
-
- -
-
-
-
-
-
- -
- -
- - -
-
- Default Card Example -
-
- This card uses Bootstrap's default styling with no utility classes added. Global - styles are the only things modifying the look and feel of this default card example. -
-
- - -
-
-
Basic Card Example
-
-
- The styling for this basic card example is created by using default Bootstrap - utility classes. By using utility classes, the style of the card component can be - easily modified with no need for any custom CSS! -
-
- -
- -
- - -
- -
-
Dropdown Card Example
- -
- -
- Dropdown menus can be placed in the card header in order to extend the functionality - of a basic card. In this dropdown card example, the Font Awesome vertical ellipsis - icon in the card header can be clicked on in order to toggle a dropdown menu. -
-
- - -
- - -
Collapsable Card Example
-
- -
-
- This is a collapsable card example using Bootstrap's built in collapse - functionality. Click on the card header to see the card body - collapse and expand! -
-
-
- -
- -
- -
- - -
- - - -
-
- -
-
- - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Views/Connections/3tenwfbf.n2i~ b/Views/Connections/3tenwfbf.n2i~ new file mode 100644 index 0000000..e69de29 diff --git a/Views/Connections/4idojod2.b45~ b/Views/Connections/4idojod2.b45~ new file mode 100644 index 0000000..e69de29 diff --git a/Views/Connections/ForgotPassword.cshtml b/Views/Connections/ForgotPassword.cshtml index 5f198c1..293aebd 100644 --- a/Views/Connections/ForgotPassword.cshtml +++ b/Views/Connections/ForgotPassword.cshtml @@ -39,6 +39,14 @@
+ @if (TempData["Info"] != null) + { +
@TempData["Info"]
+ } + @if (TempData["ResetLink"] != null) + { +
Lien (temporaire) : @TempData["ResetLink"]
+ }

Forgot Your Password?

We get it, stuff happens. Just enter your email address below and we'll send you a link to reset your password!

diff --git a/Views/Connections/Login.cshtml b/Views/Connections/Login.cshtml index f4fa58e..ab43c04 100644 --- a/Views/Connections/Login.cshtml +++ b/Views/Connections/Login.cshtml @@ -2,6 +2,9 @@ + @{ + Layout = null; + } @@ -11,19 +14,27 @@ SB Admin 2 - Login + + + - - + - - + + + + + + + + - +
@@ -39,43 +50,30 @@
-

Welcome Back!

+

+ Welcome Back! +

-
+ + @Html.AntiForgeryToken() + +
- +
+
- +
-
-
- - -
-
- - Login - -
- - Login with Google - - - Login with Facebook - + +
-
- - + +
@Html.ValidationSummary(false)
@@ -88,15 +86,20 @@
- - - - - + + + + - - + + + + + + + + diff --git a/Views/Connections/d5e0rmn2.n54~ b/Views/Connections/d5e0rmn2.n54~ new file mode 100644 index 0000000..e69de29 diff --git a/Views/Connections/dcmtrkdm.j5g~ b/Views/Connections/dcmtrkdm.j5g~ new file mode 100644 index 0000000..e69de29 diff --git a/Views/Connections/jr3n0uyh.xig~ b/Views/Connections/jr3n0uyh.xig~ new file mode 100644 index 0000000..e69de29 diff --git a/Views/Connections/qutlkkki.ndb~ b/Views/Connections/qutlkkki.ndb~ new file mode 100644 index 0000000..e69de29 diff --git a/Views/Connections/wqomdnjo.1g3~ b/Views/Connections/wqomdnjo.1g3~ new file mode 100644 index 0000000..e69de29 diff --git a/Views/Connections/xdmdr5nz.az0~ b/Views/Connections/xdmdr5nz.az0~ new file mode 100644 index 0000000..e69de29 diff --git a/Views/Connections/ylzqhfgf.znz~ b/Views/Connections/ylzqhfgf.znz~ new file mode 100644 index 0000000..e69de29 diff --git a/Views/Connections/zzbblwmn.g2p~ b/Views/Connections/zzbblwmn.g2p~ new file mode 100644 index 0000000..e69de29 diff --git a/Views/Finances/Index.cshtml b/Views/Finances/Index.cshtml new file mode 100644 index 0000000..bbdc1e9 --- /dev/null +++ b/Views/Finances/Index.cshtml @@ -0,0 +1,572 @@ +@{ + ViewData["Title"] = "Home Page"; +} + + @ViewData["Title"] - administration + + + + + + + + +
+ + +
+ + + + +
+ + + +
+ + +
+

Dépenses

+
+ +
+ + +
+
+
+
+
+
+ LOYER +
+
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+
+ ORDURE +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+ ASSURANCE +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+ WIFI +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+ ELECTRICITE +
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ COURSES +
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ ABONNEMENTS +
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ Autres dépenses +
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ Mis de côté +
+
+
+
+ +
+
+
+
+
+
+ +
+

Revenues

+
+ +
+ +
+
+
+
+
+
+ SALAIRE +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+ Autres gains +
+
+
+
+ +
+
+
+
+
+ +
+ + @*
*@ + + + +@* +
+ + +
+
+ +
+
Earnings Overview
+ +
+ +
+
+ +
+
+
+
+ + +
+
+ +
+
Revenue Sources
+ +
+ +
+
+ +
+
+ + Direct + + + Social + + + Referral + +
+
+
+
+
+ +
+ + +
+ + +
+
+
Projects
+
+
+

+ Server Migration 20% +

+
+
+
+

+ Sales Tracking 40% +

+
+
+
+

+ Customer Database 60% +

+
+
+
+

+ Payout Details 80% +

+
+
+
+

+ Account Setup Complete! +

+
+
+
+
+
+ + +
+
+
+
+ Primary +
#4e73df
+
+
+
+
+
+
+ Success +
#1cc88a
+
+
+
+
+
+
+ Info +
#36b9cc
+
+
+
+
+
+
+ Warning +
#f6c23e
+
+
+
+
+
+
+ Danger +
#e74a3b
+
+
+
+
+
+
+ Secondary +
#858796
+
+
+
+
+
+
+ Light +
#f8f9fc
+
+
+
+
+
+
+ Dark +
#5a5c69
+
+
+
+
+ +
+ +
+ + +
+
+
Illustrations
+
+
+
+ ... +
+

+ Add some quality, svg illustrations to your project courtesy of unDraw, a + constantly updated collection of beautiful svg images that you can use + completely free and without attribution! +

+ + Browse Illustrations on + unDraw → + +
+
+ + +
+
+
Development Approach
+
+
+

+ SB Admin 2 makes extensive use of Bootstrap 4 utility classes in order to reduce + CSS bloat and poor page performance. Custom CSS classes are used to create + custom components and custom utility classes. +

+

+ Before working with this theme, you should become familiar with the + Bootstrap framework, especially the utility classes. +

+
+
+ +
+
*@ + +
+ +
+
+
+
+
+
+ Restants +
+
+
+
+ +
+
+
+
+
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + diff --git a/Views/HelloFresh/Cuisine.cshtml b/Views/HelloFresh/Cuisine.cshtml new file mode 100644 index 0000000..c41c373 --- /dev/null +++ b/Views/HelloFresh/Cuisine.cshtml @@ -0,0 +1,56 @@ +@{ + ViewData["Title"] = "Cuisine"; +} + + @ViewData["Title"] + + + + + + + + + + +
+ + + + +
+
+
+ Sélectionnez une recette +
+
+ +
+
+ +
+ + +
+ Choisissez une recette pour afficher son PDF. +
+
+
+
+ + + + + diff --git a/Views/HelloFresh/Historique.cshtml b/Views/HelloFresh/Historique.cshtml new file mode 100644 index 0000000..79b8c6e --- /dev/null +++ b/Views/HelloFresh/Historique.cshtml @@ -0,0 +1,32 @@ +@{ + ViewData["Title"] = "Historique"; +} + + @ViewData["Title"] + + + + + +
+
+

Historique des recettes

+
+ + + + + +
+
+ +
+
+ + + diff --git a/Views/HelloFresh/Index.cshtml b/Views/HelloFresh/Index.cshtml new file mode 100644 index 0000000..47cdffc --- /dev/null +++ b/Views/HelloFresh/Index.cshtml @@ -0,0 +1,135 @@ +@{ + ViewData["Title"] = "Home Page"; +} + + @ViewData["Title"] - Hello Fresh + + + + + + + + + + + + + + +
+
+ +
+ + + + + +
+ + + +
+ + 1/1 + +
+ + + + + + + + diff --git a/Views/HelloFresh/Ingredients.cshtml b/Views/HelloFresh/Ingredients.cshtml new file mode 100644 index 0000000..9c12689 --- /dev/null +++ b/Views/HelloFresh/Ingredients.cshtml @@ -0,0 +1,42 @@ +@{ + ViewData["Title"] = "Home Page"; +} + + @ViewData["Title"] + + + + + + + + + + +
+
+
+ +
+
+
+

Ingrédients

+
+ + +
+ + +
    + + +
    + + +

    Ingrédients à avoir chez soi

    +
      +
      + + diff --git a/Views/Home/Index.cshtml b/Views/Home/Index.cshtml index b4c39cb..a1e32b8 100644 --- a/Views/Home/Index.cshtml +++ b/Views/Home/Index.cshtml @@ -3,7 +3,7 @@ } @ViewData["Title"] - administration - + @@ -11,819 +11,10 @@ rel="stylesheet"> - -
      - - - - -
      - - -
      - - - - -
      - - -
      -

      Dépenses

      - - Generate Report - -
      - -
      - - -
      -
      -
      -
      -
      -
      - LOYER -
      -
      -
      -
      - -
      -
      -
      -
      -
      - - - -
      -
      -
      -
      -
      -
      - ORDURE -
      -
      -
      -
      - -
      -
      -
      -
      -
      - - -
      -
      -
      -
      -
      -
      - ASSURANCE -
      -
      -
      -
      - -
      -
      -
      -
      -
      - - -
      -
      -
      -
      -
      -
      - WIFI -
      -
      -
      -
      - -
      -
      -
      -
      -
      - - -
      -
      -
      -
      -
      -
      - ELECTRICITE -
      -
      -
      -
      - -
      -
      -
      -
      -
      - -
      -
      -
      -
      -
      -
      - COURSES -
      -
      -
      -
      - -
      -
      -
      -
      -
      - -
      -
      -
      -
      -
      -
      - ABONNEMENTS -
      -
      -
      -
      - -
      -
      -
      -
      -
      - -
      -
      -
      -
      -
      -
      - Autres dépenses -
      -
      -
      -
      - -
      -
      -
      -
      -
      - -
      -
      -
      -
      -
      -
      - Mis de côté -
      -
      -
      -
      - -
      -
      -
      -
      -
      -
      -
      - -
      -
      -
      -
      -
      -
      - SALAIRE -
      -
      -
      -
      - -
      -
      -
      -
      -
      - - -
      -
      -
      -
      -
      -
      - Autres gains -
      -
      -
      -
      - -
      -
      -
      -
      -
      - -
      -
      -
      -
      -
      -
      - Restants -
      -
      -
      -
      - -
      -
      -
      -
      -
      -
      - -
      - - - - -
      - - -
      -
      - -
      -
      Earnings Overview
      - -
      - -
      -
      - -
      -
      -
      -
      - - -
      -
      - -
      -
      Revenue Sources
      - -
      - -
      -
      - -
      -
      - - Direct - - - Social - - - Referral - -
      -
      -
      -
      -
      - -
      - - -
      - - -
      -
      -
      Projects
      -
      -
      -

      - Server Migration 20% -

      -
      -
      -
      -

      - Sales Tracking 40% -

      -
      -
      -
      -

      - Customer Database 60% -

      -
      -
      -
      -

      - Payout Details 80% -

      -
      -
      -
      -

      - Account Setup Complete! -

      -
      -
      -
      -
      -
      - - -
      -
      -
      -
      - Primary -
      #4e73df
      -
      -
      -
      -
      -
      -
      - Success -
      #1cc88a
      -
      -
      -
      -
      -
      -
      - Info -
      #36b9cc
      -
      -
      -
      -
      -
      -
      - Warning -
      #f6c23e
      -
      -
      -
      -
      -
      -
      - Danger -
      #e74a3b
      -
      -
      -
      -
      -
      -
      - Secondary -
      #858796
      -
      -
      -
      -
      -
      -
      - Light -
      #f8f9fc
      -
      -
      -
      -
      -
      -
      - Dark -
      #5a5c69
      -
      -
      -
      -
      - -
      - -
      - - -
      -
      -
      Illustrations
      -
      -
      -
      - ... -
      -

      - Add some quality, svg illustrations to your project courtesy of unDraw, a - constantly updated collection of beautiful svg images that you can use - completely free and without attribution! -

      - - Browse Illustrations on - unDraw → - -
      -
      - - -
      -
      -
      Development Approach
      -
      -
      -

      - SB Admin 2 makes extensive use of Bootstrap 4 utility classes in order to reduce - CSS bloat and poor page performance. Custom CSS classes are used to create - custom components and custom utility classes. -

      -

      - Before working with this theme, you should become familiar with the - Bootstrap framework, especially the utility classes. -

      -
      -
      - -
      -
      - -
      - - -
      - - -
      -
      - -
      -
      - - -
      - - -
      - - - - - - - -