using System.Text.Json;
using Deal.Api;
using Deal.Api.Dtos;
using Deal.Api.Services;
using Deal.Api.Extensions;
using Deal.Api.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Api.Endpoints;
///
/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6).
///
///
/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3);
/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения
/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию:
/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH
/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400).
/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое
/// обновление кэша курсов (, Ruling 6); пересчёт карточек при смене
/// targetCurrency/conversionOn выполняет сам SettingsService через порт
/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12).
///
/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не
/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость
/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса
/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401,
/// а не 500 при резолве.
///
///
public static class SettingsEndpoints
{
private const string ApiGroupPrefix = "/api";
private const string SettingsPath = "/settings";
private const string SettingsOpenApiTag = "settings";
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
///
/// Регистрирует 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);
}
// Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт).
Dictionary? body;
try
{
body = await JsonSerializer.DeserializeAsync>(
context.Request.Body,
options: null,
cancellationToken: ct);
}
catch (JsonException)
{
// Не-JSON или не-объект целиком — ошибка запроса: 400 + detail
// (в прототипе FastAPI на такое тело — 422).
return EndpointResults.BadRequest(InvalidBodyDetail);
}
if (body is null)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
SettingsService settingsService = context.RequestServices.GetRequiredService();
PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct);
// Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся.
await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct);
// Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша
// курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource.
if (ShouldScheduleRatesRefresh(body))
{
context.RequestServices.GetRequiredService().Schedule();
}
return Results.Ok(result);
}
// Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188).
// body: Тело PATCH — публичные ключи §4.6.
// Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка).
private static bool ShouldScheduleRatesRefresh(Dictionary body)
{
if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element))
{
return false;
}
// JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет.
return element.ValueKind switch
{
JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()),
JsonValueKind.Null => false,
_ => true,
};
}
}