diff --git a/README.md b/README.md index 0aed2fa..5e8e35e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - Личный кабинет: профиль, бронирования - Роли клиент / администратор, админ-панель - Согласие на cookies +- Подписка «сообщить о поступлении», если товара нет в наличии ## Требования diff --git a/postgres/init/05_stock_alerts.sql b/postgres/init/05_stock_alerts.sql new file mode 100644 index 0000000..21fb904 --- /dev/null +++ b/postgres/init/05_stock_alerts.sql @@ -0,0 +1,14 @@ +-- Подписка «сообщить о поступлении» +CREATE TABLE IF NOT EXISTS product_stock_alerts ( + id SERIAL PRIMARY KEY, + product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE, + email TEXT NOT NULL, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + notified_at TIMESTAMPTZ, + UNIQUE (product_id, email) +); + +CREATE INDEX IF NOT EXISTS idx_stock_alerts_product_pending + ON product_stock_alerts (product_id) + WHERE notified_at IS NULL; diff --git a/src/public/css/style.css b/src/public/css/style.css index e7fe33f..79c3813 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -281,6 +281,39 @@ a:hover { color: var(--muted); } +.stock-notify { + margin-top: 1.25rem; + padding: 1.25rem; +} + +.stock-notify__title { + margin: 0 0 0.35rem; + font-size: 1.05rem; +} + +.stock-notify__hint { + margin: 0 0 1rem; + font-size: 0.9rem; +} + +.stock-notify__form { + max-width: 320px; +} + +.stock-notify--done { + margin-top: 1rem; + padding: 0.75rem 1rem; + background: rgba(0, 184, 148, 0.12); + border-radius: 8px; + color: var(--success); +} + +.admin-stock-form { + display: flex; + align-items: center; + gap: 0.35rem; +} + .product-detail__form { display: flex; flex-wrap: wrap; diff --git a/src/routes/admin.js b/src/routes/admin.js index 02fc08b..b7f2e20 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -3,6 +3,7 @@ const { query, formatPrice } = require('../db'); const { requireAdmin } = require('../middleware/auth'); const { asyncHandler } = require('../utils/asyncHandler'); const { ROLE_LABELS } = require('../constants/roles'); +const { notifyIfBackInStock } = require('../services/stock-alerts'); const router = express.Router(); @@ -91,7 +92,9 @@ router.get( '/products', asyncHandler(async (req, res) => { const { rows: products } = await query( - `SELECT p.*, c.name AS category_name + `SELECT p.*, c.name AS category_name, + (SELECT COUNT(*)::int FROM product_stock_alerts a + WHERE a.product_id = p.id AND a.notified_at IS NULL) AS alert_count FROM products p LEFT JOIN categories c ON c.id = p.category_id ORDER BY p.id` @@ -100,10 +103,38 @@ router.get( title: 'Товары', products, formatPrice, + stockUpdated: req.query.stock_updated === '1', + notified: req.query.notified ? parseInt(req.query.notified, 10) : 0, }); }) ); +router.post( + '/products/:id/stock', + asyncHandler(async (req, res) => { + const productId = parseInt(req.params.id, 10); + const stock = parseInt(req.body.stock, 10); + if (!Number.isFinite(productId) || !Number.isFinite(stock) || stock < 0) { + return res.redirect('/admin/products'); + } + + const { rows } = await query('SELECT stock FROM products WHERE id = $1', [productId]); + const oldStock = rows[0]?.stock ?? 0; + + await query('UPDATE products SET stock = $1 WHERE id = $2', [stock, productId]); + + let notified = 0; + if (oldStock <= 0 && stock > 0) { + const result = await notifyIfBackInStock(productId); + notified = result.sent; + } + + const qs = new URLSearchParams({ stock_updated: '1' }); + if (notified > 0) qs.set('notified', String(notified)); + res.redirect(`/admin/products?${qs}`); + }) +); + router.get( '/reservations', asyncHandler(async (req, res) => { diff --git a/src/routes/shop.js b/src/routes/shop.js index 88cb299..3aa97e6 100644 --- a/src/routes/shop.js +++ b/src/routes/shop.js @@ -89,6 +89,25 @@ router.get( const errorMsg = req.query.error ? decodeURIComponent(String(req.query.error)) : null; const reserved = req.query.reserved === '1'; + const notifySuccess = req.query.notify_success + ? decodeURIComponent(String(req.query.notify_success)) + : null; + const notifyError = req.query.notify_error + ? decodeURIComponent(String(req.query.notify_error)) + : null; + + let stockAlertSubscribed = false; + let notifyEmail = res.locals.user?.email || ''; + if (product.stock <= 0) { + const stockAlerts = require('../services/stock-alerts'); + if (notifyEmail) { + stockAlertSubscribed = await stockAlerts.isSubscribed( + product.id, + notifyEmail, + req.session.userId + ); + } + } res.render('product', { title: product.name, @@ -96,6 +115,10 @@ router.get( userReservation, error: errorMsg, reserved, + notifySuccess, + notifyError, + stockAlertSubscribed, + notifyEmail, }); }) ); diff --git a/src/routes/stock-alerts.js b/src/routes/stock-alerts.js new file mode 100644 index 0000000..7c280e6 --- /dev/null +++ b/src/routes/stock-alerts.js @@ -0,0 +1,75 @@ +const express = require('express'); +const { query, formatPrice } = require('../db'); +const { getCart, cartCount } = require('../cart'); +const { requireCookieConsent } = require('../middleware/cookieConsent'); +const { asyncHandler } = require('../utils/asyncHandler'); +const stockAlerts = require('../services/stock-alerts'); + +const router = express.Router(); + +router.use((req, res, next) => { + const cart = getCart(req); + res.locals.cartCount = cartCount(cart); + res.locals.formatPrice = formatPrice; + next(); +}); + +const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +router.post( + '/product/:slug/notify-stock', + requireCookieConsent, + asyncHandler(async (req, res) => { + const slug = req.params.slug; + const { rows } = await query('SELECT id, name, stock FROM products WHERE slug = $1', [ + slug, + ]); + const product = rows[0]; + + if (!product) { + return res.status(404).render('error', { + title: 'Не найдено', + message: 'Товар не найден', + code: 404, + }); + } + + if (product.stock > 0) { + return res.redirect( + `/product/${slug}?notify_error=${encodeURIComponent('Товар уже в наличии')}` + ); + } + + let email = (req.body.email || '').trim().toLowerCase(); + if (req.session.userId) { + const { rows: users } = await query('SELECT email FROM users WHERE id = $1', [ + req.session.userId, + ]); + if (users[0]) email = users[0].email; + } + + if (!email || !emailRe.test(email)) { + return res.redirect( + `/product/${slug}?notify_error=${encodeURIComponent('Укажите корректный email')}` + ); + } + + if (await stockAlerts.isSubscribed(product.id, email, req.session.userId)) { + return res.redirect( + `/product/${slug}?notify_success=${encodeURIComponent('Вы уже подписаны — сообщим на почту')}` + ); + } + + await stockAlerts.subscribe({ + productId: product.id, + email, + userId: req.session.userId || null, + }); + + res.redirect( + `/product/${slug}?notify_success=${encodeURIComponent('Когда товар появится, отправим письмо на ' + email)}` + ); + }) +); + +module.exports = router; diff --git a/src/server.js b/src/server.js index 35d5ab4..a9bbb6d 100644 --- a/src/server.js +++ b/src/server.js @@ -18,6 +18,7 @@ const cookiesRoutes = require('./routes/cookies'); const passwordResetRoutes = require('./routes/password-reset'); const reservationsRoutes = require('./routes/reservations'); const passkeyRoutes = require('./routes/passkey'); +const stockAlertsRoutes = require('./routes/stock-alerts'); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || '0.0.0.0'; @@ -68,6 +69,7 @@ async function start() { app.use('/cookies', cookiesRoutes); app.use('/', passwordResetRoutes); app.use('/reservations', reservationsRoutes); + app.use('/', stockAlertsRoutes); app.use('/', shopRoutes); app.use('/', authRoutes); app.use('/webauthn', passkeyRoutes); diff --git a/src/services/mail.js b/src/services/mail.js index de9b25f..1ba7e92 100644 --- a/src/services/mail.js +++ b/src/services/mail.js @@ -68,10 +68,22 @@ async function sendReservationEmail(to, productName, quantity, expiresAt) { return sendMail({ to, subject, text, html }); } +async function sendStockAvailableEmail(to, productName, productUrl) { + const subject = `Снова в наличии: ${productName}`; + const text = `Товар «${productName}» снова в наличии.\n\nПерейти: ${productUrl}`; + const html = ` +

Товар ${productName} снова в наличии.

+

Открыть товар в магазине

+

Вы получили это письмо, потому что подписались на уведомление о поступлении.

+ `; + return sendMail({ to, subject, text, html }); +} + module.exports = { isConfigured, sendMail, sendPasswordResetEmail, sendReservationEmail, + sendStockAvailableEmail, siteUrl, }; diff --git a/src/services/stock-alerts.js b/src/services/stock-alerts.js new file mode 100644 index 0000000..3c2e68d --- /dev/null +++ b/src/services/stock-alerts.js @@ -0,0 +1,74 @@ +const { query } = require('../db'); +const { sendStockAvailableEmail, siteUrl } = require('./mail'); + +async function isSubscribed(productId, email, userId) { + const normalized = email.trim().toLowerCase(); + const params = [productId, normalized]; + let sql = `SELECT 1 FROM product_stock_alerts + WHERE product_id = $1 AND notified_at IS NULL + AND (email = $2`; + if (userId) { + sql += ' OR user_id = $3'; + params.push(userId); + } + sql += ')'; + const { rows } = await query(sql, params); + return rows.length > 0; +} + +async function subscribe({ productId, email, userId }) { + const normalized = email.trim().toLowerCase(); + await query( + `INSERT INTO product_stock_alerts (product_id, email, user_id) + VALUES ($1, $2, $3) + ON CONFLICT (product_id, email) DO UPDATE SET + user_id = COALESCE(EXCLUDED.user_id, product_stock_alerts.user_id), + notified_at = NULL, + created_at = CASE + WHEN product_stock_alerts.notified_at IS NOT NULL THEN NOW() + ELSE product_stock_alerts.created_at + END`, + [productId, normalized, userId || null] + ); +} + +async function notifyIfBackInStock(productId) { + const { rows: products } = await query( + 'SELECT id, slug, name, stock FROM products WHERE id = $1', + [productId] + ); + const product = products[0]; + if (!product || product.stock <= 0) return { sent: 0 }; + + const { rows: alerts } = await query( + `SELECT id, email FROM product_stock_alerts + WHERE product_id = $1 AND notified_at IS NULL`, + [productId] + ); + + if (!alerts.length) return { sent: 0 }; + + const productUrl = `${siteUrl()}/product/${product.slug}`; + let sent = 0; + + for (const alert of alerts) { + try { + await sendStockAvailableEmail(alert.email, product.name, productUrl); + await query( + 'UPDATE product_stock_alerts SET notified_at = NOW() WHERE id = $1', + [alert.id] + ); + sent++; + } catch (err) { + console.error('stock alert email failed:', alert.email, err.message); + } + } + + return { sent }; +} + +module.exports = { + isSubscribed, + subscribe, + notifyIfBackInStock, +}; diff --git a/src/views/admin/products.ejs b/src/views/admin/products.ejs index 746ffe7..1ac4687 100644 --- a/src/views/admin/products.ejs +++ b/src/views/admin/products.ejs @@ -12,6 +12,12 @@ +<% if (stockUpdated) { %> +

+ Остаток обновлён.<% if (notified > 0) { %> Отправлено уведомлений подписчикам: <%= notified %>.<% } %> +

+<% } %> + @@ -20,6 +26,7 @@ + @@ -30,7 +37,19 @@ - + + <% }) %> diff --git a/src/views/product.ejs b/src/views/product.ejs index 72da1f4..82e7301 100644 --- a/src/views/product.ejs +++ b/src/views/product.ejs @@ -18,6 +18,8 @@

В наличии: <%= product.stock %> шт.

<% if (error) { %>

<%= error %>

<% } %> + <% if (notifySuccess) { %>

<%= notifySuccess %>

<% } %> + <% if (notifyError) { %>

<%= notifyError %>

<% } %> <% if (reserved) { %>

Товар успешно забронирован. Подробности на почте и в личном кабинете.

<% } %> <% if (userReservation) { %>

@@ -52,6 +54,31 @@ <% } %> <% } else { %>

Нет в наличии

+ + <% if (typeof cookieConsent !== 'undefined' && !cookieConsent) { %> +

Примите cookies, чтобы подписаться на уведомление о поступлении.

+ <% } else if (stockAlertSubscribed) { %> +

+ Вы подписаны на уведомление<% if (notifyEmail) { %> — письмо придёт на <%= notifyEmail %><% } %>. +

+ <% } else { %> +
+

Сообщить о поступлении

+

Будьте среди первых, кто узнает, когда товар снова появится в наличии.

+
+ <% if (user && notifyEmail) { %> +

Уведомление отправим на <%= notifyEmail %>

+ + <% } else { %> + + <% } %> + + +
+ <% } %> <% } %> ← Назад в каталог
Категория Цена ОстатокПодписки
<%= p.name %> <%= p.category_name || '—' %> <%= formatPrice(p.price_cents) %><%= p.stock %> +
+ + +
+
+ <% if (p.alert_count > 0) { %> + <%= p.alert_count %> + <% } else { %> + — + <% } %> + На сайте