using System.Text.Json;
using Deal.Api.Extensions;
using Deal.Api.Services;
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;
///
/// HTTP-эндпоинты настроек тенанта
///
public static class SettingsEndpoints
{
private const string ApiGroupPrefix = "/api";
private const string SettingsPath = "/settings";
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.
///
/// Построитель маршрутов приложения.
/// Построитель маршрутов для цепочки вызовов.
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup(ApiGroupPrefix).WithTags(SettingsOpenApiTag);
group.MapGet(SettingsPath, GetSettingsAsync);
group.MapPatch(SettingsPath, PatchSettingsAsync);
return app;
}
// GET /api/settings: публичный снимок настроек текущего тенанта.
private static async Task GetSettingsAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
SettingsService settingsService = context.RequestServices.GetRequiredService();
return Results.Ok(await settingsService.GetPublicAsync(ct));
}
// PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения.
private static async Task PatchSettingsAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
Dictionary? body;
try
{
body = await JsonSerializer.DeserializeAsync>(
context.Request.Body,
options: null,
cancellationToken: ct);
}
catch (JsonException)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
if (body is null)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
SettingsService settingsService = context.RequestServices.GetRequiredService();
PublicSettingsDto before = await settingsService.GetPublicAsync(ct);
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, BuildSettingsChanges(before, result, body.Keys), ct);
if (ShouldScheduleRatesRefresh(body))
{
context.RequestServices.GetRequiredService().Schedule();
}
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)
{
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))
{
return false;
}
return element.ValueKind switch
{
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
JsonValueKind.Null => false,
_ => true,
};
}
}