From 7060b0566a9213845439a57e79d88534720df76e Mon Sep 17 00:00:00 2001 From: shop Date: Sun, 17 May 2026 09:32:41 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20PostgreSQL=2017=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20SQLite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pg + connect-pg-simple, async routes, docker-compose, скрипт setup-postgres. Co-authored-by: Cursor --- .env.example | 8 + .gitignore | 5 +- README.md | 313 +++++++--------------- docker-compose.yml | 22 ++ package.json | 10 +- postgres/init/01_schema.sql | 52 ++++ scripts/diagnose-502.sh | 47 ++-- scripts/server-update.sh | 3 - scripts/setup-postgres-ubuntu.sh | 32 +++ src/cart.js | 13 +- src/db.js | 92 +++---- src/middleware/auth.js | 17 +- src/routes/auth.js | 154 ++++++----- src/routes/health.js | 8 +- src/routes/shop.js | 445 +++++++++++++++++-------------- src/seed.js | 219 +++++++-------- src/server.js | 129 +++++---- src/utils/asyncHandler.js | 7 + 18 files changed, 808 insertions(+), 768 deletions(-) create mode 100644 docker-compose.yml create mode 100644 postgres/init/01_schema.sql create mode 100644 scripts/setup-postgres-ubuntu.sh create mode 100644 src/utils/asyncHandler.js diff --git a/.env.example b/.env.example index 74d827e..68707ea 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,11 @@ HOST=127.0.0.1 NODE_ENV=production TRUST_PROXY=1 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 diff --git a/.gitignore b/.gitignore index dde968d..5235962 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ node_modules/ -data/ .env -*.db -*.db-journal -sessions.db +data/ diff --git a/README.md b/README.md index bfc0863..7ef4bba 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Shop -Интернет-магазин на **Node.js** с локальной базой **SQLite**. +Интернет-магазин на **Node.js** и **PostgreSQL 17**. ## Возможности @@ -12,119 +12,125 @@ ## Требования - Node.js 18+ +- PostgreSQL 17 - 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 22.04 / 24.04 (от root или через `sudo`): - ```bash -# 1. Зависимости системы +# 1. Система + Node.js 20 apt update -apt install -y git curl build-essential python3 - -# 2. Node.js 20 LTS +apt install -y git curl curl -fsSL https://deb.nodesource.com/setup_20.x | bash - apt install -y nodejs -# 3. Клонирование (подставьте URL вашего репозитория) +# 2. PostgreSQL 17 +apt install -y postgresql-17 postgresql-client-17 + +# 3. Код cd /opt git clone shop cd shop -# 4. Настройка окружения +# 4. БД +bash scripts/setup-postgres-ubuntu.sh + +# 5. Окружение cp .env.example .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 - -# 6. Caddy (HTTPS + прокси) — см. раздел ниже; для проверки без домена: 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 PORT=3000 HOST=127.0.0.1 NODE_ENV=production 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) -Чтобы магазин работал после перезагрузки сервера: - ```bash cp /opt/shop/deploy/shop.service /etc/systemd/system/shop.service -# Код обновляйте от root; НЕ делайте chown -R на весь /opt/shop (ломает git pull) cd /opt/shop git config --global --add safe.directory /opt/shop -git pull npm install --omit=dev -# Запись только в data/ — для пользователя службы 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 - +# Код — root, служба — www-data (только чтение кода достаточно) systemctl daemon-reload systemctl enable shop systemctl start shop -systemctl status shop - -# Backend должен ответить: -curl -s http://127.0.0.1:3000/health -# {"ok":true,"service":"shop"} +journalctl -u shop -f ``` -Логи: `journalctl -u shop -f` +`EnvironmentFile=/opt/shop/.env` должен содержать `DATABASE_URL`. --- ## Проверка после установки -Перед настройкой Caddy убедитесь, что Node отвечает локально: - ```bash -cd /opt/shop systemctl restart shop sleep 1 - curl -s http://127.0.0.1:3000/health -# {"ok":true,"service":"shop"} - ss -tlnp | grep 3000 -# LISTEN ... 127.0.0.1:3000 ... node - 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 /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. - -**Перед установкой:** - -1. Домен указывает на IP сервера (A-запись). -2. Открыты порты **80** и **443** в файрволе. -3. Служба `shop` запущена и слушает `127.0.0.1:3000`. - -### Установка Caddy на Ubuntu +**Перед Caddy:** `curl http://127.0.0.1:3000/health` → OK. ```bash 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 curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ | tee /etc/apt/sources.list.d/caddy-stable.list -apt update -apt install -y caddy -``` +apt update && apt install -y caddy -### Конфигурация - -В репозитории лежит пример: `caddy/Caddyfile.example`. - -```bash -# Замените shop.example.com и email на свои 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 { @@ -177,178 +171,59 @@ shop.example.com { } ``` -Проверка и перезапуск: +Порты **80/443** открыть; **5432** и **3000** наружу не публиковать. -```bash -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` не отвечает.** - -На сервере выполните: +### HTTP 502 ```bash bash /opt/shop/scripts/diagnose-502.sh 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` | -| Редирект-цикл / нет cookies | В `.env`: `TRUST_PROXY=1`, `NODE_ENV=production` | - ---- - -## Обновление с 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 -``` +| PostgreSQL не запущен | `systemctl start postgresql` | +| Неверный `DATABASE_URL` | проверить `.env`, `psql` | +| Node не слушает 3000 | `journalctl -u shop -f` | +| Caddy без backend | сначала `curl /health`, потом `reload caddy` | --- ## Локальная разработка ```bash -npm install +docker compose up -d cp .env.example .env +npm install 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 start` | Запуск сервера (production) | -| `npm run dev` | Запуск с автоперезагрузкой | -| `npm run seed` | Заполнение каталога, если пуст | +| Команда | Описание | +|---------|----------| +| `npm start` | Запуск сервера | +| `npm run dev` | С автоперезагрузкой | +| `npm run seed` | Демо-товары (если каталог пуст) | -## База данных - -Локально в каталоге `data/`: - -- `shop.db` — товары, пользователи, заказы -- `sessions.db` — сессии - -Каталог `data/` в git не попадает (см. `.gitignore`). Делайте резервные копии: - -```bash -cp -a data/shop.db data/shop.db.bak -``` - -## Структура проекта +## Структура ``` -caddy/ - Caddyfile.example — пример reverse proxy + SSL -deploy/ - shop.service — unit для systemd +postgres/init/01_schema.sql +docker-compose.yml — PostgreSQL 17 локально +caddy/Caddyfile.example +deploy/shop.service scripts/ - diagnose-502.sh — проверка при 502 - server-update.sh — git pull + restart + health + setup-postgres-ubuntu.sh + diagnose-502.sh + server-update.sh 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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..16b5322 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/package.json b/package.json index 71fdaff..50c8d8d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "shop", - "version": "1.0.0", - "description": "Интернет-магазин на Node.js с локальной SQLite", + "version": "2.0.0", + "description": "Интернет-магазин на Node.js с PostgreSQL 17", "main": "src/server.js", "scripts": { "start": "node src/server.js", @@ -13,10 +13,10 @@ }, "dependencies": { "bcryptjs": "^2.4.3", - "better-sqlite3": "^11.7.0", - "connect-sqlite3": "^0.9.15", + "connect-pg-simple": "^10.0.0", "ejs": "^3.1.10", "express": "^4.21.2", - "express-session": "^1.18.1" + "express-session": "^1.18.1", + "pg": "^8.13.1" } } diff --git a/postgres/init/01_schema.sql b/postgres/init/01_schema.sql new file mode 100644 index 0000000..82a8408 --- /dev/null +++ b/postgres/init/01_schema.sql @@ -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); diff --git a/scripts/diagnose-502.sh b/scripts/diagnose-502.sh index d17a952..c3ae0d8 100644 --- a/scripts/diagnose-502.sh +++ b/scripts/diagnose-502.sh @@ -1,53 +1,52 @@ #!/bin/bash -# Диагностика HTTP 502 (Caddy не достучался до Node) +# Диагностика HTTP 502 (Caddy не достучался до Node / БД) set -e echo "=== Shop / Caddy 502 diagnostic ===" echo -echo "1. Служба shop" -systemctl is-active shop 2>/dev/null || echo " shop: не установлена или не active" -systemctl status shop --no-pager -l 2>/dev/null | head -20 || true +echo "1. PostgreSQL" +if command -v pg_isready >/dev/null; then + pg_isready -h 127.0.0.1 -p 5432 && echo " pg_isready: OK" || echo " pg_isready: FAIL" +else + echo " pg_isready не найден" +fi 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 echo -echo "3. Порт 3000" +echo "4. Порт 3000" if command -v ss >/dev/null; then - ss -tlnp | grep ':3000' || echo " Ничего не слушает порт 3000 — запустите shop" + ss -tlnp | grep ':3000' || echo " Ничего не слушает порт 3000" else - netstat -tlnp 2>/dev/null | grep ':3000' || echo " Порт 3000 не слушается" + netstat -tlnp 2>/dev/null | grep ':3000' || true fi echo -echo "4. curl backend" +echo "5. curl backend" if curl -sf --max-time 3 http://127.0.0.1:3000/health; then echo - echo " OK — Node отвечает, проблема скорее в Caddyfile" + echo " OK" else - echo " FAIL — Node не отвечает на 127.0.0.1:3000" - echo " Проверьте: journalctl -u shop -n 50 --no-pager" + echo " FAIL — проверьте DATABASE_URL и journalctl -u shop" fi echo -echo "5. .env (HOST, PORT)" +echo "6. .env" 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 echo " /opt/shop/.env не найден" fi 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" -which node || which nodejs || echo " node не найден в PATH" -node -v 2>/dev/null || nodejs -v 2>/dev/null || true +which node && node -v diff --git a/scripts/server-update.sh b/scripts/server-update.sh index be68617..d4bff0d 100644 --- a/scripts/server-update.sh +++ b/scripts/server-update.sh @@ -9,9 +9,6 @@ git pull npm install --omit=dev -mkdir -p data -chown -R www-data:www-data data - if systemctl is-active --quiet shop 2>/dev/null; then systemctl restart shop sleep 1 diff --git a/scripts/setup-postgres-ubuntu.sh b/scripts/setup-postgres-ubuntu.sh new file mode 100644 index 0000000..a8a3f26 --- /dev/null +++ b/scripts/setup-postgres-ubuntu.sh @@ -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 < 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) => ({ diff --git a/src/db.js b/src/db.js index 36da261..0b1443d 100644 --- a/src/db.js +++ b/src/db.js @@ -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, +}; diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 7a8e8bc..232aad7 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -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 }; diff --git a/src/routes/auth.js b/src/routes/auth.js index a9f1d1c..d9337ea 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -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; diff --git a/src/routes/health.js b/src/routes/health.js index 18b8094..21a0f2a 100644 --- a/src/routes/health.js +++ b/src/routes/health.js @@ -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 }); } diff --git a/src/routes/shop.js b/src/routes/shop.js index 498073f..1f43a43 100644 --- a/src/routes/shop.js +++ b/src/routes/shop.js @@ -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; diff --git a/src/seed.js b/src/seed.js index 1327088..39e67c8 100644 --- a/src/seed.js +++ b/src/seed.js @@ -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); + }); } diff --git a/src/server.js b/src/server.js index 8277668..a761de8 100644 --- a/src/server.js +++ b/src/server.js @@ -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); }); diff --git a/src/utils/asyncHandler.js b/src/utils/asyncHandler.js new file mode 100644 index 0000000..6863ad5 --- /dev/null +++ b/src/utils/asyncHandler.js @@ -0,0 +1,7 @@ +function asyncHandler(fn) { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} + +module.exports = { asyncHandler };