Add email notifications and sync dashboard

This commit is contained in:
Ruslan Bakiev
2026-04-07 10:25:28 +07:00
parent 386f6fa9fe
commit 92592e2baa
4 changed files with 299 additions and 37 deletions

View File

@@ -55,6 +55,45 @@ function getTransporter() {
return cachedTransporter;
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
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) => `<p>${escapeHtml(line)}</p>`)
.join('');
if (!buttonUrl) {
return paragraphs;
}
return `${paragraphs}<p><a href="${escapeHtml(buttonUrl)}" style="display:inline-block;padding:12px 18px;border-radius:999px;background:#123824;color:#ffffff;text-decoration:none;font-weight:700;">${escapeHtml(buttonText || 'Открыть')}</a></p>`;
}
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),
});
}

View File

@@ -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,

View File

@@ -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,7 +254,13 @@ async function applyManualOrderStatus(context, order, manager, status) {
amount: toFloat(referralBonus.transaction.amount),
orderCode: updated.code,
});
await dispatchToUserConnections(
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,
@@ -249,7 +268,17 @@ async function applyManualOrderStatus(context, order, manager, status) {
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, {
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,9 +2066,14 @@ export const resolvers = {
const template = buildManualBonusNotificationTemplate({
amount: toFloat(transaction.amount),
reason: transaction.reason,
});
await dispatchToUserConnections(
const transactionUser = await context.prisma.user.findUnique({
where: { id: transaction.userId },
select: { email: true },
});
await Promise.allSettled([
dispatchToUserConnections(
context.prisma,
transaction.userId,
template.message,
@@ -1934,7 +2081,17 @@ export const resolvers = {
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,7 +2135,13 @@ export const resolvers = {
status: withdrawal.status,
reviewComment: withdrawal.reviewComment,
});
await dispatchToUserConnections(
const requester = await context.prisma.user.findUnique({
where: { id: withdrawal.requesterId },
select: { email: true },
});
await Promise.allSettled([
dispatchToUserConnections(
context.prisma,
withdrawal.requesterId,
template.message,
@@ -1986,7 +2149,17 @@ export const resolvers = {
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;
},

View File

@@ -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