Centralize notification templates

This commit is contained in:
Ruslan Bakiev
2026-04-06 15:04:45 +07:00
parent 0f8f64a8a2
commit 44c24c4abd
5 changed files with 337 additions and 66 deletions

View File

@@ -1,4 +1,5 @@
import nodemailer from 'nodemailer';
import { buildLoginCodeEmailTemplate } from './notification-templates.js';
let cachedTransporter = null;
@@ -57,13 +58,13 @@ function getTransporter() {
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,
});
}

View File

@@ -0,0 +1,255 @@
import { isManagerRole } from './access.js';
const DELIVERY_CHANNELS = ['EMAIL', 'TELEGRAM', 'MAX'];
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,
};
}
function createMissingChannelPreview(channel) {
return {
channel,
implemented: false,
subject: null,
body: ['В коде не реализовано.'],
buttonText: null,
buttonUrl: null,
};
}
function buildChannelMatrix(implementedChannels) {
const implementedMap = new Map(implementedChannels.map((item) => [item.channel, item]));
return DELIVERY_CHANNELS.map((channel) => implementedMap.get(channel) ?? createMissingChannelPreview(channel));
}
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 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>`,
};
}
export function buildMessengerLoginTemplate({ buttonUrl }) {
return {
message: 'Вход подтвержден. Нажмите кнопку, чтобы открыть личный кабинет.',
buttonText: 'Открыть кабинет',
buttonUrl,
};
}
export function buildOrderStatusNotificationTemplate({ orderId, orderCode, status, note, role }) {
const message = `Заказ ${orderCode} изменил статус: ${status}.${note ? `\nКомментарий: ${note}` : ''}`;
return {
message,
buttonText: 'Открыть заказ',
buttonUrl: buildFrontendAppUrl(buildUserOrderPath(orderId, role)),
};
}
export function buildAutoBonusNotificationTemplate({ amount, orderCode }) {
return {
message: `Начислен бонус: ${amount} за заказ ${orderCode}.`,
buttonText: 'Открыть бонусную программу',
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('auto-balance')),
};
}
export function buildManualBonusNotificationTemplate({ amount, reason }) {
return {
message: `Начислен бонус: ${amount}. Причина: ${reason}`,
buttonText: 'Открыть бонусную программу',
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('manual-balance')),
};
}
export function buildWithdrawalReviewNotificationTemplate({ status, reviewComment }) {
return {
message: `Заявка на вывод вознаграждения обновлена: ${status}.${reviewComment ? ` Комментарий: ${reviewComment}` : ''}`,
buttonText: 'Проверить бонусную программу',
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('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',
});
const orderStatusTemplate = buildOrderStatusNotificationTemplate({
orderId: 'demo-order-id',
orderCode: 'FRG-2401',
status: 'IN_PROGRESS',
note: 'заказ передан в производство',
role: 'CLIENT',
});
const autoBonusTemplate = buildAutoBonusNotificationTemplate({
amount: 1250,
orderCode: 'FRG-2401',
});
const manualBonusTemplate = buildManualBonusNotificationTemplate({
amount: 500,
reason: 'ручное начисление менеджером',
});
const withdrawalReviewTemplate = buildWithdrawalReviewNotificationTemplate({
status: 'APPROVED',
reviewComment: 'выплата передана в обработку',
});
return [
{
id: 'login-code-email',
title: 'Код входа по email',
channels: buildChannelMatrix([
createChannelPreview('EMAIL', {
subject: loginTemplate.subject,
body: loginTemplate.body,
}),
]),
},
{
id: 'messenger-login-confirmed',
title: 'Подтверждение входа через мессенджер',
channels: buildChannelMatrix([
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: buildChannelMatrix([
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: 'auto-referral-bonus',
title: 'Автоматическое начисление реферального бонуса',
channels: buildChannelMatrix([
createChannelPreview('TELEGRAM', {
body: splitBody(autoBonusTemplate.message),
buttonText: autoBonusTemplate.buttonText,
buttonUrl: autoBonusTemplate.buttonUrl,
}),
createChannelPreview('MAX', {
body: splitBody(autoBonusTemplate.message),
buttonText: autoBonusTemplate.buttonText,
buttonUrl: autoBonusTemplate.buttonUrl,
}),
]),
},
{
id: 'manual-bonus-credit',
title: 'Ручное начисление бонуса',
channels: buildChannelMatrix([
createChannelPreview('TELEGRAM', {
body: splitBody(manualBonusTemplate.message),
buttonText: manualBonusTemplate.buttonText,
buttonUrl: manualBonusTemplate.buttonUrl,
}),
createChannelPreview('MAX', {
body: splitBody(manualBonusTemplate.message),
buttonText: manualBonusTemplate.buttonText,
buttonUrl: manualBonusTemplate.buttonUrl,
}),
]),
},
{
id: 'reward-withdrawal-review',
title: 'Решение по заявке на вывод бонусов',
channels: buildChannelMatrix([
createChannelPreview('TELEGRAM', {
body: splitBody(withdrawalReviewTemplate.message),
buttonText: withdrawalReviewTemplate.buttonText,
buttonUrl: withdrawalReviewTemplate.buttonUrl,
}),
createChannelPreview('MAX', {
body: splitBody(withdrawalReviewTemplate.message),
buttonText: withdrawalReviewTemplate.buttonText,
buttonUrl: withdrawalReviewTemplate.buttonUrl,
}),
]),
},
];
}

View File

@@ -15,6 +15,13 @@ import {
isManagerRole,
} from './access.js';
import { sendLoginCodeEmail } from './mailer.js';
import {
buildAutoBonusNotificationTemplate,
buildManualBonusNotificationTemplate,
buildOrderStatusNotificationTemplate,
buildWithdrawalReviewNotificationTemplate,
getNotificationTemplatesCatalog,
} from './notification-templates.js';
import { upsertActiveMessengerConnection } from './messenger-connections.js';
import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js';
import { dateTimeScalar, jsonScalar } from './scalars.js';
@@ -230,13 +237,17 @@ async function applyManualOrderStatus(context, order, manager, status) {
await notifyOrderStakeholders(context, updated, status, `Manager set status to ${status}`);
if (referralBonus?.isNew) {
const template = buildAutoBonusNotificationTemplate({
amount: toFloat(referralBonus.transaction.amount),
orderCode: updated.code,
});
await dispatchToUserConnections(
context.prisma,
referralBonus.transaction.userId,
`Начислен бонус: ${toFloat(referralBonus.transaction.amount)} за заказ ${updated.code}.`,
template.message,
{
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('auto-balance')),
buttonText: 'Открыть бонусную программу',
buttonUrl: template.buttonUrl,
buttonText: template.buttonText,
},
);
}
@@ -452,57 +463,12 @@ async function resolveOrderRequirements(context, user, deliveryAddressId) {
return resolveSelectedDeliveryAddress(context, user.id, deliveryAddressId);
}
function formatOrderStatusMessage(order, status, note) {
const suffix = note ? `\nКомментарий: ${note}` : '';
return `Заказ ${order.code} изменил статус: ${status}.${suffix}`;
}
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}`;
}
function buildUserOrderPath(orderId, role) {
const normalizedOrderId = String(orderId || '').trim();
if (!normalizedOrderId) {
return '';
}
return isManagerRole(role)
? `/client-orders/${normalizedOrderId}`
: `/orders/${normalizedOrderId}`;
}
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';
}
async function notifyOrderStakeholders(context, order, status, note) {
const recipients = [order.customerId, order.managerId].filter(Boolean);
if (!recipients.length) {
return;
}
const message = formatOrderStatusMessage(order, status, note);
const uniqueRecipients = [...new Set(recipients)];
const users = await context.prisma.user.findMany({
where: {
@@ -516,10 +482,20 @@ async function notifyOrderStakeholders(context, order, status, note) {
const userRoleMap = new Map(users.map((user) => [user.id, user.role]));
await Promise.allSettled(
uniqueRecipients.map((userId) => dispatchToUserConnections(context.prisma, userId, message, {
buttonUrl: buildFrontendAppUrl(buildUserOrderPath(order.id, userRoleMap.get(userId))),
buttonText: 'Открыть заказ',
})),
uniqueRecipients.map((userId) => {
const template = buildOrderStatusNotificationTemplate({
orderId: order.id,
orderCode: order.code,
status,
note,
role: userRoleMap.get(userId),
});
return dispatchToUserConnections(context.prisma, userId, template.message, {
buttonUrl: template.buttonUrl,
buttonText: template.buttonText,
});
}),
);
}
@@ -562,7 +538,13 @@ async function collectNotificationHistory(context, userId, channel, limit) {
id: `ORDER_${event.id}_${channel}`,
channel,
title: `Статус заказа ${event.order.code}`,
message: formatOrderStatusMessage(event.order, event.status, event.note),
message: buildOrderStatusNotificationTemplate({
orderId: event.orderId,
orderCode: event.order.code,
status: event.status,
note: event.note,
role: 'CLIENT',
}).message,
createdAt: event.createdAt,
orderId: event.orderId,
}));
@@ -679,6 +661,11 @@ export const resolvers = {
return collectNotificationHistory(context, user.id, channel, normalizedLimit);
},
notificationTemplates: (_, __, context) => {
requireManagerAccess(context);
return getNotificationTemplatesCatalog();
},
managerNotificationHistory: async (_, { userId, channel, limit }, context) => {
const manager = requireManagerAccess(context);
await assertManagerCanAccessUser(context.prisma, manager, userId);
@@ -1906,13 +1893,17 @@ export const resolvers = {
},
});
const template = buildManualBonusNotificationTemplate({
amount: toFloat(transaction.amount),
reason: transaction.reason,
});
await dispatchToUserConnections(
context.prisma,
transaction.userId,
`Начислен бонус: ${toFloat(transaction.amount)}. Причина: ${transaction.reason}`,
template.message,
{
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('manual-balance')),
buttonText: 'Открыть бонусную программу',
buttonUrl: template.buttonUrl,
buttonText: template.buttonText,
},
);
@@ -1954,13 +1945,17 @@ export const resolvers = {
},
});
const template = buildWithdrawalReviewNotificationTemplate({
status: withdrawal.status,
reviewComment: withdrawal.reviewComment,
});
await dispatchToUserConnections(
context.prisma,
withdrawal.requesterId,
`Заявка на вывод вознаграждения обновлена: ${withdrawal.status}.${withdrawal.reviewComment ? ` Комментарий: ${withdrawal.reviewComment}` : ''}`,
template.message,
{
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('withdrawal-review')),
buttonText: 'Проверить бонусную программу',
buttonUrl: template.buttonUrl,
buttonText: template.buttonText,
},
);

View File

@@ -178,6 +178,21 @@ 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 Warehouse {
id: ID!
code: String!
@@ -364,6 +379,7 @@ type Query {
myDeliveryAddresses: [DeliveryAddress!]!
myMessengerConnections: [MessengerConnection!]!
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
notificationTemplates: [NotificationTemplate!]!
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
clientProducts: [Product!]!
order(id: ID!): Order

View File

@@ -30,6 +30,7 @@ import {
} 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';
@@ -427,12 +428,15 @@ app.post('/bot/messenger-login', async (req, res) => {
const loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
if (!skipDispatch) {
const template = buildMessengerLoginTemplate({
buttonUrl: loginUrl,
});
const dispatch = await sendMessengerMessage({
type: channel,
channelId,
message: 'Вход подтвержден. Нажмите кнопку, чтобы открыть личный кабинет.',
buttonUrl: loginUrl,
buttonText: 'Открыть кабинет',
message: template.message,
buttonUrl: template.buttonUrl,
buttonText: template.buttonText,
});
if (!dispatch.success) {