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>
This commit is contained in:
2026-08-07 20:44:05 +02:00
parent 26a820b41a
commit ce68182c08
23 changed files with 552 additions and 163 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
database.sqlite
*.sqlite-shm
*.sqlite-wal
+8
View File
@@ -1,12 +1,20 @@
require("dotenv").config(); require("dotenv").config();
const http = require("http"); const http = require("http");
const { Server } = require("socket.io");
const createApp = require("./config/express"); const createApp = require("./config/express");
const session = require("./config/session");
const socketHandlers = require("./sockets/handlers");
const app = createApp(); const app = createApp();
const server = http.createServer(app); 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; const PORT = process.env.PORT || 3000;
+26 -1
View File
@@ -57,9 +57,29 @@ module.exports = () => {
app.use(session); app.use(session);
app.use(flash());
app.use(userMiddleware); 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( app.use(
@@ -69,6 +89,11 @@ app.use(flash());
); );
app.use(
"/api",
require("../routes/api")
);
app.use( app.use(
"/", "/",
require("../routes/auth") require("../routes/auth")
+7 -3
View File
@@ -1,8 +1,9 @@
const session = require("express-session"); const session = require("express-session");
const BetterSqlite3Store = require("better-sqlite3-session-store")(session); const BetterSqlite3Store = require("better-sqlite3-session-store")(session);
const Database = require("better-sqlite3"); 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({ 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, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
maxAge: 1000 * 60 * 60 * 24 * 30 maxAge: 1000 * 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production"
} }
}); });
+10 -9
View File
@@ -19,11 +19,14 @@ class AuthController {
async register(req, res) { async register(req, res) {
const { const name = (req.body.name || "").trim();
name, const email = (req.body.email || "").trim().toLowerCase();
email, const password = req.body.password || "";
password
} = req.body; 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( const existingUser = db.prepare(
@@ -97,10 +100,8 @@ class AuthController {
async login(req, res) { async login(req, res) {
const { const email = (req.body.email || "").trim().toLowerCase();
email, const password = req.body.password || "";
password
} = req.body;
const user = db.prepare( const user = db.prepare(
+26 -5
View File
@@ -1,29 +1,50 @@
const ProductService = const ProductService =
require("../services/ProductService"); require("../services/ProductService");
const db = require("../config/database");
class ProductController { class ProductController {
index(req, res) { index(req, res) {
const products = const products =
ProductService.getProducts(); ProductService.getProducts();
const categories = db.prepare(
"SELECT * FROM categories ORDER BY name"
).all();
res.render( res.render(
"products/index", "products/index",
{ {
title:"Producten", 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");
} }
+71
View File
@@ -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();
+18 -82
View File
@@ -2,106 +2,42 @@ require("dotenv").config();
const db = require("../config/database"); const db = require("../config/database");
const categories = [ const categories = [
["Groente", "🥬"], ["Groente", "🥬"],
["Fruit", "🍎"], ["Fruit", "🍎"],
["Zuivel", "🥛"], ["Zuivel", "🥛"],
["Brood", "🍞"], ["Brood", "🍞"],
["Dranken", "🥤"], ["Dranken", "🥤"],
["Huishouden", "🧻"] ["Huishouden", "🧻"]
]; ];
const products = [ const products = [
["Melk", "Zuivel"],
[ ["Bananen", "Fruit"],
"Melk", ["Brood", "Brood"],
3 ["Koffie", "Dranken"],
], ["WC papier", "Huishouden"]
[
"Bananen",
2
],
[
"Brood",
4
],
[
"Koffie",
5
],
[
"WC papier",
6
]
]; ];
const insertCategory = db.prepare(`
INSERT OR IGNORE INTO categories (name, icon) VALUES (?, ?)
`);
const insertProduct = db.prepare(` const insertProduct = db.prepare(`
INSERT OR IGNORE INTO products (name, category_id) VALUES (?, ?)
INSERT OR IGNORE INTO products
(
name,
category_id
)
VALUES (?,?)
`); `);
db.transaction(() => {
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(() => {
for (const category of categories) { for (const category of categories) {
insertCategory.run(category);
insert.run(category);
} }
}); for (const [name, categoryName] of products) {
const category = db.prepare(
"SELECT id FROM categories WHERE name = ?"
seed(); ).get(categoryName);
insertProduct.run(name, category.id);
}
})();
console.log("Seed voltooid."); console.log("Seed voltooid.");
+4
View File
@@ -3,6 +3,10 @@ module.exports = function(req, res, next) {
if (!req.session.userId) { if (!req.session.userId) {
if (req.originalUrl.startsWith("/api/")) {
return res.status(401).json({ error: "Je bent niet ingelogd" });
}
return res.redirect("/login"); return res.redirect("/login");
} }
+11
View File
@@ -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();
});
})();
+43
View File
@@ -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;
});
})
);
});
+71
View File
@@ -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();
+8
View File
@@ -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;
+45 -3
View File
@@ -11,8 +11,8 @@ require("../controllers/HouseholdController");
const ProductController = const ProductController =
require("../controllers/ProductController"); require("../controllers/ProductController");
console.log("AUTH:", auth); const ShoppingListController =
console.log("DASHBOARD INDEX:", DashboardController.index); require("../controllers/ShoppingListController");
router.get( router.get(
"/", "/",
@@ -27,7 +27,6 @@ router.get(
); );
router.get( router.get(
"/products", "/products",
auth, auth,
@@ -35,4 +34,47 @@ router.get(
ProductController.index.bind(ProductController) 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; module.exports = router;
+12
View File
@@ -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}`));
});
};
+4
View File
@@ -52,6 +52,10 @@ Inloggen
</form> </form>
<p class="text-secondary mt-3">
Nog geen account? <a href="/register">Registreren</a>
</p>
</div> </div>
+3 -1
View File
@@ -21,6 +21,7 @@
<div class="container-xl"> <div class="container-xl">
<%- include("../partials/flash") %>
<%- body %> <%- body %>
</div> </div>
@@ -36,7 +37,8 @@
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script>
<script src="/socket.io/socket.io.js"></script>
<script src="/js/app.js"></script>
</body> </body>
+42
View File
@@ -0,0 +1,42 @@
<div class="page-header mb-4">
<h1>Boodschappenlijsten</h1>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
<% if (lists.length === 0) { %>
<p class="text-secondary">Je hebt nog geen boodschappenlijsten.</p>
<% } else { %>
<div class="list-group">
<% lists.forEach(list => { %>
<a href="/lists/<%= list.id %>" class="list-group-item list-group-item-action">
<div class="d-flex justify-content-between">
<strong><%= list.name %></strong>
<span><%= list.checked_count || 0 %>/<%= list.item_count || 0 %> gedaan</span>
</div>
<small class="text-muted">
Aangemaakt <%= new Date(list.created_at).toLocaleDateString("nl-NL") %>
</small>
</a>
<% }); %>
</div>
<% } %>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Nieuwe lijst</h3></div>
<div class="card-body">
<form method="post" action="/lists">
<label class="form-label" for="list-name">Lijstnaam</label>
<input id="list-name" class="form-control mb-3" type="text" name="name"
placeholder="bijv. Weekboodschappen" required maxlength="120">
<button class="btn btn-primary w-100" type="submit">Lijst aanmaken</button>
</form>
</div>
</div>
</div>
</div>
+69
View File
@@ -0,0 +1,69 @@
<div class="page-header mb-4">
<a href="/lists" class="btn btn-link">&larr; Terug</a>
<h1><%= list.name %></h1>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
<% if (items.length === 0) { %>
<p class="text-secondary">Nog geen producten op de lijst.</p>
<% } else { %>
<div class="list-group">
<% items.forEach(item => { %>
<div class="list-group-item d-flex align-items-center">
<form method="post" action="/lists/<%= list.id %>/items/<%= item.id %>/toggle">
<button class="btn btn-sm me-2" type="submit" aria-label="Product afvinken">
<%= item.checked ? "☑" : "☐" %>
</button>
</form>
<div class="flex-grow-1">
<strong<% if (item.checked) { %> style="text-decoration: line-through"<% } %>>
<%= item.product_name %>
</strong>
<% if (item.amount !== 1) { %><span class="badge bg-info"><%= item.amount %></span><% } %>
<div class="small text-secondary"><%= item.category_icon || "" %> <%= item.category_name || "Geen categorie" %></div>
</div>
<form class="delete-item-form" method="post" action="/lists/<%= list.id %>/items/<%= item.id %>">
<button class="btn btn-sm btn-link text-danger" type="submit">Verwijder</button>
</form>
</div>
<% }); %>
</div>
<% } %>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Product toevoegen</h3></div>
<div class="card-body">
<form method="post" action="/lists/<%= list.id %>/items">
<label class="form-label" for="product-id">Product</label>
<select id="product-id" class="form-select mb-3" name="product_id" required>
<option value="">Kies een product</option>
<% (products || []).forEach(product => { %>
<option value="<%= product.id %>"><%= product.name %></option>
<% }); %>
</select>
<label class="form-label" for="amount">Hoeveelheid</label>
<input id="amount" class="form-control mb-3" type="number" name="amount"
value="1" min="0.5" step="0.5" required>
<button class="btn btn-primary w-100" type="submit">Toevoegen</button>
</form>
</div>
</div>
</div>
</div>
<script>
document.querySelectorAll(".delete-item-form").forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!confirm("Product verwijderen?")) return;
const response = await fetch(form.action, { method: "DELETE" });
if (response.ok) window.location.reload();
});
});
</script>
+12
View File
@@ -0,0 +1,12 @@
<% if (success && success.length) { %>
<div class="alert alert-success" role="alert"><%= success[0] %></div>
<% } %>
<% if (error && error.length) { %>
<div class="alert alert-danger" role="alert"><%= error[0] %></div>
<% } %>
<% if (info && info.length) { %>
<div class="alert alert-info" role="alert"><%= info[0] %></div>
<% } %>
<% if (warning && warning.length) { %>
<div class="alert alert-warning" role="alert"><%= warning[0] %></div>
<% } %>
+6
View File
@@ -12,6 +12,12 @@
href="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/css/tabler.min.css" href="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/css/tabler.min.css"
rel="stylesheet"> rel="stylesheet">
<script>
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/service-worker.js", { updateViaCache: "none" });
}
</script>
<link <link
href="/css/style.css" href="/css/style.css"
+10
View File
@@ -8,6 +8,16 @@
</a> </a>
<% if (user) { %>
<div class="navbar-nav flex-row ms-auto">
<a class="nav-link" href="/">Dashboard</a>
<a class="nav-link" href="/lists">Lijsten</a>
<a class="nav-link" href="/products">Producten</a>
<a class="nav-link" href="/household">Huishouden</a>
<a class="nav-link text-danger" href="/logout">Uitloggen</a>
</div>
<% } %>
</div> </div>
+30 -48
View File
@@ -8,64 +8,46 @@
</div> </div>
<div class="row">
<div class="col-md-8">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<% if (products.length === 0) { %> <% if (products.length === 0) { %>
<p class="text-secondary">Nog geen producten.</p>
<% } else { %>
<p>
Nog geen producten.
</p>
<% } %>
<div class="list-group"> <div class="list-group">
<% products.forEach(product => { %> <% products.forEach(product => { %>
<div class="list-group-item"> <div class="list-group-item">
<strong><%= product.category_icon || "" %> <%= product.name %></strong>
<div class="text-secondary"><%= product.category_name || "Geen categorie" %></div>
<strong> </div>
<% }); %>
<%= product.name %> </div>
<% } %>
</strong> </div>
</div>
<br>
<span class="text-secondary">
<%= product.category_icon || "" %>
<%= product.category_name || "Geen categorie" %>
</span>
</div> </div>
<div class="col-md-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Nieuw product</h3></div>
<div class="card-body">
<form method="post" action="/products">
<label class="form-label" for="product-name">Productnaam</label>
<input id="product-name" class="form-control mb-3" type="text" name="name"
placeholder="bijv. Melk" maxlength="120" required>
<% }) %> <label class="form-label" for="category-id">Categorie</label>
<select id="category-id" class="form-select mb-3" name="category_id">
<option value="">Geen categorie</option>
<% (categories || []).forEach(category => { %>
<option value="<%= category.id %>"><%= category.icon || "" %> <%= category.name %></option>
<% }); %>
</select>
<button class="btn btn-primary w-100" type="submit">Product toevoegen</button>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>