diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs index f8caa6e..f322e10 100644 --- a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs +++ b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs @@ -228,6 +228,7 @@ internal sealed class TelegramNotifier( public async Task NotifyUserAsync( Guid userId, string message, + string? linkPath, CancellationToken cancellationToken ) { @@ -240,15 +241,14 @@ internal sealed class TelegramNotifier( InlineKeyboardMarkup? keyboard = null; if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) + { + var url = string.IsNullOrWhiteSpace(linkPath) + ? options.Value.PublicSiteUrl + : $"{options.Value.PublicSiteUrl.TrimEnd('/')}{linkPath}"; keyboard = new InlineKeyboardMarkup( - new[] - { - InlineKeyboardButton.WithUrl( - "🌐 Открыть на сайте", - options.Value.PublicSiteUrl - ), - } + new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) } ); + } try { diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs index fd60041..a3cfdc4 100644 --- a/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs @@ -61,6 +61,7 @@ public sealed class ApproveActivationCommandHandler( await telegramNotifier.NotifyUserAsync( request.UserId, "✅ Ваш аккаунт активирован администратором.", + null, cancellationToken ); return Result.Success(); diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs index c5873e8..ef5d00f 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs @@ -82,6 +82,7 @@ public sealed class ApproveRoleRequestCommandHandler( await telegramNotifier.NotifyUserAsync( ticket.UserId, "✅ Ваша заявка на роль одобрена.", + $"/support?ticket={ticket.Id}", cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommandHandler.cs index 2b34af0..c94f860 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommandHandler.cs @@ -51,6 +51,7 @@ public sealed class CloseTicketCommandHandler( await telegramNotifier.NotifyUserAsync( ticket.UserId, "🔒 Ваше обращение закрыто.", + $"/support?ticket={ticket.Id}", cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs index 173a09b..d5461c8 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs @@ -57,6 +57,7 @@ public sealed class RejectRoleRequestCommandHandler( await telegramNotifier.NotifyUserAsync( ticket.UserId, "❌ Ваша заявка на роль отклонена.", + $"/support?ticket={ticket.Id}", cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommandHandler.cs index be899c0..ca9f453 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommandHandler.cs @@ -51,6 +51,7 @@ public sealed class ResolveTicketCommandHandler( await telegramNotifier.NotifyUserAsync( ticket.UserId, "✅ Ваше обращение решено.", + $"/support?ticket={ticket.Id}", cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs index 893cc25..20cc85e 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs @@ -91,6 +91,7 @@ public sealed class BlockUserCommandHandler( await telegramNotifier.NotifyUserAsync( command.UserId, "⛔ Ваш аккаунт заблокирован администратором.", + null, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommandHandler.cs index df137ca8..0d00529 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommandHandler.cs @@ -66,6 +66,7 @@ public sealed class DeleteUserCommandHandler( await telegramNotifier.NotifyUserAsync( command.UserId, "🗑 Ваш аккаунт удалён администратором.", + null, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs index 113ab3d..d77a0c6 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs @@ -71,6 +71,7 @@ public sealed class ForceRevokeConfigCommandHandler( await telegramNotifier.NotifyUserAsync( config.UserId, $"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».", + null, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs index 74ead5a..8b30e00 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs @@ -17,8 +17,9 @@ public interface ITelegramNotifier ); /// Личное сообщение пользователю, если у него привязан Telegram (иначе no-op). - /// Если задан PublicSiteUrl — добавляет кнопку-ссылку на сайт. - Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken); + /// Если задан PublicSiteUrl — добавляет кнопку-ссылку на сайт; linkPath — относительный путь + /// (например "/support?ticket={id}"), на который она ведёт, null — на корень сайта. + Task NotifyUserAsync(Guid userId, string message, string? linkPath, CancellationToken cancellationToken); /// Баг-репорт/предложение — только кнопка-ссылка на сайт (переписка и картинки — там), /// без инлайн-действий. diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs index 2b3e47b..58801c4 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs @@ -56,7 +56,7 @@ public class ApproveRoleRequestCommandHandlerTests .ChangeUserRoleAsync(userId, newRoleId, Arg.Any()); await _telegramNotifier .Received(1) - .NotifyUserAsync(userId, Arg.Any(), Arg.Any()); + .NotifyUserAsync(userId, Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/DeleteUserCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/DeleteUserCommandHandlerTests.cs index 3386382..33ccc10 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Users/DeleteUserCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/DeleteUserCommandHandlerTests.cs @@ -101,7 +101,7 @@ public class DeleteUserCommandHandlerTests ); await _telegramNotifier .Received(1) - .NotifyUserAsync(userId, Arg.Any(), Arg.Any()); + .NotifyUserAsync(userId, Arg.Any(), Arg.Any(), Arg.Any()); await _identityService.Received(1).DeleteUserAsync(userId, Arg.Any()); var audit = Assert.Single(dbContext.AuditLogs.Local); diff --git a/frontend/src/features/support/SupportTicketList.tsx b/frontend/src/features/support/SupportTicketList.tsx index ecbb12e..0866670 100644 --- a/frontend/src/features/support/SupportTicketList.tsx +++ b/frontend/src/features/support/SupportTicketList.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { getRouteApi } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { Button } from '@/shared/ui/button' @@ -10,11 +11,13 @@ import { TicketStatusBadge } from './TicketStatusBadge' import { listMyTickets } from './api' const PAGE_SIZE = 20 +const routeApi = getRouteApi('/support') export function SupportTicketList() { const { t } = useTranslation() + const navigate = routeApi.useNavigate() + const { ticket: selectedTicketId } = routeApi.useSearch() const [page, setPage] = useState(1) - const [selectedTicketId, setSelectedTicketId] = useState(null) const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['my-tickets', page], @@ -44,7 +47,7 @@ export function SupportTicketList() { {data && data.items.length > 0 && (
{data.items.map((ticket) => ( - setSelectedTicketId(ticket.id)}> + navigate({ search: { ticket: ticket.id } })}> {t(`support.type.${ticket.type}`)} @@ -71,7 +74,7 @@ export function SupportTicketList() { )} {selectedTicketId && ( - !open && setSelectedTicketId(null)} /> + !open && navigate({ search: {} })} /> )}
) diff --git a/frontend/src/routes/support.tsx b/frontend/src/routes/support.tsx index 5d1db52..465eb57 100644 --- a/frontend/src/routes/support.tsx +++ b/frontend/src/routes/support.tsx @@ -3,7 +3,14 @@ import { useTranslation } from 'react-i18next' import { useRequireActivated } from '@/features/auth/guards' import { SupportTicketList } from '@/features/support/SupportTicketList' -export const Route = createFileRoute('/support')({ component: SupportPage }) +export const Route = createFileRoute('/support')({ + component: SupportPage, + // Ссылка из Telegram-уведомления об изменении статуса тикета ведёт на этот же роут с + // ?ticket= — отдельного роута на конкретный тикет нет, он открывается диалогом поверх списка. + validateSearch: (search: Record): { ticket?: string } => ({ + ticket: typeof search.ticket === 'string' ? search.ticket : undefined, + }), +}) function SupportPage() { const { t } = useTranslation()