feat: PostgreSQL 17 вместо SQLite
pg + connect-pg-simple, async routes, docker-compose, скрипт setup-postgres. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+8
-5
@@ -1,3 +1,5 @@
|
||||
const { query } = require('./db');
|
||||
|
||||
function getCart(req) {
|
||||
if (!req.session.cart) {
|
||||
req.session.cart = {};
|
||||
@@ -9,14 +11,15 @@ function cartCount(cart) {
|
||||
return Object.values(cart).reduce((sum, qty) => sum + qty, 0);
|
||||
}
|
||||
|
||||
function cartItems(db, cart) {
|
||||
async function cartItems(cart) {
|
||||
const ids = Object.keys(cart).map(Number).filter(Boolean);
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const products = db
|
||||
.prepare(`SELECT * FROM products WHERE id IN (${placeholders})`)
|
||||
.all(...ids);
|
||||
const placeholders = ids.map((_, i) => `$${i + 1}`).join(',');
|
||||
const { rows: products } = await query(
|
||||
`SELECT * FROM products WHERE id IN (${placeholders})`,
|
||||
ids
|
||||
);
|
||||
|
||||
return products
|
||||
.map((p) => ({
|
||||
|
||||
@@ -1,69 +1,39 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const dataDir = path.join(__dirname, '..', 'data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
function buildPoolConfig() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
return { connectionString: process.env.DATABASE_URL };
|
||||
}
|
||||
return {
|
||||
host: process.env.PGHOST || '127.0.0.1',
|
||||
port: parseInt(process.env.PGPORT || '5432', 10),
|
||||
user: process.env.PGUSER || 'shop',
|
||||
password: process.env.PGPASSWORD || 'shop',
|
||||
database: process.env.PGDATABASE || 'shop',
|
||||
};
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, 'shop.db');
|
||||
const db = new Database(dbPath);
|
||||
const pool = new Pool(buildPoolConfig());
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
pool.on('error', (err) => {
|
||||
console.error('PostgreSQL pool error:', err.message);
|
||||
});
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
async function query(text, params) {
|
||||
return pool.query(text, params);
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
async function initSchema() {
|
||||
const schemaPath = path.join(__dirname, '..', 'postgres', 'init', '01_schema.sql');
|
||||
const sql = fs.readFileSync(schemaPath, 'utf8');
|
||||
await pool.query(sql);
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
|
||||
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
|
||||
image_url TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled')),
|
||||
total_cents INTEGER NOT NULL CHECK (total_cents >= 0),
|
||||
customer_name TEXT NOT NULL,
|
||||
customer_email TEXT NOT NULL,
|
||||
customer_phone TEXT NOT NULL DEFAULT '',
|
||||
address TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
price_cents INTEGER NOT NULL CHECK (price_cents >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_products_category ON products(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id);
|
||||
`);
|
||||
async function checkConnection() {
|
||||
await pool.query('SELECT 1');
|
||||
}
|
||||
|
||||
function formatPrice(cents) {
|
||||
return (cents / 100).toLocaleString('ru-RU', {
|
||||
@@ -73,4 +43,10 @@ function formatPrice(cents) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { db, formatPrice, dbPath };
|
||||
module.exports = {
|
||||
pool,
|
||||
query,
|
||||
initSchema,
|
||||
checkConnection,
|
||||
formatPrice,
|
||||
};
|
||||
|
||||
+10
-7
@@ -1,3 +1,6 @@
|
||||
const { query } = require('../db');
|
||||
const { asyncHandler } = require('../utils/asyncHandler');
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.session.userId) {
|
||||
const nextUrl = encodeURIComponent(req.originalUrl);
|
||||
@@ -6,17 +9,17 @@ function requireAuth(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
function loadUser(req, res, next) {
|
||||
const loadUser = asyncHandler(async (req, res, next) => {
|
||||
if (req.session.userId) {
|
||||
const { db } = require('../db');
|
||||
const user = db
|
||||
.prepare('SELECT id, email, name FROM users WHERE id = ?')
|
||||
.get(req.session.userId);
|
||||
res.locals.user = user || null;
|
||||
const { rows } = await query(
|
||||
'SELECT id, email, name FROM users WHERE id = $1',
|
||||
[req.session.userId]
|
||||
);
|
||||
res.locals.user = rows[0] || null;
|
||||
} else {
|
||||
res.locals.user = null;
|
||||
}
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = { requireAuth, loadUser };
|
||||
|
||||
+87
-67
@@ -1,15 +1,16 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { db } = require('../db');
|
||||
const { query, formatPrice } = require('../db');
|
||||
const { getCart, cartCount } = require('../cart');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { asyncHandler } = require('../utils/asyncHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use((req, res, next) => {
|
||||
const cart = getCart(req);
|
||||
res.locals.cartCount = cartCount(cart);
|
||||
res.locals.formatPrice = require('../db').formatPrice;
|
||||
res.locals.formatPrice = formatPrice;
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -18,50 +19,54 @@ router.get('/register', (req, res) => {
|
||||
res.render('register', { title: 'Регистрация', error: null, values: {} });
|
||||
});
|
||||
|
||||
router.post('/register', (req, res) => {
|
||||
const { name, email, password, password2 } = req.body;
|
||||
const values = { name, email };
|
||||
router.post(
|
||||
'/register',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { name, email, password, password2 } = req.body;
|
||||
const values = { name, email };
|
||||
|
||||
if (!name?.trim() || !email?.trim() || !password) {
|
||||
return res.status(400).render('register', {
|
||||
title: 'Регистрация',
|
||||
error: 'Заполните все поля',
|
||||
values,
|
||||
});
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return res.status(400).render('register', {
|
||||
title: 'Регистрация',
|
||||
error: 'Пароль не менее 6 символов',
|
||||
values,
|
||||
});
|
||||
}
|
||||
if (password !== password2) {
|
||||
return res.status(400).render('register', {
|
||||
title: 'Регистрация',
|
||||
error: 'Пароли не совпадают',
|
||||
values,
|
||||
});
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
try {
|
||||
const r = db
|
||||
.prepare('INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)')
|
||||
.run(email.trim().toLowerCase(), hash, name.trim());
|
||||
req.session.userId = r.lastInsertRowid;
|
||||
res.redirect('/');
|
||||
} catch (err) {
|
||||
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
if (!name?.trim() || !email?.trim() || !password) {
|
||||
return res.status(400).render('register', {
|
||||
title: 'Регистрация',
|
||||
error: 'Этот email уже зарегистрирован',
|
||||
error: 'Заполните все поля',
|
||||
values,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
if (password.length < 6) {
|
||||
return res.status(400).render('register', {
|
||||
title: 'Регистрация',
|
||||
error: 'Пароль не менее 6 символов',
|
||||
values,
|
||||
});
|
||||
}
|
||||
if (password !== password2) {
|
||||
return res.status(400).render('register', {
|
||||
title: 'Регистрация',
|
||||
error: 'Пароли не совпадают',
|
||||
values,
|
||||
});
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
try {
|
||||
const { rows } = await query(
|
||||
'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id',
|
||||
[email.trim().toLowerCase(), hash, name.trim()]
|
||||
);
|
||||
req.session.userId = rows[0].id;
|
||||
res.redirect('/');
|
||||
} catch (err) {
|
||||
if (err.code === '23505') {
|
||||
return res.status(400).render('register', {
|
||||
title: 'Регистрация',
|
||||
error: 'Этот email уже зарегистрирован',
|
||||
values,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
router.get('/login', (req, res) => {
|
||||
if (req.session.userId) return res.redirect('/account');
|
||||
@@ -73,27 +78,31 @@ router.get('/login', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
const next = req.body.next || '/';
|
||||
const values = { email };
|
||||
router.post(
|
||||
'/login',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
const next = req.body.next || '/';
|
||||
const values = { email };
|
||||
|
||||
const user = db
|
||||
.prepare('SELECT * FROM users WHERE email = ?')
|
||||
.get((email || '').trim().toLowerCase());
|
||||
const { rows } = await query('SELECT * FROM users WHERE email = $1', [
|
||||
(email || '').trim().toLowerCase(),
|
||||
]);
|
||||
const user = rows[0];
|
||||
|
||||
if (!user || !bcrypt.compareSync(password || '', user.password_hash)) {
|
||||
return res.status(401).render('login', {
|
||||
title: 'Вход',
|
||||
error: 'Неверный email или пароль',
|
||||
next,
|
||||
values,
|
||||
});
|
||||
}
|
||||
if (!user || !bcrypt.compareSync(password || '', user.password_hash)) {
|
||||
return res.status(401).render('login', {
|
||||
title: 'Вход',
|
||||
error: 'Неверный email или пароль',
|
||||
next,
|
||||
values,
|
||||
});
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
res.redirect(next.startsWith('/') ? next : '/');
|
||||
});
|
||||
req.session.userId = user.id;
|
||||
res.redirect(next.startsWith('/') ? next : '/');
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
@@ -101,16 +110,27 @@ router.post('/logout', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/account', requireAuth, (req, res) => {
|
||||
const user = db
|
||||
.prepare('SELECT id, email, name, created_at FROM users WHERE id = ?')
|
||||
.get(req.session.userId);
|
||||
router.get(
|
||||
'/account',
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { rows } = await query(
|
||||
'SELECT id, email, name, created_at FROM users WHERE id = $1',
|
||||
[req.session.userId]
|
||||
);
|
||||
const user = rows[0];
|
||||
|
||||
const orderCount = db
|
||||
.prepare('SELECT COUNT(*) AS n FROM orders WHERE user_id = ?')
|
||||
.get(user.id).n;
|
||||
const countResult = await query(
|
||||
'SELECT COUNT(*)::int AS n FROM orders WHERE user_id = $1',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
res.render('account', { title: 'Личный кабинет', user, orderCount });
|
||||
});
|
||||
res.render('account', {
|
||||
title: 'Личный кабинет',
|
||||
user,
|
||||
orderCount: countResult.rows[0].n,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../db');
|
||||
const { checkConnection } = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/health', (_req, res) => {
|
||||
router.get('/health', async (_req, res) => {
|
||||
try {
|
||||
db.prepare('SELECT 1').get();
|
||||
res.json({ ok: true, service: 'shop' });
|
||||
await checkConnection();
|
||||
res.json({ ok: true, service: 'shop', database: 'postgresql' });
|
||||
} catch (err) {
|
||||
res.status(503).json({ ok: false, error: err.message });
|
||||
}
|
||||
|
||||
+239
-206
@@ -1,7 +1,8 @@
|
||||
const express = require('express');
|
||||
const { db, formatPrice } = require('../db');
|
||||
const { query, pool, formatPrice } = require('../db');
|
||||
const { getCart, cartCount, cartItems, cartTotal } = require('../cart');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { asyncHandler } = require('../utils/asyncHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -16,120 +17,135 @@ router.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const category = req.query.category || '';
|
||||
const q = (req.query.q || '').trim();
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const category = req.query.category || '';
|
||||
const q = (req.query.q || '').trim();
|
||||
|
||||
let sql = `
|
||||
SELECT p.*, c.name AS category_name, c.slug AS category_slug
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
WHERE p.stock > 0
|
||||
`;
|
||||
const params = [];
|
||||
let sql = `
|
||||
SELECT p.*, c.name AS category_name, c.slug AS category_slug
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
WHERE p.stock > 0
|
||||
`;
|
||||
const params = [];
|
||||
let n = 1;
|
||||
|
||||
if (category) {
|
||||
sql += ' AND c.slug = ?';
|
||||
params.push(category);
|
||||
}
|
||||
if (q) {
|
||||
sql += ' AND (p.name LIKE ? OR p.description LIKE ?)';
|
||||
const like = `%${q}%`;
|
||||
params.push(like, like);
|
||||
}
|
||||
sql += ' ORDER BY p.name';
|
||||
if (category) {
|
||||
sql += ` AND c.slug = $${n++}`;
|
||||
params.push(category);
|
||||
}
|
||||
if (q) {
|
||||
sql += ` AND (p.name ILIKE $${n} OR p.description ILIKE $${n})`;
|
||||
params.push(`%${q}%`);
|
||||
n++;
|
||||
}
|
||||
sql += ' ORDER BY p.name';
|
||||
|
||||
const products = db.prepare(sql).all(...params);
|
||||
const categories = db.prepare('SELECT * FROM categories ORDER BY name').all();
|
||||
const { rows: products } = await query(sql, params);
|
||||
const { rows: categories } = await query('SELECT * FROM categories ORDER BY name');
|
||||
|
||||
res.render('home', {
|
||||
title: 'Каталог',
|
||||
products,
|
||||
categories,
|
||||
activeCategory: category,
|
||||
searchQuery: q,
|
||||
});
|
||||
});
|
||||
res.render('home', {
|
||||
title: 'Каталог',
|
||||
products,
|
||||
categories,
|
||||
activeCategory: category,
|
||||
searchQuery: q,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.get('/product/:slug', (req, res) => {
|
||||
const product = db
|
||||
.prepare(
|
||||
router.get(
|
||||
'/product/:slug',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { rows } = await query(
|
||||
`SELECT p.*, c.name AS category_name, c.slug AS category_slug
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
WHERE p.slug = ?`
|
||||
)
|
||||
.get(req.params.slug);
|
||||
WHERE p.slug = $1`,
|
||||
[req.params.slug]
|
||||
);
|
||||
const product = rows[0];
|
||||
|
||||
if (!product) {
|
||||
return res.status(404).render('error', {
|
||||
title: 'Не найдено',
|
||||
message: 'Товар не найден',
|
||||
code: 404,
|
||||
if (!product) {
|
||||
return res.status(404).render('error', {
|
||||
title: 'Не найдено',
|
||||
message: 'Товар не найден',
|
||||
code: 404,
|
||||
});
|
||||
}
|
||||
|
||||
res.render('product', { title: product.name, product });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/cart',
|
||||
asyncHandler(async (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const items = await cartItems(cart);
|
||||
const total = cartTotal(items);
|
||||
const errorMsg = req.query.error ? decodeURIComponent(String(req.query.error)) : null;
|
||||
|
||||
res.render('cart', {
|
||||
title: 'Корзина',
|
||||
items,
|
||||
total,
|
||||
error: errorMsg,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
res.render('product', { title: product.name, product });
|
||||
});
|
||||
router.post(
|
||||
'/cart/add',
|
||||
asyncHandler(async (req, res) => {
|
||||
const productId = parseInt(req.body.product_id, 10);
|
||||
const quantity = Math.max(1, parseInt(req.body.quantity, 10) || 1);
|
||||
|
||||
router.get('/cart', (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const items = cartItems(db, cart);
|
||||
const total = cartTotal(items);
|
||||
|
||||
const errorMsg = req.query.error ? decodeURIComponent(String(req.query.error)) : null;
|
||||
res.render('cart', {
|
||||
title: 'Корзина',
|
||||
items,
|
||||
total,
|
||||
error: errorMsg,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/cart/add', (req, res) => {
|
||||
const productId = parseInt(req.body.product_id, 10);
|
||||
const quantity = Math.max(1, parseInt(req.body.quantity, 10) || 1);
|
||||
|
||||
const product = db
|
||||
.prepare('SELECT id, stock FROM products WHERE id = ?')
|
||||
.get(productId);
|
||||
if (!product) {
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
const cart = getCart(req);
|
||||
const current = cart[productId] || 0;
|
||||
const nextQty = Math.min(product.stock, current + quantity);
|
||||
cart[productId] = nextQty;
|
||||
|
||||
const redirect = req.body.redirect || '/cart';
|
||||
res.redirect(redirect);
|
||||
});
|
||||
|
||||
router.post('/cart/update', (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const updates = req.body.items || {};
|
||||
|
||||
for (const [id, qty] of Object.entries(updates)) {
|
||||
const productId = parseInt(id, 10);
|
||||
const quantity = parseInt(qty, 10);
|
||||
if (!productId) continue;
|
||||
|
||||
if (!quantity || quantity <= 0) {
|
||||
delete cart[productId];
|
||||
continue;
|
||||
const { rows } = await query('SELECT id, stock FROM products WHERE id = $1', [
|
||||
productId,
|
||||
]);
|
||||
const product = rows[0];
|
||||
if (!product) {
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
const product = db
|
||||
.prepare('SELECT stock FROM products WHERE id = ?')
|
||||
.get(productId);
|
||||
if (product) {
|
||||
cart[productId] = Math.min(product.stock, quantity);
|
||||
}
|
||||
}
|
||||
const cart = getCart(req);
|
||||
const current = cart[productId] || 0;
|
||||
cart[productId] = Math.min(product.stock, current + quantity);
|
||||
|
||||
res.redirect('/cart');
|
||||
});
|
||||
res.redirect(req.body.redirect || '/cart');
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/cart/update',
|
||||
asyncHandler(async (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const updates = req.body.items || {};
|
||||
|
||||
for (const [id, qty] of Object.entries(updates)) {
|
||||
const productId = parseInt(id, 10);
|
||||
const quantity = parseInt(qty, 10);
|
||||
if (!productId) continue;
|
||||
|
||||
if (!quantity || quantity <= 0) {
|
||||
delete cart[productId];
|
||||
continue;
|
||||
}
|
||||
|
||||
const { rows } = await query('SELECT stock FROM products WHERE id = $1', [
|
||||
productId,
|
||||
]);
|
||||
if (rows[0]) {
|
||||
cart[productId] = Math.min(rows[0].stock, quantity);
|
||||
}
|
||||
}
|
||||
|
||||
res.redirect('/cart');
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/cart/remove/:id', (req, res) => {
|
||||
const cart = getCart(req);
|
||||
@@ -137,131 +153,148 @@ router.post('/cart/remove/:id', (req, res) => {
|
||||
res.redirect('/cart');
|
||||
});
|
||||
|
||||
router.get('/checkout', requireAuth, (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const items = cartItems(db, cart);
|
||||
if (items.length === 0) {
|
||||
return res.redirect('/cart');
|
||||
}
|
||||
router.get(
|
||||
'/checkout',
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const items = await cartItems(cart);
|
||||
if (items.length === 0) {
|
||||
return res.redirect('/cart');
|
||||
}
|
||||
|
||||
res.render('checkout', {
|
||||
title: 'Оформление заказа',
|
||||
items,
|
||||
total: cartTotal(items),
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/checkout', requireAuth, (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const items = cartItems(db, cart);
|
||||
if (items.length === 0) {
|
||||
return res.redirect('/cart');
|
||||
}
|
||||
|
||||
const { name, email, phone, address } = req.body;
|
||||
if (!name?.trim() || !email?.trim() || !address?.trim()) {
|
||||
return res.status(400).render('checkout', {
|
||||
res.render('checkout', {
|
||||
title: 'Оформление заказа',
|
||||
items,
|
||||
total: cartTotal(items),
|
||||
error: 'Заполните имя, email и адрес доставки',
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const total = cartTotal(items);
|
||||
router.post(
|
||||
'/checkout',
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const cart = getCart(req);
|
||||
const items = await cartItems(cart);
|
||||
if (items.length === 0) {
|
||||
return res.redirect('/cart');
|
||||
}
|
||||
|
||||
const placeOrder = db.transaction(() => {
|
||||
for (const item of items) {
|
||||
const row = db
|
||||
.prepare('SELECT stock FROM products WHERE id = ?')
|
||||
.get(item.id);
|
||||
if (!row || row.stock < item.quantity) {
|
||||
throw new Error(`Недостаточно «${item.name}» на складе`);
|
||||
const { name, email, phone, address } = req.body;
|
||||
if (!name?.trim() || !email?.trim() || !address?.trim()) {
|
||||
return res.status(400).render('checkout', {
|
||||
title: 'Оформление заказа',
|
||||
items,
|
||||
total: cartTotal(items),
|
||||
error: 'Заполните имя, email и адрес доставки',
|
||||
});
|
||||
}
|
||||
|
||||
const total = cartTotal(items);
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
for (const item of items) {
|
||||
const { rows } = await client.query(
|
||||
'SELECT stock FROM products WHERE id = $1 FOR UPDATE',
|
||||
[item.id]
|
||||
);
|
||||
if (!rows[0] || rows[0].stock < item.quantity) {
|
||||
throw new Error(`Недостаточно «${item.name}» на складе`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const order = db
|
||||
.prepare(
|
||||
const orderResult = await client.query(
|
||||
`INSERT INTO orders (user_id, status, total_cents, customer_name, customer_email, customer_phone, address)
|
||||
VALUES (?, 'pending', ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
req.session.userId,
|
||||
total,
|
||||
name.trim(),
|
||||
email.trim(),
|
||||
(phone || '').trim(),
|
||||
address.trim()
|
||||
VALUES ($1, 'pending', $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[
|
||||
req.session.userId,
|
||||
total,
|
||||
name.trim(),
|
||||
email.trim(),
|
||||
(phone || '').trim(),
|
||||
address.trim(),
|
||||
]
|
||||
);
|
||||
const orderId = orderResult.rows[0].id;
|
||||
|
||||
const insertItem = db.prepare(
|
||||
`INSERT INTO order_items (order_id, product_id, quantity, price_cents)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
);
|
||||
const updateStock = db.prepare(
|
||||
'UPDATE products SET stock = stock - ? WHERE id = ?'
|
||||
for (const item of items) {
|
||||
await client.query(
|
||||
`INSERT INTO order_items (order_id, product_id, quantity, price_cents)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[orderId, item.id, item.quantity, item.price_cents]
|
||||
);
|
||||
await client.query('UPDATE products SET stock = stock - $1 WHERE id = $2', [
|
||||
item.quantity,
|
||||
item.id,
|
||||
]);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
req.session.cart = {};
|
||||
res.redirect(`/orders/${orderId}?success=1`);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
res.redirect(`/cart?error=${encodeURIComponent(err.message)}`);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/orders',
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { rows: orders } = await query(
|
||||
`SELECT id, status, total_cents, created_at
|
||||
FROM orders WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[req.session.userId]
|
||||
);
|
||||
|
||||
for (const item of items) {
|
||||
insertItem.run(order.lastInsertRowid, item.id, item.quantity, item.price_cents);
|
||||
updateStock.run(item.quantity, item.id);
|
||||
res.render('orders', { title: 'Мои заказы', orders });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/orders/:id',
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { rows } = await query(
|
||||
'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.session.userId]
|
||||
);
|
||||
const order = rows[0];
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).render('error', {
|
||||
title: 'Не найдено',
|
||||
message: 'Заказ не найден',
|
||||
code: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return order.lastInsertRowid;
|
||||
});
|
||||
|
||||
try {
|
||||
const orderId = placeOrder();
|
||||
req.session.cart = {};
|
||||
res.redirect(`/orders/${orderId}?success=1`);
|
||||
} catch (err) {
|
||||
res.redirect(`/cart?error=${encodeURIComponent(err.message)}`);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/orders', requireAuth, (req, res) => {
|
||||
const orders = db
|
||||
.prepare(
|
||||
`SELECT id, status, total_cents, created_at
|
||||
FROM orders WHERE user_id = ?
|
||||
ORDER BY created_at DESC`
|
||||
)
|
||||
.all(req.session.userId);
|
||||
|
||||
res.render('orders', { title: 'Мои заказы', orders });
|
||||
});
|
||||
|
||||
router.get('/orders/:id', requireAuth, (req, res) => {
|
||||
const order = db
|
||||
.prepare(
|
||||
`SELECT * FROM orders WHERE id = ? AND user_id = ?`
|
||||
)
|
||||
.get(req.params.id, req.session.userId);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).render('error', {
|
||||
title: 'Не найдено',
|
||||
message: 'Заказ не найден',
|
||||
code: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const items = db
|
||||
.prepare(
|
||||
const { rows: items } = await query(
|
||||
`SELECT oi.*, p.name, p.slug, p.image_url
|
||||
FROM order_items oi
|
||||
JOIN products p ON p.id = oi.product_id
|
||||
WHERE oi.order_id = ?`
|
||||
)
|
||||
.all(order.id);
|
||||
WHERE oi.order_id = $1`,
|
||||
[order.id]
|
||||
);
|
||||
|
||||
res.render('order', {
|
||||
title: `Заказ #${order.id}`,
|
||||
order,
|
||||
items,
|
||||
success: req.query.success === '1',
|
||||
});
|
||||
});
|
||||
res.render('order', {
|
||||
title: `Заказ #${order.id}`,
|
||||
order,
|
||||
items,
|
||||
success: req.query.success === '1',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+111
-108
@@ -1,20 +1,12 @@
|
||||
const { db } = require('./db');
|
||||
const { query } = require('./db');
|
||||
|
||||
function runSeed() {
|
||||
const count = db.prepare('SELECT COUNT(*) AS n FROM products').get().n;
|
||||
if (count > 0) {
|
||||
async function runSeed() {
|
||||
const { rows } = await query('SELECT COUNT(*)::int AS n FROM products');
|
||||
if (rows[0].n > 0) {
|
||||
console.log('База уже содержит товары, пропуск seed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const insertCategory = db.prepare(
|
||||
'INSERT INTO categories (slug, name) VALUES (?, ?)'
|
||||
);
|
||||
const insertProduct = db.prepare(`
|
||||
INSERT INTO products (category_id, slug, name, description, price_cents, stock, image_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const categories = [
|
||||
{ slug: 'electronics', name: 'Электроника' },
|
||||
{ slug: 'clothing', name: 'Одежда' },
|
||||
@@ -22,114 +14,125 @@ function runSeed() {
|
||||
];
|
||||
|
||||
const categoryIds = {};
|
||||
const seed = db.transaction(() => {
|
||||
for (const c of categories) {
|
||||
const r = insertCategory.run(c.slug, c.name);
|
||||
categoryIds[c.slug] = r.lastInsertRowid;
|
||||
}
|
||||
for (const c of categories) {
|
||||
const r = await query(
|
||||
'INSERT INTO categories (slug, name) VALUES ($1, $2) RETURNING id',
|
||||
[c.slug, c.name]
|
||||
);
|
||||
categoryIds[c.slug] = r.rows[0].id;
|
||||
}
|
||||
|
||||
const products = [
|
||||
{
|
||||
cat: 'electronics',
|
||||
slug: 'wireless-headphones',
|
||||
name: 'Беспроводные наушники',
|
||||
description: 'Шумоподавление, 30 ч автономности, Bluetooth 5.3.',
|
||||
price: 499000,
|
||||
stock: 24,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'electronics',
|
||||
slug: 'smart-watch',
|
||||
name: 'Умные часы',
|
||||
description: 'Пульс, GPS, водозащита IP68.',
|
||||
price: 1299000,
|
||||
stock: 15,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'electronics',
|
||||
slug: 'mechanical-keyboard',
|
||||
name: 'Механическая клавиатура',
|
||||
description: 'Hot-swap, RGB подсветка, переключатели Brown.',
|
||||
price: 749000,
|
||||
stock: 18,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'clothing',
|
||||
slug: 'cotton-tshirt',
|
||||
name: 'Хлопковая футболка',
|
||||
description: '100% хлопок, унисекс, размеры S–XL.',
|
||||
price: 199000,
|
||||
stock: 50,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'clothing',
|
||||
slug: 'denim-jacket',
|
||||
name: 'Джинсовая куртка',
|
||||
description: 'Классический крой, прочный деним.',
|
||||
price: 459000,
|
||||
stock: 12,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1551028711-00167b16eac5?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'home',
|
||||
slug: 'ceramic-mug',
|
||||
name: 'Керамическая кружка',
|
||||
description: 'Объём 350 мл, подходит для посудомойки.',
|
||||
price: 89000,
|
||||
stock: 40,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1514228742587-6b1558fcca13?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'home',
|
||||
slug: 'desk-lamp',
|
||||
name: 'Настольная лампа',
|
||||
description: 'LED, регулировка яркости и цветовой температуры.',
|
||||
price: 329000,
|
||||
stock: 20,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1507473885765-e6ed057f782c?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'home',
|
||||
slug: 'throw-blanket',
|
||||
name: 'Плед',
|
||||
description: 'Мягкий флис, 150×200 см.',
|
||||
price: 249000,
|
||||
stock: 30,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1555041469-a586c12e1940?w=400&h=400&fit=crop',
|
||||
},
|
||||
];
|
||||
const products = [
|
||||
{
|
||||
cat: 'electronics',
|
||||
slug: 'wireless-headphones',
|
||||
name: 'Беспроводные наушники',
|
||||
description: 'Шумоподавление, 30 ч автономности, Bluetooth 5.3.',
|
||||
price: 499000,
|
||||
stock: 24,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'electronics',
|
||||
slug: 'smart-watch',
|
||||
name: 'Умные часы',
|
||||
description: 'Пульс, GPS, водозащита IP68.',
|
||||
price: 1299000,
|
||||
stock: 15,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'electronics',
|
||||
slug: 'mechanical-keyboard',
|
||||
name: 'Механическая клавиатура',
|
||||
description: 'Hot-swap, RGB подсветка, переключатели Brown.',
|
||||
price: 749000,
|
||||
stock: 18,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'clothing',
|
||||
slug: 'cotton-tshirt',
|
||||
name: 'Хлопковая футболка',
|
||||
description: '100% хлопок, унисекс, размеры S–XL.',
|
||||
price: 199000,
|
||||
stock: 50,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'clothing',
|
||||
slug: 'denim-jacket',
|
||||
name: 'Джинсовая куртка',
|
||||
description: 'Классический крой, прочный деним.',
|
||||
price: 459000,
|
||||
stock: 12,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1551028711-00167b16eac5?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'home',
|
||||
slug: 'ceramic-mug',
|
||||
name: 'Керамическая кружка',
|
||||
description: 'Объём 350 мл, подходит для посудомойки.',
|
||||
price: 89000,
|
||||
stock: 40,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1514228742587-6b1558fcca13?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'home',
|
||||
slug: 'desk-lamp',
|
||||
name: 'Настольная лампа',
|
||||
description: 'LED, регулировка яркости и цветовой температуры.',
|
||||
price: 329000,
|
||||
stock: 20,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1507473885765-e6ed057f782c?w=400&h=400&fit=crop',
|
||||
},
|
||||
{
|
||||
cat: 'home',
|
||||
slug: 'throw-blanket',
|
||||
name: 'Плед',
|
||||
description: 'Мягкий флис, 150×200 см.',
|
||||
price: 249000,
|
||||
stock: 30,
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1555041469-a586c12e1940?w=400&h=400&fit=crop',
|
||||
},
|
||||
];
|
||||
|
||||
for (const p of products) {
|
||||
insertProduct.run(
|
||||
for (const p of products) {
|
||||
await query(
|
||||
`INSERT INTO products (category_id, slug, name, description, price_cents, stock, image_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
categoryIds[p.cat],
|
||||
p.slug,
|
||||
p.name,
|
||||
p.description,
|
||||
p.price,
|
||||
p.stock,
|
||||
p.image
|
||||
);
|
||||
}
|
||||
});
|
||||
p.image,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
seed();
|
||||
console.log('Добавлено категорий:', categories.length, ', товаров: 8');
|
||||
console.log('Добавлено категорий:', categories.length, ', товаров:', products.length);
|
||||
}
|
||||
|
||||
module.exports = { runSeed };
|
||||
|
||||
if (require.main === module) {
|
||||
runSeed();
|
||||
const { initSchema, pool } = require('./db');
|
||||
initSchema()
|
||||
.then(() => runSeed())
|
||||
.then(() => pool.end())
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
+71
-58
@@ -1,75 +1,88 @@
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const SQLiteStore = require('connect-sqlite3')(session);
|
||||
|
||||
require('./db');
|
||||
require('./seed').runSeed();
|
||||
const pgSession = require('connect-pg-simple')(session);
|
||||
|
||||
const { pool, initSchema, checkConnection } = require('./db');
|
||||
const { runSeed } = require('./seed');
|
||||
const { loadUser } = require('./middleware/auth');
|
||||
const healthRoutes = require('./routes/health');
|
||||
const shopRoutes = require('./routes/shop');
|
||||
const authRoutes = require('./routes/auth');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (process.env.TRUST_PROXY === '1' || isProduction) {
|
||||
app.set('trust proxy', 1);
|
||||
async function start() {
|
||||
await checkConnection();
|
||||
await initSchema();
|
||||
await runSeed();
|
||||
|
||||
const app = express();
|
||||
|
||||
if (process.env.TRUST_PROXY === '1' || isProduction) {
|
||||
app.set('trust proxy', 1);
|
||||
}
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
app.use(healthRoutes);
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.use(
|
||||
session({
|
||||
store: new pgSession({
|
||||
pool,
|
||||
createTableIfMissing: true,
|
||||
tableName: 'session',
|
||||
}),
|
||||
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProduction,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.use(loadUser);
|
||||
app.use('/', shopRoutes);
|
||||
app.use('/', authRoutes);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).render('error', {
|
||||
title: 'Не найдено',
|
||||
message: 'Страница не найдена',
|
||||
code: 404,
|
||||
});
|
||||
});
|
||||
|
||||
app.use((err, req, res, _next) => {
|
||||
console.error(err);
|
||||
res.status(500).render('error', {
|
||||
title: 'Ошибка',
|
||||
message: 'Внутренняя ошибка сервера',
|
||||
code: 500,
|
||||
});
|
||||
});
|
||||
|
||||
const server = app.listen(PORT, HOST, () => {
|
||||
console.log(`Магазин: http://${HOST}:${PORT} (PostgreSQL)`);
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error('Не удалось запустить сервер:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
app.use(healthRoutes);
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.use(
|
||||
session({
|
||||
store: new SQLiteStore({ db: 'sessions.db', dir: path.join(__dirname, '..', 'data') }),
|
||||
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProduction,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.use(loadUser);
|
||||
|
||||
app.use('/', shopRoutes);
|
||||
app.use('/', authRoutes);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).render('error', {
|
||||
title: 'Не найдено',
|
||||
message: 'Страница не найдена',
|
||||
code: 404,
|
||||
});
|
||||
});
|
||||
|
||||
app.use((err, req, res, _next) => {
|
||||
console.error(err);
|
||||
res.status(500).render('error', {
|
||||
title: 'Ошибка',
|
||||
message: 'Внутренняя ошибка сервера',
|
||||
code: 500,
|
||||
});
|
||||
});
|
||||
|
||||
const server = app.listen(PORT, HOST, () => {
|
||||
console.log(`Магазин: http://${HOST}:${PORT}`);
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error('Не удалось запустить сервер:', err.message);
|
||||
start().catch((err) => {
|
||||
console.error('Ошибка запуска:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { asyncHandler };
|
||||
Reference in New Issue
Block a user