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;
///
/// Эндпоинты контейнеров
///
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);
///
/// Регистрирует группы /api/containers
///
/// Построитель маршрутов приложения.
/// Построитель маршрутов для цепочки вызовов.
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 ListContainersAsync(
string? space,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService();
return Results.Ok(new { items = await containers.ListAsync(space, ct) });
}
// POST /api/containers: создать контейнер; ответ {id}.
private static async Task 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();
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 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(RequestJsonOptions);
}
catch (JsonException)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
if (patchBody is null)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService();
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 AcceptSuggestedAsync(
string containerId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService();
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 DeleteContainerAsync(
string containerId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService();
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 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();
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 GetColumnsStateAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService();
IReadOnlyDictionary state = await containers.GetColStateAsync(ct);
var wire = new Dictionary(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 PatchColumnStateAsync(
string containerId,
ColStateBody body,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ContainersService containers = context.RequestServices.GetRequiredService();
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(),
Keywords = rules.Keywords ?? Array.Empty(),
Stack = rules.Stack ?? Array.Empty(),
Grade = rules.Grade ?? Array.Empty(),
Exclude = rules.Exclude ?? Array.Empty(),
Budget = rules.Budget is null ? null : rules.Budget with { Cur = rules.Budget.Cur ?? string.Empty },
Levels = rules.Levels ?? Array.Empty(),
Locations = rules.Locations ?? Array.Empty(),
Types = rules.Types ?? Array.Empty(),
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 ToWireState(ColumnStateDto state)
{
var wire = new Dictionary();
if (state.Collapsed is { } collapsed)
{
wire["collapsed"] = collapsed;
}
if (state.Width is not null)
{
wire["width"] = state.Width;
}
return wire;
}
}