initial: server + shared
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
scripts/deploy.env
|
||||||
|
server/prisma/dev.db
|
||||||
|
server/prisma/dev.db-journal
|
||||||
|
server/uploads/
|
||||||
|
uploads/.cache/
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# AGENTS.md — shop-server
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
- `server/` — Fastify + Prisma + SQLite backend
|
||||||
|
- `shared/constants/` — JS + .d.ts shared with client (order statuses, delivery carriers, payment methods, upload limits)
|
||||||
|
|
||||||
|
## Developer commands
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | node --env-file=.env --watch src/index.js (requires Node 20.6+) |
|
||||||
|
| `npm run dev:classic` | node --watch src/index.js (loads .env via dotenv) |
|
||||||
|
| `npm run lint` | ESLint (flat config) |
|
||||||
|
| `npm run lint:fix` | ESLint with --fix |
|
||||||
|
| `npm run format` | Prettier write all |
|
||||||
|
| `npm run format:check` | Prettier check only |
|
||||||
|
| `npm test` | vitest run |
|
||||||
|
| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses .env) |
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Language**: Отвечай пользователю на русском.
|
||||||
|
- **Single quotes**, no semicolons, trailing commas, 120 print width (Prettier + ESLint enforce).
|
||||||
|
- **Alias**: @shared → shared/ (configured in vitest.config.js for tests).
|
||||||
|
- **Admin access**: Only users with email matching ADMIN_EMAIL env var can access admin routes. Server auto-creates the admin user on startup.
|
||||||
|
- **Server helpers**: slugify, parseMaterialsInput, mapProductForApi are decorated on fastify instance, accessed via request.server.*.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Vitest with globals enabled.
|
||||||
|
- Test files live in __tests__/ directories next to the code they test.
|
||||||
|
|
||||||
|
## OAuth
|
||||||
|
|
||||||
|
- VK callback: {SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback
|
||||||
|
- Yandex callback: {SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback
|
||||||
|
|
||||||
|
## Infrastructure (deployment)
|
||||||
|
|
||||||
|
- VPS runs Nginx Proxy Manager (NPM), connected via Netbird VPN to the server machine
|
||||||
|
- Server machine runs the project
|
||||||
|
- Traffic flow: Browser → Domain → VPS (NPM) → Netbird → Server machine (3333)
|
||||||
|
- trustProxy: true on Fastify
|
||||||
|
|
||||||
|
## Notable quirks
|
||||||
|
|
||||||
|
- .env is gitignored. Copy .env.example to .env for local dev.
|
||||||
|
- db:reset:test runs prisma migrate reset --force, which destroys all data.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# shop-server — бэкенд магазина ручной работы
|
||||||
|
|
||||||
|
Fastify + Prisma + SQLite. API, админка, загрузка изображений.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
cd server
|
||||||
|
cp .env.example .env
|
||||||
|
npm ci
|
||||||
|
npx prisma migrate dev
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
Сервер: http://127.0.0.1:3333. Health: GET /health.
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
См. scripts/SERVER_SETUP.md.
|
||||||
Executable
+196
@@ -0,0 +1,196 @@
|
|||||||
|
# Первичная настройка LXC для Craftshop
|
||||||
|
|
||||||
|
Выполнять от **root** на свежем Debian/Ubuntu LXC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Базовые пакеты и Node.js
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y ca-certificates curl gnupg curl git
|
||||||
|
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
|
||||||
|
node --version # ожидается >= 22
|
||||||
|
npm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Каталоги
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/craftshop/server/uploads /opt/craftshop/www
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. systemd unit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat >/etc/systemd/system/craftshop-api.service <<'UNIT'
|
||||||
|
[Unit]
|
||||||
|
Description=Craftshop API (Fastify)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
WorkingDirectory=/opt/craftshop/server
|
||||||
|
EnvironmentFile=-/opt/craftshop/server/.env
|
||||||
|
ExecStart=/usr/bin/node src/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
LimitNOFILE=65535
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable craftshop-api.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt-get install -y nginx
|
||||||
|
|
||||||
|
cat >/etc/nginx/sites-available/craftshop <<'NGX'
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /opt/craftshop/www;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
client_max_body_size 250m;
|
||||||
|
proxy_pass http://127.0.0.1:3333;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://127.0.0.1:3333;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploads-resized/ {
|
||||||
|
client_max_body_size 250m;
|
||||||
|
proxy_pass http://127.0.0.1:3333;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGX
|
||||||
|
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
ln -sf /etc/nginx/sites-available/craftshop /etc/nginx/sites-enabled/craftshop
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. NetBird VPN
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://pkgs.netbird.io/install.sh | sh
|
||||||
|
netbird up
|
||||||
|
```
|
||||||
|
|
||||||
|
После `netbird up` появится интерфейс `wt0` с IP из твоей NetBird-сети. Запомни его — он понадобится для NPM.
|
||||||
|
|
||||||
|
## 6. Переменные окружения
|
||||||
|
|
||||||
|
Сгенерируй JWT_SECRET:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Создай `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat >/opt/craftshop/server/.env <<'ENV'
|
||||||
|
DATABASE_URL="file:./prod.db"
|
||||||
|
PORT=3333
|
||||||
|
JWT_SECRET=<вставь сгенерированную строку>
|
||||||
|
ADMIN_EMAIL=<твой email>
|
||||||
|
CORS_ORIGIN=https://<твой-домен>
|
||||||
|
IS_DEFAULT_CODE_ENABLED=false
|
||||||
|
ENV
|
||||||
|
chmod 600 /opt/craftshop/server/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Первый деплой
|
||||||
|
|
||||||
|
На машине разработчика (после заполнения `scripts/deploy.env`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy-auto.sh --force
|
||||||
|
```
|
||||||
|
|
||||||
|
После завершения — на сервере:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl start craftshop-api
|
||||||
|
systemctl status craftshop-api
|
||||||
|
curl http://127.0.0.1:3333/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. VPS с Nginx Proxy Manager
|
||||||
|
|
||||||
|
На VPS (где установлен NPM):
|
||||||
|
|
||||||
|
1. DNS-запись A: `craftshop.твой-домен` → IP VPS
|
||||||
|
2. В NPM → Proxy Hosts → Add:
|
||||||
|
- Domain: `craftshop.твой-домен`
|
||||||
|
- Forward Hostname: `<NetBird-IP-LXC>` (IP wt0 на LXC)
|
||||||
|
- Forward Port: `80`
|
||||||
|
- SSL: Let's Encrypt
|
||||||
|
3. Сохрани
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://craftshop.твой-домен/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Бэкапы БД (systemd timer)
|
||||||
|
|
||||||
|
Установить таймер для автоматического бэкапа каждые 6 часов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установить sqlite3 для безопасного копирования
|
||||||
|
apt-get install -y sqlite3
|
||||||
|
|
||||||
|
# Скопировать unit-файлы
|
||||||
|
cp /opt/craftshop/scripts/craftshop-backup.service /etc/systemd/system/
|
||||||
|
cp /opt/craftshop/scripts/craftshop-backup.timer /etc/systemd/system/
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now craftshop-backup.timer
|
||||||
|
|
||||||
|
# Проверить статус
|
||||||
|
systemctl list-timers craftshop-backup.timer
|
||||||
|
|
||||||
|
# Ручной запуск для проверки
|
||||||
|
systemctl start craftshop-backup.service
|
||||||
|
ls /opt/craftshop/server/backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
Бэкапы хранятся 30 дней (настраивается в `scripts/backup-db.sh`).
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Backup SQLite database — копирует .db файл с timestamp в директорию бэкапов.
|
||||||
|
# Вызывается из systemd timer или cron.
|
||||||
|
#
|
||||||
|
# Использование: ./scripts/backup-db.sh [path-to-db] [backup-dir] [retention-days]
|
||||||
|
# По умолчанию: db=server/prisma/prod.db, backup=server/backups, retention=30
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
DB_PATH="${1:-$ROOT/server/prisma/prod.db}"
|
||||||
|
BACKUP_DIR="${2:-$ROOT/server/backups}"
|
||||||
|
RETENTION_DAYS="${3:-30}"
|
||||||
|
|
||||||
|
if [[ ! -f "$DB_PATH" ]]; then
|
||||||
|
echo "[$(date -Iseconds)] DB not found: $DB_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_FILE="$BACKUP_DIR/craftshop_${TIMESTAMP}.db"
|
||||||
|
|
||||||
|
# SQLite-safe copy: use .backup to avoid copying a file mid-write
|
||||||
|
if command -v sqlite3 &>/dev/null; then
|
||||||
|
sqlite3 "$DB_PATH" ".backup '$BACKUP_FILE'"
|
||||||
|
else
|
||||||
|
# Fallback: plain copy (risk of inconsistent state if DB is being written)
|
||||||
|
cp "$DB_PATH" "$BACKUP_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Compress
|
||||||
|
gzip -f "$BACKUP_FILE"
|
||||||
|
|
||||||
|
# Remove old backups
|
||||||
|
find "$BACKUP_DIR" -name 'craftshop_*.db.gz' -mtime +"$RETENTION_DAYS" -delete
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Backup created: ${BACKUP_FILE}.gz"
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Craftshop SQLite Database Backup
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/opt/craftshop/scripts/backup-db.sh /opt/craftshop/server/prisma/prod.db /opt/craftshop/server/backups 30
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Craftshop Database Backup Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 00/6:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# Nginx для доступа к админке через Netbird
|
||||||
|
# Размещается на сервере в /etc/nginx/sites-available/craftshop-netbird
|
||||||
|
# с симлинком в /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 100.109.3.6:80;
|
||||||
|
server_name 100.109.3.6;
|
||||||
|
|
||||||
|
root /opt/craftshop/www;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3333;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://127.0.0.1:3333;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
PORT=3333
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
JWT_SECRET=замените-на-секрет-jwt
|
||||||
|
|
||||||
|
# Загрузки (байты). Админ: одно изображение (товары, галерея) — по умолчанию 20 МБ.
|
||||||
|
# ADMIN_IMAGE_MAX_FILE_BYTES=20971520
|
||||||
|
# (устаревшее имя, то же значение) PRODUCT_IMAGE_MAX_FILE_BYTES=20971520
|
||||||
|
# Отзывы, чек оплаты и т.п.: 2 МБ.
|
||||||
|
# OTHER_UPLOAD_MAX_FILE_BYTES=2097152
|
||||||
|
# MAX_UPLOAD_BODY_BYTES=… — весь POST multipart при необходимости
|
||||||
|
|
||||||
|
# Только приватный стенд: фиксированный код входа (без письма), см. server/.dev_env и npm run dev/start:dev_env
|
||||||
|
# IS_DEFAULT_CODE_ENABLED=true
|
||||||
|
# DEFAULT_CODE=123456
|
||||||
|
|
||||||
|
# Разрешённый Origin фронта (через запятую при нескольких)
|
||||||
|
# CORS_ORIGIN=http://127.0.0.1:5173
|
||||||
|
|
||||||
|
# Ограничение доступа по IP на время разработки (через запятую). Поддерживает точные IP и CIDR-диапазоны. Не задано — защита отключена.
|
||||||
|
# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8,192.168.1.0/24
|
||||||
|
|
||||||
|
# Ограничение доступа к админ-роутам по IP (через запятую). Поддерживает точные IP и CIDR-диапазоны. Не задано — защита отключена.
|
||||||
|
# ADMIN_ACCESS_IPS=1.2.3.4,10.0.0.0/24
|
||||||
|
|
||||||
|
# Публичные URL для OAuth redirect (локально обычно так):
|
||||||
|
SERVER_PUBLIC_URL=http://127.0.0.1:3333
|
||||||
|
CLIENT_PUBLIC_URL=http://127.0.0.1:5173
|
||||||
|
|
||||||
|
# VK OAuth: в кабинете VK задать redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/vk/callback
|
||||||
|
VK_CLIENT_ID=
|
||||||
|
VK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback
|
||||||
|
# Scopes: login:email login:info
|
||||||
|
YANDEX_CLIENT_ID=
|
||||||
|
YANDEX_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Telegram Bot (оповещения админа)
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
|
||||||
|
# YooKassa payment integration
|
||||||
|
YOOKASSA_SHOP_ID=
|
||||||
|
YOOKASSA_SECRET_KEY=
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
# Keep environment variables out of version control
|
||||||
|
.env
|
||||||
|
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
import eslint from '@eslint/js'
|
||||||
|
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||||
|
import importX from 'eslint-plugin-import-x'
|
||||||
|
import eslintPluginPrettier from 'eslint-plugin-prettier'
|
||||||
|
import globals from 'globals'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ['node_modules/**'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
importX.flatConfigs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,mjs,cjs}'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.node, ...globals.es2021 },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-console': ['error', { allow: ['warn', 'error', 'info'] }],
|
||||||
|
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
|
||||||
|
'max-len': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
code: 120,
|
||||||
|
ignoreStrings: true,
|
||||||
|
ignoreTrailingComments: true,
|
||||||
|
ignoreTemplateLiterals: true,
|
||||||
|
ignoreComments: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'import-x/extensions': 'off',
|
||||||
|
'import-x/prefer-default-export': 'off',
|
||||||
|
'import-x/no-extraneous-dependencies': 'off',
|
||||||
|
'import-x/no-cycle': 'warn',
|
||||||
|
'import-x/order': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
|
||||||
|
'newlines-between': 'never',
|
||||||
|
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'no-unused-vars': ['error', { args: 'none' }],
|
||||||
|
'no-shadow': 'off',
|
||||||
|
'consistent-return': 'off',
|
||||||
|
'no-use-before-define': 'error',
|
||||||
|
'no-empty-function': 'warn',
|
||||||
|
'class-methods-use-this': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plugins: { prettier: eslintPluginPrettier },
|
||||||
|
rules: { 'prettier/prettier': ['warn', { endOfLine: 'lf' }] },
|
||||||
|
},
|
||||||
|
eslintConfigPrettier,
|
||||||
|
{
|
||||||
|
files: ['eslint.config.js'],
|
||||||
|
rules: {
|
||||||
|
'import-x/no-unresolved': 'off',
|
||||||
|
'import-x/no-named-as-default': 'off',
|
||||||
|
'import-x/no-named-as-default-member': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
+5502
File diff suppressed because it is too large
Load Diff
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --env-file=.env --unhandled-rejections=warn --watch src/index.js",
|
||||||
|
"dev:classic": "node --watch src/index.js",
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier . --write --ignore-unknown",
|
||||||
|
"format:check": "prettier . --check --ignore-unknown",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"db:reset:test": "prisma migrate reset --force"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dicebear/collection": "^9.4.2",
|
||||||
|
"@dicebear/core": "^9.4.2",
|
||||||
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/multipart": "^10.0.0",
|
||||||
|
"@fastify/static": "^9.1.3",
|
||||||
|
"@prisma/client": "5.22.0",
|
||||||
|
"@rollup/rollup-linux-x64-gnu": "^4.61.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"fastify": "^5.8.5",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
|
"sharp": "0.32.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"eslint": "^10.4.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-import-x": "^4.16.2",
|
||||||
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"prisma": "5.22.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Category" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"sort" INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Product" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"priceCents" INTEGER NOT NULL,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"categoryId" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AuthCode" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"codeHash" TEXT NOT NULL,
|
||||||
|
"purpose" TEXT NOT NULL,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"usedAt" DATETIME,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"userId" TEXT,
|
||||||
|
CONSTRAINT "AuthCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuthCode_email_purpose_idx" ON "AuthCode"("email", "purpose");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuthCode_expiresAt_idx" ON "AuthCode"("expiresAt");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "name" TEXT;
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Product" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"shortDescription" TEXT,
|
||||||
|
"description" TEXT,
|
||||||
|
"priceCents" INTEGER NOT NULL,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"inStock" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"leadTimeDays" INTEGER,
|
||||||
|
"categoryId" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Product" ("categoryId", "createdAt", "description", "id", "imageUrl", "priceCents", "published", "slug", "title", "updatedAt") SELECT "categoryId", "createdAt", "description", "id", "imageUrl", "priceCents", "published", "slug", "title", "updatedAt" FROM "Product";
|
||||||
|
DROP TABLE "Product";
|
||||||
|
ALTER TABLE "new_Product" RENAME TO "Product";
|
||||||
|
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductImage" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"sort" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductImage_productId_sort_idx" ON "ProductImage"("productId", "sort");
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Product" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"shortDescription" TEXT,
|
||||||
|
"description" TEXT,
|
||||||
|
"quantity" INTEGER,
|
||||||
|
"materials" TEXT NOT NULL DEFAULT '[]',
|
||||||
|
"priceCents" INTEGER NOT NULL,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"inStock" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"leadTimeDays" INTEGER,
|
||||||
|
"categoryId" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Product" ("categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "priceCents", "published", "shortDescription", "slug", "title", "updatedAt") SELECT "categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "priceCents", "published", "shortDescription", "slug", "title", "updatedAt" FROM "Product";
|
||||||
|
DROP TABLE "Product";
|
||||||
|
ALTER TABLE "new_Product" RENAME TO "Product";
|
||||||
|
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "phone" TEXT;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ShippingAddress" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"label" TEXT,
|
||||||
|
"recipientName" TEXT NOT NULL,
|
||||||
|
"recipientPhone" TEXT NOT NULL,
|
||||||
|
"addressLine" TEXT NOT NULL,
|
||||||
|
"comment" TEXT,
|
||||||
|
"lat" REAL NOT NULL,
|
||||||
|
"lng" REAL NOT NULL,
|
||||||
|
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "ShippingAddress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ShippingAddress_userId_isDefault_idx" ON "ShippingAddress"("userId", "isDefault");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ShippingAddress_userId_updatedAt_idx" ON "ShippingAddress"("userId", "updatedAt");
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CartItem" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"qty" INTEGER NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "CartItem_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "CartItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Order" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"totalCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'RUB',
|
||||||
|
"addressSnapshotJson" TEXT NOT NULL,
|
||||||
|
"comment" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OrderItem" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"qty" INTEGER NOT NULL,
|
||||||
|
"titleSnapshot" TEXT NOT NULL,
|
||||||
|
"priceCentsSnapshot" INTEGER NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "OrderItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OrderMessage" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"authorType" TEXT NOT NULL,
|
||||||
|
"text" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "OrderMessage_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Review" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"rating" INTEGER NOT NULL,
|
||||||
|
"text" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"moderatedAt" DATETIME,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Review_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CartItem_userId_idx" ON "CartItem"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CartItem_userId_productId_key" ON "CartItem"("userId", "productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_userId_createdAt_idx" ON "Order"("userId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Order_status_updatedAt_idx" ON "Order"("status", "updatedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OrderMessage_orderId_createdAt_idx" ON "OrderMessage"("orderId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Review_productId_status_createdAt_idx" ON "Review"("productId", "status", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Review_status_createdAt_idx" ON "Review"("status", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Review_productId_userId_key" ON "Review"("productId", "userId");
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Product" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"shortDescription" TEXT,
|
||||||
|
"description" TEXT,
|
||||||
|
"quantity" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"materials" TEXT NOT NULL DEFAULT '[]',
|
||||||
|
"priceCents" INTEGER NOT NULL,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"inStock" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"leadTimeDays" INTEGER,
|
||||||
|
"categoryId" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Product" ("categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "materials", "priceCents", "published", "quantity", "shortDescription", "slug", "title", "updatedAt") SELECT "categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "materials", "priceCents", "published", coalesce("quantity", 0) AS "quantity", "shortDescription", "slug", "title", "updatedAt" FROM "Product";
|
||||||
|
DROP TABLE "Product";
|
||||||
|
ALTER TABLE "new_Product" RENAME TO "Product";
|
||||||
|
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Order" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"deliveryType" TEXT NOT NULL DEFAULT 'delivery',
|
||||||
|
"itemsSubtotalCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"deliveryFeeCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"totalCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'RUB',
|
||||||
|
"addressSnapshotJson" TEXT,
|
||||||
|
"comment" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Order" ("addressSnapshotJson", "comment", "createdAt", "currency", "id", "status", "totalCents", "updatedAt", "userId") SELECT "addressSnapshotJson", "comment", "createdAt", "currency", "id", "status", "totalCents", "updatedAt", "userId" FROM "Order";
|
||||||
|
DROP TABLE "Order";
|
||||||
|
ALTER TABLE "new_Order" RENAME TO "Order";
|
||||||
|
CREATE INDEX "Order_userId_createdAt_idx" ON "Order"("userId", "createdAt");
|
||||||
|
CREATE INDEX "Order_status_updatedAt_idx" ON "Order"("status", "updatedAt");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserOrderMessageReadState" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"lastReadAt" DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00 +00:00',
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "UserOrderMessageReadState_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "UserOrderMessageReadState_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OAuthAccount" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"providerUserId" TEXT NOT NULL,
|
||||||
|
"accessToken" TEXT,
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "OAuthAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UserOrderMessageReadState_userId_idx" ON "UserOrderMessageReadState"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UserOrderMessageReadState_userId_orderId_key" ON "UserOrderMessageReadState"("userId", "orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OAuthAccount_userId_idx" ON "OAuthAccount"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "OAuthAccount_provider_providerUserId_key" ON "OAuthAccount"("provider", "providerUserId");
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Review" ADD COLUMN "imageUrl" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "InfoPageBlock" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"sort" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"published" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "InfoPageBlock_key_key" ON "InfoPageBlock"("key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "InfoPageBlock_published_sort_idx" ON "InfoPageBlock"("published", "sort");
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Order" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"deliveryType" TEXT NOT NULL DEFAULT 'delivery',
|
||||||
|
"paymentMethod" TEXT NOT NULL DEFAULT 'online',
|
||||||
|
"itemsSubtotalCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"deliveryFeeCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"totalCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'RUB',
|
||||||
|
"addressSnapshotJson" TEXT,
|
||||||
|
"comment" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Order" ("addressSnapshotJson", "comment", "createdAt", "currency", "deliveryFeeCents", "deliveryType", "id", "itemsSubtotalCents", "status", "totalCents", "updatedAt", "userId") SELECT "addressSnapshotJson", "comment", "createdAt", "currency", "deliveryFeeCents", "deliveryType", "id", "itemsSubtotalCents", "status", "totalCents", "updatedAt", "userId" FROM "Order";
|
||||||
|
DROP TABLE "Order";
|
||||||
|
ALTER TABLE "new_Order" RENAME TO "Order";
|
||||||
|
CREATE INDEX "Order_userId_createdAt_idx" ON "Order"("userId", "createdAt");
|
||||||
|
CREATE INDEX "Order_status_updatedAt_idx" ON "Order"("status", "updatedAt");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "OrderMessage" ADD COLUMN "attachmentUrl" TEXT;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GalleryImage" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GalleryImage_url_key" ON "GalleryImage"("url");
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Order" ADD COLUMN "deliveryCarrier" TEXT;
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CatalogSliderSlide" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"sortOrder" INTEGER NOT NULL,
|
||||||
|
"caption" TEXT NOT NULL DEFAULT '',
|
||||||
|
"galleryImageId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "CatalogSliderSlide_galleryImageId_fkey" FOREIGN KEY ("galleryImageId") REFERENCES "GalleryImage" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Product" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"shortDescription" TEXT,
|
||||||
|
"description" TEXT,
|
||||||
|
"quantity" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"materials" TEXT NOT NULL DEFAULT '[]',
|
||||||
|
"priceCents" INTEGER NOT NULL,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"inStock" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"leadTimeDays" INTEGER,
|
||||||
|
"categoryId" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Product" ("categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "materials", "priceCents", "published", "quantity", "shortDescription", "slug", "title", "updatedAt") SELECT "categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "materials", "priceCents", "published", "quantity", "shortDescription", "slug", "title", "updatedAt" FROM "Product";
|
||||||
|
DROP TABLE "Product";
|
||||||
|
ALTER TABLE "new_Product" RENAME TO "Product";
|
||||||
|
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CatalogSliderSlide_sortOrder_idx" ON "CatalogSliderSlide"("sortOrder");
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
-- RedefineProductTable
|
||||||
|
-- Set quantity = 0 for made-to-order products before dropping inStock
|
||||||
|
UPDATE Product SET quantity = 0 WHERE inStock = 0;
|
||||||
|
|
||||||
|
-- Drop inStock and leadTimeDays columns
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Product" (
|
||||||
|
"id" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"shortDescription" TEXT,
|
||||||
|
"description" TEXT,
|
||||||
|
"quantity" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"materials" TEXT NOT NULL DEFAULT '[]',
|
||||||
|
"priceCents" INTEGER NOT NULL,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"categoryId" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Product" ("createdAt", "description", "id", "imageUrl", "materials", "priceCents", "published", "quantity", "shortDescription", "slug", "title", "updatedAt", "categoryId") SELECT "createdAt", "description", "id", "imageUrl", "materials", "priceCents", "published", "quantity", "shortDescription", "slug", "title", "updatedAt", "categoryId" FROM "Product";
|
||||||
|
DROP TABLE "Product";
|
||||||
|
ALTER TABLE "new_Product" RENAME TO "Product";
|
||||||
|
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Order" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"deliveryFeeLocked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"deliveryType" TEXT NOT NULL DEFAULT 'delivery',
|
||||||
|
"deliveryCarrier" TEXT,
|
||||||
|
"paymentMethod" TEXT NOT NULL DEFAULT 'online',
|
||||||
|
"itemsSubtotalCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"deliveryFeeCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"totalCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'RUB',
|
||||||
|
"addressSnapshotJson" TEXT,
|
||||||
|
"comment" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Order" ("addressSnapshotJson", "comment", "createdAt", "currency", "deliveryCarrier", "deliveryFeeCents", "deliveryType", "id", "itemsSubtotalCents", "paymentMethod", "status", "totalCents", "updatedAt", "userId") SELECT "addressSnapshotJson", "comment", "createdAt", "currency", "deliveryCarrier", "deliveryFeeCents", "deliveryType", "id", "itemsSubtotalCents", "paymentMethod", "status", "totalCents", "updatedAt", "userId" FROM "Order";
|
||||||
|
DROP TABLE "Order";
|
||||||
|
ALTER TABLE "new_Order" RENAME TO "Order";
|
||||||
|
CREATE INDEX "Order_userId_createdAt_idx" ON "Order"("userId", "createdAt");
|
||||||
|
CREATE INDEX "Order_status_updatedAt_idx" ON "Order"("status", "updatedAt");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_GalleryImage" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"isResized" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
INSERT INTO "new_GalleryImage" ("createdAt", "id", "url") SELECT "createdAt", "id", "url" FROM "GalleryImage";
|
||||||
|
DROP TABLE "GalleryImage";
|
||||||
|
ALTER TABLE "new_GalleryImage" RENAME TO "GalleryImage";
|
||||||
|
CREATE UNIQUE INDEX "GalleryImage_url_key" ON "GalleryImage"("url");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "NotificationPreference" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"globalEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"orderCreated" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"orderStatusChanged" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"orderMessageReceived" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"paymentStatusChanged" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AdminNotificationSettings" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"emailEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"telegramEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"telegramChatId" TEXT,
|
||||||
|
"newOrder" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"newOrderMessage" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"newReview" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"authCodeDuplicate" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "NotificationLog" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT,
|
||||||
|
"eventType" TEXT NOT NULL,
|
||||||
|
"channel" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"error" TEXT,
|
||||||
|
"payload" TEXT NOT NULL,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "NotificationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationPreference_userId_idx" ON "NotificationPreference"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_status_createdAt_idx" ON "NotificationLog"("status", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_userId_createdAt_idx" ON "NotificationLog"("userId", "createdAt");
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "NotificationPreference_userId_idx";
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_NotificationPreference" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"globalEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"orderCreated" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"orderStatusChanged" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"orderMessageReceived" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"paymentStatusChanged" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"deliveryFeeAdjusted" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_NotificationPreference" ("createdAt", "globalEnabled", "id", "orderCreated", "orderMessageReceived", "orderStatusChanged", "paymentStatusChanged", "updatedAt", "userId") SELECT "createdAt", "globalEnabled", "id", "orderCreated", "orderMessageReceived", "orderStatusChanged", "paymentStatusChanged", "updatedAt", "userId" FROM "NotificationPreference";
|
||||||
|
DROP TABLE "NotificationPreference";
|
||||||
|
ALTER TABLE "new_NotificationPreference" RENAME TO "NotificationPreference";
|
||||||
|
CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `InfoPageBlock` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropTable
|
||||||
|
PRAGMA foreign_keys=off;
|
||||||
|
DROP TABLE "InfoPageBlock";
|
||||||
|
PRAGMA foreign_keys=on;
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `name` on the `User` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT,
|
||||||
|
"firstName" TEXT,
|
||||||
|
"lastName" TEXT,
|
||||||
|
"gender" TEXT,
|
||||||
|
"avatar" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"passwordHash" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "new_User" ("createdAt", "email", "id", "passwordHash", "phone", "updatedAt") SELECT "createdAt", "email", "id", "passwordHash", "phone", "updatedAt" FROM "User";
|
||||||
|
DROP TABLE "User";
|
||||||
|
ALTER TABLE "new_User" RENAME TO "User";
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Payment" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"yookassaPaymentId" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"amountCents" INTEGER NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'RUB',
|
||||||
|
"confirmationUrl" TEXT,
|
||||||
|
"expiresAt" DATETIME,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Payment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Payment_yookassaPaymentId_key" ON "Payment"("yookassaPaymentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Payment_orderId_idx" ON "Payment"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Payment_yookassaPaymentId_idx" ON "Payment"("yookassaPaymentId");
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Payment_yookassaPaymentId_idx";
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE User DROP COLUMN phone;
|
||||||
|
ALTER TABLE User ADD COLUMN "avatarType" TEXT;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE User ADD COLUMN "avatarStyle" TEXT;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `avatarType` on the `User` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT,
|
||||||
|
"firstName" TEXT,
|
||||||
|
"lastName" TEXT,
|
||||||
|
"gender" TEXT,
|
||||||
|
"avatar" TEXT,
|
||||||
|
"avatarStyle" TEXT,
|
||||||
|
"passwordHash" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "new_User" ("avatar", "avatarStyle", "createdAt", "displayName", "email", "firstName", "gender", "id", "lastName", "passwordHash", "updatedAt") SELECT "avatar", "avatarStyle", "createdAt", "displayName", "email", "firstName", "gender", "id", "lastName", "passwordHash", "updatedAt" FROM "User";
|
||||||
|
DROP TABLE "User";
|
||||||
|
ALTER TABLE "new_User" RENAME TO "User";
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `firstName` on the `User` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `gender` on the `User` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `lastName` on the `User` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT,
|
||||||
|
"avatar" TEXT,
|
||||||
|
"avatarStyle" TEXT,
|
||||||
|
"passwordHash" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "new_User" ("avatar", "avatarStyle", "createdAt", "displayName", "email", "id", "passwordHash", "updatedAt") SELECT "avatar", "avatarStyle", "createdAt", "displayName", "email", "id", "passwordHash", "updatedAt" FROM "User";
|
||||||
|
DROP TABLE "User";
|
||||||
|
ALTER TABLE "new_User" RENAME TO "User";
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PendingEmail" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "PendingEmail_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PendingEmail_token_key" ON "PendingEmail"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PendingEmail_token_idx" ON "PendingEmail"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PendingEmail_userId_idx" ON "PendingEmail"("userId");
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ChecklistResult" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"itemKey" TEXT NOT NULL,
|
||||||
|
"passed" BOOLEAN NOT NULL,
|
||||||
|
"checkedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ChecklistResult_itemKey_key" ON "ChecklistResult"("itemKey");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ChecklistResult" ADD COLUMN "comment" TEXT;
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_CatalogSliderSlide" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"sortOrder" INTEGER NOT NULL,
|
||||||
|
"caption" TEXT NOT NULL DEFAULT '',
|
||||||
|
"textColor" TEXT NOT NULL DEFAULT '#ffffff',
|
||||||
|
"galleryImageId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "CatalogSliderSlide_galleryImageId_fkey" FOREIGN KEY ("galleryImageId") REFERENCES "GalleryImage" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_CatalogSliderSlide" ("caption", "galleryImageId", "id", "sortOrder") SELECT "caption", "galleryImageId", "id", "sortOrder" FROM "CatalogSliderSlide";
|
||||||
|
DROP TABLE "CatalogSliderSlide";
|
||||||
|
ALTER TABLE "new_CatalogSliderSlide" RENAME TO "CatalogSliderSlide";
|
||||||
|
CREATE INDEX "CatalogSliderSlide_sortOrder_idx" ON "CatalogSliderSlide"("sortOrder");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "sqlite"
|
||||||
Executable
BIN
Binary file not shown.
Executable
+353
@@ -0,0 +1,353 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Категория изделий (игрушки, сувениры и т.д.)
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
sort Int @default(0)
|
||||||
|
products Product[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
slug String @unique
|
||||||
|
shortDescription String?
|
||||||
|
description String?
|
||||||
|
/// Количество на складе
|
||||||
|
quantity Int @default(0)
|
||||||
|
/// Материалы (список, например: ["хлопок","дерево"])
|
||||||
|
materials String @default("[]")
|
||||||
|
/// Цена в копейках (целое число, без дробной части)
|
||||||
|
priceCents Int
|
||||||
|
imageUrl String?
|
||||||
|
published Boolean @default(false)
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||||
|
categoryId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
images ProductImage[]
|
||||||
|
reviews Review[]
|
||||||
|
orderItems OrderItem[]
|
||||||
|
cartItems CartItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProductImage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
url String
|
||||||
|
sort Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
productId String
|
||||||
|
|
||||||
|
@@index([productId, sort])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Медиатека админки: зарегистрированные файлы /uploads/... (без обязательной привязки к товару).
|
||||||
|
model GalleryImage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
url String @unique
|
||||||
|
isResized Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
catalogSliderSlides CatalogSliderSlide[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Слайды главной витрины (каталог): картинка из галереи + подпись.
|
||||||
|
model CatalogSliderSlide {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sortOrder Int
|
||||||
|
caption String @default("")
|
||||||
|
textColor String @default("#ffffff")
|
||||||
|
galleryImageId String
|
||||||
|
galleryImage GalleryImage @relation(fields: [galleryImageId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
displayName String?
|
||||||
|
avatar String?
|
||||||
|
avatarStyle String?
|
||||||
|
passwordHash String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
codes AuthCode[]
|
||||||
|
addresses ShippingAddress[]
|
||||||
|
cartItems CartItem[]
|
||||||
|
orders Order[]
|
||||||
|
reviews Review[]
|
||||||
|
orderMessageReadStates UserOrderMessageReadState[]
|
||||||
|
oauthAccounts OAuthAccount[]
|
||||||
|
pendingEmails PendingEmail[]
|
||||||
|
notificationPreference NotificationPreference?
|
||||||
|
notificationLogs NotificationLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Прочитанность чата по заказу (для сообщений от админа после lastReadAt)
|
||||||
|
model UserOrderMessageReadState {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
lastReadAt DateTime @default("1970-01-01T00:00:00.000Z")
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
orderId String
|
||||||
|
|
||||||
|
@@unique([userId, orderId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CartItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
qty Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
productId String
|
||||||
|
|
||||||
|
@@unique([userId, productId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Order {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Статус заказа (валидация переходов на уровне API)
|
||||||
|
status String @default("DRAFT")
|
||||||
|
deliveryFeeLocked Boolean @default(false)
|
||||||
|
/// 'delivery' | 'pickup'
|
||||||
|
deliveryType String @default("delivery")
|
||||||
|
/// RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST при deliveryType=delivery
|
||||||
|
deliveryCarrier String?
|
||||||
|
/// 'online' | 'on_pickup' — способ расчёта для заказа
|
||||||
|
paymentMethod String @default("online")
|
||||||
|
itemsSubtotalCents Int @default(0)
|
||||||
|
deliveryFeeCents Int @default(0)
|
||||||
|
totalCents Int @default(0)
|
||||||
|
currency String @default("RUB")
|
||||||
|
addressSnapshotJson String?
|
||||||
|
comment String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
|
||||||
|
items OrderItem[]
|
||||||
|
messages OrderMessage[]
|
||||||
|
payments Payment[]
|
||||||
|
messageReadStates UserOrderMessageReadState[]
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([status, updatedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Payment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
yookassaPaymentId String @unique
|
||||||
|
status String
|
||||||
|
amountCents Int
|
||||||
|
currency String @default("RUB")
|
||||||
|
confirmationUrl String?
|
||||||
|
expiresAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([orderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrderItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
qty Int
|
||||||
|
titleSnapshot String
|
||||||
|
priceCentsSnapshot Int
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
orderId String
|
||||||
|
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
||||||
|
productId String
|
||||||
|
|
||||||
|
@@index([orderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrderMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// 'user' | 'admin'
|
||||||
|
authorType String
|
||||||
|
text String
|
||||||
|
/// URL вида /uploads/… (чек к оплате и т.п.)
|
||||||
|
attachmentUrl String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
orderId String
|
||||||
|
|
||||||
|
@@index([orderId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Review {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
rating Int
|
||||||
|
text String?
|
||||||
|
imageUrl String?
|
||||||
|
/// 'pending' | 'approved' | 'rejected'
|
||||||
|
status String @default("pending")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
moderatedAt DateTime?
|
||||||
|
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
productId String
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
|
||||||
|
@@index([productId, status, createdAt])
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@unique([productId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShippingAddress {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
label String?
|
||||||
|
recipientName String
|
||||||
|
recipientPhone String
|
||||||
|
addressLine String
|
||||||
|
comment String?
|
||||||
|
lat Float
|
||||||
|
lng Float
|
||||||
|
isDefault Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
|
||||||
|
@@index([userId, isDefault])
|
||||||
|
@@index([userId, updatedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OAuthAccount {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// 'vk' | 'yandex'
|
||||||
|
provider String
|
||||||
|
providerUserId String
|
||||||
|
accessToken String?
|
||||||
|
refreshToken String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
|
||||||
|
@@unique([provider, providerUserId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PendingEmail {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
email String
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([token])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuthCode {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String
|
||||||
|
codeHash String
|
||||||
|
purpose String
|
||||||
|
expiresAt DateTime
|
||||||
|
usedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String?
|
||||||
|
|
||||||
|
@@index([email, purpose])
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Настройки оповещений пользователя
|
||||||
|
model NotificationPreference {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
globalEnabled Boolean @default(true)
|
||||||
|
orderCreated Boolean @default(true)
|
||||||
|
orderStatusChanged Boolean @default(true)
|
||||||
|
orderMessageReceived Boolean @default(true)
|
||||||
|
paymentStatusChanged Boolean @default(true)
|
||||||
|
deliveryFeeAdjusted Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Настройки оповещений админа
|
||||||
|
model AdminNotificationSettings {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
emailEnabled Boolean @default(true)
|
||||||
|
telegramEnabled Boolean @default(false)
|
||||||
|
telegramChatId String?
|
||||||
|
newOrder Boolean @default(true)
|
||||||
|
newOrderMessage Boolean @default(true)
|
||||||
|
newReview Boolean @default(true)
|
||||||
|
authCodeDuplicate Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Лог отправки оповещений
|
||||||
|
model NotificationLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String?
|
||||||
|
eventType String
|
||||||
|
channel String
|
||||||
|
status String
|
||||||
|
error String?
|
||||||
|
payload String
|
||||||
|
attempts Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Результат ручной проверки тест-чеклиста
|
||||||
|
model ChecklistResult {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
itemKey String @unique
|
||||||
|
passed Boolean
|
||||||
|
comment String?
|
||||||
|
checkedAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import { prisma } from '../src/lib/prisma.js'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { count } = await prisma.galleryImage.updateMany({
|
||||||
|
where: { isResized: false },
|
||||||
|
data: { isResized: true },
|
||||||
|
})
|
||||||
|
console.info(`Marked ${count} existing images as resized`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
Executable
+253
@@ -0,0 +1,253 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import path from 'node:path'
|
||||||
|
import cors from '@fastify/cors'
|
||||||
|
import jwt from '@fastify/jwt'
|
||||||
|
import multipart from '@fastify/multipart'
|
||||||
|
import fastifyStatic from '@fastify/static'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||||
|
import { ensureAdminUser } from './lib/bootstrap-admin.js'
|
||||||
|
import { getOrCreateUnspecifiedCategory } from './lib/default-category.js'
|
||||||
|
import { createEventBus } from './lib/notifications/event-bus.js'
|
||||||
|
import {
|
||||||
|
resolveUserNotificationTargets,
|
||||||
|
resolveAdminNotificationTargets,
|
||||||
|
resolveAuthCodeTargets,
|
||||||
|
} from './lib/notifications/preferences.js'
|
||||||
|
import { createNotificationQueue } from './lib/notifications/queue.js'
|
||||||
|
import { prisma } from './lib/prisma.js'
|
||||||
|
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
|
||||||
|
import { registerAuth } from './plugins/auth.js'
|
||||||
|
import { registerIpGate } from './plugins/ip-gate.js'
|
||||||
|
import { registerSecurityHeaders } from './plugins/security-headers.js'
|
||||||
|
import { registerApiRoutes } from './routes/api.js'
|
||||||
|
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
||||||
|
import { registerSseRoutes } from './routes/sse.js'
|
||||||
|
import { registerUploadsResized } from './routes/uploads-resized.js'
|
||||||
|
import { registerUserNotificationRoutes } from './routes/user/notifications.js'
|
||||||
|
import { registerUserAddressRoutes } from './routes/user-addresses.js'
|
||||||
|
import { registerUserCartRoutes } from './routes/user-cart.js'
|
||||||
|
import { registerUserMessageRoutes } from './routes/user-messages.js'
|
||||||
|
import { registerUserOrderRoutes } from './routes/user-orders.js'
|
||||||
|
import { registerUserPaymentRoutes } from './routes/user-payments.js'
|
||||||
|
import { registerYookassaWebhookRoute } from './routes/webhook-yookassa.js'
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT) || 3333
|
||||||
|
const origin = (process.env.CORS_ORIGIN ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const fastify = Fastify({
|
||||||
|
logger: true,
|
||||||
|
bodyLimit: getMaxUploadBodyBytes(),
|
||||||
|
trustProxy: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await fastify.register(cors, {
|
||||||
|
origin: origin.length ? origin : true,
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await registerSecurityHeaders(fastify)
|
||||||
|
|
||||||
|
fastify.get('/health', async (request) => {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`
|
||||||
|
return { status: 'ok', database: 'connected', uptime: process.uptime() }
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err }, 'Health check database query failed')
|
||||||
|
return { status: 'degraded', database: 'disconnected', uptime: process.uptime() }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.setErrorHandler(function errorHandler(error, request, reply) {
|
||||||
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
if (error.validation) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Ошибка валидации',
|
||||||
|
details: isProd ? undefined : error.validation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'FST_ERR_VALIDATION') {
|
||||||
|
return reply.code(400).send({ error: 'Неверный формат запроса' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
return reply.code(error.statusCode).send({
|
||||||
|
error: error.message || 'Произошла ошибка',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.error(error)
|
||||||
|
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: isProd ? 'Внутренняя ошибка сервера' : error.message,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await fastify.register(jwt, {
|
||||||
|
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me',
|
||||||
|
})
|
||||||
|
|
||||||
|
await fastify.register(multipart, {
|
||||||
|
limits: {
|
||||||
|
files: 10,
|
||||||
|
fileSize: getProductImageMaxFileBytes(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
registerUploadsResized(fastify)
|
||||||
|
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'uploads')
|
||||||
|
await fastify.register(fastifyStatic, {
|
||||||
|
root: uploadsDir,
|
||||||
|
prefix: '/uploads/',
|
||||||
|
setHeaders(res, filePath) {
|
||||||
|
if (filePath.includes('/.cache/')) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||||
|
} else {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.decorate('authenticate', async function authenticate(request, reply) {
|
||||||
|
try {
|
||||||
|
if (!request.headers.authorization && request.query?.token) {
|
||||||
|
request.headers.authorization = `Bearer ${request.query.token}`
|
||||||
|
}
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err }, 'JWT verification failed')
|
||||||
|
return reply.code(401).send({ error: 'Не авторизован' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventBus = createEventBus()
|
||||||
|
const notificationQueue = createNotificationQueue()
|
||||||
|
fastify.decorate('eventBus', eventBus)
|
||||||
|
fastify.decorate('notificationQueue', notificationQueue)
|
||||||
|
|
||||||
|
await registerIpGate(fastify)
|
||||||
|
registerAuth(fastify)
|
||||||
|
await registerUserAddressRoutes(fastify)
|
||||||
|
await registerUserCartRoutes(fastify)
|
||||||
|
await registerUserMessageRoutes(fastify)
|
||||||
|
await registerSseRoutes(fastify)
|
||||||
|
await registerUserOrderRoutes(fastify)
|
||||||
|
await registerUserPaymentRoutes(fastify)
|
||||||
|
await registerUserNotificationRoutes(fastify)
|
||||||
|
await registerOAuthSocialRoutes(fastify)
|
||||||
|
await registerYookassaWebhookRoute(fastify)
|
||||||
|
await registerApiRoutes(fastify)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureAdminUser()
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error({ err }, 'ensureAdminUser failed — continuing startup')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getOrCreateUnspecifiedCategory()
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error({ err }, 'getOrCreateUnspecifiedCategory failed — continuing startup')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await notificationQueue.flushPendingOnStartup()
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error({ err }, 'notificationQueue.flushPendingOnStartup failed')
|
||||||
|
}
|
||||||
|
notificationQueue.start()
|
||||||
|
|
||||||
|
const {
|
||||||
|
ORDER_CREATED,
|
||||||
|
ORDER_STATUS_CHANGED,
|
||||||
|
ORDER_MESSAGE_SENT,
|
||||||
|
ORDER_MESSAGE_ADMIN_REPLY,
|
||||||
|
PAYMENT_STATUS_CHANGED,
|
||||||
|
AUTH_CODE_REQUESTED,
|
||||||
|
DELIVERY_FEE_ADJUSTED,
|
||||||
|
} = NOTIFICATION_EVENTS
|
||||||
|
|
||||||
|
async function dispatchNotification(eventType, payload) {
|
||||||
|
try {
|
||||||
|
if (eventType === AUTH_CODE_REQUESTED) {
|
||||||
|
const targets = await resolveAuthCodeTargets(eventType, payload)
|
||||||
|
for (const target of targets.filter((t) => t.channel === 'telegram')) {
|
||||||
|
const log = await prisma.notificationLog.create({
|
||||||
|
data: {
|
||||||
|
eventType,
|
||||||
|
channel: target.channel,
|
||||||
|
status: 'pending',
|
||||||
|
payload: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userTargets = await resolveUserNotificationTargets(eventType, payload)
|
||||||
|
for (const target of userTargets) {
|
||||||
|
const log = await prisma.notificationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: payload.userId,
|
||||||
|
eventType,
|
||||||
|
channel: target.channel,
|
||||||
|
status: 'pending',
|
||||||
|
payload: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminEventType = eventType === 'order:created:admin' ? ORDER_CREATED : eventType
|
||||||
|
const adminTargets = await resolveAdminNotificationTargets(adminEventType, payload)
|
||||||
|
for (const target of adminTargets) {
|
||||||
|
const log = await prisma.notificationLog.create({
|
||||||
|
data: {
|
||||||
|
eventType,
|
||||||
|
channel: target.channel,
|
||||||
|
status: 'pending',
|
||||||
|
payload: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[notification] Error dispatching ${eventType}:`, err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBus.on(ORDER_CREATED, (payload) => dispatchNotification(ORDER_CREATED, payload))
|
||||||
|
eventBus.on(ORDER_STATUS_CHANGED, (payload) => dispatchNotification(ORDER_STATUS_CHANGED, payload))
|
||||||
|
eventBus.on(ORDER_MESSAGE_SENT, (payload) => dispatchNotification(ORDER_MESSAGE_SENT, payload))
|
||||||
|
eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, (payload) => dispatchNotification(ORDER_MESSAGE_ADMIN_REPLY, payload))
|
||||||
|
eventBus.on(PAYMENT_STATUS_CHANGED, (payload) => dispatchNotification(PAYMENT_STATUS_CHANGED, payload))
|
||||||
|
eventBus.on(AUTH_CODE_REQUESTED, (payload) => dispatchNotification(AUTH_CODE_REQUESTED, payload))
|
||||||
|
eventBus.on('order:created:admin', (payload) => dispatchNotification('order:created:admin', payload))
|
||||||
|
eventBus.on('review:created', (payload) => dispatchNotification('review:created', payload))
|
||||||
|
eventBus.on(DELIVERY_FEE_ADJUSTED, (payload) => dispatchNotification(DELIVERY_FEE_ADJUSTED, payload))
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
notificationQueue.stop()
|
||||||
|
await fastify.close()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
process.on('SIGINT', shutdown)
|
||||||
|
process.on('SIGTERM', shutdown)
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
console.error('[process] Unhandled rejection:', reason?.message || reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastify.listen({ port, host: '0.0.0.0' })
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { asyncHandler } from '../async-handler.js'
|
||||||
|
|
||||||
|
describe('asyncHandler', () => {
|
||||||
|
it('calls the handler and returns result on success', async () => {
|
||||||
|
const handler = vi.fn().mockResolvedValue({ hello: 'world' })
|
||||||
|
const request = {}
|
||||||
|
const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
|
||||||
|
const result = await asyncHandler(handler)(request, reply)
|
||||||
|
expect(handler).toHaveBeenCalledWith(request, reply)
|
||||||
|
expect(result).toEqual({ hello: 'world' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('catches errors and sends 500 with generic message', async () => {
|
||||||
|
const handler = vi.fn().mockRejectedValue(new Error('boom'))
|
||||||
|
const request = { log: { error: vi.fn() } }
|
||||||
|
const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
|
||||||
|
await asyncHandler(handler)(request, reply)
|
||||||
|
expect(reply.code).toHaveBeenCalledWith(500)
|
||||||
|
expect(reply.send).toHaveBeenCalledWith({ error: 'Internal server error' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses statusCode from error object when present', async () => {
|
||||||
|
const err = new Error('Not found')
|
||||||
|
err.statusCode = 404
|
||||||
|
const handler = vi.fn().mockRejectedValue(err)
|
||||||
|
const request = { log: { error: vi.fn() } }
|
||||||
|
const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
|
||||||
|
await asyncHandler(handler)(request, reply)
|
||||||
|
expect(reply.code).toHaveBeenCalledWith(404)
|
||||||
|
expect(reply.send).toHaveBeenCalledWith({ error: 'Not found' })
|
||||||
|
})
|
||||||
|
})
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { escapeHtml } from '../escape-html.js'
|
||||||
|
|
||||||
|
describe('escapeHtml', () => {
|
||||||
|
it('escapes & < > "', () => {
|
||||||
|
expect(escapeHtml('&<>"')).toBe('&<>"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty string for null/undefined', () => {
|
||||||
|
expect(escapeHtml(null)).toBe('')
|
||||||
|
expect(escapeHtml(undefined)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes safe text through', () => {
|
||||||
|
expect(escapeHtml('hello world')).toBe('hello world')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes mixed content', () => {
|
||||||
|
expect(escapeHtml('<script>alert("xss")</script>')).toBe('<script>alert("xss")</script>')
|
||||||
|
})
|
||||||
|
})
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { findUserOrder } from '../find-user-order.js'
|
||||||
|
|
||||||
|
describe('findUserOrder', () => {
|
||||||
|
it('returns order when found', async () => {
|
||||||
|
const mockOrder = { id: '1', userId: 'user1' }
|
||||||
|
const prisma = { order: { findFirst: vi.fn().mockResolvedValue(mockOrder) } }
|
||||||
|
const result = await findUserOrder(prisma, '1', 'user1')
|
||||||
|
expect(result).toEqual(mockOrder)
|
||||||
|
expect(prisma.order.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { id: '1', userId: 'user1' } }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws 404 when order not found', async () => {
|
||||||
|
const prisma = { order: { findFirst: vi.fn().mockResolvedValue(null) } }
|
||||||
|
await expect(findUserOrder(prisma, '999', 'user1')).rejects.toMatchObject({ statusCode: 404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes include option', async () => {
|
||||||
|
const mockOrder = { id: '1', userId: 'user1', items: [] }
|
||||||
|
const prisma = { order: { findFirst: vi.fn().mockResolvedValue(mockOrder) } }
|
||||||
|
const result = await findUserOrder(prisma, '1', 'user1', { items: true })
|
||||||
|
expect(result).toEqual(mockOrder)
|
||||||
|
expect(prisma.order.findFirst).toHaveBeenCalledWith(expect.objectContaining({ include: { items: true } }))
|
||||||
|
})
|
||||||
|
})
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { findOriginalFile, getOrCreateResized } from '../image-resize.js'
|
||||||
|
|
||||||
|
const TEST_DIR = path.join(process.cwd(), 'uploads', '.test-tmp')
|
||||||
|
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await fs.promises.mkdir(TEST_DIR, { recursive: true })
|
||||||
|
// Create a small test PNG file
|
||||||
|
const sharp = (await import('sharp')).default
|
||||||
|
const testPng = path.join(TEST_DIR, 'test-original.png')
|
||||||
|
await sharp({
|
||||||
|
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toFile(testPng)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.promises.rm(TEST_DIR, { recursive: true, force: true })
|
||||||
|
// Clean up any cache files created during tests
|
||||||
|
const cacheDir = path.join(UPLOADS_DIR, '.cache')
|
||||||
|
try {
|
||||||
|
await fs.promises.rm(cacheDir, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('image-resize', () => {
|
||||||
|
it('findOriginalFile locates file by UUID', async () => {
|
||||||
|
const files = await fs.promises.readdir(TEST_DIR)
|
||||||
|
const pngFile = files.find((f) => f.endsWith('.png'))
|
||||||
|
const uuid = pngFile.replace('.png', '')
|
||||||
|
|
||||||
|
// Copy file to actual uploads directory
|
||||||
|
const destPath = path.join(UPLOADS_DIR, pngFile)
|
||||||
|
await fs.promises.copyFile(path.join(TEST_DIR, pngFile), destPath)
|
||||||
|
|
||||||
|
const found = await findOriginalFile(uuid)
|
||||||
|
expect(found).not.toBeNull()
|
||||||
|
expect(found).toBe(destPath)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await fs.promises.unlink(destPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getOrCreateResized generates AVIF file', async () => {
|
||||||
|
const sharp = (await import('sharp')).default
|
||||||
|
const uuid = crypto.randomUUID()
|
||||||
|
const testPath = path.join(UPLOADS_DIR, `${uuid}.png`)
|
||||||
|
await sharp({
|
||||||
|
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toFile(testPath)
|
||||||
|
|
||||||
|
const result = await getOrCreateResized(uuid, 100, 'avif')
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
expect(result.isNew).toBe(true)
|
||||||
|
expect(result.path).toContain('.cache')
|
||||||
|
expect(result.path).toContain('_w100.avif')
|
||||||
|
|
||||||
|
const exists = await fs.promises
|
||||||
|
.access(result.path)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
expect(exists).toBe(true)
|
||||||
|
|
||||||
|
// Verify it's actually AVIF (sharp reports AVIF as 'heif' in metadata)
|
||||||
|
expect(result.path).toMatch(/\.avif$/)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await fs.promises.unlink(testPath)
|
||||||
|
await fs.promises.unlink(result.path)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getOrCreateResized returns cached file on second call', async () => {
|
||||||
|
const sharp = (await import('sharp')).default
|
||||||
|
const uuid = crypto.randomUUID()
|
||||||
|
const testPath = path.join(UPLOADS_DIR, `${uuid}.png`)
|
||||||
|
await sharp({
|
||||||
|
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 0, b: 255 } },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toFile(testPath)
|
||||||
|
|
||||||
|
const first = await getOrCreateResized(uuid, 100, 'webp')
|
||||||
|
expect(first.isNew).toBe(true)
|
||||||
|
|
||||||
|
const second = await getOrCreateResized(uuid, 100, 'webp')
|
||||||
|
expect(second.isNew).toBe(false)
|
||||||
|
expect(second.path).toBe(first.path)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await fs.promises.unlink(testPath)
|
||||||
|
await fs.promises.unlink(first.path)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('eager image processing', () => {
|
||||||
|
it('generateAllSizes creates all width+format combinations', async () => {
|
||||||
|
const { generateAllSizes } = await import('../image-resize.js')
|
||||||
|
const sharp = (await import('sharp')).default
|
||||||
|
const uuid = 'test-eager-uuid-1'
|
||||||
|
const testImagePath = path.join(UPLOADS_DIR, `${uuid}.png`)
|
||||||
|
await sharp({ create: { width: 2000, height: 1500, channels: 3, background: { r: 255, g: 0, b: 0 } } })
|
||||||
|
.png()
|
||||||
|
.toFile(testImagePath)
|
||||||
|
|
||||||
|
await generateAllSizes(uuid, '', testImagePath)
|
||||||
|
|
||||||
|
const cacheDir = path.join(UPLOADS_DIR, '.cache')
|
||||||
|
for (const width of [320, 640, 1024, 1600]) {
|
||||||
|
for (const format of ['avif', 'webp']) {
|
||||||
|
const cachePath = path.join(cacheDir, `${uuid}_w${width}.${format}`)
|
||||||
|
const exists = await fs.promises
|
||||||
|
.access(cachePath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
expect(exists).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await fs.promises.unlink(testImagePath)
|
||||||
|
for (const width of [320, 640, 1024, 1600]) {
|
||||||
|
for (const format of ['avif', 'webp']) {
|
||||||
|
const cachePath = path.join(cacheDir, `${uuid}_w${width}.${format}`)
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(cachePath)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('convertOriginalToWebp converts and deletes original', async () => {
|
||||||
|
const { convertOriginalToWebp } = await import('../image-resize.js')
|
||||||
|
const sharp = (await import('sharp')).default
|
||||||
|
const uuid = 'test-eager-uuid-2'
|
||||||
|
const testImagePath = path.join(UPLOADS_DIR, `${uuid}.png`)
|
||||||
|
await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 0, g: 255, b: 0 } } })
|
||||||
|
.png()
|
||||||
|
.toFile(testImagePath)
|
||||||
|
|
||||||
|
const result = await convertOriginalToWebp(uuid, '')
|
||||||
|
|
||||||
|
expect(result).toBe(`/uploads/${uuid}.webp`)
|
||||||
|
const pngExists = await fs.promises
|
||||||
|
.access(testImagePath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
expect(pngExists).toBe(false)
|
||||||
|
const webpPath = path.join(UPLOADS_DIR, `${uuid}.webp`)
|
||||||
|
const webpExists = await fs.promises
|
||||||
|
.access(webpPath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
expect(webpExists).toBe(true)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(webpPath)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { canTransitionAdminOrderStatus } from '../order-status.js'
|
||||||
|
|
||||||
|
describe('canTransitionAdminOrderStatus', () => {
|
||||||
|
const delivery = { deliveryType: 'delivery' }
|
||||||
|
const pickup = { deliveryType: 'pickup' }
|
||||||
|
|
||||||
|
it('DRAFT → PENDING_PAYMENT', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'PENDING_PAYMENT')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DRAFT → CANCELLED', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'CANCELLED')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DRAFT cannot skip to PAID', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'PAID')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PENDING_PAYMENT → PAID', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'PENDING_PAYMENT', ...delivery }, 'PAID')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PENDING_PAYMENT → CANCELLED', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'PENDING_PAYMENT', ...delivery }, 'CANCELLED')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PAID → IN_PROGRESS', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'PAID', ...delivery }, 'IN_PROGRESS')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IN_PROGRESS (delivery) → SHIPPED', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'IN_PROGRESS', ...delivery }, 'SHIPPED')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IN_PROGRESS (pickup) → READY_FOR_PICKUP', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'IN_PROGRESS', ...pickup }, 'READY_FOR_PICKUP')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IN_PROGRESS (delivery) cannot go to READY_FOR_PICKUP', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'IN_PROGRESS', ...delivery }, 'READY_FOR_PICKUP')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DONE allows no transitions', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DONE', ...delivery }, 'CANCELLED')).toBe(false)
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DONE', ...delivery }, 'PAID')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('same status returns true', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'DRAFT')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { persistMultipartImages } from '../upload-images.js'
|
||||||
|
|
||||||
|
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
|
||||||
|
const TEST_PREFIX = 'upload-test-'
|
||||||
|
|
||||||
|
describe('persistMultipartImages with eager=false', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
const files = await fs.promises.readdir(UPLOADS_DIR).catch(() => [])
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.startsWith(TEST_PREFIX)) {
|
||||||
|
await fs.promises.unlink(path.join(UPLOADS_DIR, file)).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns original format URLs when eager=false', async () => {
|
||||||
|
const sharp = (await import('sharp')).default
|
||||||
|
const testImagePath = path.join(UPLOADS_DIR, `${TEST_PREFIX}original2.png`)
|
||||||
|
await sharp({ create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 255, b: 0 } } })
|
||||||
|
.png()
|
||||||
|
.toFile(testImagePath)
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
isMultipart: () => true,
|
||||||
|
parts: async function* () {
|
||||||
|
const buffer = await fs.promises.readFile(testImagePath)
|
||||||
|
yield {
|
||||||
|
file: true,
|
||||||
|
filename: 'test.png',
|
||||||
|
toBuffer: async () => buffer,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = await persistMultipartImages(mockRequest, {
|
||||||
|
maxFiles: 1,
|
||||||
|
maxFileBytes: 20 * 1024 * 1024,
|
||||||
|
subdir: '',
|
||||||
|
eager: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(urls).toHaveLength(1)
|
||||||
|
expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.png$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { validateGalleryImages } from '../validate-gallery-images.js'
|
||||||
|
|
||||||
|
describe('validateGalleryImages', () => {
|
||||||
|
it('returns null when urls is empty', async () => {
|
||||||
|
const prisma = { galleryImage: { findMany: vi.fn() } }
|
||||||
|
const result = await validateGalleryImages(prisma, [])
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws 400 when image not found', async () => {
|
||||||
|
const prisma = { galleryImage: { findMany: vi.fn().mockResolvedValue([]) } }
|
||||||
|
await expect(validateGalleryImages(prisma, ['/uploads/missing.jpg'])).rejects.toMatchObject({ statusCode: 400 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws 400 when image not yet resized', async () => {
|
||||||
|
const prisma = {
|
||||||
|
galleryImage: { findMany: vi.fn().mockResolvedValue([{ url: '/uploads/img.jpg', isResized: false }]) },
|
||||||
|
}
|
||||||
|
await expect(validateGalleryImages(prisma, ['/uploads/img.jpg'])).rejects.toMatchObject({ statusCode: 400 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns existing images when all valid and resized', async () => {
|
||||||
|
const images = [
|
||||||
|
{ url: '/uploads/img1.jpg', isResized: true },
|
||||||
|
{ url: '/uploads/img2.jpg', isResized: true },
|
||||||
|
]
|
||||||
|
const prisma = { galleryImage: { findMany: vi.fn().mockResolvedValue(images) } }
|
||||||
|
const result = await validateGalleryImages(prisma, ['/uploads/img1.jpg', '/uploads/img2.jpg'])
|
||||||
|
expect(result).toEqual(images)
|
||||||
|
})
|
||||||
|
})
|
||||||
Executable
+257
@@ -0,0 +1,257 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPayment, getPayment, buildReceipt, validateWebhook } from '../yookassa.js'
|
||||||
|
|
||||||
|
describe('yookassa createPayment', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.YOOKASSA_SHOP_ID = '123456'
|
||||||
|
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
|
||||||
|
vi.stubGlobal('fetch', vi.fn())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
delete process.env.YOOKASSA_SHOP_ID
|
||||||
|
delete process.env.YOOKASSA_SECRET_KEY
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls POST /payments with Basic auth and Idempotence-Key', async () => {
|
||||||
|
const mockPayment = {
|
||||||
|
id: '2d0c6f35-000f-5000-8000-1234567890ab',
|
||||||
|
status: 'pending',
|
||||||
|
paid: false,
|
||||||
|
amount: { value: '1000.00', currency: 'RUB' },
|
||||||
|
confirmation: { type: 'redirect', confirmation_url: 'https://yoomoney.ru/checkout/...' },
|
||||||
|
created_at: '2026-05-20T12:00:00.000Z',
|
||||||
|
test: true,
|
||||||
|
refundable: false,
|
||||||
|
recipient: { account_id: '123456', gateway_id: '123456' },
|
||||||
|
}
|
||||||
|
fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(mockPayment),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await createPayment({
|
||||||
|
amount: { value: '1000.00', currency: 'RUB' },
|
||||||
|
description: 'Order #test',
|
||||||
|
receipt: {
|
||||||
|
customer: { email: 'test@example.com' },
|
||||||
|
items: [{ description: 'Item', quantity: 1, amount: { value: '1000.00', currency: 'RUB' }, vat_code: 1 }],
|
||||||
|
tax_system_code: 1,
|
||||||
|
},
|
||||||
|
confirmation: { type: 'redirect', return_url: 'http://localhost:5173/me/orders/test?paid=1' },
|
||||||
|
metadata: { orderId: 'test' },
|
||||||
|
idempotencyKey: 'test-v1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1)
|
||||||
|
const [url, opts] = fetch.mock.calls[0]
|
||||||
|
expect(url).toBe('https://api.yookassa.ru/v3/payments')
|
||||||
|
expect(opts.method).toBe('POST')
|
||||||
|
expect(opts.headers['Idempotence-Key']).toBe('test-v1')
|
||||||
|
expect(opts.headers['Authorization']).toBe('Basic MTIzNDU2OnRlc3Rfc2VjcmV0')
|
||||||
|
expect(result.paymentId).toBe('2d0c6f35-000f-5000-8000-1234567890ab')
|
||||||
|
expect(result.confirmationUrl).toBe('https://yoomoney.ru/checkout/...')
|
||||||
|
expect(result.status).toBe('pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries on 5xx error', async () => {
|
||||||
|
fetch
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 500 })
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 503 })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: 'retry-id',
|
||||||
|
status: 'pending',
|
||||||
|
paid: false,
|
||||||
|
amount: { value: '500.00', currency: 'RUB' },
|
||||||
|
confirmation: { type: 'redirect', confirmation_url: 'https://yoomoney.ru/checkout/retry' },
|
||||||
|
created_at: '2026-05-20T12:00:00.000Z',
|
||||||
|
test: true,
|
||||||
|
refundable: false,
|
||||||
|
recipient: { account_id: '123456', gateway_id: '123456' },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await createPayment({
|
||||||
|
amount: { value: '500.00', currency: 'RUB' },
|
||||||
|
description: 'Retry test',
|
||||||
|
receipt: {
|
||||||
|
customer: { email: 'test@example.com' },
|
||||||
|
items: [{ description: 'Item', quantity: 1, amount: { value: '500.00', currency: 'RUB' }, vat_code: 1 }],
|
||||||
|
tax_system_code: 1,
|
||||||
|
},
|
||||||
|
confirmation: { type: 'redirect', return_url: 'http://localhost:5173/me/orders/test' },
|
||||||
|
metadata: {},
|
||||||
|
idempotencyKey: 'retry-v1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(3)
|
||||||
|
expect(result.paymentId).toBe('retry-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on 4xx error', async () => {
|
||||||
|
fetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
type: 'error',
|
||||||
|
id: 'err-id',
|
||||||
|
code: 'invalid_request',
|
||||||
|
description: 'Missing required field',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createPayment({
|
||||||
|
amount: { value: '1000.00', currency: 'RUB' },
|
||||||
|
description: 'Bad request',
|
||||||
|
receipt: {
|
||||||
|
customer: { email: 'test@example.com' },
|
||||||
|
items: [{ description: 'Item', quantity: 1, amount: { value: '1000.00', currency: 'RUB' }, vat_code: 1 }],
|
||||||
|
tax_system_code: 1,
|
||||||
|
},
|
||||||
|
confirmation: { type: 'redirect', return_url: 'http://localhost:5173' },
|
||||||
|
metadata: {},
|
||||||
|
idempotencyKey: 'bad-v1',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('YooKassa API error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('yookassa getPayment', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.YOOKASSA_SHOP_ID = '123456'
|
||||||
|
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
|
||||||
|
vi.stubGlobal('fetch', vi.fn())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
delete process.env.YOOKASSA_SHOP_ID
|
||||||
|
delete process.env.YOOKASSA_SECRET_KEY
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls GET /payments/{id} and returns payment data', async () => {
|
||||||
|
fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: 'payment-id',
|
||||||
|
status: 'succeeded',
|
||||||
|
paid: true,
|
||||||
|
amount: { value: '1000.00', currency: 'RUB' },
|
||||||
|
created_at: '2026-05-20T12:00:00.000Z',
|
||||||
|
test: true,
|
||||||
|
refundable: true,
|
||||||
|
recipient: { account_id: '123456', gateway_id: '123456' },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await getPayment('payment-id')
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1)
|
||||||
|
expect(fetch.mock.calls[0][0]).toBe('https://api.yookassa.ru/v3/payments/payment-id')
|
||||||
|
expect(result.paymentId).toBe('payment-id')
|
||||||
|
expect(result.status).toBe('succeeded')
|
||||||
|
expect(result.paid).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('yookassa buildReceipt', () => {
|
||||||
|
it('builds receipt with order items', () => {
|
||||||
|
const result = buildReceipt({
|
||||||
|
orderItems: [{ titleSnapshot: 'Test Product', qty: 2, priceCentsSnapshot: 100000 }],
|
||||||
|
deliveryFeeCents: 0,
|
||||||
|
userEmail: 'user@test.ru',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.customer.email).toBe('user@test.ru')
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
expect(result.items[0].description).toBe('Test Product')
|
||||||
|
expect(result.items[0].quantity).toBe(2)
|
||||||
|
expect(result.items[0].amount.value).toBe('1000.00')
|
||||||
|
expect(result.items[0].vat_code).toBe(1)
|
||||||
|
expect(result.items[0].measure).toBe('piece')
|
||||||
|
expect(result.items[0].payment_subject).toBe('commodity')
|
||||||
|
expect(result.items[0].payment_mode).toBe('full_prepayment')
|
||||||
|
expect(result.tax_system_code).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds delivery item when deliveryFeeCents > 0', () => {
|
||||||
|
const result = buildReceipt({
|
||||||
|
orderItems: [{ titleSnapshot: 'Item A', qty: 1, priceCentsSnapshot: 50000 }],
|
||||||
|
deliveryFeeCents: 35000,
|
||||||
|
userEmail: 'user@test.ru',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(2)
|
||||||
|
expect(result.items[1].description).toBe('Доставка')
|
||||||
|
expect(result.items[1].amount.value).toBe('350.00')
|
||||||
|
expect(result.items[1].payment_subject).toBe('service')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes through taxSystemCode', () => {
|
||||||
|
const result = buildReceipt({
|
||||||
|
orderItems: [{ titleSnapshot: 'Item', qty: 1, priceCentsSnapshot: 1000 }],
|
||||||
|
deliveryFeeCents: 0,
|
||||||
|
userEmail: 'user@test.ru',
|
||||||
|
taxSystemCode: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.tax_system_code).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('yookassa validateWebhook', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.YOOKASSA_SECRET_KEY
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns event and paymentObject for valid notification', () => {
|
||||||
|
const body = {
|
||||||
|
type: 'notification',
|
||||||
|
event: 'payment.succeeded',
|
||||||
|
object: { id: 'yk-id', status: 'succeeded', paid: true },
|
||||||
|
}
|
||||||
|
const result = validateWebhook('127.0.0.1', body)
|
||||||
|
expect(result.event).toBe('payment.succeeded')
|
||||||
|
expect(result.paymentObject.id).toBe('yk-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws if type is not notification', () => {
|
||||||
|
expect(() => validateWebhook('127.0.0.1', { type: 'other', event: 'x', object: {} })).toThrow(
|
||||||
|
'Expected notification type',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws if missing event', () => {
|
||||||
|
expect(() => validateWebhook('127.0.0.1', { type: 'notification', object: {} })).toThrow('Missing event or object')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws if missing object', () => {
|
||||||
|
expect(() => validateWebhook('127.0.0.1', { type: 'notification', event: 'x' })).toThrow('Missing event or object')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws for invalid body type', () => {
|
||||||
|
expect(() => validateWebhook('127.0.0.1', 'not an object')).toThrow('Invalid webhook body')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws for null body', () => {
|
||||||
|
expect(() => validateWebhook('127.0.0.1', null)).toThrow('Invalid webhook body')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips IP validation in test mode (test_ key)', () => {
|
||||||
|
const body = { type: 'notification', event: 'payment.succeeded', object: {} }
|
||||||
|
expect(() => validateWebhook('1.2.3.4', body)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
export function asyncHandler(fn) {
|
||||||
|
return async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await fn(request, reply)
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error(err)
|
||||||
|
const statusCode = err.statusCode || 500
|
||||||
|
const message = err.statusCode ? err.message : 'Internal server error'
|
||||||
|
return reply.code(statusCode).send({ error: message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+106
@@ -0,0 +1,106 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import { sendLoginCodeEmail } from './email.js'
|
||||||
|
import { prisma } from './prisma.js'
|
||||||
|
|
||||||
|
export function normalizeEmail(email) {
|
||||||
|
return String(email || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomCode6() {
|
||||||
|
return String(Math.floor(100000 + Math.random() * 900000))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256(input) {
|
||||||
|
return crypto.createHash('sha256').update(input).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueEmailCode({ email, purpose, userId = null }) {
|
||||||
|
const code = randomCode6()
|
||||||
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000)
|
||||||
|
await prisma.authCode.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
purpose,
|
||||||
|
userId,
|
||||||
|
codeHash: sha256(`${email}:${purpose}:${code}:${userId ?? ''}`),
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await sendLoginCodeEmail({ to: email, code })
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvBool(raw) {
|
||||||
|
const v = String(raw ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
return v === 'true' || v === '1' || v === 'yes'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тестовые стенды: принять код из переменной DEFAULT_CODE без записи в БД. */
|
||||||
|
export function isDefaultLoginCodeAccepted(codeInput) {
|
||||||
|
if (!parseEnvBool(process.env.IS_DEFAULT_CODE_ENABLED)) return false
|
||||||
|
const expected = String(process.env.DEFAULT_CODE ?? '').trim()
|
||||||
|
if (!expected || expected.length < 4) return false
|
||||||
|
return String(codeInput ?? '').trim() === expected
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyEmailCode({ email, purpose, code, userId = null }) {
|
||||||
|
if (purpose === 'login' && isDefaultLoginCodeAccepted(code)) return true
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const codeHash = sha256(`${email}:${purpose}:${code}:${userId ?? ''}`)
|
||||||
|
|
||||||
|
const found = await prisma.authCode.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
purpose,
|
||||||
|
userId,
|
||||||
|
codeHash,
|
||||||
|
usedAt: null,
|
||||||
|
expiresAt: { gt: now },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
if (!found) return false
|
||||||
|
|
||||||
|
await prisma.authCode.update({
|
||||||
|
where: { id: found.id },
|
||||||
|
data: { usedAt: now },
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const PASSWORD_MIN_LEN = 8
|
||||||
|
|
||||||
|
const PASSWORD_REGEX = {
|
||||||
|
letter: /[a-zа-яё]/i,
|
||||||
|
digit: /[0-9]/,
|
||||||
|
special: /[^a-zа-яё0-9\s]/i,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePassword(password) {
|
||||||
|
if (typeof password !== 'string') return 'Пароль обязателен'
|
||||||
|
if (password.length < PASSWORD_MIN_LEN) return `Пароль должен быть не менее ${PASSWORD_MIN_LEN} символов`
|
||||||
|
if (!PASSWORD_REGEX.letter.test(password)) return 'Пароль должен содержать хотя бы одну букву'
|
||||||
|
if (!PASSWORD_REGEX.digit.test(password)) return 'Пароль должен содержать хотя бы одну цифру'
|
||||||
|
if (!PASSWORD_REGEX.special.test(password)) return 'Пароль должен содержать хотя бы один спецсимвол'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password) {
|
||||||
|
return bcrypt.hash(password, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function comparePassword(password, hash) {
|
||||||
|
return bcrypt.compare(password, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminEmail(email) {
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase()
|
||||||
|
if (!adminEmail) return false
|
||||||
|
return normalizeEmail(email) === adminEmail
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import { normalizeEmail } from './auth.js'
|
||||||
|
import { generateAvatar } from './generate-avatar.js'
|
||||||
|
import { prisma } from './prisma.js'
|
||||||
|
|
||||||
|
export async function ensureAdminUser() {
|
||||||
|
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||||
|
if (!adminEmail) return
|
||||||
|
if (!adminEmail.includes('@')) {
|
||||||
|
throw new Error('ADMIN_EMAIL должен быть валидным email')
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarUri = await generateAvatar(adminEmail)
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: adminEmail },
|
||||||
|
update: {},
|
||||||
|
create: { email: adminEmail, avatar: avatarUri, avatarStyle: 'avataaars' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ensure admin notification settings exist
|
||||||
|
const existing = await prisma.adminNotificationSettings.findFirst()
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.adminNotificationSettings.create({
|
||||||
|
data: {
|
||||||
|
emailEnabled: true,
|
||||||
|
telegramEnabled: false,
|
||||||
|
newOrder: true,
|
||||||
|
newOrderMessage: true,
|
||||||
|
newReview: true,
|
||||||
|
authCodeDuplicate: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import { prisma } from './prisma.js'
|
||||||
|
|
||||||
|
/** Служебная категория для товаров без выбранной категории. Slug не менять. */
|
||||||
|
export const UNSPECIFIED_CATEGORY_SLUG = 'ne-ukazano'
|
||||||
|
|
||||||
|
export async function getOrCreateUnspecifiedCategory() {
|
||||||
|
return prisma.category.upsert({
|
||||||
|
where: { slug: UNSPECIFIED_CATEGORY_SLUG },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'Не указано',
|
||||||
|
slug: UNSPECIFIED_CATEGORY_SLUG,
|
||||||
|
sort: 9999,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnspecifiedCategorySlug(slug) {
|
||||||
|
return slug === UNSPECIFIED_CATEGORY_SLUG
|
||||||
|
}
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import { DELIVERY_CARRIERS } from '../../../shared/constants/delivery-carrier.js'
|
||||||
|
|
||||||
|
export { DELIVERY_CARRIERS }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {value is typeof DELIVERY_CARRIERS[number]}
|
||||||
|
*/
|
||||||
|
export function isDeliveryCarrier(value) {
|
||||||
|
return typeof value === 'string' && DELIVERY_CARRIERS.includes(value)
|
||||||
|
}
|
||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
|
function hasSmtpEnv() {
|
||||||
|
return Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTransporter() {
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT),
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
connectionTimeout: 5000,
|
||||||
|
greetingTimeout: 5000,
|
||||||
|
socketTimeout: 5000,
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendLoginCodeEmail({ to, code }) {
|
||||||
|
if (!hasSmtpEnv()) {
|
||||||
|
console.info(`[DEV] login code for ${to}: ${code}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transporter = createTransporter()
|
||||||
|
const from = process.env.MAIL_FROM || process.env.SMTP_USER
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: 'Код входа',
|
||||||
|
text: `Ваш код: ${code}\n\nЕсли это были не вы — просто проигнорируйте письмо.`,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[email] Failed to send login code to ${to}: ${err.message}`)
|
||||||
|
console.info(`[DEV] login code for ${to}: ${code}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendNotificationEmail({ to, subject, html }) {
|
||||||
|
if (!hasSmtpEnv()) {
|
||||||
|
console.info(`[DEV] notification email to ${to}: ${subject}`)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transporter = createTransporter()
|
||||||
|
const from = process.env.MAIL_FROM || process.env.SMTP_USER
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
})
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
/** Минимальное экранирование для безопасного HTML из пользовательского ввода. */
|
||||||
|
export function escapeHtml(input) {
|
||||||
|
return String(input ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
export async function findUserOrder(prisma, orderId, userId, include = {}) {
|
||||||
|
const order = await prisma.order.findFirst({
|
||||||
|
where: { id: orderId, userId },
|
||||||
|
include,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw Object.assign(new Error('Order not found'), { statusCode: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return order
|
||||||
|
}
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { initials } from '@dicebear/collection'
|
||||||
|
import { createAvatar } from '@dicebear/core'
|
||||||
|
|
||||||
|
const DEFAULT_STYLE = initials
|
||||||
|
|
||||||
|
export async function generateAvatar(seed) {
|
||||||
|
const avatar = createAvatar(DEFAULT_STYLE, { seed: String(seed) })
|
||||||
|
return avatar.toDataUri()
|
||||||
|
}
|
||||||
Executable
+143
@@ -0,0 +1,143 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
|
||||||
|
const CACHE_DIR = path.join(UPLOADS_DIR, '.cache')
|
||||||
|
const VALID_WIDTHS = [320, 640, 1024, 1600]
|
||||||
|
const SUPPORTED_FORMATS = new Set(['avif', 'webp'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the original file by UUID (without extension) in the uploads directory tree.
|
||||||
|
* Searches both /uploads/ and /uploads/reviews/.
|
||||||
|
* Returns full path or null.
|
||||||
|
*/
|
||||||
|
export async function findOriginalFile(uuid, subdir = '') {
|
||||||
|
const searchDirs = subdir ? [subdir] : ['', 'reviews']
|
||||||
|
for (const dir of searchDirs) {
|
||||||
|
for (const ext of ['.png', '.jpg', '.jpeg', '.webp']) {
|
||||||
|
const fullPath = path.join(UPLOADS_DIR, dir, `${uuid}${ext}`)
|
||||||
|
try {
|
||||||
|
await fs.promises.access(fullPath)
|
||||||
|
return fullPath
|
||||||
|
} catch {
|
||||||
|
// file not found with this extension, try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or generate a resized image. Returns { path: string, isNew: boolean }.
|
||||||
|
*/
|
||||||
|
export async function getOrCreateResized(uuid, width, format, subdir = '') {
|
||||||
|
const cacheSubdir = subdir ? subdir : ''
|
||||||
|
const cacheFileName = `${uuid}_w${width}.${format}`
|
||||||
|
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.access(cachePath)
|
||||||
|
return { path: cachePath, isNew: false }
|
||||||
|
} catch {
|
||||||
|
// cache miss, need to generate
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalPath = await findOriginalFile(uuid, subdir)
|
||||||
|
if (!originalPath) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.mkdir(path.dirname(cachePath), { recursive: true })
|
||||||
|
|
||||||
|
let sharpModule
|
||||||
|
try {
|
||||||
|
sharpModule = (await import('sharp')).default
|
||||||
|
} catch (err) {
|
||||||
|
const msg = `Failed to load sharp image processing library: ${err.message}`
|
||||||
|
throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_LOAD_ERROR' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let pipeline
|
||||||
|
try {
|
||||||
|
pipeline = sharpModule(originalPath)
|
||||||
|
|
||||||
|
if (width) {
|
||||||
|
pipeline = pipeline.resize(width, null, { withoutEnlargement: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
|
||||||
|
await pipeline[format](options).toFile(cachePath)
|
||||||
|
} catch (err) {
|
||||||
|
const msg = `Failed to resize image ${originalPath} to ${width}w ${format}: ${err.message}`
|
||||||
|
throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_RESIZE_ERROR' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: cachePath, isNew: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { VALID_WIDTHS, SUPPORTED_FORMATS }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all resize widths in AVIF + WebP for eager processing.
|
||||||
|
* @param {string} uuid - UUID without extension
|
||||||
|
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
|
||||||
|
* @param {string} originalPath - Full path to the original file
|
||||||
|
*/
|
||||||
|
export async function generateAllSizes(uuid, subdir, originalPath) {
|
||||||
|
const cacheSubdir = subdir ? subdir : ''
|
||||||
|
const cacheDir = path.join(CACHE_DIR, cacheSubdir)
|
||||||
|
await fs.promises.mkdir(cacheDir, { recursive: true })
|
||||||
|
|
||||||
|
let sharpModule
|
||||||
|
try {
|
||||||
|
sharpModule = (await import('sharp')).default
|
||||||
|
} catch (err) {
|
||||||
|
const msg = `Failed to load sharp image processing library: ${err.message}`
|
||||||
|
throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_LOAD_ERROR' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = sharpModule(originalPath)
|
||||||
|
|
||||||
|
for (const width of VALID_WIDTHS) {
|
||||||
|
for (const format of SUPPORTED_FORMATS) {
|
||||||
|
const cacheFileName = `${uuid}_w${width}.${format}`
|
||||||
|
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pipeline = source.clone().resize(width, null, { withoutEnlargement: true })
|
||||||
|
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
|
||||||
|
await pipeline[format](options).toFile(cachePath)
|
||||||
|
} catch (err) {
|
||||||
|
const msg = `Failed to generate ${width}w ${format} for ${originalPath}: ${err.message}`
|
||||||
|
throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_RESIZE_ERROR' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert original file to WebP and delete the source file.
|
||||||
|
* @param {string} uuid - UUID without extension
|
||||||
|
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
|
||||||
|
* @returns {string} New URL path like `/uploads/<uuid>.webp`
|
||||||
|
*/
|
||||||
|
export async function convertOriginalToWebp(uuid, subdir) {
|
||||||
|
const targetDir = subdir ? path.join(UPLOADS_DIR, subdir) : UPLOADS_DIR
|
||||||
|
|
||||||
|
const originalPath = await findOriginalFile(uuid, subdir)
|
||||||
|
if (!originalPath) {
|
||||||
|
throw new Error(`Original file not found for UUID: ${uuid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalExt = path.extname(originalPath).toLowerCase()
|
||||||
|
const webpPath = path.join(targetDir, `${uuid}.webp`)
|
||||||
|
|
||||||
|
const sharp = (await import('sharp')).default
|
||||||
|
await sharp(originalPath).webp({ quality: 80 }).toFile(webpPath)
|
||||||
|
|
||||||
|
if (originalExt !== '.webp') {
|
||||||
|
await fs.promises.unlink(originalPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return subdir ? `/uploads/${subdir}/${uuid}.webp` : `/uploads/${uuid}.webp`
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { prisma } from '../../prisma.js'
|
||||||
|
import {
|
||||||
|
resolveUserNotificationTargets,
|
||||||
|
resolveAdminNotificationTargets,
|
||||||
|
resolveAuthCodeTargets,
|
||||||
|
ensureUserNotificationPreference,
|
||||||
|
} from '../preferences.js'
|
||||||
|
|
||||||
|
const ORDER_CREATED = 'order:created'
|
||||||
|
const AUTH_CODE_REQUESTED = 'auth:codeRequested'
|
||||||
|
|
||||||
|
describe('preferences', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.notificationPreference.deleteMany()
|
||||||
|
await prisma.adminNotificationSettings.deleteMany()
|
||||||
|
await prisma.user.deleteMany()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await prisma.notificationPreference.deleteMany()
|
||||||
|
await prisma.adminNotificationSettings.deleteMany()
|
||||||
|
await prisma.user.deleteMany()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty targets when user has no preferences', async () => {
|
||||||
|
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
|
||||||
|
const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id })
|
||||||
|
expect(targets).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns email target when user has preferences enabled', async () => {
|
||||||
|
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
|
||||||
|
await prisma.notificationPreference.create({
|
||||||
|
data: { userId: user.id, globalEnabled: true, orderCreated: true },
|
||||||
|
})
|
||||||
|
const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id })
|
||||||
|
expect(targets).toHaveLength(1)
|
||||||
|
expect(targets[0]).toEqual({ channel: 'email', recipient: 'test@test.com' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no targets when globalEnabled is false', async () => {
|
||||||
|
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
|
||||||
|
await prisma.notificationPreference.create({
|
||||||
|
data: { userId: user.id, globalEnabled: false, orderCreated: true },
|
||||||
|
})
|
||||||
|
const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id })
|
||||||
|
expect(targets).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no targets when specific event is disabled', async () => {
|
||||||
|
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
|
||||||
|
await prisma.notificationPreference.create({
|
||||||
|
data: { userId: user.id, globalEnabled: true, orderCreated: false },
|
||||||
|
})
|
||||||
|
const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id })
|
||||||
|
expect(targets).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ensures user preference is created if not exists', async () => {
|
||||||
|
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
|
||||||
|
const prefs = await ensureUserNotificationPreference(user.id)
|
||||||
|
expect(prefs.globalEnabled).toBe(true)
|
||||||
|
expect(prefs.userId).toBe(user.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns admin targets when settings enabled', async () => {
|
||||||
|
await prisma.user.create({ data: { email: 'admin@test.com' } })
|
||||||
|
const origAdminEmail = process.env.ADMIN_EMAIL
|
||||||
|
process.env.ADMIN_EMAIL = 'admin@test.com'
|
||||||
|
|
||||||
|
await prisma.adminNotificationSettings.create({
|
||||||
|
data: { emailEnabled: true, newOrder: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const targets = await resolveAdminNotificationTargets(ORDER_CREATED, {})
|
||||||
|
expect(targets.some((t) => t.channel === 'email' && t.recipient === 'admin@test.com')).toBe(true)
|
||||||
|
|
||||||
|
process.env.ADMIN_EMAIL = origAdminEmail
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolveAuthCodeTargets returns email for user and telegram for admin', async () => {
|
||||||
|
await prisma.adminNotificationSettings.create({
|
||||||
|
data: { telegramEnabled: true, telegramChatId: '12345', authCodeDuplicate: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const targets = await resolveAuthCodeTargets(AUTH_CODE_REQUESTED, {
|
||||||
|
email: 'user@test.com',
|
||||||
|
code: '123456',
|
||||||
|
isAdmin: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(targets.some((t) => t.channel === 'email' && t.recipient === 'user@test.com')).toBe(true)
|
||||||
|
expect(targets.some((t) => t.channel === 'telegram' && t.recipient === '12345')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
// server/src/lib/notifications/channels/email-channel.js
|
||||||
|
import { sendNotificationEmail } from '../../email.js'
|
||||||
|
import {
|
||||||
|
renderAdminOrderMessageEmail,
|
||||||
|
renderOrderCreatedEmail,
|
||||||
|
renderOrderStatusChangedEmail,
|
||||||
|
renderOrderMessageEmail,
|
||||||
|
renderPaymentStatusChangedEmail,
|
||||||
|
renderAdminOrderCreatedEmail,
|
||||||
|
renderAdminNewReviewEmail,
|
||||||
|
renderAuthCodeEmail,
|
||||||
|
renderDeliveryFeeAdjustedEmail,
|
||||||
|
} from '../templates/email-templates.js'
|
||||||
|
|
||||||
|
const templateRenderers = {
|
||||||
|
'order:created': renderOrderCreatedEmail,
|
||||||
|
'order:statusChanged': renderOrderStatusChangedEmail,
|
||||||
|
'orderMessage:adminReply': renderOrderMessageEmail,
|
||||||
|
'payment:statusChanged': renderPaymentStatusChangedEmail,
|
||||||
|
'order:created:admin': renderAdminOrderCreatedEmail,
|
||||||
|
'orderMessage:sent': renderAdminOrderMessageEmail,
|
||||||
|
'review:created': renderAdminNewReviewEmail,
|
||||||
|
'auth:codeRequested': renderAuthCodeEmail,
|
||||||
|
'order:deliveryFeeAdjusted': renderDeliveryFeeAdjustedEmail,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emailChannel = {
|
||||||
|
name: 'email',
|
||||||
|
|
||||||
|
async send({ recipient, eventType, payload }) {
|
||||||
|
const renderer = templateRenderers[eventType]
|
||||||
|
if (!renderer) {
|
||||||
|
return { success: false, error: `No email template for event: ${eventType}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subject, html } = renderer(payload)
|
||||||
|
const result = await sendNotificationEmail({ to: recipient, subject, html })
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
renderOrderCreatedTg,
|
||||||
|
renderOrderStatusChangedTg,
|
||||||
|
renderOrderMessageTg,
|
||||||
|
renderPaymentStatusChangedTg,
|
||||||
|
renderAdminOrderCreatedTg,
|
||||||
|
renderAdminNewReviewTg,
|
||||||
|
renderAuthCodeTg,
|
||||||
|
renderDeliveryFeeAdjustedTg,
|
||||||
|
} from '../templates/telegram-templates.js'
|
||||||
|
|
||||||
|
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ''
|
||||||
|
|
||||||
|
const templateRenderers = {
|
||||||
|
'order:created': renderOrderCreatedTg,
|
||||||
|
'order:statusChanged': renderOrderStatusChangedTg,
|
||||||
|
'orderMessage:adminReply': renderOrderMessageTg,
|
||||||
|
'payment:statusChanged': renderPaymentStatusChangedTg,
|
||||||
|
'order:created:admin': renderAdminOrderCreatedTg,
|
||||||
|
'orderMessage:sent': renderOrderMessageTg,
|
||||||
|
'review:created': renderAdminNewReviewTg,
|
||||||
|
'auth:codeRequested': renderAuthCodeTg,
|
||||||
|
'order:deliveryFeeAdjusted': renderDeliveryFeeAdjustedTg,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postToTelegram(chatId, text) {
|
||||||
|
if (!TELEGRAM_BOT_TOKEN) {
|
||||||
|
console.info(`[DEV] telegram to ${chatId}: ${text.slice(0, 80)}`)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_id: chatId,
|
||||||
|
text,
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!data.ok) {
|
||||||
|
return { success: false, error: data.description || 'Telegram API error' }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const telegramChannel = {
|
||||||
|
name: 'telegram',
|
||||||
|
|
||||||
|
async send({ recipient: chatId, eventType, payload }) {
|
||||||
|
if (!chatId) {
|
||||||
|
return { success: false, error: 'No telegram chatId' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderer = templateRenderers[eventType]
|
||||||
|
if (!renderer) {
|
||||||
|
return { success: false, error: `No telegram template for event: ${eventType}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = renderer(payload)
|
||||||
|
return postToTelegram(chatId, text)
|
||||||
|
},
|
||||||
|
}
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
|
||||||
|
export function createEventBus() {
|
||||||
|
const bus = new EventEmitter()
|
||||||
|
bus.setMaxListeners(50)
|
||||||
|
return bus
|
||||||
|
}
|
||||||
Executable
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
|
||||||
|
import { prisma } from '../prisma.js'
|
||||||
|
|
||||||
|
const {
|
||||||
|
ORDER_CREATED,
|
||||||
|
ORDER_STATUS_CHANGED,
|
||||||
|
ORDER_MESSAGE_SENT,
|
||||||
|
ORDER_MESSAGE_ADMIN_REPLY,
|
||||||
|
PAYMENT_STATUS_CHANGED,
|
||||||
|
DELIVERY_FEE_ADJUSTED,
|
||||||
|
} = NOTIFICATION_EVENTS
|
||||||
|
|
||||||
|
const userEventFieldMap = {
|
||||||
|
[ORDER_CREATED]: 'orderCreated',
|
||||||
|
[ORDER_STATUS_CHANGED]: 'orderStatusChanged',
|
||||||
|
[ORDER_MESSAGE_ADMIN_REPLY]: 'orderMessageReceived',
|
||||||
|
[PAYMENT_STATUS_CHANGED]: 'paymentStatusChanged',
|
||||||
|
[DELIVERY_FEE_ADJUSTED]: 'deliveryFeeAdjusted',
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminEventFieldMap = {
|
||||||
|
[ORDER_MESSAGE_SENT]: 'newOrderMessage',
|
||||||
|
'review:created': 'newReview',
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveUserNotificationTargets(eventType, payload) {
|
||||||
|
const targets = []
|
||||||
|
|
||||||
|
if (payload.userId) {
|
||||||
|
const prefs = await prisma.notificationPreference.findUnique({
|
||||||
|
where: { userId: payload.userId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (prefs && prefs.globalEnabled) {
|
||||||
|
const field = userEventFieldMap[eventType]
|
||||||
|
if (field && prefs[field]) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: payload.userId },
|
||||||
|
select: { email: true },
|
||||||
|
})
|
||||||
|
if (user) {
|
||||||
|
targets.push({ channel: 'email', recipient: user.email })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAdminNotificationTargets(eventType, payload) {
|
||||||
|
const targets = []
|
||||||
|
const settings = await prisma.adminNotificationSettings.findFirst()
|
||||||
|
if (!settings) return targets
|
||||||
|
|
||||||
|
const field = adminEventFieldMap[eventType]
|
||||||
|
if (field === 'newReview') {
|
||||||
|
if (!settings.newReview) return targets
|
||||||
|
} else if (field && !settings[field]) {
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.emailEnabled) {
|
||||||
|
const admin = await prisma.user.findFirst({
|
||||||
|
where: { email: process.env.ADMIN_EMAIL },
|
||||||
|
select: { email: true },
|
||||||
|
})
|
||||||
|
if (admin) {
|
||||||
|
targets.push({ channel: 'email', recipient: admin.email })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.telegramEnabled && settings.telegramChatId) {
|
||||||
|
targets.push({ channel: 'telegram', recipient: settings.telegramChatId })
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAuthCodeTargets(eventType, payload) {
|
||||||
|
const targets = []
|
||||||
|
|
||||||
|
if (payload.email) {
|
||||||
|
targets.push({ channel: 'email', recipient: payload.email })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.isAdmin) {
|
||||||
|
const settings = await prisma.adminNotificationSettings.findFirst()
|
||||||
|
if (settings && settings.telegramEnabled && settings.telegramChatId && settings.authCodeDuplicate) {
|
||||||
|
targets.push({ channel: 'telegram', recipient: settings.telegramChatId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureUserNotificationPreference(userId) {
|
||||||
|
const existing = await prisma.notificationPreference.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
})
|
||||||
|
if (existing) return existing
|
||||||
|
return prisma.notificationPreference.create({
|
||||||
|
data: { userId, globalEnabled: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
Executable
+134
@@ -0,0 +1,134 @@
|
|||||||
|
import {
|
||||||
|
NOTIFICATION_STATUSES,
|
||||||
|
MAX_RETRY_ATTEMPTS,
|
||||||
|
RETRY_DELAYS_MS,
|
||||||
|
} from '../../../../shared/constants/notification-events.js'
|
||||||
|
import { prisma } from '../prisma.js'
|
||||||
|
import { emailChannel } from './channels/email-channel.js'
|
||||||
|
import { telegramChannel } from './channels/telegram-channel.js'
|
||||||
|
|
||||||
|
const { PENDING, SENT, FAILED } = NOTIFICATION_STATUSES
|
||||||
|
|
||||||
|
const channels = {
|
||||||
|
email: emailChannel,
|
||||||
|
telegram: telegramChannel,
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationQueue {
|
||||||
|
constructor() {
|
||||||
|
this.tasks = []
|
||||||
|
this.processing = 0
|
||||||
|
this.maxConcurrent = 5
|
||||||
|
this.intervalMs = 2000
|
||||||
|
this.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(task) {
|
||||||
|
this.tasks.push({ ...task, enqueuedAt: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.running) return
|
||||||
|
this.running = true
|
||||||
|
this._tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
_tick() {
|
||||||
|
if (!this.running) return
|
||||||
|
|
||||||
|
this._processAvailable()
|
||||||
|
|
||||||
|
setTimeout(() => this._tick(), this.intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
_processAvailable() {
|
||||||
|
while (this.tasks.length > 0 && this.processing < this.maxConcurrent) {
|
||||||
|
const task = this.tasks.shift()
|
||||||
|
this.processing++
|
||||||
|
this._execute(task).finally(() => {
|
||||||
|
this.processing--
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _execute(task) {
|
||||||
|
const channel = channels[task.channel]
|
||||||
|
if (!channel) {
|
||||||
|
await this._markFailed(task.logId, `Unknown channel: ${task.channel}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await channel.send({
|
||||||
|
recipient: task.recipient,
|
||||||
|
eventType: task.eventType,
|
||||||
|
payload: task.payload,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await this._markSent(task.logId)
|
||||||
|
} else {
|
||||||
|
await this._handleFailure(task.logId, task, result.error)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await this._handleFailure(task.logId, task, err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _markSent(logId) {
|
||||||
|
await prisma.notificationLog.update({
|
||||||
|
where: { id: logId },
|
||||||
|
data: { status: SENT },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async _markFailed(logId, error) {
|
||||||
|
await prisma.notificationLog.update({
|
||||||
|
where: { id: logId },
|
||||||
|
data: { status: FAILED, error },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async _handleFailure(logId, task, error) {
|
||||||
|
const log = await prisma.notificationLog.findUnique({ where: { id: logId } })
|
||||||
|
const newAttempts = (log?.attempts || 0) + 1
|
||||||
|
|
||||||
|
if (newAttempts >= MAX_RETRY_ATTEMPTS) {
|
||||||
|
await this._markFailed(logId, error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.notificationLog.update({
|
||||||
|
where: { id: logId },
|
||||||
|
data: { attempts: newAttempts },
|
||||||
|
})
|
||||||
|
|
||||||
|
const delay = RETRY_DELAYS_MS[newAttempts - 1] || RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]
|
||||||
|
setTimeout(() => {
|
||||||
|
this.enqueue({ ...task, logId })
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
async flushPendingOnStartup() {
|
||||||
|
const pending = await prisma.notificationLog.findMany({
|
||||||
|
where: { status: PENDING },
|
||||||
|
})
|
||||||
|
for (const log of pending) {
|
||||||
|
await prisma.notificationLog.update({
|
||||||
|
where: { id: log.id },
|
||||||
|
data: { status: FAILED, error: 'Server restarted, pending notification lost' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (pending.length > 0) {
|
||||||
|
console.info(`[notifications] Marked ${pending.length} pending notifications as failed on startup`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNotificationQueue() {
|
||||||
|
return new NotificationQueue()
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
renderAdminOrderMessageEmail,
|
||||||
|
renderDeliveryFeeAdjustedEmail,
|
||||||
|
renderOrderCreatedEmail,
|
||||||
|
renderOrderMessageEmail,
|
||||||
|
renderOrderStatusChangedEmail,
|
||||||
|
renderPaymentStatusChangedEmail,
|
||||||
|
} from '../email-templates.js'
|
||||||
|
|
||||||
|
const originalClientPublicUrl = process.env.CLIENT_PUBLIC_URL
|
||||||
|
const orderId = 'order-123456789'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalClientPublicUrl === undefined) {
|
||||||
|
delete process.env.CLIENT_PUBLIC_URL
|
||||||
|
return
|
||||||
|
}
|
||||||
|
process.env.CLIENT_PUBLIC_URL = originalClientPublicUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('email templates', () => {
|
||||||
|
it('adds personal account order links to order emails', () => {
|
||||||
|
process.env.CLIENT_PUBLIC_URL = 'https://shop.example.com/'
|
||||||
|
const expectedUrl = `https://shop.example.com/me/orders/${orderId}`
|
||||||
|
|
||||||
|
const emails = [
|
||||||
|
renderOrderCreatedEmail({ orderId, totalCents: 120000, itemsCount: 2, deliveryType: 'pickup' }),
|
||||||
|
renderOrderStatusChangedEmail({ orderId, oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' }),
|
||||||
|
renderPaymentStatusChangedEmail({ orderId, paymentStatus: 'confirmed' }),
|
||||||
|
renderDeliveryFeeAdjustedEmail({ orderId, totalCents: 135000 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const email of emails) {
|
||||||
|
expect(email.html).toContain(`href="${expectedUrl}"`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds personal account messages link to order message emails', () => {
|
||||||
|
process.env.CLIENT_PUBLIC_URL = 'https://shop.example.com'
|
||||||
|
|
||||||
|
const email = renderOrderMessageEmail({ orderId, preview: 'Здравствуйте' })
|
||||||
|
|
||||||
|
expect(email.html).toContain('href="https://shop.example.com/me/messages"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders paid payment status as paid in Russian', () => {
|
||||||
|
const email = renderPaymentStatusChangedEmail({ orderId, paymentStatus: 'paid' })
|
||||||
|
|
||||||
|
expect(email.subject).toBe('Оплата заказа — Оплачен')
|
||||||
|
expect(email.html).toContain('<b>Оплачен</b>')
|
||||||
|
expect(email.html).not.toContain('<b>paid</b>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds admin orders link to admin order message emails', () => {
|
||||||
|
process.env.CLIENT_PUBLIC_URL = 'https://shop.example.com'
|
||||||
|
|
||||||
|
const email = renderAdminOrderMessageEmail({ orderId, preview: 'Нужна консультация' })
|
||||||
|
|
||||||
|
expect(email.html).toContain('href="https://shop.example.com/admin/orders"')
|
||||||
|
})
|
||||||
|
})
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
function baseLayout(title, body) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><title>${title}</title></head>
|
||||||
|
<body style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1a1a1a;">
|
||||||
|
<div style="background:#f8f9fa;padding:16px;border-radius:8px;margin-bottom:16px;">
|
||||||
|
<h2 style="margin:0;">${title}</h2>
|
||||||
|
</div>
|
||||||
|
${body}
|
||||||
|
<div style="margin-top:24px;padding-top:16px;border-top:1px solid #e0e0e0;color:#666;font-size:14px;">
|
||||||
|
<p>Любимый Креатив — магазин handmade изделий</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientPublicUrl() {
|
||||||
|
return (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClientUrl(path) {
|
||||||
|
return `${getClientPublicUrl()}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActionLink(url, label) {
|
||||||
|
return `
|
||||||
|
<p style="margin:20px 0;">
|
||||||
|
<a href="${url}" style="display:inline-block;background:#1f2937;color:#fff;text-decoration:none;padding:10px 16px;border-radius:6px;font-weight:600;">
|
||||||
|
${label}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrderUrl(orderId) {
|
||||||
|
return buildClientUrl(`/me/orders/${encodeURIComponent(orderId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessagesUrl() {
|
||||||
|
return buildClientUrl('/me/messages')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdminOrdersUrl() {
|
||||||
|
return buildClientUrl('/admin/orders')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount, deliveryType }) {
|
||||||
|
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||||
|
const nextAction =
|
||||||
|
deliveryType === 'delivery' ? 'Оплата будет доступна после уточнения стоимости доставки.' : 'Ожидает оплаты.'
|
||||||
|
const body = `
|
||||||
|
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
|
||||||
|
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
|
||||||
|
<p>${nextAction}</p>
|
||||||
|
${renderActionLink(buildOrderUrl(orderId), 'Открыть заказ в личном кабинете')}
|
||||||
|
`
|
||||||
|
return { subject: 'Заказ создан', html: baseLayout('Заказ создан', body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOrderStatusChangedEmail({ orderId, oldStatus, newStatus }) {
|
||||||
|
const statusLabels = {
|
||||||
|
DRAFT: 'Черновик',
|
||||||
|
PENDING_PAYMENT: 'Ожидает оплаты',
|
||||||
|
PAID: 'Оплачен',
|
||||||
|
IN_PROGRESS: 'Подготовка к отправке',
|
||||||
|
READY_FOR_PICKUP: 'Готов к выдаче',
|
||||||
|
SHIPPED: 'Отправлен',
|
||||||
|
DONE: 'Завершён',
|
||||||
|
CANCELLED: 'Отменён',
|
||||||
|
}
|
||||||
|
const oldLabel = statusLabels[oldStatus] || oldStatus
|
||||||
|
const newLabel = statusLabels[newStatus] || newStatus
|
||||||
|
const body = `
|
||||||
|
<p>Статус заказа <b>#${orderId.slice(0, 8)}</b> изменён.</p>
|
||||||
|
<p><b>${oldLabel}</b> → <b>${newLabel}</b></p>
|
||||||
|
${renderActionLink(buildOrderUrl(orderId), 'Открыть заказ в личном кабинете')}
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
subject: `Статус заказа изменён — ${newLabel}`,
|
||||||
|
html: baseLayout('Статус заказа изменён', body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOrderMessageEmail({ orderId, preview }) {
|
||||||
|
const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview
|
||||||
|
const body = `
|
||||||
|
<p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p>
|
||||||
|
<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">
|
||||||
|
${truncated}
|
||||||
|
</div>
|
||||||
|
<p>Ответьте в личном кабинете.</p>
|
||||||
|
${renderActionLink(buildMessagesUrl(), 'Открыть сообщения в личном кабинете')}
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
subject: 'Новое сообщение к заказу',
|
||||||
|
html: baseLayout('Новое сообщение', body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAdminOrderMessageEmail({ orderId, preview }) {
|
||||||
|
const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview
|
||||||
|
const body = `
|
||||||
|
<p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p>
|
||||||
|
<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">
|
||||||
|
${truncated}
|
||||||
|
</div>
|
||||||
|
<p>Ответьте в админ-панели.</p>
|
||||||
|
${renderActionLink(buildAdminOrdersUrl(), 'Открыть заказы в админ-панели')}
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
subject: 'Новое сообщение к заказу',
|
||||||
|
html: baseLayout('Новое сообщение', body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) {
|
||||||
|
const statusLabels = {
|
||||||
|
pending: 'Ожидает',
|
||||||
|
paid: 'Оплачен',
|
||||||
|
confirmed: 'Подтверждён',
|
||||||
|
rejected: 'Отклонён',
|
||||||
|
}
|
||||||
|
const label = statusLabels[paymentStatus] || paymentStatus
|
||||||
|
const body = `
|
||||||
|
<p>Статус оплаты заказа <b>#${orderId.slice(0, 8)}</b>: <b>${label}</b>.</p>
|
||||||
|
${renderActionLink(buildOrderUrl(orderId), 'Открыть заказ в личном кабинете')}
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
subject: `Оплата заказа — ${label}`,
|
||||||
|
html: baseLayout('Оплата заказа', body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount, deliveryType }) {
|
||||||
|
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||||
|
const note = deliveryType === 'delivery' ? '<p>⚠️ <b>Скорректируйте стоимость доставки</b> в админ-панели.</p>' : ''
|
||||||
|
const body = `
|
||||||
|
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
|
||||||
|
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
|
||||||
|
${note}
|
||||||
|
`
|
||||||
|
return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) {
|
||||||
|
const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating)
|
||||||
|
const body = `
|
||||||
|
<p>Новый отзыв ${stars} на товар <b>${productTitle}</b> от <b>${userName}</b>.</p>
|
||||||
|
${text ? `<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">${text}</div>` : ''}
|
||||||
|
<p>Проверьте отзыв в админ-панели.</p>
|
||||||
|
`
|
||||||
|
return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAuthCodeEmail({ code }) {
|
||||||
|
const body = `
|
||||||
|
<p>Ваш код входа: <b style="font-size:24px;letter-spacing:4px;">${code}</b></p>
|
||||||
|
<p>Если это были не вы — просто проигнорируйте письмо.</p>
|
||||||
|
`
|
||||||
|
return { subject: 'Код входа', html: baseLayout('Код входа', body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDeliveryFeeAdjustedEmail({ orderId, totalCents }) {
|
||||||
|
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||||
|
const body = `
|
||||||
|
<p>Стоимость доставки заказа <b>#${orderId.slice(0, 8)}</b> скорректирована.</p>
|
||||||
|
<p>Новая сумма: <b>${total} ₽</b></p>
|
||||||
|
<p>Ожидает оплаты. Проверьте статус заказа в личном кабинете.</p>
|
||||||
|
${renderActionLink(buildOrderUrl(orderId), 'Открыть заказ в личном кабинете')}
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
subject: 'Стоимость доставки скорректирована',
|
||||||
|
html: baseLayout('Стоимость доставки скорректирована', body),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export function renderOrderCreatedTg({ orderId, totalCents, itemsCount, deliveryType }) {
|
||||||
|
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||||
|
const nextAction =
|
||||||
|
deliveryType === 'delivery' ? 'Оплата будет доступна после уточнения стоимости доставки.' : 'Ожидает оплаты.'
|
||||||
|
return `📦 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total} ₽\n${nextAction}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) {
|
||||||
|
const labels = {
|
||||||
|
DRAFT: 'Черновик',
|
||||||
|
PENDING_PAYMENT: 'Ожидает оплаты',
|
||||||
|
PAID: 'Оплачен',
|
||||||
|
IN_PROGRESS: 'Подготовка к отправке',
|
||||||
|
READY_FOR_PICKUP: 'Готов к выдаче',
|
||||||
|
SHIPPED: 'Отправлен',
|
||||||
|
DONE: 'Завершён',
|
||||||
|
CANCELLED: 'Отменён',
|
||||||
|
}
|
||||||
|
return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → <b>${labels[newStatus] || newStatus}</b>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOrderMessageTg({ orderId, preview }) {
|
||||||
|
const truncated = preview.length > 300 ? preview.slice(0, 297) + '...' : preview
|
||||||
|
return `💬 Сообщение к заказу #${orderId.slice(0, 8)}\n\n${truncated}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPaymentStatusChangedTg({ orderId, paymentStatus }) {
|
||||||
|
const labels = { pending: 'Ожидает', paid: 'Оплачен', confirmed: 'Подтверждён', rejected: 'Отклонён' }
|
||||||
|
return `💳 Оплата заказа #${orderId.slice(0, 8)}: <b>${labels[paymentStatus] || paymentStatus}</b>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount, deliveryType }) {
|
||||||
|
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||||
|
const note = deliveryType === 'delivery' ? '\n\n⚠️ Скорректируйте стоимость доставки' : ''
|
||||||
|
return `🛒 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nОт: ${userEmail}\nТоваров: ${itemsCount} | Сумма: ${total} ₽${note}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAdminNewReviewTg({ rating, text, productTitle, userName }) {
|
||||||
|
const stars = '⭐'.repeat(rating)
|
||||||
|
return `📝 <b>Новый отзыв</b> ${stars}\nТовар: ${productTitle}\nАвтор: ${userName}${text ? '\n\n' + text : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAuthCodeTg({ code }) {
|
||||||
|
return `🔐 Код входа: <b>${code}</b>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDeliveryFeeAdjustedTg({ orderId, totalCents }) {
|
||||||
|
const total = (totalCents / 100).toLocaleString('ru-RU')
|
||||||
|
return `💰 <b>Стоимость доставки скорректирована</b> для заказа #${orderId.slice(0, 8)}\nНовая сумма: ${total} ₽\n\nОжидает оплаты.`
|
||||||
|
}
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
ORDER_STATUSES,
|
||||||
|
getNextAdminStatuses,
|
||||||
|
canTransitionAdminOrderStatus,
|
||||||
|
} from '../../../shared/constants/order-status.js'
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
globalForPrisma.prisma = prisma
|
||||||
|
}
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
const windows = new Map()
|
||||||
|
|
||||||
|
const DEFAULT_MAX_ATTEMPTS = 5
|
||||||
|
const DEFAULT_WINDOW_MS = 60_000
|
||||||
|
|
||||||
|
// Per-endpoint rate limits
|
||||||
|
const LIMITS = {
|
||||||
|
login: { maxAttempts: 5, windowMs: 60_000 },
|
||||||
|
codeRequest: { maxAttempts: 3, windowMs: 60_000 },
|
||||||
|
codeVerify: { maxAttempts: 5, windowMs: 60_000 },
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [ip, entry] of windows) {
|
||||||
|
if (now - entry.start > DEFAULT_WINDOW_MS) windows.delete(ip)
|
||||||
|
}
|
||||||
|
}, 5 * 60_000).unref()
|
||||||
|
|
||||||
|
function getKey(ip, scope) {
|
||||||
|
return `${scope}:${ip}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRateLimit(ip, scope) {
|
||||||
|
const limit = LIMITS[scope] || { maxAttempts: DEFAULT_MAX_ATTEMPTS, windowMs: DEFAULT_WINDOW_MS }
|
||||||
|
const key = getKey(ip, scope)
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = windows.get(key)
|
||||||
|
if (!entry || now - entry.start > limit.windowMs) {
|
||||||
|
windows.set(key, { start: now, count: 1 })
|
||||||
|
return { allowed: true }
|
||||||
|
}
|
||||||
|
entry.count += 1
|
||||||
|
if (entry.count > limit.maxAttempts) {
|
||||||
|
const retryAfter = Math.ceil((entry.start + limit.windowMs - now) / 1000)
|
||||||
|
return { allowed: false, retryAfter }
|
||||||
|
}
|
||||||
|
return { allowed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkLoginRateLimit(ip) {
|
||||||
|
return checkRateLimit(ip, 'login')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkCodeRequestRateLimit(ip) {
|
||||||
|
return checkRateLimit(ip, 'codeRequest')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkCodeVerifyRateLimit(ip) {
|
||||||
|
return checkRateLimit(ip, 'codeVerify')
|
||||||
|
}
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/** Публичное отображение автора отзыва (без «голого» email). */
|
||||||
|
export function publicReviewAuthorDisplay(user) {
|
||||||
|
if (!user || typeof user !== 'object') return 'Покупатель'
|
||||||
|
const name = typeof user.displayName === 'string' ? user.displayName.trim() : ''
|
||||||
|
if (name) return name
|
||||||
|
const email = typeof user.email === 'string' ? user.email.trim() : ''
|
||||||
|
const at = email.indexOf('@')
|
||||||
|
if (at <= 0) return 'Покупатель'
|
||||||
|
const local = email.slice(0, at)
|
||||||
|
const domain = email.slice(at + 1)
|
||||||
|
const masked = local.length <= 1 ? '*' : `${local.slice(0, 1)}***`
|
||||||
|
return `${masked}@${domain}`
|
||||||
|
}
|
||||||
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export function safeImageExt(filename) {
|
||||||
|
const ext = path.extname(String(filename || '')).toLowerCase()
|
||||||
|
const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
||||||
|
return allowed.has(ext) ? ext : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadError(message, statusCode = 400) {
|
||||||
|
const err = new Error(message)
|
||||||
|
err.statusCode = statusCode
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '', eager = false }) {
|
||||||
|
if (!request.isMultipart()) {
|
||||||
|
throw uploadError('Ожидается multipart/form-data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'uploads')
|
||||||
|
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
|
||||||
|
await fs.promises.mkdir(targetDir, { recursive: true })
|
||||||
|
|
||||||
|
const urls = []
|
||||||
|
const parts = request.parts({
|
||||||
|
limits: {
|
||||||
|
fileSize: maxFileBytes,
|
||||||
|
files: maxFiles,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for await (const part of parts) {
|
||||||
|
if (!part.file) continue
|
||||||
|
if (urls.length >= maxFiles) {
|
||||||
|
throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`)
|
||||||
|
}
|
||||||
|
const ext = safeImageExt(part.filename)
|
||||||
|
if (!ext) {
|
||||||
|
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = crypto.randomUUID()
|
||||||
|
const fileName = `${uuid}${ext}`
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
await fs.promises.writeFile(fullPath, await part.toBuffer())
|
||||||
|
|
||||||
|
let finalUrl = subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`
|
||||||
|
|
||||||
|
if (eager) {
|
||||||
|
try {
|
||||||
|
const { generateAllSizes, convertOriginalToWebp } = await import('./image-resize.js')
|
||||||
|
await generateAllSizes(uuid, subdir, fullPath)
|
||||||
|
finalUrl = await convertOriginalToWebp(uuid, subdir)
|
||||||
|
} catch (error) {
|
||||||
|
await fs.promises.unlink(fullPath).catch(() => {})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
urls.push(finalUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urls.length === 0) {
|
||||||
|
throw uploadError(
|
||||||
|
'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сохранить один буфер изображения в uploads/, вернуть путь `/uploads/...`. */
|
||||||
|
export async function saveImageBufferToUploads(originalFilename, buffer, subdir = '') {
|
||||||
|
const ext = safeImageExt(originalFilename)
|
||||||
|
if (!ext) {
|
||||||
|
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'uploads')
|
||||||
|
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
|
||||||
|
await fs.promises.mkdir(targetDir, { recursive: true })
|
||||||
|
|
||||||
|
const fileName = `${crypto.randomUUID()}${ext}`
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
await fs.promises.writeFile(fullPath, buffer)
|
||||||
|
return subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`
|
||||||
|
}
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT as SHARED_DEFAULT } from '../../../shared/constants/upload-limits.js'
|
||||||
|
|
||||||
|
const MB = 1024 * 1024
|
||||||
|
|
||||||
|
export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = SHARED_DEFAULT
|
||||||
|
|
||||||
|
/** @deprecated используйте ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT; оставлено для совместимости импортов */
|
||||||
|
export const PRODUCT_IMAGE_MAX_FILE_BYTES = ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
|
||||||
|
|
||||||
|
/** Отзывы, чек оплаты и прочие загрузки (на файл). По умолчанию 2 МБ. */
|
||||||
|
export const OTHER_UPLOAD_MAX_FILE_BYTES = 2 * MB
|
||||||
|
|
||||||
|
/** Лимит одного файла для админских изображений (байты). Env: `ADMIN_IMAGE_MAX_FILE_BYTES` или `PRODUCT_IMAGE_MAX_FILE_BYTES`. */
|
||||||
|
export function getProductImageMaxFileBytes() {
|
||||||
|
const fromAdmin = Number(process.env.ADMIN_IMAGE_MAX_FILE_BYTES)
|
||||||
|
const fromLegacy = Number(process.env.PRODUCT_IMAGE_MAX_FILE_BYTES)
|
||||||
|
const n =
|
||||||
|
Number.isFinite(fromAdmin) && fromAdmin > 0
|
||||||
|
? fromAdmin
|
||||||
|
: Number.isFinite(fromLegacy) && fromLegacy > 0
|
||||||
|
? fromLegacy
|
||||||
|
: NaN
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOtherUploadMaxFileBytes() {
|
||||||
|
const n = Number(process.env.OTHER_UPLOAD_MAX_FILE_BYTES)
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : OTHER_UPLOAD_MAX_FILE_BYTES
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лимит тела HTTP: до 10 фото товара за запрос + запас. */
|
||||||
|
export function getMaxUploadBodyBytes() {
|
||||||
|
const n = Number(process.env.MAX_UPLOAD_BODY_BYTES)
|
||||||
|
if (Number.isFinite(n) && n > 0) return Math.floor(n)
|
||||||
|
return getProductImageMaxFileBytes() * 10 + MB
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {unknown} error */
|
||||||
|
export function isMultipartFileTooLargeError(error) {
|
||||||
|
if (!error || typeof error !== 'object') return false
|
||||||
|
if (error.code === 'FST_REQ_FILE_TOO_LARGE') return true
|
||||||
|
const msg = String(Reflect.get(error, 'message') ?? '')
|
||||||
|
return /request file too large|file too large/i.test(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} maxFileBytes */
|
||||||
|
export function formatFileTooLargeMessage(maxFileBytes) {
|
||||||
|
const mb = Math.max(1, Math.round(maxFileBytes / MB))
|
||||||
|
return `Файл слишком большой (максимум ${mb} МБ).`
|
||||||
|
}
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
export async function validateGalleryImages(prisma, urls) {
|
||||||
|
if (!urls || urls.length === 0) return null
|
||||||
|
|
||||||
|
const existing = await prisma.galleryImage.findMany({
|
||||||
|
where: { url: { in: urls } },
|
||||||
|
select: { url: true, isResized: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const galleryMap = new Map(existing.map((g) => [g.url, g]))
|
||||||
|
const notFound = urls.filter((u) => !galleryMap.has(u))
|
||||||
|
if (notFound.length > 0) {
|
||||||
|
throw Object.assign(new Error(`Gallery images not found: ${notFound.join(', ')}`), { statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized)
|
||||||
|
if (notResized.length > 0) {
|
||||||
|
throw Object.assign(new Error('Some gallery images have not been processed yet. Please try again later.'), {
|
||||||
|
statusCode: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return existing
|
||||||
|
}
|
||||||
Executable
+182
@@ -0,0 +1,182 @@
|
|||||||
|
const YOOKASSA_API_URL = 'https://api.yookassa.ru/v3'
|
||||||
|
|
||||||
|
function getAuthHeader() {
|
||||||
|
const shopId = process.env.YOOKASSA_SHOP_ID
|
||||||
|
const secretKey = process.env.YOOKASSA_SECRET_KEY
|
||||||
|
if (!shopId || !secretKey) {
|
||||||
|
throw new Error('YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY are required')
|
||||||
|
}
|
||||||
|
const token = Buffer.from(`${shopId}:${secretKey}`).toString('base64')
|
||||||
|
return `Basic ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryable(status) {
|
||||||
|
return status >= 500 || status === 429
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithRetry(url, opts, maxRetries = 3) {
|
||||||
|
let lastError
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
const delay = 500 * 2 ** (attempt - 1)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, opts)
|
||||||
|
if (res.ok) return res
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
if (isRetryable(res.status)) {
|
||||||
|
lastError = new Error(`YooKassa API error: ${res.status} — ${body.description || 'unknown'}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`YooKassa API error: ${res.status} — ${body.description || body.code || 'unknown'} (${body.parameter || 'n/a'})`,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message.startsWith('YooKassa API error')) throw err
|
||||||
|
lastError = new Error(`YooKassa API error: network failure — ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
if (attempt === maxRetries) throw lastError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPayment({
|
||||||
|
amount,
|
||||||
|
description,
|
||||||
|
receipt,
|
||||||
|
confirmation,
|
||||||
|
metadata,
|
||||||
|
idempotencyKey,
|
||||||
|
clientIp,
|
||||||
|
}) {
|
||||||
|
const headers = {
|
||||||
|
Authorization: getAuthHeader(),
|
||||||
|
'Idempotence-Key': idempotencyKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
amount,
|
||||||
|
capture: true,
|
||||||
|
description,
|
||||||
|
confirmation,
|
||||||
|
metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receipt) {
|
||||||
|
body.receipt = receipt
|
||||||
|
}
|
||||||
|
if (clientIp) {
|
||||||
|
body.client_ip = clientIp
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetchWithRetry(`${YOOKASSA_API_URL}/payments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
return {
|
||||||
|
paymentId: data.id,
|
||||||
|
status: data.status,
|
||||||
|
confirmationUrl: data.confirmation?.confirmation_url || null,
|
||||||
|
expiresAt: data.expires_at || null,
|
||||||
|
paid: data.paid,
|
||||||
|
test: data.test,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPayment(paymentId) {
|
||||||
|
const res = await fetchWithRetry(`${YOOKASSA_API_URL}/payments/${paymentId}`, {
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
return {
|
||||||
|
paymentId: data.id,
|
||||||
|
status: data.status,
|
||||||
|
confirmationUrl: data.confirmation?.confirmation_url || null,
|
||||||
|
expiresAt: data.expires_at || null,
|
||||||
|
paid: data.paid,
|
||||||
|
test: data.test,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const YOOKASSA_IP_RANGES_V4 = ['185.71.76.0/27', '185.71.77.0/27', '77.75.153.0/25', '77.75.154.128/25']
|
||||||
|
|
||||||
|
function ip4ToInt(ip) {
|
||||||
|
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function cidrMatch(ip, cidr) {
|
||||||
|
const [range, bits] = cidr.split('/')
|
||||||
|
const mask = ~(2 ** (32 - parseInt(bits, 10)) - 1) >>> 0
|
||||||
|
const ipInt = ip4ToInt(ip)
|
||||||
|
const rangeInt = ip4ToInt(range)
|
||||||
|
return (ipInt & mask) === (rangeInt & mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYookassaIp(ip) {
|
||||||
|
const v4 = ip.replace(/^::ffff:/, '')
|
||||||
|
if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(v4)) return false
|
||||||
|
return YOOKASSA_IP_RANGES_V4.some((cidr) => cidrMatch(v4, cidr))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTestMode() {
|
||||||
|
return process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateWebhook(ip, body) {
|
||||||
|
if (!isTestMode() && !isYookassaIp(ip)) {
|
||||||
|
throw new Error('Invalid webhook source IP')
|
||||||
|
}
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
throw new Error('Invalid webhook body')
|
||||||
|
}
|
||||||
|
if (body.type !== 'notification') {
|
||||||
|
throw new Error('Expected notification type in webhook body')
|
||||||
|
}
|
||||||
|
if (!body.event || !body.object) {
|
||||||
|
throw new Error('Missing event or object in webhook body')
|
||||||
|
}
|
||||||
|
return { event: body.event, paymentObject: body.object }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReceipt({ orderItems, deliveryFeeCents, userEmail, taxSystemCode = 1 }) {
|
||||||
|
const items = orderItems.map((item) => ({
|
||||||
|
description: (item.titleSnapshot || 'Товар').slice(0, 128),
|
||||||
|
quantity: item.qty,
|
||||||
|
amount: {
|
||||||
|
value: (item.priceCentsSnapshot / 100).toFixed(2),
|
||||||
|
currency: 'RUB',
|
||||||
|
},
|
||||||
|
vat_code: 1,
|
||||||
|
measure: 'piece',
|
||||||
|
payment_subject: 'commodity',
|
||||||
|
payment_mode: 'full_prepayment',
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (deliveryFeeCents > 0) {
|
||||||
|
items.push({
|
||||||
|
description: 'Доставка',
|
||||||
|
quantity: 1,
|
||||||
|
amount: {
|
||||||
|
value: (deliveryFeeCents / 100).toFixed(2),
|
||||||
|
currency: 'RUB',
|
||||||
|
},
|
||||||
|
vat_code: 1,
|
||||||
|
measure: 'piece',
|
||||||
|
payment_subject: 'service',
|
||||||
|
payment_mode: 'full_prepayment',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const receipt = {
|
||||||
|
customer: { email: userEmail },
|
||||||
|
items,
|
||||||
|
tax_system_code: taxSystemCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
return receipt
|
||||||
|
}
|
||||||
Executable
+296
@@ -0,0 +1,296 @@
|
|||||||
|
import jwt from '@fastify/jwt'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { registerAuth } from '../auth.js'
|
||||||
|
|
||||||
|
const JWT_SECRET = 'test-secret'
|
||||||
|
const ADMIN_EMAIL = 'admin@test.com'
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const app = Fastify({ logger: false, trustProxy: true })
|
||||||
|
await app.register(jwt, { secret: JWT_SECRET })
|
||||||
|
registerAuth(app)
|
||||||
|
app.get('/admin/test', { preHandler: [app.verifyAdmin] }, async () => ({ ok: true }))
|
||||||
|
await app.ready()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signToken(app, email) {
|
||||||
|
return app.jwt.sign({ sub: 'test-user-id', email })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('verifyAdmin — ADMIN_ACCESS_IPS', () => {
|
||||||
|
const originalIps = process.env.ADMIN_ACCESS_IPS
|
||||||
|
const originalEmail = process.env.ADMIN_EMAIL
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.ADMIN_EMAIL = ADMIN_EMAIL
|
||||||
|
delete process.env.ADMIN_ACCESS_IPS
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalIps === undefined) {
|
||||||
|
delete process.env.ADMIN_ACCESS_IPS
|
||||||
|
} else {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = originalIps
|
||||||
|
}
|
||||||
|
if (originalEmail === undefined) {
|
||||||
|
delete process.env.ADMIN_EMAIL
|
||||||
|
} else {
|
||||||
|
process.env.ADMIN_EMAIL = originalEmail
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает если ADMIN_ACCESS_IPS не задан (IP не проверяется)', async () => {
|
||||||
|
delete process.env.ADMIN_ACCESS_IPS
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
expect(res.json()).toEqual({ ok: true })
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает если ADMIN_ACCESS_IPS пустой после трима', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = ' , , '
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает с разрешённого IP', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4,5.6.7.8'
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
// IP passes, JWT and email match → 200
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает с IPv6-mapped разрешённого IP', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '::ffff:1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('блокирует с неразрешённого IP (403 JSON)', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const app = await buildApp()
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
// IP not allowed — 403 even before JWT check
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
const body = res.json()
|
||||||
|
expect(body.error).toBe('Доступ с данного IP запрещён')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('тримит пробелы в списке IP', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 '
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '5.6.7.8',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('нормализует IPv6-mapped адреса в whitelist', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '::ffff:1.2.3.4'
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает запрос с IP в CIDR-диапазоне /24', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '192.168.1.0/24'
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '192.168.1.100',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('блокирует запрос с IP вне CIDR-диапазона', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '192.168.1.0/24'
|
||||||
|
const app = await buildApp()
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
remoteAddress: '10.0.0.1',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('поддерживает микс точных IP и CIDR-диапазонов', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4,10.0.0.0/24'
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res1 = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res1.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const res2 = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '10.0.0.50',
|
||||||
|
})
|
||||||
|
expect(res2.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const res3 = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res3.statusCode).toBe(403)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IPv6-mapped адрес в CIDR-диапазоне пропускается', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '192.168.1.0/24'
|
||||||
|
const app = await buildApp()
|
||||||
|
const token = await signToken(app, ADMIN_EMAIL)
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
remoteAddress: '::ffff:192.168.1.50',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IP-проверка происходит до JWT (неразрешённый IP → 403, а не 401)', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const app = await buildApp()
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
// Should be 403 from IP check, NOT 401 from missing JWT
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
expect(res.json().error).toBe('Доступ с данного IP запрещён')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('после прохождения IP-проверки всё ещё нужен JWT (разрешённый IP, нет токена → 401)', async () => {
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const app = await buildApp()
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
// IP passes, but no JWT → 401
|
||||||
|
expect(res.statusCode).toBe(401)
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ADMIN_EMAIL не задан → 503, IP не проверяется', async () => {
|
||||||
|
delete process.env.ADMIN_EMAIL
|
||||||
|
process.env.ADMIN_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const app = await buildApp()
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/test',
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(503)
|
||||||
|
expect(res.json().error).toBe('ADMIN_EMAIL не задан в .env')
|
||||||
|
} finally {
|
||||||
|
await app.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Executable
+259
@@ -0,0 +1,259 @@
|
|||||||
|
import Fastify from 'fastify'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { build403Html, registerIpGate } from '../ip-gate.js'
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = Fastify({ logger: false, trustProxy: true })
|
||||||
|
app.get('/test', async () => ({ ok: true }))
|
||||||
|
app.get('/api/webhooks/yookassa', async () => ({ ok: true }))
|
||||||
|
app.get('/api/auth/oauth/vk/callback', async () => ({ ok: true }))
|
||||||
|
app.get('/api/auth/oauth/yandex/callback', async () => ({ ok: true }))
|
||||||
|
app.get('/api/admin/notifications/telegram/webhook', async () => ({ ok: true }))
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('registerIpGate', () => {
|
||||||
|
let app
|
||||||
|
const originalIps = process.env.SITE_ACCESS_IPS
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = buildApp()
|
||||||
|
await registerIpGate(app)
|
||||||
|
await app.ready()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close()
|
||||||
|
if (originalIps === undefined) {
|
||||||
|
delete process.env.SITE_ACCESS_IPS
|
||||||
|
} else {
|
||||||
|
process.env.SITE_ACCESS_IPS = originalIps
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает запрос если SITE_ACCESS_IPS не задан', async () => {
|
||||||
|
delete process.env.SITE_ACCESS_IPS
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
expect(res.json()).toEqual({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает запрос с разрешённого IP', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '1.2.3.4,5.6.7.8'
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает запрос с IPv6-mapped разрешённого IP', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '::ffff:1.2.3.4' })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('блокирует запрос с неразрешённого IP (403)', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '9.9.9.9' })
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
expect(res.headers['content-type']).toMatch(/text\/html/)
|
||||||
|
expect(res.body).toContain('Любимый Креатив')
|
||||||
|
expect(res.body).toContain('9.9.9.9')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('build403Html показывает "не определён" когда IP не передан', () => {
|
||||||
|
const html = build403Html()
|
||||||
|
expect(html).toContain('не определён')
|
||||||
|
expect(html).toContain('Любимый Креатив')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('build403Html показывает переданный IP', () => {
|
||||||
|
const html = build403Html('9.9.9.9')
|
||||||
|
expect(html).toContain('9.9.9.9')
|
||||||
|
expect(html).not.toContain('не определён')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('build403Html с пустой строкой показывает "не определён"', () => {
|
||||||
|
const html = build403Html('')
|
||||||
|
expect(html).toContain('не определён')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('403-страница показывает IP по умолчанию (127.0.0.1) когда remoteAddress не указан', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/test' })
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
expect(res.body).toContain('127.0.0.1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает исключённые пути с любым IP (webhook yookassa)', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/webhooks/yookassa',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает исключённые пути с любым IP (vk callback)', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/auth/oauth/vk/callback',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает исключённые пути с любым IP (yandex callback)', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/auth/oauth/yandex/callback',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает исключённые пути с любым IP (telegram webhook)', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/notifications/telegram/webhook',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('корректно тримит пробелы в списке IP', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 '
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '5.6.7.8',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('нормализует IPv6-mapped адреса в whitelist', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '::ffff:1.2.3.4'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает если после трима список IP пуст', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = ' , , '
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('путь с query-параметрами проверяется корректно', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test?foo=bar',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('исключённый путь с query-параметрами тоже пропускается', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '1.2.3.4'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/webhooks/yookassa?foo=bar',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает запрос с IP в CIDR-диапазоне /24', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '192.168.1.0/24'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '192.168.1.100',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('блокирует запрос с IP вне CIDR-диапазона', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '192.168.1.0/24'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '10.0.0.1',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает IP в CIDR /32 (эквивалент одного IP)', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '10.0.0.5/32'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '10.0.0.5',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('блокирует IP рядом с CIDR /32', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '10.0.0.5/32'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '10.0.0.6',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пропускает любой IP в CIDR /0', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '0.0.0.0/0'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('поддерживает микс точных IP и CIDR-диапазонов', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '1.2.3.4,10.0.0.0/24'
|
||||||
|
const res1 = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '1.2.3.4',
|
||||||
|
})
|
||||||
|
expect(res1.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const res2 = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '10.0.0.50',
|
||||||
|
})
|
||||||
|
expect(res2.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const res3 = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '9.9.9.9',
|
||||||
|
})
|
||||||
|
expect(res3.statusCode).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IPv6-mapped адрес в CIDR-диапазоне', async () => {
|
||||||
|
process.env.SITE_ACCESS_IPS = '192.168.1.0/24'
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/test',
|
||||||
|
remoteAddress: '::ffff:192.168.1.50',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
import { normalizeIp, cidrMatch } from './ip-gate.js'
|
||||||
|
|
||||||
|
export function registerAuth(fastify) {
|
||||||
|
function normalizeEmail(email) {
|
||||||
|
return String(email || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) {
|
||||||
|
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||||
|
if (!adminEmail || !adminEmail.includes('@')) {
|
||||||
|
return reply.code(503).send({ error: 'ADMIN_EMAIL не задан в .env' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminIps = process.env.ADMIN_ACCESS_IPS
|
||||||
|
if (adminIps) {
|
||||||
|
const allowedList = adminIps
|
||||||
|
.split(',')
|
||||||
|
.map((s) => normalizeIp(s.trim()))
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (allowedList.length > 0) {
|
||||||
|
const reqIp = normalizeIp(request.ip)
|
||||||
|
const isAllowed = allowedList.includes(reqIp) || allowedList.some((entry) => cidrMatch(reqIp, entry))
|
||||||
|
if (!isAllowed) {
|
||||||
|
return reply.code(403).send({ error: 'Доступ с данного IP запрещён' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err }, '[auth] verifyAdmin failed')
|
||||||
|
return reply.code(401).send({ error: 'Не авторизован' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userEmail = normalizeEmail(request.user?.email)
|
||||||
|
if (userEmail !== adminEmail) {
|
||||||
|
return reply.code(403).send({ error: 'Недостаточно прав' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Executable
+132
@@ -0,0 +1,132 @@
|
|||||||
|
const EXCLUDED_PATHS = [
|
||||||
|
'/api/auth/oauth/vk/callback',
|
||||||
|
'/api/auth/oauth/yandex/callback',
|
||||||
|
'/api/webhooks/yookassa',
|
||||||
|
'/api/admin/notifications/telegram/webhook',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function normalizeIp(ip) {
|
||||||
|
if (ip && ip.startsWith('::ffff:')) {
|
||||||
|
return ip.slice(7)
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ipToInt(ip) {
|
||||||
|
const parts = ip.split('.')
|
||||||
|
if (parts.length !== 4) return null
|
||||||
|
return parts.reduce((acc, octet) => {
|
||||||
|
const num = parseInt(octet, 10)
|
||||||
|
if (isNaN(num) || num < 0 || num > 255) return null
|
||||||
|
return acc !== null ? (acc << 8) + num : null
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cidrMatch(ip, cidr) {
|
||||||
|
const slashIdx = cidr.indexOf('/')
|
||||||
|
if (slashIdx === -1) return false
|
||||||
|
|
||||||
|
const baseIp = cidr.slice(0, slashIdx)
|
||||||
|
const prefix = parseInt(cidr.slice(slashIdx + 1), 10)
|
||||||
|
if (isNaN(prefix) || prefix < 0 || prefix > 32) return false
|
||||||
|
|
||||||
|
const ipInt = ipToInt(normalizeIp(ip))
|
||||||
|
const baseInt = ipToInt(normalizeIp(baseIp))
|
||||||
|
if (ipInt === null || baseInt === null) return false
|
||||||
|
|
||||||
|
const mask = prefix === 0 ? 0 : ~(2 ** (32 - prefix) - 1) >>> 0
|
||||||
|
return (ipInt & mask) === (baseInt & mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function build403Html(ip) {
|
||||||
|
const safeIp = ip || 'не определён'
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Любимый Креатив</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #faf8f5;
|
||||||
|
color: #3d322b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8e0d8;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 16px rgb(0 0 0 / 4%);
|
||||||
|
}
|
||||||
|
.card h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
color: #4a3a2e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.card .tagline {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #8c8177;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.card .status {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #6b5e52;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.card .ip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b8a99b;
|
||||||
|
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Любимый Креатив</h1>
|
||||||
|
<p class="tagline">Изделия ручной работы: вещи с характером и вниманием к деталям</p>
|
||||||
|
<p class="status">Сайт находится в разработке и скоро будет доступен</p>
|
||||||
|
<p class="ip">Ваш IP: ${safeIp}</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerIpGate(fastify) {
|
||||||
|
fastify.addHook('onRequest', async (request, reply) => {
|
||||||
|
const allowed = process.env.SITE_ACCESS_IPS
|
||||||
|
if (!allowed) return
|
||||||
|
|
||||||
|
const allowedIps = allowed
|
||||||
|
.split(',')
|
||||||
|
.map((s) => normalizeIp(s.trim()))
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (allowedIps.length === 0) return
|
||||||
|
|
||||||
|
const urlPath = request.url.split('?')[0]
|
||||||
|
|
||||||
|
if (EXCLUDED_PATHS.includes(urlPath)) return
|
||||||
|
|
||||||
|
const normalizedIp = normalizeIp(request.ip)
|
||||||
|
if (allowedIps.includes(normalizedIp)) return
|
||||||
|
|
||||||
|
const isInCidr = allowedIps.some((entry) => cidrMatch(normalizedIp, entry))
|
||||||
|
if (isInCidr) return
|
||||||
|
|
||||||
|
return reply.code(403).type('text/html').send(build403Html(request.ip))
|
||||||
|
})
|
||||||
|
}
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
export async function registerSecurityHeaders(fastify) {
|
||||||
|
fastify.addHook('onSend', async (request, reply) => {
|
||||||
|
reply.header('X-Content-Type-Options', 'nosniff')
|
||||||
|
reply.header('X-Frame-Options', 'DENY')
|
||||||
|
reply.header('X-XSS-Protection', '0')
|
||||||
|
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||||
|
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
||||||
|
|
||||||
|
const cspDirectives = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
|
"img-src 'self' data: blob: https://tile.openstreetmap.org https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
|
||||||
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
|
"connect-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
|
||||||
|
'frame-src https://*.yookassa.ru',
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
].join('; ')
|
||||||
|
|
||||||
|
reply.header('Content-Security-Policy', cspDirectives)
|
||||||
|
})
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
import jwt from '@fastify/jwt'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
import { registerAuthSessionRoutes } from '../auth-session.js'
|
||||||
|
|
||||||
|
const JWT_SECRET = 'test-secret'
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const app = Fastify({ logger: false })
|
||||||
|
await app.register(jwt, { secret: JWT_SECRET })
|
||||||
|
app.decorate('authenticate', async function (request, reply) {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
app.decorate('eventBus', { emit: () => {} })
|
||||||
|
await registerAuthSessionRoutes(app)
|
||||||
|
await app.ready()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
function signToken(app, userId, email) {
|
||||||
|
return app.jwt.sign({ sub: userId, email })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(email) {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' },
|
||||||
|
})
|
||||||
|
await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } })
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/me/auth-methods', () => {
|
||||||
|
let app, user, token
|
||||||
|
const email = `test-methods-${Date.now()}@example.com`
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildApp()
|
||||||
|
})
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
|
||||||
|
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
|
user = await createUser(email)
|
||||||
|
token = signToken(app, user.id, email)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns methods for user without any method', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/me/auth-methods',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const body = JSON.parse(res.body)
|
||||||
|
expect(body.methods.find((m) => m.type === 'password').active).toBe(false)
|
||||||
|
expect(body.methods.find((m) => m.type === 'vk').active).toBe(false)
|
||||||
|
expect(body.methods.find((m) => m.type === 'yandex').active).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns password as active after setting it', async () => {
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } })
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/me/auth-methods',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
import jwt from '@fastify/jwt'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
import { registerAuthOAuthRoutes } from '../auth-oauth.js'
|
||||||
|
|
||||||
|
const JWT_SECRET = 'test-secret'
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const app = Fastify({ logger: false })
|
||||||
|
await app.register(jwt, { secret: JWT_SECRET })
|
||||||
|
app.decorate('authenticate', async function (request, reply) {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
app.decorate('eventBus', { emit: () => {} })
|
||||||
|
await registerAuthOAuthRoutes(app)
|
||||||
|
await app.ready()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
function signToken(app, userId, email) {
|
||||||
|
return app.jwt.sign({ sub: userId, email })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(email) {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' },
|
||||||
|
})
|
||||||
|
await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } })
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DELETE /api/me/oauth/:provider', () => {
|
||||||
|
let app, user, token
|
||||||
|
const email = `test-unlink-${Date.now()}@example.com`
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildApp()
|
||||||
|
})
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
|
||||||
|
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
|
user = await createUser(email)
|
||||||
|
token = signToken(app, user.id, email)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 for non-linked provider', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/me/oauth/vk',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unlinks a provider', async () => {
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } })
|
||||||
|
await prisma.oAuthAccount.create({
|
||||||
|
data: { provider: 'vk', providerUserId: '123', userId: user.id },
|
||||||
|
})
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/me/oauth/vk',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const count = await prisma.oAuthAccount.count({ where: { userId: user.id } })
|
||||||
|
expect(count).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects removing last method without password', async () => {
|
||||||
|
await prisma.oAuthAccount.create({
|
||||||
|
data: { provider: 'vk', providerUserId: '123', userId: user.id },
|
||||||
|
})
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/me/oauth/vk',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(400)
|
||||||
|
expect(JSON.parse(res.body).error).toContain('последний метод')
|
||||||
|
})
|
||||||
|
})
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
import jwt from '@fastify/jwt'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
import { registerAuthPasswordRoutes } from '../auth-password.js'
|
||||||
|
|
||||||
|
const JWT_SECRET = 'test-secret'
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const app = Fastify({ logger: false })
|
||||||
|
await app.register(jwt, { secret: JWT_SECRET })
|
||||||
|
app.decorate('authenticate', async function (request, reply) {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
app.decorate('eventBus', { emit: () => {} })
|
||||||
|
await registerAuthPasswordRoutes(app)
|
||||||
|
await app.ready()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
function signToken(app, userId, email) {
|
||||||
|
return app.jwt.sign({ sub: userId, email })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(email) {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' },
|
||||||
|
})
|
||||||
|
await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } })
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/me/password', () => {
|
||||||
|
let app, user, token
|
||||||
|
const email = `test-set-pw-${Date.now()}@example.com`
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildApp()
|
||||||
|
})
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
|
user = await createUser(email)
|
||||||
|
token = signToken(app, user.id, email)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets password', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/me/password',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { password: 'Test123!@' },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const u = await prisma.user.findUnique({ where: { id: user.id } })
|
||||||
|
expect(u.passwordHash).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects if password already set', async () => {
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'existing' } })
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/me/password',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { password: 'Test123!@' },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(409)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /api/me/change-password', () => {
|
||||||
|
let app, user, token
|
||||||
|
const email = `test-change-pw-${Date.now()}@example.com`
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildApp()
|
||||||
|
})
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
|
user = await createUser(email)
|
||||||
|
token = signToken(app, user.id, email)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes password', async () => {
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'oldhash' } })
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/me/change-password',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { oldPassword: 'OldPass1!', newPassword: 'NewPass2@' },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(401)
|
||||||
|
|
||||||
|
const u = await prisma.user.findUnique({ where: { id: user.id } })
|
||||||
|
expect(u.passwordHash).toBe('oldhash')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects if no password set', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/me/change-password',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { oldPassword: 'OldPass1!', newPassword: 'NewPass2@' },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
import jwt from '@fastify/jwt'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { afterAll, beforeEach, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
import { registerAuthSessionRoutes } from '../auth-session.js'
|
||||||
|
|
||||||
|
const JWT_SECRET = 'test-secret'
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const app = Fastify({ logger: false })
|
||||||
|
await app.register(jwt, { secret: JWT_SECRET })
|
||||||
|
app.decorate('authenticate', async function (request, reply) {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
app.decorate('eventBus', { emit: () => {} })
|
||||||
|
await registerAuthSessionRoutes(app)
|
||||||
|
await app.ready()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
function signToken(app, userId, email) {
|
||||||
|
return app.jwt.sign({ sub: userId, email })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(email) {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' },
|
||||||
|
})
|
||||||
|
await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } })
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/me', () => {
|
||||||
|
let app, user, token
|
||||||
|
const email = `test-me-${Date.now()}@example.com`
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildApp()
|
||||||
|
})
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
|
user = await createUser(email)
|
||||||
|
token = signToken(app, user.id, email)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns current user', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/me',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const body = JSON.parse(res.body)
|
||||||
|
expect(body.user.email).toBe(email)
|
||||||
|
expect(body.user.displayName).toBe('Test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 401 without token', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/me',
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(401)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /api/me/auth-methods', () => {
|
||||||
|
let app, user, token
|
||||||
|
const email = `test-methods-${Date.now()}@example.com`
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildApp()
|
||||||
|
})
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.notificationPreference.deleteMany({ where: { userId: user?.id } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.oAuthAccount.deleteMany({ where: { user: { email } } })
|
||||||
|
await prisma.notificationPreference.deleteMany({ where: { user: { email } } })
|
||||||
|
await prisma.user.deleteMany({ where: { email } })
|
||||||
|
user = await createUser(email)
|
||||||
|
token = signToken(app, user.id, email)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns methods for user without any method', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/me/auth-methods',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const body = JSON.parse(res.body)
|
||||||
|
expect(body.methods.find((m) => m.type === 'password').active).toBe(false)
|
||||||
|
expect(body.methods.find((m) => m.type === 'vk').active).toBe(false)
|
||||||
|
expect(body.methods.find((m) => m.type === 'yandex').active).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns password as active after setting it', async () => {
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } })
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/me/auth-methods',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
|
describe('OAuth — User model fields', () => {
|
||||||
|
const createdIds = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const id of createdIds) {
|
||||||
|
try {
|
||||||
|
await prisma.user.delete({ where: { id } })
|
||||||
|
} catch {
|
||||||
|
// Already deleted by another test or cleanup — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createdIds.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores displayName and avatar fields on User model', async () => {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: 'test-oauth@example.com',
|
||||||
|
displayName: 'Test User',
|
||||||
|
avatar: 'https://example.com/avatar.jpg',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
createdIds.push(user.id)
|
||||||
|
|
||||||
|
expect(user.displayName).toBe('Test User')
|
||||||
|
expect(user.avatar).toBe('https://example.com/avatar.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows nullable fields', async () => {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: 'test-oauth-null@example.com',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
createdIds.push(user.id)
|
||||||
|
|
||||||
|
expect(user.displayName).toBeNull()
|
||||||
|
expect(user.avatar).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user