Enhance Telegram notification system with optional link support
CI / Backend (build + test) (push) Successful in 1m16s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s

- Updated NotifyUserAsync method in TelegramNotifier to accept an optional linkPath parameter for dynamic URL generation.
- Modified various command handlers to utilize the new linkPath feature, providing users with relevant links in their notifications.
- Adjusted ITelegramNotifier interface documentation to reflect the changes in method signature and functionality.
- Enhanced unit tests to verify the correct invocation of the updated NotifyUserAsync method.
This commit is contained in:
Leonid Pershin
2026-07-14 07:36:18 +03:00
parent df137ca5a7
commit d26723dce0
14 changed files with 34 additions and 15 deletions
@@ -228,6 +228,7 @@ internal sealed class TelegramNotifier(
public async Task NotifyUserAsync( public async Task NotifyUserAsync(
Guid userId, Guid userId,
string message, string message,
string? linkPath,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
@@ -240,15 +241,14 @@ internal sealed class TelegramNotifier(
InlineKeyboardMarkup? keyboard = null; InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{
var url = string.IsNullOrWhiteSpace(linkPath)
? options.Value.PublicSiteUrl
: $"{options.Value.PublicSiteUrl.TrimEnd('/')}{linkPath}";
keyboard = new InlineKeyboardMarkup( keyboard = new InlineKeyboardMarkup(
new[] new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
{
InlineKeyboardButton.WithUrl(
"🌐 Открыть на сайте",
options.Value.PublicSiteUrl
),
}
); );
}
try try
{ {
@@ -61,6 +61,7 @@ public sealed class ApproveActivationCommandHandler(
await telegramNotifier.NotifyUserAsync( await telegramNotifier.NotifyUserAsync(
request.UserId, request.UserId,
"✅ Ваш аккаунт активирован администратором.", "✅ Ваш аккаунт активирован администратором.",
null,
cancellationToken cancellationToken
); );
return Result.Success(); return Result.Success();
@@ -82,6 +82,7 @@ public sealed class ApproveRoleRequestCommandHandler(
await telegramNotifier.NotifyUserAsync( await telegramNotifier.NotifyUserAsync(
ticket.UserId, ticket.UserId,
"✅ Ваша заявка на роль одобрена.", "✅ Ваша заявка на роль одобрена.",
$"/support?ticket={ticket.Id}",
cancellationToken cancellationToken
); );
@@ -51,6 +51,7 @@ public sealed class CloseTicketCommandHandler(
await telegramNotifier.NotifyUserAsync( await telegramNotifier.NotifyUserAsync(
ticket.UserId, ticket.UserId,
"🔒 Ваше обращение закрыто.", "🔒 Ваше обращение закрыто.",
$"/support?ticket={ticket.Id}",
cancellationToken cancellationToken
); );
@@ -57,6 +57,7 @@ public sealed class RejectRoleRequestCommandHandler(
await telegramNotifier.NotifyUserAsync( await telegramNotifier.NotifyUserAsync(
ticket.UserId, ticket.UserId,
"❌ Ваша заявка на роль отклонена.", "❌ Ваша заявка на роль отклонена.",
$"/support?ticket={ticket.Id}",
cancellationToken cancellationToken
); );
@@ -51,6 +51,7 @@ public sealed class ResolveTicketCommandHandler(
await telegramNotifier.NotifyUserAsync( await telegramNotifier.NotifyUserAsync(
ticket.UserId, ticket.UserId,
"✅ Ваше обращение решено.", "✅ Ваше обращение решено.",
$"/support?ticket={ticket.Id}",
cancellationToken cancellationToken
); );
@@ -91,6 +91,7 @@ public sealed class BlockUserCommandHandler(
await telegramNotifier.NotifyUserAsync( await telegramNotifier.NotifyUserAsync(
command.UserId, command.UserId,
"⛔ Ваш аккаунт заблокирован администратором.", "⛔ Ваш аккаунт заблокирован администратором.",
null,
cancellationToken cancellationToken
); );
@@ -66,6 +66,7 @@ public sealed class DeleteUserCommandHandler(
await telegramNotifier.NotifyUserAsync( await telegramNotifier.NotifyUserAsync(
command.UserId, command.UserId,
"🗑 Ваш аккаунт удалён администратором.", "🗑 Ваш аккаунт удалён администратором.",
null,
cancellationToken cancellationToken
); );
@@ -71,6 +71,7 @@ public sealed class ForceRevokeConfigCommandHandler(
await telegramNotifier.NotifyUserAsync( await telegramNotifier.NotifyUserAsync(
config.UserId, config.UserId,
$"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».", $"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».",
null,
cancellationToken cancellationToken
); );
@@ -17,8 +17,9 @@ public interface ITelegramNotifier
); );
/// <summary>Личное сообщение пользователю, если у него привязан Telegram (иначе no-op). /// <summary>Личное сообщение пользователю, если у него привязан Telegram (иначе no-op).
/// Если задан PublicSiteUrl — добавляет кнопку-ссылку на сайт.</summary> /// Если задан PublicSiteUrl — добавляет кнопку-ссылку на сайт; linkPath — относительный путь
Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken); /// (например "/support?ticket={id}"), на который она ведёт, null — на корень сайта.</summary>
Task NotifyUserAsync(Guid userId, string message, string? linkPath, CancellationToken cancellationToken);
/// <summary>Баг-репорт/предложение — только кнопка-ссылка на сайт (переписка и картинки — там), /// <summary>Баг-репорт/предложение — только кнопка-ссылка на сайт (переписка и картинки — там),
/// без инлайн-действий.</summary> /// без инлайн-действий.</summary>
@@ -56,7 +56,7 @@ public class ApproveRoleRequestCommandHandlerTests
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>()); .ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>());
await _telegramNotifier await _telegramNotifier
.Received(1) .Received(1)
.NotifyUserAsync(userId, Arg.Any<string>(), Arg.Any<CancellationToken>()); .NotifyUserAsync(userId, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
@@ -101,7 +101,7 @@ public class DeleteUserCommandHandlerTests
); );
await _telegramNotifier await _telegramNotifier
.Received(1) .Received(1)
.NotifyUserAsync(userId, Arg.Any<string>(), Arg.Any<CancellationToken>()); .NotifyUserAsync(userId, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
await _identityService.Received(1).DeleteUserAsync(userId, Arg.Any<CancellationToken>()); await _identityService.Received(1).DeleteUserAsync(userId, Arg.Any<CancellationToken>());
var audit = Assert.Single(dbContext.AuditLogs.Local); var audit = Assert.Single(dbContext.AuditLogs.Local);
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { getRouteApi } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
@@ -10,11 +11,13 @@ import { TicketStatusBadge } from './TicketStatusBadge'
import { listMyTickets } from './api' import { listMyTickets } from './api'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
const routeApi = getRouteApi('/support')
export function SupportTicketList() { export function SupportTicketList() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = routeApi.useNavigate()
const { ticket: selectedTicketId } = routeApi.useSearch()
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['my-tickets', page], queryKey: ['my-tickets', page],
@@ -44,7 +47,7 @@ export function SupportTicketList() {
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{data.items.map((ticket) => ( {data.items.map((ticket) => (
<Card key={ticket.id} className="cursor-pointer" onClick={() => setSelectedTicketId(ticket.id)}> <Card key={ticket.id} className="cursor-pointer" onClick={() => navigate({ search: { ticket: ticket.id } })}>
<CardHeader className="flex-row items-center justify-between gap-2"> <CardHeader className="flex-row items-center justify-between gap-2">
<CardTitle className="text-base">{t(`support.type.${ticket.type}`)}</CardTitle> <CardTitle className="text-base">{t(`support.type.${ticket.type}`)}</CardTitle>
<TicketStatusBadge status={ticket.status} /> <TicketStatusBadge status={ticket.status} />
@@ -71,7 +74,7 @@ export function SupportTicketList() {
)} )}
{selectedTicketId && ( {selectedTicketId && (
<TicketDetailDialog ticketId={selectedTicketId} onOpenChange={(open) => !open && setSelectedTicketId(null)} /> <TicketDetailDialog ticketId={selectedTicketId} onOpenChange={(open) => !open && navigate({ search: {} })} />
)} )}
</div> </div>
) )
+8 -1
View File
@@ -3,7 +3,14 @@ import { useTranslation } from 'react-i18next'
import { useRequireActivated } from '@/features/auth/guards' import { useRequireActivated } from '@/features/auth/guards'
import { SupportTicketList } from '@/features/support/SupportTicketList' import { SupportTicketList } from '@/features/support/SupportTicketList'
export const Route = createFileRoute('/support')({ component: SupportPage }) export const Route = createFileRoute('/support')({
component: SupportPage,
// Ссылка из Telegram-уведомления об изменении статуса тикета ведёт на этот же роут с
// ?ticket=<id> — отдельного роута на конкретный тикет нет, он открывается диалогом поверх списка.
validateSearch: (search: Record<string, unknown>): { ticket?: string } => ({
ticket: typeof search.ticket === 'string' ? search.ticket : undefined,
}),
})
function SupportPage() { function SupportPage() {
const { t } = useTranslation() const { t } = useTranslation()