From 65da047e7c3535c62639ef0fe9333d662aa39402 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 11 Jun 2026 13:41:38 +0500 Subject: [PATCH] initial: server + shared --- .gitignore | 9 + AGENTS.md | 49 + README.md | 17 + scripts/SERVER_SETUP.md | 196 + scripts/backup-db.sh | 41 + scripts/craftshop-backup.service | 7 + scripts/craftshop-backup.timer | 9 + scripts/craftshop-netbird.conf | 33 + server/.env.example | 44 + server/.gitignore | 4 + server/.prettierrc.json | 8 + server/eslint.config.js | 64 + server/package-lock.json | 5502 +++++++++++++++++ server/package.json | 46 + .../20260428051622_init/migration.sql | 28 + .../20260428160544_auth/migration.sql | 30 + .../migration.sql | 2 + .../migration.sql | 25 + .../migration.sql | 12 + .../migration.sql | 27 + .../20260429130733_user_phone/migration.sql | 2 + .../migration.sql | 22 + .../migration.sql | 87 + .../migration.sql | 27 + .../migration.sql | 25 + .../migration.sql | 35 + .../migration.sql | 20 + .../migration.sql | 26 + .../migration.sql | 2 + .../migration.sql | 9 + .../migration.sql | 2 + .../migration.sql | 39 + .../migration.sql | 27 + .../migration.sql | 28 + .../migration.sql | 15 + .../migration.sql | 54 + .../migration.sql | 2 + .../migration.sql | 22 + .../migration.sql | 10 + .../migration.sql | 28 + .../20260520124831_add_payment/migration.sql | 23 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 1 + .../migration.sql | 28 + .../migration.sql | 27 + .../migration.sql | 19 + .../migration.sql | 11 + .../migration.sql | 2 + .../migration.sql | 17 + server/prisma/migrations/migration_lock.toml | 3 + server/prisma/prisma/dev.db | Bin 0 -> 364544 bytes server/prisma/schema.prisma | 353 ++ server/prisma/seed-is-resized.js | 13 + server/src/index.js | 253 + .../src/lib/__tests__/async-handler.test.js | 33 + server/src/lib/__tests__/escape-html.test.js | 21 + .../src/lib/__tests__/find-user-order.test.js | 27 + server/src/lib/__tests__/image-resize.test.js | 173 + server/src/lib/__tests__/order-status.test.js | 52 + .../src/lib/__tests__/upload-images.test.js | 48 + .../__tests__/validate-gallery-images.test.js | 32 + server/src/lib/__tests__/yookassa.test.js | 257 + server/src/lib/async-handler.js | 12 + server/src/lib/auth.js | 106 + server/src/lib/bootstrap-admin.js | 33 + server/src/lib/default-category.js | 20 + server/src/lib/delivery-carrier.js | 11 + server/src/lib/email.js | 64 + server/src/lib/escape-html.js | 8 + server/src/lib/find-user-order.js | 12 + server/src/lib/generate-avatar.js | 9 + server/src/lib/image-resize.js | 143 + .../__tests__/preferences.test.js | 96 + .../notifications/channels/email-channel.js | 40 + .../channels/telegram-channel.js | 69 + server/src/lib/notifications/event-bus.js | 7 + server/src/lib/notifications/preferences.js | 105 + server/src/lib/notifications/queue.js | 134 + .../__tests__/email-templates.test.js | 62 + .../templates/email-templates.js | 175 + .../templates/telegram-templates.js | 50 + server/src/lib/order-status.js | 5 + server/src/lib/prisma.js | 9 + server/src/lib/rate-limit.js | 51 + server/src/lib/review-display.js | 13 + server/src/lib/upload-images.js | 88 + server/src/lib/upload-limits.js | 50 + server/src/lib/validate-gallery-images.js | 23 + server/src/lib/yookassa.js | 182 + server/src/plugins/__tests__/auth.test.js | 296 + server/src/plugins/__tests__/ip-gate.test.js | 259 + server/src/plugins/auth.js | 44 + server/src/plugins/ip-gate.js | 132 + server/src/plugins/security-headers.js | 24 + .../src/routes/__tests__/auth-methods.test.js | 80 + .../src/routes/__tests__/auth-oauth.test.js | 95 + .../routes/__tests__/auth-password.test.js | 125 + .../src/routes/__tests__/auth-session.test.js | 121 + .../src/routes/__tests__/oauth-social.test.js | 45 + server/src/routes/__tests__/sse.test.js | 238 + .../src/routes/__tests__/user-orders.test.js | 88 + .../routes/__tests__/user-payments.test.js | 245 + .../routes/__tests__/webhook-yookassa.test.js | 133 + server/src/routes/api.js | 42 + .../api/__tests__/admin-gallery.test.js | 65 + .../routes/api/__tests__/admin-orders.test.js | 118 + .../api/__tests__/admin-products.test.js | 96 + server/src/routes/api/_product-helpers.js | 55 + server/src/routes/api/admin-categories.js | 138 + server/src/routes/api/admin-gallery.js | 134 + server/src/routes/api/admin-orders.js | 182 + server/src/routes/api/admin-products.js | 264 + server/src/routes/api/admin-profile.js | 69 + server/src/routes/api/admin-reviews.js | 65 + server/src/routes/api/admin-users.js | 168 + server/src/routes/api/admin/notifications.js | 78 + server/src/routes/api/admin/test-checklist.js | 45 + server/src/routes/api/catalog-slider.js | 108 + server/src/routes/api/public-catalog.js | 162 + server/src/routes/api/public-reviews.js | 152 + server/src/routes/auth-oauth.js | 35 + server/src/routes/auth-password.js | 49 + server/src/routes/auth-session.js | 81 + server/src/routes/auth.js | 239 + server/src/routes/oauth-social.js | 343 + server/src/routes/sse.js | 149 + server/src/routes/uploads-resized.js | 81 + server/src/routes/user-addresses.js | 199 + server/src/routes/user-cart.js | 93 + server/src/routes/user-messages.js | 144 + server/src/routes/user-orders.js | 279 + server/src/routes/user-payments.js | 154 + server/src/routes/user/notifications.js | 31 + server/src/routes/webhook-yookassa.js | 61 + server/vitest.config.js | 17 + shared/constants/delivery-carrier.d.ts | 11 + shared/constants/delivery-carrier.js | 14 + shared/constants/notification-events.d.ts | 36 + shared/constants/notification-events.js | 26 + shared/constants/order-status.d.ts | 17 + shared/constants/order-status.js | 38 + shared/constants/payment-method.d.ts | 1 + shared/constants/payment-method.js | 1 + shared/constants/test-checklist-items.d.ts | 8 + shared/constants/test-checklist-items.js | 304 + shared/constants/upload-limits.d.ts | 5 + shared/constants/upload-limits.js | 9 + 148 files changed, 15900 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100755 scripts/SERVER_SETUP.md create mode 100755 scripts/backup-db.sh create mode 100755 scripts/craftshop-backup.service create mode 100755 scripts/craftshop-backup.timer create mode 100755 scripts/craftshop-netbird.conf create mode 100755 server/.env.example create mode 100755 server/.gitignore create mode 100755 server/.prettierrc.json create mode 100755 server/eslint.config.js create mode 100755 server/package-lock.json create mode 100755 server/package.json create mode 100755 server/prisma/migrations/20260428051622_init/migration.sql create mode 100755 server/prisma/migrations/20260428160544_auth/migration.sql create mode 100755 server/prisma/migrations/20260428163250_add_user_name/migration.sql create mode 100755 server/prisma/migrations/20260428165414_product_shortdesc_stock_leadtime/migration.sql create mode 100755 server/prisma/migrations/20260428165733_product_images/migration.sql create mode 100755 server/prisma/migrations/20260429121131_product_quantity_materials/migration.sql create mode 100755 server/prisma/migrations/20260429130733_user_phone/migration.sql create mode 100755 server/prisma/migrations/20260429130833_shipping_addresses/migration.sql create mode 100755 server/prisma/migrations/20260429134933_orders_cart_reviews/migration.sql create mode 100755 server/prisma/migrations/20260429145229_quantity_required/migration.sql create mode 100755 server/prisma/migrations/20260430063434_order_delivery_type_fee/migration.sql create mode 100755 server/prisma/migrations/20260430170746_user_message_read_oauth_accounts/migration.sql create mode 100755 server/prisma/migrations/20260503144425_add_admin_email_info_blocks_review_image/migration.sql create mode 100755 server/prisma/migrations/20260510084617_order_payment_method/migration.sql create mode 100755 server/prisma/migrations/20260510092327_order_message_attachment_url/migration.sql create mode 100755 server/prisma/migrations/20260510123418_add_gallery_image/migration.sql create mode 100755 server/prisma/migrations/20260511093350_order_delivery_carrier/migration.sql create mode 100755 server/prisma/migrations/20260511150413_unspecified_category_restrict_and_slider/migration.sql create mode 100755 server/prisma/migrations/20260515000000_remove_instock_leadtime/migration.sql create mode 100755 server/prisma/migrations/20260515164821_add_delivery_fee_locked/migration.sql create mode 100755 server/prisma/migrations/20260517123931_add_is_resized/migration.sql create mode 100755 server/prisma/migrations/20260518061700_add_notification_system/migration.sql create mode 100755 server/prisma/migrations/20260518062042_fix_notification_schema/migration.sql create mode 100755 server/prisma/migrations/20260518093815_add_delivery_fee_adjusted/migration.sql create mode 100755 server/prisma/migrations/20260519095803_remove_infopageblock/migration.sql create mode 100755 server/prisma/migrations/20260520053830_rename_user_name_to_display_name_add_fields/migration.sql create mode 100755 server/prisma/migrations/20260520124831_add_payment/migration.sql create mode 100755 server/prisma/migrations/20260520125347_drop_duplicate_payment_index/migration.sql create mode 100755 server/prisma/migrations/20260521100000_drop_phone_add_avatar_type/migration.sql create mode 100755 server/prisma/migrations/20260521103000_add_avatar_style/migration.sql create mode 100755 server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql create mode 100755 server/prisma/migrations/20260522143134_remove_unused_user_fields/migration.sql create mode 100755 server/prisma/migrations/20260522175250_pending_email/migration.sql create mode 100755 server/prisma/migrations/20260524111004_add_checklist_result/migration.sql create mode 100755 server/prisma/migrations/20260524115044_add_checklist_comment/migration.sql create mode 100755 server/prisma/migrations/20260526064837_add_slider_text_color/migration.sql create mode 100755 server/prisma/migrations/migration_lock.toml create mode 100755 server/prisma/prisma/dev.db create mode 100755 server/prisma/schema.prisma create mode 100755 server/prisma/seed-is-resized.js create mode 100755 server/src/index.js create mode 100755 server/src/lib/__tests__/async-handler.test.js create mode 100755 server/src/lib/__tests__/escape-html.test.js create mode 100755 server/src/lib/__tests__/find-user-order.test.js create mode 100755 server/src/lib/__tests__/image-resize.test.js create mode 100755 server/src/lib/__tests__/order-status.test.js create mode 100755 server/src/lib/__tests__/upload-images.test.js create mode 100755 server/src/lib/__tests__/validate-gallery-images.test.js create mode 100755 server/src/lib/__tests__/yookassa.test.js create mode 100755 server/src/lib/async-handler.js create mode 100755 server/src/lib/auth.js create mode 100755 server/src/lib/bootstrap-admin.js create mode 100755 server/src/lib/default-category.js create mode 100755 server/src/lib/delivery-carrier.js create mode 100755 server/src/lib/email.js create mode 100755 server/src/lib/escape-html.js create mode 100755 server/src/lib/find-user-order.js create mode 100755 server/src/lib/generate-avatar.js create mode 100755 server/src/lib/image-resize.js create mode 100755 server/src/lib/notifications/__tests__/preferences.test.js create mode 100755 server/src/lib/notifications/channels/email-channel.js create mode 100755 server/src/lib/notifications/channels/telegram-channel.js create mode 100755 server/src/lib/notifications/event-bus.js create mode 100755 server/src/lib/notifications/preferences.js create mode 100755 server/src/lib/notifications/queue.js create mode 100755 server/src/lib/notifications/templates/__tests__/email-templates.test.js create mode 100755 server/src/lib/notifications/templates/email-templates.js create mode 100755 server/src/lib/notifications/templates/telegram-templates.js create mode 100755 server/src/lib/order-status.js create mode 100755 server/src/lib/prisma.js create mode 100755 server/src/lib/rate-limit.js create mode 100755 server/src/lib/review-display.js create mode 100755 server/src/lib/upload-images.js create mode 100755 server/src/lib/upload-limits.js create mode 100755 server/src/lib/validate-gallery-images.js create mode 100755 server/src/lib/yookassa.js create mode 100755 server/src/plugins/__tests__/auth.test.js create mode 100755 server/src/plugins/__tests__/ip-gate.test.js create mode 100755 server/src/plugins/auth.js create mode 100755 server/src/plugins/ip-gate.js create mode 100755 server/src/plugins/security-headers.js create mode 100755 server/src/routes/__tests__/auth-methods.test.js create mode 100755 server/src/routes/__tests__/auth-oauth.test.js create mode 100755 server/src/routes/__tests__/auth-password.test.js create mode 100755 server/src/routes/__tests__/auth-session.test.js create mode 100755 server/src/routes/__tests__/oauth-social.test.js create mode 100755 server/src/routes/__tests__/sse.test.js create mode 100755 server/src/routes/__tests__/user-orders.test.js create mode 100755 server/src/routes/__tests__/user-payments.test.js create mode 100755 server/src/routes/__tests__/webhook-yookassa.test.js create mode 100755 server/src/routes/api.js create mode 100755 server/src/routes/api/__tests__/admin-gallery.test.js create mode 100755 server/src/routes/api/__tests__/admin-orders.test.js create mode 100755 server/src/routes/api/__tests__/admin-products.test.js create mode 100755 server/src/routes/api/_product-helpers.js create mode 100755 server/src/routes/api/admin-categories.js create mode 100755 server/src/routes/api/admin-gallery.js create mode 100755 server/src/routes/api/admin-orders.js create mode 100755 server/src/routes/api/admin-products.js create mode 100755 server/src/routes/api/admin-profile.js create mode 100755 server/src/routes/api/admin-reviews.js create mode 100755 server/src/routes/api/admin-users.js create mode 100755 server/src/routes/api/admin/notifications.js create mode 100755 server/src/routes/api/admin/test-checklist.js create mode 100755 server/src/routes/api/catalog-slider.js create mode 100755 server/src/routes/api/public-catalog.js create mode 100755 server/src/routes/api/public-reviews.js create mode 100755 server/src/routes/auth-oauth.js create mode 100755 server/src/routes/auth-password.js create mode 100755 server/src/routes/auth-session.js create mode 100755 server/src/routes/auth.js create mode 100755 server/src/routes/oauth-social.js create mode 100755 server/src/routes/sse.js create mode 100755 server/src/routes/uploads-resized.js create mode 100755 server/src/routes/user-addresses.js create mode 100755 server/src/routes/user-cart.js create mode 100755 server/src/routes/user-messages.js create mode 100755 server/src/routes/user-orders.js create mode 100755 server/src/routes/user-payments.js create mode 100755 server/src/routes/user/notifications.js create mode 100755 server/src/routes/webhook-yookassa.js create mode 100755 server/vitest.config.js create mode 100755 shared/constants/delivery-carrier.d.ts create mode 100755 shared/constants/delivery-carrier.js create mode 100755 shared/constants/notification-events.d.ts create mode 100755 shared/constants/notification-events.js create mode 100755 shared/constants/order-status.d.ts create mode 100755 shared/constants/order-status.js create mode 100755 shared/constants/payment-method.d.ts create mode 100755 shared/constants/payment-method.js create mode 100755 shared/constants/test-checklist-items.d.ts create mode 100755 shared/constants/test-checklist-items.js create mode 100755 shared/constants/upload-limits.d.ts create mode 100755 shared/constants/upload-limits.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28e1468 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f56eaba --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..640fed7 --- /dev/null +++ b/README.md @@ -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. diff --git a/scripts/SERVER_SETUP.md b/scripts/SERVER_SETUP.md new file mode 100755 index 0000000..abf3600 --- /dev/null +++ b/scripts/SERVER_SETUP.md @@ -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: `` (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`). diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100755 index 0000000..ec5a84c --- /dev/null +++ b/scripts/backup-db.sh @@ -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" diff --git a/scripts/craftshop-backup.service b/scripts/craftshop-backup.service new file mode 100755 index 0000000..9f8f3b9 --- /dev/null +++ b/scripts/craftshop-backup.service @@ -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 diff --git a/scripts/craftshop-backup.timer b/scripts/craftshop-backup.timer new file mode 100755 index 0000000..6655abc --- /dev/null +++ b/scripts/craftshop-backup.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Craftshop Database Backup Timer + +[Timer] +OnCalendar=*-*-* 00/6:00:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/scripts/craftshop-netbird.conf b/scripts/craftshop-netbird.conf new file mode 100755 index 0000000..8e56562 --- /dev/null +++ b/scripts/craftshop-netbird.conf @@ -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; + } +} diff --git a/server/.env.example b/server/.env.example new file mode 100755 index 0000000..78f725a --- /dev/null +++ b/server/.env.example @@ -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= diff --git a/server/.gitignore b/server/.gitignore new file mode 100755 index 0000000..f40b142 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,4 @@ +node_modules +# Keep environment variables out of version control +.env + diff --git a/server/.prettierrc.json b/server/.prettierrc.json new file mode 100755 index 0000000..3e8b14b --- /dev/null +++ b/server/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "singleQuote": true, + "semi": false, + "printWidth": 120, + "trailingComma": "all", + "endOfLine": "lf", + "arrowParens": "always" +} diff --git a/server/eslint.config.js b/server/eslint.config.js new file mode 100755 index 0000000..0721280 --- /dev/null +++ b/server/eslint.config.js @@ -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', + }, + }, +] diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100755 index 0000000..006bcbc --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,5502 @@ +{ + "name": "server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "server", + "version": "1.0.0", + "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" + } + }, + "node_modules/@dicebear/adventurer": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.4.2.tgz", + "integrity": "sha512-jqYp834ZmGDA9HBBDQAdgF1O2UTCwHF4vVrktXWa2Dppp1JczPL5HnVOWsjtrLmXNn61Wd6OLmBb2e6rhzp3ig==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/adventurer-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.4.2.tgz", + "integrity": "sha512-5xgkG/mNL4j3Q4SJGQLBU/KnU90tng8Ze5ofThD+55wi0oeY/nSAUowg6UFCmHrktjifj/MEx3CQqbpcPWtfIA==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/avataaars": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.4.2.tgz", + "integrity": "sha512-3x9jKFkOkFSPmpTbt9xvhiU2E1GX7beCSsX0tXRUShj8x6+5Ks9yBRT1VlkySbnXrZ/GglADGg7vJ/D2uIx1Yw==", + "license": "See LICENSE file", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/avataaars-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.4.2.tgz", + "integrity": "sha512-/eNrp0YCNJRwQXqOloLm1+3Ss2C+pMpUQIGkbEnGsP1UK+13Ge80ggDDof1HpdqvG9HAZcKa7hnbG/0HSwyDSw==", + "license": "See LICENSE file", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-ears": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.4.2.tgz", + "integrity": "sha512-mNfz3ppNA7UBq0IO3nXCiV5pFPG7c1DfzRB0foNU2Wo1XXT8FIcSY2BvDlYqorZTOUOz7dHb0vx06hqvG0HP5w==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-ears-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.4.2.tgz", + "integrity": "sha512-M8Ozmzza4eY4hpLOYULgJxMYmBA0CsBnrE15/xw6LZkEREXnrX5z0NJsf8hUfdyF6BWZ+RBgzoiav32DAC5zcg==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-smile": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.4.2.tgz", + "integrity": "sha512-hmT5i7rcPPhStjZyg28pbIhdTnnMBzK3RObI0vKCpY30EFrzaPkkdDL6Ck5fAFBdvDIW1EpOJkenyR0XPmhgbQ==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/bottts": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.4.2.tgz", + "integrity": "sha512-tsx+dII7EFUCVA8URj66G1GqORCCVduCAx4dY2prEY2IeFianVpkntXuFsWZ9BBGx1NZFndvDith5oTwKMQPbQ==", + "license": "See LICENSE file", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/bottts-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.4.2.tgz", + "integrity": "sha512-kFNwWt6j+gzZ5n5Pz7WVwePubREAQOF8ZwWA9ztwVYDVMLnOChWbAofy5FED4j5md2MXFH2EgLCFCMr5K2BmIA==", + "license": "See LICENSE file", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/collection": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.4.2.tgz", + "integrity": "sha512-KArubv7if8H7j9sIfpDK2hJJqrdNVR5zMPAMOSpIU2JPyXx8TC9o5wsmXb8il5wOHgaS9Q/cla7jUNIiDD7Gsg==", + "license": "MIT", + "dependencies": { + "@dicebear/adventurer": "9.4.2", + "@dicebear/adventurer-neutral": "9.4.2", + "@dicebear/avataaars": "9.4.2", + "@dicebear/avataaars-neutral": "9.4.2", + "@dicebear/big-ears": "9.4.2", + "@dicebear/big-ears-neutral": "9.4.2", + "@dicebear/big-smile": "9.4.2", + "@dicebear/bottts": "9.4.2", + "@dicebear/bottts-neutral": "9.4.2", + "@dicebear/croodles": "9.4.2", + "@dicebear/croodles-neutral": "9.4.2", + "@dicebear/dylan": "9.4.2", + "@dicebear/fun-emoji": "9.4.2", + "@dicebear/glass": "9.4.2", + "@dicebear/icons": "9.4.2", + "@dicebear/identicon": "9.4.2", + "@dicebear/initials": "9.4.2", + "@dicebear/lorelei": "9.4.2", + "@dicebear/lorelei-neutral": "9.4.2", + "@dicebear/micah": "9.4.2", + "@dicebear/miniavs": "9.4.2", + "@dicebear/notionists": "9.4.2", + "@dicebear/notionists-neutral": "9.4.2", + "@dicebear/open-peeps": "9.4.2", + "@dicebear/personas": "9.4.2", + "@dicebear/pixel-art": "9.4.2", + "@dicebear/pixel-art-neutral": "9.4.2", + "@dicebear/rings": "9.4.2", + "@dicebear/shapes": "9.4.2", + "@dicebear/thumbs": "9.4.2", + "@dicebear/toon-head": "9.4.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/core": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.4.2.tgz", + "integrity": "sha512-MF0042+Z3s8PGZKZLySfhft28bUa3B1iq0e5NSjCvY8gfMi5aIH/iRJGRJa1N9Jz1BNkxYb4yvJ/N9KO8Z6Y+w==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@dicebear/croodles": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.4.2.tgz", + "integrity": "sha512-6VoO0JviIf7dKKMBTL/SMXxWhnXHaZuzufX90G0nXxS77ELG1YkGNMaZzawizN4C09Gbya2gJkozqrWiJN/aGw==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/croodles-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.4.2.tgz", + "integrity": "sha512-oG5IeUdtiYshQ89gkAVcl5w3xAEi5UZX2fTzIyelpBPCG176l7VuuFzlxi2umnB3E6LVHYy06DXvUo/p+rXB2Q==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/dylan": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.4.2.tgz", + "integrity": "sha512-1vQvRu9x9DrwFxhFaIU2rf0EUL04yDTbAt7fHyAjM0mEsKzTD4mRNf95tCRuavCoW6W48u7A/OY6jyIub6kxLQ==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/fun-emoji": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.4.2.tgz", + "integrity": "sha512-kqB6LPkdYCdEU/mwbyz34xLzoNUKL6ARcoo3fr5ASq9D6ZE07qIKybC3xv5+CPz7VmspJ1Q3c/VVWVMDRP7Twg==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/glass": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.4.2.tgz", + "integrity": "sha512-z5qUogHQ1b6UJ2zCqT848mU2U9DKbVDhiX6GPDjD7tYLisCCJVisH9p6WyNdHvflUd4SHkA6gRqVJIh2v2HnTA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/icons": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.4.2.tgz", + "integrity": "sha512-QSMMz0NA03ypSGhXC8HQX8FSj8lYT+/5yqH+/N03OH2IjL0q7wwGZ7nqsrtlRp76O5WqMTwGfSbTUUYPjFr+Xw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/identicon": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.4.2.tgz", + "integrity": "sha512-JVDSmZsv11mSWqwAktK5x9Bslht2xY3TFUn8xzu6slAYe1Z7hEXZ76eb+UJ6F4qEzdwZ7xPWzAS6Nb0Y3A0pww==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/initials": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.4.2.tgz", + "integrity": "sha512-yePuIUasmwtl9IrtB6rEzE/zb5fImKP/neW0CdcTC2MwLgMuP1GLHEGRgg1zI8exIh+PMv1YdLGyyUuRTE2Qpw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/lorelei": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.4.2.tgz", + "integrity": "sha512-YMv6vnriW6VLFDsreKuOnUFFno6SRe7+7X7R7zPY0rZ+MaHX9V3jcioIG+1PSjIHEDfOLUHpr5vd1JBWv8y7UA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/lorelei-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.4.2.tgz", + "integrity": "sha512-yspanTthA5vh6iCdeLzn6xZ4yYMYRcfcxblcgSvHTF1ut0bjAXtw5SXzZ6aJTrJWiHkzYOQuTOR6GVYiW80Q7w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/micah": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.4.2.tgz", + "integrity": "sha512-e4D3W/OlChSsLo7Llwsy0J18vk0azJqF/uFoY+EKACCNHBc1HGNsqVvu2CTf+OWOA8wTyAK6UkjBN5p01r7D+g==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/miniavs": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.4.2.tgz", + "integrity": "sha512-wLwyFNNUnDRd3BbhSBhXR0XEpX8sG0/xDA5M/OkDoapLqZnnI48YLUSDd2N5QTAVMmcSEuZOYxkcnj7WW79vlg==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/notionists": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.4.2.tgz", + "integrity": "sha512-ZCySq+nxcD/x4xyYgytcj2N9uY3gxrL+qpnmOdp2BdA221KacVrxlsUPpIgEMqxS2rMmBQXfxg129Pzn4ycIpA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/notionists-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.4.2.tgz", + "integrity": "sha512-AyD9kEfVxQUwDGf4Op059gVmYIOAkTKg3dtE9h9mEKP7zl/kMy5B67BFFOo7sB0mXCjzAegZ6ekGU02E8+hIHw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/open-peeps": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.4.2.tgz", + "integrity": "sha512-i01tLgtp2g937T81sVeAOVlqsCtiTck/Kw20g7hN80+7xrXjOUepz2HPLy3HeiMjwjMGRy5o54kSd0/8Ht4Dqg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/personas": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.4.2.tgz", + "integrity": "sha512-NJlkvI5F5gugt6t2+7QrYNTwQC7+4IQZS3vG0dYk2BncxOHax0BuLovdSdiAesTL4ZkytFYIydWmKmV2/xcUwg==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/pixel-art": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.4.2.tgz", + "integrity": "sha512-peHf7oKICDgBZ8dUyj+txPnS7VZEWgvKE+xW4mNQqBt6dYZIjmva2shOVHn0b1JU+FDxMx3uIkWVixKdUq4WGg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/pixel-art-neutral": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.4.2.tgz", + "integrity": "sha512-9e9Lz554uQvWaXV2P17ss+hPa6rTyuAKBtB8zk8ECjHiZzIl61N/KcTVLZ4dILVZwj7gYriaLo16QEqvL2GJCg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/rings": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.4.2.tgz", + "integrity": "sha512-Pc3ymWrRDQPJFNrbbLt7RJrzGvUuuxUiDkrfLhoVE+B6mZWEL1PC78DPbS1yUWYLErJOpJuM2GSwXmTbVjWf+g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/shapes": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.4.2.tgz", + "integrity": "sha512-AFL6jAaiLztvcqyq+ds+lWZu6Vbp3PlGWhJeJRm842jxtiluJpl6r4f6nUXP2fdMz7MNpDzXfLooQK9E04NbUQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/thumbs": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.4.2.tgz", + "integrity": "sha512-ccWvDBqbkWS5uzHbsg5L6uML6vBfX7jT3J3jHCQksvz8haHItxTK02w+6e1UavZUsvza4lG5X/XY3eji3siJ4Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/toon-head": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/toon-head/-/toon-head-9.4.2.tgz", + "integrity": "sha512-lwFeSXyAnaKnCfMt9TiJwnD1cXQUGkey/0h6i/+4TVHVMCz5/Ri5u1ynovPNHy1SnBf858QwoXHkxilGLwQX/g==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/jwt": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.1.0.tgz", + "integrity": "sha512-U1y8ZbxoH1Pjon3euzPJmbCkuYBM+hrQlFWLQWvKmJGCNT6mVsAolnVJdEWfXeQOKpgmuRVCIsPll5RLZxj10A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.2.0", + "@lukeed/ms": "^2.0.2", + "fast-jwt": "^6.2.0", + "fastify-plugin": "^5.0.1", + "steed": "^1.1.3" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/multipart": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.0.0.tgz", + "integrity": "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz", + "integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^1.0.1", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@package-json/types": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@package-json/types/-/types-0.0.12.tgz", + "integrity": "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", + "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", + "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", + "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", + "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/comment-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.7.tgz", + "integrity": "sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.2.tgz", + "integrity": "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@package-json/types": "^0.0.12", + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", + "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/fast-jwt": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.2.4.tgz", + "integrity": "sha512-IoQa53wI6TbARU2yelb0L44ggFQnP2qVcwswCSYHbCAWuwpr70icDb3QjG0v01I8Tt01rVGDkN/rRvpk0lKFTA==", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0", + "safe-regex2": "^5.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fastseries": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz", + "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mnemonist": { + "version": "0.40.4", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz", + "integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nodemailer": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.8.tgz", + "integrity": "sha512-p+XsnzXGdtIHXUu2ugxdfG+eX2nehsGhMjW9h0CWj1BhE30hrFz0kh0yIM0/VjUgVsRrDj+80ZO+I1nSkGE4tA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/steed": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", + "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==", + "license": "MIT", + "dependencies": { + "fastfall": "^1.5.0", + "fastparallel": "^2.2.0", + "fastq": "^1.3.0", + "fastseries": "^1.7.0", + "reusify": "^1.0.0" + } + }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100755 index 0000000..538f5c1 --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/prisma/migrations/20260428051622_init/migration.sql b/server/prisma/migrations/20260428051622_init/migration.sql new file mode 100755 index 0000000..91e1187 --- /dev/null +++ b/server/prisma/migrations/20260428051622_init/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260428160544_auth/migration.sql b/server/prisma/migrations/20260428160544_auth/migration.sql new file mode 100755 index 0000000..e5902a4 --- /dev/null +++ b/server/prisma/migrations/20260428160544_auth/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260428163250_add_user_name/migration.sql b/server/prisma/migrations/20260428163250_add_user_name/migration.sql new file mode 100755 index 0000000..fc3b9dd --- /dev/null +++ b/server/prisma/migrations/20260428163250_add_user_name/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "name" TEXT; diff --git a/server/prisma/migrations/20260428165414_product_shortdesc_stock_leadtime/migration.sql b/server/prisma/migrations/20260428165414_product_shortdesc_stock_leadtime/migration.sql new file mode 100755 index 0000000..33a1d84 --- /dev/null +++ b/server/prisma/migrations/20260428165414_product_shortdesc_stock_leadtime/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260428165733_product_images/migration.sql b/server/prisma/migrations/20260428165733_product_images/migration.sql new file mode 100755 index 0000000..7ea7f8a --- /dev/null +++ b/server/prisma/migrations/20260428165733_product_images/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260429121131_product_quantity_materials/migration.sql b/server/prisma/migrations/20260429121131_product_quantity_materials/migration.sql new file mode 100755 index 0000000..fd9a90b --- /dev/null +++ b/server/prisma/migrations/20260429121131_product_quantity_materials/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260429130733_user_phone/migration.sql b/server/prisma/migrations/20260429130733_user_phone/migration.sql new file mode 100755 index 0000000..8ef463c --- /dev/null +++ b/server/prisma/migrations/20260429130733_user_phone/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "phone" TEXT; diff --git a/server/prisma/migrations/20260429130833_shipping_addresses/migration.sql b/server/prisma/migrations/20260429130833_shipping_addresses/migration.sql new file mode 100755 index 0000000..59b5b3d --- /dev/null +++ b/server/prisma/migrations/20260429130833_shipping_addresses/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260429134933_orders_cart_reviews/migration.sql b/server/prisma/migrations/20260429134933_orders_cart_reviews/migration.sql new file mode 100755 index 0000000..a4c1291 --- /dev/null +++ b/server/prisma/migrations/20260429134933_orders_cart_reviews/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260429145229_quantity_required/migration.sql b/server/prisma/migrations/20260429145229_quantity_required/migration.sql new file mode 100755 index 0000000..1d5bedf --- /dev/null +++ b/server/prisma/migrations/20260429145229_quantity_required/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260430063434_order_delivery_type_fee/migration.sql b/server/prisma/migrations/20260430063434_order_delivery_type_fee/migration.sql new file mode 100755 index 0000000..1feea50 --- /dev/null +++ b/server/prisma/migrations/20260430063434_order_delivery_type_fee/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260430170746_user_message_read_oauth_accounts/migration.sql b/server/prisma/migrations/20260430170746_user_message_read_oauth_accounts/migration.sql new file mode 100755 index 0000000..90ec5c3 --- /dev/null +++ b/server/prisma/migrations/20260430170746_user_message_read_oauth_accounts/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260503144425_add_admin_email_info_blocks_review_image/migration.sql b/server/prisma/migrations/20260503144425_add_admin_email_info_blocks_review_image/migration.sql new file mode 100755 index 0000000..c33acfb --- /dev/null +++ b/server/prisma/migrations/20260503144425_add_admin_email_info_blocks_review_image/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260510084617_order_payment_method/migration.sql b/server/prisma/migrations/20260510084617_order_payment_method/migration.sql new file mode 100755 index 0000000..a4a854c --- /dev/null +++ b/server/prisma/migrations/20260510084617_order_payment_method/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260510092327_order_message_attachment_url/migration.sql b/server/prisma/migrations/20260510092327_order_message_attachment_url/migration.sql new file mode 100755 index 0000000..89e0b7d --- /dev/null +++ b/server/prisma/migrations/20260510092327_order_message_attachment_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "OrderMessage" ADD COLUMN "attachmentUrl" TEXT; diff --git a/server/prisma/migrations/20260510123418_add_gallery_image/migration.sql b/server/prisma/migrations/20260510123418_add_gallery_image/migration.sql new file mode 100755 index 0000000..e90f479 --- /dev/null +++ b/server/prisma/migrations/20260510123418_add_gallery_image/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260511093350_order_delivery_carrier/migration.sql b/server/prisma/migrations/20260511093350_order_delivery_carrier/migration.sql new file mode 100755 index 0000000..eaccc42 --- /dev/null +++ b/server/prisma/migrations/20260511093350_order_delivery_carrier/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "deliveryCarrier" TEXT; diff --git a/server/prisma/migrations/20260511150413_unspecified_category_restrict_and_slider/migration.sql b/server/prisma/migrations/20260511150413_unspecified_category_restrict_and_slider/migration.sql new file mode 100755 index 0000000..daad59a --- /dev/null +++ b/server/prisma/migrations/20260511150413_unspecified_category_restrict_and_slider/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260515000000_remove_instock_leadtime/migration.sql b/server/prisma/migrations/20260515000000_remove_instock_leadtime/migration.sql new file mode 100755 index 0000000..480057a --- /dev/null +++ b/server/prisma/migrations/20260515000000_remove_instock_leadtime/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260515164821_add_delivery_fee_locked/migration.sql b/server/prisma/migrations/20260515164821_add_delivery_fee_locked/migration.sql new file mode 100755 index 0000000..d331b29 --- /dev/null +++ b/server/prisma/migrations/20260515164821_add_delivery_fee_locked/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260517123931_add_is_resized/migration.sql b/server/prisma/migrations/20260517123931_add_is_resized/migration.sql new file mode 100755 index 0000000..9f545e7 --- /dev/null +++ b/server/prisma/migrations/20260517123931_add_is_resized/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260518061700_add_notification_system/migration.sql b/server/prisma/migrations/20260518061700_add_notification_system/migration.sql new file mode 100755 index 0000000..c780066 --- /dev/null +++ b/server/prisma/migrations/20260518061700_add_notification_system/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260518062042_fix_notification_schema/migration.sql b/server/prisma/migrations/20260518062042_fix_notification_schema/migration.sql new file mode 100755 index 0000000..23e6a7a --- /dev/null +++ b/server/prisma/migrations/20260518062042_fix_notification_schema/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "NotificationPreference_userId_idx"; diff --git a/server/prisma/migrations/20260518093815_add_delivery_fee_adjusted/migration.sql b/server/prisma/migrations/20260518093815_add_delivery_fee_adjusted/migration.sql new file mode 100755 index 0000000..1ace285 --- /dev/null +++ b/server/prisma/migrations/20260518093815_add_delivery_fee_adjusted/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260519095803_remove_infopageblock/migration.sql b/server/prisma/migrations/20260519095803_remove_infopageblock/migration.sql new file mode 100755 index 0000000..2ecacbb --- /dev/null +++ b/server/prisma/migrations/20260519095803_remove_infopageblock/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260520053830_rename_user_name_to_display_name_add_fields/migration.sql b/server/prisma/migrations/20260520053830_rename_user_name_to_display_name_add_fields/migration.sql new file mode 100755 index 0000000..23ac31e --- /dev/null +++ b/server/prisma/migrations/20260520053830_rename_user_name_to_display_name_add_fields/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260520124831_add_payment/migration.sql b/server/prisma/migrations/20260520124831_add_payment/migration.sql new file mode 100755 index 0000000..121dd2e --- /dev/null +++ b/server/prisma/migrations/20260520124831_add_payment/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260520125347_drop_duplicate_payment_index/migration.sql b/server/prisma/migrations/20260520125347_drop_duplicate_payment_index/migration.sql new file mode 100755 index 0000000..84f0f45 --- /dev/null +++ b/server/prisma/migrations/20260520125347_drop_duplicate_payment_index/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "Payment_yookassaPaymentId_idx"; diff --git a/server/prisma/migrations/20260521100000_drop_phone_add_avatar_type/migration.sql b/server/prisma/migrations/20260521100000_drop_phone_add_avatar_type/migration.sql new file mode 100755 index 0000000..384c607 --- /dev/null +++ b/server/prisma/migrations/20260521100000_drop_phone_add_avatar_type/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE User DROP COLUMN phone; +ALTER TABLE User ADD COLUMN "avatarType" TEXT; diff --git a/server/prisma/migrations/20260521103000_add_avatar_style/migration.sql b/server/prisma/migrations/20260521103000_add_avatar_style/migration.sql new file mode 100755 index 0000000..acccfeb --- /dev/null +++ b/server/prisma/migrations/20260521103000_add_avatar_style/migration.sql @@ -0,0 +1 @@ +ALTER TABLE User ADD COLUMN "avatarStyle" TEXT; diff --git a/server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql b/server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql new file mode 100755 index 0000000..b9ee70a --- /dev/null +++ b/server/prisma/migrations/20260522062112_remove_avatar_type/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260522143134_remove_unused_user_fields/migration.sql b/server/prisma/migrations/20260522143134_remove_unused_user_fields/migration.sql new file mode 100755 index 0000000..4b0a7e4 --- /dev/null +++ b/server/prisma/migrations/20260522143134_remove_unused_user_fields/migration.sql @@ -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; diff --git a/server/prisma/migrations/20260522175250_pending_email/migration.sql b/server/prisma/migrations/20260522175250_pending_email/migration.sql new file mode 100755 index 0000000..b6dfb3c --- /dev/null +++ b/server/prisma/migrations/20260522175250_pending_email/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260524111004_add_checklist_result/migration.sql b/server/prisma/migrations/20260524111004_add_checklist_result/migration.sql new file mode 100755 index 0000000..b283e43 --- /dev/null +++ b/server/prisma/migrations/20260524111004_add_checklist_result/migration.sql @@ -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"); diff --git a/server/prisma/migrations/20260524115044_add_checklist_comment/migration.sql b/server/prisma/migrations/20260524115044_add_checklist_comment/migration.sql new file mode 100755 index 0000000..4f16b6a --- /dev/null +++ b/server/prisma/migrations/20260524115044_add_checklist_comment/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ChecklistResult" ADD COLUMN "comment" TEXT; diff --git a/server/prisma/migrations/20260526064837_add_slider_text_color/migration.sql b/server/prisma/migrations/20260526064837_add_slider_text_color/migration.sql new file mode 100755 index 0000000..a5c6d27 --- /dev/null +++ b/server/prisma/migrations/20260526064837_add_slider_text_color/migration.sql @@ -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; diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml new file mode 100755 index 0000000..e5e5c47 --- /dev/null +++ b/server/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db new file mode 100755 index 0000000000000000000000000000000000000000..70655045a13eb7af8f2677005ec3b6f7a05ae368 GIT binary patch literal 364544 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ΞEct)dwio?Qd8eTK;li~$bI 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 I|Mz$QA6d2YivR!s literal 0 HcmV?d00001 diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma new file mode 100755 index 0000000..4d6a073 --- /dev/null +++ b/server/prisma/schema.prisma @@ -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 +} diff --git a/server/prisma/seed-is-resized.js b/server/prisma/seed-is-resized.js new file mode 100755 index 0000000..936f816 --- /dev/null +++ b/server/prisma/seed-is-resized.js @@ -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()) diff --git a/server/src/index.js b/server/src/index.js new file mode 100755 index 0000000..175e33f --- /dev/null +++ b/server/src/index.js @@ -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) +} diff --git a/server/src/lib/__tests__/async-handler.test.js b/server/src/lib/__tests__/async-handler.test.js new file mode 100755 index 0000000..710d56b --- /dev/null +++ b/server/src/lib/__tests__/async-handler.test.js @@ -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' }) + }) +}) diff --git a/server/src/lib/__tests__/escape-html.test.js b/server/src/lib/__tests__/escape-html.test.js new file mode 100755 index 0000000..4e62b3f --- /dev/null +++ b/server/src/lib/__tests__/escape-html.test.js @@ -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('')).toBe('<script>alert("xss")</script>') + }) +}) diff --git a/server/src/lib/__tests__/find-user-order.test.js b/server/src/lib/__tests__/find-user-order.test.js new file mode 100755 index 0000000..a101edb --- /dev/null +++ b/server/src/lib/__tests__/find-user-order.test.js @@ -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 } })) + }) +}) diff --git a/server/src/lib/__tests__/image-resize.test.js b/server/src/lib/__tests__/image-resize.test.js new file mode 100755 index 0000000..2fc7bad --- /dev/null +++ b/server/src/lib/__tests__/image-resize.test.js @@ -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 + } + }) +}) diff --git a/server/src/lib/__tests__/order-status.test.js b/server/src/lib/__tests__/order-status.test.js new file mode 100755 index 0000000..886755a --- /dev/null +++ b/server/src/lib/__tests__/order-status.test.js @@ -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) + }) +}) diff --git a/server/src/lib/__tests__/upload-images.test.js b/server/src/lib/__tests__/upload-images.test.js new file mode 100755 index 0000000..f0a78da --- /dev/null +++ b/server/src/lib/__tests__/upload-images.test.js @@ -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$/) + }) +}) diff --git a/server/src/lib/__tests__/validate-gallery-images.test.js b/server/src/lib/__tests__/validate-gallery-images.test.js new file mode 100755 index 0000000..6818bd6 --- /dev/null +++ b/server/src/lib/__tests__/validate-gallery-images.test.js @@ -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) + }) +}) diff --git a/server/src/lib/__tests__/yookassa.test.js b/server/src/lib/__tests__/yookassa.test.js new file mode 100755 index 0000000..f15be2a --- /dev/null +++ b/server/src/lib/__tests__/yookassa.test.js @@ -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() + }) +}) diff --git a/server/src/lib/async-handler.js b/server/src/lib/async-handler.js new file mode 100755 index 0000000..6924971 --- /dev/null +++ b/server/src/lib/async-handler.js @@ -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 }) + } + } +} diff --git a/server/src/lib/auth.js b/server/src/lib/auth.js new file mode 100755 index 0000000..cdfc5bb --- /dev/null +++ b/server/src/lib/auth.js @@ -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 +} diff --git a/server/src/lib/bootstrap-admin.js b/server/src/lib/bootstrap-admin.js new file mode 100755 index 0000000..a4462f9 --- /dev/null +++ b/server/src/lib/bootstrap-admin.js @@ -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, + }, + }) + } +} diff --git a/server/src/lib/default-category.js b/server/src/lib/default-category.js new file mode 100755 index 0000000..bf80090 --- /dev/null +++ b/server/src/lib/default-category.js @@ -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 +} diff --git a/server/src/lib/delivery-carrier.js b/server/src/lib/delivery-carrier.js new file mode 100755 index 0000000..95131a6 --- /dev/null +++ b/server/src/lib/delivery-carrier.js @@ -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) +} diff --git a/server/src/lib/email.js b/server/src/lib/email.js new file mode 100755 index 0000000..d2d995f --- /dev/null +++ b/server/src/lib/email.js @@ -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 } + } +} diff --git a/server/src/lib/escape-html.js b/server/src/lib/escape-html.js new file mode 100755 index 0000000..067efc5 --- /dev/null +++ b/server/src/lib/escape-html.js @@ -0,0 +1,8 @@ +/** Минимальное экранирование для безопасного HTML из пользовательского ввода. */ +export function escapeHtml(input) { + return String(input ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} diff --git a/server/src/lib/find-user-order.js b/server/src/lib/find-user-order.js new file mode 100755 index 0000000..6c14d09 --- /dev/null +++ b/server/src/lib/find-user-order.js @@ -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 +} diff --git a/server/src/lib/generate-avatar.js b/server/src/lib/generate-avatar.js new file mode 100755 index 0000000..002344f --- /dev/null +++ b/server/src/lib/generate-avatar.js @@ -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() +} diff --git a/server/src/lib/image-resize.js b/server/src/lib/image-resize.js new file mode 100755 index 0000000..5283ed2 --- /dev/null +++ b/server/src/lib/image-resize.js @@ -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/.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` +} diff --git a/server/src/lib/notifications/__tests__/preferences.test.js b/server/src/lib/notifications/__tests__/preferences.test.js new file mode 100755 index 0000000..34afa03 --- /dev/null +++ b/server/src/lib/notifications/__tests__/preferences.test.js @@ -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) + }) +}) diff --git a/server/src/lib/notifications/channels/email-channel.js b/server/src/lib/notifications/channels/email-channel.js new file mode 100755 index 0000000..f37e75c --- /dev/null +++ b/server/src/lib/notifications/channels/email-channel.js @@ -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 + }, +} diff --git a/server/src/lib/notifications/channels/telegram-channel.js b/server/src/lib/notifications/channels/telegram-channel.js new file mode 100755 index 0000000..4303743 --- /dev/null +++ b/server/src/lib/notifications/channels/telegram-channel.js @@ -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) + }, +} diff --git a/server/src/lib/notifications/event-bus.js b/server/src/lib/notifications/event-bus.js new file mode 100755 index 0000000..b3dbbf3 --- /dev/null +++ b/server/src/lib/notifications/event-bus.js @@ -0,0 +1,7 @@ +import { EventEmitter } from 'node:events' + +export function createEventBus() { + const bus = new EventEmitter() + bus.setMaxListeners(50) + return bus +} diff --git a/server/src/lib/notifications/preferences.js b/server/src/lib/notifications/preferences.js new file mode 100755 index 0000000..2bd70ff --- /dev/null +++ b/server/src/lib/notifications/preferences.js @@ -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 }, + }) +} diff --git a/server/src/lib/notifications/queue.js b/server/src/lib/notifications/queue.js new file mode 100755 index 0000000..c8b88f2 --- /dev/null +++ b/server/src/lib/notifications/queue.js @@ -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() +} diff --git a/server/src/lib/notifications/templates/__tests__/email-templates.test.js b/server/src/lib/notifications/templates/__tests__/email-templates.test.js new file mode 100755 index 0000000..e92b610 --- /dev/null +++ b/server/src/lib/notifications/templates/__tests__/email-templates.test.js @@ -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('Оплачен') + expect(email.html).not.toContain('paid') + }) + + 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"') + }) +}) diff --git a/server/src/lib/notifications/templates/email-templates.js b/server/src/lib/notifications/templates/email-templates.js new file mode 100755 index 0000000..a71462a --- /dev/null +++ b/server/src/lib/notifications/templates/email-templates.js @@ -0,0 +1,175 @@ +function baseLayout(title, body) { + return ` + +${title} + +
+

${title}

+
+ ${body} +
+

Любимый Креатив — магазин handmade изделий

+
+ +` +} + +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 ` +

+ + ${label} + +

+ ` +} + +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 = ` +

Ваш заказ #${orderId.slice(0, 8)} успешно создан.

+

Товаров: ${itemsCount} | Сумма: ${total} ₽

+

${nextAction}

+ ${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 = ` +

Статус заказа #${orderId.slice(0, 8)} изменён.

+

${oldLabel}${newLabel}

+ ${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 = ` +

Новое сообщение к заказу #${orderId.slice(0, 8)}:

+
+ ${truncated} +
+

Ответьте в личном кабинете.

+ ${renderActionLink(buildMessagesUrl(), 'Открыть сообщения в личном кабинете')} + ` + return { + subject: 'Новое сообщение к заказу', + html: baseLayout('Новое сообщение', body), + } +} + +export function renderAdminOrderMessageEmail({ orderId, preview }) { + const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview + const body = ` +

Новое сообщение к заказу #${orderId.slice(0, 8)}:

+
+ ${truncated} +
+

Ответьте в админ-панели.

+ ${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 = ` +

Статус оплаты заказа #${orderId.slice(0, 8)}: ${label}.

+ ${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' ? '

⚠️ Скорректируйте стоимость доставки в админ-панели.

' : '' + const body = ` +

Новый заказ #${orderId.slice(0, 8)} от ${userEmail}.

+

Товаров: ${itemsCount} | Сумма: ${total} ₽

+ ${note} + ` + return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) } +} + +export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) { + const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating) + const body = ` +

Новый отзыв ${stars} на товар ${productTitle} от ${userName}.

+ ${text ? `
${text}
` : ''} +

Проверьте отзыв в админ-панели.

+ ` + return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) } +} + +export function renderAuthCodeEmail({ code }) { + const body = ` +

Ваш код входа: ${code}

+

Если это были не вы — просто проигнорируйте письмо.

+ ` + return { subject: 'Код входа', html: baseLayout('Код входа', body) } +} + +export function renderDeliveryFeeAdjustedEmail({ orderId, totalCents }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + const body = ` +

Стоимость доставки заказа #${orderId.slice(0, 8)} скорректирована.

+

Новая сумма: ${total} ₽

+

Ожидает оплаты. Проверьте статус заказа в личном кабинете.

+ ${renderActionLink(buildOrderUrl(orderId), 'Открыть заказ в личном кабинете')} + ` + return { + subject: 'Стоимость доставки скорректирована', + html: baseLayout('Стоимость доставки скорректирована', body), + } +} diff --git a/server/src/lib/notifications/templates/telegram-templates.js b/server/src/lib/notifications/templates/telegram-templates.js new file mode 100755 index 0000000..ba9adda --- /dev/null +++ b/server/src/lib/notifications/templates/telegram-templates.js @@ -0,0 +1,50 @@ +export function renderOrderCreatedTg({ orderId, totalCents, itemsCount, deliveryType }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + const nextAction = + deliveryType === 'delivery' ? 'Оплата будет доступна после уточнения стоимости доставки.' : 'Ожидает оплаты.' + return `📦 Новый заказ #${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} → ${labels[newStatus] || newStatus}` +} + +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)}: ${labels[paymentStatus] || paymentStatus}` +} + +export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount, deliveryType }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + const note = deliveryType === 'delivery' ? '\n\n⚠️ Скорректируйте стоимость доставки' : '' + return `🛒 Новый заказ #${orderId.slice(0, 8)}\nОт: ${userEmail}\nТоваров: ${itemsCount} | Сумма: ${total} ₽${note}` +} + +export function renderAdminNewReviewTg({ rating, text, productTitle, userName }) { + const stars = '⭐'.repeat(rating) + return `📝 Новый отзыв ${stars}\nТовар: ${productTitle}\nАвтор: ${userName}${text ? '\n\n' + text : ''}` +} + +export function renderAuthCodeTg({ code }) { + return `🔐 Код входа: ${code}` +} + +export function renderDeliveryFeeAdjustedTg({ orderId, totalCents }) { + const total = (totalCents / 100).toLocaleString('ru-RU') + return `💰 Стоимость доставки скорректирована для заказа #${orderId.slice(0, 8)}\nНовая сумма: ${total} ₽\n\nОжидает оплаты.` +} diff --git a/server/src/lib/order-status.js b/server/src/lib/order-status.js new file mode 100755 index 0000000..31a7de6 --- /dev/null +++ b/server/src/lib/order-status.js @@ -0,0 +1,5 @@ +export { + ORDER_STATUSES, + getNextAdminStatuses, + canTransitionAdminOrderStatus, +} from '../../../shared/constants/order-status.js' diff --git a/server/src/lib/prisma.js b/server/src/lib/prisma.js new file mode 100755 index 0000000..0efdeaf --- /dev/null +++ b/server/src/lib/prisma.js @@ -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 +} diff --git a/server/src/lib/rate-limit.js b/server/src/lib/rate-limit.js new file mode 100755 index 0000000..0f09b1f --- /dev/null +++ b/server/src/lib/rate-limit.js @@ -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') +} diff --git a/server/src/lib/review-display.js b/server/src/lib/review-display.js new file mode 100755 index 0000000..ddcc05c --- /dev/null +++ b/server/src/lib/review-display.js @@ -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}` +} diff --git a/server/src/lib/upload-images.js b/server/src/lib/upload-images.js new file mode 100755 index 0000000..4d8f965 --- /dev/null +++ b/server/src/lib/upload-images.js @@ -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}` +} diff --git a/server/src/lib/upload-limits.js b/server/src/lib/upload-limits.js new file mode 100755 index 0000000..94e6acb --- /dev/null +++ b/server/src/lib/upload-limits.js @@ -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} МБ).` +} diff --git a/server/src/lib/validate-gallery-images.js b/server/src/lib/validate-gallery-images.js new file mode 100755 index 0000000..ec49047 --- /dev/null +++ b/server/src/lib/validate-gallery-images.js @@ -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 +} diff --git a/server/src/lib/yookassa.js b/server/src/lib/yookassa.js new file mode 100755 index 0000000..029274f --- /dev/null +++ b/server/src/lib/yookassa.js @@ -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 +} diff --git a/server/src/plugins/__tests__/auth.test.js b/server/src/plugins/__tests__/auth.test.js new file mode 100755 index 0000000..b1a21a8 --- /dev/null +++ b/server/src/plugins/__tests__/auth.test.js @@ -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() + } + }) +}) diff --git a/server/src/plugins/__tests__/ip-gate.test.js b/server/src/plugins/__tests__/ip-gate.test.js new file mode 100755 index 0000000..2710460 --- /dev/null +++ b/server/src/plugins/__tests__/ip-gate.test.js @@ -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) + }) +}) diff --git a/server/src/plugins/auth.js b/server/src/plugins/auth.js new file mode 100755 index 0000000..4fe9231 --- /dev/null +++ b/server/src/plugins/auth.js @@ -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: 'Недостаточно прав' }) + } + }) +} diff --git a/server/src/plugins/ip-gate.js b/server/src/plugins/ip-gate.js new file mode 100755 index 0000000..735d985 --- /dev/null +++ b/server/src/plugins/ip-gate.js @@ -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 ` + + + + +Любимый Креатив + + + +
+

Любимый Креатив

+

Изделия ручной работы: вещи с характером и вниманием к деталям

+

Сайт находится в разработке и скоро будет доступен

+

Ваш IP: ${safeIp}

+
+ +` +} + +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)) + }) +} diff --git a/server/src/plugins/security-headers.js b/server/src/plugins/security-headers.js new file mode 100755 index 0000000..283ebc3 --- /dev/null +++ b/server/src/plugins/security-headers.js @@ -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) + }) +} diff --git a/server/src/routes/__tests__/auth-methods.test.js b/server/src/routes/__tests__/auth-methods.test.js new file mode 100755 index 0000000..47ef17c --- /dev/null +++ b/server/src/routes/__tests__/auth-methods.test.js @@ -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) + }) +}) diff --git a/server/src/routes/__tests__/auth-oauth.test.js b/server/src/routes/__tests__/auth-oauth.test.js new file mode 100755 index 0000000..447755f --- /dev/null +++ b/server/src/routes/__tests__/auth-oauth.test.js @@ -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('последний метод') + }) +}) diff --git a/server/src/routes/__tests__/auth-password.test.js b/server/src/routes/__tests__/auth-password.test.js new file mode 100755 index 0000000..238f927 --- /dev/null +++ b/server/src/routes/__tests__/auth-password.test.js @@ -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) + }) +}) diff --git a/server/src/routes/__tests__/auth-session.test.js b/server/src/routes/__tests__/auth-session.test.js new file mode 100755 index 0000000..185469f --- /dev/null +++ b/server/src/routes/__tests__/auth-session.test.js @@ -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) + }) +}) diff --git a/server/src/routes/__tests__/oauth-social.test.js b/server/src/routes/__tests__/oauth-social.test.js new file mode 100755 index 0000000..d311075 --- /dev/null +++ b/server/src/routes/__tests__/oauth-social.test.js @@ -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() + }) +}) diff --git a/server/src/routes/__tests__/sse.test.js b/server/src/routes/__tests__/sse.test.js new file mode 100755 index 0000000..df2da65 --- /dev/null +++ b/server/src/routes/__tests__/sse.test.js @@ -0,0 +1,238 @@ +import { EventEmitter } from 'node:events' +import Fastify from 'fastify' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js' + +describe('formatSSE', () => { + it('formats event with data', () => { + const result = formatSSE('message:new', { orderId: 'o1' }) + expect(result).toBe('event: message:new\ndata: {"orderId":"o1"}\n\n') + }) + + it('formats event without data', () => { + const result = formatSSE('heartbit') + expect(result).toBe('event: heartbit\n\n') + }) +}) + +describe('formatHeartbit', () => { + it('returns SSE comment', () => { + expect(formatHeartbit()).toBe(':heartbit\n\n') + }) +}) + +describe('isAdminUser', () => { + it('returns false for non-matching email', () => { + expect(isAdminUser({ email: 'user@test.com' })).toBe(false) + }) + + it('returns true when email matches ADMIN_EMAIL', () => { + const adminEmail = process.env.ADMIN_EMAIL + if (!adminEmail) { + console.warn('ADMIN_EMAIL not set, skipping') + return + } + expect(isAdminUser({ email: adminEmail })).toBe(true) + }) + + it('returns false for null/undefined user', () => { + expect(isAdminUser(null)).toBe(false) + expect(isAdminUser(undefined)).toBe(false) + }) + + it('normalizes email before comparing with ADMIN_EMAIL', () => { + const previousAdminEmail = process.env.ADMIN_EMAIL + process.env.ADMIN_EMAIL = 'Admin@Test.com' + + expect(isAdminUser({ email: ' admin@test.com ' })).toBe(true) + + if (previousAdminEmail === undefined) { + delete process.env.ADMIN_EMAIL + } else { + process.env.ADMIN_EMAIL = previousAdminEmail + } + }) +}) + +describe('buildSseListeners', () => { + let eventBus + let write + + beforeEach(() => { + eventBus = new EventEmitter() + eventBus.setMaxListeners(50) + write = vi.fn() + }) + + it('forwards orderMessage:adminReply to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: message:new') + expect(write.mock.calls[0][0]).toContain('"orderId":"o1"') + cleanup() + }) + + it('ignores orderMessage:adminReply for non-matching userId', () => { + const cleanup = buildSseListeners('user-2', false, eventBus, write) + eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' }) + expect(write).not.toHaveBeenCalled() + cleanup() + }) + + it('forwards orderMessage:sent to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: message:new') + cleanup() + }) + + it('ignores orderMessage:sent for non-admin', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' }) + expect(write).not.toHaveBeenCalled() + cleanup() + }) + + it('forwards order:statusChanged to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('order:statusChanged', { + orderId: 'o1', + userId: 'user-1', + oldStatus: 'PENDING_PAYMENT', + newStatus: 'PAID', + }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') + expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"') + cleanup() + }) + + it('forwards order:statusChanged to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('order:statusChanged', { + orderId: 'o1', + userId: 'user-1', + oldStatus: 'READY_FOR_PICKUP', + newStatus: 'DONE', + }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') + expect(write.mock.calls[0][0]).toContain('"orderId":"o1"') + cleanup() + }) + + it('forwards payment:statusChanged to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') + cleanup() + }) + + it('forwards payment:statusChanged to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:statusChanged') + expect(write.mock.calls[0][0]).toContain('"orderId":"o1"') + cleanup() + }) + + it('forwards order:deliveryFeeAdjusted to matching userId', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:updated') + cleanup() + }) + + it('forwards order:deliveryFeeAdjusted to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:updated') + expect(write.mock.calls[0][0]).toContain('"orderId":"o1"') + cleanup() + }) + + it('forwards order:created to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:new') + cleanup() + }) + + it('forwards order:created:admin to admin', () => { + const cleanup = buildSseListeners('admin-1', true, eventBus, write) + eventBus.emit('order:created:admin', { orderId: 'o1', userId: 'user-1', userEmail: 'user@test.com' }) + expect(write).toHaveBeenCalledTimes(1) + expect(write.mock.calls[0][0]).toContain('event: order:new') + cleanup() + }) + + it('ignores order:created for non-admin', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 }) + expect(write).not.toHaveBeenCalled() + cleanup() + }) + + it('cleanup removes all listeners', () => { + const cleanup = buildSseListeners('user-1', false, eventBus, write) + cleanup() + eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' }) + expect(write).not.toHaveBeenCalled() + }) +}) + +describe('GET /api/sse/stream (integration)', () => { + let app + + beforeAll(async () => { + app = Fastify({ logger: false }) + app.decorate('authenticate', async function (request, reply) { + try { + const token = request.query?.token + if (!token) throw new Error('no token') + if (token === 'user-token') { + request.user = { sub: 'user-1', email: 'user@test.com' } + } else if (token === 'admin-token') { + request.user = { sub: 'admin-1', email: process.env.ADMIN_EMAIL || 'admin@test.com' } + } else { + throw new Error('bad token') + } + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', new EventEmitter()) + await registerSseRoutes(app) + await app.ready() + }) + + afterAll(async () => { + await app.close() + }) + + it('returns 401 without token', async () => { + const res = await app.inject({ method: 'GET', url: '/api/sse/stream' }) + expect(res.statusCode).toBe(401) + }) + + it('returns 200 and event-stream headers for authenticated user', async () => { + const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token', payloadAsStream: true }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe('text/event-stream') + expect(res.headers['cache-control']).toBe('no-cache') + expect(res.headers['connection']).toBe('keep-alive') + }) + + it('sends initial heartbit', async () => { + const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token', payloadAsStream: true }) + const body = res.stream().read().toString() + expect(body).toContain(':heartbit') + }) +}) diff --git a/server/src/routes/__tests__/user-orders.test.js b/server/src/routes/__tests__/user-orders.test.js new file mode 100755 index 0000000..c03d62d --- /dev/null +++ b/server/src/routes/__tests__/user-orders.test.js @@ -0,0 +1,88 @@ +import { randomUUID } from 'node:crypto' +import jwt from '@fastify/jwt' +import Fastify from 'fastify' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' +import { prisma } from '../../lib/prisma.js' +import { registerUserOrderRoutes } from '../user-orders.js' + +const JWT_SECRET = 'test-secret' + +let app +let testUser +let testUserEmail + +async function buildApp() { + const fastify = Fastify({ logger: false }) + await fastify.register(jwt, { secret: JWT_SECRET }) + fastify.decorate('authenticate', async function (request, reply) { + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + fastify.decorate('eventBus', { emit: vi.fn() }) + await registerUserOrderRoutes(fastify) + await fastify.ready() + return fastify +} + +async function signToken(user) { + return app.jwt.sign({ sub: user.id, email: user.email }) +} + +async function createOrder(data = {}) { + return prisma.order.create({ + data: { + userId: testUser.id, + status: 'SHIPPED', + deliveryType: 'delivery', + deliveryFeeLocked: true, + paymentMethod: 'online', + itemsSubtotalCents: 10000, + deliveryFeeCents: 50000, + totalCents: 60000, + currency: 'RUB', + ...data, + }, + }) +} + +describe('user order routes', () => { + beforeEach(async () => { + testUserEmail = `user-orders-${randomUUID()}@example.com` + testUser = await prisma.user.create({ data: { email: testUserEmail } }) + app = await buildApp() + }) + + afterEach(async () => { + await app?.close() + if (testUser?.id) { + await prisma.order.deleteMany({ where: { userId: testUser.id } }) + await prisma.user.deleteMany({ where: { id: testUser.id } }) + } else if (testUserEmail) { + await prisma.user.deleteMany({ where: { email: testUserEmail } }) + } + vi.clearAllMocks() + }) + + it('emits order status event when user confirms receiving an order', async () => { + const order = await createOrder() + const token = await signToken(testUser) + + const res = await app.inject({ + method: 'POST', + url: `/api/me/orders/${order.id}/confirm-received`, + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.statusCode).toBe(200) + expect(app.eventBus.emit).toHaveBeenCalledWith(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, { + orderId: order.id, + userId: testUser.id, + oldStatus: 'SHIPPED', + newStatus: 'DONE', + }) + }) +}) diff --git a/server/src/routes/__tests__/user-payments.test.js b/server/src/routes/__tests__/user-payments.test.js new file mode 100755 index 0000000..d46af81 --- /dev/null +++ b/server/src/routes/__tests__/user-payments.test.js @@ -0,0 +1,245 @@ +import jwt from '@fastify/jwt' +import Fastify from 'fastify' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { prisma } from '../../lib/prisma.js' +import { registerUserPaymentRoutes } from '../user-payments.js' + +const JWT_SECRET = 'test-secret' +const TEST_USER_EMAIL = `test-pay-${Date.now()}@example.com` + +let testUserId +let testOrderId + +async function signToken(userId, email = TEST_USER_EMAIL) { + const fastify = Fastify() + await fastify.register(jwt, { secret: JWT_SECRET }) + await fastify.ready() + return fastify.jwt.sign({ sub: userId, email }) +} + +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 registerUserPaymentRoutes(app) + await app.ready() + return app +} + +describe('POST /api/me/orders/:id/pay', () => { + let app + + beforeAll(async () => { + await prisma.payment.deleteMany() + await prisma.order.deleteMany({ where: { user: { email: TEST_USER_EMAIL } } }) + await prisma.user.deleteMany({ where: { email: TEST_USER_EMAIL } }) + + const user = await prisma.user.create({ + data: { email: TEST_USER_EMAIL }, + }) + testUserId = user.id + + const order = await prisma.order.create({ + data: { + userId: testUserId, + status: 'PENDING_PAYMENT', + paymentMethod: 'online', + deliveryFeeLocked: true, + totalCents: 100000, + currency: 'RUB', + deliveryFeeCents: 0, + }, + }) + testOrderId = order.id + }) + + afterAll(async () => { + await prisma.payment.deleteMany({ where: { orderId: testOrderId } }) + await prisma.order.deleteMany({ where: { userId: testUserId } }) + await prisma.user.deleteMany({ where: { email: TEST_USER_EMAIL } }) + }) + + beforeEach(async () => { + await prisma.order.update({ + where: { id: testOrderId }, + data: { + status: 'PENDING_PAYMENT', + paymentMethod: 'online', + deliveryFeeLocked: true, + }, + }) + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + vi.restoreAllMocks() + }) + + it('returns 401 without auth', async () => { + const res = await app.inject({ + method: 'POST', + url: `/api/me/orders/${testOrderId}/pay`, + }) + expect(res.statusCode).toBe(401) + }) + + it('returns 404 when order not found', async () => { + const token = await signToken(testUserId) + const res = await app.inject({ + method: 'POST', + url: '/api/me/orders/nonexistent-id/pay', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(404) + }) + + it('returns 409 when payment method is on_pickup', async () => { + await prisma.order.update({ + where: { id: testOrderId }, + data: { paymentMethod: 'on_pickup' }, + }) + const token = await signToken(testUserId) + const res = await app.inject({ + method: 'POST', + url: `/api/me/orders/${testOrderId}/pay`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(409) + }) + + it('returns 409 when order not in PENDING_PAYMENT status', async () => { + await prisma.order.update({ + where: { id: testOrderId }, + data: { status: 'PAID' }, + }) + const token = await signToken(testUserId) + const res = await app.inject({ + method: 'POST', + url: `/api/me/orders/${testOrderId}/pay`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(409) + }) + + it('returns 409 when deliveryFeeLocked is false', async () => { + await prisma.order.update({ + where: { id: testOrderId }, + data: { deliveryFeeLocked: false }, + }) + const token = await signToken(testUserId) + const res = await app.inject({ + method: 'POST', + url: `/api/me/orders/${testOrderId}/pay`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(409) + }) + + it('returns 422 when user has no email', async () => { + const noEmailUser = await prisma.user.create({ + data: { email: `noemail-${Date.now()}@test.com` }, + }) + const noEmailOrder = await prisma.order.create({ + data: { + userId: noEmailUser.id, + status: 'PENDING_PAYMENT', + paymentMethod: 'online', + deliveryFeeLocked: true, + totalCents: 100000, + currency: 'RUB', + }, + }) + + const fastify = Fastify() + await fastify.register(jwt, { secret: JWT_SECRET }) + const token = fastify.jwt.sign({ sub: noEmailUser.id }) + await fastify.close() + + const res = await app.inject({ + method: 'POST', + url: `/api/me/orders/${noEmailOrder.id}/pay`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(422) + + await prisma.order.deleteMany({ where: { userId: noEmailUser.id } }) + await prisma.user.deleteMany({ where: { id: noEmailUser.id } }) + }) +}) + +describe('GET /api/me/orders/:orderId/payment', () => { + let app + let getTestUserId + let getTestOrderId + + beforeAll(async () => { + const getEmail = `get-pay-${Date.now()}@example.com` + const user = await prisma.user.create({ data: { email: getEmail } }) + getTestUserId = user.id + + const order = await prisma.order.create({ + data: { + userId: getTestUserId, + status: 'PENDING_PAYMENT', + paymentMethod: 'online', + deliveryFeeLocked: true, + totalCents: 100000, + currency: 'RUB', + }, + }) + getTestOrderId = order.id + }) + + afterAll(async () => { + await prisma.payment.deleteMany({ where: { orderId: getTestOrderId } }) + await prisma.order.deleteMany({ where: { userId: getTestUserId } }) + await prisma.user.deleteMany({ where: { id: getTestUserId } }) + }) + + beforeEach(async () => { + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + }) + + it('returns 401 without auth', async () => { + const res = await app.inject({ + method: 'GET', + url: `/api/me/orders/${getTestOrderId}/payment`, + }) + expect(res.statusCode).toBe(401) + }) + + it('returns 404 when order not found', async () => { + const token = await signToken(getTestUserId) + const res = await app.inject({ + method: 'GET', + url: '/api/me/orders/nonexistent-id/payment', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(404) + }) + + it('returns status null when no payment exists', async () => { + const token = await signToken(getTestUserId) + const res = await app.inject({ + method: 'GET', + url: `/api/me/orders/${getTestOrderId}/payment`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.payload) + expect(body.status).toBeNull() + expect(body.paid).toBe(false) + }) +}) diff --git a/server/src/routes/__tests__/webhook-yookassa.test.js b/server/src/routes/__tests__/webhook-yookassa.test.js new file mode 100755 index 0000000..4227434 --- /dev/null +++ b/server/src/routes/__tests__/webhook-yookassa.test.js @@ -0,0 +1,133 @@ +import Fastify from 'fastify' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' + +const { mockPrisma } = vi.hoisted(() => ({ + mockPrisma: { + payment: { findFirst: vi.fn(), update: vi.fn() }, + order: { findFirst: vi.fn(), updateMany: vi.fn() }, + }, +})) + +vi.mock('../../lib/prisma.js', () => ({ + prisma: mockPrisma, +})) + +vi.mock('../../lib/yookassa.js', () => ({ + validateWebhook: vi.fn(), +})) + +import { validateWebhook } from '../../lib/yookassa.js' +import { registerYookassaWebhookRoute } from '../webhook-yookassa.js' + +function buildApp(eventBusMock) { + const app = Fastify({ logger: false }) + app.decorate('eventBus', eventBusMock || { emit: () => {} }) + return app +} + +describe('POST /api/webhooks/yookassa', () => { + let app + let eventBus + + beforeEach(async () => { + eventBus = { emit: vi.fn() } + validateWebhook.mockImplementation((_ip, body) => { + 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 } + }) + app = buildApp(eventBus) + await registerYookassaWebhookRoute(app) + await app.ready() + }) + + afterEach(async () => { + await app.close() + vi.clearAllMocks() + }) + + it('returns 400 for invalid body', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks/yookassa', + payload: { not: 'valid' }, + }) + expect(res.statusCode).toBe(400) + }) + + it('returns 404 when payment not found', async () => { + mockPrisma.payment.findFirst.mockResolvedValue(null) + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks/yookassa', + payload: { + type: 'notification', + event: 'payment.succeeded', + object: { id: 'unknown-id', status: 'succeeded', paid: true }, + }, + }) + expect(res.statusCode).toBe(404) + }) + + it('updates payment and order on payment.succeeded', async () => { + mockPrisma.payment.findFirst.mockResolvedValue({ + id: 'payment-1', + yookassaPaymentId: 'yk-id', + status: 'pending', + orderId: 'order-1', + }) + mockPrisma.payment.update.mockResolvedValue({}) + mockPrisma.order.findFirst.mockResolvedValue({ + id: 'order-1', + status: 'PENDING_PAYMENT', + userId: 'user-1', + }) + mockPrisma.order.updateMany.mockResolvedValue({ count: 1 }) + + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks/yookassa', + payload: { + type: 'notification', + event: 'payment.succeeded', + object: { id: 'yk-id', status: 'succeeded', paid: true }, + }, + }) + expect(res.statusCode).toBe(200) + + const updateData = mockPrisma.payment.update.mock.calls[0][0].data + expect(updateData.status).toBe('succeeded') + + const orderUpdateData = mockPrisma.order.updateMany.mock.calls[0][0].data + expect(orderUpdateData.status).toBe('PAID') + expect(eventBus.emit).toHaveBeenCalledWith(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId: 'order-1', + userId: 'user-1', + paymentStatus: 'paid', + }) + }) + + it('updates payment on payment.canceled without changing order', async () => { + mockPrisma.payment.findFirst.mockResolvedValue({ + id: 'payment-1', + yookassaPaymentId: 'yk-id', + status: 'pending', + orderId: 'order-1', + }) + mockPrisma.payment.update.mockResolvedValue({}) + + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks/yookassa', + payload: { + type: 'notification', + event: 'payment.canceled', + object: { id: 'yk-id', status: 'canceled', paid: false }, + }, + }) + expect(res.statusCode).toBe(200) + expect(mockPrisma.order.findFirst).not.toHaveBeenCalled() + }) +}) diff --git a/server/src/routes/api.js b/server/src/routes/api.js new file mode 100755 index 0000000..4cdcfbc --- /dev/null +++ b/server/src/routes/api.js @@ -0,0 +1,42 @@ +import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js' +import { registerAdminNotificationRoutes } from './api/admin/notifications.js' +import { registerAdminTestChecklistRoutes } from './api/admin/test-checklist.js' +import { registerAdminCategoryRoutes } from './api/admin-categories.js' +import { registerAdminGalleryRoutes } from './api/admin-gallery.js' +import { registerAdminOrderRoutes } from './api/admin-orders.js' +import { registerAdminProductRoutes } from './api/admin-products.js' +import { registerAdminProfileRoutes } from './api/admin-profile.js' +import { registerAdminReviewRoutes } from './api/admin-reviews.js' +import { registerAdminUserRoutes } from './api/admin-users.js' +import { registerCatalogSliderRoutes } from './api/catalog-slider.js' +import { registerPublicCatalogRoutes } from './api/public-catalog.js' +import { registerPublicReviewRoutes } from './api/public-reviews.js' +import { registerAuthOAuthRoutes } from './auth-oauth.js' +import { registerAuthPasswordRoutes } from './auth-password.js' +import { registerAuthSessionRoutes } from './auth-session.js' +import { registerAuthRoutes } from './auth.js' + +export async function registerApiRoutes(fastify) { + fastify.decorate('slugify', slugify) + fastify.decorate('parseMaterialsInput', parseMaterialsInput) + fastify.decorate('mapProductForApi', mapProductForApi) + + await registerPublicCatalogRoutes(fastify) + await registerPublicReviewRoutes(fastify) + await registerCatalogSliderRoutes(fastify) + + await registerAdminProductRoutes(fastify) + await registerAdminGalleryRoutes(fastify) + await registerAdminCategoryRoutes(fastify) + await registerAdminOrderRoutes(fastify) + await registerAdminReviewRoutes(fastify) + await registerAdminUserRoutes(fastify) + await registerAdminNotificationRoutes(fastify) + await registerAdminTestChecklistRoutes(fastify) + await registerAdminProfileRoutes(fastify) + + await registerAuthRoutes(fastify) + await registerAuthSessionRoutes(fastify) + await registerAuthPasswordRoutes(fastify) + await registerAuthOAuthRoutes(fastify) +} diff --git a/server/src/routes/api/__tests__/admin-gallery.test.js b/server/src/routes/api/__tests__/admin-gallery.test.js new file mode 100755 index 0000000..5cce035 --- /dev/null +++ b/server/src/routes/api/__tests__/admin-gallery.test.js @@ -0,0 +1,65 @@ +import fs from 'node:fs' +import path from 'node:path' +import { describe, it, expect, beforeAll, afterAll } from 'vitest' + +const UPLOADS_DIR = path.join(process.cwd(), 'uploads') + +import { generateAllSizes, convertOriginalToWebp } from '../../../lib/image-resize.js' + +describe('Admin gallery resize integration', () => { + const testUuid = 'gallery-test-resize-uuid' + const testOriginalPath = path.join(UPLOADS_DIR, `${testUuid}.png`) + + beforeAll(async () => { + const sharp = (await import('sharp')).default + await sharp({ create: { width: 200, height: 200, channels: 3, background: { r: 255, g: 0, b: 0 } } }) + .png() + .toFile(testOriginalPath) + }) + + afterAll(async () => { + await fs.promises.unlink(testOriginalPath).catch(() => {}) + const webpPath = path.join(UPLOADS_DIR, `${testUuid}.webp`) + await fs.promises.unlink(webpPath).catch(() => {}) + const cacheDir = path.join(UPLOADS_DIR, '.cache') + for (const width of [320, 640, 1024, 1600]) { + for (const format of ['avif', 'webp']) { + await fs.promises.unlink(path.join(cacheDir, `${testUuid}_w${width}.${format}`)).catch(() => {}) + } + } + }) + + it('generateAllSizes + convertOriginalToWebp works on raw upload', async () => { + await generateAllSizes(testUuid, '', testOriginalPath) + const newUrl = await convertOriginalToWebp(testUuid, '') + + expect(newUrl).toBe(`/uploads/${testUuid}.webp`) + + // Verify original PNG is deleted + const pngExists = await fs.promises + .access(testOriginalPath) + .then(() => true) + .catch(() => false) + expect(pngExists).toBe(false) + + // Verify cached files exist + 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, `${testUuid}_w${width}.${format}`) + const exists = await fs.promises + .access(cachePath) + .then(() => true) + .catch(() => false) + expect(exists).toBe(true) + } + } + + // Verify webp original exists + const webpExists = await fs.promises + .access(path.join(UPLOADS_DIR, `${testUuid}.webp`)) + .then(() => true) + .catch(() => false) + expect(webpExists).toBe(true) + }) +}) diff --git a/server/src/routes/api/__tests__/admin-orders.test.js b/server/src/routes/api/__tests__/admin-orders.test.js new file mode 100755 index 0000000..920cfab --- /dev/null +++ b/server/src/routes/api/__tests__/admin-orders.test.js @@ -0,0 +1,118 @@ +import jwt from '@fastify/jwt' +import Fastify from 'fastify' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { prisma } from '../../../lib/prisma.js' +import { registerAdminOrderRoutes } from '../admin-orders.js' + +const JWT_SECRET = 'test-secret' +const ADMIN_EMAIL = `admin-orders-${Date.now()}@example.com` +const USER_EMAIL = `admin-orders-user-${Date.now()}@example.com` + +let app +let adminUser +let buyer + +async function signToken(user) { + return app.jwt.sign({ sub: user.id, email: user.email }) +} + +async function buildApp() { + const fastify = Fastify({ logger: false }) + await fastify.register(jwt, { secret: JWT_SECRET }) + fastify.decorate('eventBus', { emit: vi.fn() }) + fastify.decorate('verifyAdmin', async (request, reply) => { + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + if (request.user.email !== ADMIN_EMAIL) { + return reply.code(401).send({ error: 'Admin only' }) + } + }) + await registerAdminOrderRoutes(fastify) + await fastify.ready() + return fastify +} + +async function createOrder(data = {}) { + return prisma.order.create({ + data: { + userId: buyer.id, + status: 'PENDING_PAYMENT', + deliveryType: 'delivery', + deliveryFeeLocked: false, + paymentMethod: 'online', + itemsSubtotalCents: 10000, + deliveryFeeCents: 50000, + totalCents: 60000, + currency: 'RUB', + ...data, + }, + }) +} + +describe('admin order routes', () => { + beforeAll(async () => { + await prisma.payment.deleteMany() + await prisma.order.deleteMany({ where: { user: { email: { in: [ADMIN_EMAIL, USER_EMAIL] } } } }) + await prisma.user.deleteMany({ where: { email: { in: [ADMIN_EMAIL, USER_EMAIL] } } }) + + adminUser = await prisma.user.create({ data: { email: ADMIN_EMAIL } }) + buyer = await prisma.user.create({ data: { email: USER_EMAIL } }) + }) + + beforeEach(async () => { + await prisma.order.deleteMany({ where: { userId: buyer.id } }) + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + vi.clearAllMocks() + }) + + afterAll(async () => { + await prisma.order.deleteMany({ where: { userId: buyer.id } }) + await prisma.user.deleteMany({ where: { id: { in: [adminUser.id, buyer.id] } } }) + }) + + it('summary counts only delivery orders waiting for price approval', async () => { + const token = await signToken(adminUser) + const beforeRes = await app.inject({ + method: 'GET', + url: '/api/admin/orders/summary', + headers: { authorization: `Bearer ${token}` }, + }) + const baseline = beforeRes.json().attentionCount + + await createOrder({ deliveryFeeLocked: false, deliveryType: 'delivery' }) + await createOrder({ deliveryFeeLocked: true, deliveryType: 'delivery' }) + await createOrder({ deliveryFeeLocked: false, deliveryType: 'pickup' }) + await createOrder({ deliveryFeeLocked: false, deliveryType: 'delivery', status: 'PAID' }) + + const res = await app.inject({ + method: 'GET', + url: '/api/admin/orders/summary', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ attentionCount: baseline + 1 }) + }) + + it('rejects PAID transition while delivery fee is not locked', async () => { + const order = await createOrder({ deliveryFeeLocked: false, deliveryType: 'delivery' }) + const token = await signToken(adminUser) + + const res = await app.inject({ + method: 'PATCH', + url: `/api/admin/orders/${order.id}/status`, + headers: { authorization: `Bearer ${token}` }, + payload: { status: 'PAID' }, + }) + + expect(res.statusCode).toBe(409) + expect(res.json().error).toContain('стоимость доставки') + }) +}) diff --git a/server/src/routes/api/__tests__/admin-products.test.js b/server/src/routes/api/__tests__/admin-products.test.js new file mode 100755 index 0000000..2deecbd --- /dev/null +++ b/server/src/routes/api/__tests__/admin-products.test.js @@ -0,0 +1,96 @@ +import jwt from '@fastify/jwt' +import Fastify from 'fastify' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { prisma } from '../../../lib/prisma.js' +import { mapProductForApi, parseMaterialsInput, slugify } from '../_product-helpers.js' +import { registerAdminProductRoutes } from '../admin-products.js' + +const JWT_SECRET = 'test-secret' +const ADMIN_EMAIL = `admin-products-${Date.now()}@example.com` + +let app +let adminUser +let category + +async function signToken(user) { + return app.jwt.sign({ sub: user.id, email: user.email }) +} + +async function buildApp() { + const fastify = Fastify({ logger: false }) + await fastify.register(jwt, { secret: JWT_SECRET }) + fastify.decorate('verifyAdmin', async (request, reply) => { + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + if (request.user.email !== ADMIN_EMAIL) { + return reply.code(401).send({ error: 'Admin only' }) + } + }) + fastify.decorate('slugify', slugify) + fastify.decorate('parseMaterialsInput', parseMaterialsInput) + fastify.decorate('mapProductForApi', mapProductForApi) + await registerAdminProductRoutes(fastify) + await fastify.ready() + return fastify +} + +function productData(overrides = {}) { + return { + title: 'Медведь', + slug: `bear-${Date.now()}`, + priceCents: 120000, + quantity: 1, + categoryId: category.id, + published: true, + ...overrides, + } +} + +describe('admin product routes', () => { + beforeAll(async () => { + await prisma.product.deleteMany({ where: { category: { slug: { startsWith: 'admin-products-test-' } } } }) + await prisma.category.deleteMany({ where: { slug: { startsWith: 'admin-products-test-' } } }) + await prisma.user.deleteMany({ where: { email: ADMIN_EMAIL } }) + + adminUser = await prisma.user.create({ data: { email: ADMIN_EMAIL } }) + category = await prisma.category.create({ + data: { + name: 'Тестовая категория', + slug: `admin-products-test-${Date.now()}`, + }, + }) + }) + + beforeEach(async () => { + await prisma.product.deleteMany({ where: { categoryId: category.id } }) + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + }) + + afterAll(async () => { + await prisma.product.deleteMany({ where: { categoryId: category.id } }) + await prisma.category.delete({ where: { id: category.id } }) + await prisma.user.delete({ where: { id: adminUser.id } }) + }) + + it('генерирует уникальный slug при создании товара с повторяющимся названием без ручного slug', async () => { + await prisma.product.create({ data: productData({ title: 'Bear', slug: 'bear' }) }) + const token = await signToken(adminUser) + + const res = await app.inject({ + method: 'POST', + url: '/api/admin/products', + headers: { authorization: `Bearer ${token}` }, + payload: productData({ title: 'Bear', slug: undefined }), + }) + + expect(res.statusCode).toBe(201) + expect(res.json().slug).toBe('bear-2') + }) +}) diff --git a/server/src/routes/api/_product-helpers.js b/server/src/routes/api/_product-helpers.js new file mode 100755 index 0000000..082dd7b --- /dev/null +++ b/server/src/routes/api/_product-helpers.js @@ -0,0 +1,55 @@ +import path from 'node:path' + +export function slugify(input) { + return String(input || '') + .toLowerCase() + .trim() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/gi, '') +} + +export function safeExtFromFilename(filename) { + const ext = path.extname(String(filename || '')).toLowerCase() + const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp']) + return allowed.has(ext) ? ext : null +} + +export function parseMaterialsInput(input) { + if (Array.isArray(input)) { + return input + .map((x) => String(x || '').trim()) + .filter(Boolean) + .slice(0, 30) + } + if (typeof input === 'string') { + const s = input.trim() + if (!s) return [] + return s + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + .slice(0, 30) + } + return [] +} + +export function materialsFromDb(materials) { + if (Array.isArray(materials)) return materials + try { + const v = JSON.parse(String(materials || '[]')) + return Array.isArray(v) ? v.map((x) => String(x || '').trim()).filter(Boolean) : [] + } catch { + return [] + } +} + +export function mapProductForApi(p, reviewsSummary = null) { + const base = { + ...p, + materials: materialsFromDb(p.materials), + } + if (reviewsSummary && typeof reviewsSummary === 'object') { + base.reviewsSummary = reviewsSummary + } + return base +} diff --git a/server/src/routes/api/admin-categories.js b/server/src/routes/api/admin-categories.js new file mode 100755 index 0000000..dff4775 --- /dev/null +++ b/server/src/routes/api/admin-categories.js @@ -0,0 +1,138 @@ +import { asyncHandler } from '../../lib/async-handler.js' +import { + getOrCreateUnspecifiedCategory, + isUnspecifiedCategorySlug, + UNSPECIFIED_CATEGORY_SLUG, +} from '../../lib/default-category.js' +import { prisma } from '../../lib/prisma.js' + +export async function registerAdminCategoryRoutes(fastify) { + fastify.get( + '/api/admin/categories', + { preHandler: [fastify.verifyAdmin] }, + asyncHandler(async (request, reply) => { + const items = await prisma.category.findMany({ + orderBy: [{ sort: 'asc' }, { name: 'asc' }], + }) + return { items } + }), + ) + + fastify.post( + '/api/admin/categories', + { preHandler: [fastify.verifyAdmin] }, + asyncHandler(async (request, reply) => { + const body = request.body ?? {} + const name = String(body.name ?? '').trim() + if (!name) { + reply.code(400).send({ error: 'Укажите название категории' }) + return + } + const slug = String(body.slug ?? '').trim() || request.server.slugify(name) || `cat-${Date.now()}` + if (isUnspecifiedCategorySlug(slug)) { + reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) + return + } + const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined + const exists = await prisma.category.findUnique({ where: { slug } }) + if (exists) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + const category = await prisma.category.create({ + data: { + name, + slug, + sort: Number.isFinite(sort) ? Math.round(sort) : 0, + }, + }) + reply.code(201).send(category) + }), + ) + + fastify.patch( + '/api/admin/categories/:id', + { preHandler: [fastify.verifyAdmin] }, + asyncHandler(async (request, reply) => { + const { id } = request.params + const body = request.body ?? {} + const existing = await prisma.category.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Категория не найдена' }) + return + } + + const data = {} + if (body.name !== undefined) data.name = String(body.name ?? '').trim() + if (body.sort !== undefined) { + const s = Number(body.sort) + if (!Number.isFinite(s)) { + reply.code(400).send({ error: 'Некорректный sort' }) + return + } + data.sort = Math.round(s) + } + if (body.slug !== undefined) { + const s = String(body.slug ?? '').trim() + if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) { + reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' }) + return + } + if (!s) { + reply.code(400).send({ error: 'Slug не может быть пустым' }) + return + } + if (s !== existing.slug) { + if (isUnspecifiedCategorySlug(s)) { + reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) + return + } + const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } }) + if (clash) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + } + data.slug = s + } + + if (Object.keys(data).length === 0) { + return existing + } + if (data.name !== undefined && !data.name) { + reply.code(400).send({ error: 'Укажите название' }) + return + } + + const updated = await prisma.category.update({ where: { id }, data }) + return updated + }), + ) + + fastify.delete( + '/api/admin/categories/:id', + { preHandler: [fastify.verifyAdmin] }, + asyncHandler(async (request, reply) => { + const { id } = request.params + const existing = await prisma.category.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Категория не найдена' }) + return + } + if (isUnspecifiedCategorySlug(existing.slug)) { + reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' }) + return + } + + const fallback = await getOrCreateUnspecifiedCategory() + await prisma.$transaction([ + prisma.product.updateMany({ + where: { categoryId: id }, + data: { categoryId: fallback.id }, + }), + prisma.category.delete({ where: { id } }), + ]) + return reply.code(204).send() + }), + ) +} diff --git a/server/src/routes/api/admin-gallery.js b/server/src/routes/api/admin-gallery.js new file mode 100755 index 0000000..c984824 --- /dev/null +++ b/server/src/routes/api/admin-gallery.js @@ -0,0 +1,134 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { asyncHandler } from '../../lib/async-handler.js' +import { prisma } from '../../lib/prisma.js' +import { persistMultipartImages } from '../../lib/upload-images.js' +import { + formatFileTooLargeMessage, + getProductImageMaxFileBytes, + isMultipartFileTooLargeError, +} from '../../lib/upload-limits.js' + +export async function registerAdminGalleryRoutes(fastify) { + fastify.get('/api/admin/gallery', { preHandler: [fastify.verifyAdmin] }, async () => { + const items = await prisma.galleryImage.findMany({ + orderBy: { createdAt: 'desc' }, + }) + + const urls = items.map((i) => i.url) + const usedUrls = new Set() + + const productImages = await prisma.productImage.findMany({ + where: { url: { in: urls } }, + select: { url: true }, + }) + for (const pi of productImages) { + usedUrls.add(pi.url) + } + + const legacyProducts = await prisma.product.findMany({ + where: { imageUrl: { in: urls } }, + select: { imageUrl: true }, + }) + for (const p of legacyProducts) { + if (p.imageUrl) usedUrls.add(p.imageUrl) + } + + return { + items: items.map((i) => ({ + id: i.id, + url: i.url, + isResized: i.isResized, + createdAt: i.createdAt, + inUse: usedUrls.has(i.url), + })), + } + }) + + fastify.post('/api/admin/gallery/upload', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + try { + const urls = await persistMultipartImages(request, { + maxFiles: 10, + maxFileBytes: getProductImageMaxFileBytes(), + subdir: '', + eager: false, + }) + for (const url of urls) { + await prisma.galleryImage.create({ + data: { url, isResized: false }, + }) + } + return { urls } + } catch (error) { + let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы' + let statusCode = + error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) + ? Number(error.statusCode) + : 400 + if (isMultipartFileTooLargeError(error)) { + message = formatFileTooLargeMessage(getProductImageMaxFileBytes()) + statusCode = 413 + } + return reply.code(statusCode).send({ error: message }) + } + }) + + fastify.post( + '/api/admin/gallery/:id/resize', + { preHandler: [fastify.verifyAdmin] }, + asyncHandler(async (request, reply) => { + const { id } = request.params + const row = await prisma.galleryImage.findUnique({ where: { id } }) + if (!row) { + return reply.code(404).send({ error: 'Изображение не найдено' }) + } + if (row.isResized) { + return reply.code(409).send({ error: 'Изображение уже обработано' }) + } + + const urlParts = row.url.replace(/^\//, '').split('/') + const fileName = urlParts[urlParts.length - 1] + const uuid = path.parse(fileName).name + + const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js') + + const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName) + await generateAllSizes(uuid, '', fullPath) + const newUrl = await convertOriginalToWebp(uuid, '') + + await prisma.galleryImage.update({ + where: { id }, + data: { url: newUrl, isResized: true }, + }) + + return { url: newUrl } + }), + ) + + fastify.delete('/api/admin/gallery/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const row = await prisma.galleryImage.findUnique({ where: { id } }) + if (!row) { + return reply.code(404).send({ error: 'Не найдено' }) + } + + const usedInImages = await prisma.productImage.count({ where: { url: row.url } }) + const usedAsLegacy = await prisma.product.count({ where: { imageUrl: row.url } }) + if (usedInImages > 0 || usedAsLegacy > 0) { + return reply.code(409).send({ error: 'Изображение используется в карточке товара' }) + } + + const relative = row.url.replace(/^\//, '') + const filePath = path.join(process.cwd(), relative) + try { + await fs.unlink(filePath) + } catch (err) { + if (err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT') { + throw err + } + } + + await prisma.galleryImage.delete({ where: { id } }) + return reply.code(204).send() + }) +} diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js new file mode 100755 index 0000000..01ee1c7 --- /dev/null +++ b/server/src/routes/api/admin-orders.js @@ -0,0 +1,182 @@ +import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' +import { canTransitionAdminOrderStatus } from '../../lib/order-status.js' +import { prisma } from '../../lib/prisma.js' + +export async function registerAdminOrderRoutes(fastify) { + fastify.get('/api/admin/orders/summary', { preHandler: [fastify.verifyAdmin] }, async () => { + const attentionCount = await prisma.order.count({ + where: { + status: 'PENDING_PAYMENT', + deliveryType: 'delivery', + deliveryFeeLocked: false, + }, + }) + return { attentionCount } + }) + + fastify.get('/api/admin/orders', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const status = typeof request.query?.status === 'string' ? request.query.status.trim() : '' + const q = typeof request.query?.q === 'string' ? request.query.q.trim() : '' + const deliveryTypeRaw = request.query?.deliveryType + const deliveryType = typeof deliveryTypeRaw === 'string' ? deliveryTypeRaw.trim() : '' + + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 + if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) + + const where = {} + if (status) where.status = status + if (deliveryType) { + if (deliveryType !== 'delivery' && deliveryType !== 'pickup') { + return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' }) + } + where.deliveryType = deliveryType + } + if (q) { + where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }] + } + + const total = await prisma.order.count({ where }) + const items = await prisma.order.findMany({ + where, + include: { user: { select: { id: true, email: true } }, items: true }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }) + + return { + items: items.map((o) => ({ + id: o.id, + status: o.status, + deliveryType: o.deliveryType, + deliveryFeeLocked: o.deliveryFeeLocked, + deliveryCarrier: o.deliveryCarrier, + paymentMethod: o.paymentMethod, + totalCents: o.totalCents, + currency: o.currency, + createdAt: o.createdAt, + updatedAt: o.updatedAt, + user: o.user, + itemsCount: o.items.reduce((s, i) => s + i.qty, 0), + })), + total, + page, + pageSize, + } + }) + + fastify.get('/api/admin/orders/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const order = await prisma.order.findUnique({ + where: { id }, + include: { + user: { + select: { id: true, email: true, displayName: true, avatar: true, avatarStyle: true }, + }, + items: true, + messages: { orderBy: { createdAt: 'asc' } }, + }, + }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + return { item: order } + }) + + fastify.patch('/api/admin/orders/:id/status', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const next = String(request.body?.status || '').trim() + if (!next) return reply.code(400).send({ error: 'status обязателен' }) + + const existing = await prisma.order.findUnique({ where: { id } }) + if (!existing) return reply.code(404).send({ error: 'Заказ не найден' }) + if (!canTransitionAdminOrderStatus(existing, next)) { + return reply.code(409).send({ + error: `Нельзя сменить статус ${existing.status} → ${next}`, + }) + } + if (next === 'PAID' && existing.deliveryType === 'delivery' && existing.deliveryFeeLocked === false) { + return reply.code(409).send({ + error: 'Сначала подтвердите итоговую стоимость доставки', + }) + } + + const updated = await prisma.order.update({ + where: { id }, + data: { status: next }, + }) + + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, { + orderId: updated.id, + userId: existing.userId, + oldStatus: existing.status, + newStatus: next, + }) + + return { item: updated } + }) + + fastify.patch('/api/admin/orders/:id/delivery-fee', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const feeRaw = request.body?.deliveryFeeCents + const parsed = typeof feeRaw === 'string' ? Number.parseInt(feeRaw, 10) : typeof feeRaw === 'number' ? feeRaw : NaN + if (!Number.isInteger(parsed) || parsed < 0) { + return reply.code(400).send({ + error: 'deliveryFeeCents должно быть целым числом ≥ 0 (копейки)', + }) + } + + const existing = await prisma.order.findUnique({ where: { id } }) + if (!existing) return reply.code(404).send({ error: 'Заказ не найден' }) + if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) { + return reply.code(409).send({ + error: 'Корректировка доставки доступна только пока стоимость не утверждена', + }) + } + + const totalCents = existing.itemsSubtotalCents + parsed + const updated = await prisma.order.update({ + where: { id }, + data: { + deliveryFeeCents: parsed, + totalCents, + deliveryFeeLocked: true, + }, + }) + + request.server.eventBus.emit(NOTIFICATION_EVENTS.DELIVERY_FEE_ADJUSTED, { + orderId: updated.id, + userId: existing.userId, + totalCents: updated.totalCents, + }) + + return { item: updated } + }) + + fastify.post('/api/admin/orders/:id/messages', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const text = String(request.body?.text || '').trim() + if (!text) return reply.code(400).send({ error: 'Сообщение пустое' }) + if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' }) + + const order = await prisma.order.findUnique({ where: { id } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + + const msg = await prisma.orderMessage.create({ + data: { orderId: id, authorType: 'admin', text }, + }) + + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY, { + orderId: id, + userId: order.userId, + messageId: msg.id, + preview: text, + }) + + return reply.code(201).send({ item: msg }) + }) +} diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js new file mode 100755 index 0000000..cba3bc0 --- /dev/null +++ b/server/src/routes/api/admin-products.js @@ -0,0 +1,264 @@ +import { prisma } from '../../lib/prisma.js' +import { validateGalleryImages } from '../../lib/validate-gallery-images.js' + +const CREATE_PRODUCT_SCHEMA = { + body: { + type: 'object', + required: ['title', 'priceCents', 'quantity', 'categoryId'], + properties: { + title: { type: 'string', minLength: 1 }, + slug: { type: 'string' }, + categoryId: { type: 'string', minLength: 1 }, + priceCents: { type: 'number', minimum: 0 }, + quantity: { type: 'number', minimum: 0 }, + shortDescription: { type: 'string', nullable: true }, + description: { type: 'string', nullable: true }, + materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] }, + imageUrl: { type: 'string', nullable: true }, + imageUrls: { type: 'array', items: { type: 'string' } }, + published: { type: 'boolean' }, + }, + }, +} + +const PATCH_PRODUCT_SCHEMA = { + body: { + type: 'object', + properties: { + title: { type: 'string', minLength: 1 }, + slug: { type: 'string' }, + categoryId: { type: 'string', minLength: 1 }, + priceCents: { type: 'number', minimum: 0 }, + quantity: { type: 'number', minimum: 0 }, + shortDescription: { type: 'string', nullable: true }, + description: { type: 'string', nullable: true }, + materials: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] }, + imageUrl: { type: 'string', nullable: true }, + imageUrls: { type: 'array', items: { type: 'string' } }, + published: { type: 'boolean' }, + }, + }, +} + +async function buildUniqueProductSlug(baseSlug) { + const base = String(baseSlug || '').trim() + let candidate = base + let suffix = 2 + + while (await prisma.product.findUnique({ where: { slug: candidate } })) { + candidate = `${base}-${suffix}` + suffix += 1 + } + + return candidate +} + +export async function registerAdminProductRoutes(fastify) { + fastify.get('/api/admin/products', { preHandler: [fastify.verifyAdmin] }, async (request) => { + const items = await prisma.product.findMany({ + include: { category: true, images: { orderBy: { sort: 'asc' } } }, + orderBy: { updatedAt: 'desc' }, + }) + return items.map((p) => request.server.mapProductForApi(p)) + }) + + fastify.post( + '/api/admin/products', + { preHandler: [fastify.verifyAdmin], schema: CREATE_PRODUCT_SCHEMA }, + async (request, reply) => { + const body = request.body ?? {} + const title = String(body.title ?? '').trim() + if (!title) { + reply.code(400).send({ error: 'Укажите название' }) + return + } + const requestedSlug = String(body.slug ?? '').trim() + const slugBase = requestedSlug || request.server.slugify(title) || `item-${Date.now()}` + const slug = requestedSlug ? slugBase : await buildUniqueProductSlug(slugBase) + const categoryId = String(body.categoryId ?? '').trim() + if (!categoryId) { + reply.code(400).send({ error: 'Укажите категорию' }) + return + } + const cat = await prisma.category.findUnique({ where: { id: categoryId } }) + if (!cat) { + reply.code(400).send({ error: 'Категория не найдена' }) + return + } + const priceCents = Number(body.priceCents) + if (!Number.isFinite(priceCents) || priceCents <= 0) { + reply.code(400).send({ error: 'Цена должна быть больше 0' }) + return + } + if (priceCents > 10_000_00) { + reply.code(400).send({ error: 'Цена не может превышать 10 000 ₽' }) + return + } + const exists = requestedSlug ? await prisma.product.findUnique({ where: { slug } }) : null + if (exists) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + + if (Array.isArray(body.imageUrls) && body.imageUrls.length > 0) { + const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean) + if (urls.length > 0) { + try { + await validateGalleryImages(prisma, urls) + } catch (err) { + return reply.code(err.statusCode || 400).send({ error: err.message }) + } + } + } + + const n = Number(body.quantity) + if (!Number.isInteger(n) || n < 0 || n > 10) { + reply.code(400).send({ error: 'Количество — целое число от 0 до 10' }) + return + } + const quantity = n + + const product = await prisma.product.create({ + data: { + title, + slug, + shortDescription: body.shortDescription ? String(body.shortDescription) : null, + description: body.description ? String(body.description) : null, + quantity, + materials: JSON.stringify(request.server.parseMaterialsInput(body.materials)), + priceCents: Math.round(priceCents), + imageUrl: body.imageUrl ? String(body.imageUrl) : null, + published: Boolean(body.published), + categoryId, + images: Array.isArray(body.imageUrls) + ? { + create: body.imageUrls + .map((u) => String(u || '').trim()) + .filter(Boolean) + .slice(0, 10) + .map((u, idx) => ({ url: u, sort: idx })), + } + : undefined, + }, + include: { category: true, images: { orderBy: { sort: 'asc' } } }, + }) + reply.code(201).send(request.server.mapProductForApi(product)) + }, + ) + + fastify.patch( + '/api/admin/products/:id', + { preHandler: [fastify.verifyAdmin], schema: PATCH_PRODUCT_SCHEMA }, + async (request, reply) => { + const { id } = request.params + const body = request.body ?? {} + const existing = await prisma.product.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Товар не найден' }) + return + } + const data = {} + if (body.title !== undefined) data.title = String(body.title).trim() + if (body.slug !== undefined) { + const s = String(body.slug).trim() + if (s && s !== existing.slug) { + const clash = await prisma.product.findFirst({ where: { slug: s, NOT: { id } } }) + if (clash) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + data.slug = s + } + } + if (body.shortDescription !== undefined) { + data.shortDescription = body.shortDescription ? String(body.shortDescription) : null + } + if (body.description !== undefined) { + data.description = body.description ? String(body.description) : null + } + if (body.quantity !== undefined) { + const n = Number(body.quantity) + if (!Number.isInteger(n) || n < 0 || n > 10) { + reply.code(400).send({ error: 'Количество — целое число от 0 до 10' }) + return + } + data.quantity = n + } + if (body.materials !== undefined) { + data.materials = JSON.stringify(request.server.parseMaterialsInput(body.materials)) + } + if (body.priceCents !== undefined) { + const p = Number(body.priceCents) + if (!Number.isFinite(p) || p <= 0) { + reply.code(400).send({ error: 'Цена должна быть больше 0' }) + return + } + if (p > 10_000_00) { + reply.code(400).send({ error: 'Цена не может превышать 10 000 ₽' }) + return + } + data.priceCents = Math.round(p) + } + if (body.imageUrl !== undefined) { + data.imageUrl = body.imageUrl ? String(body.imageUrl) : null + } + if (body.published !== undefined) data.published = Boolean(body.published) + if (body.categoryId !== undefined) { + const cid = String(body.categoryId).trim() + if (!cid) { + reply.code(400).send({ error: 'Укажите категорию' }) + return + } + const cat = await prisma.category.findUnique({ where: { id: cid } }) + if (!cat) { + reply.code(400).send({ error: 'Категория не найдена' }) + return + } + data.categoryId = cid + } + + if (body.imageUrls !== undefined && Array.isArray(body.imageUrls)) { + const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean) + if (urls.length > 0) { + try { + await validateGalleryImages(prisma, urls) + } catch (err) { + return reply.code(err.statusCode || 400).send({ error: err.message }) + } + } + } + + const imagesUpdate = + body.imageUrls !== undefined + ? { + deleteMany: {}, + create: Array.isArray(body.imageUrls) + ? body.imageUrls + .map((u) => String(u || '').trim()) + .filter(Boolean) + .slice(0, 10) + .map((u, idx) => ({ url: u, sort: idx })) + : [], + } + : undefined + + const product = await prisma.product.update({ + where: { id }, + data: { ...data, images: imagesUpdate }, + include: { category: true, images: { orderBy: { sort: 'asc' } } }, + }) + return request.server.mapProductForApi(product) + }, + ) + + fastify.delete('/api/admin/products/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + try { + await prisma.product.delete({ where: { id } }) + reply.code(204).send() + } catch (err) { + request.log.error({ err }, '[admin-products] Operation failed') + reply.code(404).send({ error: 'Товар не найден' }) + } + }) +} diff --git a/server/src/routes/api/admin-profile.js b/server/src/routes/api/admin-profile.js new file mode 100755 index 0000000..748d45c --- /dev/null +++ b/server/src/routes/api/admin-profile.js @@ -0,0 +1,69 @@ +import { normalizeEmail } from '../../lib/auth.js' +import { prisma } from '../../lib/prisma.js' + +export async function registerAdminProfileRoutes(fastify) { + fastify.get('/api/admin/profile', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const userId = request.user.sub + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) + return { + id: user.id, + email: user.email, + displayName: user.displayName, + avatar: user.avatar, + avatarStyle: user.avatarStyle, + } + }) + + fastify.get('/api/admin/avatar', async (request, reply) => { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (!adminEmail || !adminEmail.includes('@')) return reply.code(404).send({ error: 'Администратор не найден' }) + + const user = await prisma.user.findUnique({ where: { email: adminEmail } }) + if (!user) return reply.code(404).send({ error: 'Администратор не найден' }) + + return { + avatar: user.avatar, + avatarStyle: user.avatarStyle, + } + }) + + fastify.patch('/api/admin/profile', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const userId = request.user.sub + const nameRaw = request.body?.displayName + const displayName = + nameRaw === undefined ? undefined : nameRaw === null ? null : nameRaw === '' ? null : String(nameRaw).trim() + const avatarRaw = request.body?.avatar + const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim() + const avatarStyleRaw = request.body?.avatarStyle + const avatarStyle = + avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() + + if (displayName !== undefined && displayName !== null && displayName.length > 40) + return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' }) + if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) { + return reply.code(400).send({ error: 'Стиль аватара слишком длинный' }) + } + + const data = {} + if (displayName !== undefined) { + data.displayName = displayName && displayName.length ? displayName : null + } + if (avatar !== undefined) { + data.avatar = avatar === '' ? null : avatar + } + if (avatarStyle !== undefined) { + data.avatarStyle = avatarStyle === '' ? null : avatarStyle + } + + const updated = await prisma.user.update({ where: { id: userId }, data }) + return { + id: updated.id, + email: updated.email, + displayName: updated.displayName, + avatar: updated.avatar, + avatarStyle: updated.avatarStyle, + } + }) +} diff --git a/server/src/routes/api/admin-reviews.js b/server/src/routes/api/admin-reviews.js new file mode 100755 index 0000000..7cb840b --- /dev/null +++ b/server/src/routes/api/admin-reviews.js @@ -0,0 +1,65 @@ +import { prisma } from '../../lib/prisma.js' + +export async function registerAdminReviewRoutes(fastify) { + fastify.get('/api/admin/reviews', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const status = typeof request.query?.status === 'string' ? request.query.status.trim() : 'pending' + + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 + if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) + + const where = status ? { status } : {} + const total = await prisma.review.count({ where }) + const items = await prisma.review.findMany({ + where, + include: { + user: { select: { id: true, email: true, displayName: true } }, + product: { select: { id: true, title: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }) + + return { items, total, page, pageSize } + }) + + fastify.patch('/api/admin/reviews/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const action = String(request.body?.action || '').trim() + if (action !== 'approve' && action !== 'reject') { + return reply.code(400).send({ error: 'action должен быть approve или reject' }) + } + + const existing = await prisma.review.findUnique({ + where: { id }, + include: { + product: { select: { title: true } }, + user: { select: { displayName: true, email: true } }, + }, + }) + if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' }) + + const updated = await prisma.review.update({ + where: { id }, + data: { + status: action === 'approve' ? 'approved' : 'rejected', + moderatedAt: new Date(), + }, + }) + request.server.eventBus.emit('review:created', { + rating: updated.rating, + text: updated.text || '', + productTitle: existing.product?.title || '', + userName: existing.user?.displayName || existing.user?.email || '', + reviewId: updated.id, + }) + + return { item: updated } + }) +} diff --git a/server/src/routes/api/admin-users.js b/server/src/routes/api/admin-users.js new file mode 100755 index 0000000..46f4abf --- /dev/null +++ b/server/src/routes/api/admin-users.js @@ -0,0 +1,168 @@ +import { normalizeEmail } from '../../lib/auth.js' +import { prisma } from '../../lib/prisma.js' + +export async function registerAdminUserRoutes(fastify) { + fastify.get('/api/admin/users', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const qRaw = request.query?.q + const q = typeof qRaw === 'string' ? qRaw.trim() : '' + + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 + + if (pageSize > 100) { + reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) + return + } + + const where = q + ? { + OR: [{ email: { contains: q } }, { displayName: { contains: q } }], + } + : undefined + + const total = await prisma.user.count({ where }) + + const users = await prisma.user.findMany({ + where, + select: { + id: true, + email: true, + displayName: true, + avatar: true, + avatarStyle: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { updatedAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }) + const items = users.map((u) => ({ + id: u.id, + email: u.email, + displayName: u.displayName, + avatar: u.avatar, + avatarStyle: u.avatarStyle, + createdAt: u.createdAt, + updatedAt: u.updatedAt, + })) + + return { items, total, page, pageSize } + }) + + fastify.post('/api/admin/users', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const body = request.body ?? {} + + const email = normalizeEmail(body.email) + if (!email || !email.includes('@')) { + reply.code(400).send({ error: 'Некорректная почта' }) + return + } + + const nameRaw = body.displayName + const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + if (displayName !== null && displayName.length > 40) { + reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + return + } + + const exists = await prisma.user.findUnique({ where: { email } }) + if (exists) { + reply.code(409).send({ error: 'Почта уже занята' }) + return + } + + const user = await prisma.user.create({ + data: { + email, + displayName: displayName && displayName.length ? displayName : null, + }, + }) + + reply.code(201).send({ + id: user.id, + email: user.email, + displayName: user.displayName, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }) + }) + + fastify.patch('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const body = request.body ?? {} + const adminUserId = request.user.sub + + const existing = await prisma.user.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Пользователь не найден' }) + return + } + + const isSelf = id === adminUserId + + const data = {} + + if (body.email !== undefined) { + if (isSelf) { + reply.code(403).send({ error: 'Нельзя изменить свою почту через панель администратора' }) + return + } + const email = normalizeEmail(body.email) + if (!email || !email.includes('@')) { + reply.code(400).send({ error: 'Некорректная почта' }) + return + } + if (email !== existing.email) { + const clash = await prisma.user.findUnique({ where: { email } }) + if (clash) { + reply.code(409).send({ error: 'Почта уже занята' }) + return + } + data.email = email + } + } + + if (body.displayName !== undefined) { + const nameRaw = body.displayName + const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + if (name !== null && name.length > 40) { + reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + return + } + data.displayName = name && name.length ? name : null + } + + const user = await prisma.user.update({ where: { id }, data }) + return { + id: user.id, + email: user.email, + displayName: user.displayName, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + } + }) + + fastify.delete('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { id } = request.params + const adminUserId = request.user.sub + + if (id === adminUserId) { + reply.code(403).send({ error: 'Нельзя удалить свою учётную запись' }) + return + } + + try { + await prisma.user.delete({ where: { id } }) + reply.code(204).send() + } catch (err) { + request.log.error({ err }, '[admin-users] Operation failed') + reply.code(404).send({ error: 'Пользователь не найден' }) + } + }) +} diff --git a/server/src/routes/api/admin/notifications.js b/server/src/routes/api/admin/notifications.js new file mode 100755 index 0000000..3cb0804 --- /dev/null +++ b/server/src/routes/api/admin/notifications.js @@ -0,0 +1,78 @@ +import { prisma } from '../../../lib/prisma.js' + +export async function registerAdminNotificationRoutes(fastify) { + fastify.get('/api/admin/notifications/settings', { preHandler: [fastify.verifyAdmin] }, async () => { + let settings = await prisma.adminNotificationSettings.findFirst() + if (!settings) { + settings = await prisma.adminNotificationSettings.create({ + data: { + emailEnabled: true, + telegramEnabled: false, + newOrder: true, + newOrderMessage: true, + newReview: true, + authCodeDuplicate: false, + }, + }) + } + return { settings } + }) + + fastify.put('/api/admin/notifications/settings', { preHandler: [fastify.verifyAdmin] }, async (request) => { + const body = request.body || {} + let settings = await prisma.adminNotificationSettings.findFirst() + + const data = {} + if ('emailEnabled' in body) data.emailEnabled = Boolean(body.emailEnabled) + if ('telegramEnabled' in body) data.telegramEnabled = Boolean(body.telegramEnabled) + if ('telegramChatId' in body) data.telegramChatId = body.telegramChatId || null + if ('newOrder' in body) data.newOrder = Boolean(body.newOrder) + if ('newOrderMessage' in body) data.newOrderMessage = Boolean(body.newOrderMessage) + if ('newReview' in body) data.newReview = Boolean(body.newReview) + if ('authCodeDuplicate' in body) data.authCodeDuplicate = Boolean(body.authCodeDuplicate) + + if (!settings) { + settings = await prisma.adminNotificationSettings.create({ data }) + } else { + settings = await prisma.adminNotificationSettings.update({ + where: { id: settings.id }, + data, + }) + } + + return { settings } + }) + + fastify.post('/api/admin/notifications/telegram/webhook', async (request) => { + const update = request.body || {} + const message = update.message + if (!message || !message.text || message.text !== '/start') return { ok: true } + + const chatId = String(message.chat.id) + const settings = await prisma.adminNotificationSettings.findFirst() + + if (settings) { + await prisma.adminNotificationSettings.update({ + where: { id: settings.id }, + data: { telegramChatId: chatId }, + }) + } else { + await prisma.adminNotificationSettings.create({ + data: { telegramChatId: chatId }, + }) + } + + if (process.env.TELEGRAM_BOT_TOKEN) { + await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: 'Вы подписаны на уведомления Любимый Креатив.', + }), + }) + } + + return { ok: true } + }) +} diff --git a/server/src/routes/api/admin/test-checklist.js b/server/src/routes/api/admin/test-checklist.js new file mode 100755 index 0000000..e6639f4 --- /dev/null +++ b/server/src/routes/api/admin/test-checklist.js @@ -0,0 +1,45 @@ +import { prisma } from '../../../lib/prisma.js' + +export async function registerAdminTestChecklistRoutes(fastify) { + fastify.get('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const results = await prisma.checklistResult.findMany() + const resultMap = {} + for (const r of results) { + resultMap[r.itemKey] = { passed: r.passed, comment: r.comment, checkedAt: r.checkedAt.toISOString() } + } + return { results: resultMap } + }) + + fastify.patch('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + const { itemKey, passed, comment } = request.body || {} + if (!itemKey || typeof passed !== 'boolean') { + return reply.code(400).send({ error: 'itemKey и passed (boolean) обязательны' }) + } + if (comment !== undefined && comment !== null && typeof comment !== 'string') { + return reply.code(400).send({ error: 'comment должен быть строкой' }) + } + if (comment !== undefined && comment !== null && comment.length > 2000) { + return reply.code(400).send({ error: 'Комментарий слишком длинный (макс. 2000 символов)' }) + } + + const result = await prisma.checklistResult.upsert({ + where: { itemKey }, + create: { itemKey, passed, comment: passed ? null : comment || null }, + update: { passed, comment: passed ? null : (comment ?? undefined), checkedAt: new Date() }, + }) + + return { + result: { + itemKey: result.itemKey, + passed: result.passed, + comment: result.comment, + checkedAt: result.checkedAt.toISOString(), + }, + } + }) + + fastify.post('/api/admin/test-checklist/reset', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + await prisma.checklistResult.deleteMany({}) + return { ok: true } + }) +} diff --git a/server/src/routes/api/catalog-slider.js b/server/src/routes/api/catalog-slider.js new file mode 100755 index 0000000..ab99d9e --- /dev/null +++ b/server/src/routes/api/catalog-slider.js @@ -0,0 +1,108 @@ +import { asyncHandler } from '../../lib/async-handler.js' +import { prisma } from '../../lib/prisma.js' + +const MAX_SLIDES = 20 + +export async function registerCatalogSliderRoutes(fastify) { + fastify.get( + '/api/catalog-slider', + asyncHandler(async (request, reply) => { + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + url: s.galleryImage.url, + caption: s.caption, + textColor: s.textColor, + })), + } + }), + ) + + fastify.get( + '/api/admin/catalog-slider', + { preHandler: [fastify.verifyAdmin] }, + asyncHandler(async (request, reply) => { + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + galleryImageId: s.galleryImageId, + url: s.galleryImage.url, + caption: s.caption, + textColor: s.textColor, + })), + } + }), + ) + + fastify.put( + '/api/admin/catalog-slider', + { preHandler: [fastify.verifyAdmin] }, + asyncHandler(async (request, reply) => { + const body = request.body ?? {} + const rawSlides = body.slides + if (!Array.isArray(rawSlides)) { + return reply.code(400).send({ error: 'Ожидается slides: массив' }) + } + if (rawSlides.length > MAX_SLIDES) { + return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` }) + } + + const seenGalleryIds = new Set() + const normalized = [] + for (let i = 0; i < rawSlides.length; i++) { + const row = rawSlides[i] + const galleryImageId = String(row?.galleryImageId ?? '').trim() + if (!galleryImageId) { + return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` }) + } + if (seenGalleryIds.has(galleryImageId)) { + return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' }) + } + seenGalleryIds.add(galleryImageId) + const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } }) + if (!img) { + return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` }) + } + const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500) + const textColor = String(row?.textColor || '#ffffff').trim() + normalized.push({ galleryImageId, caption, textColor, sortOrder: i }) + } + + await prisma.$transaction(async (tx) => { + await tx.catalogSliderSlide.deleteMany({}) + for (const n of normalized) { + await tx.catalogSliderSlide.create({ + data: { + sortOrder: n.sortOrder, + caption: n.caption, + textColor: n.textColor, + galleryImageId: n.galleryImageId, + }, + }) + } + }) + + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + galleryImageId: s.galleryImageId, + url: s.galleryImage.url, + caption: s.caption, + textColor: s.textColor, + })), + } + }), + ) +} diff --git a/server/src/routes/api/public-catalog.js b/server/src/routes/api/public-catalog.js new file mode 100755 index 0000000..7977e85 --- /dev/null +++ b/server/src/routes/api/public-catalog.js @@ -0,0 +1,162 @@ +import { prisma } from '../../lib/prisma.js' + +const PUBLIC_PRODUCTS_QUERY_SCHEMA = { + querystring: { + type: 'object', + properties: { + categorySlug: { type: 'string' }, + q: { type: 'string' }, + sort: { type: 'string', enum: ['', 'price_asc', 'price_desc'] }, + page: { type: 'integer', minimum: 1 }, + pageSize: { type: 'integer', minimum: 1, maximum: 100 }, + priceMin: { type: 'number', minimum: 0 }, + priceMax: { type: 'number', minimum: 0 }, + }, + }, +} + +const EMPTY_REVIEWS_SUMMARY = Object.freeze({ + approvedReviewCount: 0, + avgRating: null, + latestApprovedText: null, +}) + +/** Сводка по одобренным отзывам для списка id товаров (для каталога и карточки товара). */ +export async function approvedReviewSummariesForProducts(productIds) { + const map = new Map() + if (!productIds.length) return map + + const uniqueIds = [...new Set(productIds)] + for (const id of uniqueIds) { + map.set(id, { ...EMPTY_REVIEWS_SUMMARY }) + } + + const grouped = await prisma.review.groupBy({ + by: ['productId'], + where: { productId: { in: uniqueIds }, status: 'approved' }, + _count: { _all: true }, + _avg: { rating: true }, + }) + + for (const g of grouped) { + const avg = g._avg.rating + const prev = map.get(g.productId) + if (!prev) continue + map.set(g.productId, { + ...prev, + approvedReviewCount: g._count._all, + avgRating: avg != null ? Number(avg) : null, + }) + } + + const withReviews = [...map.entries()].filter(([, v]) => v.approvedReviewCount > 0).map(([k]) => k) + if (!withReviews.length) return map + + const previewRows = await prisma.review.findMany({ + where: { productId: { in: withReviews }, status: 'approved' }, + orderBy: { createdAt: 'desc' }, + select: { productId: true, text: true }, + take: 450, + }) + const hasPreviewFor = new Set() + for (const r of previewRows) { + if (hasPreviewFor.has(r.productId)) continue + const t = typeof r.text === 'string' ? r.text.trim() : '' + if (!t) continue + hasPreviewFor.add(r.productId) + const prev = map.get(r.productId) + if (!prev) continue + prev.latestApprovedText = t.length > 160 ? `${t.slice(0, 160)}…` : t + if (hasPreviewFor.size === withReviews.length) break + } + + return map +} + +export async function registerPublicCatalogRoutes(fastify) { + fastify.get('/api/categories', async () => { + return prisma.category.findMany({ orderBy: { sort: 'asc' } }) + }) + + fastify.get('/api/products', { schema: PUBLIC_PRODUCTS_QUERY_SCHEMA }, async (request, reply) => { + const { categorySlug } = request.query + const qRaw = request.query?.q + const q = typeof qRaw === 'string' ? qRaw.trim() : '' + + const sortRaw = request.query?.sort + const sort = typeof sortRaw === 'string' ? sortRaw : '' + + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 12 + + const priceMinRaw = request.query?.priceMin + const priceMinParsed = typeof priceMinRaw === 'string' ? Number(priceMinRaw) : Number(priceMinRaw) + const priceMin = Number.isFinite(priceMinParsed) && priceMinParsed >= 0 ? Math.floor(priceMinParsed) : null + + const priceMaxRaw = request.query?.priceMax + const priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw) + const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null + + const where = { published: true } + if (typeof categorySlug === 'string' && categorySlug.length > 0) { + where.category = { slug: categorySlug } + } + if (q) { + where.OR = [{ title: { contains: q } }, { shortDescription: { contains: q } }] + } + const applyPriceFilter = !(priceMin !== null && priceMax !== null && priceMin === 0 && priceMax === 0) + + if (applyPriceFilter && (priceMin !== null || priceMax !== null)) { + if (priceMin !== null && priceMax !== null && priceMax < priceMin) { + return reply.code(400).send({ error: 'priceMax должен быть ≥ priceMin' }) + } + where.priceCents = { + ...(priceMin !== null ? { gte: priceMin } : {}), + ...(priceMax !== null ? { lte: priceMax } : {}), + } + } + + const orderBy = + sort === 'price_asc' + ? { priceCents: 'asc' } + : sort === 'price_desc' + ? { priceCents: 'desc' } + : { createdAt: 'desc' } + + const total = await prisma.product.count({ where }) + const items = await prisma.product.findMany({ + where, + include: { category: true, images: { orderBy: { sort: 'asc' } } }, + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, + }) + + const summaries = await approvedReviewSummariesForProducts(items.map((it) => it.id)) + return { + items: items.map((p) => request.server.mapProductForApi(p, summaries.get(p.id) ?? EMPTY_REVIEWS_SUMMARY)), + total, + page, + pageSize, + } + }) + + fastify.get('/api/products/:id', async (request, reply) => { + const { id } = request.params + const product = await prisma.product.findFirst({ + where: { id, published: true }, + include: { category: true, images: { orderBy: { sort: 'asc' } } }, + }) + if (!product) { + reply.code(404).send({ error: 'Товар не найден' }) + return + } + const summaries = await approvedReviewSummariesForProducts([product.id]) + return request.server.mapProductForApi(product, summaries.get(product.id) ?? EMPTY_REVIEWS_SUMMARY) + }) +} diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js new file mode 100755 index 0000000..ddd9701 --- /dev/null +++ b/server/src/routes/api/public-reviews.js @@ -0,0 +1,152 @@ +import { prisma } from '../../lib/prisma.js' +import { publicReviewAuthorDisplay } from '../../lib/review-display.js' +import { persistMultipartImages } from '../../lib/upload-images.js' +import { + formatFileTooLargeMessage, + getOtherUploadMaxFileBytes, + isMultipartFileTooLargeError, +} from '../../lib/upload-limits.js' + +export async function registerPublicReviewRoutes(fastify) { + fastify.post('/api/reviews/upload-image', { preHandler: [fastify.authenticate] }, async (request, reply) => { + try { + const urls = await persistMultipartImages(request, { + maxFiles: 1, + maxFileBytes: getOtherUploadMaxFileBytes(), + subdir: 'reviews', + }) + if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' }) + return { url: urls[0] } + } catch (error) { + let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение' + let statusCode = + error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) + ? Number(error.statusCode) + : 400 + if (isMultipartFileTooLargeError(error)) { + message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes()) + statusCode = 413 + } + return reply.code(statusCode).send({ error: message }) + } + }) + + fastify.get('/api/reviews/latest', async (request, reply) => { + const limitRaw = request.query?.limit + const limitParsed = typeof limitRaw === 'string' ? Number(limitRaw) : Number(limitRaw) + const parsed = Number.isFinite(limitParsed) && limitParsed > 0 ? Math.floor(limitParsed) : 5 + const take = Math.min(parsed, 5) + + const rows = await prisma.review.findMany({ + where: { status: 'approved' }, + include: { + user: { select: { id: true, email: true, displayName: true, avatar: true, avatarStyle: true } }, + product: { select: { id: true, title: true, published: true, slug: true } }, + }, + orderBy: { createdAt: 'desc' }, + take, + }) + + const items = rows.map((r) => ({ + id: r.id, + rating: r.rating, + text: r.text, + imageUrl: r.imageUrl, + createdAt: r.createdAt, + authorId: r.user?.id ?? r.userId, + authorDisplay: publicReviewAuthorDisplay(r.user), + authorAvatar: r.user?.avatar ?? null, + authorAvatarStyle: r.user?.avatarStyle ?? null, + product: { + id: r.product?.id ?? r.productId, + title: r.product?.title ?? '', + published: r.product?.published ?? false, + slug: r.product?.slug ?? '', + }, + })) + + return { items } + }) + + fastify.get('/api/products/:id/reviews', async (request, reply) => { + const { id } = request.params + + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 10 + if (pageSize > 50) return reply.code(400).send({ error: 'pageSize должен быть ≤ 50' }) + + const product = await prisma.product.findFirst({ where: { id, published: true } }) + if (!product) return reply.code(404).send({ error: 'Товар не найден' }) + + const where = { productId: id, status: 'approved' } + const total = await prisma.review.count({ where }) + const rawItems = await prisma.review.findMany({ + where, + include: { + user: { select: { id: true, email: true, displayName: true, avatar: true, avatarStyle: true } }, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }) + + const items = rawItems.map((r) => ({ + id: r.id, + rating: r.rating, + text: r.text, + imageUrl: r.imageUrl, + createdAt: r.createdAt, + authorId: r.user?.id ?? r.userId, + authorDisplay: publicReviewAuthorDisplay(r.user), + authorAvatar: r.user?.avatar ?? null, + authorAvatarStyle: r.user?.avatarStyle ?? null, + })) + + return { items, total, page, pageSize } + }) + + fastify.post('/api/products/:id/reviews', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { id: productId } = request.params + + const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) + if (!product) return reply.code(404).send({ error: 'Товар не найден' }) + + const rating = Number(request.body?.rating) + if (!Number.isFinite(rating) || rating < 1 || rating > 5) { + return reply.code(400).send({ error: 'rating должен быть от 1 до 5' }) + } + const textRaw = request.body?.text + const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim() + if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' }) + const imageUrlRaw = request.body?.imageUrl + const imageUrl = imageUrlRaw === null || imageUrlRaw === undefined ? null : String(imageUrlRaw).trim() + if (imageUrl !== null && imageUrl.length > 300) + return reply.code(400).send({ error: 'Ссылка на фото слишком длинная' }) + if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) { + return reply.code(400).send({ error: 'Некорректная ссылка на изображение' }) + } + + try { + const created = await prisma.review.create({ + data: { + productId, + userId, + rating: Math.floor(rating), + text: text && text.length ? text : null, + imageUrl: imageUrl && imageUrl.length ? imageUrl : null, + status: 'pending', + }, + }) + return reply.code(201).send({ item: created }) + } catch (err) { + request.log.error({ err }, 'Failed to create review (possible duplicate)') + return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' }) + } + }) +} diff --git a/server/src/routes/auth-oauth.js b/server/src/routes/auth-oauth.js new file mode 100755 index 0000000..fbedc2b --- /dev/null +++ b/server/src/routes/auth-oauth.js @@ -0,0 +1,35 @@ +import { isAdminEmail } from '../lib/auth.js' +import { prisma } from '../lib/prisma.js' + +export async function registerAuthOAuthRoutes(fastify) { + fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const provider = request.params?.provider + + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может отвязывать OAuth' }) + } + if (provider !== 'vk' && provider !== 'yandex') { + return reply.code(400).send({ error: 'Неизвестный провайдер' }) + } + + const oauth = await prisma.oAuthAccount.findFirst({ + where: { userId, provider }, + }) + if (!oauth) return reply.code(404).send({ error: 'Аккаунт не привязан' }) + + const remainingOAuth = await prisma.oAuthAccount.count({ + where: { userId, provider: { not: provider } }, + }) + const currentUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { passwordHash: true }, + }) + if (!currentUser?.passwordHash && remainingOAuth === 0) { + return reply.code(400).send({ error: 'Нельзя удалить последний метод входа' }) + } + + await prisma.oAuthAccount.delete({ where: { id: oauth.id } }) + return { ok: true } + }) +} diff --git a/server/src/routes/auth-password.js b/server/src/routes/auth-password.js new file mode 100755 index 0000000..3520229 --- /dev/null +++ b/server/src/routes/auth-password.js @@ -0,0 +1,49 @@ +import { comparePassword, hashPassword, isAdminEmail, validatePassword } from '../lib/auth.js' +import { prisma } from '../lib/prisma.js' + +export async function registerAuthPasswordRoutes(fastify) { + fastify.post('/api/me/password', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может устанавливать пароль' }) + } + + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) + if (user.passwordHash) return reply.code(409).send({ error: 'Пароль уже установлен' }) + + const password = String(request.body?.password || '') + const passwordErr = validatePassword(password) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const passwordHash = await hashPassword(password) + await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) + + return { ok: true } + }) + + fastify.post('/api/me/change-password', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может менять пароль' }) + } + + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) + if (!user.passwordHash) + return reply.code(400).send({ error: 'Пароль не установлен. Используйте установку пароля.' }) + + const oldPassword = String(request.body?.oldPassword || '') + const valid = await comparePassword(oldPassword, user.passwordHash) + if (!valid) return reply.code(401).send({ error: 'Неверный текущий пароль' }) + + const newPassword = String(request.body?.newPassword || '') + const passwordErr = validatePassword(newPassword) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const passwordHash = await hashPassword(newPassword) + await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) + + return { ok: true } + }) +} diff --git a/server/src/routes/auth-session.js b/server/src/routes/auth-session.js new file mode 100755 index 0000000..e12dabc --- /dev/null +++ b/server/src/routes/auth-session.js @@ -0,0 +1,81 @@ +import crypto from 'node:crypto' +import { normalizeEmail } from '../lib/auth.js' +import { prisma } from '../lib/prisma.js' +import { mapUserForClient } from './auth.js' + +export async function registerAuthSessionRoutes(fastify) { + fastify.get('/api/me', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return { user: null } + return { user: mapUserForClient(user) } + }) + + fastify.get('/api/me/auth-methods', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { oauthAccounts: { select: { provider: true } } }, + }) + if (!user) return { methods: [] } + + const providers = user.oauthAccounts.map((a) => a.provider) + return { + methods: [ + { type: 'password', active: Boolean(user.passwordHash) }, + { type: 'vk', active: providers.includes('vk') }, + { type: 'yandex', active: providers.includes('yandex') }, + ], + } + }) + + fastify.patch('/api/me/email', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const rawEmail = typeof request.body?.email === 'string' ? request.body.email.trim() : '' + + if (!rawEmail || !rawEmail.includes('@')) { + return reply.code(400).send({ error: 'Некорректная почта' }) + } + + const email = normalizeEmail(rawEmail) + + const existing = await prisma.user.findUnique({ where: { email } }) + if (existing && existing.id !== userId) { + return reply.code(409).send({ error: 'Эта почта уже используется' }) + } + + await prisma.pendingEmail.deleteMany({ where: { userId } }) + + const token = crypto.randomUUID() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) + + await prisma.pendingEmail.create({ + data: { userId, email, token, expiresAt }, + }) + + return { verificationUrl: `/api/me/verify-email?token=${token}` } + }) + + fastify.get('/api/me/verify-email', async (request, reply) => { + const token = typeof request.query?.token === 'string' ? request.query.token : '' + + if (!token) { + return reply.code(400).send({ error: 'Отсутствует токен подтверждения' }) + } + + const pending = await prisma.pendingEmail.findUnique({ where: { token } }) + if (!pending || pending.expiresAt < new Date()) { + return reply.code(400).send({ error: 'Токен подтверждения недействителен или истёк' }) + } + + await prisma.user.update({ + where: { id: pending.userId }, + data: { email: pending.email }, + }) + + await prisma.pendingEmail.delete({ where: { id: pending.id } }) + + const clientUrl = (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '') + return reply.redirect(`${clientUrl}/me?emailVerified=1`) + }) +} diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js new file mode 100755 index 0000000..2e5ee6f --- /dev/null +++ b/server/src/routes/auth.js @@ -0,0 +1,239 @@ +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { + comparePassword, + hashPassword, + isAdminEmail, + issueEmailCode, + normalizeEmail, + validatePassword, + verifyEmailCode, +} from '../lib/auth.js' +import { generateAvatar } from '../lib/generate-avatar.js' +import { prisma } from '../lib/prisma.js' +import { checkCodeRequestRateLimit, checkCodeVerifyRateLimit, checkLoginRateLimit } from '../lib/rate-limit.js' + +export function mapUserForClient(user) { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + const userEmail = normalizeEmail(user.email) + return { + id: user.id, + email: user.email, + displayName: user.displayName, + avatar: user.avatar, + avatarStyle: user.avatarStyle, + isAdmin: Boolean(adminEmail) && userEmail === adminEmail, + } +} + +export async function registerAuthRoutes(fastify) { + fastify.post('/api/auth/request-code', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + + const ip = request.ip + const rate = checkCodeRequestRateLimit(ip) + if (!rate.allowed) { + return reply + .code(429) + .header('Retry-After', String(rate.retryAfter)) + .send({ error: `Слишком много запросов. Попробуйте через ${rate.retryAfter} сек.` }) + } + + const code = await issueEmailCode({ email, purpose: 'login' }) + + const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() + const isAdmin = email === adminEmail + + request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { + email, + code, + isAdmin, + }) + + return { ok: true } + }) + + fastify.post('/api/auth/verify-code', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const code = String(request.body?.code || '').trim() + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) + + const ip = request.ip + const rate = checkCodeVerifyRateLimit(ip) + if (!rate.allowed) { + return reply + .code(429) + .header('Retry-After', String(rate.retryAfter)) + .send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` }) + } + + const ok = await verifyEmailCode({ email, purpose: 'login', code }) + if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) + + const avatarUri = await generateAvatar(email) + const user = await prisma.user.upsert({ + where: { email }, + update: {}, + create: { email, avatar: avatarUri, avatarStyle: 'avataaars' }, + }) + + // Ensure notification preference exists + await prisma.notificationPreference.upsert({ + where: { userId: user.id }, + create: { userId: user.id, globalEnabled: true }, + update: {}, + }) + + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return { token, user: mapUserForClient(user) } + }) + + fastify.post('/api/auth/register', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const password = String(request.body?.password || '') + const displayNameRaw = request.body?.displayName + const displayName = displayNameRaw ? String(displayNameRaw).trim().slice(0, 100) : email.split('@')[0] + + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор не может регистрироваться с паролем' }) + + const passwordErr = validatePassword(password) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const exists = await prisma.user.findUnique({ where: { email } }) + if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' }) + + const passwordHash = await hashPassword(password) + const avatarUri = await generateAvatar(email) + const user = await prisma.user.create({ + data: { + email, + passwordHash, + displayName: displayName || null, + avatar: avatarUri, + avatarStyle: 'initials', + }, + }) + + await prisma.notificationPreference.upsert({ + where: { userId: user.id }, + create: { userId: user.id, globalEnabled: true }, + update: {}, + }) + + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return reply.code(201).send({ token, user: mapUserForClient(user) }) + }) + + fastify.post('/api/auth/login', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const password = String(request.body?.password || '') + const ip = request.ip + + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор входит только по коду' }) + + const rate = checkLoginRateLimit(ip) + if (!rate.allowed) { + return reply + .code(429) + .header('Retry-After', String(rate.retryAfter)) + .send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` }) + } + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user || !user.passwordHash) { + return reply.code(401).send({ error: 'Неверная почта или пароль' }) + } + + const valid = await comparePassword(password, user.passwordHash) + if (!valid) { + return reply.code(401).send({ error: 'Неверная почта или пароль' }) + } + + const token = fastify.jwt.sign({ sub: user.id, email: user.email }) + return { token, user: mapUserForClient(user) } + }) + + fastify.post('/api/auth/forgot-password', async (request) => { + const email = normalizeEmail(request.body?.email) + if (!email || !email.includes('@')) return { ok: true } + + if (isAdminEmail(email)) return { ok: true } + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user || !user.passwordHash) return { ok: true } + + await issueEmailCode({ email, purpose: 'reset_password' }) + return { ok: true } + }) + + fastify.post('/api/auth/reset-password', async (request, reply) => { + const email = normalizeEmail(request.body?.email) + const code = String(request.body?.code || '').trim() + const newPassword = String(request.body?.newPassword || '') + + if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) + if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) + + const ok = await verifyEmailCode({ email, purpose: 'reset_password', code }) + if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) + + const passwordErr = validatePassword(newPassword) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const passwordHash = await hashPassword(newPassword) + await prisma.user.update({ where: { email }, data: { passwordHash } }) + + return { ok: true } + }) + + fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const nameRaw = request.body?.displayName + const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + const avatarRaw = request.body?.avatar + const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim() + const avatarStyleRaw = request.body?.avatarStyle + const avatarStyle = + avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() + + if (displayName !== null && displayName.length > 40) + return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' }) + if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) { + return reply.code(400).send({ error: 'Стиль аватара слишком длинный' }) + } + + const data = { + displayName: displayName && displayName.length ? displayName : null, + } + + if (avatar !== undefined) { + data.avatar = avatar === '' ? null : avatar + } + if (avatarStyle !== undefined) { + data.avatarStyle = avatarStyle === '' ? null : avatarStyle + } + const updated = await prisma.user.update({ + where: { id: userId }, + data, + }) + return { user: mapUserForClient(updated) } + }) + + fastify.delete('/api/me', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + + const ACTIVE_STATUSES = ['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'READY_FOR_PICKUP'] + + const activeOrders = await prisma.order.findMany({ + where: { userId, status: { in: ACTIVE_STATUSES } }, + select: { id: true }, + }) + + await prisma.user.delete({ where: { id: userId } }) + return { ok: true, activeOrderIds: activeOrders.map((o) => o.id) } + }) +} diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js new file mode 100755 index 0000000..7bab24c --- /dev/null +++ b/server/src/routes/oauth-social.js @@ -0,0 +1,343 @@ +import crypto from 'node:crypto' +import { normalizeEmail } from '../lib/auth.js' +import { generateAvatar } from '../lib/generate-avatar.js' +import { prisma } from '../lib/prisma.js' + +const pkceStore = new Map() + +function storePkce(state, codeVerifier, meta = {}) { + pkceStore.set(state, { codeVerifier, meta, createdAt: Date.now() }) +} + +function consumePkce(state) { + const entry = pkceStore.get(state) + if (entry) { + pkceStore.delete(state) + return { codeVerifier: entry.codeVerifier, meta: entry.meta } + } + return null +} + +function generatePkcePair() { + const verifier = crypto.randomBytes(48).toString('base64url').slice(0, 64) + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url') + return { codeVerifier: verifier, codeChallenge: challenge } +} + +function decodeIdTokenPayload(idToken) { + const parts = idToken.split('.') + if (parts.length !== 3) return null + try { + return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) + } catch { + return null + } +} + +function clientRedirect(fastify, reply, token) { + const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' + const url = `${base.replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(token)}` + return reply.redirect(url) +} + +function oauthErrorRedirect(reply, msg) { + const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' + const url = `${base.replace(/\/$/, '')}/auth?oauthError=${encodeURIComponent(msg)}` + return reply.redirect(url) +} + +async function issueUserJwt(fastify, userId, email) { + return fastify.jwt.sign({ sub: userId, email }) +} + +async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail, linkToUserId }) { + const existingLink = await prisma.oAuthAccount.findUnique({ + where: { provider_providerUserId: { provider, providerUserId } }, + include: { user: true }, + }) + if (existingLink?.user) { + if (accessToken !== undefined) { + await prisma.oAuthAccount.update({ + where: { provider_providerUserId: { provider, providerUserId } }, + data: { accessToken }, + }) + } + return existingLink.user + } + + const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : '' + const norm = trimmed ? normalizeEmail(trimmed) : null + + if (linkToUserId) { + await prisma.oAuthAccount.create({ + data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken }, + }) + return prisma.user.findUnique({ where: { id: linkToUserId } }) + } + + let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null + if (user) { + await prisma.oAuthAccount.create({ + data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, + }) + return user + } + + const email = norm || `${provider}_${providerUserId}@vk.local` + + user = await prisma.user.create({ + data: { + email, + displayName: norm ? norm.split('@')[0] : 'Пользователь', + avatar: await generateAvatar(email), + avatarStyle: 'initials', + }, + }) + await prisma.oAuthAccount.create({ + data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, + }) + await prisma.notificationPreference.create({ + data: { userId: user.id, globalEnabled: true }, + }) + return user +} + +export async function registerOAuthSocialRoutes(fastify) { + const serverPublic = (process.env.SERVER_PUBLIC_URL || 'http://127.0.0.1:3333').replace(/\/$/, '') + + /** --- VK --- */ + fastify.get('/api/auth/oauth/vk', async (_request, reply) => { + const clientId = process.env.VK_CLIENT_ID + const clientSecret = process.env.VK_CLIENT_SECRET + if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен (нет VK_* в env)' }) + + const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` + const { codeVerifier, codeChallenge } = generatePkcePair() + const state = crypto.randomUUID() + storePkce(state, codeVerifier) + + const url = new URL('https://id.vk.ru/authorize') + url.searchParams.set('client_id', clientId) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('response_type', 'code') + url.searchParams.set('scope', 'email') + url.searchParams.set('code_challenge', codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') + url.searchParams.set('state', state) + + return reply.redirect(url.toString()) + }) + + fastify.get('/api/auth/oauth/vk/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (request.user.email === adminEmail) { + return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) + } + + const clientId = process.env.VK_CLIENT_ID + const clientSecret = process.env.VK_CLIENT_SECRET + if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' }) + + const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` + const { codeVerifier, codeChallenge } = generatePkcePair() + const state = crypto.randomUUID() + storePkce(state, codeVerifier, { action: 'link', userId: request.user.sub }) + + const url = new URL('https://id.vk.ru/authorize') + url.searchParams.set('client_id', clientId) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('response_type', 'code') + url.searchParams.set('scope', 'email') + url.searchParams.set('code_challenge', codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') + url.searchParams.set('state', state) + + return reply.redirect(url.toString()) + }) + + fastify.get('/api/auth/oauth/vk/callback', async (request, reply) => { + const query = request.query ?? {} + if (query.error || query.error_description) { + return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK')) + } + + const state = typeof query.state === 'string' ? query.state.trim() : '' + if (!state) return oauthErrorRedirect(reply, 'Недействительный state OAuth') + + const pkceEntry = consumePkce(state) + if (!pkceEntry) return oauthErrorRedirect(reply, 'Недействительный state OAuth') + + const code = typeof query.code === 'string' ? query.code.trim() : '' + if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK') + + const deviceId = typeof query.device_id === 'string' ? query.device_id : null + + const clientId = process.env.VK_CLIENT_ID + const clientSecret = process.env.VK_CLIENT_SECRET + const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` + + const body = new URLSearchParams() + body.set('grant_type', 'authorization_code') + body.set('client_id', clientId) + body.set('client_secret', clientSecret) + body.set('code', code) + body.set('code_verifier', pkceEntry.codeVerifier) + body.set('redirect_uri', redirectUri) + if (deviceId) { + body.set('device_id', deviceId) + } + + const tokenRes = await fetch('https://id.vk.ru/oauth2/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + const tokenBody = await tokenRes.json() + + if (tokenBody?.error_description || tokenBody?.error || !tokenRes.ok) { + return oauthErrorRedirect(reply, tokenBody?.error_description || tokenBody?.error || 'Не удалось обменять код VK') + } + + const idToken = typeof tokenBody?.id_token === 'string' ? tokenBody.id_token : null + const claims = idToken ? decodeIdTokenPayload(idToken) : null + + const vkUserId = claims?.sub ?? tokenBody?.user_id + const emailSuggestion = claims?.email ?? tokenBody?.email ?? null + + if (!vkUserId) return oauthErrorRedirect(reply, 'no_user_id') + + const linkToUserId = pkceEntry.meta?.action === 'link' ? pkceEntry.meta.userId : undefined + + const user = await findOrCreateUserFromOAuth({ + provider: 'vk', + providerUserId: String(vkUserId), + accessToken: tokenBody?.access_token ?? null, + suggestedEmail: emailSuggestion, + linkToUserId, + }) + + if (linkToUserId) { + const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' + return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`) + } + + const token = await issueUserJwt(fastify, user.id, user.email) + return clientRedirect(fastify, reply, token) + }) + + /** --- Yandex --- */ + fastify.get('/api/auth/oauth/yandex', async (_request, reply) => { + const clientId = process.env.YANDEX_CLIENT_ID + if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен (нет YANDEX_* в env)' }) + + const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback` + const state = fastify.jwt.sign({ oauth: 'yandex' }, { expiresIn: '15m' }) + + const url = new URL('https://oauth.yandex.ru/authorize') + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', clientId) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', 'login:email') + url.searchParams.set('state', state) + + return reply.redirect(url.toString()) + }) + + fastify.get('/api/auth/oauth/yandex/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (request.user.email === adminEmail) { + return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) + } + + const clientId = process.env.YANDEX_CLIENT_ID + if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен' }) + + const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback` + const state = fastify.jwt.sign({ oauth: 'yandex', action: 'link', userId: request.user.sub }, { expiresIn: '15m' }) + + const url = new URL('https://oauth.yandex.ru/authorize') + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', clientId) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', 'login:email') + url.searchParams.set('state', state) + + return reply.redirect(url.toString()) + }) + + fastify.get('/api/auth/oauth/yandex/callback', async (request, reply) => { + const query = request.query ?? {} + if (query.error) return oauthErrorRedirect(reply, String(query.error)) + + const statePayload = (() => { + try { + const raw = typeof query.state === 'string' ? query.state : '' + return fastify.jwt.verify(raw || '') + } catch { + return null + } + })() + if (!statePayload) return oauthErrorRedirect(reply, 'Недействительный state OAuth') + + const code = typeof query.code === 'string' ? query.code.trim() : '' + if (!code) return oauthErrorRedirect(reply, 'Не получен код от Яндекс') + + const clientId = process.env.YANDEX_CLIENT_ID + const clientSecret = process.env.YANDEX_CLIENT_SECRET + const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback` + + const body = new URLSearchParams() + body.set('grant_type', 'authorization_code') + body.set('code', code) + body.set('client_id', clientId) + body.set('client_secret', clientSecret) + if (redirectUri) body.set('redirect_uri', redirectUri) + + const tokenRes = await fetch('https://oauth.yandex.ru/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + const tokenBody = await tokenRes.json() + + if (!tokenRes.ok || !tokenBody.access_token) { + return oauthErrorRedirect( + reply, + tokenBody.error_description || tokenBody.error || 'Не удалось обменять код Yandex', + ) + } + + const yaToken = tokenBody.access_token + + const infoRes = await fetch('https://login.yandex.ru/info', { + headers: { Authorization: `OAuth ${yaToken}` }, + }) + const info = await infoRes.json() + const yaUserId = String(info?.id || '') + if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex') + + const emailGuess = (Array.isArray(info?.emails) && info.emails[0]) || info?.default_email || null + + if (!emailGuess) return oauthErrorRedirect(reply, 'no_email') + + const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined + + const user = await findOrCreateUserFromOAuth({ + provider: 'yandex', + providerUserId: yaUserId, + accessToken: yaToken, + suggestedEmail: emailGuess, + linkToUserId, + }) + + if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от Яндекс') + + if (linkToUserId) { + const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' + return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=yandex`) + } + + const token = await issueUserJwt(fastify, user.id, user.email) + return clientRedirect(fastify, reply, token) + }) +} diff --git a/server/src/routes/sse.js b/server/src/routes/sse.js new file mode 100755 index 0000000..15980fe --- /dev/null +++ b/server/src/routes/sse.js @@ -0,0 +1,149 @@ +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' + +const { + ORDER_CREATED, + ORDER_STATUS_CHANGED, + ORDER_MESSAGE_SENT, + ORDER_MESSAGE_ADMIN_REPLY, + PAYMENT_STATUS_CHANGED, + DELIVERY_FEE_ADJUSTED, +} = NOTIFICATION_EVENTS + +export function isAdminUser(user) { + const adminEmail = String(process.env.ADMIN_EMAIL || '') + .trim() + .toLowerCase() + const userEmail = String(user?.email || '') + .trim() + .toLowerCase() + return !!(adminEmail && userEmail === adminEmail) +} + +export function formatSSE(event, data) { + const lines = [`event: ${event}`] + if (data !== undefined) { + lines.push(`data: ${JSON.stringify(data)}`) + } + return lines.join('\n') + '\n\n' +} + +export function formatHeartbit() { + return ':heartbit\n\n' +} + +export function buildSseListeners(userId, admin, eventBus, write) { + const listeners = [] + + function on(eventName, filterFn, sseEvent, dataFn) { + function handler(payload) { + if (!filterFn(payload)) return + write(formatSSE(sseEvent, dataFn(payload))) + } + listeners.push({ eventName, handler }) + eventBus.on(eventName, handler) + } + + on( + ORDER_MESSAGE_ADMIN_REPLY, + (p) => p.userId === userId, + 'message:new', + (p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }), + ) + + on( + ORDER_MESSAGE_SENT, + () => admin, + 'message:new', + (p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }), + ) + + on( + ORDER_STATUS_CHANGED, + (p) => admin || p.userId === userId, + 'order:statusChanged', + (p) => ({ orderId: p.orderId, newStatus: p.newStatus }), + ) + + on( + PAYMENT_STATUS_CHANGED, + (p) => admin || p.userId === userId, + 'order:statusChanged', + (p) => ({ orderId: p.orderId }), + ) + + on( + DELIVERY_FEE_ADJUSTED, + (p) => admin || p.userId === userId, + 'order:updated', + (p) => ({ orderId: p.orderId }), + ) + + on( + ORDER_CREATED, + () => admin, + 'order:new', + (p) => ({ orderId: p.orderId }), + ) + + on( + 'order:created:admin', + () => admin, + 'order:new', + (p) => ({ orderId: p.orderId }), + ) + + return function cleanup() { + for (const { eventName, handler } of listeners) { + eventBus.off(eventName, handler) + } + } +} + +export async function registerSseRoutes(fastify) { + fastify.get('/api/sse/stream', { preHandler: [fastify.authenticate] }, async (request, reply) => { + reply.hijack() + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }) + + let closed = false + let heartbitTimer + let removeListeners + + function cleanUp() { + if (closed) return + closed = true + clearInterval(heartbitTimer) + removeListeners() + } + + function safeWrite(chunk) { + if (closed) return + try { + reply.raw.write(chunk) + } catch (err) { + request.log.error({ err }, '[sse] safeWrite failed') + closed = true + cleanUp() + } + } + + const userId = request.user.sub + const admin = isAdminUser(request.user) + + safeWrite(formatHeartbit()) + + heartbitTimer = setInterval(() => { + safeWrite(formatHeartbit()) + }, 30_000) + + removeListeners = buildSseListeners(userId, admin, fastify.eventBus, safeWrite) + + request.raw.on('close', cleanUp) + request.raw.on('error', cleanUp) + }) +} diff --git a/server/src/routes/uploads-resized.js b/server/src/routes/uploads-resized.js new file mode 100755 index 0000000..add5606 --- /dev/null +++ b/server/src/routes/uploads-resized.js @@ -0,0 +1,81 @@ +// server/src/routes/uploads-resized.js +import fs from 'node:fs' +import { findOriginalFile, getOrCreateResized, SUPPORTED_FORMATS, VALID_WIDTHS } from '../lib/image-resize.js' + +const CACHE_CONTROL_IMMUTABLE = 'public, max-age=31536000, immutable' +const CACHE_CONTROL_SHORT = 'public, max-age=86400' + +/** + * Register GET /uploads-resized/* route for on-demand image resizing. + * Must be registered BEFORE fastify-static for /uploads/. + */ +export function registerUploadsResized(fastify) { + fastify.get('/uploads-resized/*', async (request, reply) => { + try { + const rawPath = request.params['*'] + if (typeof rawPath !== 'string') { + return reply.code(400).send({ error: 'Invalid request: missing file path' }) + } + + const url = new URL(request.url, 'http://localhost') + const widthParam = url.searchParams.get('w') + + // Parse: [subdir/]filename.format + const parts = rawPath.split('/') + let filename, + subdir = '' + + if (parts.length > 1) { + subdir = parts.slice(0, -1).join('/') + '/' + filename = parts[parts.length - 1] + } else { + filename = parts[0] + } + + const dotIdx = filename.lastIndexOf('.') + if (dotIdx === -1) { + return reply.code(400).send({ error: 'Invalid request: no format specified' }) + } + + const uuid = filename.slice(0, dotIdx) + const format = filename.slice(dotIdx + 1).toLowerCase() + + if (!SUPPORTED_FORMATS.has(format)) { + return reply.code(400).send({ error: `Unsupported format: ${format}. Use avif or webp.` }) + } + + // Validate width + let width = null + if (widthParam) { + const w = parseInt(widthParam, 10) + if (!VALID_WIDTHS.includes(w)) { + return reply.code(400).send({ error: `Invalid width: ${widthParam}. Use: ${VALID_WIDTHS.join(', ')}` }) + } + width = w + } + + // If no width requested, serve original with short cache + if (!width) { + const originalPath = await findOriginalFile(uuid, subdir || undefined) + if (!originalPath) { + return reply.code(404).send({ error: 'Image not found' }) + } + reply.header('Cache-Control', CACHE_CONTROL_SHORT) + reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp') + return reply.send(fs.createReadStream(originalPath)) + } + + const result = await getOrCreateResized(uuid, width, format, subdir || undefined) + if (!result) { + return reply.code(404).send({ error: 'Image not found' }) + } + + reply.header('Cache-Control', CACHE_CONTROL_IMMUTABLE) + reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp') + return reply.send(fs.createReadStream(result.path)) + } catch (error) { + request.log.error({ err: error, url: request.url }, 'uploads-resized route error') + return reply.code(500).send({ error: error.message || 'Image resize failed' }) + } + }) +} diff --git a/server/src/routes/user-addresses.js b/server/src/routes/user-addresses.js new file mode 100755 index 0000000..6ea4b30 --- /dev/null +++ b/server/src/routes/user-addresses.js @@ -0,0 +1,199 @@ +import { asyncHandler } from '../lib/async-handler.js' +import { prisma } from '../lib/prisma.js' + +function normalizePhoneLite(input) { + const s = String(input || '').trim() + if (!s) return '' + return s.replace(/[\s()-]/g, '') +} + +function validateAddressPayload(body, reply) { + const labelRaw = body?.label + const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim() + if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' }) + + const recipientName = String(body?.recipientName || '').trim() + if (!recipientName) return reply.code(400).send({ error: 'Укажите ФИО получателя' }) + if (recipientName.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' }) + + const recipientPhone = normalizePhoneLite(body?.recipientPhone) + if (!recipientPhone) return reply.code(400).send({ error: 'Укажите телефон получателя' }) + if (!/^\+?\d{7,20}$/.test(recipientPhone)) return reply.code(400).send({ error: 'Некорректный телефон получателя' }) + + const addressLine = String(body?.addressLine || '').trim() + if (!addressLine) return reply.code(400).send({ error: 'Укажите адрес' }) + if (addressLine.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' }) + + const commentRaw = body?.comment + const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() + if (comment !== null && comment.length > 200) + return reply.code(400).send({ error: 'Комментарий максимум 200 символов' }) + + const lat = Number(body?.lat) + const lng = Number(body?.lng) + if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' }) + if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' }) + + return { + label, + recipientName, + recipientPhone, + addressLine, + comment, + lat, + lng, + } +} + +export async function registerUserAddressRoutes(fastify) { + fastify.get( + '/api/me/addresses', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const items = await prisma.shippingAddress.findMany({ + where: { userId }, + orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }], + }) + return { items } + }), + ) + + fastify.post( + '/api/me/addresses', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const validated = validateAddressPayload(request.body, reply) + if (!validated) return + + const isDefault = Boolean(request.body?.isDefault) + const created = await prisma.$transaction(async (tx) => { + if (isDefault) { + await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) + } + return tx.shippingAddress.create({ + data: { + userId, + ...validated, + isDefault, + }, + }) + }) + return reply.code(201).send({ item: created }) + }), + ) + + fastify.patch( + '/api/me/addresses/:id', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) + + const body = request.body ?? {} + const data = {} + + if (body.label !== undefined) { + const labelRaw = body.label + const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim() + if (label !== null && label.length > 40) + return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' }) + data.label = label && label.length ? label : null + } + + if (body.recipientName !== undefined) { + const v = String(body.recipientName || '').trim() + if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' }) + if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' }) + data.recipientName = v + } + + if (body.recipientPhone !== undefined) { + const v = normalizePhoneLite(body.recipientPhone) + if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' }) + if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' }) + data.recipientPhone = v + } + + if (body.addressLine !== undefined) { + const v = String(body.addressLine || '').trim() + if (!v) return reply.code(400).send({ error: 'Укажите адрес' }) + if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' }) + data.addressLine = v + } + + if (body.comment !== undefined) { + const commentRaw = body.comment + const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() + if (comment !== null && comment.length > 200) + return reply.code(400).send({ error: 'Комментарий максимум 200 символов' }) + data.comment = comment && comment.length ? comment : null + } + + if (body.lat !== undefined) { + const lat = Number(body.lat) + if (!Number.isFinite(lat) || lat < -90 || lat > 90) + return reply.code(400).send({ error: 'Некорректная широта' }) + data.lat = lat + } + + if (body.lng !== undefined) { + const lng = Number(body.lng) + if (!Number.isFinite(lng) || lng < -180 || lng > 180) + return reply.code(400).send({ error: 'Некорректная долгота' }) + data.lng = lng + } + + const setDefault = body.isDefault === true + const updated = await prisma.$transaction(async (tx) => { + if (setDefault) { + await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) + } + return tx.shippingAddress.update({ + where: { id }, + data: { + ...data, + ...(setDefault ? { isDefault: true } : {}), + }, + }) + }) + + return { item: updated } + }), + ) + + fastify.delete( + '/api/me/addresses/:id', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) + + await prisma.shippingAddress.delete({ where: { id } }) + return reply.code(204).send() + }), + ) + + fastify.post( + '/api/me/addresses/:id/default', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) + + const updated = await prisma.$transaction(async (tx) => { + await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) + return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } }) + }) + + return { item: updated } + }), + ) +} diff --git a/server/src/routes/user-cart.js b/server/src/routes/user-cart.js new file mode 100755 index 0000000..f35dfcd --- /dev/null +++ b/server/src/routes/user-cart.js @@ -0,0 +1,93 @@ +import { asyncHandler } from '../lib/async-handler.js' +import { prisma } from '../lib/prisma.js' + +export async function registerUserCartRoutes(fastify) { + fastify.get( + '/api/me/cart', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const items = await prisma.cartItem.findMany({ + where: { userId }, + include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } }, + orderBy: { createdAt: 'asc' }, + }) + return { + items: items.map((x) => ({ + id: x.id, + qty: x.qty, + product: x.product, + })), + } + }), + ) + + fastify.post( + '/api/me/cart/items', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const productId = String(request.body?.productId || '').trim() + const qtyRaw = request.body?.qty + const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw) + + if (!productId) return reply.code(400).send({ error: 'productId обязателен' }) + if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' }) + + const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) + if (!product) return reply.code(404).send({ error: 'Товар не найден' }) + + const available = product.quantity + const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } }) + const nextQty = (existing?.qty ?? 0) + Math.floor(qty) + if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) + + const item = await prisma.cartItem.upsert({ + where: { userId_productId: { userId, productId } }, + update: { qty: nextQty }, + create: { userId, productId, qty: nextQty }, + }) + return reply.code(201).send({ item }) + }), + ) + + fastify.patch( + '/api/me/cart/items/:id', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const qtyRaw = request.body?.qty + const qty = Number(qtyRaw) + if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' }) + + const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } }) + if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) + + if (qty === 0) { + await prisma.cartItem.delete({ where: { id } }) + return reply.code(204).send() + } + + const available = existing.product.quantity + const nextQty = Math.floor(qty) + if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) + + const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } }) + return { item: updated } + }), + ) + + fastify.delete( + '/api/me/cart/items/:id', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.cartItem.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) + await prisma.cartItem.delete({ where: { id } }) + return reply.code(204).send() + }), + ) +} diff --git a/server/src/routes/user-messages.js b/server/src/routes/user-messages.js new file mode 100755 index 0000000..863f821 --- /dev/null +++ b/server/src/routes/user-messages.js @@ -0,0 +1,144 @@ +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { asyncHandler } from '../lib/async-handler.js' +import { findUserOrder } from '../lib/find-user-order.js' +import { prisma } from '../lib/prisma.js' + +export async function registerUserMessageRoutes(fastify) { + fastify.get( + '/api/me/orders/:id/messages', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + await findUserOrder(prisma, id, userId) + const items = await prisma.orderMessage.findMany({ + where: { orderId: id }, + orderBy: { createdAt: 'asc' }, + }) + return { items } + }), + ) + + fastify.post( + '/api/me/orders/:id/messages', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + await findUserOrder(prisma, id, userId) + const text = String(request.body?.text || '').trim() + if (!text) return reply.code(400).send({ error: 'Сообщение пустое' }) + if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' }) + const msg = await prisma.orderMessage.create({ + data: { orderId: id, authorType: 'user', text }, + }) + + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, { + orderId: id, + authorType: 'user', + messageId: msg.id, + preview: text, + }) + + return reply.code(201).send({ item: msg }) + }), + ) + + fastify.get('/api/me/messages/unread-count', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const orders = await prisma.order.findMany({ + where: { userId }, + select: { id: true }, + }) + if (orders.length === 0) return { count: 0 } + + const orderIds = orders.map((o) => o.id) + const readStates = await prisma.userOrderMessageReadState.findMany({ + where: { userId }, + }) + const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) + + const adminMessages = await prisma.orderMessage.findMany({ + where: { orderId: { in: orderIds }, authorType: 'admin' }, + select: { orderId: true, createdAt: true }, + }) + + let count = 0 + for (const msg of adminMessages) { + const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0) + if (msg.createdAt > lastRead) count++ + } + return { count } + }) + + fastify.get('/api/me/conversations', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const orders = await prisma.order.findMany({ + where: { userId, messages: { some: {} } }, + select: { + id: true, + status: true, + deliveryType: true, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { text: true, createdAt: true }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }) + + const readStates = await prisma.userOrderMessageReadState.findMany({ + where: { userId }, + }) + const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) + + const orderIds = orders.map((o) => o.id) + const unreadCounts = new Map() + if (orderIds.length > 0) { + const adminMessages = await prisma.orderMessage.findMany({ + where: { orderId: { in: orderIds }, authorType: 'admin' }, + select: { orderId: true, createdAt: true }, + }) + for (const msg of adminMessages) { + const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0) + if (msg.createdAt > lastRead) { + unreadCounts.set(msg.orderId, (unreadCounts.get(msg.orderId) ?? 0) + 1) + } + } + } + + const items = [] + for (const o of orders) { + const lastMsg = o.messages[0] + if (!lastMsg) continue + items.push({ + orderId: o.id, + status: o.status, + deliveryType: o.deliveryType, + lastMessageAt: lastMsg.createdAt, + preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text, + unreadCount: unreadCounts.get(o.id) ?? 0, + }) + } + return { items } + }) + + fastify.post( + '/api/me/orders/:id/messages/read', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + await findUserOrder(prisma, id, userId) + + const now = new Date() + await prisma.userOrderMessageReadState.upsert({ + where: { userId_orderId: { userId, orderId: id } }, + create: { userId, orderId: id, lastReadAt: now }, + update: { lastReadAt: now }, + }) + return { ok: true } + }), + ) +} diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js new file mode 100755 index 0000000..d74ba91 --- /dev/null +++ b/server/src/routes/user-orders.js @@ -0,0 +1,279 @@ +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { asyncHandler } from '../lib/async-handler.js' +import { isDeliveryCarrier } from '../lib/delivery-carrier.js' +import { findUserOrder } from '../lib/find-user-order.js' +import { prisma } from '../lib/prisma.js' + +export async function registerUserOrderRoutes(fastify) { + // ---- Создание заказа (checkout) ---- + + fastify.post('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const deliveryTypeRaw = request.body?.deliveryType + const deliveryType = + deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === '' + ? 'delivery' + : String(deliveryTypeRaw).trim() + + const addressId = String(request.body?.addressId || '').trim() + const commentRaw = request.body?.comment + const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() + + const paymentMethodRaw = request.body?.paymentMethod + const paymentMethod = + paymentMethodRaw === undefined || paymentMethodRaw === null || paymentMethodRaw === '' + ? 'online' + : String(paymentMethodRaw).trim() + if (paymentMethod !== 'online' && paymentMethod !== 'on_pickup') { + return reply.code(400).send({ error: 'paymentMethod должен быть online | on_pickup' }) + } + + if (deliveryType !== 'delivery' && deliveryType !== 'pickup') { + return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' }) + } + + const carrierRaw = request.body?.deliveryCarrier + let deliveryCarrier = null + if (deliveryType === 'delivery') { + const carrierStr = + carrierRaw === undefined || carrierRaw === null || carrierRaw === '' ? '' : String(carrierRaw).trim() + if (!isDeliveryCarrier(carrierStr)) { + return reply.code(400).send({ + error: 'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST | WB_PVZ', + }) + } + deliveryCarrier = carrierStr + } + + if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') { + return reply.code(400).send({ + error: 'Оплата при получении доступна только для самовывоза', + }) + } + + let address = null + if (deliveryType === 'delivery') { + if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' }) + address = await prisma.shippingAddress.findFirst({ + where: { id: addressId, userId }, + }) + if (!address) return reply.code(404).send({ error: 'Адрес не найден' }) + } + + const cartItems = await prisma.cartItem.findMany({ + where: { userId }, + include: { product: true }, + }) + if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' }) + + for (const ci of cartItems) { + const available = ci.product.quantity + if (ci.qty > available) { + return reply.code(409).send({ + error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`, + }) + } + } + + const itemsPayload = cartItems.map((ci) => ({ + productId: ci.productId, + qty: ci.qty, + titleSnapshot: ci.product.title, + priceCentsSnapshot: ci.product.priceCents, + })) + + const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0) + const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0 + const totalCents = itemsSubtotalCents + deliveryFeeCents + + const addressSnapshotJson = + deliveryType === 'pickup' + ? JSON.stringify({ deliveryType: 'pickup' }) + : JSON.stringify({ + deliveryType: 'delivery', + id: address.id, + label: address.label, + recipientName: address.recipientName, + recipientPhone: address.recipientPhone, + addressLine: address.addressLine, + comment: address.comment, + lat: address.lat, + lng: address.lng, + }) + + let initialStatus = 'PENDING_PAYMENT' + let deliveryFeeLocked = true + if (paymentMethod === 'on_pickup') { + initialStatus = 'IN_PROGRESS' + } else if (deliveryType === 'delivery') { + initialStatus = 'PENDING_PAYMENT' + deliveryFeeLocked = false + } + + let created + try { + created = await prisma.$transaction(async (tx) => { + for (const ci of cartItems) { + const res = await tx.product.updateMany({ + where: { id: ci.productId, quantity: { gte: ci.qty } }, + data: { quantity: { decrement: ci.qty } }, + }) + if (res.count !== 1) { + throw new Error(`Недостаточно товара: "${ci.product.title}"`) + } + } + + const order = await tx.order.create({ + data: { + userId, + status: initialStatus, + deliveryFeeLocked, + deliveryType, + deliveryCarrier, + paymentMethod, + itemsSubtotalCents, + deliveryFeeCents, + totalCents, + currency: 'RUB', + addressSnapshotJson, + comment: comment && comment.length ? comment : null, + items: { + create: itemsPayload.map((i) => ({ + productId: i.productId, + qty: i.qty, + titleSnapshot: i.titleSnapshot, + priceCentsSnapshot: i.priceCentsSnapshot, + })), + }, + }, + }) + await tx.cartItem.deleteMany({ where: { userId } }) + return order + }) + } catch (e) { + return reply.code(409).send({ + error: (e instanceof Error && e.message) || 'Недостаточно товара', + }) + } + + // Emit notification events + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, { + orderId: created.id, + userId, + totalCents: created.totalCents, + itemsCount: cartItems.length, + deliveryType: created.deliveryType, + }) + + // Also emit admin notification + request.server.eventBus.emit('order:created:admin', { + orderId: created.id, + userId, + userEmail: request.user.email || '', + totalCents: created.totalCents, + itemsCount: cartItems.length, + deliveryType: created.deliveryType, + }) + + return reply.code(201).send({ orderId: created.id }) + }) + + fastify.get( + '/api/me/orders', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const orders = await prisma.order.findMany({ + where: { userId }, + include: { items: { select: { qty: true } } }, + orderBy: { createdAt: 'desc' }, + }) + return { + items: orders.map((o) => ({ + id: o.id, + status: o.status, + totalCents: o.totalCents, + currency: o.currency, + createdAt: o.createdAt, + updatedAt: o.updatedAt, + itemsCount: o.items.reduce((s, i) => s + i.qty, 0), + })), + } + }), + ) + + fastify.get( + '/api/me/orders/:id', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const order = await findUserOrder(prisma, id, userId, { + items: true, + messages: { orderBy: { createdAt: 'asc' } }, + }) + return { item: order } + }), + ) + + fastify.get( + '/api/me/orders/:id/review-eligibility', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const order = await findUserOrder(prisma, id, userId, { items: true }) + if (order.status !== 'DONE') { + return { canReview: false, items: [] } + } + + const uniq = new Map() + for (const it of order.items) { + if (!uniq.has(it.productId)) { + uniq.set(it.productId, { + productId: it.productId, + title: it.titleSnapshot, + }) + } + } + const productIds = [...uniq.keys()] + const existing = await prisma.review.findMany({ + where: { userId, productId: { in: productIds } }, + select: { productId: true }, + }) + const reviewed = new Set(existing.map((r) => r.productId)) + return { + canReview: true, + items: [...uniq.values()].map((x) => ({ + ...x, + hasReview: reviewed.has(x.productId), + })), + } + }), + ) + + fastify.post( + '/api/me/orders/:id/confirm-received', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const order = await findUserOrder(prisma, id, userId) + + const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED' + const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP' + if (!okDelivery && !okPickup) { + return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' }) + } + + await prisma.order.update({ where: { id }, data: { status: 'DONE' } }) + request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, { + orderId: order.id, + userId: order.userId, + oldStatus: order.status, + newStatus: 'DONE', + }) + return { ok: true, status: 'DONE' } + }), + ) +} diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js new file mode 100755 index 0000000..22cc260 --- /dev/null +++ b/server/src/routes/user-payments.js @@ -0,0 +1,154 @@ +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { asyncHandler } from '../lib/async-handler.js' +import { findUserOrder } from '../lib/find-user-order.js' +import { prisma } from '../lib/prisma.js' +import { createPayment, buildReceipt, getPayment } from '../lib/yookassa.js' + +export async function registerUserPaymentRoutes(fastify) { + fastify.post( + '/api/me/orders/:id/pay', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const userEmail = request.user.email + + if (!userEmail) { + return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' }) + } + + const { id } = request.params + + const order = await findUserOrder(prisma, id, userId, { items: true }) + + if (order.paymentMethod === 'on_pickup') { + return reply.code(409).send({ + error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна', + }) + } + + if (order.status !== 'PENDING_PAYMENT') { + return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' }) + } + + if (!order.deliveryFeeLocked) { + return reply.code(409).send({ + error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже', + }) + } + + const existingPayment = await prisma.payment.findFirst({ + where: { + orderId: id, + status: { in: ['pending', 'waiting_for_capture'] }, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + orderBy: { createdAt: 'desc' }, + }) + + if (existingPayment && existingPayment.confirmationUrl) { + return { confirmationUrl: existingPayment.confirmationUrl } + } + + const idempotencyKey = `${id}-${Date.now()}` + const clientUrl = (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '') + const returnUrl = `${clientUrl}/me/orders/${id}?paid=1` + const clientIp = request.ip + + const amount = { + value: (order.totalCents / 100).toFixed(2), + currency: order.currency, + } + + const receipt = buildReceipt({ + orderItems: order.items, + deliveryFeeCents: order.deliveryFeeCents, + userEmail: userEmail, + }) + + let result + try { + result = await createPayment({ + amount, + description: `Оплата заказа №${order.id.slice(-6)}`, + receipt, + confirmation: { type: 'redirect', return_url: returnUrl }, + metadata: { orderId: order.id }, + idempotencyKey, + clientIp, + }) + } catch (err) { + request.log.error({ err, orderId: id }, 'YooKassa createPayment failed') + return reply.code(502).send({ + error: 'Не удалось создать платёж. Платёжный сервис временно недоступен.', + }) + } + + await prisma.payment.create({ + data: { + orderId: order.id, + yookassaPaymentId: result.paymentId, + status: result.status, + amountCents: order.totalCents, + currency: order.currency, + confirmationUrl: result.confirmationUrl, + expiresAt: result.expiresAt ? new Date(result.expiresAt) : null, + }, + }) + + return { confirmationUrl: result.confirmationUrl } + }), + ) + + fastify.get( + '/api/me/orders/:orderId/payment', + { preHandler: [fastify.authenticate] }, + asyncHandler(async (request, reply) => { + const userId = request.user.sub + const { orderId } = request.params + + const order = await findUserOrder(prisma, orderId, userId) + + const payment = await prisma.payment.findFirst({ + where: { orderId }, + orderBy: { createdAt: 'desc' }, + }) + if (!payment) { + return { status: null, paid: false } + } + + if (payment.status === 'succeeded' || payment.status === 'canceled') { + return { status: payment.status, paid: payment.status === 'succeeded' } + } + + try { + const ykPayment = await getPayment(payment.yookassaPaymentId) + + if (ykPayment.status !== payment.status) { + await prisma.payment.update({ + where: { id: payment.id }, + data: { status: ykPayment.status }, + }) + + if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') { + const updated = await prisma.order.updateMany({ + where: { id: orderId, status: 'PENDING_PAYMENT' }, + data: { status: 'PAID' }, + }) + if (updated.count > 0) { + request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId, + userId: order.userId, + paymentStatus: 'paid', + }) + } + } + } + + return { status: ykPayment.status, paid: ykPayment.paid } + } catch (err) { + request.log.error({ err }, '[user-payments] Operation failed') + return { status: payment.status, paid: payment.status === 'succeeded' } + } + }), + ) +} diff --git a/server/src/routes/user/notifications.js b/server/src/routes/user/notifications.js new file mode 100755 index 0000000..0750cec --- /dev/null +++ b/server/src/routes/user/notifications.js @@ -0,0 +1,31 @@ +import { ensureUserNotificationPreference } from '../../lib/notifications/preferences.js' +import { prisma } from '../../lib/prisma.js' + +export async function registerUserNotificationRoutes(fastify) { + fastify.get('/api/me/notifications/settings', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const prefs = await ensureUserNotificationPreference(userId) + return { settings: prefs } + }) + + fastify.put('/api/me/notifications/settings', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const body = request.body || {} + + const data = {} + if ('globalEnabled' in body) data.globalEnabled = Boolean(body.globalEnabled) + if ('orderCreated' in body) data.orderCreated = Boolean(body.orderCreated) + if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged) + if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived) + if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged) + if ('deliveryFeeAdjusted' in body) data.deliveryFeeAdjusted = Boolean(body.deliveryFeeAdjusted) + + const prefs = await prisma.notificationPreference.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + }) + + return { settings: prefs } + }) +} diff --git a/server/src/routes/webhook-yookassa.js b/server/src/routes/webhook-yookassa.js new file mode 100755 index 0000000..4d0a705 --- /dev/null +++ b/server/src/routes/webhook-yookassa.js @@ -0,0 +1,61 @@ +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { prisma } from '../lib/prisma.js' +import { validateWebhook } from '../lib/yookassa.js' + +export async function registerYookassaWebhookRoute(fastify) { + fastify.post('/api/webhooks/yookassa', async (request, reply) => { + let body + try { + body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body + } catch (err) { + request.log.error({ err }, 'Failed to parse webhook JSON body') + return reply.code(400).send({ error: 'Invalid JSON body' }) + } + + let event, paymentObject + try { + const clientIp = request.ip + ;({ event, paymentObject } = validateWebhook(clientIp, body)) + } catch (err) { + return reply.code(400).send({ error: err.message }) + } + + const yookassaPaymentId = paymentObject.id + if (!yookassaPaymentId) { + return reply.code(400).send({ error: 'Missing payment id in webhook object' }) + } + + const payment = await prisma.payment.findFirst({ + where: { yookassaPaymentId }, + }) + if (!payment) { + return reply.code(404).send({ error: 'Payment not found' }) + } + + await prisma.payment.update({ + where: { id: payment.id }, + data: { status: paymentObject.status }, + }) + + if (event === 'payment.succeeded') { + const order = await prisma.order.findFirst({ + where: { id: payment.orderId }, + }) + if (order && order.status === 'PENDING_PAYMENT') { + const updated = await prisma.order.updateMany({ + where: { id: payment.orderId, status: 'PENDING_PAYMENT' }, + data: { status: 'PAID' }, + }) + if (updated.count > 0) { + fastify.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId: payment.orderId, + userId: order.userId, + paymentStatus: 'paid', + }) + } + } + } + + return { ok: true } + }) +} diff --git a/server/vitest.config.js b/server/vitest.config.js new file mode 100755 index 0000000..ff72a75 --- /dev/null +++ b/server/vitest.config.js @@ -0,0 +1,17 @@ +import path from 'node:path' +import { defineConfig } from 'vitest/config' + +const projectRoot = path.resolve(__dirname, '..') + +export default defineConfig({ + resolve: { + alias: { + '@shared': path.resolve(projectRoot, 'shared'), + }, + }, + server: { + fs: { + allow: [projectRoot], + }, + }, +}) diff --git a/shared/constants/delivery-carrier.d.ts b/shared/constants/delivery-carrier.d.ts new file mode 100755 index 0000000..d48d06e --- /dev/null +++ b/shared/constants/delivery-carrier.d.ts @@ -0,0 +1,11 @@ +export declare const DELIVERY_CARRIERS: readonly ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST', 'WB_PVZ'] + +export declare const DELIVERY_CARRIER_LABELS: { + readonly RUSSIAN_POST: 'Почта России' + readonly OZON_PVZ: 'Озон доставка (пункт выдачи)' + readonly YANDEX_PVZ: 'Яндекс доставка (пункт выдачи)' + readonly FIVE_POST: '5Post (пункт выдачи)' + readonly WB_PVZ: 'WB доставка (пункт выдачи)' +} + +export declare function deliveryCarrierLabelRu(code: string | null | undefined): string | null diff --git a/shared/constants/delivery-carrier.js b/shared/constants/delivery-carrier.js new file mode 100755 index 0000000..f3afe51 --- /dev/null +++ b/shared/constants/delivery-carrier.js @@ -0,0 +1,14 @@ +export const DELIVERY_CARRIERS = Object.freeze(['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST', 'WB_PVZ']) + +export const DELIVERY_CARRIER_LABELS = Object.freeze({ + RUSSIAN_POST: 'Почта России', + OZON_PVZ: 'Озон доставка (пункт выдачи)', + YANDEX_PVZ: 'Яндекс доставка (пункт выдачи)', + FIVE_POST: '5Post (пункт выдачи)', + WB_PVZ: 'WB доставка (пункт выдачи)', +}) + +export function deliveryCarrierLabelRu(code) { + if (!code) return null + return DELIVERY_CARRIER_LABELS[code] ?? code +} diff --git a/shared/constants/notification-events.d.ts b/shared/constants/notification-events.d.ts new file mode 100755 index 0000000..ef4ab8e --- /dev/null +++ b/shared/constants/notification-events.d.ts @@ -0,0 +1,36 @@ +export type NotificationEventType = + | 'order:created' + | 'order:statusChanged' + | 'orderMessage:sent' + | 'orderMessage:adminReply' + | 'payment:statusChanged' + | 'auth:codeRequested' + | 'order:deliveryFeeAdjusted' + +export type NotificationChannel = 'email' | 'telegram' + +export type NotificationStatus = 'pending' | 'sent' | 'failed' + +export const NOTIFICATION_EVENTS: { + ORDER_CREATED: NotificationEventType + ORDER_STATUS_CHANGED: NotificationEventType + ORDER_MESSAGE_SENT: NotificationEventType + ORDER_MESSAGE_ADMIN_REPLY: NotificationEventType + PAYMENT_STATUS_CHANGED: NotificationEventType + AUTH_CODE_REQUESTED: NotificationEventType + DELIVERY_FEE_ADJUSTED: NotificationEventType +} + +export const NOTIFICATION_CHANNELS: { + EMAIL: NotificationChannel + TELEGRAM: NotificationChannel +} + +export const NOTIFICATION_STATUSES: { + PENDING: NotificationStatus + SENT: NotificationStatus + FAILED: NotificationStatus +} + +export const MAX_RETRY_ATTEMPTS: number +export const RETRY_DELAYS_MS: number[] diff --git a/shared/constants/notification-events.js b/shared/constants/notification-events.js new file mode 100755 index 0000000..c22f625 --- /dev/null +++ b/shared/constants/notification-events.js @@ -0,0 +1,26 @@ +/** @typedef {'order:created' | 'order:statusChanged' | 'orderMessage:sent' | 'orderMessage:adminReply' | 'payment:statusChanged' | 'auth:codeRequested'} NotificationEventType */ + +export const NOTIFICATION_EVENTS = { + ORDER_CREATED: 'order:created', + ORDER_STATUS_CHANGED: 'order:statusChanged', + ORDER_MESSAGE_SENT: 'orderMessage:sent', + ORDER_MESSAGE_ADMIN_REPLY: 'orderMessage:adminReply', + PAYMENT_STATUS_CHANGED: 'payment:statusChanged', + AUTH_CODE_REQUESTED: 'auth:codeRequested', + DELIVERY_FEE_ADJUSTED: 'order:deliveryFeeAdjusted', +} + +export const NOTIFICATION_CHANNELS = { + EMAIL: 'email', + TELEGRAM: 'telegram', +} + +export const NOTIFICATION_STATUSES = { + PENDING: 'pending', + SENT: 'sent', + FAILED: 'failed', +} + +export const MAX_RETRY_ATTEMPTS = 3 + +export const RETRY_DELAYS_MS = [5_000, 30_000, 120_000] diff --git a/shared/constants/order-status.d.ts b/shared/constants/order-status.d.ts new file mode 100755 index 0000000..ad0a04f --- /dev/null +++ b/shared/constants/order-status.d.ts @@ -0,0 +1,17 @@ +export declare const ORDER_STATUSES: readonly [ + 'DRAFT', + 'PENDING_PAYMENT', + 'PAID', + 'IN_PROGRESS', + 'SHIPPED', + 'READY_FOR_PICKUP', + 'DONE', + 'CANCELLED', +] + +export type OrderStatus = (typeof ORDER_STATUSES)[number] + +export declare const ADMIN_ORDER_TRANSITIONS: Record + +export declare function getNextAdminStatuses(from: string, deliveryType: string): string[] +export declare function canTransitionAdminOrderStatus(order: { status: string; deliveryType: string }, next: string): boolean diff --git a/shared/constants/order-status.js b/shared/constants/order-status.js new file mode 100755 index 0000000..6d9a54e --- /dev/null +++ b/shared/constants/order-status.js @@ -0,0 +1,38 @@ +export const ORDER_STATUSES = Object.freeze([ + "DRAFT", + "PENDING_PAYMENT", + "PAID", + "IN_PROGRESS", + "SHIPPED", + "READY_FOR_PICKUP", + "DONE", + "CANCELLED", +]); + +/** + * Допустимые переходы статусов, доступные админу. + * Значение — массив из next-статусов. + * Для IN_PROGRESS: объект с ключами по deliveryType. + */ +export const ADMIN_ORDER_TRANSITIONS = Object.freeze({ + DRAFT: ["PENDING_PAYMENT", "CANCELLED"], + PENDING_PAYMENT: ["PAID", "CANCELLED"], + PAID: ["IN_PROGRESS", "CANCELLED"], + IN_PROGRESS: Object.freeze({ + delivery: ["SHIPPED", "CANCELLED"], + pickup: ["READY_FOR_PICKUP", "CANCELLED"], + }), +}); + +export function getNextAdminStatuses(from, deliveryType) { + const transition = ADMIN_ORDER_TRANSITIONS[from]; + if (!transition) return []; + if (Array.isArray(transition)) return [...transition]; + return transition[deliveryType] ? [...transition[deliveryType]] : []; +} + +export function canTransitionAdminOrderStatus(order, next) { + const from = order.status; + if (from === next) return true; + return getNextAdminStatuses(from, order.deliveryType).includes(next); +} diff --git a/shared/constants/payment-method.d.ts b/shared/constants/payment-method.d.ts new file mode 100755 index 0000000..70a9fee --- /dev/null +++ b/shared/constants/payment-method.d.ts @@ -0,0 +1 @@ +export declare const PAYMENT_METHODS: readonly ['online', 'on_pickup'] diff --git a/shared/constants/payment-method.js b/shared/constants/payment-method.js new file mode 100755 index 0000000..9616e19 --- /dev/null +++ b/shared/constants/payment-method.js @@ -0,0 +1 @@ +export const PAYMENT_METHODS = Object.freeze(['online', 'on_pickup']) diff --git a/shared/constants/test-checklist-items.d.ts b/shared/constants/test-checklist-items.d.ts new file mode 100755 index 0000000..85c683b --- /dev/null +++ b/shared/constants/test-checklist-items.d.ts @@ -0,0 +1,8 @@ +export interface TestChecklistItem { + key: string; + section: string; + action: string; + expectedResult: string; +} + +export declare const TEST_CHECKLIST_ITEMS: readonly TestChecklistItem[]; diff --git a/shared/constants/test-checklist-items.js b/shared/constants/test-checklist-items.js new file mode 100755 index 0000000..662f82c --- /dev/null +++ b/shared/constants/test-checklist-items.js @@ -0,0 +1,304 @@ +export const TEST_CHECKLIST_ITEMS = Object.freeze([ + // Авторизация + { + key: "auth.register-email", + section: "Авторизация", + action: "Зарегистрироваться по email", + expectedResult: "Код приходит на почту, аккаунт создаётся", + }, + { + key: "auth.login-password", + section: "Авторизация", + action: "Войти по паролю", + expectedResult: "Корректный пароль пускает, неправильный — ошибка", + }, + { + key: "auth.oauth-vk", + section: "Авторизация", + action: "Войти через OAuth VK", + expectedResult: "Редирект на VK, callback, авторизация успешна", + }, + { + key: "auth.oauth-yandex", + section: "Авторизация", + action: "Войти через OAuth Yandex", + expectedResult: "Редирект на Yandex, callback, авторизация успешна", + }, + { + key: "auth.reset-password", + section: "Авторизация", + action: "Сбросить пароль", + expectedResult: "Письмо приходит, ссылка работает, пароль меняется", + }, + { + key: "auth.logout", + section: "Авторизация", + action: "Выйти из аккаунта", + expectedResult: "Сессия очищается, редирект на страницу входа", + }, + + // Каталог и товары + { + key: "catalog.homepage", + section: "Каталог и товары", + action: "Открыть главную страницу", + expectedResult: "Слайдер грузится, товары отображаются", + }, + { + key: "catalog.filters", + section: "Каталог и товары", + action: "Применить фильтры", + expectedResult: "Фильтры по категории, цене, материалам работают", + }, + { + key: "catalog.product-page", + section: "Каталог и товары", + action: "Открыть страницу товара", + expectedResult: 'Фото, описание, цена, кнопка "В корзину" отображаются', + }, + { + key: "catalog.seo", + section: "Каталог и товары", + action: "Проверить SEO-метаданные", + expectedResult: "Title, meta, slug корректные", + }, + + // Корзина + { + key: "cart.add", + section: "Корзина", + action: "Добавить товар в корзину", + expectedResult: "Счётчик корзины обновляется", + }, + { + key: "cart.change-qty", + section: "Корзина", + action: "Изменить количество товара", + expectedResult: "Сумма пересчитывается", + }, + { + key: "cart.remove", + section: "Корзина", + action: "Удалить товар из корзины", + expectedResult: "Товар убирается, сумма пересчитывается", + }, + + // Чекаут + { + key: "checkout.address", + section: "Чекаут", + action: "Выбрать адрес доставки", + expectedResult: "Можно выбрать из сохранённых или добавить новый", + }, + { + key: "checkout.delivery", + section: "Чекаут", + action: "Выбрать способ доставки", + expectedResult: "Почта, OZON, Яндекс, 5post — доступны", + }, + { + key: "checkout.payment", + section: "Чекаут", + action: "Выбрать способ оплаты", + expectedResult: "Онлайн / при получении — доступны", + }, + { + key: "checkout.comment", + section: "Чекаут", + action: "Добавить комментарий к заказу", + expectedResult: "Поле работает, текст сохраняется", + }, + { + key: "checkout.create", + section: "Чекаут", + action: "Создать заказ", + expectedResult: "Заказ создаётся, статус DRAFT", + }, + + // Оплата + { + key: "payment.yookassa", + section: "Оплата", + action: "Оплатить через ЮKassa", + expectedResult: "Редирект на оплату, webhook обрабатывается", + }, + { + key: "payment.status", + section: "Оплата", + action: "Проверить статус платежа", + expectedResult: "Статус обновляется после webhook", + }, + + // Профиль пользователя + { + key: "profile.avatar", + section: "Профиль пользователя", + action: "Управление аватаром", + expectedResult: "Загрузка, отображение, удаление работают", + }, + { + key: "profile.settings", + section: "Профиль пользователя", + action: "Изменить настройки профиля", + expectedResult: "Email, имя, способы входа обновляются", + }, + { + key: "profile.addresses", + section: "Профиль пользователя", + action: "Управление адресами", + expectedResult: "Добавление, редактирование, удаление, по умолчанию", + }, + { + key: "profile.orders", + section: "Профиль пользователя", + action: "Просмотр заказов", + expectedResult: "Список, детали, статусы отображаются", + }, + { + key: "profile.messages", + section: "Профиль пользователя", + action: "Сообщения по заказу", + expectedResult: "Отправка, получение, read state работают", + }, + { + key: "profile.notifications", + section: "Профиль пользователя", + action: "Настройки уведомлений", + expectedResult: "Вкл/выкл каналов работают", + }, + { + key: "profile.delete-account", + section: "Профиль пользователя", + action: "Удалить аккаунт", + expectedResult: "Данные удаляются", + }, + + // Админ — Товары + { + key: "admin-products.list", + section: "Админ — Товары", + action: "Открыть список товаров", + expectedResult: "Пагинация, поиск работают", + }, + { + key: "admin-products.create", + section: "Админ — Товары", + action: "Создать товар", + expectedResult: + "Все поля сохраняются, фото загружаются, публикация работает", + }, + { + key: "admin-products.edit", + section: "Админ — Товары", + action: "Редактировать товар", + expectedResult: "Изменения сохраняются", + }, + { + key: "admin-products.delete", + section: "Админ — Товары", + action: "Удалить товар", + expectedResult: "Товар удаляется", + }, + { + key: "admin-products.images", + section: "Админ — Товары", + action: "Управление изображениями товара", + expectedResult: "Добавление, сортировка, удаление работают", + }, + + // Админ — Категории + { + key: "admin-categories.crud", + section: "Админ — Категории", + action: "CRUD категорий", + expectedResult: "Создание, редактирование, удаление, сортировка работают", + }, + + // Админ — Заказы + { + key: "admin-orders.list", + section: "Админ — Заказы", + action: "Открыть список заказов", + expectedResult: "Фильтрация по статусу, внимание отображается", + }, + { + key: "admin-orders.details", + section: "Админ — Заказы", + action: "Открыть детали заказа", + expectedResult: "Состав, статус, смена статуса работают", + }, + { + key: "admin-orders.messages", + section: "Админ — Заказы", + action: "Ответить на сообщение заказа", + expectedResult: "Сообщение отправляется пользователю", + }, + + // Админ — Отзывы + { + key: "admin-reviews.list", + section: "Админ — Отзывы", + action: "Открыть список отзывов", + expectedResult: "Фильтрация pending/approved/rejected работает", + }, + { + key: "admin-reviews.moderate", + section: "Админ — Отзывы", + action: "Модерировать отзыв", + expectedResult: "Approve/reject работают", + }, + + // Админ — Пользователи + { + key: "admin-users.list", + section: "Админ — Пользователи", + action: "Открыть список пользователей", + expectedResult: "Email, дата регистрации отображаются", + }, + { + key: "admin-users.orders", + section: "Админ — Пользователи", + action: "Просмотр заказов пользователя", + expectedResult: "Заказы пользователя отображаются", + }, + + // Админ — Галерея + { + key: "admin-gallery.upload", + section: "Админ — Галерея", + action: "Управление галереей", + expectedResult: "Загрузка, удаление, использование в слайдере работают", + }, + + // Админ — Настройки + { + key: "admin-settings.notifications", + section: "Админ — Настройки", + action: "Настройки уведомлений админа", + expectedResult: "Email, telegram настраиваются", + }, + + // Инфо-страницы + { + key: "info.pages", + section: "Инфо-страницы", + action: "Открыть инфо-страницы", + expectedResult: + "Доставка, оплата, как заказать, статусы заказов отображаются", + }, + { + key: "info.legal", + section: "Инфо-страницы", + action: "Открыть юридические страницы", + expectedResult: + "Политика конфиденциальности, условия использования отображаются", + }, + + // SSE / Realtime + { + key: "sse.notifications", + section: "SSE / Realtime", + action: "Проверить SSE-уведомления", + expectedResult: "Уведомления приходят в реальном времени", + }, +]); diff --git a/shared/constants/upload-limits.d.ts b/shared/constants/upload-limits.d.ts new file mode 100755 index 0000000..88f7e54 --- /dev/null +++ b/shared/constants/upload-limits.d.ts @@ -0,0 +1,5 @@ +export declare const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT: 20971520 + +export declare const ADMIN_UPLOAD_IMAGE_MAX_BYTES: 20971520 + +export declare function formatAdminImageMaxSizeHint(): string diff --git a/shared/constants/upload-limits.js b/shared/constants/upload-limits.js new file mode 100755 index 0000000..ffea1d9 --- /dev/null +++ b/shared/constants/upload-limits.js @@ -0,0 +1,9 @@ +const MB = 1024 * 1024 + +export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = 20 * MB + +export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = 20 * MB + +export function formatAdminImageMaxSizeHint() { + return '20 МБ' +}