using Deal.Api.Endpoints.RequestModels; using Deal.Api.Events; using Deal.Api.Extensions; using Deal.Api.Services; using Deal.Modules.Cards.Application.Abstractions; using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Tenants.Application.Models; namespace Deal.Api.Endpoints; /// /// Эндпоинты карточек и поиска /// public static class CardsEndpoints { // Префикс группы карточек. private const string CardsGroupPrefix = "/api/cards"; // Префикс группы поиска (единственный эндпоинт группы — /api/search). private const string ApiGroupPrefix = "/api"; // Путь поиска (GET). private const string SearchPath = "/search"; private const string OpenApiTag = "dashboard"; private const string CardNotFoundDetail = "Карточка не найдена"; // 400 GET /cards: containerId не существует. private const string UnknownColumnDetail = "Неизвестный контейнер"; private const string UserActor = "user"; private const string ReclassifiedEventType = "cards_reclassified"; // Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса. private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true }; /// /// Регистрирует группы /api/cards и /api /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. public static IEndpointRouteBuilder MapCardsEndpoints(this IEndpointRouteBuilder app) { var leads = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag); // Статические сегменты ДО /cards/{cardId}: ASP.NET Core отдаёт приоритет литералам, порядок регистрации // сохранён для читаемости. leads.MapGet("", ListCardsAsync); leads.MapGet("/counts", CountsAsync); leads.MapPost("/mark-all-seen", MarkAllSeenAsync); leads.MapPost("/mark-col-seen", MarkColSeenAsync); leads.MapPost("/clear-col", ClearColAsync); leads.MapPost("/reclassify", ReclassifyAsync); leads.MapGet("/{cardId}", GetCardAsync); leads.MapPost("/{cardId}/reclassify", ReclassifyOneAsync); leads.MapPost("/{cardId}/move", MoveAsync); leads.MapPost("/{cardId}/trash", TrashAsync); leads.MapPost("/{cardId}/restore", RestoreAsync); leads.MapDelete("/{cardId}", DeleteAsync); leads.MapPost("/{cardId}/comments", AddCommentAsync); app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag).MapGet(SearchPath, SearchAsync); return app; } // GET /api/cards?containerId=: карточки контейнера (или все карточки дашборда); 400 «Неизвестный контейнер». // Параметр col принят как алиас containerId (совместимость со старым фронтом). private static async Task ListCardsAsync( string? containerId, string? col, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } string? target = string.IsNullOrEmpty(containerId) ? col : containerId; CardsService cardsService = context.RequestServices.GetRequiredService(); if (!string.IsNullOrEmpty(target) && !await IsKnownContainerAsync(target, context, ct)) { return EndpointResults.BadRequest(UnknownColumnDetail); } IReadOnlyList cards = await cardsService.ListCardsAsync(target, ct); return Results.Ok(new { items = cards }); } private static async Task CountsAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); CardCountsDto counts = await cardsService.CountsAsync(ct); var wire = new Dictionary { ["new"] = counts.New }; foreach ((string col, CardColumnCountDto column) in counts.Columns) { wire[col] = column; } wire["learning"] = counts.Learning; wire["ml"] = counts.Ml; wire["ai"] = counts.Ai; return Results.Ok(wire); } private static async Task GetCardAsync( string cardId, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); CardDto card = await cardsService.GetCardAsync(cardId, ct); return Results.Ok(card); } private static async Task MarkAllSeenAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); await cardsService.MarkSeenAsync(cardId: null, col: null, ct); return Results.Ok(new { ok = true }); } private static async Task MarkColSeenAsync( MarkColBody body, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } if (body.Col is null) { return EndpointResults.BadRequest(UnknownColumnDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); await cardsService.MarkSeenAsync(cardId: null, col: body.Col, ct); return Results.Ok(new { ok = true }); } private static async Task MoveAsync( string cardId, MoveBody body, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } ICardMover mover = context.RequestServices.GetRequiredService(); CardMoveResultDto outcome = await mover.MoveAsync(cardId, body.To ?? string.Empty, UserMoveContext, ct); if (outcome.Error is not null) { return EndpointResults.BadRequest(outcome.Error); } await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct); CardsService cardsService = context.RequestServices.GetRequiredService(); CardDto unified = await cardsService.GetCardAsync(cardId, ct); return Results.Ok(unified); } private static async Task TrashAsync( string cardId, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); await cardsService.TrashCardAsync(cardId, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct); return Results.Ok(new { ok = true }); } private static async Task RestoreAsync( string cardId, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); string col = await cardsService.RestoreCardAsync(cardId, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct); return Results.Ok(new { ok = true, col }); } private static async Task DeleteAsync( string cardId, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); bool deleted = await cardsService.DeleteForeverAsync(cardId, ct); if (!deleted) { return EndpointResults.NotFound(CardNotFoundDetail); } await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct); return Results.Ok(new { ok = true }); } private static async Task ClearColAsync( ClearColBody body, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); ClearColResultDto result = await cardsService.ClearColAsync(body.Col ?? string.Empty, ct); return result.Error is not null ? EndpointResults.BadRequest(result.Error) : Results.Ok(new { ok = true, cleared = result.Cleared }); } private static async Task AddCommentAsync( string cardId, CommentBody body, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); AddCommentResultDto result = await cardsService.AddCommentAsync(cardId, body.Text ?? string.Empty, ct); if (result.Error is not null) { return EndpointResults.BadRequest(result.Error); } if (result.Comments is null) { return EndpointResults.NotFound(CardNotFoundDetail); } await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct); return Results.Ok(new { comments = result.Comments }); } // POST /api/cards/reclassify: переклассификация «Неразобранного» (все карточки либо ids). // Тело ids опционально (фронт шлёт запрос без тела — все карточки inbox). Проход синхронный; при занятом // проходе ответ {started:false, busy:true}. Поля started/busy/attempted сохранены ради совместимости, // добавлены reclassified/moved/kept/trashed/skipped/usedAi/reason. Аудит — card_reclassified; после // успешного прохода (reclassified > 0) публикуется SSE cards_reclassified {reclassified,moved}. // body: Тело запроса (ids — опционально). // context: Контекст запроса. // ct: Токен отмены. private static async Task ReclassifyAsync( ReclassifyBody? body, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardReclassifier reclassifier = context.RequestServices.GetRequiredService(); SseBroker broker = context.RequestServices.GetRequiredService(); Guid tenantId = context.GetCurrentUser()!.TenantId; // Синхронный IProgress: события прогресса уходят в SSE в порядке прохода, до финального события. var progress = new InlineProgress(value => broker.Publish( tenantId, ReclassifiedEventType, new { progress = true, done = value.Done, total = value.Total, moved = value.Moved, kept = value.Kept, trashed = value.Trashed, skipped = value.Skipped, })); ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct, progress); await AppendReclassifyAuditAsync(context, result, ct); PublishReclassified(context, result); return Results.Ok(ToReclassifyWire(result)); } // POST /api/cards/{cardId}/reclassify: переклассификация одной карточки; 404 «Карточка не найдена». // cardId: Id карточки (c_...). // context: Контекст запроса. // ct: Токен отмены. private static async Task ReclassifyOneAsync( string cardId, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); CardDto card = await cardsService.GetCardAsync(cardId, ct); CardReclassifier reclassifier = context.RequestServices.GetRequiredService(); ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct); await AppendReclassifyAuditAsync(context, result, ct); PublishReclassified(context, result); return Results.Ok(ToReclassifyWire(result)); } // Wire-форма итога переклассификации (camelCase; сохранены started/busy/attempted). // result: Итог прохода. // Возвращает: Объект ответа эндпоинта. private static object ToReclassifyWire(ReclassifyResultDto result) => new { started = result.Started, busy = result.Busy, attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, kept = result.Kept, trashed = result.Trashed, skipped = result.Skipped, usedAi = result.UsedAi, reason = result.Reason, }; private static void PublishReclassified(HttpContext context, ReclassifyResultDto result) { if (!result.Started || result.Reclassified == 0) { return; } SseBroker broker = context.RequestServices.GetRequiredService(); broker.Publish( context.GetCurrentUser()!.TenantId, ReclassifiedEventType, new { reclassified = result.Reclassified, moved = result.Moved, progress = false }); } // Аудит переклассификации: пишется только когда проход реально что-то изменил. // context: Контекст запроса. // result: Итог прохода. // ct: Токен отмены. private static Task AppendReclassifyAuditAsync( HttpContext context, ReclassifyResultDto result, CancellationToken ct) { if (result.Reclassified == 0) { return Task.CompletedTask; } return AuditAppender.AppendTenantAsync( context, AuditEvents.CardReclassified, new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed }, ct); } private static async Task SearchAsync( string? q, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); IReadOnlyList leads = await cardsService.SearchCardsAsync(q, ct); // messages всегда []: фронт их не читает. return Results.Ok(new { cards = leads, messages = Array.Empty() }); } // ── Внутреннее ───────────────────────────────────────────────────────── // Существует ли контейнер с таким id (служебная зона/стадия/доска). // col: Значение query-параметра containerId (непустое). // context: Контекст запроса (для резолва ContainersService). // ct: Токен отмены. // Возвращает: True — контейнер допустим для фильтра. private static async Task IsKnownContainerAsync( string col, HttpContext context, CancellationToken ct) { ContainersService containers = context.RequestServices.GetRequiredService(); return await containers.GetAsync(col, ct) is not null; } }