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:
shop
2026-05-17 09:32:41 +03:00
parent cb3b8bc49f
commit 7060b0566a
18 changed files with 808 additions and 768 deletions
+8
View File
@@ -3,3 +3,11 @@ HOST=127.0.0.1
NODE_ENV=production NODE_ENV=production
TRUST_PROXY=1 TRUST_PROXY=1
SESSION_SECRET=change-me-to-a-long-random-string SESSION_SECRET=change-me-to-a-long-random-string
# PostgreSQL 17 (одна строка или отдельные переменные)
DATABASE_URL=postgresql://shop:shop@127.0.0.1:5432/shop
# PGHOST=127.0.0.1
# PGPORT=5432
# PGUSER=shop
# PGPASSWORD=shop
# PGDATABASE=shop
+1 -4
View File
@@ -1,6 +1,3 @@
node_modules/ node_modules/
data/
.env .env
*.db data/
*.db-journal
sessions.db
+94 -219
View File
@@ -1,6 +1,6 @@
# Shop # Shop
Интернет-магазин на **Node.js** с локальной базой **SQLite**. Интернет-магазин на **Node.js** и **PostgreSQL 17**.
## Возможности ## Возможности
@@ -12,119 +12,125 @@
## Требования ## Требования
- Node.js 18+ - Node.js 18+
- PostgreSQL 17
- npm - npm
- На Linux для сборки `better-sqlite3`: `build-essential`, `python3`
---
## PostgreSQL 17
### Docker (разработка / тест)
```bash
docker compose up -d
# БД: postgresql://shop:shop@127.0.0.1:5432/shop
```
### Ubuntu (сервер)
```bash
apt update
apt install -y postgresql-17 postgresql-client-17
# Пользователь и база shop
cd /opt/shop
sudo bash scripts/setup-postgres-ubuntu.sh
```
Схема таблиц: `postgres/init/01_schema.sql` (применяется при старте приложения и при первом запуске Docker).
Сессии хранятся в PostgreSQL (таблица `session`, создаётся автоматически).
--- ---
## Быстрый развёртывание на Ubuntu ## Быстрый развёртывание на Ubuntu
Скопируйте блок целиком на чистый сервер Ubuntu 22.04 / 24.04 (от root или через `sudo`):
```bash ```bash
# 1. Зависимости системы # 1. Система + Node.js 20
apt update apt update
apt install -y git curl build-essential python3 apt install -y git curl
# 2. Node.js 20 LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs apt install -y nodejs
# 3. Клонирование (подставьте URL вашего репозитория) # 2. PostgreSQL 17
apt install -y postgresql-17 postgresql-client-17
# 3. Код
cd /opt cd /opt
git clone <URL_РЕПОЗИТОРИЯ> shop git clone <URL_РЕПОЗИТОРИЯ> shop
cd shop cd shop
# 4. Настройка окружения # 4. БД
bash scripts/setup-postgres-ubuntu.sh
# 5. Окружение
cp .env.example .env cp .env.example .env
# Сгенерируйте секрет сессии:
sed -i "s/change-me-to-a-long-random-string/$(openssl rand -hex 32)/" .env sed -i "s/change-me-to-a-long-random-string/$(openssl rand -hex 32)/" .env
# Проверьте DATABASE_URL в .env
# 5. Установка приложения # 6. Приложение
npm install --omit=dev npm install --omit=dev
# 6. Caddy (HTTPS + прокси) — см. раздел ниже; для проверки без домена:
npm start npm start
``` ```
Без Caddy сайт на **http://IP_СЕРВЕРА:3000**. С Caddy — **https://ваш-домен** (любой, указанный в `Caddyfile`). Проверка:
В `.env` для production задайте (уже есть в `.env.example`): ```bash
curl -s http://127.0.0.1:3000/health
# {"ok":true,"service":"shop","database":"postgresql"}
```
### Переменные `.env`
```env ```env
PORT=3000 PORT=3000
HOST=127.0.0.1 HOST=127.0.0.1
NODE_ENV=production NODE_ENV=production
TRUST_PROXY=1 TRUST_PROXY=1
SESSION_SECRET=ваш-длинный-секрет SESSION_SECRET=длинный-секрет
DATABASE_URL=postgresql://shop:shop@127.0.0.1:5432/shop
``` ```
`HOST=127.0.0.1` — Node слушает только localhost; снаружи доступ через Caddy. | Переменная | Описание |
|------------|----------|
При первом запуске создаются `data/shop.db`, `data/sessions.db` и демо-товары. | `DATABASE_URL` | Строка подключения PostgreSQL |
| `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` | Альтернатива `DATABASE_URL` |
| `HOST` | `127.0.0.1` в production (доступ через Caddy) |
--- ---
## Запуск как служба (systemd) ## Запуск как служба (systemd)
Чтобы магазин работал после перезагрузки сервера:
```bash ```bash
cp /opt/shop/deploy/shop.service /etc/systemd/system/shop.service cp /opt/shop/deploy/shop.service /etc/systemd/system/shop.service
# Код обновляйте от root; НЕ делайте chown -R на весь /opt/shop (ломает git pull)
cd /opt/shop cd /opt/shop
git config --global --add safe.directory /opt/shop git config --global --add safe.directory /opt/shop
git pull
npm install --omit=dev npm install --omit=dev
# Запись только в data/ — для пользователя службы www-data # Код — root, служба www-data (только чтение кода достаточно)
mkdir -p /opt/shop/data
chown -R www-data:www-data /opt/shop/data
chmod +x /opt/shop/scripts/diagnose-502.sh 2>/dev/null || true
systemctl daemon-reload systemctl daemon-reload
systemctl enable shop systemctl enable shop
systemctl start shop systemctl start shop
systemctl status shop journalctl -u shop -f
# Backend должен ответить:
curl -s http://127.0.0.1:3000/health
# {"ok":true,"service":"shop"}
``` ```
Логи: `journalctl -u shop -f` `EnvironmentFile=/opt/shop/.env` должен содержать `DATABASE_URL`.
--- ---
## Проверка после установки ## Проверка после установки
Перед настройкой Caddy убедитесь, что Node отвечает локально:
```bash ```bash
cd /opt/shop
systemctl restart shop systemctl restart shop
sleep 1 sleep 1
curl -s http://127.0.0.1:3000/health curl -s http://127.0.0.1:3000/health
# {"ok":true,"service":"shop"}
ss -tlnp | grep 3000 ss -tlnp | grep 3000
# LISTEN ... 127.0.0.1:3000 ... node
journalctl -u shop -n 5 --no-pager journalctl -u shop -n 5 --no-pager
# должна быть строка: Магазин: http://127.0.0.1:3000 # Магазин: http://127.0.0.1:3000 (PostgreSQL)
``` ```
Если в логе только «База уже содержит товары…» и служба сразу останавливается — обновите код: `git pull` (нужна актуальная версия `seed.js`). Обновление:
После успешной проверки настройте Caddy и выполните:
```bash
systemctl reload caddy
```
Обновление на сервере одной командой:
```bash ```bash
bash /opt/shop/scripts/server-update.sh bash /opt/shop/scripts/server-update.sh
@@ -132,17 +138,9 @@ bash /opt/shop/scripts/server-update.sh
--- ---
## Caddy — SSL и reverse proxy (рекомендуется) ## Caddy — SSL и reverse proxy
[Caddy](https://caddyserver.com/) автоматически выпускает и продлевает сертификаты Let's Encrypt. **Перед Caddy:** `curl http://127.0.0.1:3000/health` → OK.
**Перед установкой:**
1. Домен указывает на IP сервера (A-запись).
2. Открыты порты **80** и **443** в файрволе.
3. Служба `shop` запущена и слушает `127.0.0.1:3000`.
### Установка Caddy на Ubuntu
```bash ```bash
apt install -y debian-keyring debian-archive-keyring apt-transport-https apt install -y debian-keyring debian-archive-keyring apt-transport-https
@@ -150,21 +148,17 @@ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| tee /etc/apt/sources.list.d/caddy-stable.list | tee /etc/apt/sources.list.d/caddy-stable.list
apt update apt update && apt install -y caddy
apt install -y caddy
```
### Конфигурация
В репозитории лежит пример: `caddy/Caddyfile.example`.
```bash
# Замените shop.example.com и email на свои
cp /opt/shop/caddy/Caddyfile.example /etc/caddy/Caddyfile cp /opt/shop/caddy/Caddyfile.example /etc/caddy/Caddyfile
nano /etc/caddy/Caddyfile nano /etc/caddy/Caddyfile # ваш домен и email
caddy validate --config /etc/caddy/Caddyfile
systemctl enable caddy
systemctl reload caddy
``` ```
Пример `/etc/caddy/Caddyfile`: Пример `Caddyfile`:
```caddyfile ```caddyfile
{ {
@@ -177,178 +171,59 @@ shop.example.com {
} }
``` ```
Проверка и перезапуск: Порты **80/443** открыть; **5432** и **3000** наружу не публиковать.
```bash ### HTTP 502
caddy validate --config /etc/caddy/Caddyfile
systemctl enable caddy
systemctl reload caddy
systemctl status caddy
```
Сайт откроется по домену из блока `Caddyfile` (в примере — `shop.example.com`).
Логи Caddy: `journalctl -u caddy -f`
### Файрвол (ufw)
```bash
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```
Порт **3000** наружу не открывайте — к приложению ходят только через Caddy.
### Порядок запуска после перезагрузки
1. `shop` (Node.js на `127.0.0.1:3000`)
2. `caddy` (прокси + HTTPS)
### HTTP 502 при рабочем SSL
**SSL есть, 502 — значит Caddy жив, а Node на `127.0.0.1:3000` не отвечает.**
На сервере выполните:
```bash ```bash
bash /opt/shop/scripts/diagnose-502.sh bash /opt/shop/scripts/diagnose-502.sh
journalctl -u shop -n 50 --no-pager journalctl -u shop -n 50 --no-pager
curl -v http://127.0.0.1:3000/health
``` ```
**Частые причины и исправление:** | Причина | Решение |
| Причина | Что сделать |
|--------|-------------|
| Служба `shop` не запущена или падает | `systemctl restart shop`, смотрите логи `journalctl -u shop -f` |
| Нет `npm install` / сломан `better-sqlite3` | `cd /opt/shop && npm install --omit=dev` (нужны `build-essential`, `python3`) |
| Нет прав на `data/` у `www-data` | `mkdir -p /opt/shop/data && chown -R www-data:www-data /opt/shop/data` |
| `dubious ownership` / нет `git pull` | `chown -R root:root /opt/shop` + `safe.directory` (см. выше) |
| В `.env` нет `HOST`/`PORT` | `HOST=127.0.0.1`, `PORT=3000`, затем `systemctl restart shop` |
| Неверный путь к `node` в systemd | `which node` → подставьте в `ExecStart` в `/etc/systemd/system/shop.service` |
| Caddy стартовал раньше shop | `cp deploy/caddy-after-shop.conf /etc/systemd/system/caddy.service.d/shop.conf` и `daemon-reload` |
| В логе seed и сразу `Deactivated` | `git pull` — старый `seed.js` вызывал `process.exit` до старта сервера |
**Быстрое восстановление:**
```bash
cd /opt/shop
git config --global --add safe.directory /opt/shop
git pull
npm install --omit=dev
mkdir -p data
chown -R www-data:www-data /opt/shop/data
systemctl restart shop
curl -s http://127.0.0.1:3000/health # должен быть {"ok":true,...}
systemctl reload caddy
```
**Ошибка `dubious ownership` при `git pull`:**
Вы сделали `chown -R www-data` на весь каталог. Верните владельца репозиторию root и оставьте `data/` за www-data:
```bash
chown -R root:root /opt/shop
chown -R www-data:www-data /opt/shop/data
git config --global --add safe.directory /opt/shop
git pull
```
Пока `curl http://127.0.0.1:3000/health` не возвращает OK — HTTPS через Caddy будет отдавать 502.
### Другие проблемы
| Симптом | Решение |
|--------|---------| |--------|---------|
| Нет сертификата | DNS, порты 80/443, верный домен в `Caddyfile` | | PostgreSQL не запущен | `systemctl start postgresql` |
| Редирект-цикл / нет cookies | В `.env`: `TRUST_PROXY=1`, `NODE_ENV=production` | | Неверный `DATABASE_URL` | проверить `.env`, `psql` |
| Node не слушает 3000 | `journalctl -u shop -f` |
--- | Caddy без backend | сначала `curl /health`, потом `reload caddy` |
## Обновление с Git
```bash
cd /opt/shop
bash scripts/server-update.sh
```
Или вручную:
```bash
cd /opt/shop
git config --global --add safe.directory /opt/shop
git pull
npm install --omit=dev
mkdir -p data && chown -R www-data:www-data data
systemctl restart shop
curl -s http://127.0.0.1:3000/health
systemctl reload caddy # если меняли Caddyfile
```
--- ---
## Локальная разработка ## Локальная разработка
```bash ```bash
npm install docker compose up -d
cp .env.example .env cp .env.example .env
npm install
npm run dev npm run dev
``` ```
Сайт: [http://localhost:3000](http://localhost:3000)
## Переменные окружения
| Переменная | Описание | По умолчанию |
|------------------|---------------------------|----------------|
| `PORT` | Порт HTTP-сервера | `3000` |
| `HOST` | Адрес привязки | `0.0.0.0` (dev), `127.0.0.1` (prod) |
| `NODE_ENV` | Режим (`production` — secure cookies) | — |
| `TRUST_PROXY` | Доверять заголовкам Caddy (`1`) | — |
| `SESSION_SECRET` | Секрет для сессий | dev-значение |
## Скрипты npm ## Скрипты npm
| Команда | Описание | | Команда | Описание |
|----------------|----------------------------------| |---------|----------|
| `npm start` | Запуск сервера (production) | | `npm start` | Запуск сервера |
| `npm run dev` | Запуск с автоперезагрузкой | | `npm run dev` | С автоперезагрузкой |
| `npm run seed` | Заполнение каталога, если пуст | | `npm run seed` | Демо-товары (если каталог пуст) |
## База данных ## Структура
Локально в каталоге `data/`:
- `shop.db` — товары, пользователи, заказы
- `sessions.db` — сессии
Каталог `data/` в git не попадает (см. `.gitignore`). Делайте резервные копии:
```bash
cp -a data/shop.db data/shop.db.bak
```
## Структура проекта
``` ```
caddy/ postgres/init/01_schema.sql
Caddyfile.example — пример reverse proxy + SSL docker-compose.yml — PostgreSQL 17 локально
deploy/ caddy/Caddyfile.example
shop.service — unit для systemd deploy/shop.service
scripts/ scripts/
diagnose-502.sh — проверка при 502 setup-postgres-ubuntu.sh
server-update.sh — git pull + restart + health diagnose-502.sh
server-update.sh
src/ src/
server.js — точка входа
db.js — схема SQLite
seed.js — демо-данные
routes/ — маршруты
views/ — шаблоны EJS
public/css/ — стили
``` ```
## Миграция с SQLite
Старая версия хранила данные в `data/*.db`. После перехода на PostgreSQL выполните `git pull`, настройте `DATABASE_URL`, `npm install` — при первом запуске создастся схема и демо-каталог (если таблица `products` пуста). Пользователей и заказы из SQLite нужно переносить вручную или заново зарегистрироваться.
## Репозиторий ## Репозиторий
```bash ```bash
+22
View File
@@ -0,0 +1,22 @@
services:
postgres:
image: postgres:17
container_name: shop-postgres
restart: unless-stopped
environment:
POSTGRES_USER: shop
POSTGRES_PASSWORD: shop
POSTGRES_DB: shop
ports:
- '5432:5432'
volumes:
- shop_pg_data:/var/lib/postgresql/data
- ./postgres/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U shop -d shop']
interval: 5s
timeout: 5s
retries: 5
volumes:
shop_pg_data:
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "shop", "name": "shop",
"version": "1.0.0", "version": "2.0.0",
"description": "Интернет-магазин на Node.js с локальной SQLite", "description": "Интернет-магазин на Node.js с PostgreSQL 17",
"main": "src/server.js", "main": "src/server.js",
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node src/server.js",
@@ -13,10 +13,10 @@
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^11.7.0", "connect-pg-simple": "^10.0.0",
"connect-sqlite3": "^0.9.15",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.2", "express": "^4.21.2",
"express-session": "^1.18.1" "express-session": "^1.18.1",
"pg": "^8.13.1"
} }
} }
+52
View File
@@ -0,0 +1,52 @@
-- PostgreSQL 17 — схема магазина
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
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 TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
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 TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS order_items (
id SERIAL PRIMARY KEY,
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);
CREATE INDEX IF NOT EXISTS idx_products_name ON products(name);
+23 -24
View File
@@ -1,53 +1,52 @@
#!/bin/bash #!/bin/bash
# Диагностика HTTP 502 (Caddy не достучался до Node) # Диагностика HTTP 502 (Caddy не достучался до Node / БД)
set -e set -e
echo "=== Shop / Caddy 502 diagnostic ===" echo "=== Shop / Caddy 502 diagnostic ==="
echo echo
echo "1. Служба shop" echo "1. PostgreSQL"
systemctl is-active shop 2>/dev/null || echo " shop: не установлена или не active" if command -v pg_isready >/dev/null; then
systemctl status shop --no-pager -l 2>/dev/null | head -20 || true pg_isready -h 127.0.0.1 -p 5432 && echo " pg_isready: OK" || echo " pg_isready: FAIL"
else
echo " pg_isready не найден"
fi
echo echo
echo "2. Служба caddy" echo "2. Служба shop"
systemctl is-active shop 2>/dev/null || echo " shop: не active"
systemctl status shop --no-pager -l 2>/dev/null | head -15 || true
echo
echo "3. Служба caddy"
systemctl is-active caddy 2>/dev/null || true systemctl is-active caddy 2>/dev/null || true
echo echo
echo "3. Порт 3000" echo "4. Порт 3000"
if command -v ss >/dev/null; then if command -v ss >/dev/null; then
ss -tlnp | grep ':3000' || echo " Ничего не слушает порт 3000 — запустите shop" ss -tlnp | grep ':3000' || echo " Ничего не слушает порт 3000"
else else
netstat -tlnp 2>/dev/null | grep ':3000' || echo " Порт 3000 не слушается" netstat -tlnp 2>/dev/null | grep ':3000' || true
fi fi
echo echo
echo "4. curl backend" echo "5. curl backend"
if curl -sf --max-time 3 http://127.0.0.1:3000/health; then if curl -sf --max-time 3 http://127.0.0.1:3000/health; then
echo echo
echo " OK — Node отвечает, проблема скорее в Caddyfile" echo " OK"
else else
echo " FAIL — Node не отвечает на 127.0.0.1:3000" echo " FAIL — проверьте DATABASE_URL и journalctl -u shop"
echo " Проверьте: journalctl -u shop -n 50 --no-pager"
fi fi
echo echo
echo "5. .env (HOST, PORT)" echo "6. .env"
if [ -f /opt/shop/.env ]; then if [ -f /opt/shop/.env ]; then
grep -E '^(HOST|PORT|NODE_ENV)=' /opt/shop/.env || true grep -E '^(DATABASE_URL|HOST|PORT)=' /opt/shop/.env 2>/dev/null | sed 's/=.*/=***/' || true
grep -E '^DATABASE_URL=' /opt/shop/.env || echo " DATABASE_URL не задан"
else else
echo " /opt/shop/.env не найден" echo " /opt/shop/.env не найден"
fi fi
echo echo
echo "6. Права data/"
if [ -d /opt/shop/data ]; then
ls -la /opt/shop/data
else
echo " Каталог data/ отсутствует — создайте: mkdir -p /opt/shop/data && chown www-data:www-data /opt/shop/data"
fi
echo
echo "7. Node" echo "7. Node"
which node || which nodejs || echo " node не найден в PATH" which node && node -v
node -v 2>/dev/null || nodejs -v 2>/dev/null || true
-3
View File
@@ -9,9 +9,6 @@ git pull
npm install --omit=dev npm install --omit=dev
mkdir -p data
chown -R www-data:www-data data
if systemctl is-active --quiet shop 2>/dev/null; then if systemctl is-active --quiet shop 2>/dev/null; then
systemctl restart shop systemctl restart shop
sleep 1 sleep 1
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
# PostgreSQL 17 на Ubuntu — пользователь и БД для магазина
# Запуск: sudo bash scripts/setup-postgres-ubuntu.sh
set -euo pipefail
DB_USER="${DB_USER:-shop}"
DB_PASS="${DB_PASS:-shop}"
DB_NAME="${DB_NAME:-shop}"
if ! command -v psql >/dev/null; then
echo "Установите PostgreSQL 17:"
echo " apt install -y postgresql-17 postgresql-client-17"
exit 1
fi
sudo -u postgres psql -v ON_ERROR_STOP=1 <<EOF
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${DB_USER}') THEN
CREATE ROLE ${DB_USER} LOGIN PASSWORD '${DB_PASS}';
END IF;
END
\$\$;
SELECT 'CREATE DATABASE ${DB_NAME} OWNER ${DB_USER}'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${DB_NAME}')\gexec
GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};
EOF
echo ""
echo "DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@127.0.0.1:5432/${DB_NAME}"
+8 -5
View File
@@ -1,3 +1,5 @@
const { query } = require('./db');
function getCart(req) { function getCart(req) {
if (!req.session.cart) { if (!req.session.cart) {
req.session.cart = {}; req.session.cart = {};
@@ -9,14 +11,15 @@ function cartCount(cart) {
return Object.values(cart).reduce((sum, qty) => sum + qty, 0); 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); const ids = Object.keys(cart).map(Number).filter(Boolean);
if (ids.length === 0) return []; if (ids.length === 0) return [];
const placeholders = ids.map(() => '?').join(','); const placeholders = ids.map((_, i) => `$${i + 1}`).join(',');
const products = db const { rows: products } = await query(
.prepare(`SELECT * FROM products WHERE id IN (${placeholders})`) `SELECT * FROM products WHERE id IN (${placeholders})`,
.all(...ids); ids
);
return products return products
.map((p) => ({ .map((p) => ({
+34 -58
View File
@@ -1,69 +1,39 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const Database = require('better-sqlite3'); const { Pool } = require('pg');
const dataDir = path.join(__dirname, '..', 'data'); function buildPoolConfig() {
if (!fs.existsSync(dataDir)) { if (process.env.DATABASE_URL) {
fs.mkdirSync(dataDir, { recursive: true }); 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 pool = new Pool(buildPoolConfig());
const db = new Database(dbPath);
db.pragma('journal_mode = WAL'); pool.on('error', (err) => {
db.pragma('foreign_keys = ON'); console.error('PostgreSQL pool error:', err.message);
});
db.exec(` async function query(text, params) {
CREATE TABLE IF NOT EXISTS users ( return pool.query(text, params);
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'))
);
CREATE TABLE IF NOT EXISTS categories ( async function initSchema() {
id INTEGER PRIMARY KEY AUTOINCREMENT, const schemaPath = path.join(__dirname, '..', 'postgres', 'init', '01_schema.sql');
slug TEXT NOT NULL UNIQUE, const sql = fs.readFileSync(schemaPath, 'utf8');
name TEXT NOT NULL await pool.query(sql);
); }
CREATE TABLE IF NOT EXISTS products ( async function checkConnection() {
id INTEGER PRIMARY KEY AUTOINCREMENT, await pool.query('SELECT 1');
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);
`);
function formatPrice(cents) { function formatPrice(cents) {
return (cents / 100).toLocaleString('ru-RU', { 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
View File
@@ -1,3 +1,6 @@
const { query } = require('../db');
const { asyncHandler } = require('../utils/asyncHandler');
function requireAuth(req, res, next) { function requireAuth(req, res, next) {
if (!req.session.userId) { if (!req.session.userId) {
const nextUrl = encodeURIComponent(req.originalUrl); const nextUrl = encodeURIComponent(req.originalUrl);
@@ -6,17 +9,17 @@ function requireAuth(req, res, next) {
next(); next();
} }
function loadUser(req, res, next) { const loadUser = asyncHandler(async (req, res, next) => {
if (req.session.userId) { if (req.session.userId) {
const { db } = require('../db'); const { rows } = await query(
const user = db 'SELECT id, email, name FROM users WHERE id = $1',
.prepare('SELECT id, email, name FROM users WHERE id = ?') [req.session.userId]
.get(req.session.userId); );
res.locals.user = user || null; res.locals.user = rows[0] || null;
} else { } else {
res.locals.user = null; res.locals.user = null;
} }
next(); next();
} });
module.exports = { requireAuth, loadUser }; module.exports = { requireAuth, loadUser };
+87 -67
View File
@@ -1,15 +1,16 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { db } = require('../db'); const { query, formatPrice } = require('../db');
const { getCart, cartCount } = require('../cart'); const { getCart, cartCount } = require('../cart');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const { asyncHandler } = require('../utils/asyncHandler');
const router = express.Router(); const router = express.Router();
router.use((req, res, next) => { router.use((req, res, next) => {
const cart = getCart(req); const cart = getCart(req);
res.locals.cartCount = cartCount(cart); res.locals.cartCount = cartCount(cart);
res.locals.formatPrice = require('../db').formatPrice; res.locals.formatPrice = formatPrice;
next(); next();
}); });
@@ -18,50 +19,54 @@ router.get('/register', (req, res) => {
res.render('register', { title: 'Регистрация', error: null, values: {} }); res.render('register', { title: 'Регистрация', error: null, values: {} });
}); });
router.post('/register', (req, res) => { router.post(
const { name, email, password, password2 } = req.body; '/register',
const values = { name, email }; asyncHandler(async (req, res) => {
const { name, email, password, password2 } = req.body;
const values = { name, email };
if (!name?.trim() || !email?.trim() || !password) { 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') {
return res.status(400).render('register', { return res.status(400).render('register', {
title: 'Регистрация', title: 'Регистрация',
error: 'Этот email уже зарегистрирован', error: 'Заполните все поля',
values, 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) => { router.get('/login', (req, res) => {
if (req.session.userId) return res.redirect('/account'); if (req.session.userId) return res.redirect('/account');
@@ -73,27 +78,31 @@ router.get('/login', (req, res) => {
}); });
}); });
router.post('/login', (req, res) => { router.post(
const { email, password } = req.body; '/login',
const next = req.body.next || '/'; asyncHandler(async (req, res) => {
const values = { email }; const { email, password } = req.body;
const next = req.body.next || '/';
const values = { email };
const user = db const { rows } = await query('SELECT * FROM users WHERE email = $1', [
.prepare('SELECT * FROM users WHERE email = ?') (email || '').trim().toLowerCase(),
.get((email || '').trim().toLowerCase()); ]);
const user = rows[0];
if (!user || !bcrypt.compareSync(password || '', user.password_hash)) { if (!user || !bcrypt.compareSync(password || '', user.password_hash)) {
return res.status(401).render('login', { return res.status(401).render('login', {
title: 'Вход', title: 'Вход',
error: 'Неверный email или пароль', error: 'Неверный email или пароль',
next, next,
values, values,
}); });
} }
req.session.userId = user.id; req.session.userId = user.id;
res.redirect(next.startsWith('/') ? next : '/'); res.redirect(next.startsWith('/') ? next : '/');
}); })
);
router.post('/logout', (req, res) => { router.post('/logout', (req, res) => {
req.session.destroy(() => { req.session.destroy(() => {
@@ -101,16 +110,27 @@ router.post('/logout', (req, res) => {
}); });
}); });
router.get('/account', requireAuth, (req, res) => { router.get(
const user = db '/account',
.prepare('SELECT id, email, name, created_at FROM users WHERE id = ?') requireAuth,
.get(req.session.userId); 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 const countResult = await query(
.prepare('SELECT COUNT(*) AS n FROM orders WHERE user_id = ?') 'SELECT COUNT(*)::int AS n FROM orders WHERE user_id = $1',
.get(user.id).n; [user.id]
);
res.render('account', { title: 'Личный кабинет', user, orderCount }); res.render('account', {
}); title: 'Личный кабинет',
user,
orderCount: countResult.rows[0].n,
});
})
);
module.exports = router; module.exports = router;
+4 -4
View File
@@ -1,12 +1,12 @@
const express = require('express'); const express = require('express');
const { db } = require('../db'); const { checkConnection } = require('../db');
const router = express.Router(); const router = express.Router();
router.get('/health', (_req, res) => { router.get('/health', async (_req, res) => {
try { try {
db.prepare('SELECT 1').get(); await checkConnection();
res.json({ ok: true, service: 'shop' }); res.json({ ok: true, service: 'shop', database: 'postgresql' });
} catch (err) { } catch (err) {
res.status(503).json({ ok: false, error: err.message }); res.status(503).json({ ok: false, error: err.message });
} }
+239 -206
View File
@@ -1,7 +1,8 @@
const express = require('express'); const express = require('express');
const { db, formatPrice } = require('../db'); const { query, pool, formatPrice } = require('../db');
const { getCart, cartCount, cartItems, cartTotal } = require('../cart'); const { getCart, cartCount, cartItems, cartTotal } = require('../cart');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const { asyncHandler } = require('../utils/asyncHandler');
const router = express.Router(); const router = express.Router();
@@ -16,120 +17,135 @@ router.use((req, res, next) => {
next(); next();
}); });
router.get('/', (req, res) => { router.get(
const category = req.query.category || ''; '/',
const q = (req.query.q || '').trim(); asyncHandler(async (req, res) => {
const category = req.query.category || '';
const q = (req.query.q || '').trim();
let sql = ` let sql = `
SELECT p.*, c.name AS category_name, c.slug AS category_slug SELECT p.*, c.name AS category_name, c.slug AS category_slug
FROM products p FROM products p
LEFT JOIN categories c ON c.id = p.category_id LEFT JOIN categories c ON c.id = p.category_id
WHERE p.stock > 0 WHERE p.stock > 0
`; `;
const params = []; const params = [];
let n = 1;
if (category) { if (category) {
sql += ' AND c.slug = ?'; sql += ` AND c.slug = $${n++}`;
params.push(category); params.push(category);
} }
if (q) { if (q) {
sql += ' AND (p.name LIKE ? OR p.description LIKE ?)'; sql += ` AND (p.name ILIKE $${n} OR p.description ILIKE $${n})`;
const like = `%${q}%`; params.push(`%${q}%`);
params.push(like, like); n++;
} }
sql += ' ORDER BY p.name'; sql += ' ORDER BY p.name';
const products = db.prepare(sql).all(...params); const { rows: products } = await query(sql, params);
const categories = db.prepare('SELECT * FROM categories ORDER BY name').all(); const { rows: categories } = await query('SELECT * FROM categories ORDER BY name');
res.render('home', { res.render('home', {
title: 'Каталог', title: 'Каталог',
products, products,
categories, categories,
activeCategory: category, activeCategory: category,
searchQuery: q, searchQuery: q,
}); });
}); })
);
router.get('/product/:slug', (req, res) => { router.get(
const product = db '/product/:slug',
.prepare( asyncHandler(async (req, res) => {
const { rows } = await query(
`SELECT p.*, c.name AS category_name, c.slug AS category_slug `SELECT p.*, c.name AS category_name, c.slug AS category_slug
FROM products p FROM products p
LEFT JOIN categories c ON c.id = p.category_id LEFT JOIN categories c ON c.id = p.category_id
WHERE p.slug = ?` WHERE p.slug = $1`,
) [req.params.slug]
.get(req.params.slug); );
const product = rows[0];
if (!product) { if (!product) {
return res.status(404).render('error', { return res.status(404).render('error', {
title: 'Не найдено', title: 'Не найдено',
message: 'Товар не найден', message: 'Товар не найден',
code: 404, 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 { rows } = await query('SELECT id, stock FROM products WHERE id = $1', [
const cart = getCart(req); productId,
const items = cartItems(db, cart); ]);
const total = cartTotal(items); const product = rows[0];
if (!product) {
const errorMsg = req.query.error ? decodeURIComponent(String(req.query.error)) : null; return res.redirect('/');
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 product = db const cart = getCart(req);
.prepare('SELECT stock FROM products WHERE id = ?') const current = cart[productId] || 0;
.get(productId); cart[productId] = Math.min(product.stock, current + quantity);
if (product) {
cart[productId] = Math.min(product.stock, 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) => { router.post('/cart/remove/:id', (req, res) => {
const cart = getCart(req); const cart = getCart(req);
@@ -137,131 +153,148 @@ router.post('/cart/remove/:id', (req, res) => {
res.redirect('/cart'); res.redirect('/cart');
}); });
router.get('/checkout', requireAuth, (req, res) => { router.get(
const cart = getCart(req); '/checkout',
const items = cartItems(db, cart); requireAuth,
if (items.length === 0) { asyncHandler(async (req, res) => {
return res.redirect('/cart'); const cart = getCart(req);
} const items = await cartItems(cart);
if (items.length === 0) {
return res.redirect('/cart');
}
res.render('checkout', { 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', {
title: 'Оформление заказа', title: 'Оформление заказа',
items, items,
total: cartTotal(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(() => { const { name, email, phone, address } = req.body;
for (const item of items) { if (!name?.trim() || !email?.trim() || !address?.trim()) {
const row = db return res.status(400).render('checkout', {
.prepare('SELECT stock FROM products WHERE id = ?') title: 'Оформление заказа',
.get(item.id); items,
if (!row || row.stock < item.quantity) { total: cartTotal(items),
throw new Error(`Недостаточно «${item.name}» на складе`); 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 const orderResult = await client.query(
.prepare(
`INSERT INTO orders (user_id, status, total_cents, customer_name, customer_email, customer_phone, address) `INSERT INTO orders (user_id, status, total_cents, customer_name, customer_email, customer_phone, address)
VALUES (?, 'pending', ?, ?, ?, ?, ?)` VALUES ($1, 'pending', $2, $3, $4, $5, $6)
) RETURNING id`,
.run( [
req.session.userId, req.session.userId,
total, total,
name.trim(), name.trim(),
email.trim(), email.trim(),
(phone || '').trim(), (phone || '').trim(),
address.trim() address.trim(),
]
); );
const orderId = orderResult.rows[0].id;
const insertItem = db.prepare( for (const item of items) {
`INSERT INTO order_items (order_id, product_id, quantity, price_cents) await client.query(
VALUES (?, ?, ?, ?)` `INSERT INTO order_items (order_id, product_id, quantity, price_cents)
); VALUES ($1, $2, $3, $4)`,
const updateStock = db.prepare( [orderId, item.id, item.quantity, item.price_cents]
'UPDATE products SET stock = stock - ? WHERE id = ?' );
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) { res.render('orders', { title: 'Мои заказы', orders });
insertItem.run(order.lastInsertRowid, item.id, item.quantity, item.price_cents); })
updateStock.run(item.quantity, item.id); );
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; const { rows: items } = await query(
});
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(
`SELECT oi.*, p.name, p.slug, p.image_url `SELECT oi.*, p.name, p.slug, p.image_url
FROM order_items oi FROM order_items oi
JOIN products p ON p.id = oi.product_id JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = ?` WHERE oi.order_id = $1`,
) [order.id]
.all(order.id); );
res.render('order', { res.render('order', {
title: `Заказ #${order.id}`, title: `Заказ #${order.id}`,
order, order,
items, items,
success: req.query.success === '1', success: req.query.success === '1',
}); });
}); })
);
module.exports = router; module.exports = router;
+111 -108
View File
@@ -1,20 +1,12 @@
const { db } = require('./db'); const { query } = require('./db');
function runSeed() { async function runSeed() {
const count = db.prepare('SELECT COUNT(*) AS n FROM products').get().n; const { rows } = await query('SELECT COUNT(*)::int AS n FROM products');
if (count > 0) { if (rows[0].n > 0) {
console.log('База уже содержит товары, пропуск seed.'); console.log('База уже содержит товары, пропуск seed.');
return; 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 = [ const categories = [
{ slug: 'electronics', name: 'Электроника' }, { slug: 'electronics', name: 'Электроника' },
{ slug: 'clothing', name: 'Одежда' }, { slug: 'clothing', name: 'Одежда' },
@@ -22,114 +14,125 @@ function runSeed() {
]; ];
const categoryIds = {}; const categoryIds = {};
const seed = db.transaction(() => { for (const c of categories) {
for (const c of categories) { const r = await query(
const r = insertCategory.run(c.slug, c.name); 'INSERT INTO categories (slug, name) VALUES ($1, $2) RETURNING id',
categoryIds[c.slug] = r.lastInsertRowid; [c.slug, c.name]
} );
categoryIds[c.slug] = r.rows[0].id;
}
const products = [ const products = [
{ {
cat: 'electronics', cat: 'electronics',
slug: 'wireless-headphones', slug: 'wireless-headphones',
name: 'Беспроводные наушники', name: 'Беспроводные наушники',
description: 'Шумоподавление, 30 ч автономности, Bluetooth 5.3.', description: 'Шумоподавление, 30 ч автономности, Bluetooth 5.3.',
price: 499000, price: 499000,
stock: 24, stock: 24,
image: image:
'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&h=400&fit=crop', 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&h=400&fit=crop',
}, },
{ {
cat: 'electronics', cat: 'electronics',
slug: 'smart-watch', slug: 'smart-watch',
name: 'Умные часы', name: 'Умные часы',
description: 'Пульс, GPS, водозащита IP68.', description: 'Пульс, GPS, водозащита IP68.',
price: 1299000, price: 1299000,
stock: 15, stock: 15,
image: image:
'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=400&h=400&fit=crop', 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=400&h=400&fit=crop',
}, },
{ {
cat: 'electronics', cat: 'electronics',
slug: 'mechanical-keyboard', slug: 'mechanical-keyboard',
name: 'Механическая клавиатура', name: 'Механическая клавиатура',
description: 'Hot-swap, RGB подсветка, переключатели Brown.', description: 'Hot-swap, RGB подсветка, переключатели Brown.',
price: 749000, price: 749000,
stock: 18, stock: 18,
image: image:
'https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=400&h=400&fit=crop', 'https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=400&h=400&fit=crop',
}, },
{ {
cat: 'clothing', cat: 'clothing',
slug: 'cotton-tshirt', slug: 'cotton-tshirt',
name: 'Хлопковая футболка', name: 'Хлопковая футболка',
description: '100% хлопок, унисекс, размеры S–XL.', description: '100% хлопок, унисекс, размеры S–XL.',
price: 199000, price: 199000,
stock: 50, stock: 50,
image: image:
'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400&h=400&fit=crop', 'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400&h=400&fit=crop',
}, },
{ {
cat: 'clothing', cat: 'clothing',
slug: 'denim-jacket', slug: 'denim-jacket',
name: 'Джинсовая куртка', name: 'Джинсовая куртка',
description: 'Классический крой, прочный деним.', description: 'Классический крой, прочный деним.',
price: 459000, price: 459000,
stock: 12, stock: 12,
image: image:
'https://images.unsplash.com/photo-1551028711-00167b16eac5?w=400&h=400&fit=crop', 'https://images.unsplash.com/photo-1551028711-00167b16eac5?w=400&h=400&fit=crop',
}, },
{ {
cat: 'home', cat: 'home',
slug: 'ceramic-mug', slug: 'ceramic-mug',
name: 'Керамическая кружка', name: 'Керамическая кружка',
description: 'Объём 350 мл, подходит для посудомойки.', description: 'Объём 350 мл, подходит для посудомойки.',
price: 89000, price: 89000,
stock: 40, stock: 40,
image: image:
'https://images.unsplash.com/photo-1514228742587-6b1558fcca13?w=400&h=400&fit=crop', 'https://images.unsplash.com/photo-1514228742587-6b1558fcca13?w=400&h=400&fit=crop',
}, },
{ {
cat: 'home', cat: 'home',
slug: 'desk-lamp', slug: 'desk-lamp',
name: 'Настольная лампа', name: 'Настольная лампа',
description: 'LED, регулировка яркости и цветовой температуры.', description: 'LED, регулировка яркости и цветовой температуры.',
price: 329000, price: 329000,
stock: 20, stock: 20,
image: image:
'https://images.unsplash.com/photo-1507473885765-e6ed057f782c?w=400&h=400&fit=crop', 'https://images.unsplash.com/photo-1507473885765-e6ed057f782c?w=400&h=400&fit=crop',
}, },
{ {
cat: 'home', cat: 'home',
slug: 'throw-blanket', slug: 'throw-blanket',
name: 'Плед', name: 'Плед',
description: 'Мягкий флис, 150×200 см.', description: 'Мягкий флис, 150×200 см.',
price: 249000, price: 249000,
stock: 30, stock: 30,
image: image:
'https://images.unsplash.com/photo-1555041469-a586c12e1940?w=400&h=400&fit=crop', 'https://images.unsplash.com/photo-1555041469-a586c12e1940?w=400&h=400&fit=crop',
}, },
]; ];
for (const p of products) { for (const p of products) {
insertProduct.run( 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], categoryIds[p.cat],
p.slug, p.slug,
p.name, p.name,
p.description, p.description,
p.price, p.price,
p.stock, p.stock,
p.image p.image,
); ]
} );
}); }
seed(); console.log('Добавлено категорий:', categories.length, ', товаров:', products.length);
console.log('Добавлено категорий:', categories.length, ', товаров: 8');
} }
module.exports = { runSeed }; module.exports = { runSeed };
if (require.main === module) { 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
View File
@@ -1,75 +1,88 @@
const path = require('path'); const path = require('path');
const express = require('express'); const express = require('express');
const session = require('express-session'); const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session); const pgSession = require('connect-pg-simple')(session);
require('./db');
require('./seed').runSeed();
const { pool, initSchema, checkConnection } = require('./db');
const { runSeed } = require('./seed');
const { loadUser } = require('./middleware/auth'); const { loadUser } = require('./middleware/auth');
const healthRoutes = require('./routes/health'); const healthRoutes = require('./routes/health');
const shopRoutes = require('./routes/shop'); const shopRoutes = require('./routes/shop');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0'; const HOST = process.env.HOST || '0.0.0.0';
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
if (process.env.TRUST_PROXY === '1' || isProduction) { async function start() {
app.set('trust proxy', 1); 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'); start().catch((err) => {
app.set('views', path.join(__dirname, 'views')); console.error('Ошибка запуска:', err.message);
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);
process.exit(1); process.exit(1);
}); });
+7
View File
@@ -0,0 +1,7 @@
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
module.exports = { asyncHandler };