Убрать неиспользуемые using по код-стайлу
Прогон dotnet format (IDE0005) по 4 решениям: удалены лишние using, оставшиеся после миграции namespace (676 файлов).
This commit is contained in:
@@ -1,502 +1,491 @@
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Events;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Models;
|
||||
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.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Registrars;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Extensions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Registrars;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Deal.Api.Dtos;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты карточек и поиска: GET /api/cards[?containerId=], /cards/counts, /cards/{id},
|
||||
/// mark-all-seen/mark-col-seen, move/trash/restore/DELETE, clear-col, comments, reclassify (batch + {id}),
|
||||
/// GET /api/search (этап 9, T6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый контракт /api/cards (R5): старые ручки /api/leads, /api/projects, /api/boards и /api/columns
|
||||
/// упразднены. GET /cards →
|
||||
/// {items}; counts — плоская wire-форма {new, <col>: {count, new}, learning, ml, ai}; move → обновлённая
|
||||
/// карточка; restore → {ok, col}; clear-col → {ok, cleared}; comments → {comments}; search →
|
||||
/// {cards, messages: []}. 400 «Неизвестный контейнер» при несуществующем containerId; 404 «Карточка не
|
||||
/// найдена» — null-результаты сервисов, 400-тексты — константы CardsService.
|
||||
/// ⚠ Статические сегменты (counts, mark-all-seen, mark-col-seen, clear-col, reclassify) регистрируются ДО
|
||||
/// /cards/{cardId}. Все эндпоинты требуют сессию: 401 {detail}; сервисы резолвятся из RequestServices
|
||||
/// ПОСЛЕ проверки сессии.
|
||||
/// </remarks>
|
||||
public static class CardsEndpoints
|
||||
{
|
||||
// Префикс группы карточек.
|
||||
private const string CardsGroupPrefix = "/api/cards";
|
||||
|
||||
// Префикс группы поиска (единственный эндпоинт группы — /api/search).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь поиска (GET).
|
||||
private const string SearchPath = "/search";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
|
||||
private const string OpenApiTag = "dashboard";
|
||||
|
||||
// 404: карточка не найдена (dashboard_routes.py _lead_or_404 L92–96).
|
||||
private const string CardNotFoundDetail = "Карточка не найдена";
|
||||
|
||||
// 400 GET /cards: containerId не существует.
|
||||
private const string UnknownColumnDetail = "Неизвестный контейнер";
|
||||
|
||||
// Инициатор перехода при ручном переносе — действие пользователя (R4 этапа 9).
|
||||
private const string UserActor = "user";
|
||||
|
||||
// SSE-тип события завершения переклассификации (этап 12, остаток 2; api.js слушает 'cards_reclassified').
|
||||
private const string ReclassifiedEventType = "cards_reclassified";
|
||||
|
||||
// Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса.
|
||||
private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true };
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группы /api/cards и /api (карточки + поиск). Статические сегменты — до /cards/{cardId}.
|
||||
/// </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 });
|
||||
}
|
||||
|
||||
// GET /api/cards/counts: плоская wire-форма счётчиков {new, <col>:{count,new}, learning, ml, ai} (L161–163, §4.1 L257).
|
||||
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);
|
||||
|
||||
// Разворачивание CardCountsDto: колонки — корневые ключи (counts L268–279), служебные — фиксированные.
|
||||
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);
|
||||
}
|
||||
|
||||
// GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
|
||||
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 card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(card);
|
||||
}
|
||||
|
||||
// POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}.
|
||||
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 });
|
||||
}
|
||||
|
||||
// POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {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)
|
||||
{
|
||||
// Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ
|
||||
// карточек (truthiness python, L250–256) — эндпоинт защищает от вызова с null (прототип: 422).
|
||||
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 });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4).
|
||||
// Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый
|
||||
// доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания
|
||||
// (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при
|
||||
// несуществующем контейнере, 404 — карточки нет.
|
||||
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);
|
||||
}
|
||||
|
||||
if (!outcome.Exists)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор.
|
||||
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 unified is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(unified);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203–207); ответ {ok:true}; 404.
|
||||
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>();
|
||||
CardDto? card = await cardsService.TrashCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит отправки карточки в корзину (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404.
|
||||
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);
|
||||
if (col is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит возврата карточки из корзины/архива (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
|
||||
return Results.Ok(new { ok = true, col });
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217–221); ответ {ok:true}; 404.
|
||||
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);
|
||||
}
|
||||
|
||||
// Аудит удаления карточки навсегда (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400.
|
||||
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 });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404.
|
||||
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);
|
||||
}
|
||||
|
||||
// Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется.
|
||||
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<IResult> ReclassifyAsync(
|
||||
ReclassifyBody? body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
|
||||
ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct);
|
||||
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);
|
||||
if (card is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api).
|
||||
// Публикуется только когда проход реально выполнен и что-то изменил (started и
|
||||
// reclassified > 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка
|
||||
// минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op.
|
||||
// context: Контекст запроса (тенант-канал сессии).
|
||||
// result: Итог прохода.
|
||||
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 });
|
||||
}
|
||||
|
||||
// Аудит переклассификации: пишется только когда проход реально что-то изменил.
|
||||
// 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);
|
||||
}
|
||||
|
||||
// GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254–256). Ответ {leads, messages: []}.
|
||||
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 всегда []: telegram-сообщений здесь нет, фронт их не читает.
|
||||
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;
|
||||
}
|
||||
}
|
||||
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>
|
||||
/// Эндпоинты карточек и поиска: GET /api/cards[?containerId=], /cards/counts, /cards/{id},
|
||||
/// mark-all-seen/mark-col-seen, move/trash/restore/DELETE, clear-col, comments, reclassify (batch + {id}),
|
||||
/// GET /api/search (этап 9, T6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единый контракт /api/cards (R5): старые ручки /api/leads, /api/projects, /api/boards и /api/columns
|
||||
/// упразднены. GET /cards →
|
||||
/// {items}; counts — плоская wire-форма {new, <col>: {count, new}, learning, ml, ai}; move → обновлённая
|
||||
/// карточка; restore → {ok, col}; clear-col → {ok, cleared}; comments → {comments}; search →
|
||||
/// {cards, messages: []}. 400 «Неизвестный контейнер» при несуществующем containerId; 404 «Карточка не
|
||||
/// найдена» — null-результаты сервисов, 400-тексты — константы CardsService.
|
||||
/// ⚠ Статические сегменты (counts, mark-all-seen, mark-col-seen, clear-col, reclassify) регистрируются ДО
|
||||
/// /cards/{cardId}. Все эндпоинты требуют сессию: 401 {detail}; сервисы резолвятся из RequestServices
|
||||
/// ПОСЛЕ проверки сессии.
|
||||
/// </remarks>
|
||||
public static class CardsEndpoints
|
||||
{
|
||||
// Префикс группы карточек.
|
||||
private const string CardsGroupPrefix = "/api/cards";
|
||||
|
||||
// Префикс группы поиска (единственный эндпоинт группы — /api/search).
|
||||
private const string ApiGroupPrefix = "/api";
|
||||
|
||||
// Путь поиска (GET).
|
||||
private const string SearchPath = "/search";
|
||||
|
||||
// OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
|
||||
private const string OpenApiTag = "dashboard";
|
||||
|
||||
// 404: карточка не найдена (dashboard_routes.py _lead_or_404 L92–96).
|
||||
private const string CardNotFoundDetail = "Карточка не найдена";
|
||||
|
||||
// 400 GET /cards: containerId не существует.
|
||||
private const string UnknownColumnDetail = "Неизвестный контейнер";
|
||||
|
||||
// Инициатор перехода при ручном переносе — действие пользователя (R4 этапа 9).
|
||||
private const string UserActor = "user";
|
||||
|
||||
// SSE-тип события завершения переклассификации (этап 12, остаток 2; api.js слушает 'cards_reclassified').
|
||||
private const string ReclassifiedEventType = "cards_reclassified";
|
||||
|
||||
// Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса.
|
||||
private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true };
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группы /api/cards и /api (карточки + поиск). Статические сегменты — до /cards/{cardId}.
|
||||
/// </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 });
|
||||
}
|
||||
|
||||
// GET /api/cards/counts: плоская wire-форма счётчиков {new, <col>:{count,new}, learning, ml, ai} (L161–163, §4.1 L257).
|
||||
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);
|
||||
|
||||
// Разворачивание CardCountsDto: колонки — корневые ключи (counts L268–279), служебные — фиксированные.
|
||||
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);
|
||||
}
|
||||
|
||||
// GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
|
||||
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 card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(card);
|
||||
}
|
||||
|
||||
// POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}.
|
||||
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 });
|
||||
}
|
||||
|
||||
// POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {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)
|
||||
{
|
||||
// Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ
|
||||
// карточек (truthiness python, L250–256) — эндпоинт защищает от вызова с null (прототип: 422).
|
||||
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 });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4).
|
||||
// Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый
|
||||
// доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания
|
||||
// (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при
|
||||
// несуществующем контейнере, 404 — карточки нет.
|
||||
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);
|
||||
}
|
||||
|
||||
if (!outcome.Exists)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор.
|
||||
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 unified is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(unified);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203–207); ответ {ok:true}; 404.
|
||||
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>();
|
||||
CardDto? card = await cardsService.TrashCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит отправки карточки в корзину (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404.
|
||||
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);
|
||||
if (col is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Аудит возврата карточки из корзины/архива (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
|
||||
return Results.Ok(new { ok = true, col });
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217–221); ответ {ok:true}; 404.
|
||||
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);
|
||||
}
|
||||
|
||||
// Аудит удаления карточки навсегда (этап 10, T1).
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
|
||||
return Results.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
// POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400.
|
||||
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 });
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404.
|
||||
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);
|
||||
}
|
||||
|
||||
// Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется.
|
||||
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<IResult> ReclassifyAsync(
|
||||
ReclassifyBody? body,
|
||||
HttpContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!context.HasUser())
|
||||
{
|
||||
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
|
||||
}
|
||||
|
||||
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
|
||||
ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct);
|
||||
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);
|
||||
if (card is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api).
|
||||
// Публикуется только когда проход реально выполнен и что-то изменил (started и
|
||||
// reclassified > 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка
|
||||
// минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op.
|
||||
// context: Контекст запроса (тенант-канал сессии).
|
||||
// result: Итог прохода.
|
||||
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 });
|
||||
}
|
||||
|
||||
// Аудит переклассификации: пишется только когда проход реально что-то изменил.
|
||||
// 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);
|
||||
}
|
||||
|
||||
// GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254–256). Ответ {leads, messages: []}.
|
||||
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 всегда []: telegram-сообщений здесь нет, фронт их не читает.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user