Centralize notification templates
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
255
src/notification-templates.js
Normal file
255
src/notification-templates.js
Normal 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,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
];
|
||||
}
|
||||
113
src/resolvers.js
113
src/resolvers.js
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user