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

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

View File

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

View File

@@ -30,6 +30,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';
@@ -427,12 +428,15 @@ app.post('/bot/messenger-login', async (req, res) => {
const loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`; const loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`;
if (!skipDispatch) { if (!skipDispatch) {
const template = buildMessengerLoginTemplate({
buttonUrl: loginUrl,
});
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) {