Compare commits
27 Commits
60e0ac630d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcc2eb7450 | ||
|
|
c641a3dd23 | ||
|
|
47ba203edc | ||
|
|
4d46174bbb | ||
|
|
0103c3fb8a | ||
|
|
2cd8d0b612 | ||
|
|
da31e21406 | ||
|
|
40b4515305 | ||
|
|
b321075293 | ||
|
|
92592e2baa | ||
|
|
386f6fa9fe | ||
|
|
db2e05bbf4 | ||
|
|
c6a515803b | ||
|
|
44c24c4abd | ||
|
|
0f8f64a8a2 | ||
|
|
84184f4568 | ||
|
|
c6634bfe5b | ||
|
|
d28b26629c | ||
|
|
8c2b4c1092 | ||
|
|
5acafba77c | ||
|
|
6c5b9ef98e | ||
|
|
1bec782edd | ||
|
|
2499aa1a6a | ||
|
|
a0cbae390c | ||
|
|
4278219c03 | ||
|
|
3abebf3701 | ||
|
|
4281afd7e8 |
@@ -14,3 +14,4 @@ SMTP_SECURE=false
|
|||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASS=
|
SMTP_PASS=
|
||||||
SMTP_FROM=
|
SMTP_FROM=
|
||||||
|
AUTH_LOGIN_CODE_DELIVERY=email
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ RUN npm ci && npx prisma generate
|
|||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
|
COPY data ./data
|
||||||
|
|
||||||
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npx prisma migrate deploy && node src/server.js"]
|
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npx prisma migrate deploy && node src/server.js"]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Fregat Apollo Backend
|
# Fregat Backend
|
||||||
|
|
||||||
GraphQL backend for Fregat client cabinet and manager cabinet.
|
GraphQL backend for Fregat client cabinet and manager cabinet.
|
||||||
|
|
||||||
|
|||||||
2363
data/catalog-import-2026-04-08.json
Normal file
2363
data/catalog-import-2026-04-08.json
Normal file
File diff suppressed because it is too large
Load Diff
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "fregat-apollo-backend",
|
"name": "fregat-backend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "fregat-apollo-backend",
|
"name": "fregat-backend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "fregat-apollo-backend",
|
"name": "fregat-backend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate deploy",
|
"prisma:migrate": "prisma migrate deploy",
|
||||||
"prisma:push": "prisma db push",
|
"prisma:push": "prisma db push",
|
||||||
"seed": "node scripts/seed.js"
|
"seed": "node scripts/seed.js",
|
||||||
|
"seed:demo": "node scripts/seed-demo.js",
|
||||||
|
"import:catalog": "sh -c '. /app/scripts/load-vault-env.sh && node scripts/import-catalog.js'"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
2
prisma/migrations/0008_order_item_pricing/migration.sql
Normal file
2
prisma/migrations/0008_order_item_pricing/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "OrderItem"
|
||||||
|
ADD COLUMN "unitPrice" DECIMAL(14, 2);
|
||||||
34
prisma/migrations/0009_referral_bonus_links/migration.sql
Normal file
34
prisma/migrations/0009_referral_bonus_links/migration.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
ALTER TABLE "ReferralLink"
|
||||||
|
ADD COLUMN "createdById" TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE "ReferralLink"
|
||||||
|
ADD COLUMN "bonusPercent" DECIMAL(5,2);
|
||||||
|
|
||||||
|
UPDATE "ReferralLink"
|
||||||
|
SET
|
||||||
|
"createdById" = "referrerId",
|
||||||
|
"bonusPercent" = 0;
|
||||||
|
|
||||||
|
ALTER TABLE "ReferralLink"
|
||||||
|
ALTER COLUMN "createdById" SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "ReferralLink"
|
||||||
|
ALTER COLUMN "bonusPercent" SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "ReferralLink"
|
||||||
|
ADD CONSTRAINT "ReferralLink_createdById_fkey"
|
||||||
|
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX "ReferralLink_referrerId_idx" ON "ReferralLink"("referrerId");
|
||||||
|
|
||||||
|
CREATE INDEX "ReferralLink_refereeId_idx" ON "ReferralLink"("refereeId");
|
||||||
|
|
||||||
|
ALTER TABLE "BonusTransaction"
|
||||||
|
ADD COLUMN "referralLinkId" TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE "BonusTransaction"
|
||||||
|
ADD CONSTRAINT "BonusTransaction_referralLinkId_fkey"
|
||||||
|
FOREIGN KEY ("referralLinkId") REFERENCES "ReferralLink"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "BonusTransaction_orderId_referralLinkId_key"
|
||||||
|
ON "BonusTransaction"("orderId", "referralLinkId");
|
||||||
3
prisma/migrations/0010_add_product_tags/migration.sql
Normal file
3
prisma/migrations/0010_add_product_tags/migration.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Product" ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CatalogProductTypeSetting" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productType" TEXT NOT NULL,
|
||||||
|
"showQuantityPerBox" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"allowCustomLength" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"customLengthMinM" INTEGER,
|
||||||
|
"customLengthMaxM" INTEGER,
|
||||||
|
"customLengthStepM" INTEGER,
|
||||||
|
"allowCustomSleeveBrand" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"allowCustomLabel" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CatalogProductTypeSetting_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CatalogProductTypeSetting_productType_key" ON "CatalogProductTypeSetting"("productType");
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CatalogProductTypeSetting" ADD COLUMN "colorOptions" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
ADD COLUMN "labelOptions" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
ADD COLUMN "lengthOptionsM" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
|
||||||
|
ADD COLUMN "sleeveOptions" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
ADD COLUMN "thicknessOptionsMicron" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
|
||||||
|
ADD COLUMN "widthOptionsMm" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "bonusProgramEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
@@ -60,6 +60,7 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
fullName String
|
fullName String
|
||||||
role UserRole
|
role UserRole
|
||||||
|
bonusProgramEnabled Boolean @default(false)
|
||||||
companyId String?
|
companyId String?
|
||||||
company Company? @relation(fields: [companyId], references: [id])
|
company Company? @relation(fields: [companyId], references: [id])
|
||||||
counterpartyProfile CounterpartyProfile?
|
counterpartyProfile CounterpartyProfile?
|
||||||
@@ -77,6 +78,7 @@ model User {
|
|||||||
orderStatusEvents OrderStatusEvent[]
|
orderStatusEvents OrderStatusEvent[]
|
||||||
referralAsReferrer ReferralLink[] @relation("ReferralReferrer")
|
referralAsReferrer ReferralLink[] @relation("ReferralReferrer")
|
||||||
referralAsReferee ReferralLink[] @relation("ReferralReferee")
|
referralAsReferee ReferralLink[] @relation("ReferralReferee")
|
||||||
|
createdReferralLinks ReferralLink[] @relation("ReferralCreator")
|
||||||
bonusTransactions BonusTransaction[]
|
bonusTransactions BonusTransaction[]
|
||||||
withdrawalRequests RewardWithdrawalRequest[] @relation("WithdrawalRequester")
|
withdrawalRequests RewardWithdrawalRequest[] @relation("WithdrawalRequester")
|
||||||
reviewedWithdrawals RewardWithdrawalRequest[] @relation("WithdrawalReviewer")
|
reviewedWithdrawals RewardWithdrawalRequest[] @relation("WithdrawalReviewer")
|
||||||
@@ -178,6 +180,7 @@ model Product {
|
|||||||
thicknessMicron Int?
|
thicknessMicron Int?
|
||||||
sleeveBrand String?
|
sleeveBrand String?
|
||||||
quantityPerBox String?
|
quantityPerBox String?
|
||||||
|
tags String[] @default([])
|
||||||
description String?
|
description String?
|
||||||
isCustomizable Boolean @default(false)
|
isCustomizable Boolean @default(false)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
@@ -188,6 +191,26 @@ model Product {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CatalogProductTypeSetting {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
productType String @unique
|
||||||
|
showQuantityPerBox Boolean @default(false)
|
||||||
|
allowCustomLength Boolean @default(false)
|
||||||
|
customLengthMinM Int?
|
||||||
|
customLengthMaxM Int?
|
||||||
|
customLengthStepM Int?
|
||||||
|
allowCustomSleeveBrand Boolean @default(false)
|
||||||
|
allowCustomLabel Boolean @default(false)
|
||||||
|
widthOptionsMm Int[] @default([])
|
||||||
|
lengthOptionsM Int[] @default([])
|
||||||
|
thicknessOptionsMicron Int[] @default([])
|
||||||
|
sleeveOptions String[] @default([])
|
||||||
|
colorOptions String[] @default([])
|
||||||
|
labelOptions String[] @default([])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model Cart {
|
model Cart {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
@@ -273,6 +296,7 @@ model OrderItem {
|
|||||||
product Product? @relation(fields: [productId], references: [id])
|
product Product? @relation(fields: [productId], references: [id])
|
||||||
productName String
|
productName String
|
||||||
quantity Decimal @db.Decimal(14, 3)
|
quantity Decimal @db.Decimal(14, 3)
|
||||||
|
unitPrice Decimal? @db.Decimal(14, 2)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,24 +312,34 @@ model OrderStatusEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ReferralLink {
|
model ReferralLink {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
referrerId String
|
referrerId String
|
||||||
referrer User @relation("ReferralReferrer", fields: [referrerId], references: [id])
|
referrer User @relation("ReferralReferrer", fields: [referrerId], references: [id])
|
||||||
refereeId String
|
refereeId String
|
||||||
referee User @relation("ReferralReferee", fields: [refereeId], references: [id])
|
referee User @relation("ReferralReferee", fields: [refereeId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdById String
|
||||||
|
createdBy User @relation("ReferralCreator", fields: [createdById], references: [id])
|
||||||
|
bonusPercent Decimal @db.Decimal(5, 2)
|
||||||
|
bonusTransactions BonusTransaction[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@unique([referrerId, refereeId])
|
@@unique([referrerId, refereeId])
|
||||||
|
@@index([referrerId])
|
||||||
|
@@index([refereeId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model BonusTransaction {
|
model BonusTransaction {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
amount Decimal @db.Decimal(14, 2)
|
amount Decimal @db.Decimal(14, 2)
|
||||||
reason String
|
reason String
|
||||||
orderId String?
|
orderId String?
|
||||||
createdAt DateTime @default(now())
|
referralLinkId String?
|
||||||
|
referralLink ReferralLink? @relation(fields: [referralLinkId], references: [id], onDelete: SetNull)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([orderId, referralLinkId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model RewardWithdrawalRequest {
|
model RewardWithdrawalRequest {
|
||||||
|
|||||||
105
scripts/import-catalog.js
Normal file
105
scripts/import-catalog.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
import { prisma } from '../src/prisma-client.js';
|
||||||
|
|
||||||
|
const IMPORT_DESCRIPTION_PREFIX = 'Импорт каталога 2026-04-08: ';
|
||||||
|
const dataset = JSON.parse(
|
||||||
|
readFileSync(new URL('../data/catalog-import-2026-04-08.json', import.meta.url), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const warehouses = [
|
||||||
|
{ code: 'MSK-01', name: 'Склад Москва' },
|
||||||
|
{ code: 'SPB-01', name: 'Склад СПб' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatQuantity(value) {
|
||||||
|
return Number(value ?? 0).toFixed(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const warehouse of warehouses) {
|
||||||
|
await prisma.warehouse.upsert({
|
||||||
|
where: { code: warehouse.code },
|
||||||
|
update: { name: warehouse.name },
|
||||||
|
create: warehouse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const warehouseIds = Object.fromEntries(
|
||||||
|
await Promise.all(
|
||||||
|
warehouses.map(async (warehouse) => {
|
||||||
|
const persistedWarehouse = await prisma.warehouse.findUniqueOrThrow({
|
||||||
|
where: { code: warehouse.code },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return [warehouse.code, persistedWarehouse.id];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.product.updateMany({
|
||||||
|
data: {
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let importedCount = 0;
|
||||||
|
|
||||||
|
for (const item of dataset) {
|
||||||
|
const product = await prisma.product.upsert({
|
||||||
|
where: { sku: item.sku },
|
||||||
|
update: {
|
||||||
|
name: item.name,
|
||||||
|
productType: item.productType,
|
||||||
|
widthMm: item.widthMm,
|
||||||
|
lengthM: item.lengthM,
|
||||||
|
thicknessMicron: item.thicknessMicron,
|
||||||
|
sleeveBrand: item.sleeveBrand,
|
||||||
|
quantityPerBox: item.quantityPerBox,
|
||||||
|
tags: item.tags,
|
||||||
|
description: `${IMPORT_DESCRIPTION_PREFIX}${item.rawName}`,
|
||||||
|
isCustomizable: false,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
sku: item.sku,
|
||||||
|
name: item.name,
|
||||||
|
productType: item.productType,
|
||||||
|
widthMm: item.widthMm,
|
||||||
|
lengthM: item.lengthM,
|
||||||
|
thicknessMicron: item.thicknessMicron,
|
||||||
|
sleeveBrand: item.sleeveBrand,
|
||||||
|
quantityPerBox: item.quantityPerBox,
|
||||||
|
tags: item.tags,
|
||||||
|
description: `${IMPORT_DESCRIPTION_PREFIX}${item.rawName}`,
|
||||||
|
isCustomizable: false,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const warehouse of warehouses) {
|
||||||
|
await prisma.productStock.upsert({
|
||||||
|
where: {
|
||||||
|
productId_warehouseId: {
|
||||||
|
productId: product.id,
|
||||||
|
warehouseId: warehouseIds[warehouse.code],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
availableQty: formatQuantity(item.balances[warehouse.code]),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
productId: product.id,
|
||||||
|
warehouseId: warehouseIds[warehouse.code],
|
||||||
|
availableQty: formatQuantity(item.balances[warehouse.code]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
importedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Imported ${importedCount} catalog products from 2026-04-08 stock reports.`);
|
||||||
|
await prisma.$disconnect();
|
||||||
536
scripts/seed-demo.js
Normal file
536
scripts/seed-demo.js
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
import { prisma } from '../src/prisma-client.js';
|
||||||
|
|
||||||
|
const MANAGER_EMAIL = 'manager@fregat.local';
|
||||||
|
const DEMO_EMAIL_DOMAIN = 'demo.fregat.local';
|
||||||
|
const DEMO_ORDER_PREFIX = 'DBG-';
|
||||||
|
const DEMO_CLIENT_COUNT = Number.parseInt(process.env.DEMO_CLIENTS ?? '20', 10);
|
||||||
|
const DEMO_ORDER_COUNT = Number.parseInt(process.env.DEMO_ORDERS ?? '40', 10);
|
||||||
|
|
||||||
|
const FIRST_NAMES = [
|
||||||
|
'Алексей', 'Мария', 'Ирина', 'Дмитрий', 'Светлана',
|
||||||
|
'Павел', 'Ольга', 'Иван', 'Наталья', 'Егор',
|
||||||
|
'Виктория', 'Максим', 'Елена', 'Роман', 'Анна',
|
||||||
|
'Кирилл', 'Юлия', 'Андрей', 'Татьяна', 'Сергей',
|
||||||
|
];
|
||||||
|
|
||||||
|
const LAST_NAMES = [
|
||||||
|
'Иванов', 'Петрова', 'Смирнова', 'Козлов', 'Васильева',
|
||||||
|
'Федоров', 'Морозова', 'Захаров', 'Орлова', 'Новиков',
|
||||||
|
'Романова', 'Соколов', 'Беляева', 'Громов', 'Крылова',
|
||||||
|
'Титов', 'Борисова', 'Попов', 'Лебедева', 'Макаров',
|
||||||
|
];
|
||||||
|
|
||||||
|
const CITIES = [
|
||||||
|
'Москва',
|
||||||
|
'Санкт-Петербург',
|
||||||
|
'Казань',
|
||||||
|
'Екатеринбург',
|
||||||
|
'Новосибирск',
|
||||||
|
'Нижний Новгород',
|
||||||
|
'Самара',
|
||||||
|
'Краснодар',
|
||||||
|
'Ростов-на-Дону',
|
||||||
|
'Воронеж',
|
||||||
|
];
|
||||||
|
|
||||||
|
const STREETS = [
|
||||||
|
'Ленинградский проспект',
|
||||||
|
'Кубинская улица',
|
||||||
|
'улица Родины',
|
||||||
|
'Промышленная улица',
|
||||||
|
'улица Победы',
|
||||||
|
'Складской проезд',
|
||||||
|
'Транспортная улица',
|
||||||
|
'улица Энергетиков',
|
||||||
|
'Рабочая улица',
|
||||||
|
'Индустриальный проспект',
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMPANY_PREFIXES = [
|
||||||
|
'ТД', 'ПК', 'Логистик', 'Сервис', 'Группа',
|
||||||
|
'Снаб', 'Пром', 'Регион', 'Партнер', 'Трейд',
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMPANY_SUFFIXES = [
|
||||||
|
'Пласт', 'Пак', 'Транс', 'Маркет', 'Снабжение',
|
||||||
|
'Лайн', 'Система', 'Поставка', 'Ритейл', 'Ресурс',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ORDER_STATUS_CYCLE = [
|
||||||
|
'NEW',
|
||||||
|
'MANAGER_PROCESSING',
|
||||||
|
'WAITING_DOUBLE_CONFIRM',
|
||||||
|
'CONFIRMED',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
'COMPLETED',
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatIndex(index) {
|
||||||
|
return String(index).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClientEmail(index) {
|
||||||
|
return `client${formatIndex(index)}@${DEMO_EMAIL_DOMAIN}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInn(index) {
|
||||||
|
return `7702${String(index).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOgrn(index) {
|
||||||
|
return `102770${String(index).padStart(7, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBik(index) {
|
||||||
|
return `0445${String(10000 + index).slice(-5)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAccount(prefix, index) {
|
||||||
|
return `${prefix}${String(100000000000000000 + index).slice(-18)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function atMiddayDaysAgo(daysAgo) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(12, 0, 0, 0);
|
||||||
|
date.setDate(date.getDate() - daysAgo);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHours(date, hours) {
|
||||||
|
return new Date(date.getTime() + (hours * 60 * 60 * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMoney(value) {
|
||||||
|
return value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOrderTimeline(status, createdAt) {
|
||||||
|
const steps = [
|
||||||
|
{ status: 'NEW', note: '[demo] Заказ создан клиентом.' },
|
||||||
|
{ status: 'MANAGER_PROCESSING', note: '[demo] Менеджер взял заказ в работу.' },
|
||||||
|
{ status: 'WAITING_DOUBLE_CONFIRM', note: '[demo] Согласование условий и цены.' },
|
||||||
|
{ status: 'CONFIRMED', note: '[demo] Стороны подтвердили заказ.' },
|
||||||
|
{ status: 'IN_PROGRESS', note: '[demo] Заказ передан в исполнение.' },
|
||||||
|
{ status: 'COMPLETED', note: '[demo] Заказ доставлен клиенту.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetIndex = steps.findIndex((step) => step.status === status);
|
||||||
|
if (targetIndex === -1) {
|
||||||
|
return [steps[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps.slice(0, targetIndex + 1).map((step, index) => ({
|
||||||
|
...step,
|
||||||
|
createdAt: addHours(createdAt, index * 6),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderKindForIndex(index) {
|
||||||
|
return index % 5 === 0 ? 'CALCULATION' : 'READY';
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderStatusForIndex(index) {
|
||||||
|
return ORDER_STATUS_CYCLE[index % ORDER_STATUS_CYCLE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function companyNameForIndex(index) {
|
||||||
|
const prefix = COMPANY_PREFIXES[index % COMPANY_PREFIXES.length];
|
||||||
|
const suffix = COMPANY_SUFFIXES[index % COMPANY_SUFFIXES.length];
|
||||||
|
return `${prefix} ${suffix} ${formatIndex(index)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullNameForIndex(index) {
|
||||||
|
return `${FIRST_NAMES[(index - 1) % FIRST_NAMES.length]} ${LAST_NAMES[(index - 1) % LAST_NAMES.length]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertClient(index) {
|
||||||
|
const companyName = companyNameForIndex(index);
|
||||||
|
const company = await prisma.company.upsert({
|
||||||
|
where: { inn: buildInn(index) },
|
||||||
|
update: { name: companyName },
|
||||||
|
create: {
|
||||||
|
name: companyName,
|
||||||
|
inn: buildInn(index),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email: buildClientEmail(index) },
|
||||||
|
update: {
|
||||||
|
fullName: fullNameForIndex(index),
|
||||||
|
role: 'CLIENT',
|
||||||
|
bonusProgramEnabled: index % 2 === 1,
|
||||||
|
companyId: company.id,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
email: buildClientEmail(index),
|
||||||
|
fullName: fullNameForIndex(index),
|
||||||
|
role: 'CLIENT',
|
||||||
|
bonusProgramEnabled: index % 2 === 1,
|
||||||
|
companyId: company.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.counterpartyProfile.upsert({
|
||||||
|
where: { userId: user.id },
|
||||||
|
update: {
|
||||||
|
companyName,
|
||||||
|
companyFullName: `${companyName}, общество с ограниченной ответственностью`,
|
||||||
|
inn: buildInn(index),
|
||||||
|
kpp: `7702${String(2000 + index).slice(-4)}`,
|
||||||
|
ogrn: buildOgrn(index),
|
||||||
|
legalAddress: `${CITIES[(index - 1) % CITIES.length]}, ${STREETS[(index - 1) % STREETS.length]}, ${10 + index}`,
|
||||||
|
bankName: 'АО Тест Банк',
|
||||||
|
bik: buildBik(index),
|
||||||
|
correspondentAccount: buildAccount('30101', index),
|
||||||
|
checkingAccount: buildAccount('40702', index),
|
||||||
|
signerFullName: fullNameForIndex(index),
|
||||||
|
signerPosition: 'Генеральный директор',
|
||||||
|
signerBasis: 'Устав',
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
companyName,
|
||||||
|
companyFullName: `${companyName}, общество с ограниченной ответственностью`,
|
||||||
|
inn: buildInn(index),
|
||||||
|
kpp: `7702${String(2000 + index).slice(-4)}`,
|
||||||
|
ogrn: buildOgrn(index),
|
||||||
|
legalAddress: `${CITIES[(index - 1) % CITIES.length]}, ${STREETS[(index - 1) % STREETS.length]}, ${10 + index}`,
|
||||||
|
bankName: 'АО Тест Банк',
|
||||||
|
bik: buildBik(index),
|
||||||
|
correspondentAccount: buildAccount('30101', index),
|
||||||
|
checkingAccount: buildAccount('40702', index),
|
||||||
|
signerFullName: fullNameForIndex(index),
|
||||||
|
signerPosition: 'Генеральный директор',
|
||||||
|
signerBasis: 'Устав',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addressLabel = 'Основной адрес';
|
||||||
|
const addressValue = `${CITIES[(index - 1) % CITIES.length]}, ${STREETS[(index - 1) % STREETS.length]}, ${20 + index}`;
|
||||||
|
const existingAddress = await prisma.deliveryAddress.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
label: addressLabel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = existingAddress
|
||||||
|
? await prisma.deliveryAddress.update({
|
||||||
|
where: { id: existingAddress.id },
|
||||||
|
data: {
|
||||||
|
address: addressValue,
|
||||||
|
unrestrictedValue: addressValue,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await prisma.deliveryAddress.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
label: addressLabel,
|
||||||
|
address: addressValue,
|
||||||
|
unrestrictedValue: addressValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
defaultDeliveryAddressId: address.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user, address };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildMessengerConnections(clients) {
|
||||||
|
await prisma.messengerConnection.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: {
|
||||||
|
in: clients.map((entry) => entry.user.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [index, client] of clients.entries()) {
|
||||||
|
const demoIndex = index + 1;
|
||||||
|
|
||||||
|
if (demoIndex % 2 === 1) {
|
||||||
|
await prisma.messengerConnection.create({
|
||||||
|
data: {
|
||||||
|
userId: client.user.id,
|
||||||
|
type: 'TELEGRAM',
|
||||||
|
channelId: `70000${demoIndex}`,
|
||||||
|
displayName: client.user.fullName,
|
||||||
|
username: `fregat_demo_${formatIndex(demoIndex)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (demoIndex % 3 === 0) {
|
||||||
|
await prisma.messengerConnection.create({
|
||||||
|
data: {
|
||||||
|
userId: client.user.id,
|
||||||
|
type: 'MAX',
|
||||||
|
channelId: `90000${demoIndex}`,
|
||||||
|
displayName: client.user.fullName,
|
||||||
|
username: `max_demo_${formatIndex(demoIndex)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupDemoData(demoUserIds) {
|
||||||
|
const demoOrders = await prisma.order.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ code: { startsWith: DEMO_ORDER_PREFIX } },
|
||||||
|
{ customerId: { in: demoUserIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const demoOrderIds = demoOrders.map((order) => order.id);
|
||||||
|
|
||||||
|
if (demoOrderIds.length) {
|
||||||
|
await prisma.orderStatusEvent.deleteMany({
|
||||||
|
where: { orderId: { in: demoOrderIds } },
|
||||||
|
});
|
||||||
|
await prisma.orderItem.deleteMany({
|
||||||
|
where: { orderId: { in: demoOrderIds } },
|
||||||
|
});
|
||||||
|
await prisma.bonusTransaction.deleteMany({
|
||||||
|
where: { orderId: { in: demoOrderIds } },
|
||||||
|
});
|
||||||
|
await prisma.order.deleteMany({
|
||||||
|
where: { id: { in: demoOrderIds } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.rewardWithdrawalRequest.deleteMany({
|
||||||
|
where: { requesterId: { in: demoUserIds } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.bonusTransaction.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: { in: demoUserIds },
|
||||||
|
reason: { startsWith: '[demo]' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.referralLink.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ referrerId: { in: demoUserIds } },
|
||||||
|
{ refereeId: { in: demoUserIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createReferralLinks(managerId, clients) {
|
||||||
|
const links = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < Math.min(10, Math.floor(clients.length / 2)); index += 1) {
|
||||||
|
const referrer = clients[index].user;
|
||||||
|
const referee = clients[index + 10]?.user;
|
||||||
|
if (!referee) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = await prisma.referralLink.create({
|
||||||
|
data: {
|
||||||
|
referrerId: referrer.id,
|
||||||
|
refereeId: referee.id,
|
||||||
|
createdById: managerId,
|
||||||
|
bonusPercent: toMoney(5 + (index % 4) * 2.5),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
links.push(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOrders(managerId, clients, products, referralLinks) {
|
||||||
|
const referralByRefereeId = new Map(referralLinks.map((link) => [link.refereeId, link]));
|
||||||
|
let completedOrders = 0;
|
||||||
|
|
||||||
|
for (let index = 1; index <= DEMO_ORDER_COUNT; index += 1) {
|
||||||
|
const client = clients[(index - 1) % clients.length];
|
||||||
|
const createdAt = atMiddayDaysAgo(DEMO_ORDER_COUNT - index + 1);
|
||||||
|
const status = orderStatusForIndex(index - 1);
|
||||||
|
const kind = orderKindForIndex(index);
|
||||||
|
const itemCount = 1 + (index % 3);
|
||||||
|
const orderProducts = Array.from({ length: itemCount }, (_, itemIndex) => (
|
||||||
|
products[(index + itemIndex * 3) % products.length]
|
||||||
|
));
|
||||||
|
|
||||||
|
const preparedItems = orderProducts.map((product, itemIndex) => {
|
||||||
|
const quantity = 5 + ((index + itemIndex * 2) % 18);
|
||||||
|
const unitPrice = 72 + ((index * 11 + itemIndex * 7) % 65);
|
||||||
|
return {
|
||||||
|
product,
|
||||||
|
quantity,
|
||||||
|
unitPrice,
|
||||||
|
lineTotal: quantity * unitPrice,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPrice = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||||
|
const deliveryFee = 1200 + (index % 5) * 350;
|
||||||
|
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
code: `${DEMO_ORDER_PREFIX}${String(3000 + index)}`,
|
||||||
|
kind,
|
||||||
|
customerId: client.user.id,
|
||||||
|
managerId,
|
||||||
|
deliveryAddressId: client.address.id,
|
||||||
|
deliveryAddress: client.address.address,
|
||||||
|
deliveryTerms: index % 4 === 0
|
||||||
|
? 'Доставка до адреса клиента'
|
||||||
|
: 'Самовывоз со склада после подтверждения',
|
||||||
|
deliveryFee: toMoney(deliveryFee),
|
||||||
|
totalPrice: toMoney(totalPrice + deliveryFee),
|
||||||
|
status,
|
||||||
|
clientApproved: ['CONFIRMED', 'IN_PROGRESS', 'COMPLETED'].includes(status) ? true : null,
|
||||||
|
managerApproved: ['WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS', 'COMPLETED'].includes(status) ? true : null,
|
||||||
|
calculationPayload: kind === 'CALCULATION'
|
||||||
|
? {
|
||||||
|
note: 'Демо-расчет для интерфейсов менеджера.',
|
||||||
|
requestedAt: createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: addHours(createdAt, 12),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of preparedItems) {
|
||||||
|
await prisma.orderItem.create({
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
productId: item.product.id,
|
||||||
|
productName: item.product.name,
|
||||||
|
quantity: item.quantity.toFixed(3),
|
||||||
|
unitPrice: toMoney(item.unitPrice),
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = createOrderTimeline(status, createdAt);
|
||||||
|
for (const event of timeline) {
|
||||||
|
await prisma.orderStatusEvent.create({
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
status: event.status,
|
||||||
|
actorUserId: managerId,
|
||||||
|
note: event.note,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'COMPLETED') {
|
||||||
|
completedOrders += 1;
|
||||||
|
const referralLink = referralByRefereeId.get(client.user.id);
|
||||||
|
if (referralLink) {
|
||||||
|
const bonusAmount = Number((Number(totalPrice + deliveryFee) * Number(referralLink.bonusPercent) / 100).toFixed(2));
|
||||||
|
await prisma.bonusTransaction.create({
|
||||||
|
data: {
|
||||||
|
userId: referralLink.referrerId,
|
||||||
|
amount: toMoney(bonusAmount),
|
||||||
|
reason: `[demo] Бонус за заказ ${order.code}`,
|
||||||
|
orderId: order.id,
|
||||||
|
referralLinkId: referralLink.id,
|
||||||
|
createdAt: addHours(createdAt, 18),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completedOrders;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWithdrawalDrafts(clients) {
|
||||||
|
const candidates = clients.slice(0, 3);
|
||||||
|
|
||||||
|
for (const [index, client] of candidates.entries()) {
|
||||||
|
await prisma.rewardWithdrawalRequest.create({
|
||||||
|
data: {
|
||||||
|
requesterId: client.user.id,
|
||||||
|
amount: toMoney(1500 + index * 750),
|
||||||
|
status: 'PENDING',
|
||||||
|
reviewComment: '[demo] Тестовая заявка на вывод.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!Number.isFinite(DEMO_CLIENT_COUNT) || DEMO_CLIENT_COUNT < 2) {
|
||||||
|
throw new Error('DEMO_CLIENTS must be at least 2.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(DEMO_ORDER_COUNT) || DEMO_ORDER_COUNT < 1) {
|
||||||
|
throw new Error('DEMO_ORDERS must be at least 1.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = await prisma.user.findUnique({
|
||||||
|
where: { email: MANAGER_EMAIL },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!manager) {
|
||||||
|
throw new Error(`Manager ${MANAGER_EMAIL} not found. Run npm run seed first.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = await prisma.product.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { sku: 'asc' },
|
||||||
|
take: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (products.length < 6) {
|
||||||
|
throw new Error('Not enough active products. Run npm run seed first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = [];
|
||||||
|
for (let index = 1; index <= DEMO_CLIENT_COUNT; index += 1) {
|
||||||
|
clients.push(await upsertClient(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
const demoUserIds = clients.map((entry) => entry.user.id);
|
||||||
|
await cleanupDemoData(demoUserIds);
|
||||||
|
await rebuildMessengerConnections(clients);
|
||||||
|
|
||||||
|
const referralLinks = await createReferralLinks(manager.id, clients);
|
||||||
|
const completedOrders = await createOrders(manager.id, clients, products, referralLinks);
|
||||||
|
await createWithdrawalDrafts(clients);
|
||||||
|
|
||||||
|
const [usersCount, ordersCount] = await Promise.all([
|
||||||
|
prisma.user.count({
|
||||||
|
where: {
|
||||||
|
email: { endsWith: `@${DEMO_EMAIL_DOMAIN}` },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.order.count({
|
||||||
|
where: {
|
||||||
|
code: { startsWith: DEMO_ORDER_PREFIX },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(`Demo seed complete: ${usersCount} demo clients, ${ordersCount} demo orders, ${referralLinks.length} referral links, ${completedOrders} completed orders.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main()
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -104,11 +104,16 @@ const manager = await prisma.user.upsert({
|
|||||||
|
|
||||||
await prisma.user.upsert({
|
await prisma.user.upsert({
|
||||||
where: { email: clientEmail },
|
where: { email: clientEmail },
|
||||||
update: { fullName: 'Demo Client', companyId: company.id },
|
update: {
|
||||||
|
fullName: 'Demo Client',
|
||||||
|
companyId: company.id,
|
||||||
|
bonusProgramEnabled: true,
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
email: clientEmail,
|
email: clientEmail,
|
||||||
fullName: 'Demo Client',
|
fullName: 'Demo Client',
|
||||||
role: 'CLIENT',
|
role: 'CLIENT',
|
||||||
|
bonusProgramEnabled: true,
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export const MANAGER_ROLES = ['MANAGER', 'SUPER_MANAGER'];
|
export const MANAGER_ROLES = ['MANAGER', 'SUPER_MANAGER'];
|
||||||
const NO_CLIENT_IDS = ['__no_managed_clients__'];
|
|
||||||
|
|
||||||
export function isSuperManager(user) {
|
export function isSuperManager(user) {
|
||||||
return user?.role === 'SUPER_MANAGER';
|
return user?.role === 'SUPER_MANAGER';
|
||||||
@@ -9,91 +8,10 @@ export function isManagerRole(role) {
|
|||||||
return MANAGER_ROLES.includes(role);
|
return MANAGER_ROLES.includes(role);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeManagedClientIds(clientIds) {
|
|
||||||
if (clientIds == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return clientIds.length ? clientIds : NO_CLIENT_IDS;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getManagedClientIds(prisma, manager) {
|
|
||||||
if (isSuperManager(manager)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [managedOrders, acceptedInvitations, reviewedRequests, reviewedWithdrawals] = await Promise.all([
|
|
||||||
prisma.order.findMany({
|
|
||||||
where: { managerId: manager.id },
|
|
||||||
select: { customerId: true },
|
|
||||||
}),
|
|
||||||
prisma.invitation.findMany({
|
|
||||||
where: {
|
|
||||||
managerId: manager.id,
|
|
||||||
acceptedById: { not: null },
|
|
||||||
},
|
|
||||||
select: { acceptedById: true },
|
|
||||||
}),
|
|
||||||
prisma.registrationRequest.findMany({
|
|
||||||
where: {
|
|
||||||
reviewedById: manager.id,
|
|
||||||
requesterId: { not: null },
|
|
||||||
},
|
|
||||||
select: { requesterId: true },
|
|
||||||
}),
|
|
||||||
prisma.rewardWithdrawalRequest.findMany({
|
|
||||||
where: { reviewedById: manager.id },
|
|
||||||
select: { requesterId: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const clientIds = new Set();
|
|
||||||
|
|
||||||
for (const order of managedOrders) {
|
|
||||||
if (order.customerId) {
|
|
||||||
clientIds.add(order.customerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const invitation of acceptedInvitations) {
|
|
||||||
if (invitation.acceptedById) {
|
|
||||||
clientIds.add(invitation.acceptedById);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const request of reviewedRequests) {
|
|
||||||
if (request.requesterId) {
|
|
||||||
clientIds.add(request.requesterId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const withdrawal of reviewedWithdrawals) {
|
|
||||||
if (withdrawal.requesterId) {
|
|
||||||
clientIds.add(withdrawal.requesterId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...clientIds];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getManagedClientUserWhere(prisma, manager) {
|
export async function getManagedClientUserWhere(prisma, manager) {
|
||||||
const managedClientIds = normalizeManagedClientIds(await getManagedClientIds(prisma, manager));
|
return {};
|
||||||
|
|
||||||
if (managedClientIds == null) {
|
|
||||||
return { role: 'CLIENT' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
role: 'CLIENT',
|
|
||||||
id: { in: managedClientIds },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function canManagerAccessUser(prisma, manager, userId) {
|
export async function canManagerAccessUser(prisma, manager, userId) {
|
||||||
if (isSuperManager(manager) || userId === manager.id) {
|
return isManagerRole(manager?.role);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const managedClientIds = await getManagedClientIds(prisma, manager);
|
|
||||||
return managedClientIds.includes(userId);
|
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/auth.js
58
src/auth.js
@@ -6,6 +6,7 @@ const AUTH_TOKEN_TTL_SECONDS = Number(process.env.AUTH_TOKEN_TTL_SECONDS ?? 60 *
|
|||||||
const AUTH_LOGIN_CHALLENGE_TTL_SECONDS = Number(process.env.AUTH_LOGIN_CHALLENGE_TTL_SECONDS ?? 10 * 60);
|
const AUTH_LOGIN_CHALLENGE_TTL_SECONDS = Number(process.env.AUTH_LOGIN_CHALLENGE_TTL_SECONDS ?? 10 * 60);
|
||||||
const AUTH_LOGIN_LINK_TTL_SECONDS = Number(process.env.AUTH_LOGIN_LINK_TTL_SECONDS ?? 5 * 60);
|
const AUTH_LOGIN_LINK_TTL_SECONDS = Number(process.env.AUTH_LOGIN_LINK_TTL_SECONDS ?? 5 * 60);
|
||||||
const AUTH_MESSENGER_START_TTL_SECONDS = Number(process.env.AUTH_MESSENGER_START_TTL_SECONDS ?? 10 * 60);
|
const AUTH_MESSENGER_START_TTL_SECONDS = Number(process.env.AUTH_MESSENGER_START_TTL_SECONDS ?? 10 * 60);
|
||||||
|
const BONUS_PROGRAM_LINK_TTL_SECONDS = Number(process.env.BONUS_PROGRAM_LINK_TTL_SECONDS ?? 7 * 24 * 60 * 60);
|
||||||
const AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456';
|
const AUTH_STATIC_CODE = process.env.AUTH_STATIC_CODE || '123456';
|
||||||
|
|
||||||
const activeChallenges = new Map();
|
const activeChallenges = new Map();
|
||||||
@@ -179,7 +180,7 @@ export function verifyLoginChallengeCode({ challengeToken, code }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMessengerStartSession({ channel, email, userId, redirectPath }) {
|
export function createMessengerStartSession({ channel, email, userId, redirectPath, targetApp = 'MAIN' }) {
|
||||||
purgeExpiredMessengerStartSessions();
|
purgeExpiredMessengerStartSessions();
|
||||||
|
|
||||||
const startToken = crypto.randomBytes(24).toString('base64url');
|
const startToken = crypto.randomBytes(24).toString('base64url');
|
||||||
@@ -189,6 +190,7 @@ export function createMessengerStartSession({ channel, email, userId, redirectPa
|
|||||||
email,
|
email,
|
||||||
userId,
|
userId,
|
||||||
redirectPath,
|
redirectPath,
|
||||||
|
targetApp,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,6 +214,7 @@ export function consumeMessengerStartSession(startToken) {
|
|||||||
email: payload.email,
|
email: payload.email,
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
redirectPath: payload.redirectPath,
|
redirectPath: payload.redirectPath,
|
||||||
|
targetApp: payload.targetApp || 'MAIN',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +240,59 @@ export function issueTemporaryLoginToken({ userId, messengerConnection = null })
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function issueBonusProgramLinkToken({ userId }) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const exp = now + BONUS_PROGRAM_LINK_TTL_SECONDS;
|
||||||
|
const payload = {
|
||||||
|
type: 'BONUS_PROGRAM_LINK',
|
||||||
|
sub: userId,
|
||||||
|
iat: now,
|
||||||
|
exp,
|
||||||
|
jti: crypto.randomUUID(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signature = sign(payloadBase64);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: `${payloadBase64}.${signature}`,
|
||||||
|
expiresAt: new Date(exp * 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyBonusProgramLinkToken(token) {
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Bonus program token is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = String(token).split('.');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
throw new Error('Bonus program token is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [payloadBase64, signature] = parts;
|
||||||
|
const expectedSignature = sign(payloadBase64);
|
||||||
|
if (expectedSignature !== signature) {
|
||||||
|
throw new Error('Bonus program token signature is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadJson = Buffer.from(payloadBase64, 'base64url').toString('utf8');
|
||||||
|
const payload = JSON.parse(payloadJson);
|
||||||
|
if (payload.type !== 'BONUS_PROGRAM_LINK') {
|
||||||
|
throw new Error('Bonus program token type is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const exp = Number(payload.exp);
|
||||||
|
if (!Number.isFinite(exp) || exp <= Math.floor(Date.now() / 1000)) {
|
||||||
|
throw new Error('Bonus program token has expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: String(payload.sub),
|
||||||
|
expiresAt: new Date(exp * 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function consumeTemporaryLoginToken(loginToken) {
|
export function consumeTemporaryLoginToken(loginToken) {
|
||||||
purgeExpiredLoginTokens();
|
purgeExpiredLoginTokens();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
|
import { buildLoginCodeEmailTemplate } from './notification-templates.js';
|
||||||
|
|
||||||
let cachedTransporter = null;
|
let cachedTransporter = null;
|
||||||
|
|
||||||
@@ -54,16 +55,68 @@ function getTransporter() {
|
|||||||
return cachedTransporter;
|
return cachedTransporter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBody(body) {
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
return body
|
||||||
|
.map((line) => String(line ?? '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(body ?? '').trim();
|
||||||
|
return text ? [text] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNotificationEmailText(body, buttonText, buttonUrl) {
|
||||||
|
const lines = normalizeBody(body);
|
||||||
|
if (buttonUrl) {
|
||||||
|
lines.push(`${buttonText || 'Открыть'}: ${buttonUrl}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNotificationEmailHtml(body, buttonText, buttonUrl) {
|
||||||
|
const paragraphs = normalizeBody(body)
|
||||||
|
.map((line) => `<p>${escapeHtml(line)}</p>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
if (!buttonUrl) {
|
||||||
|
return paragraphs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${paragraphs}<p><a href="${escapeHtml(buttonUrl)}" style="display:inline-block;padding:12px 18px;border-radius:999px;background:#123824;color:#ffffff;text-decoration:none;font-weight:700;">${escapeHtml(buttonText || 'Открыть')}</a></p>`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendLoginCodeEmail({ to, code, expiresAt }) {
|
export async function sendLoginCodeEmail({ to, code, expiresAt }) {
|
||||||
const { from } = getSmtpConfig();
|
const { from } = getSmtpConfig();
|
||||||
const transporter = getTransporter();
|
const transporter = getTransporter();
|
||||||
const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false });
|
const template = buildLoginCodeEmailTemplate({ code, expiresAt });
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
subject: 'Код входа в личный кабинет Fregat',
|
subject: template.subject,
|
||||||
text: `Код входа: ${code}\nДействует до: ${expiresText}`,
|
text: template.text,
|
||||||
html: `<p>Код входа: <b>${code}</b></p><p>Действует до: ${expiresText}</p>`,
|
html: template.html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendNotificationEmail({ to, subject, body, buttonText = null, buttonUrl = null }) {
|
||||||
|
const { from } = getSmtpConfig();
|
||||||
|
const transporter = getTransporter();
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text: buildNotificationEmailText(body, buttonText, buttonUrl),
|
||||||
|
html: buildNotificationEmailHtml(body, buttonText, buttonUrl),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
115
src/max-mini-app.js
Normal file
115
src/max-mini-app.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const MAX_MINI_APP_AUTH_MAX_AGE_SECONDS = Number(process.env.MAX_MINI_APP_AUTH_MAX_AGE_SECONDS ?? 60 * 60);
|
||||||
|
|
||||||
|
function requireMaxBotToken() {
|
||||||
|
const token = String(process.env.MAX_BOT_TOKEN || '').trim();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('MAX_BOT_TOKEN is not configured.');
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqualHex(left, right) {
|
||||||
|
const leftBuffer = Buffer.from(String(left || ''), 'hex');
|
||||||
|
const rightBuffer = Buffer.from(String(right || ''), 'hex');
|
||||||
|
|
||||||
|
if (!leftBuffer.length || leftBuffer.length !== rightBuffer.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonParam(rawValue, fieldName) {
|
||||||
|
if (!rawValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawValue);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`MAX ${fieldName} is invalid.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
throw new Error(`MAX ${fieldName} is invalid.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMaxUser(rawUser) {
|
||||||
|
const parsed = parseJsonParam(rawUser, 'user');
|
||||||
|
if (!parsed?.id) {
|
||||||
|
throw new Error('MAX user is missing in initData.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(parsed.id),
|
||||||
|
first_name: String(parsed.first_name || '').trim(),
|
||||||
|
last_name: String(parsed.last_name || '').trim() || null,
|
||||||
|
username: String(parsed.username || '').trim() || null,
|
||||||
|
language_code: String(parsed.language_code || '').trim() || null,
|
||||||
|
photo_url: String(parsed.photo_url || '').trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMaxChat(rawChat) {
|
||||||
|
const parsed = parseJsonParam(rawChat, 'chat');
|
||||||
|
if (!parsed?.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(parsed.id),
|
||||||
|
type: String(parsed.type || '').trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMaxMiniAppInitData(initDataRaw) {
|
||||||
|
const initData = String(initDataRaw || '').trim();
|
||||||
|
if (!initData) {
|
||||||
|
throw new Error('MAX initData is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(initData);
|
||||||
|
const receivedHashes = params.getAll('hash').map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
|
||||||
|
if (receivedHashes.length !== 1) {
|
||||||
|
throw new Error('MAX initData hash is missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDate = Number(params.get('auth_date'));
|
||||||
|
if (!Number.isFinite(authDate) || authDate <= 0) {
|
||||||
|
throw new Error('MAX initData auth_date is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDateSeconds = authDate > 1e12 ? Math.floor(authDate / 1000) : Math.floor(authDate);
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (now - authDateSeconds > MAX_MINI_APP_AUTH_MAX_AGE_SECONDS) {
|
||||||
|
throw new Error('MAX initData is expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataCheckString = [...params.entries()]
|
||||||
|
.filter(([key]) => key !== 'hash')
|
||||||
|
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const secretKey = crypto.createHmac('sha256', 'WebAppData').update(requireMaxBotToken()).digest();
|
||||||
|
const expectedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex');
|
||||||
|
|
||||||
|
if (!timingSafeEqualHex(expectedHash, receivedHashes[0])) {
|
||||||
|
throw new Error('MAX initData signature is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authDate: new Date(authDateSeconds * 1000),
|
||||||
|
queryId: String(params.get('query_id') || '').trim() || null,
|
||||||
|
startParam: String(params.get('start_param') || '').trim() || null,
|
||||||
|
user: parseMaxUser(params.get('user')),
|
||||||
|
chat: parseMaxChat(params.get('chat')),
|
||||||
|
};
|
||||||
|
}
|
||||||
125
src/messenger-connections.js
Normal file
125
src/messenger-connections.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
function normalizeOptionalText(value) {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessengerProfile(profile) {
|
||||||
|
if (!profile || typeof profile !== 'object') {
|
||||||
|
return {
|
||||||
|
displayName: null,
|
||||||
|
username: null,
|
||||||
|
avatarFileId: null,
|
||||||
|
avatarFileUniqueId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayName: normalizeOptionalText(profile.displayName),
|
||||||
|
username: normalizeOptionalText(profile.username)?.replace(/^@+/, '') || null,
|
||||||
|
avatarFileId: normalizeOptionalText(profile.avatarFileId),
|
||||||
|
avatarFileUniqueId: normalizeOptionalText(profile.avatarFileUniqueId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTelegramProfile(profile) {
|
||||||
|
return normalizeMessengerProfile(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMaxProfile(profile) {
|
||||||
|
return normalizeMessengerProfile({
|
||||||
|
displayName: profile?.displayName,
|
||||||
|
username: profile?.username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileFromTelegramMiniAppUser(user) {
|
||||||
|
if (!user || typeof user !== 'object') {
|
||||||
|
return normalizeTelegramProfile(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstName = String(user.first_name || '').trim();
|
||||||
|
const lastName = String(user.last_name || '').trim();
|
||||||
|
const displayName = `${firstName} ${lastName}`.trim();
|
||||||
|
|
||||||
|
return normalizeTelegramProfile({
|
||||||
|
displayName,
|
||||||
|
username: user.username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileFromMaxMiniAppUser(user) {
|
||||||
|
if (!user || typeof user !== 'object') {
|
||||||
|
return normalizeMaxProfile(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstName = String(user.first_name || '').trim();
|
||||||
|
const lastName = String(user.last_name || '').trim();
|
||||||
|
const displayName = `${firstName} ${lastName}`.trim();
|
||||||
|
|
||||||
|
return normalizeMaxProfile({
|
||||||
|
displayName,
|
||||||
|
username: user.username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertActiveMessengerConnection(prisma, { userId, type, channelId, profile = null }) {
|
||||||
|
const normalizedChannelId = String(channelId || '').trim();
|
||||||
|
if (!userId || !type || !normalizedChannelId) {
|
||||||
|
throw new Error('userId, type and channelId are required to connect messenger.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedProfile = type === 'TELEGRAM'
|
||||||
|
? normalizeTelegramProfile(profile)
|
||||||
|
: type === 'MAX'
|
||||||
|
? normalizeMaxProfile(profile)
|
||||||
|
: normalizeMessengerProfile(null);
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await tx.messengerConnection.updateMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
isActive: true,
|
||||||
|
NOT: { channelId: normalizedChannelId },
|
||||||
|
},
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.messengerConnection.updateMany({
|
||||||
|
where: {
|
||||||
|
type,
|
||||||
|
channelId: normalizedChannelId,
|
||||||
|
isActive: true,
|
||||||
|
NOT: { userId },
|
||||||
|
},
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return tx.messengerConnection.upsert({
|
||||||
|
where: {
|
||||||
|
userId_type_channelId: {
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
channelId: normalizedChannelId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
isActive: true,
|
||||||
|
displayName: normalizedProfile.displayName,
|
||||||
|
username: normalizedProfile.username,
|
||||||
|
avatarFileId: normalizedProfile.avatarFileId,
|
||||||
|
avatarFileUniqueId: normalizedProfile.avatarFileUniqueId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
channelId: normalizedChannelId,
|
||||||
|
isActive: true,
|
||||||
|
displayName: normalizedProfile.displayName,
|
||||||
|
username: normalizedProfile.username,
|
||||||
|
avatarFileId: normalizedProfile.avatarFileId,
|
||||||
|
avatarFileUniqueId: normalizedProfile.avatarFileUniqueId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,35 @@ function maskChannel(channelId) {
|
|||||||
return `${text.slice(0, 3)}***${text.slice(-3)}`;
|
return `${text.slice(0, 3)}***${text.slice(-3)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTelegramButton(buttonUrl, buttonText) {
|
||||||
|
const url = String(buttonUrl || '').trim();
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(buttonText || '').trim() || 'Открыть кабинет';
|
||||||
|
const miniAppBaseUrl = String(
|
||||||
|
process.env.TELEGRAM_MINI_APP_URL ||
|
||||||
|
process.env.WEB_FRONTEND_URL ||
|
||||||
|
process.env.NUXT_PUBLIC_SITE_URL ||
|
||||||
|
'',
|
||||||
|
).trim().replace(/\/$/, '');
|
||||||
|
|
||||||
|
if (miniAppBaseUrl && url.startsWith(miniAppBaseUrl)) {
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
web_app: {
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function sendTelegramMessage(channelId, message, options = {}) {
|
async function sendTelegramMessage(channelId, message, options = {}) {
|
||||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -16,6 +45,7 @@ async function sendTelegramMessage(channelId, message, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const button = buildTelegramButton(options.buttonUrl, options.buttonText);
|
||||||
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -23,14 +53,11 @@ async function sendTelegramMessage(channelId, message, options = {}) {
|
|||||||
chat_id: channelId,
|
chat_id: channelId,
|
||||||
text: message,
|
text: message,
|
||||||
disable_web_page_preview: true,
|
disable_web_page_preview: true,
|
||||||
...(options.buttonUrl
|
...(button
|
||||||
? {
|
? {
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
inline_keyboard: [[
|
inline_keyboard: [[
|
||||||
{
|
button,
|
||||||
text: options.buttonText || 'Открыть кабинет',
|
|
||||||
url: options.buttonUrl,
|
|
||||||
},
|
|
||||||
]],
|
]],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -74,7 +101,7 @@ async function sendMaxMessage(channelId, message, options = {}) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
channelId,
|
channelId,
|
||||||
text: message,
|
text: message,
|
||||||
source: 'fregat-apollo-backend',
|
source: 'fregat-backend',
|
||||||
...(options.buttonUrl
|
...(options.buttonUrl
|
||||||
? {
|
? {
|
||||||
button: {
|
button: {
|
||||||
@@ -121,7 +148,7 @@ export async function sendMessengerMessage({ type, channelId, message, buttonUrl
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dispatchToUserConnections(prisma, userId, message) {
|
export async function dispatchToUserConnections(prisma, userId, message, options = {}) {
|
||||||
const connections = await prisma.messengerConnection.findMany({
|
const connections = await prisma.messengerConnection.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
@@ -139,6 +166,8 @@ export async function dispatchToUserConnections(prisma, userId, message) {
|
|||||||
type: connection.type,
|
type: connection.type,
|
||||||
channelId: connection.channelId,
|
channelId: connection.channelId,
|
||||||
message,
|
message,
|
||||||
|
buttonUrl: options.buttonUrl,
|
||||||
|
buttonText: options.buttonText,
|
||||||
});
|
});
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
|
|||||||
307
src/notification-templates.js
Normal file
307
src/notification-templates.js
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { isManagerRole } from './access.js';
|
||||||
|
|
||||||
|
function splitBody(text) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChannelPreview(channel, fields = {}) {
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
implemented: true,
|
||||||
|
subject: fields.subject ?? null,
|
||||||
|
body: fields.body ?? [],
|
||||||
|
buttonText: fields.buttonText ?? null,
|
||||||
|
buttonUrl: fields.buttonUrl ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFrontendAppUrl(path) {
|
||||||
|
const baseUrl = String(
|
||||||
|
process.env.TELEGRAM_MINI_APP_URL ||
|
||||||
|
process.env.WEB_FRONTEND_URL ||
|
||||||
|
process.env.NUXT_PUBLIC_SITE_URL ||
|
||||||
|
'',
|
||||||
|
).trim().replace(/\/$/, '');
|
||||||
|
|
||||||
|
const normalizedPath = String(path || '').trim();
|
||||||
|
if (!baseUrl || !normalizedPath.startsWith('/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseUrl}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUserOrderPath(orderId, role) {
|
||||||
|
const normalizedOrderId = String(orderId || '').trim();
|
||||||
|
if (!normalizedOrderId) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return isManagerRole(role)
|
||||||
|
? `/client-orders/${normalizedOrderId}`
|
||||||
|
: `/orders/${normalizedOrderId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBonusProgramPath(entry = 'bonus-message') {
|
||||||
|
const normalizedEntry = String(entry || '').trim();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (normalizedEntry) {
|
||||||
|
params.set('entry', normalizedEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
return query ? `/bonus-program?${query}` : '/bonus-program';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBonusProgramUrl(entry = 'bonus-message') {
|
||||||
|
const bonusBaseUrl = String(
|
||||||
|
process.env.BONUS_FRONTEND_URL ||
|
||||||
|
process.env.BONUS_PUBLIC_BASE_URL ||
|
||||||
|
'',
|
||||||
|
).trim().replace(/\/$/, '');
|
||||||
|
|
||||||
|
if (bonusBaseUrl) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const normalizedEntry = String(entry || '').trim();
|
||||||
|
if (normalizedEntry) {
|
||||||
|
params.set('entry', normalizedEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
return query ? `${bonusBaseUrl}/?${query}` : `${bonusBaseUrl}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildFrontendAppUrl(buildBonusProgramPath(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLoginCodeEmailTemplate({ code, expiresAt }) {
|
||||||
|
const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false });
|
||||||
|
const body = [
|
||||||
|
`Код входа: ${code}`,
|
||||||
|
`Действует до: ${expiresText}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: 'Код входа в личный кабинет Fregat',
|
||||||
|
body,
|
||||||
|
text: body.join('\n'),
|
||||||
|
html: `<p>Код входа: <b>${code}</b></p><p>Действует до: ${expiresText}</p>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOrderStatusLabel(status) {
|
||||||
|
if (status === 'NEW' || status === 'MANAGER_PROCESSING') {
|
||||||
|
return 'Заявка';
|
||||||
|
}
|
||||||
|
if (status === 'WAITING_DOUBLE_CONFIRM' || status === 'CONFIRMED') {
|
||||||
|
return 'Предложение';
|
||||||
|
}
|
||||||
|
if (status === 'IN_PROGRESS') {
|
||||||
|
return 'В работе';
|
||||||
|
}
|
||||||
|
if (status === 'COMPLETED') {
|
||||||
|
return 'Завершен';
|
||||||
|
}
|
||||||
|
if (status === 'CLIENT_REJECTED' || status === 'MANAGER_REJECTED') {
|
||||||
|
return 'Отклонен';
|
||||||
|
}
|
||||||
|
if (status === 'MANAGER_BLOCKED') {
|
||||||
|
return 'Пауза';
|
||||||
|
}
|
||||||
|
return String(status || '').trim() || 'Статус обновлен';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWithdrawalStatusLabel(status) {
|
||||||
|
if (status === 'APPROVED') {
|
||||||
|
return 'принята';
|
||||||
|
}
|
||||||
|
if (status === 'REJECTED') {
|
||||||
|
return 'не принята';
|
||||||
|
}
|
||||||
|
return 'обработана';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMessengerLoginTemplate({ buttonUrl, expiresAt = null }) {
|
||||||
|
const body = ['Для входа в личный кабинет перейдите по ссылке.'];
|
||||||
|
if (expiresAt) {
|
||||||
|
const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false });
|
||||||
|
body.push(`Ссылка действует до: ${expiresText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: body.join('\n'),
|
||||||
|
buttonText: 'Открыть кабинет',
|
||||||
|
buttonUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOrderStatusNotificationTemplate({ orderId, orderCode, status, note, role }) {
|
||||||
|
void note;
|
||||||
|
const body = [
|
||||||
|
`Статус заказа изменен на «${formatOrderStatusLabel(status)}».`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `Статус заказа ${orderCode} изменен`,
|
||||||
|
body,
|
||||||
|
message: body.join('\n'),
|
||||||
|
buttonText: 'Открыть заказ',
|
||||||
|
buttonUrl: buildFrontendAppUrl(buildUserOrderPath(orderId, role)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBonusCreditTemplate({ amount }) {
|
||||||
|
const normalizedAmount = Number(amount);
|
||||||
|
const body = [
|
||||||
|
`Начислен бонус: ${Number.isFinite(normalizedAmount) ? normalizedAmount : amount}.`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: 'Начислен бонус',
|
||||||
|
body,
|
||||||
|
message: body.join('\n'),
|
||||||
|
buttonText: 'Открыть бонусную программу',
|
||||||
|
buttonUrl: buildBonusProgramUrl('balance'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAutoBonusNotificationTemplate({ amount }) {
|
||||||
|
return buildBonusCreditTemplate({ amount });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildManualBonusNotificationTemplate({ amount }) {
|
||||||
|
return buildBonusCreditTemplate({ amount });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWithdrawalReviewNotificationTemplate({ status, reviewComment }) {
|
||||||
|
void reviewComment;
|
||||||
|
const body = [
|
||||||
|
`Ваша заявка на выплату вознаграждения ${formatWithdrawalStatusLabel(status)}.`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: 'Заявка на выплату вознаграждения',
|
||||||
|
body,
|
||||||
|
message: body.join('\n'),
|
||||||
|
buttonText: 'Открыть бонусную программу',
|
||||||
|
buttonUrl: buildBonusProgramUrl('withdrawal-review'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationTemplatesCatalog() {
|
||||||
|
const loginTemplate = buildLoginCodeEmailTemplate({
|
||||||
|
code: '123456',
|
||||||
|
expiresAt: '2026-04-06T15:30:00.000Z',
|
||||||
|
});
|
||||||
|
const messengerLoginTemplate = buildMessengerLoginTemplate({
|
||||||
|
buttonUrl: 'https://fregat.dsrptlab.com/login?login_token=demo-token',
|
||||||
|
expiresAt: '2026-04-06T15:35:00.000Z',
|
||||||
|
});
|
||||||
|
const orderStatusTemplate = buildOrderStatusNotificationTemplate({
|
||||||
|
orderId: 'demo-order-id',
|
||||||
|
orderCode: 'FRG-2401',
|
||||||
|
status: 'IN_PROGRESS',
|
||||||
|
note: null,
|
||||||
|
role: 'CLIENT',
|
||||||
|
});
|
||||||
|
const bonusTemplate = buildBonusCreditTemplate({
|
||||||
|
amount: 1250,
|
||||||
|
});
|
||||||
|
const withdrawalReviewTemplate = buildWithdrawalReviewNotificationTemplate({
|
||||||
|
status: 'APPROVED',
|
||||||
|
reviewComment: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'login-code-email',
|
||||||
|
title: 'Код входа по email',
|
||||||
|
channels: [
|
||||||
|
createChannelPreview('EMAIL', {
|
||||||
|
subject: loginTemplate.subject,
|
||||||
|
body: loginTemplate.body,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'messenger-login-confirmed',
|
||||||
|
title: 'Привязка мессенджера',
|
||||||
|
channels: [
|
||||||
|
createChannelPreview('TELEGRAM', {
|
||||||
|
body: splitBody(messengerLoginTemplate.message),
|
||||||
|
buttonText: messengerLoginTemplate.buttonText,
|
||||||
|
buttonUrl: messengerLoginTemplate.buttonUrl,
|
||||||
|
}),
|
||||||
|
createChannelPreview('MAX', {
|
||||||
|
body: splitBody(messengerLoginTemplate.message),
|
||||||
|
buttonText: messengerLoginTemplate.buttonText,
|
||||||
|
buttonUrl: messengerLoginTemplate.buttonUrl,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'order-status-update',
|
||||||
|
title: 'Изменение статуса заказа',
|
||||||
|
channels: [
|
||||||
|
createChannelPreview('EMAIL', {
|
||||||
|
subject: orderStatusTemplate.subject,
|
||||||
|
body: orderStatusTemplate.body,
|
||||||
|
}),
|
||||||
|
createChannelPreview('TELEGRAM', {
|
||||||
|
body: splitBody(orderStatusTemplate.message),
|
||||||
|
buttonText: orderStatusTemplate.buttonText,
|
||||||
|
buttonUrl: orderStatusTemplate.buttonUrl,
|
||||||
|
}),
|
||||||
|
createChannelPreview('MAX', {
|
||||||
|
body: splitBody(orderStatusTemplate.message),
|
||||||
|
buttonText: orderStatusTemplate.buttonText,
|
||||||
|
buttonUrl: orderStatusTemplate.buttonUrl,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bonus-credit',
|
||||||
|
title: 'Начислен бонус',
|
||||||
|
channels: [
|
||||||
|
createChannelPreview('EMAIL', {
|
||||||
|
subject: bonusTemplate.subject,
|
||||||
|
body: bonusTemplate.body,
|
||||||
|
}),
|
||||||
|
createChannelPreview('TELEGRAM', {
|
||||||
|
body: splitBody(bonusTemplate.message),
|
||||||
|
buttonText: bonusTemplate.buttonText,
|
||||||
|
buttonUrl: bonusTemplate.buttonUrl,
|
||||||
|
}),
|
||||||
|
createChannelPreview('MAX', {
|
||||||
|
body: splitBody(bonusTemplate.message),
|
||||||
|
buttonText: bonusTemplate.buttonText,
|
||||||
|
buttonUrl: bonusTemplate.buttonUrl,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reward-withdrawal-review',
|
||||||
|
title: 'Заявка на выплату вознаграждения',
|
||||||
|
channels: [
|
||||||
|
createChannelPreview('EMAIL', {
|
||||||
|
subject: withdrawalReviewTemplate.subject,
|
||||||
|
body: withdrawalReviewTemplate.body,
|
||||||
|
}),
|
||||||
|
createChannelPreview('TELEGRAM', {
|
||||||
|
body: splitBody(withdrawalReviewTemplate.message),
|
||||||
|
buttonText: withdrawalReviewTemplate.buttonText,
|
||||||
|
buttonUrl: withdrawalReviewTemplate.buttonUrl,
|
||||||
|
}),
|
||||||
|
createChannelPreview('MAX', {
|
||||||
|
body: splitBody(withdrawalReviewTemplate.message),
|
||||||
|
buttonText: withdrawalReviewTemplate.buttonText,
|
||||||
|
buttonUrl: withdrawalReviewTemplate.buttonUrl,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
1445
src/resolvers.js
1445
src/resolvers.js
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,7 @@ type User {
|
|||||||
email: String!
|
email: String!
|
||||||
fullName: String!
|
fullName: String!
|
||||||
role: UserRole!
|
role: UserRole!
|
||||||
|
bonusProgramEnabled: Boolean!
|
||||||
company: Company
|
company: Company
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ type ManagerUser {
|
|||||||
email: String!
|
email: String!
|
||||||
fullName: String!
|
fullName: String!
|
||||||
role: UserRole!
|
role: UserRole!
|
||||||
|
bonusProgramEnabled: Boolean!
|
||||||
companyName: String
|
companyName: String
|
||||||
inn: String
|
inn: String
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
@@ -178,6 +180,48 @@ type NotificationHistoryItem {
|
|||||||
orderId: ID
|
orderId: ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NotificationTemplateChannel {
|
||||||
|
channel: LoginChannel!
|
||||||
|
implemented: Boolean!
|
||||||
|
subject: String
|
||||||
|
body: [String!]!
|
||||||
|
buttonText: String
|
||||||
|
buttonUrl: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationTemplate {
|
||||||
|
id: ID!
|
||||||
|
title: String!
|
||||||
|
channels: [NotificationTemplateChannel!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntegrationSyncItem {
|
||||||
|
id: ID!
|
||||||
|
title: String!
|
||||||
|
description: String!
|
||||||
|
source: String!
|
||||||
|
syncedCount: Int!
|
||||||
|
lastSyncedAt: DateTime
|
||||||
|
status: String!
|
||||||
|
note: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntegrationSyncDashboard {
|
||||||
|
generatedAt: DateTime!
|
||||||
|
lastActivityAt: DateTime
|
||||||
|
totalOrders: Int!
|
||||||
|
totalProducts: Int!
|
||||||
|
totalClients: Int!
|
||||||
|
items: [IntegrationSyncItem!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type BonusProgramLink {
|
||||||
|
userId: ID!
|
||||||
|
token: String!
|
||||||
|
url: String!
|
||||||
|
expiresAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
type Warehouse {
|
type Warehouse {
|
||||||
id: ID!
|
id: ID!
|
||||||
code: String!
|
code: String!
|
||||||
@@ -200,11 +244,29 @@ type Product {
|
|||||||
thicknessMicron: Int
|
thicknessMicron: Int
|
||||||
sleeveBrand: String
|
sleeveBrand: String
|
||||||
quantityPerBox: String
|
quantityPerBox: String
|
||||||
|
tags: [String!]!
|
||||||
isCustomizable: Boolean!
|
isCustomizable: Boolean!
|
||||||
isActive: Boolean!
|
isActive: Boolean!
|
||||||
availableInWarehouses: [ProductWarehouseBalance!]!
|
availableInWarehouses: [ProductWarehouseBalance!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CatalogProductTypeSetting {
|
||||||
|
productType: String!
|
||||||
|
showQuantityPerBox: Boolean!
|
||||||
|
allowCustomLength: Boolean!
|
||||||
|
customLengthMinM: Int
|
||||||
|
customLengthMaxM: Int
|
||||||
|
customLengthStepM: Int
|
||||||
|
allowCustomSleeveBrand: Boolean!
|
||||||
|
allowCustomLabel: Boolean!
|
||||||
|
widthOptionsMm: [Int!]!
|
||||||
|
lengthOptionsM: [Int!]!
|
||||||
|
thicknessOptionsMicron: [Int!]!
|
||||||
|
sleeveOptions: [String!]!
|
||||||
|
colorOptions: [String!]!
|
||||||
|
labelOptions: [String!]!
|
||||||
|
}
|
||||||
|
|
||||||
type CartItem {
|
type CartItem {
|
||||||
id: ID!
|
id: ID!
|
||||||
productId: ID!
|
productId: ID!
|
||||||
@@ -232,6 +294,8 @@ type OrderItem {
|
|||||||
productId: ID
|
productId: ID
|
||||||
productName: String!
|
productName: String!
|
||||||
quantity: Float!
|
quantity: Float!
|
||||||
|
unitPrice: Float
|
||||||
|
lineTotal: Float
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrderStatusEvent {
|
type OrderStatusEvent {
|
||||||
@@ -267,6 +331,23 @@ type ReferralLink {
|
|||||||
id: ID!
|
id: ID!
|
||||||
referrerId: ID!
|
referrerId: ID!
|
||||||
refereeId: ID!
|
refereeId: ID!
|
||||||
|
createdById: ID!
|
||||||
|
bonusPercent: Float!
|
||||||
|
createdAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagerReferralLink {
|
||||||
|
id: ID!
|
||||||
|
referrerId: ID!
|
||||||
|
referrerName: String!
|
||||||
|
referrerEmail: String!
|
||||||
|
referrerCompanyName: String
|
||||||
|
refereeId: ID!
|
||||||
|
refereeName: String!
|
||||||
|
refereeEmail: String!
|
||||||
|
refereeCompanyName: String
|
||||||
|
createdById: ID!
|
||||||
|
bonusPercent: Float!
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,11 +384,27 @@ type ManagerBonusBalance {
|
|||||||
email: String!
|
email: String!
|
||||||
fullName: String!
|
fullName: String!
|
||||||
companyName: String
|
companyName: String
|
||||||
|
bonusProgramEnabled: Boolean!
|
||||||
balance: Float!
|
balance: Float!
|
||||||
pendingWithdrawalAmount: Float!
|
pendingWithdrawalAmount: Float!
|
||||||
transactionsCount: Int!
|
transactionsCount: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ManagerBonusAccount {
|
||||||
|
userId: ID!
|
||||||
|
email: String!
|
||||||
|
fullName: String!
|
||||||
|
companyName: String
|
||||||
|
balance: Float!
|
||||||
|
earnedAmount: Float!
|
||||||
|
pendingWithdrawalAmount: Float!
|
||||||
|
transactionsCount: Int!
|
||||||
|
referralsCount: Int!
|
||||||
|
referralLinks: [ManagerReferralLink!]!
|
||||||
|
transactions: [BonusTransaction!]!
|
||||||
|
pendingWithdrawals: [RewardWithdrawalRequest!]!
|
||||||
|
}
|
||||||
|
|
||||||
type ManagerWithdrawalRequest {
|
type ManagerWithdrawalRequest {
|
||||||
id: ID!
|
id: ID!
|
||||||
requesterId: ID!
|
requesterId: ID!
|
||||||
@@ -330,13 +427,19 @@ type Query {
|
|||||||
myDeliveryAddresses: [DeliveryAddress!]!
|
myDeliveryAddresses: [DeliveryAddress!]!
|
||||||
myMessengerConnections: [MessengerConnection!]!
|
myMessengerConnections: [MessengerConnection!]!
|
||||||
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
||||||
|
notificationTemplates: [NotificationTemplate!]!
|
||||||
|
integrationSyncDashboard: IntegrationSyncDashboard!
|
||||||
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
||||||
clientProducts: [Product!]!
|
clientProducts: [Product!]!
|
||||||
|
catalogProductTypeSettings: [CatalogProductTypeSetting!]!
|
||||||
|
order(id: ID!): Order
|
||||||
myOrders: [Order!]!
|
myOrders: [Order!]!
|
||||||
myCurrentOrders: [Order!]!
|
myCurrentOrders: [Order!]!
|
||||||
managerUsers: [ManagerUser!]!
|
managerUsers: [ManagerUser!]!
|
||||||
managerOrders(status: OrderStatus, customerId: ID): [Order!]!
|
managerOrders(status: OrderStatus, customerId: ID): [Order!]!
|
||||||
|
managerReferralLinks: [ManagerReferralLink!]!
|
||||||
managerBonusBalances: [ManagerBonusBalance!]!
|
managerBonusBalances: [ManagerBonusBalance!]!
|
||||||
|
managerBonusAccount(userId: ID!): ManagerBonusAccount!
|
||||||
managerWithdrawalRequests(status: WithdrawalStatus): [ManagerWithdrawalRequest!]!
|
managerWithdrawalRequests(status: WithdrawalStatus): [ManagerWithdrawalRequest!]!
|
||||||
registrationRequests(status: RegistrationStatus): [RegistrationRequest!]!
|
registrationRequests(status: RegistrationStatus): [RegistrationRequest!]!
|
||||||
referralStats: ReferralStats!
|
referralStats: ReferralStats!
|
||||||
@@ -409,6 +512,23 @@ input UpdateCartItemQuantityInput {
|
|||||||
quantity: Float!
|
quantity: Float!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input UpsertCatalogProductTypeSettingInput {
|
||||||
|
productType: String!
|
||||||
|
showQuantityPerBox: Boolean!
|
||||||
|
allowCustomLength: Boolean!
|
||||||
|
customLengthMinM: Int
|
||||||
|
customLengthMaxM: Int
|
||||||
|
customLengthStepM: Int
|
||||||
|
allowCustomSleeveBrand: Boolean!
|
||||||
|
allowCustomLabel: Boolean!
|
||||||
|
widthOptionsMm: [Int!]!
|
||||||
|
lengthOptionsM: [Int!]!
|
||||||
|
thicknessOptionsMicron: [Int!]!
|
||||||
|
sleeveOptions: [String!]!
|
||||||
|
colorOptions: [String!]!
|
||||||
|
labelOptions: [String!]!
|
||||||
|
}
|
||||||
|
|
||||||
input ReadyOrderItemInput {
|
input ReadyOrderItemInput {
|
||||||
productId: ID!
|
productId: ID!
|
||||||
quantity: Float!
|
quantity: Float!
|
||||||
@@ -428,18 +548,20 @@ input SubmitCalculationOrderInput {
|
|||||||
|
|
||||||
input SetOrderOfferInput {
|
input SetOrderOfferInput {
|
||||||
orderId: ID!
|
orderId: ID!
|
||||||
|
itemPrices: [OrderItemPriceInput!]!
|
||||||
deliveryTerms: String!
|
deliveryTerms: String!
|
||||||
deliveryFee: Float!
|
deliveryFee: Float
|
||||||
totalPrice: Float!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input BlockOrderInput {
|
input OrderItemPriceInput {
|
||||||
orderId: ID!
|
itemId: ID!
|
||||||
reason: String!
|
unitPrice: Float
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateReferralInput {
|
input CreateReferralInput {
|
||||||
|
referrerUserId: ID!
|
||||||
refereeUserId: ID!
|
refereeUserId: ID!
|
||||||
|
bonusPercent: Float!
|
||||||
}
|
}
|
||||||
|
|
||||||
input AddBonusTransactionInput {
|
input AddBonusTransactionInput {
|
||||||
@@ -468,7 +590,9 @@ type Mutation {
|
|||||||
createInvitation(input: CreateInvitationInput!): Invitation!
|
createInvitation(input: CreateInvitationInput!): Invitation!
|
||||||
acceptInvitation(input: AcceptInvitationInput!): User!
|
acceptInvitation(input: AcceptInvitationInput!): User!
|
||||||
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
|
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
|
||||||
|
deleteMyMessengerConnection(connectionId: ID!): Boolean!
|
||||||
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
|
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
|
||||||
|
upsertCatalogProductTypeSetting(input: UpsertCatalogProductTypeSettingInput!): CatalogProductTypeSetting!
|
||||||
addProductToCart(productId: ID!): Cart!
|
addProductToCart(productId: ID!): Cart!
|
||||||
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!
|
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!
|
||||||
removeCartItem(productId: ID!): Cart!
|
removeCartItem(productId: ID!): Cart!
|
||||||
@@ -482,13 +606,12 @@ type Mutation {
|
|||||||
submitReadyOrder(input: SubmitReadyOrderInput!): Order!
|
submitReadyOrder(input: SubmitReadyOrderInput!): Order!
|
||||||
submitCalculationOrder(input: SubmitCalculationOrderInput!): Order!
|
submitCalculationOrder(input: SubmitCalculationOrderInput!): Order!
|
||||||
managerSetOrderOffer(input: SetOrderOfferInput!): Order!
|
managerSetOrderOffer(input: SetOrderOfferInput!): Order!
|
||||||
|
managerSetOrderStatus(orderId: ID!, status: OrderStatus!): Order!
|
||||||
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
|
clientReviewOrder(orderId: ID!, decision: Decision!): Order!
|
||||||
managerFinalizeOrder(orderId: ID!, decision: Decision!): Order!
|
|
||||||
blockOrder(input: BlockOrderInput!): Order!
|
|
||||||
startOrderWork(orderId: ID!): Order!
|
|
||||||
completeOrder(orderId: ID!): Order!
|
|
||||||
|
|
||||||
createReferral(input: CreateReferralInput!): ReferralLink!
|
createReferral(input: CreateReferralInput!): ReferralLink!
|
||||||
|
setClientBonusProgramEnabled(userId: ID!, enabled: Boolean!): ManagerUser!
|
||||||
|
createBonusProgramLink(userId: ID!): BonusProgramLink!
|
||||||
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
|
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
|
||||||
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
|
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
|
||||||
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!
|
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!
|
||||||
|
|||||||
319
src/server.js
319
src/server.js
@@ -15,15 +15,27 @@ import {
|
|||||||
consumeMessengerStartSession,
|
consumeMessengerStartSession,
|
||||||
extractAuthTokenFromRequest,
|
extractAuthTokenFromRequest,
|
||||||
hasMessengerStartSession,
|
hasMessengerStartSession,
|
||||||
|
issueAccessToken,
|
||||||
issueTemporaryLoginToken,
|
issueTemporaryLoginToken,
|
||||||
|
verifyBonusProgramLinkToken,
|
||||||
verifyAccessToken,
|
verifyAccessToken,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
import { canManagerAccessUser, isManagerRole } from './access.js';
|
import { canManagerAccessUser, isManagerRole } from './access.js';
|
||||||
import { buildContext } from './context.js';
|
import { buildContext } from './context.js';
|
||||||
|
import {
|
||||||
|
normalizeMaxProfile,
|
||||||
|
normalizeTelegramProfile,
|
||||||
|
profileFromMaxMiniAppUser,
|
||||||
|
profileFromTelegramMiniAppUser,
|
||||||
|
upsertActiveMessengerConnection,
|
||||||
|
} from './messenger-connections.js';
|
||||||
|
import { validateMaxMiniAppInitData } from './max-mini-app.js';
|
||||||
import { sendMessengerMessage } from './messenger.js';
|
import { sendMessengerMessage } from './messenger.js';
|
||||||
|
import { buildMessengerLoginTemplate } from './notification-templates.js';
|
||||||
import { prisma } from './prisma-client.js';
|
import { prisma } from './prisma-client.js';
|
||||||
import { resolvers } from './resolvers.js';
|
import { resolvers } from './resolvers.js';
|
||||||
import { telegramApi, telegramFileUrl } from './telegram.js';
|
import { telegramApi, telegramFileUrl } from './telegram.js';
|
||||||
|
import { validateTelegramMiniAppInitData } from './telegram-mini-app.js';
|
||||||
|
|
||||||
const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8');
|
const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8');
|
||||||
|
|
||||||
@@ -92,16 +104,40 @@ function normalizeRedirectPath(value) {
|
|||||||
return redirectPath;
|
return redirectPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTelegramProfile(profile) {
|
function normalizeTargetApp(value) {
|
||||||
if (!profile || typeof profile !== 'object') {
|
return String(value || '').trim().toUpperCase() === 'BONUS' ? 'BONUS' : 'MAIN';
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
function presentTelegramMiniAppUser(user) {
|
||||||
|
return presentMiniAppUser(user, 'Пользователь Telegram');
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentMaxMiniAppUser(user) {
|
||||||
|
return presentMiniAppUser(user, 'Пользователь MAX');
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentMiniAppUser(user, fallbackDisplayName) {
|
||||||
|
const firstName = String(user?.first_name || '').trim();
|
||||||
|
const lastName = String(user?.last_name || '').trim();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
displayName: String(profile.displayName || '').trim() || null,
|
id: String(user?.id || '').trim(),
|
||||||
username: String(profile.username || '').trim().replace(/^@+/, '') || null,
|
firstName,
|
||||||
avatarFileId: String(profile.avatarFileId || '').trim() || null,
|
lastName: lastName || null,
|
||||||
avatarFileUniqueId: String(profile.avatarFileUniqueId || '').trim() || null,
|
username: String(user?.username || '').trim() || null,
|
||||||
|
languageCode: String(user?.language_code || '').trim() || null,
|
||||||
|
photoUrl: String(user?.photo_url || '').trim() || null,
|
||||||
|
displayName: `${firstName} ${lastName}`.trim() || firstName || fallbackDisplayName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentAuthUser(user) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
fullName: user.fullName,
|
||||||
|
role: user.role,
|
||||||
|
companyId: user.companyId ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +165,7 @@ app.post('/auth/messenger-start', async (req, res) => {
|
|||||||
const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail;
|
const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail;
|
||||||
const userId = authenticatedUser?.id ?? null;
|
const userId = authenticatedUser?.id ?? null;
|
||||||
const redirectPath = normalizeRedirectPath(req.body?.redirectPath);
|
const redirectPath = normalizeRedirectPath(req.body?.redirectPath);
|
||||||
|
const targetApp = normalizeTargetApp(req.body?.targetApp);
|
||||||
|
|
||||||
if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
res.status(400).json({ error: 'A valid email is required.' });
|
res.status(400).json({ error: 'A valid email is required.' });
|
||||||
@@ -140,6 +177,7 @@ app.post('/auth/messenger-start', async (req, res) => {
|
|||||||
email,
|
email,
|
||||||
userId,
|
userId,
|
||||||
redirectPath,
|
redirectPath,
|
||||||
|
targetApp,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -150,6 +188,217 @@ app.post('/auth/messenger-start', async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/auth/bonus-program-start', async (req, res) => {
|
||||||
|
const channel = String(req.body?.channel || 'TELEGRAM').toUpperCase();
|
||||||
|
if (channel !== 'TELEGRAM') {
|
||||||
|
res.status(400).json({ error: 'Only Telegram is supported for the bonus program.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = String(req.body?.token || '').trim();
|
||||||
|
if (!token) {
|
||||||
|
res.status(400).json({ error: 'Bonus program token is required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = verifyBonusProgramLinkToken(token);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = createMessengerStartSession({
|
||||||
|
channel,
|
||||||
|
email: '',
|
||||||
|
userId: payload.userId,
|
||||||
|
redirectPath: '/',
|
||||||
|
targetApp: 'BONUS',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
startToken: session.startToken,
|
||||||
|
expiresAt: session.expiresAt.toISOString(),
|
||||||
|
mode: 'login',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/telegram-mini-app/session', async (req, res) => {
|
||||||
|
let telegram;
|
||||||
|
try {
|
||||||
|
telegram = validateTelegramMiniAppInitData(req.body?.initData);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId = String(telegram.user.id);
|
||||||
|
const connection = await prisma.messengerConnection.findFirst({
|
||||||
|
where: {
|
||||||
|
type: 'TELEGRAM',
|
||||||
|
channelId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection?.user) {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
authenticated: false,
|
||||||
|
telegramUser: presentTelegramMiniAppUser(telegram.user),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertActiveMessengerConnection(prisma, {
|
||||||
|
userId: connection.user.id,
|
||||||
|
type: 'TELEGRAM',
|
||||||
|
channelId,
|
||||||
|
profile: profileFromTelegramMiniAppUser(telegram.user),
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = issueAccessToken(connection.user.id);
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
authenticated: true,
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
expiresAt: session.expiresAt.toISOString(),
|
||||||
|
user: presentAuthUser(connection.user),
|
||||||
|
telegramUser: presentTelegramMiniAppUser(telegram.user),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/telegram-mini-app/connect', async (req, res) => {
|
||||||
|
const user = await resolveAuthenticatedUserFromRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({ error: 'Authentication required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let telegram;
|
||||||
|
try {
|
||||||
|
telegram = validateTelegramMiniAppInitData(req.body?.initData);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await upsertActiveMessengerConnection(prisma, {
|
||||||
|
userId: user.id,
|
||||||
|
type: 'TELEGRAM',
|
||||||
|
channelId: telegram.user.id,
|
||||||
|
profile: profileFromTelegramMiniAppUser(telegram.user),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
connection: {
|
||||||
|
id: connection.id,
|
||||||
|
userId: connection.userId,
|
||||||
|
type: connection.type,
|
||||||
|
channelId: connection.channelId,
|
||||||
|
displayName: connection.displayName,
|
||||||
|
username: connection.username,
|
||||||
|
avatarAvailable: Boolean(connection.avatarFileId),
|
||||||
|
isActive: connection.isActive,
|
||||||
|
},
|
||||||
|
telegramUser: presentTelegramMiniAppUser(telegram.user),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/max-mini-app/session', async (req, res) => {
|
||||||
|
let max;
|
||||||
|
try {
|
||||||
|
max = validateMaxMiniAppInitData(req.body?.initData);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId = String(max.user.id);
|
||||||
|
const connection = await prisma.messengerConnection.findFirst({
|
||||||
|
where: {
|
||||||
|
type: 'MAX',
|
||||||
|
channelId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection?.user) {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
authenticated: false,
|
||||||
|
maxUser: presentMaxMiniAppUser(max.user),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertActiveMessengerConnection(prisma, {
|
||||||
|
userId: connection.user.id,
|
||||||
|
type: 'MAX',
|
||||||
|
channelId,
|
||||||
|
profile: profileFromMaxMiniAppUser(max.user),
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = issueAccessToken(connection.user.id);
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
authenticated: true,
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
expiresAt: session.expiresAt.toISOString(),
|
||||||
|
user: presentAuthUser(connection.user),
|
||||||
|
maxUser: presentMaxMiniAppUser(max.user),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/max-mini-app/connect', async (req, res) => {
|
||||||
|
const user = await resolveAuthenticatedUserFromRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({ error: 'Authentication required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max;
|
||||||
|
try {
|
||||||
|
max = validateMaxMiniAppInitData(req.body?.initData);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await upsertActiveMessengerConnection(prisma, {
|
||||||
|
userId: user.id,
|
||||||
|
type: 'MAX',
|
||||||
|
channelId: max.user.id,
|
||||||
|
profile: profileFromMaxMiniAppUser(max.user),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
connection: {
|
||||||
|
id: connection.id,
|
||||||
|
userId: connection.userId,
|
||||||
|
type: connection.type,
|
||||||
|
channelId: connection.channelId,
|
||||||
|
displayName: connection.displayName,
|
||||||
|
username: connection.username,
|
||||||
|
avatarAvailable: Boolean(connection.avatarFileId),
|
||||||
|
isActive: connection.isActive,
|
||||||
|
},
|
||||||
|
maxUser: presentMaxMiniAppUser(max.user),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/bot/messenger-login', async (req, res) => {
|
app.post('/bot/messenger-login', async (req, res) => {
|
||||||
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
|
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
|
||||||
const providedToken = req.body?.token;
|
const providedToken = req.body?.token;
|
||||||
@@ -199,34 +448,54 @@ app.post('/bot/messenger-login', async (req, res) => {
|
|||||||
messengerConnection: {
|
messengerConnection: {
|
||||||
type: channel,
|
type: channel,
|
||||||
channelId,
|
channelId,
|
||||||
...normalizeTelegramProfile(req.body?.profile),
|
...(channel === 'TELEGRAM'
|
||||||
|
? normalizeTelegramProfile(req.body?.profile)
|
||||||
|
: normalizeMaxProfile(req.body?.profile)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const frontendUrl = (
|
const mainFrontendUrl = (
|
||||||
process.env.WEB_FRONTEND_URL ||
|
process.env.WEB_FRONTEND_URL ||
|
||||||
process.env.NUXT_PUBLIC_SITE_URL ||
|
process.env.NUXT_PUBLIC_SITE_URL ||
|
||||||
'http://localhost:3000'
|
'http://localhost:3000'
|
||||||
).replace(/\/$/, '');
|
).replace(/\/$/, '');
|
||||||
const nextPath = startSession.redirectPath || (
|
const bonusFrontendUrl = String(
|
||||||
channel === 'TELEGRAM'
|
process.env.BONUS_FRONTEND_URL ||
|
||||||
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
|
process.env.BONUS_PUBLIC_BASE_URL ||
|
||||||
: ''
|
'',
|
||||||
);
|
).trim().replace(/\/$/, '');
|
||||||
const loginQuery = new URLSearchParams({
|
const frontendUrl = startSession.targetApp === 'BONUS'
|
||||||
login_token: login.loginToken,
|
? (bonusFrontendUrl || mainFrontendUrl)
|
||||||
});
|
: mainFrontendUrl;
|
||||||
if (nextPath) {
|
|
||||||
loginQuery.set('next', nextPath);
|
let loginUrl = `${frontendUrl}/login?login_token=${encodeURIComponent(login.loginToken)}`;
|
||||||
|
if (startSession.targetApp === 'BONUS') {
|
||||||
|
loginUrl = `${frontendUrl}/?login_token=${encodeURIComponent(login.loginToken)}`;
|
||||||
|
} else {
|
||||||
|
const nextPath = startSession.redirectPath || (
|
||||||
|
channel === 'TELEGRAM' || channel === 'MAX'
|
||||||
|
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
if (nextPath) {
|
||||||
|
const loginQuery = new URLSearchParams({
|
||||||
|
login_token: login.loginToken,
|
||||||
|
next: nextPath,
|
||||||
|
});
|
||||||
|
loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
|
|
||||||
|
|
||||||
if (!skipDispatch) {
|
if (!skipDispatch) {
|
||||||
|
const template = buildMessengerLoginTemplate({
|
||||||
|
buttonUrl: loginUrl,
|
||||||
|
expiresAt: login.expiresAt,
|
||||||
|
});
|
||||||
const dispatch = await sendMessengerMessage({
|
const dispatch = await sendMessengerMessage({
|
||||||
type: channel,
|
type: channel,
|
||||||
channelId,
|
channelId,
|
||||||
message: 'Вход подтвержден. Нажмите кнопку, чтобы открыть личный кабинет.',
|
message: template.message,
|
||||||
buttonUrl: loginUrl,
|
buttonUrl: template.buttonUrl,
|
||||||
buttonText: 'Открыть кабинет',
|
buttonText: template.buttonText,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!dispatch.success) {
|
if (!dispatch.success) {
|
||||||
@@ -316,7 +585,7 @@ app.use(
|
|||||||
|
|
||||||
const port = Number(process.env.PORT ?? 4000);
|
const port = Number(process.env.PORT ?? 4000);
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`apollo-backend running at http://localhost:${port}/graphql`);
|
console.log(`backend running at http://localhost:${port}/graphql`);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
|
|||||||
90
src/telegram-mini-app.js
Normal file
90
src/telegram-mini-app.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const TELEGRAM_MINI_APP_AUTH_MAX_AGE_SECONDS = Number(process.env.TELEGRAM_MINI_APP_AUTH_MAX_AGE_SECONDS ?? 60 * 60);
|
||||||
|
|
||||||
|
function requireTelegramBotToken() {
|
||||||
|
const token = String(process.env.TELEGRAM_BOT_TOKEN || '').trim();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('TELEGRAM_BOT_TOKEN is not configured.');
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqualHex(left, right) {
|
||||||
|
const leftBuffer = Buffer.from(String(left || ''), 'hex');
|
||||||
|
const rightBuffer = Buffer.from(String(right || ''), 'hex');
|
||||||
|
|
||||||
|
if (!leftBuffer.length || leftBuffer.length !== rightBuffer.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTelegramUser(rawUser) {
|
||||||
|
if (!rawUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(rawUser);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || !parsed.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(parsed.id),
|
||||||
|
first_name: String(parsed.first_name || '').trim(),
|
||||||
|
last_name: String(parsed.last_name || '').trim() || null,
|
||||||
|
username: String(parsed.username || '').trim() || null,
|
||||||
|
language_code: String(parsed.language_code || '').trim() || null,
|
||||||
|
photo_url: String(parsed.photo_url || '').trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTelegramMiniAppInitData(initDataRaw) {
|
||||||
|
const initData = String(initDataRaw || '').trim();
|
||||||
|
if (!initData) {
|
||||||
|
throw new Error('Telegram initData is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(initData);
|
||||||
|
const receivedHash = String(params.get('hash') || '').trim().toLowerCase();
|
||||||
|
if (!receivedHash) {
|
||||||
|
throw new Error('Telegram initData hash is missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDate = Number(params.get('auth_date'));
|
||||||
|
if (!Number.isFinite(authDate) || authDate <= 0) {
|
||||||
|
throw new Error('Telegram initData auth_date is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (now - authDate > TELEGRAM_MINI_APP_AUTH_MAX_AGE_SECONDS) {
|
||||||
|
throw new Error('Telegram initData is expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkEntries = [...params.entries()]
|
||||||
|
.filter(([key]) => key !== 'hash')
|
||||||
|
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||||
|
.map(([key, value]) => `${key}=${value}`);
|
||||||
|
const dataCheckString = checkEntries.join('\n');
|
||||||
|
|
||||||
|
const secretKey = crypto.createHmac('sha256', 'WebAppData').update(requireTelegramBotToken()).digest();
|
||||||
|
const expectedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex');
|
||||||
|
|
||||||
|
if (!timingSafeEqualHex(expectedHash, receivedHash)) {
|
||||||
|
throw new Error('Telegram initData signature is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = parseTelegramUser(params.get('user'));
|
||||||
|
if (!user?.id) {
|
||||||
|
throw new Error('Telegram user is missing in initData.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authDate: new Date(authDate * 1000),
|
||||||
|
queryId: String(params.get('query_id') || '').trim() || null,
|
||||||
|
startParam: String(params.get('start_param') || '').trim() || null,
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user