diff --git a/src/mailer.js b/src/mailer.js index 59b4e78..c257e1d 100644 --- a/src/mailer.js +++ b/src/mailer.js @@ -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: `

Код входа: ${code}

Действует до: ${expiresText}

`, + subject: template.subject, + text: template.text, + html: template.html, }); } diff --git a/src/notification-templates.js b/src/notification-templates.js new file mode 100644 index 0000000..406da22 --- /dev/null +++ b/src/notification-templates.js @@ -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: `

Код входа: ${code}

Действует до: ${expiresText}

`, + }; +} + +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, + }), + ]), + }, + ]; +} diff --git a/src/resolvers.js b/src/resolvers.js index ae4ad04..5d3456f 100644 --- a/src/resolvers.js +++ b/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, }, ); diff --git a/src/schema.graphql b/src/schema.graphql index 6ee668d..db87ebd 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -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 diff --git a/src/server.js b/src/server.js index 45a347e..4fae6d8 100644 --- a/src/server.js +++ b/src/server.js @@ -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) {