diff --git a/backend/src/routes/products.js b/backend/src/routes/products.js index 1019cad..36aa3b0 100644 --- a/backend/src/routes/products.js +++ b/backend/src/routes/products.js @@ -78,7 +78,7 @@ router.get('/', async (req, res, next) => { FROM products p JOIN product_categories pc ON p.category_id = pc.id LEFT JOIN product_rates pr ON p.id = pr.product_id - WHERE 1=1 ${whereClause} + WHERE 1=1 ${whereClause} AND COALESCE(p.archived,false) = false GROUP BY p.id, pc.name ORDER BY p.name `; @@ -93,7 +93,7 @@ router.get('/', async (req, res, next) => { LEFT JOIN products p ON up.product_id = p.id LEFT JOIN product_categories pc ON p.category_id = pc.id LEFT JOIN product_categories upc ON up.category_id = upc.id - WHERE up.user_id = $1 + WHERE up.user_id = $1 AND COALESCE(up.archived,false) = false ORDER BY COALESCE(up.custom_name, p.name) `; @@ -662,7 +662,9 @@ router.delete('/user/:id', validateParams(idParamSchema), async (req, res, next) ); if (parseInt(usageCheck.rows[0].count) > 0) { - throw new AppError('Cannot delete product that has been used in applications', 400); + // Soft-archive instead of delete when used + await pool.query('UPDATE user_products SET archived = true, updated_at = CURRENT_TIMESTAMP WHERE id = $1', [userProductId]); + return res.json({ success: true, message: 'Custom product archived (in use by applications)' }); } await pool.query('DELETE FROM user_products WHERE id = $1', [userProductId]); @@ -676,6 +678,30 @@ router.delete('/user/:id', validateParams(idParamSchema), async (req, res, next) } }); +// @route PUT /api/products/user/:id/archive +// @desc Archive user's custom product +// @access Private +router.put('/user/:id/archive', validateParams(idParamSchema), async (req, res, next) => { + try { + const userProductId = req.params.id; + const upd = await pool.query('UPDATE user_products SET archived = true, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND user_id = $2', [userProductId, req.user.id]); + if (upd.rowCount === 0) throw new AppError('User product not found', 404); + res.json({ success: true, message: 'Custom product archived' }); + } catch (error) { next(error); } +}); + +// @route PUT /api/products/user/:id/unarchive +// @desc Unarchive user's custom product +// @access Private +router.put('/user/:id/unarchive', validateParams(idParamSchema), async (req, res, next) => { + try { + const userProductId = req.params.id; + const upd = await pool.query('UPDATE user_products SET archived = false, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND user_id = $2', [userProductId, req.user.id]); + if (upd.rowCount === 0) throw new AppError('User product not found', 404); + res.json({ success: true, message: 'Custom product unarchived' }); + } catch (error) { next(error); } +}); + // @route GET /api/products/search // @desc Search products by name or ingredients // @access Private @@ -721,4 +747,4 @@ router.get('/search', async (req, res, next) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/database/migrations/V7__archive_products.sql b/database/migrations/V7__archive_products.sql new file mode 100644 index 0000000..e6ffb4d --- /dev/null +++ b/database/migrations/V7__archive_products.sql @@ -0,0 +1,12 @@ +-- Add archived flag to products and user_products +ALTER TABLE IF EXISTS products + ADD COLUMN IF NOT EXISTS archived BOOLEAN DEFAULT FALSE; + +ALTER TABLE IF EXISTS user_products + ADD COLUMN IF NOT EXISTS archived BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +-- Indexes for filtering +CREATE INDEX IF NOT EXISTS idx_products_archived ON products (archived); +CREATE INDEX IF NOT EXISTS idx_user_products_archived ON user_products (archived); + diff --git a/frontend/src/pages/Admin/AdminProducts.js b/frontend/src/pages/Admin/AdminProducts.js index 2251959..bc83818 100644 --- a/frontend/src/pages/Admin/AdminProducts.js +++ b/frontend/src/pages/Admin/AdminProducts.js @@ -113,7 +113,18 @@ const AdminProducts = () => { if (selectedProduct.isShared) { await adminAPI.deleteProduct(selectedProduct.id); } else { - await productsAPI.deleteUserProduct(selectedProduct.id); + try { + await productsAPI.deleteUserProduct(selectedProduct.id); + } catch (e) { + const msg = e?.response?.data?.message || ''; + if (msg.includes('used in applications')) { + // Fallback to archive instead of delete + await productsAPI.archiveUserProduct(selectedProduct.id); + toast('Product archived instead of deleted (in use)'); + } else { + throw e; + } + } } toast.success('Product deleted successfully'); setShowDeleteModal(false); @@ -121,7 +132,7 @@ const AdminProducts = () => { fetchData(); } catch (error) { console.error('Failed to delete product:', error); - toast.error('Failed to delete product'); + toast.error(error?.response?.data?.message || 'Failed to delete product'); } }; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index d0fdd16..d51515d 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -156,6 +156,8 @@ export const productsAPI = { getUserProduct: (id) => apiClient.get(`/products/user/${id}`), updateUserProduct: (id, productData) => apiClient.put(`/products/user/${id}`, productData), deleteUserProduct: (id) => apiClient.delete(`/products/user/${id}`), + archiveUserProduct: (id) => apiClient.put(`/products/user/${id}/archive`), + unarchiveUserProduct: (id) => apiClient.put(`/products/user/${id}/unarchive`), }; // Applications API endpoints