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; } }