initial: server + shared

This commit is contained in:
admin
2026-06-11 13:41:38 +05:00
commit 65da047e7c
148 changed files with 15900 additions and 0 deletions
+44
View File
@@ -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=
+4
View File
@@ -0,0 +1,4 @@
node_modules
# Keep environment variables out of version control
.env
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"semi": false,
"printWidth": 120,
"trailingComma": "all",
"endOfLine": "lf",
"arrowParens": "always"
}
+64
View File
@@ -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',
},
},
]
Generated Executable
+5502
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -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"
}
}
+28
View File
@@ -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");
+30
View File
@@ -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;
@@ -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");
@@ -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;
@@ -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;
@@ -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");
@@ -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;
@@ -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");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "deliveryCarrier" TEXT;
@@ -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");
@@ -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;
@@ -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;
@@ -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");
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "NotificationPreference_userId_idx";
@@ -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;
@@ -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");
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "Payment_yookassaPaymentId_idx";
@@ -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;
@@ -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;
@@ -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;
+3
View File
@@ -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"
BIN
View File
Binary file not shown.
+353
View File
@@ -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
}
+13
View File
@@ -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())
+253
View File
@@ -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
View File
@@ -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' })
})
})
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { escapeHtml } from '../escape-html.js'
describe('escapeHtml', () => {
it('escapes & < > "', () => {
expect(escapeHtml('&<>"')).toBe('&amp;&lt;&gt;&quot;')
})
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('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;')
})
})
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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$/)
})
})
+32
View File
@@ -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)
})
})
+257
View File
@@ -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()
})
})
+12
View File
@@ -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 })
}
}
}
+106
View File
@@ -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
View File
@@ -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,
},
})
}
}
+20
View File
@@ -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
}
+11
View File
@@ -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)
}
+64
View File
@@ -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 }
}
}
+8
View File
@@ -0,0 +1,8 @@
/** Минимальное экранирование для безопасного HTML из пользовательского ввода. */
export function escapeHtml(input) {
return String(input ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
+12
View File
@@ -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
}
+9
View File
@@ -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()
}
+143
View File
@@ -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`
}
+96
View File
@@ -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
View File
@@ -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
},
}
+69
View File
@@ -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)
},
}
+7
View File
@@ -0,0 +1,7 @@
import { EventEmitter } from 'node:events'
export function createEventBus() {
const bus = new EventEmitter()
bus.setMaxListeners(50)
return bus
}
+105
View File
@@ -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 },
})
}
+134
View File
@@ -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
View File
@@ -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Ожидает оплаты.`
}
+5
View File
@@ -0,0 +1,5 @@
export {
ORDER_STATUSES,
getNextAdminStatuses,
canTransitionAdminOrderStatus,
} from '../../../shared/constants/order-status.js'
+9
View File
@@ -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
}
+51
View File
@@ -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')
}
+13
View File
@@ -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}`
}
+88
View File
@@ -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}`
}
+50
View File
@@ -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} МБ).`
}
+23
View File
@@ -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
}
+182
View File
@@ -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
}
+296
View File
@@ -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()
}
})
})
+259
View File
@@ -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)
})
})
+44
View File
@@ -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: 'Недостаточно прав' })
}
})
}
+132
View File
@@ -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))
})
}
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
})
})
+238
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
})
})
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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