Показывать в аудите изменение «было → стало»

Перемещение карточки пишет прежнюю и новую колонку (CardMoveResultDto.From/To), изменение колонки — переименование «было → стало», разбор старого плоского формата спаривает oldBudget/budgetTokens и oldPeriod/period. Добавлены тесты разбора.
This commit is contained in:
2026-09-13 21:08:15 +03:00
parent 28cf8f23ca
commit f31989a133
6 changed files with 69 additions and 8 deletions
@@ -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<CardsService>();
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
@@ -140,6 +140,7 @@ public static class ContainersEndpoints
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
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 });
}
@@ -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);
}
}
@@ -6,4 +6,6 @@ namespace Deal.Modules.Cards.Application.Dtos;
/// Результат перехода карточки единым механизмом <see cref="ICardMover"/>.
/// </summary>
/// <param name="Error">Текст 400-ошибки либо null (успех).</param>
public sealed record CardMoveResultDto(string? Error);
/// <param name="From">Контейнер-источник после перехода; null — переход не выполнен.</param>
/// <param name="To">Контейнер-назначение после перехода; null — переход не выполнен.</param>
public sealed record CardMoveResultDto(string? Error, string? From = null, string? To = null);
@@ -22,6 +22,14 @@ public static class AuditRecordDtoExtensions
private const string OldPrefix = "old";
private const string NewPrefix = "new";
// Соответствие суффикса «old<X>» имени ключа нового значения в устаревшем формате (имена не совпадают).
private static readonly IReadOnlyDictionary<string, string> LegacyNewNameByOldSuffix =
new Dictionary<string, string>(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);
@@ -57,6 +57,30 @@ 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<AuditChangeDto> 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_InvalidJson_ReturnsEmpty()
{