SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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";
|
||||
|
||||
// 404 PATCH/accept: контейнер не найден.
|
||||
private const string ContainerNotFoundDetail = "Контейнер не найден";
|
||||
|
||||
// 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, new { id = created.Id, 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);
|
||||
if (updated is null)
|
||||
{
|
||||
return EndpointResults.NotFound(ContainerNotFoundDetail);
|
||||
}
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = 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);
|
||||
if (accepted is null)
|
||||
{
|
||||
return EndpointResults.NotFound(ContainerNotFoundDetail);
|
||||
}
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = 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, new { id = 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user