Add email notifications and sync dashboard
This commit is contained in:
@@ -55,6 +55,45 @@ function getTransporter() {
|
|||||||
return cachedTransporter;
|
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 }) {
|
export async function sendLoginCodeEmail({ to, code, expiresAt }) {
|
||||||
const { from } = getSmtpConfig();
|
const { from } = getSmtpConfig();
|
||||||
const transporter = getTransporter();
|
const transporter = getTransporter();
|
||||||
@@ -68,3 +107,16 @@ export async function sendLoginCodeEmail({ to, code, expiresAt }) {
|
|||||||
html: template.html,
|
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 }) {
|
export function buildBonusCreditTemplate({ amount }) {
|
||||||
const normalizedAmount = Number(amount);
|
const normalizedAmount = Number(amount);
|
||||||
|
const body = [
|
||||||
|
`Начислен бонус: ${Number.isFinite(normalizedAmount) ? normalizedAmount : amount}.`,
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: `Начислен бонус: ${Number.isFinite(normalizedAmount) ? normalizedAmount : amount}.`,
|
subject: 'Начислен бонус',
|
||||||
|
body,
|
||||||
|
message: body.join('\n'),
|
||||||
buttonText: 'Открыть бонусную программу',
|
buttonText: 'Открыть бонусную программу',
|
||||||
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('balance')),
|
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('balance')),
|
||||||
};
|
};
|
||||||
@@ -157,6 +163,8 @@ export function buildWithdrawalReviewNotificationTemplate({ status, reviewCommen
|
|||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
subject: 'Заявка на выплату вознаграждения',
|
||||||
|
body,
|
||||||
message: body.join('\n'),
|
message: body.join('\n'),
|
||||||
buttonText: 'Открыть бонусную программу',
|
buttonText: 'Открыть бонусную программу',
|
||||||
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('withdrawal-review')),
|
buttonUrl: buildFrontendAppUrl(buildBonusProgramPath('withdrawal-review')),
|
||||||
@@ -238,6 +246,10 @@ export function getNotificationTemplatesCatalog() {
|
|||||||
id: 'bonus-credit',
|
id: 'bonus-credit',
|
||||||
title: 'Начислен бонус',
|
title: 'Начислен бонус',
|
||||||
channels: [
|
channels: [
|
||||||
|
createChannelPreview('EMAIL', {
|
||||||
|
subject: bonusTemplate.subject,
|
||||||
|
body: bonusTemplate.body,
|
||||||
|
}),
|
||||||
createChannelPreview('TELEGRAM', {
|
createChannelPreview('TELEGRAM', {
|
||||||
body: splitBody(bonusTemplate.message),
|
body: splitBody(bonusTemplate.message),
|
||||||
buttonText: bonusTemplate.buttonText,
|
buttonText: bonusTemplate.buttonText,
|
||||||
@@ -254,6 +266,10 @@ export function getNotificationTemplatesCatalog() {
|
|||||||
id: 'reward-withdrawal-review',
|
id: 'reward-withdrawal-review',
|
||||||
title: 'Заявка на выплату вознаграждения',
|
title: 'Заявка на выплату вознаграждения',
|
||||||
channels: [
|
channels: [
|
||||||
|
createChannelPreview('EMAIL', {
|
||||||
|
subject: withdrawalReviewTemplate.subject,
|
||||||
|
body: withdrawalReviewTemplate.body,
|
||||||
|
}),
|
||||||
createChannelPreview('TELEGRAM', {
|
createChannelPreview('TELEGRAM', {
|
||||||
body: splitBody(withdrawalReviewTemplate.message),
|
body: splitBody(withdrawalReviewTemplate.message),
|
||||||
buttonText: withdrawalReviewTemplate.buttonText,
|
buttonText: withdrawalReviewTemplate.buttonText,
|
||||||
|
|||||||
245
src/resolvers.js
245
src/resolvers.js
@@ -14,7 +14,7 @@ import {
|
|||||||
getManagedClientUserWhere,
|
getManagedClientUserWhere,
|
||||||
isManagerRole,
|
isManagerRole,
|
||||||
} from './access.js';
|
} from './access.js';
|
||||||
import { sendLoginCodeEmail } from './mailer.js';
|
import { sendLoginCodeEmail, sendNotificationEmail } from './mailer.js';
|
||||||
import {
|
import {
|
||||||
buildAutoBonusNotificationTemplate,
|
buildAutoBonusNotificationTemplate,
|
||||||
buildManualBonusNotificationTemplate,
|
buildManualBonusNotificationTemplate,
|
||||||
@@ -37,6 +37,19 @@ function roundMoney(value) {
|
|||||||
return Math.round((Number(value) + Number.EPSILON) * 100) / 100;
|
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) {
|
function requireUser(context) {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new Error('Authentication required.');
|
throw new Error('Authentication required.');
|
||||||
@@ -241,15 +254,31 @@ async function applyManualOrderStatus(context, order, manager, status) {
|
|||||||
amount: toFloat(referralBonus.transaction.amount),
|
amount: toFloat(referralBonus.transaction.amount),
|
||||||
orderCode: updated.code,
|
orderCode: updated.code,
|
||||||
});
|
});
|
||||||
await dispatchToUserConnections(
|
const rewardUser = await context.prisma.user.findUnique({
|
||||||
context.prisma,
|
where: { id: referralBonus.transaction.userId },
|
||||||
referralBonus.transaction.userId,
|
select: { email: true },
|
||||||
template.message,
|
});
|
||||||
{
|
|
||||||
buttonUrl: template.buttonUrl,
|
await Promise.allSettled([
|
||||||
buttonText: template.buttonText,
|
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({
|
return context.prisma.order.findUnique({
|
||||||
@@ -477,24 +506,37 @@ async function notifyOrderStakeholders(context, order, status, note) {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
role: 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(
|
await Promise.allSettled(
|
||||||
uniqueRecipients.map((userId) => {
|
uniqueRecipients.map(async (userId) => {
|
||||||
|
const user = userMap.get(userId);
|
||||||
const template = buildOrderStatusNotificationTemplate({
|
const template = buildOrderStatusNotificationTemplate({
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
orderCode: order.code,
|
orderCode: order.code,
|
||||||
status,
|
status,
|
||||||
note,
|
note,
|
||||||
role: userRoleMap.get(userId),
|
role: user?.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
return dispatchToUserConnections(context.prisma, userId, template.message, {
|
await Promise.allSettled([
|
||||||
buttonUrl: template.buttonUrl,
|
dispatchToUserConnections(context.prisma, userId, template.message, {
|
||||||
buttonText: template.buttonText,
|
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();
|
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) => {
|
managerNotificationHistory: async (_, { userId, channel, limit }, context) => {
|
||||||
const manager = requireManagerAccess(context);
|
const manager = requireManagerAccess(context);
|
||||||
await assertManagerCanAccessUser(context.prisma, manager, userId);
|
await assertManagerCanAccessUser(context.prisma, manager, userId);
|
||||||
@@ -1924,17 +2066,32 @@ export const resolvers = {
|
|||||||
|
|
||||||
const template = buildManualBonusNotificationTemplate({
|
const template = buildManualBonusNotificationTemplate({
|
||||||
amount: toFloat(transaction.amount),
|
amount: toFloat(transaction.amount),
|
||||||
reason: transaction.reason,
|
|
||||||
});
|
});
|
||||||
await dispatchToUserConnections(
|
const transactionUser = await context.prisma.user.findUnique({
|
||||||
context.prisma,
|
where: { id: transaction.userId },
|
||||||
transaction.userId,
|
select: { email: true },
|
||||||
template.message,
|
});
|
||||||
{
|
|
||||||
buttonUrl: template.buttonUrl,
|
await Promise.allSettled([
|
||||||
buttonText: template.buttonText,
|
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;
|
return transaction;
|
||||||
},
|
},
|
||||||
@@ -1978,15 +2135,31 @@ export const resolvers = {
|
|||||||
status: withdrawal.status,
|
status: withdrawal.status,
|
||||||
reviewComment: withdrawal.reviewComment,
|
reviewComment: withdrawal.reviewComment,
|
||||||
});
|
});
|
||||||
await dispatchToUserConnections(
|
const requester = await context.prisma.user.findUnique({
|
||||||
context.prisma,
|
where: { id: withdrawal.requesterId },
|
||||||
withdrawal.requesterId,
|
select: { email: true },
|
||||||
template.message,
|
});
|
||||||
{
|
|
||||||
buttonUrl: template.buttonUrl,
|
await Promise.allSettled([
|
||||||
buttonText: template.buttonText,
|
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;
|
return withdrawal;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -193,6 +193,26 @@ type NotificationTemplate {
|
|||||||
channels: [NotificationTemplateChannel!]!
|
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 {
|
type Warehouse {
|
||||||
id: ID!
|
id: ID!
|
||||||
code: String!
|
code: String!
|
||||||
@@ -380,6 +400,7 @@ type Query {
|
|||||||
myMessengerConnections: [MessengerConnection!]!
|
myMessengerConnections: [MessengerConnection!]!
|
||||||
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
||||||
notificationTemplates: [NotificationTemplate!]!
|
notificationTemplates: [NotificationTemplate!]!
|
||||||
|
integrationSyncDashboard: IntegrationSyncDashboard!
|
||||||
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]!
|
||||||
clientProducts: [Product!]!
|
clientProducts: [Product!]!
|
||||||
order(id: ID!): Order
|
order(id: ID!): Order
|
||||||
|
|||||||
Reference in New Issue
Block a user