Enhance admin maintenance functionality with new endpoints and response types
- 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:
@@ -1,6 +1,7 @@
|
|||||||
using PnvPanel.Api.Common;
|
using PnvPanel.Api.Common;
|
||||||
using PnvPanel.Application.Admin.Maintenance;
|
using PnvPanel.Application.Admin.Maintenance;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Infrastructure.Identity;
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace PnvPanel.Api.Endpoints;
|
namespace PnvPanel.Api.Endpoints;
|
||||||
@@ -15,7 +16,13 @@ public static class AdminMaintenanceEndpoints
|
|||||||
|
|
||||||
admin
|
admin
|
||||||
.MapDelete("/tickets/closed", DeleteClosedTickets)
|
.MapDelete("/tickets/closed", DeleteClosedTickets)
|
||||||
.Produces<DeleteClosedTicketsResponseDto>();
|
.Produces<MaintenanceCleanupResponseDto>();
|
||||||
|
admin
|
||||||
|
.MapDelete("/audit-logs", DeleteOldAuditLogs)
|
||||||
|
.Produces<MaintenanceCleanupResponseDto>();
|
||||||
|
admin
|
||||||
|
.MapDelete("/apps/disabled", DeleteDisabledApps)
|
||||||
|
.Produces<MaintenanceCleanupResponseDto>();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -26,11 +33,35 @@ public static class AdminMaintenanceEndpoints
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new DeleteClosedTicketsCommand(), cancellationToken);
|
var result = await sender.Send(new DeleteClosedTicketsCommand(), cancellationToken);
|
||||||
if (!result.IsSuccess)
|
return ToResponse(result);
|
||||||
return result.ToHttpResult();
|
|
||||||
|
|
||||||
return Results.Ok(new DeleteClosedTicketsResponseDto(result.Value));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record DeleteClosedTicketsResponseDto(int DeletedCount);
|
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 MaintenanceCleanupResponseDto(int DeletedCount);
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ public sealed class DeleteClosedTicketsCommandHandler(
|
|||||||
"ClosedTicketsCleanedUp",
|
"ClosedTicketsCleanedUp",
|
||||||
"SupportTicket",
|
"SupportTicket",
|
||||||
"bulk",
|
"bulk",
|
||||||
metadata: $"count={ticketIds.Count}",
|
metadata: $"{{\"count\":{ticketIds.Count}}}",
|
||||||
AuditSource.Web
|
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>>;
|
||||||
+45
@@ -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>>;
|
||||||
+49
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -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
@@ -178,13 +178,19 @@ Support.CannotRequestAdminRole`), либо все три поля новой р
|
|||||||
Группа `/api/admin/maintenance`, `RequireAuthorization(RoleNames.Admin)`. Вкладка «Обслуживание» —
|
Группа `/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` (1–3650) | `{ deletedCount }` — удаляет записи `AuditLog` старше `olderThanDays` дней |
|
||||||
|
| DELETE | `/api/admin/maintenance/apps/disabled` | — | `{ deletedCount }` — удаляет все `ClientApp` с `IsEnabled = false` |
|
||||||
|
|
||||||
Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. `SupportTicket`), поэтому хендлер
|
Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. `SupportTicket`), поэтому хендлер
|
||||||
удаляет вручную в порядке вложения → комментарии → тикеты.
|
удаляет вручную в порядке вложения → комментарии → тикеты.
|
||||||
|
|
||||||
|
Очистка аудита пишет собственную запись `AuditLogsCleanedUp` уже **после** выборки старых записей —
|
||||||
|
её `CreatedAt` позже порога, поэтому она не удаляет сама себя. Полного удаления всего журнала нет
|
||||||
|
осознанно — `AuditLog` в проекте append-only, доступна только очистка по возрасту.
|
||||||
|
|
||||||
## Admin — Activation, Roles
|
## Admin — Activation, Roles
|
||||||
|
|
||||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ AppUser
|
|||||||
|
|
||||||
Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`.
|
Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`.
|
||||||
Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста.
|
Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста.
|
||||||
|
Массовая очистка отключённых (`IsEnabled = false`) — вкладка «Обслуживание»,
|
||||||
|
`DELETE /api/admin/maintenance/apps/disabled`.
|
||||||
|
|
||||||
### NewsPost — новости для пользователей
|
### NewsPost — новости для пользователей
|
||||||
Публикуются админом немедленно, видны всем залогиненным пользователям в хронологической ленте.
|
Публикуются админом немедленно, видны всем залогиненным пользователям в хронологической ленте.
|
||||||
@@ -184,7 +186,10 @@ AppUser
|
|||||||
| `Source` | `AuditSource` | `Web` / `Telegram` / `System` |
|
| `Source` | `AuditSource` | `Web` / `Telegram` / `System` |
|
||||||
| `CreatedAt` | `DateTimeOffset` | |
|
| `CreatedAt` | `DateTimeOffset` | |
|
||||||
|
|
||||||
Пишется из хендлеров (или обработчиков доменных событий), append-only.
|
Пишется из хендлеров (или обработчиков доменных событий), append-only — из приложения ничего не
|
||||||
|
удаляет и не редактирует записи. Единственное исключение — retention-очистка по возрасту (вкладка
|
||||||
|
«Обслуживание», `DELETE /api/admin/maintenance/audit-logs?olderThanDays=N`), доступная только
|
||||||
|
админу; полного удаления журнала осознанно нет.
|
||||||
|
|
||||||
### AppUser — расширения (Identity)
|
### AppUser — расширения (Identity)
|
||||||
`AppUser` живёт в Identity (`Infrastructure`). **Логин — по `UserName`** (уникальный, обязательный).
|
`AppUser` живёт в Identity (`Infrastructure`). **Логин — по `UserName`** (уникальный, обязательный).
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { apiRequest } from '@/shared/api/client'
|
import { apiRequest } from '@/shared/api/client'
|
||||||
import type { DeleteClosedTicketsResponseDto } from '@/shared/api/types'
|
import type { MaintenanceCleanupResponseDto } from '@/shared/api/types'
|
||||||
|
|
||||||
export function deleteClosedTickets() {
|
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' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
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 })
|
export const Route = createFileRoute('/admin/maintenance')({ component: AdminMaintenancePage })
|
||||||
|
|
||||||
|
const DEFAULT_AUDIT_RETENTION_DAYS = 90
|
||||||
|
|
||||||
function AdminMaintenancePage() {
|
function AdminMaintenancePage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const [auditRetentionDays, setAuditRetentionDays] = useState(DEFAULT_AUDIT_RETENTION_DAYS)
|
||||||
|
|
||||||
const deleteClosedTicketsMutation = useMutation({
|
const deleteClosedTicketsMutation = useMutation({
|
||||||
mutationFn: deleteClosedTickets,
|
mutationFn: deleteClosedTickets,
|
||||||
@@ -21,6 +26,24 @@ function AdminMaintenancePage() {
|
|||||||
onError: () => toast.error(t('auth.genericError')),
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -42,6 +65,59 @@ function AdminMaintenancePage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export type TicketSummaryDto = {
|
|||||||
messagePreview: string | null
|
messagePreview: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeleteClosedTicketsResponseDto = {
|
export type MaintenanceCleanupResponseDto = {
|
||||||
deletedCount: number
|
deletedCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -354,6 +354,22 @@ const resources = {
|
|||||||
confirm: 'Удалить все закрытые обращения и файлы в них? Действие необратимо.',
|
confirm: 'Удалить все закрытые обращения и файлы в них? Действие необратимо.',
|
||||||
deleted: 'Удалено обращений: {{count}}.',
|
deleted: 'Удалено обращений: {{count}}.',
|
||||||
},
|
},
|
||||||
|
audit: {
|
||||||
|
title: 'Журнал аудита',
|
||||||
|
description: 'Удалить записи журнала аудита старше указанного числа дней. Действие необратимо.',
|
||||||
|
olderThan: 'Старше',
|
||||||
|
days: 'дней',
|
||||||
|
action: 'Удалить старые записи',
|
||||||
|
confirm: 'Удалить записи аудита старше {{days}} дней? Действие необратимо.',
|
||||||
|
deleted: 'Удалено записей аудита: {{count}}.',
|
||||||
|
},
|
||||||
|
apps: {
|
||||||
|
title: 'Отключённые приложения',
|
||||||
|
description: 'Удалить все отключённые приложения из каталога. Действие необратимо.',
|
||||||
|
action: 'Удалить отключённые приложения',
|
||||||
|
confirm: 'Удалить все отключённые приложения? Действие необратимо.',
|
||||||
|
deleted: 'Удалено приложений: {{count}}.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
totalUsers: 'Всего пользователей',
|
totalUsers: 'Всего пользователей',
|
||||||
@@ -720,6 +736,22 @@ const resources = {
|
|||||||
confirm: 'Delete all closed tickets and their files? This cannot be undone.',
|
confirm: 'Delete all closed tickets and their files? This cannot be undone.',
|
||||||
deleted: 'Deleted tickets: {{count}}.',
|
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: {
|
stats: {
|
||||||
totalUsers: 'Total users',
|
totalUsers: 'Total users',
|
||||||
|
|||||||
Reference in New Issue
Block a user