Files
Deal/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs
T
stepan 482b345438 Вынести коды параметров аудита и ключи JSON в константы
Магические строки кодов деталей аудита заменены каталогом AuditFields (68 мест), ключи элемента изменения и разбора JSON — константами, ключи хранилища ключей Telegram и геометрия маски — именованными константами.
2026-09-13 20:32:11 +03:00

302 lines
12 KiB
C#

using System.Text.Json;
using Deal.Api.Endpoints.RequestModels;
using Deal.Api.Extensions;
using Deal.Api.Services;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Api.Endpoints;
/// <summary>
/// Эндпоинты контейнеров
/// </summary>
public static class ContainersEndpoints
{
// Префикс группы контейнеров.
private const string ContainersGroupPrefix = "/api/containers";
// OpenAPI-тег группы.
private const string OpenApiTag = "containers";
// 400: отсутствующий/явный null name контейнера.
private const string ContainerNameRequiredDetail = "Укажите название колонки";
// 400 reorder: отсутствующий/явный null order.
private const string ContainerOrderRequiredDetail = "Не указан порядок колонок";
// 400 PATCH: тело не JSON-объект.
private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом";
// Опции разбора PATCH-тела: web-дефолты (camelCase + регистронезависимость).
private static readonly JsonSerializerOptions RequestJsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Регистрирует группы /api/containers
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
public static IEndpointRouteBuilder MapContainersEndpoints(this IEndpointRouteBuilder app)
{
var containers = app.MapGroup(ContainersGroupPrefix).WithTags(OpenApiTag);
containers.MapGet("", ListContainersAsync);
containers.MapPost("", CreateContainerAsync);
containers.MapPost("/reorder", ReorderContainersAsync);
containers.MapGet("/state", GetColumnsStateAsync);
containers.MapPatch("/{containerId}/state", PatchColumnStateAsync);
containers.MapPost("/{containerId}/accept", AcceptSuggestedAsync);
containers.MapPatch("/{containerId}", PatchContainerAsync);
containers.MapDelete("/{containerId}", DeleteContainerAsync);
return app;
}
// GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками.
private static async Task<IResult> ListContainersAsync(
string? space,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
return Results.Ok(new { items = await containers.ListAsync(space, ct) });
}
// POST /api/containers: создать контейнер; ответ {id}.
private static async Task<IResult> CreateContainerAsync(
ContainerCreateRequest body,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
if (body.Name is null)
{
return EndpointResults.BadRequest(ContainerNameRequiredDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto created = await containers.CreateAsync(
new ContainerCreateDto(
Name: body.Name,
Description: body.Description ?? string.Empty,
Color: body.Color,
Space: body.Space ?? ContainerSpaces.Dashboard,
Kind: body.Kind ?? ContainerKinds.Board,
Suggested: body.Suggested ?? false,
Rules: NormalizeWireRules(body.Rules),
Note: body.Note ?? string.Empty),
ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, [AuditDetails.Set(AuditFields.ContainerId, created.Id), AuditDetails.Set(AuditFields.Name, created.Name)], ct);
return Results.Ok(new { id = created.Id });
}
// PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден».
private static async Task<IResult> PatchContainerAsync(
string containerId,
JsonElement body,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
if (body.ValueKind != JsonValueKind.Object)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
foreach (JsonProperty property in body.EnumerateObject())
{
if (string.Equals(property.Name, "name", StringComparison.OrdinalIgnoreCase)
&& property.Value.ValueKind == JsonValueKind.Null)
{
return EndpointResults.BadRequest(ContainerNameRequiredDetail);
}
}
ContainerPatchRequest? patchBody;
try
{
patchBody = body.Deserialize<ContainerPatchRequest>(RequestJsonOptions);
}
catch (JsonException)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
if (patchBody is null)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto updated = await containers.PatchAsync(
containerId,
new ContainerPatchDto(
patchBody.Name,
patchBody.Description,
patchBody.Color,
patchBody.Collapsed,
patchBody.Suggested,
patchBody.Note,
NormalizeWireRules(patchBody.Rules),
patchBody.Policy),
ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set(AuditFields.ContainerId, updated.Id)], ct);
return Results.Ok(new { id = updated.Id });
}
// POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false).
private static async Task<IResult> AcceptSuggestedAsync(
string containerId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ContainerDto accepted = await containers.AcceptSuggestedAsync(containerId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, [AuditDetails.Set(AuditFields.ContainerId, accepted.Id)], ct);
return Results.Ok(accepted);
}
// DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми.
private static async Task<IResult> DeleteContainerAsync(
string containerId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
int moved = await containers.DeleteAsync(containerId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, [AuditDetails.Set(AuditFields.ContainerId, containerId)], ct);
return Results.Ok(new { ok = true, movedToInbox = moved });
}
// POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}.
private static async Task<IResult> ReorderContainersAsync(
OrderBody body,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
if (body.Order is null)
{
return EndpointResults.BadRequest(ContainerOrderRequiredDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
await containers.ReorderAsync(body.Space ?? ContainerSpaces.Dashboard, body.Order, ct);
return Results.Ok(new { ok = true });
}
// GET /api/containers/state: свёрнутость/ширина всех колонок (colState).
private static async Task<IResult> GetColumnsStateAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
IReadOnlyDictionary<string, ColumnStateDto> state = await containers.GetColStateAsync(ct);
var wire = new Dictionary<string, object>(StringComparer.Ordinal);
foreach ((string colId, ColumnStateDto colState) in state)
{
wire[colId] = ToWireState(colState);
}
return Results.Ok(wire);
}
// PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки.
private static async Task<IResult> PatchColumnStateAsync(
string containerId,
ColStateBody body,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
ColumnStateDto merged = await containers.PatchColStateAsync(
containerId,
new ColumnStateDto(body.Collapsed, body.Width),
ct);
return Results.Ok(ToWireState(merged));
}
// Правила из wire → каноничный ContainerRulesDto: отсутствующие группы становятся пустыми списками.
// rules: Правила из тела запроса (null — «не меняются/нет правил»).
// Возвращает: Каноничные правила с не-null группами; null — правил в теле нет.
private static ContainerRulesDto? NormalizeWireRules(ContainerRulesDto? rules)
{
if (rules is null)
{
return null;
}
return rules with
{
Mode = rules.Mode ?? string.Empty,
Direction = rules.Direction ?? Array.Empty<string>(),
Keywords = rules.Keywords ?? Array.Empty<string>(),
Stack = rules.Stack ?? Array.Empty<string>(),
Grade = rules.Grade ?? Array.Empty<string>(),
Exclude = rules.Exclude ?? Array.Empty<string>(),
Budget = rules.Budget is null ? null : rules.Budget with { Cur = rules.Budget.Cur ?? string.Empty },
Levels = rules.Levels ?? Array.Empty<string>(),
Locations = rules.Locations ?? Array.Empty<string>(),
Types = rules.Types ?? Array.Empty<string>(),
Prices = rules.Prices is null ? null : rules.Prices with { Cur = rules.Prices.Cur ?? string.Empty },
};
}
// Состояние колонки → wire-объект только с заданными полями (collapsed/width), без null.
// state: Состояние колонки (могут быть null-поля).
// Возвращает: Словарь из не-null полей состояния.
private static Dictionary<string, object> ToWireState(ColumnStateDto state)
{
var wire = new Dictionary<string, object>();
if (state.Collapsed is { } collapsed)
{
wire["collapsed"] = collapsed;
}
if (state.Width is not null)
{
wire["width"] = state.Width;
}
return wire;
}
}