V1.1 met AI functie

This commit is contained in:
2026-08-09 19:25:44 +02:00
parent f3da7dfb8d
commit 1f9fd73026
8 changed files with 323 additions and 13 deletions
+67
View File
@@ -0,0 +1,67 @@
const MenuService = require("../services/MenuService");
class MenuController {
index(req, res) {
res.render("menu/index", { title: "Weekmenu" });
}
async generate(req, res) {
const people = Number.parseInt(req.body.people, 10);
const preferences = {
people: Number.isInteger(people) && people > 0 && people <= 12 ? people : 2,
diet: (req.body.diet || "").trim(),
exclude: (req.body.exclude || "").trim(),
budget: (req.body.budget || "").trim()
};
try {
const menu = await MenuService.generate(preferences);
res.render("menu/preview", {
title: "Weekmenu controleren",
menu,
newProducts: MenuService.getNewProducts(menu),
listName: "Weekboodschappen"
});
} catch (error) {
req.flash("error", error.message);
res.redirect("/menu");
}
}
addToList(req, res) {
let menu;
try {
menu = JSON.parse(req.body.menu || "{}");
} catch (error) {
req.flash("error", "Het menu kan niet worden gelezen.");
return res.redirect("/menu");
}
if (!Array.isArray(menu.days) || menu.days.length === 0) {
req.flash("error", "Het menu is leeg.");
return res.redirect("/menu");
}
const listName = (req.body.list_name || "Weekboodschappen").trim();
if (!listName) {
req.flash("error", "Geef een naam voor de boodschappenlijst op.");
return res.redirect("/menu");
}
const listId = MenuService.addToShoppingList(req.user.household_id, listName, menu);
req.flash("success", "Weekmenu toegevoegd aan de boodschappenlijst.");
res.redirect(`/lists/${listId}`);
}
}
module.exports = new MenuController();
+21
View File
@@ -34,6 +34,9 @@ require("../controllers/CategoryController");
const ShoppingListController = const ShoppingListController =
require("../controllers/ShoppingListController"); require("../controllers/ShoppingListController");
const MenuController =
require("../controllers/MenuController");
router.get( router.get(
"/", "/",
auth, auth,
@@ -122,6 +125,24 @@ router.get(
ShoppingListController.index.bind(ShoppingListController) ShoppingListController.index.bind(ShoppingListController)
); );
router.get(
"/menu",
auth,
MenuController.index.bind(MenuController)
);
router.post(
"/menu/generate",
auth,
MenuController.generate.bind(MenuController)
);
router.post(
"/menu/add",
auth,
MenuController.addToList.bind(MenuController)
);
router.post( router.post(
"/lists", "/lists",
auth, auth,
+157
View File
@@ -0,0 +1,157 @@
const db = require("../config/database");
const ProductRepository = require("../repositories/ProductRepository");
function normalizeName(name) {
return name.trim().toLowerCase().replace(/\s+/g, " ");
}
class MenuService {
async generate(preferences) {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error("OPENAI_API_KEY ontbreekt in de configuratie.");
}
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`
},
body: JSON.stringify({
model: process.env.OPENAI_MODEL || "gpt-4o-mini",
response_format: { type: "json_object" },
messages: [
{
role: "system",
content: "Je maakt praktische weekmenu's. Geef uitsluitend geldige JSON terug met precies de structuur {days:[{day,recipe,ingredients:[{name,amount,unit}]}]}. Gebruik zeven dagen, Nederlandse productnamen, positieve numerieke hoeveelheden en korte recepten."
},
{
role: "user",
content: `Maak een weekmenu voor ${preferences.people} personen. Dieet: ${preferences.diet || "geen specifiek dieet"}. Niet gewenst: ${preferences.exclude || "niets"}. Budget: ${preferences.budget || "geen voorkeur"}.`
}
]
})
});
if (!response.ok) {
let providerMessage = "onbekende fout";
try {
const errorPayload = await response.json();
providerMessage = errorPayload.error?.message || providerMessage;
} catch (error) {
providerMessage = `HTTP ${response.status}`;
}
throw new Error(`AI-service fout (${response.status}): ${providerMessage}`);
}
const payload = await response.json();
if (!payload.choices?.[0]?.message?.content) {
throw new Error("De AI-service gaf geen menu-inhoud terug.");
}
let menu;
try {
menu = JSON.parse(payload.choices?.[0]?.message?.content || "{}");
} catch (error) {
throw new Error("De AI-service gaf geen geldig menu terug.");
}
if (!Array.isArray(menu.days) || menu.days.length === 0) {
throw new Error("De AI-service gaf een leeg menu terug.");
}
menu.days = menu.days.map(day => ({
day: String(day.day || "Dag"),
recipe: String(day.recipe || "Onbekend recept"),
ingredients: Array.isArray(day.ingredients)
? day.ingredients
.filter(item => item && String(item.name || "").trim())
.map(item => ({
name: String(item.name).trim(),
amount: Number.isFinite(Number(item.amount)) ? Number(item.amount) : 1,
unit: String(item.unit || "")
}))
: []
}));
return menu;
}
getNewProducts(menu) {
const existingNames = new Set(
ProductRepository.getAll().map(product => normalizeName(product.name))
);
const seen = new Set();
const newProducts = [];
for (const day of menu.days) {
for (const ingredient of day.ingredients) {
const key = normalizeName(ingredient.name);
if (!existingNames.has(key) && !seen.has(key)) {
seen.add(key);
newProducts.push(ingredient.name);
}
}
}
return newProducts;
}
addToShoppingList(householdId, name, menu) {
const products = ProductRepository.getAll();
const productsByName = new Map(
products.map(product => [normalizeName(product.name), product])
);
const insertProduct = db.prepare(`
INSERT INTO products (name, category_id, barcode) VALUES (?, NULL, NULL)
`);
const insertList = db.prepare(`
INSERT INTO shopping_lists (household_id, name) VALUES (?, ?)
`);
const insertItem = db.prepare(`
INSERT INTO shopping_list_items (list_id, product_id, amount, sort_order)
VALUES (?, ?, ?, ?)
`);
const create = db.transaction(() => {
const listResult = insertList.run(householdId, name);
const listId = listResult.lastInsertRowid;
let sortOrder = 0;
const added = new Set();
for (const day of menu.days) {
for (const ingredient of day.ingredients) {
const key = normalizeName(ingredient.name);
if (added.has(key)) continue;
let product = productsByName.get(key);
if (!product) {
const result = insertProduct.run(ingredient.name);
product = { id: result.lastInsertRowid, name: ingredient.name };
productsByName.set(key, product);
}
const amount = Number(ingredient.amount);
insertItem.run(listId, product.id, amount > 0 ? amount : 1, sortOrder);
added.add(key);
sortOrder += 1;
}
}
return listId;
});
return create();
}
}
module.exports = new MenuService();
BIN
View File
Binary file not shown.
-13
View File
@@ -73,16 +73,3 @@
</div> </div>
</div> </div>
</form>
<p class="text-secondary mt-3">
Nog geen account? <a href="/register">Registreren</a>
</p>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
<div class="page-header mb-4">
<h1>Weekmenu maken</h1>
<p class="text-secondary">Laat een menu maken en controleer het voordat de boodschappenlijst wordt aangemaakt.</p>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header"><h2 class="card-title">Jouw voorkeuren</h2></div>
<div class="card-body">
<form method="post" action="/menu/generate">
<label class="form-label" for="people">Aantal personen</label>
<input id="people" class="form-control mb-3" type="number" name="people"
value="2" min="1" max="12" required>
<label class="form-label" for="diet">Dieet of voorkeur</label>
<input id="diet" class="form-control mb-3" type="text" name="diet"
placeholder="bijv. vegetarisch">
<label class="form-label" for="exclude">Niet gewenst</label>
<input id="exclude" class="form-control mb-3" type="text" name="exclude"
placeholder="bijv. champignons, noten">
<label class="form-label" for="budget">Budget</label>
<input id="budget" class="form-control mb-4" type="text" name="budget"
placeholder="bijv. maximaal 80 euro">
<button class="btn btn-primary" type="submit">Weekmenu maken</button>
</form>
</div>
</div>
</div>
</div>
+44
View File
@@ -0,0 +1,44 @@
<div class="page-header mb-4">
<a href="/menu" class="btn btn-link">&larr; Voorkeuren aanpassen</a>
<h1>Weekmenu controleren</h1>
</div>
<div class="row">
<div class="col-md-8">
<% menu.days.forEach(day => { %>
<div class="card mb-3">
<div class="card-header"><h2 class="card-title"><%= day.day %>: <%= day.recipe %></h2></div>
<div class="card-body">
<ul class="mb-0">
<% day.ingredients.forEach(ingredient => { %>
<li><%= ingredient.amount %> <%= ingredient.unit %> <%= ingredient.name %></li>
<% }); %>
</ul>
</div>
</div>
<% }); %>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header"><h2 class="card-title">Aan boodschappenlijst toevoegen</h2></div>
<div class="card-body">
<% if (newProducts.length > 0) { %>
<p class="text-secondary small">Deze producten bestaan nog niet en worden aangemaakt:</p>
<ul class="small">
<% newProducts.forEach(product => { %><li><%= product %></li><% }); %>
</ul>
<% } else { %>
<p class="text-secondary small">Alle producten bestaan al in PantryHub.</p>
<% } %>
<form method="post" action="/menu/add">
<input type="hidden" name="menu" value="<%= JSON.stringify(menu) %>">
<label class="form-label" for="list-name">Naam boodschappenlijst</label>
<input id="list-name" class="form-control mb-3" type="text" name="list_name"
value="<%= listName %>" maxlength="120" required>
<button class="btn btn-primary w-100" type="submit">Toevoegen aan lijst</button>
</form>
</div>
</div>
</div>
</div>
+1
View File
@@ -26,6 +26,7 @@
<nav class="navbar-nav ms-auto"> <nav class="navbar-nav ms-auto">
<a class="nav-link" href="/">Dashboard</a> <a class="nav-link" href="/">Dashboard</a>
<a class="nav-link" href="/lists">Lijsten</a> <a class="nav-link" href="/lists">Lijsten</a>
<a class="nav-link" href="/menu">Weekmenu</a>
<a class="nav-link" href="/products">Producten</a> <a class="nav-link" href="/products">Producten</a>
<a class="nav-link" href="/categories">Categorieën</a> <a class="nav-link" href="/categories">Categorieën</a>
<a class="nav-link" href="/household">Huishouden</a> <a class="nav-link" href="/household">Huishouden</a>