Files
Deal/src/core/Deal.Api/Endpoints/CardsEndpoints.cs
T
stepan d2d6b81aa0 Перевести «не найдено» карточек на NotFoundException
CardsService (get/move/links/reminders) и CardMover бросают NotFoundException вместо null/CardResultDto(null,null)/Exists=false. CardMoveResultDto — только Error; эндпоинты без локальных 404-проверок.
2026-09-13 18:23:53 +03:00

441 lines
18 KiB
C#

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;
/// <summary>
/// Эндпоинты карточек и поиска
/// </summary>
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 };
/// <summary>
/// Регистрирует группы /api/cards и /api
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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<IResult> 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<CardsService>();
if (!string.IsNullOrEmpty(target) && !await IsKnownContainerAsync(target, context, ct))
{
return EndpointResults.BadRequest(UnknownColumnDetail);
}
IReadOnlyList<CardDto> cards = await cardsService.ListCardsAsync(target, ct);
return Results.Ok(new { items = cards });
}
private static async Task<IResult> CountsAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardCountsDto counts = await cardsService.CountsAsync(ct);
var wire = new Dictionary<string, object> { ["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<IResult> GetCardAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto card = await cardsService.GetCardAsync(cardId, ct);
return Results.Ok(card);
}
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
await cardsService.MarkSeenAsync(cardId: null, col: null, ct);
return Results.Ok(new { ok = true });
}
private static async Task<IResult> 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<CardsService>();
await cardsService.MarkSeenAsync(cardId: null, col: body.Col, ct);
return Results.Ok(new { ok = true });
}
private static async Task<IResult> MoveAsync(
string cardId,
MoveBody body,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
ICardMover mover = context.RequestServices.GetRequiredService<ICardMover>();
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<CardsService>();
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
return Results.Ok(unified);
}
private static async Task<IResult> TrashAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
await cardsService.TrashCardAsync(cardId, ct);
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
return Results.Ok(new { ok = true });
}
private static async Task<IResult> RestoreAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> DeleteAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> ClearColAsync(
ClearColBody body,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> AddCommentAsync(
string cardId,
CommentBody body,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
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 &gt; 0) публикуется SSE cards_reclassified {reclassified,moved}.
// body: Тело запроса (ids — опционально).
// context: Контекст запроса.
// ct: Токен отмены.
private static async Task<IResult> ReclassifyAsync(
ReclassifyBody? body,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
SseBroker broker = context.RequestServices.GetRequiredService<SseBroker>();
Guid tenantId = context.GetCurrentUser()!.TenantId;
// Синхронный IProgress: события прогресса уходят в SSE в порядке прохода, до финального события.
var progress = new InlineProgress<ReclassifyProgressDto>(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<IResult> ReclassifyOneAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto card = await cardsService.GetCardAsync(cardId, ct);
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
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<SseBroker>();
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<IResult> SearchAsync(
string? q,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
IReadOnlyList<CardDto> leads = await cardsService.SearchCardsAsync(q, ct);
// messages всегда []: фронт их не читает.
return Results.Ok(new { cards = leads, messages = Array.Empty<object>() });
}
// ── Внутреннее ─────────────────────────────────────────────────────────
// Существует ли контейнер с таким id (служебная зона/стадия/доска).
// col: Значение query-параметра containerId (непустое).
// context: Контекст запроса (для резолва ContainersService).
// ct: Токен отмены.
// Возвращает: True — контейнер допустим для фильтра.
private static async Task<bool> IsKnownContainerAsync(
string col,
HttpContext context,
CancellationToken ct)
{
ContainersService containers = context.RequestServices.GetRequiredService<ContainersService>();
return await containers.GetAsync(col, ct) is not null;
}
}