Add email notifications and sync dashboard
This commit is contained in:
@@ -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) => `<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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
245
src/resolvers.js
245
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;
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user