Enhance admin maintenance functionality with new endpoints and response types
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- Added new DELETE endpoints for managing audit logs and disabled apps in the admin maintenance section.
- Updated existing endpoint for closed tickets to use a unified response type, `MaintenanceCleanupResponseDto`.
- Enhanced API documentation to reflect the new operations and their expected request/response formats.
- Improved frontend integration with new functions for deleting old audit logs and disabled apps, including user confirmation prompts.
- Added localization support for new maintenance actions in both Russian and English.
This commit is contained in:
Leonid Pershin
2026-07-14 18:37:22 +03:00
parent 8dfeb05912
commit 94ba514b8e
15 changed files with 449 additions and 15 deletions
@@ -1,6 +1,7 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Maintenance;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
@@ -15,7 +16,13 @@ public static class AdminMaintenanceEndpoints
admin
.MapDelete("/tickets/closed", DeleteClosedTickets)
.Produces<DeleteClosedTicketsResponseDto>();
.Produces<MaintenanceCleanupResponseDto>();
admin
.MapDelete("/audit-logs", DeleteOldAuditLogs)
.Produces<MaintenanceCleanupResponseDto>();
admin
.MapDelete("/apps/disabled", DeleteDisabledApps)
.Produces<MaintenanceCleanupResponseDto>();
return app;
}
@@ -26,11 +33,35 @@ public static class AdminMaintenanceEndpoints
)
{
var result = await sender.Send(new DeleteClosedTicketsCommand(), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
return Results.Ok(new DeleteClosedTicketsResponseDto(result.Value));
return ToResponse(result);
}
private static async Task<IResult> DeleteOldAuditLogs(
int olderThanDays,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new DeleteOldAuditLogsCommand(olderThanDays),
cancellationToken
);
return ToResponse(result);
}
private static async Task<IResult> DeleteDisabledApps(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteDisabledAppsCommand(), cancellationToken);
return ToResponse(result);
}
private static IResult ToResponse(Result<int> result) =>
result.IsSuccess
? Results.Ok(new MaintenanceCleanupResponseDto(result.Value))
: result.ToHttpResult();
}
public sealed record DeleteClosedTicketsResponseDto(int DeletedCount);
public sealed record MaintenanceCleanupResponseDto(int DeletedCount);
@@ -59,7 +59,7 @@ public sealed class DeleteClosedTicketsCommandHandler(
"ClosedTicketsCleanedUp",
"SupportTicket",
"bulk",
metadata: $"count={ticketIds.Count}",
metadata: $"{{\"count\":{ticketIds.Count}}}",
AuditSource.Web
)
);
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Maintenance;
/// <summary>Удаляет все ClientApp с IsEnabled = false. Value — число удалённых записей.</summary>
public sealed record DeleteDisabledAppsCommand : ICommand<Result<int>>;
@@ -0,0 +1,45 @@
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;
namespace PnvPanel.Application.Admin.Maintenance;
public sealed class DeleteDisabledAppsCommandHandler(
IAppDbContext dbContext,
ICurrentUser currentUser
) : ICommandHandler<DeleteDisabledAppsCommand, Result<int>>
{
public async Task<Result<int>> Handle(
DeleteDisabledAppsCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure<int>(AuthErrors.Unauthorized);
var apps = await dbContext
.ClientApps.Where(a => !a.IsEnabled)
.ToListAsync(cancellationToken);
if (apps.Count == 0)
return Result.Success(0);
dbContext.ClientApps.RemoveRange(apps);
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"DisabledAppsCleanedUp",
"ClientApp",
"bulk",
metadata: $"{{\"count\":{apps.Count}}}",
AuditSource.Web
)
);
return Result.Success(apps.Count);
}
}
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Maintenance;
/// <summary>Удаляет записи AuditLog старше OlderThanDays дней. Value — число удалённых записей.</summary>
public sealed record DeleteOldAuditLogsCommand(int OlderThanDays) : ICommand<Result<int>>;
@@ -0,0 +1,49 @@
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;
namespace PnvPanel.Application.Admin.Maintenance;
public sealed class DeleteOldAuditLogsCommandHandler(
IAppDbContext dbContext,
ICurrentUser currentUser
) : ICommandHandler<DeleteOldAuditLogsCommand, Result<int>>
{
public async Task<Result<int>> Handle(
DeleteOldAuditLogsCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure<int>(AuthErrors.Unauthorized);
var threshold = DateTimeOffset.UtcNow.AddDays(-command.OlderThanDays);
var logs = await dbContext
.AuditLogs.Where(l => l.CreatedAt < threshold)
.ToListAsync(cancellationToken);
if (logs.Count == 0)
return Result.Success(0);
dbContext.AuditLogs.RemoveRange(logs);
// Новая запись создаётся уже после выборки старых — её CreatedAt = UtcNow не попадает
// под тот же порог и не удаляется этой же операцией.
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"AuditLogsCleanedUp",
"AuditLog",
"bulk",
metadata: $"{{\"count\":{logs.Count},\"olderThanDays\":{command.OlderThanDays}}}",
AuditSource.Web
)
);
return Result.Success(logs.Count);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Maintenance;
public sealed class DeleteOldAuditLogsCommandValidator
: AbstractValidator<DeleteOldAuditLogsCommand>
{
public DeleteOldAuditLogsCommandValidator()
{
RuleFor(x => x.OlderThanDays).GreaterThanOrEqualTo(1).LessThanOrEqualTo(3650);
}
}
@@ -0,0 +1,82 @@
using PnvPanel.Application.Admin.Maintenance;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Apps;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Maintenance;
public class DeleteDisabledAppsCommandHandlerTests
{
[Fact]
public async Task Handle_DeletesDisabledApps_KeepsEnabled()
{
using var dbContext = InMemoryDbContextFactory.Create();
var disabledApp = ClientApp.Create(
"Old Client",
new Uri("https://example.com/old"),
OsPlatform.Android,
null,
null,
0
);
disabledApp.Update(
disabledApp.Name,
disabledApp.DownloadUrl,
disabledApp.OperatingSystem,
null,
null,
0,
isEnabled: false
);
var enabledApp = ClientApp.Create(
"Active Client",
new Uri("https://example.com/active"),
OsPlatform.IOS,
null,
null,
0
);
dbContext.ClientApps.AddRange(disabledApp, enabledApp);
await dbContext.SaveChangesAsync(CancellationToken.None);
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
var handler = new DeleteDisabledAppsCommandHandler(dbContext, currentUser);
var result = await handler.Handle(new DeleteDisabledAppsCommand(), CancellationToken.None);
// Хендлер не коммитит сам (в проде это делает UnitOfWorkBehavior после диспетчера) — коммитим явно.
await dbContext.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value);
Assert.False(dbContext.ClientApps.Any(a => a.Id == disabledApp.Id));
Assert.True(dbContext.ClientApps.Any(a => a.Id == enabledApp.Id));
}
[Fact]
public async Task Handle_WhenNoDisabledApps_ReturnsZero()
{
using var dbContext = InMemoryDbContextFactory.Create();
var enabledApp = ClientApp.Create(
"Active Client",
new Uri("https://example.com/active"),
OsPlatform.IOS,
null,
null,
0
);
dbContext.ClientApps.Add(enabledApp);
await dbContext.SaveChangesAsync(CancellationToken.None);
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
var handler = new DeleteDisabledAppsCommandHandler(dbContext, currentUser);
var result = await handler.Handle(new DeleteDisabledAppsCommand(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(0, result.Value);
Assert.True(dbContext.ClientApps.Any(a => a.Id == enabledApp.Id));
}
}
@@ -0,0 +1,72 @@
using PnvPanel.Application.Admin.Maintenance;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Audit;
using Xunit;
namespace PnvPanel.Application.Tests.Admin.Maintenance;
public class DeleteOldAuditLogsCommandHandlerTests
{
[Fact]
public async Task Handle_DeletesOldLogs_KeepsItsOwnCleanupEntry()
{
using var dbContext = InMemoryDbContextFactory.Create();
var oldLog = AuditLog.Create(
Guid.NewGuid(),
"OldAction",
"Test",
"1",
null,
AuditSource.Web
);
dbContext.AuditLogs.Add(oldLog);
await dbContext.SaveChangesAsync(CancellationToken.None);
// Гарантируем, что порог (UtcNow внутри хендлера) окажется позже CreatedAt старой записи.
await Task.Delay(5, CancellationToken.None);
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
var handler = new DeleteOldAuditLogsCommandHandler(dbContext, currentUser);
var result = await handler.Handle(
new DeleteOldAuditLogsCommand(OlderThanDays: 0),
CancellationToken.None
);
// Хендлер не коммитит сам (в проде это делает UnitOfWorkBehavior после диспетчера) — коммитим явно.
await dbContext.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value);
Assert.False(dbContext.AuditLogs.Any(l => l.Id == oldLog.Id));
// Собственная запись об очистке создаётся уже после выборки порога и не попадает под удаление.
Assert.True(dbContext.AuditLogs.Any(l => l.Action == "AuditLogsCleanedUp"));
}
[Fact]
public async Task Handle_WhenNothingOlderThanThreshold_ReturnsZero()
{
using var dbContext = InMemoryDbContextFactory.Create();
var recentLog = AuditLog.Create(
Guid.NewGuid(),
"RecentAction",
"Test",
"1",
null,
AuditSource.Web
);
dbContext.AuditLogs.Add(recentLog);
await dbContext.SaveChangesAsync(CancellationToken.None);
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
var handler = new DeleteOldAuditLogsCommandHandler(dbContext, currentUser);
var result = await handler.Handle(
new DeleteOldAuditLogsCommand(OlderThanDays: 36500),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(0, result.Value);
Assert.True(dbContext.AuditLogs.Any(l => l.Id == recentLog.Id));
}
}
+9 -3
View File
@@ -178,13 +178,19 @@ Support.CannotRequestAdminRole`), либо все три поля новой р
Группа `/api/admin/maintenance`, `RequireAuthorization(RoleNames.Admin)`. Вкладка «Обслуживание» —
разовые операции подчистки, задумана расширяемой (следующие кандидаты: очистка старых новостей и т.п.).
| Метод | Путь | Тело ответа |
| ------ | -------------------------------------- | ------------- |
| DELETE | `/api/admin/maintenance/tickets/closed` | `{ deletedCount }` — удаляет все тикеты в статусе `Closed` вместе с комментариями и вложениями (файлы стираются с диска через `IFileStorage.DeleteAsync`) |
| Метод | Путь | Тело запроса | Тело ответа |
| ------ | ----------------------------------------- | -------------- | ------------- |
| DELETE | `/api/admin/maintenance/tickets/closed` | — | `{ deletedCount }` — удаляет все тикеты в статусе `Closed` вместе с комментариями и вложениями (файлы стираются с диска через `IFileStorage.DeleteAsync`) |
| DELETE | `/api/admin/maintenance/audit-logs` | query: `olderThanDays` (13650) | `{ deletedCount }` — удаляет записи `AuditLog` старше `olderThanDays` дней |
| DELETE | `/api/admin/maintenance/apps/disabled` | — | `{ deletedCount }` — удаляет все `ClientApp` с `IsEnabled = false` |
Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. `SupportTicket`), поэтому хендлер
удаляет вручную в порядке вложения → комментарии → тикеты.
Очистка аудита пишет собственную запись `AuditLogsCleanedUp` уже **после** выборки старых записей —
её `CreatedAt` позже порога, поэтому она не удаляет сама себя. Полного удаления всего журнала нет
осознанно — `AuditLog` в проекте append-only, доступна только очистка по возрасту.
## Admin — Activation, Roles
| Метод | Путь | Роль | Тело запроса | Тело ответа |
+6 -1
View File
@@ -154,6 +154,8 @@ AppUser
Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`.
Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста.
Массовая очистка отключённых (`IsEnabled = false`) — вкладка «Обслуживание»,
`DELETE /api/admin/maintenance/apps/disabled`.
### NewsPost — новости для пользователей
Публикуются админом немедленно, видны всем залогиненным пользователям в хронологической ленте.
@@ -184,7 +186,10 @@ AppUser
| `Source` | `AuditSource` | `Web` / `Telegram` / `System` |
| `CreatedAt` | `DateTimeOffset` | |
Пишется из хендлеров (или обработчиков доменных событий), append-only.
Пишется из хендлеров (или обработчиков доменных событий), append-only — из приложения ничего не
удаляет и не редактирует записи. Единственное исключение — retention-очистка по возрасту (вкладка
«Обслуживание», `DELETE /api/admin/maintenance/audit-logs?olderThanDays=N`), доступная только
админу; полного удаления журнала осознанно нет.
### AppUser — расширения (Identity)
`AppUser` живёт в Identity (`Infrastructure`). **Логин — по `UserName`** (уникальный, обязательный).
+12 -2
View File
@@ -1,6 +1,16 @@
import { apiRequest } from '@/shared/api/client'
import type { DeleteClosedTicketsResponseDto } from '@/shared/api/types'
import type { MaintenanceCleanupResponseDto } from '@/shared/api/types'
export function deleteClosedTickets() {
return apiRequest<DeleteClosedTicketsResponseDto>('/admin/maintenance/tickets/closed', { method: 'DELETE' })
return apiRequest<MaintenanceCleanupResponseDto>('/admin/maintenance/tickets/closed', { method: 'DELETE' })
}
export function deleteOldAuditLogs(olderThanDays: number) {
return apiRequest<MaintenanceCleanupResponseDto>(`/admin/maintenance/audit-logs?olderThanDays=${olderThanDays}`, {
method: 'DELETE',
})
}
export function deleteDisabledApps() {
return apiRequest<MaintenanceCleanupResponseDto>('/admin/maintenance/apps/disabled', { method: 'DELETE' })
}
+77 -1
View File
@@ -1,16 +1,21 @@
import { useState } from 'react'
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'
import { Input } from '@/shared/ui/input'
import { deleteClosedTickets, deleteDisabledApps, deleteOldAuditLogs } from '@/features/admin/maintenance/api'
export const Route = createFileRoute('/admin/maintenance')({ component: AdminMaintenancePage })
const DEFAULT_AUDIT_RETENTION_DAYS = 90
function AdminMaintenancePage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [auditRetentionDays, setAuditRetentionDays] = useState(DEFAULT_AUDIT_RETENTION_DAYS)
const deleteClosedTicketsMutation = useMutation({
mutationFn: deleteClosedTickets,
@@ -21,6 +26,24 @@ function AdminMaintenancePage() {
onError: () => toast.error(t('auth.genericError')),
})
const deleteOldAuditLogsMutation = useMutation({
mutationFn: () => deleteOldAuditLogs(auditRetentionDays),
onSuccess: async (data) => {
toast.success(t('admin.maintenance.audit.deleted', { count: data.deletedCount }))
await queryClient.invalidateQueries({ queryKey: ['admin-audit'] })
},
onError: () => toast.error(t('auth.genericError')),
})
const deleteDisabledAppsMutation = useMutation({
mutationFn: deleteDisabledApps,
onSuccess: async (data) => {
toast.success(t('admin.maintenance.apps.deleted', { count: data.deletedCount }))
await queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<div className="flex flex-col gap-4">
<Card>
@@ -42,6 +65,59 @@ function AdminMaintenancePage() {
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('admin.maintenance.audit.title')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">{t('admin.maintenance.audit.description')}</p>
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
{t('admin.maintenance.audit.olderThan')}
<Input
type="number"
min={1}
max={3650}
value={auditRetentionDays}
onChange={(e) => setAuditRetentionDays(Number(e.target.value))}
className="w-20"
/>
{t('admin.maintenance.audit.days')}
</label>
<Button
variant="outline"
disabled={deleteOldAuditLogsMutation.isPending || auditRetentionDays < 1}
onClick={() => {
if (confirm(t('admin.maintenance.audit.confirm', { days: auditRetentionDays })))
deleteOldAuditLogsMutation.mutate()
}}
>
{t('admin.maintenance.audit.action')}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('admin.maintenance.apps.title')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">{t('admin.maintenance.apps.description')}</p>
<div>
<Button
variant="outline"
disabled={deleteDisabledAppsMutation.isPending}
onClick={() => {
if (confirm(t('admin.maintenance.apps.confirm'))) deleteDisabledAppsMutation.mutate()
}}
>
{t('admin.maintenance.apps.action')}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+1 -1
View File
@@ -270,7 +270,7 @@ export type TicketSummaryDto = {
messagePreview: string | null
}
export type DeleteClosedTicketsResponseDto = {
export type MaintenanceCleanupResponseDto = {
deletedCount: number
}
+32
View File
@@ -354,6 +354,22 @@ const resources = {
confirm: 'Удалить все закрытые обращения и файлы в них? Действие необратимо.',
deleted: 'Удалено обращений: {{count}}.',
},
audit: {
title: 'Журнал аудита',
description: 'Удалить записи журнала аудита старше указанного числа дней. Действие необратимо.',
olderThan: 'Старше',
days: 'дней',
action: 'Удалить старые записи',
confirm: 'Удалить записи аудита старше {{days}} дней? Действие необратимо.',
deleted: 'Удалено записей аудита: {{count}}.',
},
apps: {
title: 'Отключённые приложения',
description: 'Удалить все отключённые приложения из каталога. Действие необратимо.',
action: 'Удалить отключённые приложения',
confirm: 'Удалить все отключённые приложения? Действие необратимо.',
deleted: 'Удалено приложений: {{count}}.',
},
},
stats: {
totalUsers: 'Всего пользователей',
@@ -720,6 +736,22 @@ const resources = {
confirm: 'Delete all closed tickets and their files? This cannot be undone.',
deleted: 'Deleted tickets: {{count}}.',
},
audit: {
title: 'Audit log',
description: 'Delete audit log entries older than the given number of days. This cannot be undone.',
olderThan: 'Older than',
days: 'days',
action: 'Delete old entries',
confirm: 'Delete audit entries older than {{days}} days? This cannot be undone.',
deleted: 'Deleted audit entries: {{count}}.',
},
apps: {
title: 'Disabled apps',
description: 'Delete all disabled apps from the catalog. This cannot be undone.',
action: 'Delete disabled apps',
confirm: 'Delete all disabled apps? This cannot be undone.',
deleted: 'Deleted apps: {{count}}.',
},
},
stats: {
totalUsers: 'Total users',