diff --git a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs index 216139a..0b56d94 100644 --- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs @@ -182,7 +182,14 @@ public static class CardsEndpoints return EndpointResults.BadRequest(outcome.Error); } - await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, [AuditDetails.Set(AuditFields.CardId, cardId), AuditDetails.Set(AuditFields.Destination, body.To)], ct); + await AuditAppender.AppendTenantAsync( + context, + AuditEvents.CardMoved, + [ + AuditDetails.Set(AuditFields.CardId, cardId), + AuditDetails.Change(AuditFields.ContainerId, outcome.From, outcome.To), + ], + ct); CardsService cardsService = context.RequestServices.GetRequiredService(); CardDto unified = await cardsService.GetCardAsync(cardId, ct); diff --git a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs index 5bc70ec..4dc32ba 100644 --- a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs @@ -140,6 +140,7 @@ public static class ContainersEndpoints } ContainersService containers = context.RequestServices.GetRequiredService(); + ContainerDto before = await containers.GetAsync(containerId, ct); ContainerDto updated = await containers.PatchAsync( containerId, new ContainerPatchDto( @@ -153,7 +154,13 @@ public static class ContainersEndpoints patchBody.Policy), ct); - await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set(AuditFields.ContainerId, updated.Id)], ct); + await AuditAppender.AppendTenantAsync( + context, + AuditEvents.ContainerUpdated, + string.Equals(before.Name, updated.Name, StringComparison.Ordinal) + ? [AuditDetails.Set(AuditFields.ContainerId, updated.Id)] + : [AuditDetails.Change(AuditFields.Name, before.Name, updated.Name)], + ct); return Results.Ok(new { id = updated.Id }); } diff --git a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs index 603d299..3d09c6d 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs @@ -135,6 +135,7 @@ public static class OperatorAnalyticsEndpoints AnalyticsService analyticsService, ITenantRepository tenantRepository, IAuthStore authStore, + IAuditReferenceResolver referenceResolver, CancellationToken ct) { if (context.GetCurrentOperator() is null) @@ -144,7 +145,7 @@ public static class OperatorAnalyticsEndpoints AnalyticsActivityDto activity = await analyticsService.ActivityAsync( eventType, actorType, actorId, tenantId, from, to, limit, offset, ct); - IReadOnlyList view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, authStore, ct); + IReadOnlyList view = await AuditViewFactory.ProjectAsync(activity.Items, tenantRepository, authStore, referenceResolver, ct); return Results.Ok(new { items = view, total = activity.Total, limit = activity.Limit, offset = activity.Offset }); } diff --git a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs index 2112281..df99e34 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs @@ -42,6 +42,7 @@ public static class OperatorAuditEndpoints AuditService auditService, ITenantRepository tenantRepository, IAuthStore authStore, + IAuditReferenceResolver referenceResolver, CancellationToken ct) { var operatorIdentity = context.GetCurrentOperator(); @@ -54,7 +55,7 @@ public static class OperatorAuditEndpoints eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset)); IReadOnlyList items = await auditService.QueryAsync(filter, ct); int total = await auditService.CountAsync(filter, ct); - IReadOnlyList view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, ct); + IReadOnlyList view = await AuditViewFactory.ProjectAsync(items, tenantRepository, authStore, referenceResolver, ct); return Results.Ok(new { items = view, total }); } diff --git a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs index 7944ea9..3270eea 100644 --- a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs @@ -5,6 +5,7 @@ using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Services; using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Utilities; namespace Deal.Api.Endpoints; @@ -18,6 +19,15 @@ public static class SettingsEndpoints private const string SettingsOpenApiTag = "settings"; private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; + // Максимальная длина значения в деталях аудита (промпты/списки бывают длинными). + private const int MaxAuditValueLength = 120; + + // Пометка для составных настроек, чьи значения в аудит не пишутся (секреты, снимки состояния). + private const string ChangedMarker = "изменено"; + + // Опции сериализации снимка настроек в деталях аудита (camelCase, как на wire). + private static readonly JsonSerializerOptions AuditJsonOptions = new(JsonSerializerDefaults.Web); + /// /// Регистрирует GET/PATCH /api/settings. /// @@ -72,9 +82,10 @@ public static class SettingsEndpoints } SettingsService settingsService = context.RequestServices.GetRequiredService(); + PublicSettingsDto before = await settingsService.GetPublicAsync(ct); PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct); - await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, [AuditDetails.Set(AuditFields.Fields, string.Join(", ", body.Keys))], ct); + await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, BuildSettingsChanges(before, result, body.Keys), ct); if (ShouldScheduleRatesRefresh(body)) { @@ -84,6 +95,60 @@ public static class SettingsEndpoints return Results.Ok(result); } + // Строит изменения настроек «поле: было → стало» по применённому PATCH. + // before: Снимок настроек до применения. + // after: Снимок настроек после применения. + // keys: Ключи PATCH-тела (изменённые). + // Возвращает: Изменения по каждому ключу (составные — пометкой «изменено»). + private static IReadOnlyList BuildSettingsChanges( + PublicSettingsDto before, + PublicSettingsDto after, + IEnumerable keys) + { + using JsonDocument beforeDoc = JsonDocument.Parse(JsonSerializer.Serialize(before, AuditJsonOptions)); + using JsonDocument afterDoc = JsonDocument.Parse(JsonSerializer.Serialize(after, AuditJsonOptions)); + var changes = new List(); + var keyList = new List(); + foreach (string key in keys) + { + keyList.Add(key); + SettingKind? kind = SettingsKeys.FindPublicKind(key); + if (kind is SettingKind.Dict or SettingKind.MyPrompts or SettingKind.AiConfigs) + { + changes.Add(AuditDetails.Set(key, ChangedMarker)); + continue; + } + + changes.Add(AuditDetails.Change( + key, + ReadSettingText(beforeDoc.RootElement, key), + ReadSettingText(afterDoc.RootElement, key))); + } + + return changes.Count > 0 + ? changes + : [AuditDetails.Set(AuditFields.Fields, string.Join(", ", keyList))]; + } + + // Читает значение ключа снимка настроек как текст для деталей аудита. + private static string? ReadSettingText(JsonElement root, string key) => + root.TryGetProperty(key, out JsonElement value) ? ToText(value) : null; + + // Приводит значение JSON к строке отображения (длинные значения обрезаются). + private static string? ToText(JsonElement value) => value.ValueKind switch + { + JsonValueKind.Null or JsonValueKind.Undefined => null, + JsonValueKind.String => Truncate(value.GetString()), + JsonValueKind.True => BoolText.True, + JsonValueKind.False => BoolText.False, + JsonValueKind.Array => Truncate(string.Join(", ", value.EnumerateArray().Select(ToText))), + _ => Truncate(value.GetRawText()), + }; + + // Обрезает длинное значение деталей аудита. + private static string? Truncate(string? value) => + value is not null && value.Length > MaxAuditValueLength ? value[..MaxAuditValueLength] + "…" : value; + private static bool ShouldScheduleRatesRefresh(Dictionary body) { if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element)) diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs index a1eb42e..f683551 100644 --- a/src/core/Deal.Api/Program.cs +++ b/src/core/Deal.Api/Program.cs @@ -165,6 +165,8 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/src/core/Deal.Api/Services/AuditReferenceResolver.cs b/src/core/Deal.Api/Services/AuditReferenceResolver.cs new file mode 100644 index 0000000..ede6cd0 --- /dev/null +++ b/src/core/Deal.Api/Services/AuditReferenceResolver.cs @@ -0,0 +1,133 @@ +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; + +namespace Deal.Api.Services; + +/// +/// Резолвер ссылок аудита на данные тенанта +/// +/// Фабрика scope для чтения схемы тенанта. +/// Логгер сбоев разрешения ссылок. +public sealed class AuditReferenceResolver( + IServiceScopeFactory scopeFactory, + ILogger logger) : IAuditReferenceResolver +{ + async Task> IAuditReferenceResolver.ResolveAsync( + IReadOnlyList records, + CancellationToken ct) + { + var names = new Dictionary(StringComparer.Ordinal); + foreach (IGrouping group in GroupByTenant(records)) + { + await ResolveTenantAsync(group.Key, group, names, ct); + } + + return names; + } + + private static IEnumerable> GroupByTenant(IReadOnlyList records) => + records + .Where(record => record.TenantId is not null) + .GroupBy(record => record.TenantId!.Value); + + private async Task ResolveTenantAsync( + Guid tenantId, + IEnumerable records, + Dictionary names, + CancellationToken ct) + { + var cardIds = new HashSet(StringComparer.Ordinal); + var boardIds = new HashSet(StringComparer.Ordinal); + foreach (AuditRecordDto record in records) + { + foreach (AuditChangeDto change in record.AuditChanges()) + { + Collect(change.From, cardIds, boardIds); + Collect(change.To, cardIds, boardIds); + } + } + + if (cardIds.Count == 0 && boardIds.Count == 0) + { + return; + } + + await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = scope.ServiceProvider.GetRequiredService(); + try + { + tenantContext.SetTenant(new TenantId(tenantId.ToString("N"))); + ICardStore store = scope.ServiceProvider.GetRequiredService(); + await ResolveCardsAsync(store, cardIds, names, ct); + await ResolveContainersAsync(store, boardIds, names, ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Резолвер аудита: ссылки тенанта {TenantId} не разрешены", tenantId); + } + finally + { + tenantContext.Reset(); + } + } + + private static void Collect( + string? value, + HashSet cardIds, + HashSet boardIds) + { + if (string.IsNullOrEmpty(value)) + { + return; + } + + if (value.StartsWith(CardIds.CardPrefix, StringComparison.Ordinal)) + { + cardIds.Add(value); + } + else if (value.StartsWith(KanbanIdPrefixes.Board, StringComparison.Ordinal)) + { + boardIds.Add(value); + } + } + + private static async Task ResolveCardsAsync( + ICardStore store, + HashSet cardIds, + Dictionary names, + CancellationToken ct) + { + foreach (string cardId in cardIds) + { + CardDto? card = await store.GetCardAsync(cardId, ct); + if (card is not null && !string.IsNullOrWhiteSpace(card.Title)) + { + names[cardId] = card.Title; + } + } + } + + private static async Task ResolveContainersAsync( + ICardStore store, + HashSet boardIds, + Dictionary names, + CancellationToken ct) + { + foreach (string boardId in boardIds) + { + ContainerDto? container = await store.GetContainerAsync(boardId, ct); + if (container is not null && !string.IsNullOrWhiteSpace(container.Name)) + { + names[boardId] = container.Name; + } + } + } +} diff --git a/src/core/Deal.Api/Services/AuditViewFactory.cs b/src/core/Deal.Api/Services/AuditViewFactory.cs index ca547e8..ea53b20 100644 --- a/src/core/Deal.Api/Services/AuditViewFactory.cs +++ b/src/core/Deal.Api/Services/AuditViewFactory.cs @@ -14,17 +14,20 @@ public static class AuditViewFactory /// Записи аудита. /// Реестр пространств для разрешения имён. /// Хранилище пользователей для разрешения логинов владельцев. + /// Резолвер ссылок на карточки и колонки. /// Токен отмены. /// Записи для чтения оператором. public static async Task> ProjectAsync( IReadOnlyList records, ITenantRepository tenantRepository, IAuthStore authStore, + IAuditReferenceResolver referenceResolver, CancellationToken ct) { Guid[] tenantIds = DistinctTenantIds(records); IReadOnlyDictionary names = await ResolveNamesAsync(tenantIds, tenantRepository, ct); IReadOnlyDictionary owners = await ResolveOwnerLoginsAsync(tenantIds, authStore, ct); + IReadOnlyDictionary references = await referenceResolver.ResolveAsync(records, ct); var view = new List(records.Count); foreach (AuditRecordDto record in records) @@ -40,7 +43,7 @@ public static class AuditViewFactory ResolveUserName(record, owners), tenantName, record.Ip, - record.AuditChanges(), + ResolveChanges(record, references), record.DetailJson, record.At, record.Id)); @@ -49,6 +52,47 @@ public static class AuditViewFactory return view; } + // Заменяет идентификаторы ссылок изменения значениями из карты имён и убирает пустые изменения. + // record: Запись аудита. + // references: Имена по идентификаторам ссылок. + // Возвращает: Изменения с читаемыми значениями ссылок без пар «без изменения». + private static IReadOnlyList ResolveChanges( + AuditRecordDto record, + IReadOnlyDictionary references) + { + IReadOnlyList changes = record.AuditChanges(); + var resolved = new List(changes.Count); + foreach (AuditChangeDto change in changes) + { + string? from = ResolveReference(references, change.From); + string? to = ResolveReference(references, change.To); + if (IsUnchanged(from, to)) + { + continue; + } + + resolved.Add(change with { From = from, To = to }); + } + + return resolved; + } + + // Сравнивает значения «было» и «стало». + // from: Значение до изменения. + // to: Значение после изменения. + // Возвращает: True — значения заданы и совпадают. + private static bool IsUnchanged(string? from, string? to) => + from is not null && to is not null && string.Equals(from, to, StringComparison.Ordinal); + + // Подставляет имя вместо идентификатора ссылки, если оно известно. + // references: Имена по идентификаторам ссылок. + // value: Значение изменения. + // Возвращает: Имя ссылки либо исходное значение. + private static string? ResolveReference( + IReadOnlyDictionary references, + string? value) => + value is not null && references.TryGetValue(value, out string? name) ? name : value; + // Логин реального пользователя: владелец пространства, иначе логин/email из деталей события. // record: Запись аудита. // owners: Логины владельцев по идентификаторам пространств. diff --git a/src/core/Deal.Api/Services/IAuditReferenceResolver.cs b/src/core/Deal.Api/Services/IAuditReferenceResolver.cs new file mode 100644 index 0000000..6d288a1 --- /dev/null +++ b/src/core/Deal.Api/Services/IAuditReferenceResolver.cs @@ -0,0 +1,19 @@ +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Api.Services; + +/// +/// Разрешает идентификаторы ссылок аудита в читаемые имена +/// +public interface IAuditReferenceResolver +{ + /// + /// Разрешает ссылки на карточки и колонки в их заголовки и имена + /// + /// Записи аудита выборки. + /// Токен отмены. + /// Имена по идентификаторам ссылок; неразрешённые идентификаторы отсутствуют. + public Task> ResolveAsync( + IReadOnlyList records, + CancellationToken ct); +} diff --git a/src/core/Deal.Infrastructure/Services/CardMover.cs b/src/core/Deal.Infrastructure/Services/CardMover.cs index 1f59b4c..e7d5459 100644 --- a/src/core/Deal.Infrastructure/Services/CardMover.cs +++ b/src/core/Deal.Infrastructure/Services/CardMover.cs @@ -22,6 +22,6 @@ public sealed class CardMover(CardsService cardsService) : ICardMover CardResultDto result = CardsDefaultContainers.Contains(toContainerId) ? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct) : await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct); - return new CardMoveResultDto(result.Error); + return new CardMoveResultDto(result.Error, From: result.Card?.PrevCol, To: result.Card?.Col); } } diff --git a/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs b/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs index 06bfa6a..65f7e01 100644 --- a/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs +++ b/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs @@ -6,4 +6,6 @@ namespace Deal.Modules.Cards.Application.Dtos; /// Результат перехода карточки единым механизмом . /// /// Текст 400-ошибки либо null (успех). -public sealed record CardMoveResultDto(string? Error); +/// Контейнер-источник после перехода; null — переход не выполнен. +/// Контейнер-назначение после перехода; null — переход не выполнен. +public sealed record CardMoveResultDto(string? Error, string? From = null, string? To = null); diff --git a/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs index 445bb56..c806285 100644 --- a/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs +++ b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs @@ -22,6 +22,14 @@ public static class AuditRecordDtoExtensions private const string OldPrefix = "old"; private const string NewPrefix = "new"; + // Соответствие суффикса «old» имени ключа нового значения в устаревшем формате (имена не совпадают). + private static readonly IReadOnlyDictionary LegacyNewNameByOldSuffix = + new Dictionary(StringComparer.Ordinal) + { + ["Budget"] = "budgetTokens", + ["Period"] = "period", + }; + // События аудита «неудачный вход» (тенант/оператор). private static readonly string[] FailedLoginEvents = { @@ -140,10 +148,23 @@ public static class AuditRecordDtoExtensions continue; } - string? pairedNewName = property.Name.StartsWith(OldPrefix, StringComparison.Ordinal) - && property.Name.Length > OldPrefix.Length - ? NewPrefix + property.Name[OldPrefix.Length..] - : null; + string? pairedNewName = null; + if (property.Name.StartsWith(OldPrefix, StringComparison.Ordinal) + && property.Name.Length > OldPrefix.Length) + { + string suffix = property.Name[OldPrefix.Length..]; + string defaultNewName = NewPrefix + suffix; + if (LegacyNewNameByOldSuffix.TryGetValue(suffix, out string? mapped) + && root.TryGetProperty(mapped, out JsonElement mappedValue)) + { + consumed.Add(mapped); + result.Add(new AuditChangeDto(LowerFirst(suffix), Stringify(property.Value), Stringify(mappedValue))); + continue; + } + + pairedNewName = root.TryGetProperty(defaultNewName, out _) ? defaultNewName : null; + } + if (pairedNewName is not null && root.TryGetProperty(pairedNewName, out JsonElement newValue)) { consumed.Add(pairedNewName); @@ -162,11 +183,12 @@ public static class AuditRecordDtoExtensions // Приводит значение JSON к строке отображения. // element: Значение JSON. - // Возвращает: Строковое представление либо null для JSON null. + // Возвращает: Строковое представление либо null для JSON null; массивы — списком через запятую. private static string? Stringify(JsonElement element) => element.ValueKind switch { JsonValueKind.Null or JsonValueKind.Undefined => null, JsonValueKind.String => element.GetString(), + JsonValueKind.Array => string.Join(", ", element.EnumerateArray().Select(Stringify)), _ => element.GetRawText(), }; diff --git a/src/core/tests/Deal.Tests.Unit/Api/AuditReferenceResolverTests.cs b/src/core/tests/Deal.Tests.Unit/Api/AuditReferenceResolverTests.cs new file mode 100644 index 0000000..41928ea --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Api/AuditReferenceResolverTests.cs @@ -0,0 +1,86 @@ +using Deal.Api.Services; +using Deal.Infrastructure.Data; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.Tests.Unit.Support; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Api; + +/// +/// Юнит-тесты резолвера ссылок аудита +/// +public sealed class AuditReferenceResolverTests +{ + private static readonly Guid TenantGuid = Guid.Parse("00000000-0000-0000-0000-000000000001"); + + [Fact] + public async Task Resolve_ResolvesCardTitleAndContainerName() + { + TestKanjStore kanban = new(); + kanban.SeedCard(new CardDto { Id = "c_1", Title = "WPF заказ", Col = "b_1" }); + kanban.SeedBoard(new ContainerDto { Id = "b_1", Name = "WPF" }); + using ServiceProvider provider = BuildProvider(kanban); + var resolver = new AuditReferenceResolver( + provider.GetRequiredService(), + NullLogger.Instance); + + var record = Record("""{"changes":[{"field":"cardId","to":"c_1"},{"field":"to","to":"b_1"}]}"""); + IReadOnlyDictionary names = + await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None); + + Assert.Equal("WPF заказ", names["c_1"]); + Assert.Equal("WPF", names["b_1"]); + } + + [Fact] + public async Task Resolve_UnknownIds_ReturnsOnlyKnownNames() + { + TestKanjStore kanban = new(); + using ServiceProvider provider = BuildProvider(kanban); + var resolver = new AuditReferenceResolver( + provider.GetRequiredService(), + NullLogger.Instance); + + var record = Record("""{"changes":[{"field":"cardId","to":"c_missing"}]}"""); + IReadOnlyDictionary names = + await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None); + + Assert.Empty(names); + } + + [Fact] + public async Task Resolve_WithoutTenant_ReturnsEmpty() + { + TestKanjStore kanban = new(); + using ServiceProvider provider = BuildProvider(kanban); + var resolver = new AuditReferenceResolver( + provider.GetRequiredService(), + NullLogger.Instance); + + var record = Record("""{"changes":[{"field":"cardId","to":"c_1"}]}""", tenantId: null); + IReadOnlyDictionary names = + await ((IAuditReferenceResolver)resolver).ResolveAsync([record], CancellationToken.None); + + Assert.Empty(names); + } + + private static ServiceProvider BuildProvider(TestKanjStore kanban) + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(kanban.Store); + return services.BuildServiceProvider(); + } + + private static AuditRecordDto Record(string detailJson, Guid? tenantId = null) => + new( + AuditEvents.CardMoved, + AuditActorTypes.Tenant, + ActorId: null, + TenantId: tenantId ?? TenantGuid, + Ip: null, + DetailJson: detailJson); +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/AuditViewFactoryTests.cs b/src/core/tests/Deal.Tests.Unit/Api/AuditViewFactoryTests.cs new file mode 100644 index 0000000..27f8b35 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Api/AuditViewFactoryTests.cs @@ -0,0 +1,59 @@ +using Deal.Api.Services; +using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Support; + +namespace Deal.Tests.Unit.Api; + +/// +/// Юнит-тесты проекции записей аудита +/// +public sealed class AuditViewFactoryTests +{ + [Fact] + public async Task Project_DropsUnchangedValues() + { + var record = new AuditRecordDto( + AuditEvents.TenantLimitChanged, + AuditActorTypes.Operator, + ActorId: null, + TenantId: null, + Ip: null, + DetailJson: """{"tenantId":"00000000-0000-0000-0000-000000000001","oldBudget":10000000,"oldPeriod":"month","budgetTokens":100000,"period":"month"}"""); + + IReadOnlyList view = await AuditViewFactory.ProjectAsync( + [record], + new TestTenantRepository().Repository, + new TestAuthStore().Store, + new TestAuditReferenceResolver(), + CancellationToken.None); + + AuditRecordViewDto row = Assert.Single(view); + AuditChangeDto budget = Assert.Single(row.Changes); + Assert.Equal("budget", budget.Field); + Assert.Equal("10000000", budget.From); + Assert.Equal("100000", budget.To); + } + + [Fact] + public async Task Project_KeepsChangedValues() + { + var record = new AuditRecordDto( + AuditEvents.SettingsUpdated, + AuditActorTypes.Tenant, + ActorId: null, + TenantId: null, + Ip: null, + DetailJson: """{"changes":[{"field":"minLen","from":"24","to":"42"}]}"""); + + IReadOnlyList view = await AuditViewFactory.ProjectAsync( + [record], + new TestTenantRepository().Repository, + new TestAuthStore().Store, + new TestAuditReferenceResolver(), + CancellationToken.None); + + AuditChangeDto change = Assert.Single(Assert.Single(view).Changes); + Assert.Equal("24", change.From); + Assert.Equal("42", change.To); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditRecordDtoExtensionsTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditRecordDtoExtensionsTests.cs index eba20e8..659e5c5 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditRecordDtoExtensionsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditRecordDtoExtensionsTests.cs @@ -57,6 +57,52 @@ public sealed class AuditRecordDtoExtensionsTests }); } + [Fact] + public void AuditChanges_LegacyLimitChange_PairsBudgetAndPeriod() + { + var record = Record( + """{"tenantId":"6f3c0d1e-2b4a-4c8d-9e0f-1a2b3c4d5e6f","oldBudget":100000,"oldPeriod":"month","budgetTokens":1000000,"period":"month"}"""); + + IReadOnlyList changes = record.AuditChanges(); + + Assert.Collection( + changes, + budget => + { + Assert.Equal("budget", budget.Field); + Assert.Equal("100000", budget.From); + Assert.Equal("1000000", budget.To); + }, + period => + { + Assert.Equal("period", period.Field); + Assert.Equal("month", period.From); + Assert.Equal("month", period.To); + }); + } + + [Fact] + public void AuditChanges_LegacyFieldsArray_RendersAsList() + { + var record = Record("""{"fields":["autoMonitorNew"]}"""); + + AuditChangeDto change = Assert.Single(record.AuditChanges()); + + Assert.Equal(AuditFields.Fields, change.Field); + Assert.Null(change.From); + Assert.Equal("autoMonitorNew", change.To); + } + + [Fact] + public void AuditChanges_LegacyFieldsArray_JoinsMultipleKeys() + { + var record = Record("""{"fields":["autoMonitorNew","minLen"]}"""); + + AuditChangeDto change = Assert.Single(record.AuditChanges()); + + Assert.Equal("autoMonitorNew, minLen", change.To); + } + [Fact] public void AuditChanges_InvalidJson_ReturnsEmpty() { diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs index 79c569f..d603551 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs @@ -212,6 +212,7 @@ internal static class OperatorAuthHttpHost builder.Services.AddSingleton(new TestRateLimitCounterStore().Store); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddSingleton(new TestAuditReferenceResolver()); WebApplication app = builder.Build(); app.UseMiddleware(); diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestAuditReferenceResolver.cs b/src/core/tests/Deal.Tests.Unit/Support/TestAuditReferenceResolver.cs new file mode 100644 index 0000000..4b37fb9 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestAuditReferenceResolver.cs @@ -0,0 +1,15 @@ +using Deal.Api.Services; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка резолвера ссылок аудита: без чтения данных тенанта +/// +public sealed class TestAuditReferenceResolver : IAuditReferenceResolver +{ + public Task> ResolveAsync( + IReadOnlyList records, + CancellationToken ct) => + Task.FromResult>(new Dictionary()); +} diff --git a/src/frontend/src/components/operator/AuditTable.vue b/src/frontend/src/components/operator/AuditTable.vue index 3d7632f..7901663 100644 --- a/src/frontend/src/components/operator/AuditTable.vue +++ b/src/frontend/src/components/operator/AuditTable.vue @@ -23,6 +23,17 @@ const COLUMNS = [ const ACTOR_TONES = { operator: 'brand', tenant: 'neutral', system: 'warn' } +// Код поля «изменённые настройки» устаревшего формата деталей. +const FIELD_SETTINGS = 'fields' + +// Поля-ссылки на контейнер и служебные контейнеры с человекочитаемыми именами. +const CONTAINER_FIELDS = new Set(['to', 'col', 'containerId']) +const CONTAINER_VALUE_KEYS = { + inbox: 'operator.columnValue.inbox', + archive: 'operator.columnValue.archive', + trash: 'operator.columnValue.trash', +} + // Строковые представления булевых значений в деталях события (как в JSON). const TRUE_VALUE = 'true' const FALSE_VALUE = 'false' @@ -53,13 +64,37 @@ function valueLabel(field, raw) { if (raw === null || raw === undefined || raw === '') return '—' if (raw === TRUE_VALUE) return t('operator.da') if (raw === FALSE_VALUE) return t('operator.net') + if (CONTAINER_FIELDS.has(field) && CONTAINER_VALUE_KEYS[raw]) return t(CONTAINER_VALUE_KEYS[raw]) return translate(`operator.value.${field}.${raw}`, raw) } +// Список ключей настроек из устаревшего формата → русские названия через запятую. +function settingList(raw) { + return String(raw) + .split(',') + .map((key) => key.trim()) + .filter(Boolean) + .map((key) => translate(`operator.setting.${key}`, key)) + .join(', ') +} + function changesOf(row) { return Array.isArray(row.changes) ? row.changes : [] } +// Строка изменения: «Поле: было → стало» либо «Поле: значение». +function changeLine(change) { + const field = fieldLabel(change.field) + if (change.field === FIELD_SETTINGS) { + return `${field}: ${settingList(change.to)}` + } + + const to = valueLabel(change.field, change.to) + return hasFrom(change) + ? `${field}: ${valueLabel(change.field, change.from)} → ${to}` + : `${field}: ${to}` +} + // Вторая строка колонки пользователя: имя пространства, иначе id. function tenantSubline(row) { return row.userName && row.tenantName ? row.tenantName : row.tenantId || '—' @@ -99,14 +134,7 @@ function pretty(json) { class="mt-1.5 max-w-[460px] rounded-md bg-ink/70 border border-white/8 p-2" >
-
- {{ fieldLabel(change.field) }}: - - {{ valueLabel(change.field, change.to) }} -
+
{{ changeLine(change) }}
{{ $t('operator.dopolnitelnyh-parametrov-net') }}