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