diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs new file mode 100644 index 0000000..908dec9 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs @@ -0,0 +1,36 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Maintenance; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class AdminMaintenanceEndpoints +{ + public static IEndpointRouteBuilder MapAdminMaintenanceEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/maintenance") + .WithTags("Admin.Maintenance") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin + .MapDelete("/tickets/closed", DeleteClosedTickets) + .Produces(); + + return app; + } + + private static async Task DeleteClosedTickets( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteClosedTicketsCommand(), cancellationToken); + if (!result.IsSuccess) + return result.ToHttpResult(); + + return Results.Ok(new DeleteClosedTicketsResponseDto(result.Value)); + } +} + +public sealed record DeleteClosedTicketsResponseDto(int DeletedCount); diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs index 7b33030..5148b02 100644 --- a/backend/src/PnvPanel.Api/Program.cs +++ b/backend/src/PnvPanel.Api/Program.cs @@ -191,6 +191,7 @@ app.MapAdminAppEndpoints(); app.MapAdminNewsEndpoints(); app.MapSupportEndpoints(); app.MapAdminSupportEndpoints(); +app.MapAdminMaintenanceEndpoints(); app.MapTelegramEndpoints(); app.MapHub("/hubs/panel"); diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteClosedTicketsCommand.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteClosedTicketsCommand.cs new file mode 100644 index 0000000..dca2f8e --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteClosedTicketsCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Maintenance; + +/// Удаляет все тикеты в статусе Closed вместе с их перепиской и вложениями (в т.ч. файлами +/// на диске). Value — число удалённых тикетов. +public sealed record DeleteClosedTicketsCommand : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteClosedTicketsCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteClosedTicketsCommandHandler.cs new file mode 100644 index 0000000..dc8e25d --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/DeleteClosedTicketsCommandHandler.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Audit; +using PnvPanel.Domain.Support; + +namespace PnvPanel.Application.Admin.Maintenance; + +public sealed class DeleteClosedTicketsCommandHandler( + IAppDbContext dbContext, + IFileStorage fileStorage, + ICurrentUser currentUser +) : ICommandHandler> +{ + public async Task> Handle( + DeleteClosedTicketsCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(AuthErrors.Unauthorized); + + var ticketIds = await dbContext + .SupportTickets.AsNoTracking() + .Where(t => t.Status == TicketStatus.Closed) + .Select(t => t.Id) + .ToListAsync(cancellationToken); + + if (ticketIds.Count == 0) + return Result.Success(0); + + // Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. SupportTicket), поэтому + // порядок удаления важен: вложения (+ файлы на диске) -> комментарии -> тикеты. + var comments = await dbContext + .TicketComments.Where(c => ticketIds.Contains(c.TicketId)) + .ToListAsync(cancellationToken); + var commentIds = comments.Select(c => c.Id).ToList(); + + var attachments = await dbContext + .TicketAttachments.Where(a => commentIds.Contains(a.CommentId)) + .ToListAsync(cancellationToken); + + foreach (var attachment in attachments) + await fileStorage.DeleteAsync(attachment.StoredFileName, cancellationToken); + + dbContext.TicketAttachments.RemoveRange(attachments); + dbContext.TicketComments.RemoveRange(comments); + + var tickets = await dbContext + .SupportTickets.Where(t => ticketIds.Contains(t.Id)) + .ToListAsync(cancellationToken); + dbContext.SupportTickets.RemoveRange(tickets); + + dbContext.AuditLogs.Add( + AuditLog.Create( + adminId, + "ClosedTicketsCleanedUp", + "SupportTicket", + "bulk", + metadata: $"count={ticketIds.Count}", + AuditSource.Web + ) + ); + + return Result.Success(ticketIds.Count); + } +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IFileStorage.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IFileStorage.cs index 5c84304..e821e4a 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IFileStorage.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IFileStorage.cs @@ -12,4 +12,7 @@ public interface IFileStorage /// Null, если файла с таким именем нет на диске (например, удалён вручную). Task OpenReadAsync(string storedFileName, CancellationToken cancellationToken); + + /// Идемпотентно — отсутствие файла не ошибка (мог быть удалён вручную). + Task DeleteAsync(string storedFileName, CancellationToken cancellationToken); } diff --git a/backend/src/PnvPanel.Infrastructure/Storage/DiskFileStorage.cs b/backend/src/PnvPanel.Infrastructure/Storage/DiskFileStorage.cs index d7789b9..25338bd 100644 --- a/backend/src/PnvPanel.Infrastructure/Storage/DiskFileStorage.cs +++ b/backend/src/PnvPanel.Infrastructure/Storage/DiskFileStorage.cs @@ -31,4 +31,13 @@ internal sealed class DiskFileStorage(IOptions options) : IF return Task.FromResult(File.OpenRead(path)); } + + public Task DeleteAsync(string storedFileName, CancellationToken cancellationToken) + { + var path = Path.Combine(options.Value.RootPath, storedFileName); + if (File.Exists(path)) + File.Delete(path); + + return Task.CompletedTask; + } } diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteClosedTicketsCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteClosedTicketsCommandHandlerTests.cs new file mode 100644 index 0000000..8ff5e55 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteClosedTicketsCommandHandlerTests.cs @@ -0,0 +1,85 @@ +using NSubstitute; +using PnvPanel.Application.Admin.Maintenance; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Support; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Maintenance; + +public class DeleteClosedTicketsCommandHandlerTests +{ + private readonly IFileStorage _fileStorage = Substitute.For(); + + [Fact] + public async Task Handle_DeletesClosedTicketsWithCommentsAndAttachments_KeepsOthers() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + + var closedTicket = SupportTicket.CreateBugReport(userId); + closedTicket.Close(); + var openTicket = SupportTicket.CreateBugReport(userId); + dbContext.SupportTickets.AddRange(closedTicket, openTicket); + + var closedComment = TicketComment.Create( + closedTicket.Id, + userId, + "закрытый тикет, есть скриншот" + ); + var openComment = TicketComment.Create(openTicket.Id, userId, "открытый тикет"); + dbContext.TicketComments.AddRange(closedComment, openComment); + + var attachment = TicketAttachment.Create( + closedComment.Id, + "screenshot.png", + "stored-name", + "image/png", + 1024 + ); + dbContext.TicketAttachments.Add(attachment); + + await dbContext.SaveChangesAsync(CancellationToken.None); + + var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"); + var handler = new DeleteClosedTicketsCommandHandler(dbContext, _fileStorage, currentUser); + + var result = await handler.Handle(new DeleteClosedTicketsCommand(), CancellationToken.None); + // Хендлер не коммитит сам (в проде это делает UnitOfWorkBehavior после диспетчера) — коммитим явно. + await dbContext.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value); + + Assert.False(dbContext.SupportTickets.Any(t => t.Id == closedTicket.Id)); + Assert.False(dbContext.TicketComments.Any(c => c.Id == closedComment.Id)); + Assert.False(dbContext.TicketAttachments.Any(a => a.Id == attachment.Id)); + + Assert.True(dbContext.SupportTickets.Any(t => t.Id == openTicket.Id)); + Assert.True(dbContext.TicketComments.Any(c => c.Id == openComment.Id)); + + await _fileStorage.Received(1).DeleteAsync("stored-name", Arg.Any()); + } + + [Fact] + public async Task Handle_WhenNoClosedTickets_ReturnsZeroAndDeletesNothing() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var openTicket = SupportTicket.CreateBugReport(userId); + dbContext.SupportTickets.Add(openTicket); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"); + var handler = new DeleteClosedTicketsCommandHandler(dbContext, _fileStorage, currentUser); + + var result = await handler.Handle(new DeleteClosedTicketsCommand(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value); + Assert.True(dbContext.SupportTickets.Any(t => t.Id == openTicket.Id)); + await _fileStorage + .DidNotReceive() + .DeleteAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/docs/api-design.md b/docs/api-design.md index a5177cc..03ecf9a 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -170,7 +170,20 @@ Support.CannotRequestAdminRole`), либо все три поля новой р новую — сперва создаётся `AppRole` (`IRoleService.CreateRoleAsync`), затем назначается. То же самое администратор может сделать **из Telegram, не заходя на сайт** — инлайн-кнопки на уведомлении о заявке (см. [telegram-bot.md](telegram-bot.md)); для баг-репортов в Telegram только кнопка-ссылка -на `/admin/support/{id}` — переписка и вложения только на сайте. +на `/admin/support?ticket={id}` — переписка и вложения только на сайте (отдельного роута на конкретный +тикет нет, `?ticket=` открывает диалог поверх списка). + +## Admin — Maintenance + +Группа `/api/admin/maintenance`, `RequireAuthorization(RoleNames.Admin)`. Вкладка «Обслуживание» — +разовые операции подчистки, задумана расширяемой (следующие кандидаты: очистка старых новостей и т.п.). + +| Метод | Путь | Тело ответа | +| ------ | -------------------------------------- | ------------- | +| DELETE | `/api/admin/maintenance/tickets/closed` | `{ deletedCount }` — удаляет все тикеты в статусе `Closed` вместе с комментариями и вложениями (файлы стираются с диска через `IFileStorage.DeleteAsync`) | + +Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. `SupportTicket`), поэтому хендлер +удаляет вручную в порядке вложения → комментарии → тикеты. ## Admin — Activation, Roles diff --git a/docs/domain-model.md b/docs/domain-model.md index f922f75..02b98cf 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -307,6 +307,9 @@ UI **настойчиво напоминает** привязать его (ед - Доступ — только активированному пользователю (`IRequiresActivation`, как и у конфигов/новостей); админские действия (resolve/close/approve/reject) идут по отдельным `/api/admin/support/*` с ролевой проверкой, без завязки на активацию. +- `Closed`-тикеты не удаляются автоматически — админ может подчистить их вручную (вкладка + «Обслуживание», `DELETE /api/admin/maintenance/tickets/closed`), это удаляет и `TicketComment`/ + `TicketAttachment` (+ файлы на диске), необратимо. ### TicketComment — сообщение в переписке Плоская сущность (не навигационная коллекция на `SupportTicket` — конвенция проекта, см. diff --git a/frontend/src/features/admin/maintenance/api.ts b/frontend/src/features/admin/maintenance/api.ts new file mode 100644 index 0000000..585f0fc --- /dev/null +++ b/frontend/src/features/admin/maintenance/api.ts @@ -0,0 +1,6 @@ +import { apiRequest } from '@/shared/api/client' +import type { DeleteClosedTicketsResponseDto } from '@/shared/api/types' + +export function deleteClosedTickets() { + return apiRequest('/admin/maintenance/tickets/closed', { method: 'DELETE' }) +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 3ddf010..157fe65 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as AdminSupportRouteImport } from './routes/admin/support' import { Route as AdminRolesRouteImport } from './routes/admin/roles' import { Route as AdminNodesRouteImport } from './routes/admin/nodes' import { Route as AdminNewsRouteImport } from './routes/admin/news' +import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance' import { Route as AdminConfigsRouteImport } from './routes/admin/configs' import { Route as AdminAuditRouteImport } from './routes/admin/audit' import { Route as AdminAppsRouteImport } from './routes/admin/apps' @@ -104,6 +105,11 @@ const AdminNewsRoute = AdminNewsRouteImport.update({ path: '/news', getParentRoute: () => AdminRoute, } as any) +const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({ + id: '/maintenance', + path: '/maintenance', + getParentRoute: () => AdminRoute, +} as any) const AdminConfigsRoute = AdminConfigsRouteImport.update({ id: '/configs', path: '/configs', @@ -139,6 +145,7 @@ export interface FileRoutesByFullPath { '/admin/apps': typeof AdminAppsRoute '/admin/audit': typeof AdminAuditRoute '/admin/configs': typeof AdminConfigsRoute + '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/news': typeof AdminNewsRoute '/admin/nodes': typeof AdminNodesRoute '/admin/roles': typeof AdminRolesRoute @@ -159,6 +166,7 @@ export interface FileRoutesByTo { '/admin/apps': typeof AdminAppsRoute '/admin/audit': typeof AdminAuditRoute '/admin/configs': typeof AdminConfigsRoute + '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/news': typeof AdminNewsRoute '/admin/nodes': typeof AdminNodesRoute '/admin/roles': typeof AdminRolesRoute @@ -181,6 +189,7 @@ export interface FileRoutesById { '/admin/apps': typeof AdminAppsRoute '/admin/audit': typeof AdminAuditRoute '/admin/configs': typeof AdminConfigsRoute + '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/news': typeof AdminNewsRoute '/admin/nodes': typeof AdminNodesRoute '/admin/roles': typeof AdminRolesRoute @@ -204,6 +213,7 @@ export interface FileRouteTypes { | '/admin/apps' | '/admin/audit' | '/admin/configs' + | '/admin/maintenance' | '/admin/news' | '/admin/nodes' | '/admin/roles' @@ -224,6 +234,7 @@ export interface FileRouteTypes { | '/admin/apps' | '/admin/audit' | '/admin/configs' + | '/admin/maintenance' | '/admin/news' | '/admin/nodes' | '/admin/roles' @@ -245,6 +256,7 @@ export interface FileRouteTypes { | '/admin/apps' | '/admin/audit' | '/admin/configs' + | '/admin/maintenance' | '/admin/news' | '/admin/nodes' | '/admin/roles' @@ -372,6 +384,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminNewsRouteImport parentRoute: typeof AdminRoute } + '/admin/maintenance': { + id: '/admin/maintenance' + path: '/maintenance' + fullPath: '/admin/maintenance' + preLoaderRoute: typeof AdminMaintenanceRouteImport + parentRoute: typeof AdminRoute + } '/admin/configs': { id: '/admin/configs' path: '/configs' @@ -408,6 +427,7 @@ interface AdminRouteChildren { AdminAppsRoute: typeof AdminAppsRoute AdminAuditRoute: typeof AdminAuditRoute AdminConfigsRoute: typeof AdminConfigsRoute + AdminMaintenanceRoute: typeof AdminMaintenanceRoute AdminNewsRoute: typeof AdminNewsRoute AdminNodesRoute: typeof AdminNodesRoute AdminRolesRoute: typeof AdminRolesRoute @@ -421,6 +441,7 @@ const AdminRouteChildren: AdminRouteChildren = { AdminAppsRoute: AdminAppsRoute, AdminAuditRoute: AdminAuditRoute, AdminConfigsRoute: AdminConfigsRoute, + AdminMaintenanceRoute: AdminMaintenanceRoute, AdminNewsRoute: AdminNewsRoute, AdminNodesRoute: AdminNodesRoute, AdminRolesRoute: AdminRolesRoute, diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index 6056fcc..42e8733 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -16,6 +16,7 @@ const TABS = [ { to: '/admin/news', key: 'news' }, { to: '/admin/support', key: 'support' }, { to: '/admin/audit', key: 'audit' }, + { to: '/admin/maintenance', key: 'maintenance' }, ] as const function AdminLayout() { diff --git a/frontend/src/routes/admin/maintenance.tsx b/frontend/src/routes/admin/maintenance.tsx new file mode 100644 index 0000000..022fe7b --- /dev/null +++ b/frontend/src/routes/admin/maintenance.tsx @@ -0,0 +1,47 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from '@/shared/ui/toast-store' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' +import { deleteClosedTickets } from '@/features/admin/maintenance/api' + +export const Route = createFileRoute('/admin/maintenance')({ component: AdminMaintenancePage }) + +function AdminMaintenancePage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + const deleteClosedTicketsMutation = useMutation({ + mutationFn: deleteClosedTickets, + onSuccess: async (data) => { + toast.success(t('admin.maintenance.closedTickets.deleted', { count: data.deletedCount })) + await queryClient.invalidateQueries({ queryKey: ['admin-tickets'] }) + }, + onError: () => toast.error(t('auth.genericError')), + }) + + return ( +
+ + + {t('admin.maintenance.closedTickets.title')} + + +

{t('admin.maintenance.closedTickets.description')}

+
+ +
+
+
+
+ ) +} diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 82e96ad..5d95d36 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -270,6 +270,10 @@ export type TicketSummaryDto = { messagePreview: string | null } +export type DeleteClosedTicketsResponseDto = { + deletedCount: number +} + export type TicketDetailDto = { id: string userId: string diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 288df3e..04569d5 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -196,8 +196,9 @@ const resources = { nodes: 'Ноды', apps: 'Приложения', news: 'Новости', - support: 'Поддержка', + support: 'Тикеты', audit: 'Аудит', + maintenance: 'Обслуживание', }, users: { searchPlaceholder: 'Поиск по имени пользователя', @@ -345,6 +346,15 @@ const resources = { source: 'Источник', empty: 'Журнал аудита пуст.', }, + maintenance: { + closedTickets: { + title: 'Закрытые обращения', + description: 'Удалить все закрытые обращения вместе с перепиской и вложениями (файлами). Действие необратимо.', + action: 'Удалить закрытые обращения', + confirm: 'Удалить все закрытые обращения и файлы в них? Действие необратимо.', + deleted: 'Удалено обращений: {{count}}.', + }, + }, stats: { totalUsers: 'Всего пользователей', activatedUsers: 'Активировано', @@ -552,8 +562,9 @@ const resources = { nodes: 'Nodes', apps: 'Apps', news: 'News', - support: 'Support', + support: 'Tickets', audit: 'Audit', + maintenance: 'Maintenance', }, users: { searchPlaceholder: 'Search by username', @@ -701,6 +712,15 @@ const resources = { source: 'Source', empty: 'The audit log is empty.', }, + maintenance: { + closedTickets: { + title: 'Closed tickets', + description: 'Delete all closed tickets along with their comments and attachments (files). This cannot be undone.', + action: 'Delete closed tickets', + confirm: 'Delete all closed tickets and their files? This cannot be undone.', + deleted: 'Deleted tickets: {{count}}.', + }, + }, stats: { totalUsers: 'Total users', activatedUsers: 'Activated',