diff --git a/controllers/MenuController.js b/controllers/MenuController.js new file mode 100644 index 0000000..4d8af53 --- /dev/null +++ b/controllers/MenuController.js @@ -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(); diff --git a/routes/web.js b/routes/web.js index 6b518b5..8c42885 100644 --- a/routes/web.js +++ b/routes/web.js @@ -34,6 +34,9 @@ require("../controllers/CategoryController"); const ShoppingListController = require("../controllers/ShoppingListController"); +const MenuController = +require("../controllers/MenuController"); + router.get( "/", auth, @@ -122,6 +125,24 @@ router.get( 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( "/lists", auth, diff --git a/services/MenuService.js b/services/MenuService.js new file mode 100644 index 0000000..c95bb16 --- /dev/null +++ b/services/MenuService.js @@ -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(); diff --git a/sessions.sqlite b/sessions.sqlite index 97304ae..9bd16f5 100644 Binary files a/sessions.sqlite and b/sessions.sqlite differ diff --git a/views/auth/login.ejs b/views/auth/login.ejs index caf6377..2c8a7dc 100644 --- a/views/auth/login.ejs +++ b/views/auth/login.ejs @@ -72,17 +72,4 @@ - - - - - - -
- Nog geen account? Registreren -
- - - - \ No newline at end of file diff --git a/views/menu/index.ejs b/views/menu/index.ejs new file mode 100644 index 0000000..63c31d4 --- /dev/null +++ b/views/menu/index.ejs @@ -0,0 +1,33 @@ +Laat een menu maken en controleer het voordat de boodschappenlijst wordt aangemaakt.
+Deze producten bestaan nog niet en worden aangemaakt:
+Alle producten bestaan al in PantryHub.
+ <% } %> + +