From ce68182c0855a6facb4f58c79e148b849873c2c8 Mon Sep 17 00:00:00 2001 From: Jurgen Rentinck Date: Fri, 7 Aug 2026 20:44:05 +0200 Subject: [PATCH] Applicatie afmaken en GUI-flow herstellen Voegt boodschappenlijsten, productbeheer, realtime synchronisatie en browsercache-updates toe. Herstelt login- en sessiegedrag en maakt database-seeding herhaalbaar.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 5 ++ app.js | 8 ++ config/express.js | 27 ++++++- config/session.js | 10 ++- controllers/AuthController.js | 19 ++--- controllers/ProductController.js | 33 ++++++-- controllers/ShoppingListController.js | 71 +++++++++++++++++ database/seed.js | 102 +++++-------------------- middleware/auth.js | 4 + public/js/app.js | 11 +++ public/service-worker.js | 43 +++++++++++ repositories/ShoppingListRepository.js | 71 +++++++++++++++++ routes/api.js | 8 ++ routes/web.js | 48 +++++++++++- sockets/handlers.js | 12 +++ views/auth/login.ejs | 4 + views/layouts/main.ejs | 4 +- views/lists/index.ejs | 42 ++++++++++ views/lists/show.ejs | 69 +++++++++++++++++ views/partials/flash.ejs | 12 +++ views/partials/head.ejs | 6 ++ views/partials/navbar.ejs | 10 +++ views/products/index.ejs | 96 ++++++++++------------- 23 files changed, 552 insertions(+), 163 deletions(-) create mode 100644 .gitignore create mode 100644 controllers/ShoppingListController.js create mode 100644 public/js/app.js create mode 100644 public/service-worker.js create mode 100644 repositories/ShoppingListRepository.js create mode 100644 routes/api.js create mode 100644 sockets/handlers.js create mode 100644 views/lists/index.ejs create mode 100644 views/lists/show.ejs create mode 100644 views/partials/flash.ejs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55d728a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +database.sqlite +*.sqlite-shm +*.sqlite-wal diff --git a/app.js b/app.js index 979f0c5..d2207c5 100644 --- a/app.js +++ b/app.js @@ -1,12 +1,20 @@ require("dotenv").config(); const http = require("http"); +const { Server } = require("socket.io"); const createApp = require("./config/express"); +const session = require("./config/session"); +const socketHandlers = require("./sockets/handlers"); const app = createApp(); const server = http.createServer(app); +const io = new Server(server); + +io.use((socket, next) => session(socket.request, {}, next)); +socketHandlers(io); +app.locals.io = io; const PORT = process.env.PORT || 3000; diff --git a/config/express.js b/config/express.js index 35ebbe3..99e2fe5 100644 --- a/config/express.js +++ b/config/express.js @@ -57,9 +57,29 @@ module.exports = () => { app.use(session); +app.use(flash()); + app.use(userMiddleware); -app.use(flash()); +app.use((req, res, next) => { + res.set("X-PantryHub-Worktree", __dirname); + res.locals.user = req.user || null; + res.locals.success = req.flash("success"); + res.locals.error = req.flash("error"); + res.locals.info = req.flash("info"); + res.locals.warning = req.flash("warning"); + res.locals.title = "PantryHub"; + next(); +}); + +app.get("/__version", (req, res) => { + res.set("Cache-Control", "no-store"); + res.json({ + app: "PantryHub", + root: path.join(__dirname, ".."), + pid: process.pid + }); +}); app.use( @@ -69,6 +89,11 @@ app.use(flash()); ); + app.use( + "/api", + require("../routes/api") + ); + app.use( "/", require("../routes/auth") diff --git a/config/session.js b/config/session.js index 0c44709..1e9ee92 100644 --- a/config/session.js +++ b/config/session.js @@ -1,8 +1,9 @@ const session = require("express-session"); const BetterSqlite3Store = require("better-sqlite3-session-store")(session); const Database = require("better-sqlite3"); +const path = require("path"); -const db = new Database("sessions.sqlite"); +const db = new Database(path.join(__dirname, "..", "sessions.sqlite")); module.exports = session({ @@ -14,14 +15,17 @@ module.exports = session({ } }), - secret: process.env.SESSION_SECRET, + secret: process.env.SESSION_SECRET || "change-this-development-secret", resave: false, saveUninitialized: false, cookie: { - maxAge: 1000 * 60 * 60 * 24 * 30 + maxAge: 1000 * 60 * 60 * 24 * 30, + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production" } }); \ No newline at end of file diff --git a/controllers/AuthController.js b/controllers/AuthController.js index dd1f9cd..4dfcaab 100644 --- a/controllers/AuthController.js +++ b/controllers/AuthController.js @@ -19,11 +19,14 @@ class AuthController { async register(req, res) { - const { - name, - email, - password - } = req.body; + const name = (req.body.name || "").trim(); + const email = (req.body.email || "").trim().toLowerCase(); + const password = req.body.password || ""; + + if (name.length < 2 || !/^\S+@\S+\.\S+$/.test(email) || password.length < 8) { + req.flash("error", "Vul een naam, geldig e-mailadres en een wachtwoord van minimaal 8 tekens in."); + return res.redirect("/register"); + } const existingUser = db.prepare( @@ -97,10 +100,8 @@ class AuthController { async login(req, res) { - const { - email, - password - } = req.body; + const email = (req.body.email || "").trim().toLowerCase(); + const password = req.body.password || ""; const user = db.prepare( diff --git a/controllers/ProductController.js b/controllers/ProductController.js index c39bf1a..c359175 100644 --- a/controllers/ProductController.js +++ b/controllers/ProductController.js @@ -1,29 +1,50 @@ const ProductService = require("../services/ProductService"); +const db = require("../config/database"); class ProductController { - index(req,res) { - + index(req, res) { const products = ProductService.getProducts(); + const categories = db.prepare( + "SELECT * FROM categories ORDER BY name" + ).all(); res.render( "products/index", { - title:"Producten", - - products - + products, + categories } + ); + } + create(req, res) { + const name = (req.body.name || "").trim(); + const categoryId = req.body.category_id + ? Number.parseInt(req.body.category_id, 10) + : null; + + if (!name || (categoryId !== null && !Number.isInteger(categoryId))) { + req.flash("error", "Geef een geldige productnaam en categorie op."); + return res.redirect("/products"); + } + + ProductService.createProduct({ + name, + category_id: categoryId + }); + + req.flash("success", "Product toegevoegd."); + res.redirect("/products"); } diff --git a/controllers/ShoppingListController.js b/controllers/ShoppingListController.js new file mode 100644 index 0000000..eff516d --- /dev/null +++ b/controllers/ShoppingListController.js @@ -0,0 +1,71 @@ +const ProductRepository = require("../repositories/ProductRepository"); +const ShoppingListRepository = require("../repositories/ShoppingListRepository"); + +class ShoppingListController { + index(req, res) { + const lists = ShoppingListRepository.getByHousehold(req.user.household_id); + res.locals.title = "Boodschappenlijsten"; + res.render("lists/index", { lists, user: req.user }); + } + + create(req, res) { + const name = (req.body.name || "").trim(); + if (!name) { + req.flash("error", "Geef een lijstnaam op"); + return res.redirect("/lists"); + } + ShoppingListRepository.create(req.user.household_id, name); + req.flash("success", "Boodschappenlijst aangemaakt"); + res.redirect("/lists"); + } + + show(req, res) { + const list = ShoppingListRepository.findById(req.params.id, req.user.household_id); + if (!list) return res.status(404).render("errors/404"); + res.locals.title = list.name; + res.render("lists/show", { + list, + items: ShoppingListRepository.getItems(list.id), + products: ProductRepository.getAll(), + user: req.user + }); + } + + addItem(req, res) { + const list = ShoppingListRepository.findById(req.params.id, req.user.household_id); + const productId = Number.parseInt(req.body.product_id, 10); + const amount = Number.parseFloat(req.body.amount || "1"); + const product = Number.isInteger(productId) ? ProductRepository.findById(productId) : null; + if (!list || !product || !Number.isFinite(amount) || amount <= 0) { + req.flash("error", "Ongeldig product of ongeldige hoeveelheid"); + return res.redirect(`/lists/${req.params.id}`); + } + ShoppingListRepository.addItem(list.id, product.id, amount); + res.redirect(`/lists/${list.id}`); + } + + toggleItem(req, res) { + const list = ShoppingListRepository.findById(req.params.id, req.user.household_id); + if (!list) return res.status(404).render("errors/404"); + ShoppingListRepository.toggleItem(list.id, req.params.itemId); + this.broadcastUpdate(req, list.id); + res.redirect(`/lists/${list.id}`); + } + + deleteItem(req, res) { + const list = ShoppingListRepository.findById(req.params.id, req.user.household_id); + if (!list) return res.status(404).json({ error: "Lijst niet gevonden" }); + const result = ShoppingListRepository.deleteItem(list.id, req.params.itemId); + if (result.changes === 0) return res.status(404).json({ error: "Product niet gevonden" }); + this.broadcastUpdate(req, list.id); + res.json({ success: true }); + } + + broadcastUpdate(req, listId) { + if (req.app.locals.io) { + req.app.locals.io.to(`list:${listId}`).emit("list:updated", { listId }); + } + } +} + +module.exports = new ShoppingListController(); diff --git a/database/seed.js b/database/seed.js index f4326ad..bc1115b 100644 --- a/database/seed.js +++ b/database/seed.js @@ -2,106 +2,42 @@ require("dotenv").config(); const db = require("../config/database"); - const categories = [ - ["Groente", "๐Ÿฅฌ"], - ["Fruit", "๐ŸŽ"], - ["Zuivel", "๐Ÿฅ›"], - ["Brood", "๐Ÿž"], - ["Dranken", "๐Ÿฅค"], - ["Huishouden", "๐Ÿงป"] - ]; const products = [ - - [ - "Melk", - 3 - ], - - [ - "Bananen", - 2 - ], - - [ - "Brood", - 4 - ], - - [ - "Koffie", - 5 - ], - - [ - "WC papier", - 6 - ] - + ["Melk", "Zuivel"], + ["Bananen", "Fruit"], + ["Brood", "Brood"], + ["Koffie", "Dranken"], + ["WC papier", "Huishouden"] ]; +const insertCategory = db.prepare(` + INSERT OR IGNORE INTO categories (name, icon) VALUES (?, ?) +`); const insertProduct = db.prepare(` - -INSERT OR IGNORE INTO products - -( - name, - category_id -) - -VALUES (?,?) - + INSERT OR IGNORE INTO products (name, category_id) VALUES (?, ?) `); - - - -const seedProducts = db.transaction(() => { - - - for(const product of products){ - - insertProduct.run(product); - - } - - -}); - - -seedProducts(); - -const insert = db.prepare(` - -INSERT OR IGNORE INTO categories -(name, icon) - -VALUES (?,?) - -`); - - -const seed = db.transaction(() => { - +db.transaction(() => { for (const category of categories) { - - insert.run(category); - + insertCategory.run(category); } -}); + for (const [name, categoryName] of products) { + const category = db.prepare( + "SELECT id FROM categories WHERE name = ?" + ).get(categoryName); + insertProduct.run(name, category.id); + } +})(); - -seed(); - - -console.log("Seed voltooid."); \ No newline at end of file +console.log("Seed voltooid."); diff --git a/middleware/auth.js b/middleware/auth.js index 01c7f78..1e08e6e 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -3,6 +3,10 @@ module.exports = function(req, res, next) { if (!req.session.userId) { + if (req.originalUrl.startsWith("/api/")) { + return res.status(401).json({ error: "Je bent niet ingelogd" }); + } + return res.redirect("/login"); } diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..9d68a52 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,11 @@ +(() => { + const socket = window.io && window.io(); + if (!socket) return; + + const listMatch = window.location.pathname.match(/^\/lists\/(\d+)$/); + if (listMatch) socket.emit("list:join", listMatch[1]); + + socket.on("list:updated", ({ listId }) => { + if (window.location.pathname === `/lists/${listId}`) window.location.reload(); + }); +})(); diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 0000000..e7aadf2 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,43 @@ +const CACHE_NAME = "pantryhub-static-v3"; + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)) + ) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + + const url = new URL(event.request.url); + if (url.origin !== self.location.origin) return; + + // Authenticated HTML and API responses must never be served from stale cache. + if (event.request.mode === "navigate" || url.pathname.startsWith("/api/")) { + event.respondWith(fetch(event.request)); + return; + } + + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached; + return fetch(event.request).then((response) => { + if (response.ok && response.type === "basic") { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy)); + } + return response; + }); + }) + ); +}); diff --git a/repositories/ShoppingListRepository.js b/repositories/ShoppingListRepository.js new file mode 100644 index 0000000..0220924 --- /dev/null +++ b/repositories/ShoppingListRepository.js @@ -0,0 +1,71 @@ +const db = require("../config/database"); + +class ShoppingListRepository { + getByHousehold(householdId) { + return db.prepare(` + SELECT shopping_lists.*, + COUNT(shopping_list_items.id) AS item_count, + COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count + FROM shopping_lists + LEFT JOIN shopping_list_items + ON shopping_list_items.list_id = shopping_lists.id + WHERE shopping_lists.household_id = ? AND shopping_lists.archived = 0 + GROUP BY shopping_lists.id + ORDER BY shopping_lists.created_at DESC, shopping_lists.id DESC + `).all(householdId); + } + + findById(id, householdId) { + return db.prepare(` + SELECT * FROM shopping_lists + WHERE id = ? AND household_id = ? + `).get(id, householdId); + } + + getItems(listId) { + return db.prepare(` + SELECT shopping_list_items.*, products.name AS product_name, + categories.name AS category_name, categories.icon AS category_icon + FROM shopping_list_items + JOIN products ON products.id = shopping_list_items.product_id + LEFT JOIN categories ON categories.id = products.category_id + WHERE shopping_list_items.list_id = ? + ORDER BY shopping_list_items.checked ASC, + shopping_list_items.sort_order ASC, + shopping_list_items.created_at ASC + `).all(listId); + } + + create(householdId, name) { + return db.prepare(` + INSERT INTO shopping_lists (household_id, name) VALUES (?, ?) + `).run(householdId, name); + } + + addItem(listId, productId, amount) { + const nextSortOrder = db.prepare(` + SELECT COALESCE(MAX(sort_order), -1) + 1 AS value + FROM shopping_list_items WHERE list_id = ? + `).get(listId).value; + return db.prepare(` + INSERT INTO shopping_list_items (list_id, product_id, amount, sort_order) + VALUES (?, ?, ?, ?) + `).run(listId, productId, amount, nextSortOrder); + } + + toggleItem(listId, itemId) { + return db.prepare(` + UPDATE shopping_list_items + SET checked = CASE checked WHEN 0 THEN 1 ELSE 0 END + WHERE id = ? AND list_id = ? + `).run(itemId, listId); + } + + deleteItem(listId, itemId) { + return db.prepare(` + DELETE FROM shopping_list_items WHERE id = ? AND list_id = ? + `).run(itemId, listId); + } +} + +module.exports = new ShoppingListRepository(); diff --git a/routes/api.js b/routes/api.js new file mode 100644 index 0000000..718c462 --- /dev/null +++ b/routes/api.js @@ -0,0 +1,8 @@ +const router = require("express").Router(); +const auth = require("../middleware/auth"); +const ProductRepository = require("../repositories/ProductRepository"); + +router.use(auth); +router.get("/products", (req, res) => res.json(ProductRepository.getAll())); + +module.exports = router; diff --git a/routes/web.js b/routes/web.js index 22d6ebf..94ae778 100644 --- a/routes/web.js +++ b/routes/web.js @@ -11,8 +11,8 @@ require("../controllers/HouseholdController"); const ProductController = require("../controllers/ProductController"); -console.log("AUTH:", auth); -console.log("DASHBOARD INDEX:", DashboardController.index); +const ShoppingListController = +require("../controllers/ShoppingListController"); router.get( "/", @@ -27,7 +27,6 @@ router.get( ); router.get( - "/products", auth, @@ -35,4 +34,47 @@ router.get( ProductController.index.bind(ProductController) ); + +router.post( + "/products", + auth, + ProductController.create.bind(ProductController) +); + +router.get( + "/lists", + auth, + ShoppingListController.index.bind(ShoppingListController) +); + +router.post( + "/lists", + auth, + ShoppingListController.create.bind(ShoppingListController) +); + +router.get( + "/lists/:id", + auth, + ShoppingListController.show.bind(ShoppingListController) +); + +router.post( + "/lists/:id/items", + auth, + ShoppingListController.addItem.bind(ShoppingListController) +); + +router.post( + "/lists/:id/items/:itemId/toggle", + auth, + ShoppingListController.toggleItem.bind(ShoppingListController) +); + +router.delete( + "/lists/:id/items/:itemId", + auth, + ShoppingListController.deleteItem.bind(ShoppingListController) +); + module.exports = router; \ No newline at end of file diff --git a/sockets/handlers.js b/sockets/handlers.js new file mode 100644 index 0000000..d623208 --- /dev/null +++ b/sockets/handlers.js @@ -0,0 +1,12 @@ +const UserRepository = require("../repositories/UserRepository"); + +module.exports = function registerSocketHandlers(io) { + io.on("connection", (socket) => { + const userId = socket.request.session && socket.request.session.userId; + const user = userId && UserRepository.findById(userId); + if (!user || !user.household_id) return socket.disconnect(true); + + socket.join(`household:${user.household_id}`); + socket.on("list:join", (listId) => socket.join(`list:${listId}`)); + }); +}; diff --git a/views/auth/login.ejs b/views/auth/login.ejs index 0835a5d..2ebfdc2 100644 --- a/views/auth/login.ejs +++ b/views/auth/login.ejs @@ -52,6 +52,10 @@ Inloggen +

+ Nog geen account? Registreren +

+ diff --git a/views/layouts/main.ejs b/views/layouts/main.ejs index f19d0e1..26b928d 100644 --- a/views/layouts/main.ejs +++ b/views/layouts/main.ejs @@ -21,6 +21,7 @@
+ <%- include("../partials/flash") %> <%- body %>
@@ -36,7 +37,8 @@ - + + diff --git a/views/lists/index.ejs b/views/lists/index.ejs new file mode 100644 index 0000000..2cbd1e2 --- /dev/null +++ b/views/lists/index.ejs @@ -0,0 +1,42 @@ + + +
+
+
+
+ <% if (lists.length === 0) { %> +

Je hebt nog geen boodschappenlijsten.

+ <% } else { %> + + <% } %> +
+
+
+
+
+

Nieuwe lijst

+
+
+ + + +
+
+
+
+
diff --git a/views/lists/show.ejs b/views/lists/show.ejs new file mode 100644 index 0000000..ef7ee38 --- /dev/null +++ b/views/lists/show.ejs @@ -0,0 +1,69 @@ + + +
+
+
+
+ <% if (items.length === 0) { %> +

Nog geen producten op de lijst.

+ <% } else { %> +
+ <% items.forEach(item => { %> +
+
+ +
+
+ style="text-decoration: line-through"<% } %>> + <%= item.product_name %> + + <% if (item.amount !== 1) { %><%= item.amount %><% } %> +
<%= item.category_icon || "" %> <%= item.category_name || "Geen categorie" %>
+
+
+ +
+
+ <% }); %> +
+ <% } %> +
+
+
+
+
+

Product toevoegen

+
+
+ + + + + +
+
+
+
+
+ + diff --git a/views/partials/flash.ejs b/views/partials/flash.ejs new file mode 100644 index 0000000..0d02d3a --- /dev/null +++ b/views/partials/flash.ejs @@ -0,0 +1,12 @@ +<% if (success && success.length) { %> + +<% } %> +<% if (error && error.length) { %> + +<% } %> +<% if (info && info.length) { %> + +<% } %> +<% if (warning && warning.length) { %> + +<% } %> diff --git a/views/partials/head.ejs b/views/partials/head.ejs index 2f3417a..ef9c1c3 100644 --- a/views/partials/head.ejs +++ b/views/partials/head.ejs @@ -12,6 +12,12 @@ href="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/css/tabler.min.css" rel="stylesheet"> + + +<% if (user) { %> + +<% } %> + diff --git a/views/products/index.ejs b/views/products/index.ejs index 171c227..a0c90c4 100644 --- a/views/products/index.ejs +++ b/views/products/index.ejs @@ -8,64 +8,46 @@ +
+
+
+
+ <% if (products.length === 0) { %> +

Nog geen producten.

+ <% } else { %> +
+ <% products.forEach(product => { %> +
+ <%= product.category_icon || "" %> <%= product.name %> +
<%= product.category_name || "Geen categorie" %>
+
+ <% }); %> +
+ <% } %> +
+
+
+
+
+

Nieuw product

+
+
+ + -
- -
- - -<% if(products.length === 0) { %> - - -

- -Nog geen producten. - -

- - -<% } %> - - - -
- - -<% products.forEach(product => { %> - - -
- - - - -<%= product.name %> - - - - -
- - - - -<%= product.category_icon || "" %> - -<%= product.category_name || "Geen categorie" %> - - - - -
- - -<% }) %> - - -
- - -
+ + + + +
+
+
\ No newline at end of file