Магические строки кодов деталей аудита заменены каталогом AuditFields (68 мест), ключи элемента изменения и разбора JSON — константами, ключи хранилища ключей Telegram и геометрия маски — именованными константами.
302 lines
12 KiB
C#
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;
|
|
}
|
|
}
|