Compare commits

31 Commits

Author SHA1 Message Date
Ruslan Bakiev
fcc2eb7450 Add client bonus access flag 2026-05-16 17:16:31 +07:00
Ruslan Bakiev
c641a3dd23 Fix login code delivery mode 2026-05-16 09:23:40 +07:00
Ruslan Bakiev
47ba203edc Rename Apollo backend service to backend 2026-05-14 14:06:43 +07:00
Ruslan Bakiev
4d46174bbb Fix duplicate catalog option migration 2026-04-09 17:22:27 +07:00
Ruslan Bakiev
0103c3fb8a Add catalog option sets 2026-04-09 17:10:52 +07:00
Ruslan Bakiev
2cd8d0b612 Add catalog product type settings 2026-04-09 16:03:32 +07:00
Ruslan Bakiev
da31e21406 Bundle catalog import data 2026-04-09 14:32:36 +07:00
Ruslan Bakiev
40b4515305 Add tagged catalog import 2026-04-09 14:14:10 +07:00
Ruslan Bakiev
b321075293 Add standalone bonus program auth flow 2026-04-07 10:47:44 +07:00
Ruslan Bakiev
92592e2baa Add email notifications and sync dashboard 2026-04-07 10:25:28 +07:00
Ruslan Bakiev
386f6fa9fe Tighten notification copy 2026-04-06 21:28:15 +07:00
Ruslan Bakiev
db2e05bbf4 Refine notification template copy 2026-04-06 20:50:17 +07:00
Ruslan Bakiev
c6a515803b Add messenger connection removal 2026-04-06 15:51:01 +07:00
Ruslan Bakiev
44c24c4abd Centralize notification templates 2026-04-06 15:04:45 +07:00
Ruslan Bakiev
0f8f64a8a2 Link bonus notifications to bonus program 2026-04-06 14:42:12 +07:00
Ruslan Bakiev
84184f4568 Simplify manager order status editing 2026-04-06 12:22:33 +07:00
Ruslan Bakiev
c6634bfe5b Sort orders by latest activity 2026-04-06 11:49:38 +07:00
Ruslan Bakiev
d28b26629c Add manager bonus account detail 2026-04-06 10:58:27 +07:00
Ruslan Bakiev
8c2b4c1092 Add demo data seed script 2026-04-06 10:38:16 +07:00
Ruslan Bakiev
5acafba77c Add MAX Mini App auth flow 2026-04-04 21:51:40 +07:00
Ruslan Bakiev
6c5b9ef98e Add client referral bonus links 2026-04-04 14:59:02 +07:00
Ruslan Bakiev
1bec782edd Fix Telegram Mini App hash validation 2026-04-04 14:37:45 +07:00
Ruslan Bakiev
2499aa1a6a Add Telegram Mini App auth flow 2026-04-04 14:21:18 +07:00
Ruslan Bakiev
a0cbae390c Allow starting priced orders 2026-04-04 14:01:46 +07:00
Ruslan Bakiev
4278219c03 Add single-order query 2026-04-04 13:45:39 +07:00
Ruslan Bakiev
3abebf3701 Add per-item order pricing 2026-04-04 11:16:16 +07:00
Ruslan Bakiev
4281afd7e8 Disable manager scoping for debug 2026-04-04 10:52:00 +07:00
Ruslan Bakiev
60e0ac630d Filter manager orders by customer 2026-04-04 10:36:05 +07:00
Ruslan Bakiev
335ba994ab Expose manager telegram avatars 2026-04-04 10:13:50 +07:00
Ruslan Bakiev
6b966c763e Add super manager role 2026-04-04 09:41:36 +07:00
Ruslan Bakiev
da7cad207c Add manager user and bonus queries 2026-04-04 09:28:52 +07:00
29 changed files with 5748 additions and 305 deletions

View File

@@ -14,3 +14,4 @@ SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=
AUTH_LOGIN_CODE_DELIVERY=email

View File

@@ -14,5 +14,6 @@ RUN npm ci && npx prisma generate
COPY src ./src
COPY scripts ./scripts
COPY data ./data
CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && npx prisma migrate deploy && node src/server.js"]

View File

@@ -1,4 +1,4 @@
# Fregat Apollo Backend
# Fregat Backend
GraphQL backend for Fregat client cabinet and manager cabinet.

File diff suppressed because it is too large Load Diff

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "fregat-apollo-backend",
"name": "fregat-backend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fregat-apollo-backend",
"name": "fregat-backend",
"version": "0.1.0",
"license": "ISC",
"dependencies": {

View File

@@ -1,5 +1,5 @@
{
"name": "fregat-apollo-backend",
"name": "fregat-backend",
"version": "0.1.0",
"description": "",
"main": "index.js",
@@ -10,7 +10,9 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy",
"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": [],
"author": "",

View File

@@ -0,0 +1 @@
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'SUPER_MANAGER';

View File

@@ -0,0 +1,2 @@
ALTER TABLE "OrderItem"
ADD COLUMN "unitPrice" DECIMAL(14, 2);

View 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");

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -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");

View File

@@ -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[];

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "bonusProgramEnabled" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -9,6 +9,7 @@ datasource db {
enum UserRole {
CLIENT
MANAGER
SUPER_MANAGER
}
enum RegistrationStatus {
@@ -59,6 +60,7 @@ model User {
email String @unique
fullName String
role UserRole
bonusProgramEnabled Boolean @default(false)
companyId String?
company Company? @relation(fields: [companyId], references: [id])
counterpartyProfile CounterpartyProfile?
@@ -76,6 +78,7 @@ model User {
orderStatusEvents OrderStatusEvent[]
referralAsReferrer ReferralLink[] @relation("ReferralReferrer")
referralAsReferee ReferralLink[] @relation("ReferralReferee")
createdReferralLinks ReferralLink[] @relation("ReferralCreator")
bonusTransactions BonusTransaction[]
withdrawalRequests RewardWithdrawalRequest[] @relation("WithdrawalRequester")
reviewedWithdrawals RewardWithdrawalRequest[] @relation("WithdrawalReviewer")
@@ -177,6 +180,7 @@ model Product {
thicknessMicron Int?
sleeveBrand String?
quantityPerBox String?
tags String[] @default([])
description String?
isCustomizable Boolean @default(false)
isActive Boolean @default(true)
@@ -187,6 +191,26 @@ model Product {
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 {
id String @id @default(cuid())
userId String @unique
@@ -272,6 +296,7 @@ model OrderItem {
product Product? @relation(fields: [productId], references: [id])
productName String
quantity Decimal @db.Decimal(14, 3)
unitPrice Decimal? @db.Decimal(14, 2)
createdAt DateTime @default(now())
}
@@ -287,24 +312,34 @@ model OrderStatusEvent {
}
model ReferralLink {
id String @id @default(cuid())
referrerId String
referrer User @relation("ReferralReferrer", fields: [referrerId], references: [id])
refereeId String
referee User @relation("ReferralReferee", fields: [refereeId], references: [id])
createdAt DateTime @default(now())
id String @id @default(cuid())
referrerId String
referrer User @relation("ReferralReferrer", fields: [referrerId], references: [id])
refereeId String
referee User @relation("ReferralReferee", fields: [refereeId], references: [id])
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])
@@index([referrerId])
@@index([refereeId])
}
model BonusTransaction {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
amount Decimal @db.Decimal(14, 2)
reason String
orderId String?
createdAt DateTime @default(now())
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
amount Decimal @db.Decimal(14, 2)
reason String
orderId String?
referralLinkId String?
referralLink ReferralLink? @relation(fields: [referralLinkId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@unique([orderId, referralLinkId])
}
model RewardWithdrawalRequest {

105
scripts/import-catalog.js Normal file
View 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
View 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();
});

View File

@@ -104,11 +104,16 @@ const manager = await prisma.user.upsert({
await prisma.user.upsert({
where: { email: clientEmail },
update: { fullName: 'Demo Client', companyId: company.id },
update: {
fullName: 'Demo Client',
companyId: company.id,
bonusProgramEnabled: true,
},
create: {
email: clientEmail,
fullName: 'Demo Client',
role: 'CLIENT',
bonusProgramEnabled: true,
companyId: company.id,
},
});

View File

@@ -6,10 +6,10 @@ const [, , emailArg, roleArg = 'MANAGER'] = process.argv;
const email = String(emailArg || '').trim().toLowerCase();
const role = String(roleArg || '').trim().toUpperCase();
const allowedRoles = new Set(['CLIENT', 'MANAGER']);
const allowedRoles = new Set(['CLIENT', 'MANAGER', 'SUPER_MANAGER']);
if (!email) {
throw new Error('Usage: node scripts/set-user-role.js <email> [CLIENT|MANAGER]');
throw new Error('Usage: node scripts/set-user-role.js <email> [CLIENT|MANAGER|SUPER_MANAGER]');
}
if (!allowedRoles.has(role)) {

17
src/access.js Normal file
View File

@@ -0,0 +1,17 @@
export const MANAGER_ROLES = ['MANAGER', 'SUPER_MANAGER'];
export function isSuperManager(user) {
return user?.role === 'SUPER_MANAGER';
}
export function isManagerRole(role) {
return MANAGER_ROLES.includes(role);
}
export async function getManagedClientUserWhere(prisma, manager) {
return {};
}
export async function canManagerAccessUser(prisma, manager, userId) {
return isManagerRole(manager?.role);
}

View File

@@ -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_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 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 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();
const startToken = crypto.randomBytes(24).toString('base64url');
@@ -189,6 +190,7 @@ export function createMessengerStartSession({ channel, email, userId, redirectPa
email,
userId,
redirectPath,
targetApp,
expiresAt,
});
@@ -212,6 +214,7 @@ export function consumeMessengerStartSession(startToken) {
email: payload.email,
userId: payload.userId,
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) {
purgeExpiredLoginTokens();

View File

@@ -1,4 +1,5 @@
import nodemailer from 'nodemailer';
import { buildLoginCodeEmailTemplate } from './notification-templates.js';
let cachedTransporter = null;
@@ -54,16 +55,68 @@ function getTransporter() {
return cachedTransporter;
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
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 }) {
const { from } = getSmtpConfig();
const transporter = getTransporter();
const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false });
const template = buildLoginCodeEmailTemplate({ code, expiresAt });
await transporter.sendMail({
from,
to,
subject: 'Код входа в личный кабинет Fregat',
text: `Код входа: ${code}\nДействует до: ${expiresText}`,
html: `<p>Код входа: <b>${code}</b></p><p>Действует до: ${expiresText}</p>`,
subject: template.subject,
text: template.text,
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
View 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')),
};
}

View 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,
},
});
});
}

View File

@@ -6,6 +6,35 @@ function maskChannel(channelId) {
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 = {}) {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) {
@@ -16,6 +45,7 @@ async function sendTelegramMessage(channelId, message, options = {}) {
}
try {
const button = buildTelegramButton(options.buttonUrl, options.buttonText);
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -23,14 +53,11 @@ async function sendTelegramMessage(channelId, message, options = {}) {
chat_id: channelId,
text: message,
disable_web_page_preview: true,
...(options.buttonUrl
...(button
? {
reply_markup: {
inline_keyboard: [[
{
text: options.buttonText || 'Открыть кабинет',
url: options.buttonUrl,
},
button,
]],
},
}
@@ -74,7 +101,7 @@ async function sendMaxMessage(channelId, message, options = {}) {
body: JSON.stringify({
channelId,
text: message,
source: 'fregat-apollo-backend',
source: 'fregat-backend',
...(options.buttonUrl
? {
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({
where: {
userId,
@@ -139,6 +166,8 @@ export async function dispatchToUserConnections(prisma, userId, message) {
type: connection.type,
channelId: connection.channelId,
message,
buttonUrl: options.buttonUrl,
buttonText: options.buttonText,
});
results.push({

View 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,
}),
],
},
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ scalar JSON
enum UserRole {
CLIENT
MANAGER
SUPER_MANAGER
}
enum MessengerType {
@@ -62,6 +63,7 @@ type User {
email: String!
fullName: String!
role: UserRole!
bonusProgramEnabled: Boolean!
company: Company
}
@@ -136,6 +138,20 @@ type RegistrationRequest {
updatedAt: DateTime!
}
type ManagerUser {
id: ID!
email: String!
fullName: String!
role: UserRole!
bonusProgramEnabled: Boolean!
companyName: String
inn: String
createdAt: DateTime!
orderCount: Int!
lastOrderAt: DateTime
telegramConnection: MessengerConnection
}
type MessengerConnection {
id: ID!
userId: ID!
@@ -164,6 +180,48 @@ type NotificationHistoryItem {
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 {
id: ID!
code: String!
@@ -186,11 +244,29 @@ type Product {
thicknessMicron: Int
sleeveBrand: String
quantityPerBox: String
tags: [String!]!
isCustomizable: Boolean!
isActive: Boolean!
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 {
id: ID!
productId: ID!
@@ -218,6 +294,8 @@ type OrderItem {
productId: ID
productName: String!
quantity: Float!
unitPrice: Float
lineTotal: Float
}
type OrderStatusEvent {
@@ -253,6 +331,23 @@ type ReferralLink {
id: ID!
referrerId: 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!
}
@@ -284,6 +379,46 @@ type ReferralStats {
pendingWithdrawals: [RewardWithdrawalRequest!]!
}
type ManagerBonusBalance {
userId: ID!
email: String!
fullName: String!
companyName: String
bonusProgramEnabled: Boolean!
balance: Float!
pendingWithdrawalAmount: Float!
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 {
id: ID!
requesterId: ID!
requesterEmail: String!
requesterFullName: String!
companyName: String
amount: Float!
status: WithdrawalStatus!
reviewedById: ID
reviewComment: String
createdAt: DateTime!
updatedAt: DateTime!
}
type Query {
healthcheck: String!
me: User
@@ -292,11 +427,20 @@ type Query {
myDeliveryAddresses: [DeliveryAddress!]!
myMessengerConnections: [MessengerConnection!]!
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
notificationTemplates: [NotificationTemplate!]!
integrationSyncDashboard: IntegrationSyncDashboard!
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
clientProducts: [Product!]!
catalogProductTypeSettings: [CatalogProductTypeSetting!]!
order(id: ID!): Order
myOrders: [Order!]!
myCurrentOrders: [Order!]!
managerOrders(status: OrderStatus): [Order!]!
managerUsers: [ManagerUser!]!
managerOrders(status: OrderStatus, customerId: ID): [Order!]!
managerReferralLinks: [ManagerReferralLink!]!
managerBonusBalances: [ManagerBonusBalance!]!
managerBonusAccount(userId: ID!): ManagerBonusAccount!
managerWithdrawalRequests(status: WithdrawalStatus): [ManagerWithdrawalRequest!]!
registrationRequests(status: RegistrationStatus): [RegistrationRequest!]!
referralStats: ReferralStats!
}
@@ -368,6 +512,23 @@ input UpdateCartItemQuantityInput {
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 {
productId: ID!
quantity: Float!
@@ -387,18 +548,20 @@ input SubmitCalculationOrderInput {
input SetOrderOfferInput {
orderId: ID!
itemPrices: [OrderItemPriceInput!]!
deliveryTerms: String!
deliveryFee: Float!
totalPrice: Float!
deliveryFee: Float
}
input BlockOrderInput {
orderId: ID!
reason: String!
input OrderItemPriceInput {
itemId: ID!
unitPrice: Float
}
input CreateReferralInput {
referrerUserId: ID!
refereeUserId: ID!
bonusPercent: Float!
}
input AddBonusTransactionInput {
@@ -427,7 +590,9 @@ type Mutation {
createInvitation(input: CreateInvitationInput!): Invitation!
acceptInvitation(input: AcceptInvitationInput!): User!
connectMessenger(input: ConnectMessengerInput!): MessengerConnection!
deleteMyMessengerConnection(connectionId: ID!): Boolean!
upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile!
upsertCatalogProductTypeSetting(input: UpsertCatalogProductTypeSettingInput!): CatalogProductTypeSetting!
addProductToCart(productId: ID!): Cart!
updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart!
removeCartItem(productId: ID!): Cart!
@@ -441,13 +606,12 @@ type Mutation {
submitReadyOrder(input: SubmitReadyOrderInput!): Order!
submitCalculationOrder(input: SubmitCalculationOrderInput!): Order!
managerSetOrderOffer(input: SetOrderOfferInput!): Order!
managerSetOrderStatus(orderId: ID!, status: OrderStatus!): 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!
setClientBonusProgramEnabled(userId: ID!, enabled: Boolean!): ManagerUser!
createBonusProgramLink(userId: ID!): BonusProgramLink!
addBonusTransaction(input: AddBonusTransactionInput!): BonusTransaction!
requestRewardWithdrawal(input: RequestRewardWithdrawalInput!): RewardWithdrawalRequest!
reviewRewardWithdrawal(input: ReviewRewardWithdrawalInput!): RewardWithdrawalRequest!

View File

@@ -15,14 +15,27 @@ import {
consumeMessengerStartSession,
extractAuthTokenFromRequest,
hasMessengerStartSession,
issueAccessToken,
issueTemporaryLoginToken,
verifyBonusProgramLinkToken,
verifyAccessToken,
} from './auth.js';
import { canManagerAccessUser, isManagerRole } from './access.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 { buildMessengerLoginTemplate } from './notification-templates.js';
import { prisma } from './prisma-client.js';
import { resolvers } from './resolvers.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');
@@ -91,16 +104,40 @@ function normalizeRedirectPath(value) {
return redirectPath;
}
function normalizeTelegramProfile(profile) {
if (!profile || typeof profile !== 'object') {
return null;
}
function normalizeTargetApp(value) {
return String(value || '').trim().toUpperCase() === 'BONUS' ? 'BONUS' : 'MAIN';
}
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 {
displayName: String(profile.displayName || '').trim() || null,
username: String(profile.username || '').trim().replace(/^@+/, '') || null,
avatarFileId: String(profile.avatarFileId || '').trim() || null,
avatarFileUniqueId: String(profile.avatarFileUniqueId || '').trim() || null,
id: String(user?.id || '').trim(),
firstName,
lastName: lastName || 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,
};
}
@@ -128,6 +165,7 @@ app.post('/auth/messenger-start', async (req, res) => {
const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail;
const userId = authenticatedUser?.id ?? null;
const redirectPath = normalizeRedirectPath(req.body?.redirectPath);
const targetApp = normalizeTargetApp(req.body?.targetApp);
if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ error: 'A valid email is required.' });
@@ -139,6 +177,7 @@ app.post('/auth/messenger-start', async (req, res) => {
email,
userId,
redirectPath,
targetApp,
});
res.json({
@@ -149,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) => {
const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN;
const providedToken = req.body?.token;
@@ -198,34 +448,54 @@ app.post('/bot/messenger-login', async (req, res) => {
messengerConnection: {
type: channel,
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.NUXT_PUBLIC_SITE_URL ||
'http://localhost:3000'
).replace(/\/$/, '');
const nextPath = startSession.redirectPath || (
channel === 'TELEGRAM'
? `/profile/notifications/success?connected=${channel.toLowerCase()}`
: ''
);
const loginQuery = new URLSearchParams({
login_token: login.loginToken,
});
if (nextPath) {
loginQuery.set('next', nextPath);
const bonusFrontendUrl = String(
process.env.BONUS_FRONTEND_URL ||
process.env.BONUS_PUBLIC_BASE_URL ||
'',
).trim().replace(/\/$/, '');
const frontendUrl = startSession.targetApp === 'BONUS'
? (bonusFrontendUrl || mainFrontendUrl)
: mainFrontendUrl;
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) {
const template = buildMessengerLoginTemplate({
buttonUrl: loginUrl,
expiresAt: login.expiresAt,
});
const dispatch = await sendMessengerMessage({
type: channel,
channelId,
message: 'Вход подтвержден. Нажмите кнопку, чтобы открыть личный кабинет.',
buttonUrl: loginUrl,
buttonText: 'Открыть кабинет',
message: template.message,
buttonUrl: template.buttonUrl,
buttonText: template.buttonText,
});
if (!dispatch.success) {
@@ -258,12 +528,29 @@ app.get('/messenger/avatar/:connectionId', async (req, res) => {
const connection = await prisma.messengerConnection.findFirst({
where: {
id: connectionId,
userId: user.id,
type: 'TELEGRAM',
isActive: true,
},
});
if (!connection) {
res.status(404).json({ error: 'Telegram avatar not found.' });
return;
}
if (connection.userId !== user.id) {
if (!isManagerRole(user.role)) {
res.status(403).json({ error: 'Access denied.' });
return;
}
const canAccess = await canManagerAccessUser(prisma, user, connection.userId);
if (!canAccess) {
res.status(403).json({ error: 'Access denied.' });
return;
}
}
if (!connection?.avatarFileId) {
res.status(404).json({ error: 'Telegram avatar not found.' });
return;
@@ -298,7 +585,7 @@ app.use(
const port = Number(process.env.PORT ?? 4000);
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() {

90
src/telegram-mini-app.js Normal file
View 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,
};
}