Compare commits

16 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
21 changed files with 3917 additions and 332 deletions

View File

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

View File

@@ -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"]

View File

@@ -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.

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", "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": {

View File

@@ -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",
@@ -11,7 +11,8 @@
"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" "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": "",

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

@@ -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?
@@ -179,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)
@@ -189,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

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();

View File

@@ -161,12 +161,14 @@ async function upsertClient(index) {
update: { update: {
fullName: fullNameForIndex(index), fullName: fullNameForIndex(index),
role: 'CLIENT', role: 'CLIENT',
bonusProgramEnabled: index % 2 === 1,
companyId: company.id, companyId: company.id,
}, },
create: { create: {
email: buildClientEmail(index), email: buildClientEmail(index),
fullName: fullNameForIndex(index), fullName: fullNameForIndex(index),
role: 'CLIENT', role: 'CLIENT',
bonusProgramEnabled: index % 2 === 1,
companyId: company.id, companyId: company.id,
}, },
}); });

View File

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

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_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();

View File

@@ -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('&', '&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 }) { 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),
}); });
} }

View File

@@ -101,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: {

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

@@ -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!
@@ -322,6 +384,7 @@ 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!
@@ -364,8 +427,11 @@ 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 order(id: ID!): Order
myOrders: [Order!]! myOrders: [Order!]!
myCurrentOrders: [Order!]! myCurrentOrders: [Order!]!
@@ -446,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!
@@ -467,17 +550,12 @@ input SetOrderOfferInput {
orderId: ID! orderId: ID!
itemPrices: [OrderItemPriceInput!]! itemPrices: [OrderItemPriceInput!]!
deliveryTerms: String! deliveryTerms: String!
deliveryFee: Float! deliveryFee: Float
} }
input OrderItemPriceInput { input OrderItemPriceInput {
itemId: ID! itemId: ID!
unitPrice: Float! unitPrice: Float
}
input BlockOrderInput {
orderId: ID!
reason: String!
} }
input CreateReferralInput { input CreateReferralInput {
@@ -512,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!
@@ -526,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!

View File

@@ -17,6 +17,7 @@ import {
hasMessengerStartSession, hasMessengerStartSession,
issueAccessToken, issueAccessToken,
issueTemporaryLoginToken, issueTemporaryLoginToken,
verifyBonusProgramLinkToken,
verifyAccessToken, verifyAccessToken,
} from './auth.js'; } from './auth.js';
import { canManagerAccessUser, isManagerRole } from './access.js'; import { canManagerAccessUser, isManagerRole } from './access.js';
@@ -30,6 +31,7 @@ import {
} from './messenger-connections.js'; } from './messenger-connections.js';
import { validateMaxMiniAppInitData } from './max-mini-app.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';
@@ -102,6 +104,10 @@ function normalizeRedirectPath(value) {
return redirectPath; return redirectPath;
} }
function normalizeTargetApp(value) {
return String(value || '').trim().toUpperCase() === 'BONUS' ? 'BONUS' : 'MAIN';
}
function presentTelegramMiniAppUser(user) { function presentTelegramMiniAppUser(user) {
return presentMiniAppUser(user, 'Пользователь Telegram'); return presentMiniAppUser(user, 'Пользователь Telegram');
} }
@@ -159,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.' });
@@ -170,6 +177,7 @@ app.post('/auth/messenger-start', async (req, res) => {
email, email,
userId, userId,
redirectPath, redirectPath,
targetApp,
}); });
res.json({ res.json({
@@ -180,6 +188,43 @@ 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) => { app.post('/auth/telegram-mini-app/session', async (req, res) => {
let telegram; let telegram;
try { try {
@@ -408,31 +453,49 @@ app.post('/bot/messenger-login', async (req, res) => {
: normalizeMaxProfile(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 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 || ( const nextPath = startSession.redirectPath || (
channel === 'TELEGRAM' || channel === 'MAX' channel === 'TELEGRAM' || channel === 'MAX'
? `/profile/notifications/success?connected=${channel.toLowerCase()}` ? `/profile/notifications/success?connected=${channel.toLowerCase()}`
: '' : ''
); );
if (nextPath) {
const loginQuery = new URLSearchParams({ const loginQuery = new URLSearchParams({
login_token: login.loginToken, login_token: login.loginToken,
next: nextPath,
}); });
if (nextPath) { loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
loginQuery.set('next', nextPath); }
} }
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) {
@@ -522,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() {