V1.1 met AI functie
This commit is contained in:
@@ -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();
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
Binary file not shown.
@@ -72,17 +72,4 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="text-secondary mt-3">
|
|
||||||
Nog geen account? <a href="/register">Registreren</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<div class="page-header mb-4">
|
||||||
|
<a href="/menu" class="btn btn-link">← 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user