diff --git a/controllers/FavoriteController.js b/controllers/FavoriteController.js new file mode 100644 index 0000000..3c5dcd6 --- /dev/null +++ b/controllers/FavoriteController.js @@ -0,0 +1,51 @@ +const FavoriteService = require("../services/FavoriteService"); + +class FavoriteController { + index(req, res) { + const favorites = FavoriteService.getFavorites(req.user.id, req.user.household_id); + + res.locals.title = "Favorieten"; + res.render("favorites/index", { + favorites, + user: req.user + }); + } + + toggle(req, res) { + const productId = Number.parseInt(req.params.id, 10); + + if (!Number.isInteger(productId)) { + req.flash("error", "Product niet gevonden."); + return res.redirect("/products"); + } + + const isFavorite = FavoriteService.toggleFavorite(req.user.id, productId, req.user.household_id); + if (!isFavorite && !FavoriteService.getFavoriteIds(req.user.id, req.user.household_id).has(productId)) { + req.flash("error", "Product niet gevonden."); + return res.redirect("/products"); + } + + req.flash("success", isFavorite ? "Product toegevoegd aan favorieten." : "Product verwijderd uit favorieten."); + res.redirect("/products"); + } + + remove(req, res) { + const productId = Number.parseInt(req.params.productId, 10); + + if (!Number.isInteger(productId)) { + req.flash("error", "Product niet gevonden."); + return res.redirect("/favorites"); + } + + const removed = FavoriteService.removeFavorite(req.user.id, productId, req.user.household_id); + if (!removed) { + req.flash("error", "Product niet gevonden in favorieten."); + return res.redirect("/favorites"); + } + + req.flash("success", "Product verwijderd uit favorieten."); + res.redirect("/favorites"); + } +} + +module.exports = new FavoriteController(); diff --git a/controllers/ProductController.js b/controllers/ProductController.js index cab9126..5bcdd5c 100644 --- a/controllers/ProductController.js +++ b/controllers/ProductController.js @@ -2,6 +2,7 @@ const ProductService = require("../services/ProductService"); const CategoryService = require("../services/CategoryService"); +const FavoriteService = require("../services/FavoriteService"); const parseCsv = require("../utils/parseCsv"); @@ -10,16 +11,18 @@ class ProductController { index(req, res) { - const products = - ProductService.getProducts(req.user.household_id); - + const products = ProductService.getProducts(req.user.household_id); + const favoriteIds = FavoriteService.getFavoriteIds(req.user.id, req.user.household_id); const categories = CategoryService.getCategories(req.user.household_id); res.render( "products/index", { title:"Producten", - products, + products: products.map(product => ({ + ...product, + is_favorite: favoriteIds.has(product.id) + })), categories } diff --git a/controllers/ShoppingListController.js b/controllers/ShoppingListController.js index ee82fa3..3ca6b40 100644 --- a/controllers/ShoppingListController.js +++ b/controllers/ShoppingListController.js @@ -1,5 +1,6 @@ const ProductRepository = require("../repositories/ProductRepository"); const ShoppingListRepository = require("../repositories/ShoppingListRepository"); +const FavoriteService = require("../services/FavoriteService"); class ShoppingListController { index(req, res) { @@ -63,6 +64,40 @@ class ShoppingListController { res.redirect(`/lists/${list.id}`); } + clearItems(req, res) { + const list = ShoppingListRepository.findById(req.params.id, req.user.household_id); + if (!list) return res.status(404).render("errors/404"); + + ShoppingListRepository.clearItems(list.id); + this.broadcastUpdate(req, list.id); + req.flash("success", "Alle producten zijn verwijderd uit de lijst."); + res.redirect(`/lists/${list.id}`); + } + + addFavoriteProducts(req, res) { + const list = ShoppingListRepository.findById(req.params.id, req.user.household_id); + if (!list) return res.status(404).render("errors/404"); + + const favorites = FavoriteService.getFavorites(req.user.id, req.user.household_id); + const items = favorites.map((favorite) => ({ + list_id: list.id, + product_id: favorite.id, + amount: 1, + checked: 0, + sort_order: 0 + })); + + if (items.length === 0) { + req.flash("info", "Je hebt nog geen favorieten om toe te voegen."); + return res.redirect(`/lists/${list.id}`); + } + + ShoppingListRepository.addItems(list.id, items); + this.broadcastUpdate(req, list.id); + req.flash("success", `${items.length} favoriete producten toegevoegd aan de lijst.`); + 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" }); diff --git a/database/migrate.js b/database/migrate.js index 239ab67..e5c18f0 100644 --- a/database/migrate.js +++ b/database/migrate.js @@ -163,18 +163,18 @@ CREATE TABLE IF NOT EXISTS product_favorites ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, + household_id INTEGER NOT NULL, product_id INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, product_id), + UNIQUE(household_id, product_id), - FOREIGN KEY(user_id) - REFERENCES users(id) + FOREIGN KEY(household_id) + REFERENCES households(id) ON DELETE CASCADE, @@ -217,8 +217,35 @@ const addColumnIfMissing = (table, column, definition) => { } }; +const migrateFavoriteTableToHouseholdScope = () => { + const columns = db.prepare(`PRAGMA table_info(product_favorites)`).all(); + const hasHouseholdId = columns.some(column => column.name === "household_id"); + + if (!hasHouseholdId) { + db.exec(`ALTER TABLE product_favorites ADD COLUMN household_id INTEGER`); + db.exec(`UPDATE product_favorites + SET household_id = ( + SELECT products.household_id + FROM products + WHERE products.id = product_favorites.product_id + ) + WHERE household_id IS NULL`); + + db.exec(`DELETE FROM product_favorites + WHERE id NOT IN ( + SELECT MIN(id) + FROM product_favorites + GROUP BY household_id, product_id + )`); + + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_product_favorites_household_product + ON product_favorites (household_id, product_id)`); + } +}; + addColumnIfMissing("categories", "household_id", "INTEGER REFERENCES households(id)"); addColumnIfMissing("products", "household_id", "INTEGER REFERENCES households(id)"); +migrateFavoriteTableToHouseholdScope(); const defaultHousehold = db.prepare("SELECT id FROM households ORDER BY id LIMIT 1").get(); if (defaultHousehold) { diff --git a/middleware/auth.js b/middleware/auth.js index 2b957d4..adea771 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -1,17 +1,18 @@ module.exports = function(req, res, next) { - - if (!req.session.userId) { - + if (!req.session || !req.session.userId || !req.user) { if (req.originalUrl.startsWith("/api/")) { return res.status(401).json({ error: "Je bent niet ingelogd" }); } + if (req.session && req.session.userId) { + delete req.session.userId; + } + + req.flash("info", "Je sessie is verlopen. Log opnieuw in."); return res.redirect("/login"); - } - next(); }; diff --git a/middleware/user.js b/middleware/user.js index a05f64c..9af10b0 100644 --- a/middleware/user.js +++ b/middleware/user.js @@ -4,17 +4,21 @@ require("../repositories/UserRepository"); module.exports = function userMiddleware(req, res, next) { + req.user = null; - if (req.session.userId) { - - req.user = - UserRepository.findById( - req.session.userId - ); - + if (!req.session || !req.session.userId) { + return next(); } + req.user = UserRepository.findById(req.session.userId); - next(); + if (!req.user) { + delete req.session.userId; + if (typeof req.session.destroy === "function") { + return req.session.destroy(() => next()); + } + } + + return next(); }; \ No newline at end of file diff --git a/repositories/FavoriteRepository.js b/repositories/FavoriteRepository.js new file mode 100644 index 0000000..103b965 --- /dev/null +++ b/repositories/FavoriteRepository.js @@ -0,0 +1,55 @@ +const db = require("../config/database"); + +class FavoriteRepository { + getByHousehold(householdId) { + return db.prepare(` + SELECT products.id, + products.name, + products.category_id, + products.barcode, + categories.name AS category_name, + categories.icon AS category_icon + FROM product_favorites + JOIN products ON products.id = product_favorites.product_id + LEFT JOIN categories ON categories.id = products.category_id + WHERE product_favorites.household_id = ? + AND products.household_id = ? + ORDER BY products.name ASC + `).all(householdId, householdId); + } + + getIdsByHousehold(householdId) { + return new Set( + db.prepare(` + SELECT product_favorites.product_id + FROM product_favorites + JOIN products ON products.id = product_favorites.product_id + WHERE product_favorites.household_id = ? + AND products.household_id = ? + `).all(householdId, householdId).map(row => row.product_id) + ); + } + + find(householdId, productId) { + return db.prepare(` + SELECT * FROM product_favorites + WHERE household_id = ? AND product_id = ? + `).get(householdId, productId); + } + + add(householdId, productId) { + return db.prepare(` + INSERT OR IGNORE INTO product_favorites (household_id, product_id) + VALUES (?, ?) + `).run(householdId, productId); + } + + remove(householdId, productId) { + return db.prepare(` + DELETE FROM product_favorites + WHERE household_id = ? AND product_id = ? + `).run(householdId, productId); + } +} + +module.exports = new FavoriteRepository(); diff --git a/repositories/ShoppingListRepository.js b/repositories/ShoppingListRepository.js index 06426ff..c15088f 100644 --- a/repositories/ShoppingListRepository.js +++ b/repositories/ShoppingListRepository.js @@ -72,6 +72,37 @@ class ShoppingListRepository { `).run(listId); } + clearItems(listId) { + return db.prepare(` + DELETE FROM shopping_list_items + WHERE list_id = ? + `).run(listId); + } + + addItems(listId, items) { + if (!items.length) return 0; + + const insert = db.prepare(` + INSERT INTO shopping_list_items (list_id, product_id, amount, checked, sort_order) + VALUES (?, ?, ?, ?, ?) + `); + + const transaction = db.transaction(() => { + for (const item of items) { + insert.run( + listId, + item.product_id, + item.amount ?? 1, + item.checked ?? 0, + item.sort_order ?? 0 + ); + } + }); + + transaction(); + return items.length; + } + deleteItem(listId, itemId) { return db.prepare(` DELETE FROM shopping_list_items WHERE id = ? AND list_id = ? diff --git a/routes/web.js b/routes/web.js index dfd0b9a..aec90df 100644 --- a/routes/web.js +++ b/routes/web.js @@ -34,6 +34,8 @@ require("../controllers/CategoryController"); const ShoppingListController = require("../controllers/ShoppingListController"); +const FavoriteController = require("../controllers/FavoriteController"); + const MenuController = require("../controllers/MenuController"); @@ -119,6 +121,24 @@ router.post( ProductController.remove.bind(ProductController) ); +router.post( + "/products/:id/favorite", + auth, + FavoriteController.toggle.bind(FavoriteController) +); + +router.get( + "/favorites", + auth, + FavoriteController.index.bind(FavoriteController) +); + +router.post( + "/favorites/:productId/delete", + auth, + FavoriteController.remove.bind(FavoriteController) +); + router.get( "/lists", auth, @@ -149,6 +169,18 @@ router.post( ShoppingListController.create.bind(ShoppingListController) ); +router.post( + "/lists/:id/favorites/add", + auth, + ShoppingListController.addFavoriteProducts.bind(ShoppingListController) +); + +router.post( + "/lists/:id/items/clear", + auth, + ShoppingListController.clearItems.bind(ShoppingListController) +); + router.get( "/lists/:id", auth, diff --git a/services/FavoriteService.js b/services/FavoriteService.js new file mode 100644 index 0000000..4c5ce8e --- /dev/null +++ b/services/FavoriteService.js @@ -0,0 +1,35 @@ +const FavoriteRepository = require("../repositories/FavoriteRepository"); +const ProductRepository = require("../repositories/ProductRepository"); + +class FavoriteService { + getFavorites(userId, householdId) { + return FavoriteRepository.getByHousehold(householdId); + } + + getFavoriteIds(userId, householdId) { + return FavoriteRepository.getIdsByHousehold(householdId); + } + + toggleFavorite(userId, productId, householdId) { + const product = ProductRepository.findById(productId, householdId); + if (!product) return false; + + const current = FavoriteRepository.find(householdId, productId); + if (current) { + FavoriteRepository.remove(householdId, productId); + return false; + } + + FavoriteRepository.add(householdId, productId); + return true; + } + + removeFavorite(userId, productId, householdId) { + const product = ProductRepository.findById(productId, householdId); + if (!product) return false; + + return FavoriteRepository.remove(householdId, productId).changes > 0; + } +} + +module.exports = new FavoriteService(); diff --git a/tmp-favorite-route.sqlite b/tmp-favorite-route.sqlite new file mode 100644 index 0000000..67e1efc Binary files /dev/null and b/tmp-favorite-route.sqlite differ diff --git a/tmp-favorites-check.sqlite b/tmp-favorites-check.sqlite new file mode 100644 index 0000000..5ee3f00 Binary files /dev/null and b/tmp-favorites-check.sqlite differ diff --git a/tmp-household-favorites.sqlite b/tmp-household-favorites.sqlite new file mode 100644 index 0000000..8f4a47a Binary files /dev/null and b/tmp-household-favorites.sqlite differ diff --git a/views/favorites/index.ejs b/views/favorites/index.ejs new file mode 100644 index 0000000..e81ee47 --- /dev/null +++ b/views/favorites/index.ejs @@ -0,0 +1,26 @@ + + +
+
+ <% if (favorites.length === 0) { %> +

Je hebt nog geen favoriete producten.

+ <% } else { %> +
+ <% favorites.forEach(product => { %> +
+
+ <%= product.category_icon || "" %> <%= product.name %> +
<%= product.category_name || "Geen categorie" %>
+
+ +
+ +
+
+ <% }); %> +
+ <% } %> +
+
diff --git a/views/lists/show.ejs b/views/lists/show.ejs index cbfba20..2fa2169 100644 --- a/views/lists/show.ejs +++ b/views/lists/show.ejs @@ -2,10 +2,20 @@ ← Terug naar lijsten

<%= list.name %>

-
- -
+
+
+ +
+
+ +
+
+ +
+
diff --git a/views/partials/navbar.ejs b/views/partials/navbar.ejs index 2c2b7d9..0430e79 100644 --- a/views/partials/navbar.ejs +++ b/views/partials/navbar.ejs @@ -27,6 +27,7 @@ Dashboard Lijsten Producten + Favorieten Categorieën Huishouden Uitloggen diff --git a/views/partials/sidebar.ejs b/views/partials/sidebar.ejs index 591e6a7..4ac69fc 100644 --- a/views/partials/sidebar.ejs +++ b/views/partials/sidebar.ejs @@ -26,6 +26,12 @@ + + +💙 Favorieten + + + diff --git a/views/products/edit.ejs b/views/products/edit.ejs index 1998ccc..918a389 100644 --- a/views/products/edit.ejs +++ b/views/products/edit.ejs @@ -32,7 +32,7 @@
- +
Annuleren diff --git a/views/products/index.ejs b/views/products/index.ejs index 7edd87a..ddb6a35 100644 --- a/views/products/index.ejs +++ b/views/products/index.ejs @@ -23,9 +23,18 @@ <%= product.category_icon || "" %> <%= product.name %>
<%= product.category_name || "Geen categorie" %>
- - Wijzigen - +
+
+ +
+ + Wijzigen + +
<% }); %>