From 92592e2baab93686165b1f9d97a2b0a115ad135a Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:25:28 +0700 Subject: [PATCH] Add email notifications and sync dashboard --- src/mailer.js | 52 ++++++++ src/notification-templates.js | 18 ++- src/resolvers.js | 245 +++++++++++++++++++++++++++++----- src/schema.graphql | 21 +++ 4 files changed, 299 insertions(+), 37 deletions(-) diff --git a/src/mailer.js b/src/mailer.js index c257e1d..b529213 100644 --- a/src/mailer.js +++ b/src/mailer.js @@ -55,6 +55,45 @@ function getTransporter() { return cachedTransporter; } +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +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) => `

${escapeHtml(line)}

`) + .join(''); + + if (!buttonUrl) { + return paragraphs; + } + + return `${paragraphs}

${escapeHtml(buttonText || 'Открыть')}

`; +} + export async function sendLoginCodeEmail({ to, code, expiresAt }) { const { from } = getSmtpConfig(); const transporter = getTransporter(); @@ -68,3 +107,16 @@ export async function sendLoginCodeEmail({ to, code, expiresAt }) { 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), + }); +} diff --git a/src/notification-templates.js b/src/notification-templates.js index dd6d458..f23f511 100644 --- a/src/notification-templates.js +++ b/src/notification-templates.js @@ -135,8 +135,14 @@ export function buildOrderStatusNotificationTemplate({ orderId, orderCode, statu export function buildBonusCreditTemplate({ amount }) { const normalizedAmount = Number(amount); + const body = [ + `Начислен бонус: ${Number.isFinite(normalizedAmount) ? normalizedAmount : amount}.`, + ]; + return { - message: `Начислен бонус: ${Number.isFinite(normalizedAmount) ? normalizedAmount : amount}.`, + subject: 'Начислен бонус', + body, + message: body.join('\n'), buttonText: 'Открыть бонусную программу', buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('balance')), }; @@ -157,6 +163,8 @@ export function buildWithdrawalReviewNotificationTemplate({ status, reviewCommen ]; return { + subject: 'Заявка на выплату вознаграждения', + body, message: body.join('\n'), buttonText: 'Открыть бонусную программу', buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('withdrawal-review')), @@ -238,6 +246,10 @@ export function getNotificationTemplatesCatalog() { id: 'bonus-credit', title: 'Начислен бонус', channels: [ + createChannelPreview('EMAIL', { + subject: bonusTemplate.subject, + body: bonusTemplate.body, + }), createChannelPreview('TELEGRAM', { body: splitBody(bonusTemplate.message), buttonText: bonusTemplate.buttonText, @@ -254,6 +266,10 @@ export function getNotificationTemplatesCatalog() { id: 'reward-withdrawal-review', title: 'Заявка на выплату вознаграждения', channels: [ + createChannelPreview('EMAIL', { + subject: withdrawalReviewTemplate.subject, + body: withdrawalReviewTemplate.body, + }), createChannelPreview('TELEGRAM', { body: splitBody(withdrawalReviewTemplate.message), buttonText: withdrawalReviewTemplate.buttonText, diff --git a/src/resolvers.js b/src/resolvers.js index 2c77a4d..9ff90c4 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -14,7 +14,7 @@ import { getManagedClientUserWhere, isManagerRole, } from './access.js'; -import { sendLoginCodeEmail } from './mailer.js'; +import { sendLoginCodeEmail, sendNotificationEmail } from './mailer.js'; import { buildAutoBonusNotificationTemplate, buildManualBonusNotificationTemplate, @@ -37,6 +37,19 @@ function roundMoney(value) { return Math.round((Number(value) + Number.EPSILON) * 100) / 100; } +function latestDate(...values) { + const timestamps = values + .filter(Boolean) + .map((value) => new Date(value).getTime()) + .filter((value) => Number.isFinite(value)); + + if (!timestamps.length) { + return null; + } + + return new Date(Math.max(...timestamps)); +} + function requireUser(context) { if (!context.user) { throw new Error('Authentication required.'); @@ -241,15 +254,31 @@ async function applyManualOrderStatus(context, order, manager, status) { amount: toFloat(referralBonus.transaction.amount), orderCode: updated.code, }); - await dispatchToUserConnections( - context.prisma, - referralBonus.transaction.userId, - template.message, - { - buttonUrl: template.buttonUrl, - buttonText: template.buttonText, - }, - ); + const rewardUser = await context.prisma.user.findUnique({ + where: { id: referralBonus.transaction.userId }, + select: { email: true }, + }); + + await Promise.allSettled([ + dispatchToUserConnections( + context.prisma, + referralBonus.transaction.userId, + template.message, + { + buttonUrl: template.buttonUrl, + buttonText: template.buttonText, + }, + ), + rewardUser?.email && template.subject && template.body?.length + ? sendNotificationEmail({ + to: rewardUser.email, + subject: template.subject, + body: template.body, + buttonText: template.buttonText, + buttonUrl: template.buttonUrl, + }) + : Promise.resolve(), + ]); } return context.prisma.order.findUnique({ @@ -477,24 +506,37 @@ async function notifyOrderStakeholders(context, order, status, note) { select: { id: true, role: true, + email: true, }, }); - const userRoleMap = new Map(users.map((user) => [user.id, user.role])); + const userMap = new Map(users.map((user) => [user.id, user])); await Promise.allSettled( - uniqueRecipients.map((userId) => { + uniqueRecipients.map(async (userId) => { + const user = userMap.get(userId); const template = buildOrderStatusNotificationTemplate({ orderId: order.id, orderCode: order.code, status, note, - role: userRoleMap.get(userId), + role: user?.role, }); - return dispatchToUserConnections(context.prisma, userId, template.message, { - buttonUrl: template.buttonUrl, - buttonText: template.buttonText, - }); + await Promise.allSettled([ + dispatchToUserConnections(context.prisma, userId, template.message, { + buttonUrl: template.buttonUrl, + buttonText: template.buttonText, + }), + user?.email && template.subject && template.body?.length + ? sendNotificationEmail({ + to: user.email, + subject: template.subject, + body: template.body, + buttonText: template.buttonText, + buttonUrl: template.buttonUrl, + }) + : Promise.resolve(), + ]); }), ); } @@ -679,6 +721,106 @@ export const resolvers = { return getNotificationTemplatesCatalog(); }, + integrationSyncDashboard: async (_, __, context) => { + requireManagerAccess(context); + + const [ + orderSummary, + orderStatusSummary, + productSummary, + stockSummary, + clientCount, + counterpartySummary, + ] = await Promise.all([ + context.prisma.order.aggregate({ + _count: { id: true }, + _max: { updatedAt: true }, + }), + context.prisma.orderStatusEvent.aggregate({ + _count: { id: true }, + _max: { createdAt: true }, + }), + context.prisma.product.aggregate({ + where: { isActive: true }, + _count: { id: true }, + _max: { updatedAt: true }, + }), + context.prisma.productStock.aggregate({ + _count: { id: true }, + _max: { updatedAt: true }, + }), + context.prisma.user.count({ + where: { role: 'CLIENT' }, + }), + context.prisma.counterpartyProfile.aggregate({ + _count: { id: true }, + _max: { updatedAt: true }, + }), + ]); + + const items = [ + { + id: 'orders', + title: 'Заказы клиентов', + description: 'Карточки заказов, которые будут приходить из 1С в кабинет.', + source: '1c.orders.pull', + syncedCount: orderSummary._count.id, + lastSyncedAt: orderSummary._max.updatedAt, + status: orderSummary._count.id > 0 ? 'Есть данные' : 'Ждём первое наполнение', + note: 'Под этот блок можно повесить pull-воркер заказов.', + }, + { + id: 'order-statuses', + title: 'Статусы заказов', + description: 'Смена статусов, сроков и всех событий по заказам.', + source: '1c.orders.statuses', + syncedCount: orderStatusSummary._count.id, + lastSyncedAt: latestDate(orderStatusSummary._max.createdAt, orderSummary._max.updatedAt), + status: orderStatusSummary._count.id > 0 ? 'Есть история событий' : 'История ещё не наполнена', + note: 'Здесь будет видно, что webhook-обновления из 1С живы.', + }, + { + id: 'catalog', + title: 'Каталог продукции', + description: 'Номенклатура, характеристики и доступные позиции каталога.', + source: '1c.catalog.products', + syncedCount: productSummary._count.id, + lastSyncedAt: productSummary._max.updatedAt, + status: productSummary._count.id > 0 ? 'Каталог доступен' : 'Каталог ещё не загружен', + note: 'Эта карточка станет контрольной точкой для синка каталога.', + }, + { + id: 'stock-balances', + title: 'Остатки по складам', + description: 'Остатки и наличие товаров для клиентской витрины.', + source: '1c.catalog.stock', + syncedCount: stockSummary._count.id, + lastSyncedAt: stockSummary._max.updatedAt, + status: stockSummary._count.id > 0 ? 'Остатки доступны' : 'Остатков пока нет', + note: 'Под этот блок можно будет повесить отдельный worker остатков.', + }, + { + id: 'counterparties', + title: 'Клиенты и контрагенты', + description: 'Связка профилей клиентов с данными контрагентов и кабинетом.', + source: '1c.counterparties.sync', + syncedCount: counterpartySummary._count.id, + lastSyncedAt: counterpartySummary._max.updatedAt, + status: counterpartySummary._count.id > 0 ? 'Контрагенты сопоставлены' : 'Сопоставление ещё не начато', + note: 'Здесь удобно контролировать связки между ЛК и 1С.', + }, + ]; + + return { + generatedAt: new Date(), + lastActivityAt: latestDate(...items.map((item) => item.lastSyncedAt)), + totalOrders: orderSummary._count.id, + totalProducts: productSummary._count.id, + totalClients: clientCount, + items, + }; + }, + managerNotificationHistory: async (_, { userId, channel, limit }, context) => { const manager = requireManagerAccess(context); await assertManagerCanAccessUser(context.prisma, manager, userId); @@ -1924,17 +2066,32 @@ export const resolvers = { const template = buildManualBonusNotificationTemplate({ amount: toFloat(transaction.amount), - reason: transaction.reason, }); - await dispatchToUserConnections( - context.prisma, - transaction.userId, - template.message, - { - buttonUrl: template.buttonUrl, - buttonText: template.buttonText, - }, - ); + const transactionUser = await context.prisma.user.findUnique({ + where: { id: transaction.userId }, + select: { email: true }, + }); + + await Promise.allSettled([ + dispatchToUserConnections( + context.prisma, + transaction.userId, + template.message, + { + buttonUrl: template.buttonUrl, + buttonText: template.buttonText, + }, + ), + transactionUser?.email && template.subject && template.body?.length + ? sendNotificationEmail({ + to: transactionUser.email, + subject: template.subject, + body: template.body, + buttonText: template.buttonText, + buttonUrl: template.buttonUrl, + }) + : Promise.resolve(), + ]); return transaction; }, @@ -1978,15 +2135,31 @@ export const resolvers = { status: withdrawal.status, reviewComment: withdrawal.reviewComment, }); - await dispatchToUserConnections( - context.prisma, - withdrawal.requesterId, - template.message, - { - buttonUrl: template.buttonUrl, - buttonText: template.buttonText, - }, - ); + const requester = await context.prisma.user.findUnique({ + where: { id: withdrawal.requesterId }, + select: { email: true }, + }); + + await Promise.allSettled([ + dispatchToUserConnections( + context.prisma, + withdrawal.requesterId, + template.message, + { + buttonUrl: template.buttonUrl, + buttonText: template.buttonText, + }, + ), + requester?.email && template.subject && template.body?.length + ? sendNotificationEmail({ + to: requester.email, + subject: template.subject, + body: template.body, + buttonText: template.buttonText, + buttonUrl: template.buttonUrl, + }) + : Promise.resolve(), + ]); return withdrawal; }, diff --git a/src/schema.graphql b/src/schema.graphql index 767f2f9..58bc239 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -193,6 +193,26 @@ type NotificationTemplate { 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 Warehouse { id: ID! code: String! @@ -380,6 +400,7 @@ type Query { myMessengerConnections: [MessengerConnection!]! myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! notificationTemplates: [NotificationTemplate!]! + integrationSyncDashboard: IntegrationSyncDashboard! managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! clientProducts: [Product!]! order(id: ID!): Order