From ade031b0e7b71296b61e9be806723ffb497d5614 Mon Sep 17 00:00:00 2001 From: shop Date: Sun, 17 May 2026 11:38:52 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B1=D1=80=D0=BE=D0=BD=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=82=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=20=D0=B8=20=D1=81=D0=B1=D1=80=D0=BE=D1=81=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F=20=D0=BF=D0=BE=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 11 ++ package.json | 1 + postgres/init/03_reservations_reset.sql | 28 ++++ src/routes/account.js | 26 +++- src/routes/admin.js | 38 +++++ src/routes/password-reset.js | 176 ++++++++++++++++++++++++ src/routes/reservations.js | 95 +++++++++++++ src/routes/shop.js | 21 ++- src/server.js | 4 + src/services/mail.js | 77 +++++++++++ src/services/reservations.js | 10 ++ src/views/account/index.ejs | 40 ++++++ src/views/admin/dashboard.ejs | 1 + src/views/admin/orders.ejs | 1 + src/views/admin/products.ejs | 1 + src/views/admin/reservations.ejs | 53 +++++++ src/views/admin/users.ejs | 1 + src/views/auth/forgot-password.ejs | 18 +++ src/views/auth/reset-password-done.ejs | 11 ++ src/views/auth/reset-password.ejs | 28 ++++ src/views/login.ejs | 5 +- src/views/product.ejs | 23 ++++ 22 files changed, 666 insertions(+), 3 deletions(-) create mode 100644 postgres/init/03_reservations_reset.sql create mode 100644 src/routes/password-reset.js create mode 100644 src/routes/reservations.js create mode 100644 src/services/mail.js create mode 100644 src/services/reservations.js create mode 100644 src/views/admin/reservations.ejs create mode 100644 src/views/auth/forgot-password.ejs create mode 100644 src/views/auth/reset-password-done.ejs create mode 100644 src/views/auth/reset-password.ejs diff --git a/.env.example b/.env.example index 220bbaa..178c22c 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,17 @@ ADMIN_EMAIL=admin@site.com ADMIN_PASSWORD=admin ADMIN_NAME=Администратор +# URL сайта (ссылки в письмах) +SITE_URL=http://localhost:3000 + +# SMTP — сброс пароля и уведомления о брони +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +SMTP_FROM=shop@example.com + # PostgreSQL 17 (одна строка или отдельные переменные) DATABASE_URL=postgresql://shop:shop@127.0.0.1:5432/shop # PGHOST=127.0.0.1 diff --git a/package.json b/package.json index 3a5b7c3..9172e38 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "ejs": "^3.1.10", "express": "^4.21.2", "express-session": "^1.18.1", + "nodemailer": "^6.9.16", "pg": "^8.13.1" } } diff --git a/postgres/init/03_reservations_reset.sql b/postgres/init/03_reservations_reset.sql new file mode 100644 index 0000000..3cefd55 --- /dev/null +++ b/postgres/init/03_reservations_reset.sql @@ -0,0 +1,28 @@ +-- Бронирование товаров +CREATE TABLE IF NOT EXISTS reservations ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL CHECK (quantity > 0), + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'fulfilled', 'cancelled', 'expired')), + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '48 hours'), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_reservations_user ON reservations(user_id); +CREATE INDEX IF NOT EXISTS idx_reservations_product ON reservations(product_id); +CREATE INDEX IF NOT EXISTS idx_reservations_status ON reservations(status); + +-- Сброс пароля +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_password_reset_user ON password_reset_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_password_reset_expires ON password_reset_tokens(expires_at); diff --git a/src/routes/account.js b/src/routes/account.js index 8521b24..f375bd1 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -6,6 +6,7 @@ const { requireAuth } = require('../middleware/auth'); const { requireCookieConsent } = require('../middleware/cookieConsent'); const { ROLE_LABELS } = require('../constants/roles'); const { asyncHandler } = require('../utils/asyncHandler'); +const { expireOldReservations } = require('../services/reservations'); const router = express.Router(); @@ -35,12 +36,22 @@ async function verifyPassword(userId, password) { } function accountRender(res, options) { - const { user, orderCount, error, success, activeTab } = options; + const { + user, + orderCount, + reservations, + error, + success, + activeTab, + formatPrice, + } = options; res.render('account/index', { title: 'Личный кабинет', user, orderCount, + reservations: reservations || [], roleLabels: ROLE_LABELS, + formatPrice: formatPrice || res.locals.formatPrice, error: error || null, success: success || null, activeTab: activeTab || 'profile', @@ -51,14 +62,27 @@ router.get( '/', requireAuth, asyncHandler(async (req, res) => { + await expireOldReservations(); const user = await loadAccountUser(req.session.userId); const countResult = await query( 'SELECT COUNT(*)::int AS n FROM orders WHERE user_id = $1', [user.id] ); + + const { rows: reservations } = await query( + `SELECT r.*, p.name AS product_name, p.slug AS product_slug, p.price_cents, p.image_url + FROM reservations r + JOIN products p ON p.id = r.product_id + WHERE r.user_id = $1 + ORDER BY r.created_at DESC`, + [user.id] + ); + accountRender(res, { user, orderCount: countResult.rows[0].n, + reservations, + formatPrice, success: req.query.success ? decodeURIComponent(String(req.query.success)) : null, error: req.query.error ? decodeURIComponent(String(req.query.error)) : null, activeTab: req.query.tab || 'profile', diff --git a/src/routes/admin.js b/src/routes/admin.js index 7b55bcd..02fc08b 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -104,4 +104,42 @@ router.get( }) ); +router.get( + '/reservations', + asyncHandler(async (req, res) => { + const { expireOldReservations } = require('../services/reservations'); + await expireOldReservations(); + + const { rows: reservations } = await query( + `SELECT r.*, p.name AS product_name, u.email AS user_email, u.name AS user_name + FROM reservations r + JOIN products p ON p.id = r.product_id + JOIN users u ON u.id = r.user_id + ORDER BY r.created_at DESC` + ); + + res.render('admin/reservations', { + title: 'Бронирования', + reservations, + formatPrice, + }); + }) +); + +router.post( + '/reservations/:id/status', + asyncHandler(async (req, res) => { + const { status } = req.body; + const allowed = ['active', 'fulfilled', 'cancelled', 'expired']; + if (!allowed.includes(status)) { + return res.redirect('/admin/reservations'); + } + await query('UPDATE reservations SET status = $1 WHERE id = $2', [ + status, + req.params.id, + ]); + res.redirect('/admin/reservations'); + }) +); + module.exports = router; diff --git a/src/routes/password-reset.js b/src/routes/password-reset.js new file mode 100644 index 0000000..ded58de --- /dev/null +++ b/src/routes/password-reset.js @@ -0,0 +1,176 @@ +const express = require('express'); +const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); +const { query } = require('../db'); +const { getCart, cartCount } = require('../cart'); +const { formatPrice } = require('../db'); +const { requireCookieConsent } = require('../middleware/cookieConsent'); +const { asyncHandler } = require('../utils/asyncHandler'); +const { sendPasswordResetEmail, siteUrl } = require('../services/mail'); + +const router = express.Router(); +const TOKEN_TTL_MS = 60 * 60 * 1000; + +router.use((req, res, next) => { + res.locals.cartCount = cartCount(getCart(req)); + res.locals.formatPrice = formatPrice; + next(); +}); + +function hashToken(token) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +router.get('/forgot-password', requireCookieConsent, (req, res) => { + res.render('auth/forgot-password', { + title: 'Сброс пароля', + error: null, + success: null, + values: {}, + }); +}); + +router.post( + '/forgot-password', + requireCookieConsent, + asyncHandler(async (req, res) => { + const email = (req.body.email || '').trim().toLowerCase(); + const values = { email }; + const genericSuccess = + 'Если аккаунт с таким email существует, мы отправили ссылку для сброса пароля.'; + + if (!email) { + return res.status(400).render('auth/forgot-password', { + title: 'Сброс пароля', + error: 'Укажите email', + success: null, + values, + }); + } + + const { rows } = await query('SELECT id, email FROM users WHERE email = $1', [email]); + + if (rows[0]) { + const token = crypto.randomBytes(32).toString('hex'); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + TOKEN_TTL_MS); + + await query( + `UPDATE password_reset_tokens SET used_at = NOW() + WHERE user_id = $1 AND used_at IS NULL`, + [rows[0].id] + ); + + await query( + `INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3)`, + [rows[0].id, tokenHash, expiresAt] + ); + + const resetLink = `${siteUrl()}/reset-password?token=${token}`; + try { + await sendPasswordResetEmail(rows[0].email, resetLink); + } catch (err) { + console.error('Ошибка отправки email:', err.message); + return res.status(500).render('auth/forgot-password', { + title: 'Сброс пароля', + error: 'Не удалось отправить письмо. Проверьте настройки SMTP.', + success: null, + values, + }); + } + } + + res.render('auth/forgot-password', { + title: 'Сброс пароля', + error: null, + success: genericSuccess, + values: {}, + }); + }) +); + +router.get( + '/reset-password', + requireCookieConsent, + asyncHandler(async (req, res) => { + const token = req.query.token || ''; + if (!token) { + return res.redirect('/forgot-password'); + } + + const valid = await findValidToken(token); + if (!valid) { + return res.render('auth/reset-password', { + title: 'Новый пароль', + error: 'Ссылка недействительна или устарела. Запросите сброс снова.', + token: null, + }); + } + + res.render('auth/reset-password', { + title: 'Новый пароль', + error: null, + token, + }); + }) +); + +router.post( + '/reset-password', + requireCookieConsent, + asyncHandler(async (req, res) => { + const { token, password, password2 } = req.body; + + if (!token) { + return res.redirect('/forgot-password'); + } + + if (!password || password.length < 6) { + return res.render('auth/reset-password', { + title: 'Новый пароль', + error: 'Пароль не менее 6 символов', + token, + }); + } + + if (password !== password2) { + return res.render('auth/reset-password', { + title: 'Новый пароль', + error: 'Пароли не совпадают', + token, + }); + } + + const row = await findValidToken(token); + if (!row) { + return res.render('auth/reset-password', { + title: 'Новый пароль', + error: 'Ссылка недействительна или устарела', + token: null, + }); + } + + const hash = bcrypt.hashSync(password, 10); + await query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, row.user_id]); + await query( + `UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, + [row.id] + ); + + res.render('auth/reset-password-done', { title: 'Пароль изменён' }); + }) +); + +async function findValidToken(token) { + const tokenHash = hashToken(token); + const { rows } = await query( + `SELECT id, user_id FROM password_reset_tokens + WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC LIMIT 1`, + [tokenHash] + ); + return rows[0] || null; +} + +module.exports = router; diff --git a/src/routes/reservations.js b/src/routes/reservations.js new file mode 100644 index 0000000..086ef34 --- /dev/null +++ b/src/routes/reservations.js @@ -0,0 +1,95 @@ +const express = require('express'); +const { query, formatPrice } = require('../db'); +const { getCart, cartCount } = require('../cart'); +const { requireAuth } = require('../middleware/auth'); +const { requireCookieConsent } = require('../middleware/cookieConsent'); +const { asyncHandler } = require('../utils/asyncHandler'); +const { sendReservationEmail } = require('../services/mail'); + +const router = express.Router(); + +router.use(requireCookieConsent); +router.use(requireAuth); + +router.use((req, res, next) => { + res.locals.cartCount = cartCount(getCart(req)); + res.locals.formatPrice = formatPrice; + next(); +}); + +router.post( + '/', + asyncHandler(async (req, res) => { + const productId = parseInt(req.body.product_id, 10); + const quantity = Math.max(1, parseInt(req.body.quantity, 10) || 1); + const slug = req.body.slug || ''; + + const { rows: products } = await query( + 'SELECT id, name, stock FROM products WHERE id = $1', + [productId] + ); + const product = products[0]; + + if (!product) { + return res.redirect('/'); + } + + if (product.stock < quantity) { + return res.redirect( + `/product/${slug}?error=${encodeURIComponent('Недостаточно товара на складе')}` + ); + } + + const { rows: existing } = await query( + `SELECT id FROM reservations + WHERE user_id = $1 AND product_id = $2 AND status = 'active'`, + [req.session.userId, productId] + ); + + if (existing[0]) { + return res.redirect( + `/product/${slug}?error=${encodeURIComponent('У вас уже есть активная бронь этого товара')}` + ); + } + + const { rows: inserted } = await query( + `INSERT INTO reservations (user_id, product_id, quantity, status, expires_at) + VALUES ($1, $2, $3, 'active', NOW() + INTERVAL '48 hours') + RETURNING id, expires_at`, + [req.session.userId, productId, quantity] + ); + + const { rows: userRows } = await query('SELECT email FROM users WHERE id = $1', [ + req.session.userId, + ]); + + try { + await sendReservationEmail( + userRows[0].email, + product.name, + quantity, + inserted[0].expires_at + ); + } catch (err) { + console.error('Ошибка email бронирования:', err.message); + } + + res.redirect( + `/product/${slug}?reserved=1` + ); + }) +); + +router.post( + '/:id/cancel', + asyncHandler(async (req, res) => { + await query( + `UPDATE reservations SET status = 'cancelled' + WHERE id = $1 AND user_id = $2 AND status = 'active'`, + [req.params.id, req.session.userId] + ); + res.redirect('/account?tab=reservations&success=' + encodeURIComponent('Бронь отменена')); + }) +); + +module.exports = router; diff --git a/src/routes/shop.js b/src/routes/shop.js index 52b0cc1..88cb299 100644 --- a/src/routes/shop.js +++ b/src/routes/shop.js @@ -77,7 +77,26 @@ router.get( }); } - res.render('product', { title: product.name, product }); + let userReservation = null; + if (req.session.userId) { + const { rows: resRows } = await query( + `SELECT id, quantity, expires_at FROM reservations + WHERE user_id = $1 AND product_id = $2 AND status = 'active'`, + [req.session.userId, product.id] + ); + userReservation = resRows[0] || null; + } + + const errorMsg = req.query.error ? decodeURIComponent(String(req.query.error)) : null; + const reserved = req.query.reserved === '1'; + + res.render('product', { + title: product.name, + product, + userReservation, + error: errorMsg, + reserved, + }); }) ); diff --git a/src/server.js b/src/server.js index 91e1004..f95ed52 100644 --- a/src/server.js +++ b/src/server.js @@ -15,6 +15,8 @@ const authRoutes = require('./routes/auth'); const accountRoutes = require('./routes/account'); const adminRoutes = require('./routes/admin'); const cookiesRoutes = require('./routes/cookies'); +const passwordResetRoutes = require('./routes/password-reset'); +const reservationsRoutes = require('./routes/reservations'); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || '0.0.0.0'; @@ -62,6 +64,8 @@ async function start() { app.use(loadCookieConsent); app.use(loadUser); app.use('/cookies', cookiesRoutes); + app.use('/', passwordResetRoutes); + app.use('/reservations', reservationsRoutes); app.use('/', shopRoutes); app.use('/', authRoutes); app.use('/account', accountRoutes); diff --git a/src/services/mail.js b/src/services/mail.js new file mode 100644 index 0000000..de9b25f --- /dev/null +++ b/src/services/mail.js @@ -0,0 +1,77 @@ +const nodemailer = require('nodemailer'); + +let transporter = null; + +function isConfigured() { + return Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM); +} + +function getTransporter() { + if (!isConfigured()) return null; + if (!transporter) { + transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT || '587', 10), + secure: process.env.SMTP_SECURE === 'true', + auth: + process.env.SMTP_USER && process.env.SMTP_PASS + ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } + : undefined, + }); + } + return transporter; +} + +function siteUrl() { + return (process.env.SITE_URL || 'http://localhost:3000').replace(/\/$/, ''); +} + +async function sendMail({ to, subject, text, html }) { + const from = process.env.SMTP_FROM || 'shop@localhost'; + const payload = { from, to, subject, text, html: html || text }; + + const transport = getTransporter(); + if (!transport) { + console.log('--- Email (SMTP не настроен) ---'); + console.log('To:', to); + console.log('Subject:', subject); + console.log(text); + console.log('--------------------------------'); + return { logged: true }; + } + + await transport.sendMail(payload); + return { sent: true }; +} + +async function sendPasswordResetEmail(to, resetLink) { + const subject = 'Сброс пароля — Shop'; + const text = `Вы запросили сброс пароля.\n\nПерейдите по ссылке (действует 1 час):\n${resetLink}\n\nЕсли это были не вы, проигнорируйте письмо.`; + const html = ` +

Вы запросили сброс пароля в магазине Shop.

+

Сбросить пароль

+

Ссылка действует 1 час.

+

Если вы не запрашивали сброс, просто удалите это письмо.

+ `; + return sendMail({ to, subject, text, html }); +} + +async function sendReservationEmail(to, productName, quantity, expiresAt) { + const subject = `Бронирование: ${productName}`; + const exp = new Date(expiresAt).toLocaleString('ru-RU'); + const text = `Товар «${productName}» забронирован (${quantity} шт.) до ${exp}.\n\n${siteUrl()}/account?tab=reservations`; + const html = ` +

Вы забронировали ${productName} — ${quantity} шт.

+

Бронь активна до: ${exp}

+

Мои бронирования

+ `; + return sendMail({ to, subject, text, html }); +} + +module.exports = { + isConfigured, + sendMail, + sendPasswordResetEmail, + sendReservationEmail, + siteUrl, +}; diff --git a/src/services/reservations.js b/src/services/reservations.js new file mode 100644 index 0000000..04c6f95 --- /dev/null +++ b/src/services/reservations.js @@ -0,0 +1,10 @@ +const { query } = require('../db'); + +async function expireOldReservations() { + await query( + `UPDATE reservations SET status = 'expired' + WHERE status = 'active' AND expires_at < NOW()` + ); +} + +module.exports = { expireOldReservations }; diff --git a/src/views/account/index.ejs b/src/views/account/index.ejs index e051d36..8116081 100644 --- a/src/views/account/index.ejs +++ b/src/views/account/index.ejs @@ -10,6 +10,7 @@ + <% if (activeTab === 'profile') { %> @@ -62,6 +63,45 @@ <% } %> + <% if (activeTab === 'reservations') { %> + + <% } %> + <% if (activeTab === 'password') { %>