Добить структуру Api, Contracts, SharedKernel и сервисов

Deal.Api/Http -> Services/Models/Extensions; Contracts/Integrations
и SharedKernel/Tenants -> Abstractions/Models; extension-классы
telegram/ml -> Extensions. namespace/using/FQN мигрированы, using
дедуплицированы.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:25:18 +03:00
parent 492950bdd0
commit e3a2692507
191 changed files with 1539 additions and 1350 deletions
@@ -1,361 +1,361 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Публичные операции карточек — partial-часть <see cref="CardsService"/> (C32: выделено из общего
/// файла по темам, поведение не менялось): чтение списка/карточки, переносы/корзина/возврат/удаление/
/// очистка колонки, комментарии, mark-seen, счётчики и поиск (leads.py L151279, L509551).
/// </summary>
public sealed partial class CardsService
{
// ── Чтение (list_leads/get_lead L151160) ───────────────────────────────
/// <summary>
/// Карточки колонки или всех колонок дашборда, received_at DESC (list_leads L151156).
/// </summary>
/// <param name="col">Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет.</returns>
public Task<IReadOnlyList<CardDto>> ListCardsAsync(string? col, CancellationToken ct)
{
return _store.ListCardsAsync(new CardsQuery(col), ct);
}
/// <summary>
/// Одна карточка по id (get_lead L159160; GET /api/cards/{id}).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns>
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
{
return _store.GetCardAsync(cardId, ct);
}
// ── Переносы / архив / корзина (L163–247) ───────────────────────────────
/// <summary>
/// Перенос карточки на доску или в «Неразобранное» (move_lead L177191 + _move L163174).
/// </summary>
/// <remarks>
/// Цель валидируется до чтения карточки: не inbox и не существующая доска → 400
/// <see cref="MoveTargetInvalidDetail"/>. Исходная колонка archive/trash для MoveLeadAsync недоступна
/// → 400 <see cref="MoveSourceRestrictedDetail"/>: вывод из них — только restore_lead (иначе перенос минует
/// снятие метки «спам» возврата из корзины, Ruling 4). Перенос «в ту же колонку» — no-op (карточка
/// возвращается без изменений; журнал и обучение не пишутся). При реальном переносе: колонка меняется
/// (is_new=FALSE, prev_col = прежняя колонка), matchHits пересчитываются для доски через
/// <see cref="ColumnRules.ComputeHits"/> (Ruling 2; для inbox — пусто), пишется строка журнала action=move,
/// а при to≠inbox — обучающий сигнал PushAsync(text, id доски, 1.0) (Ruling 4; текст = source_msg или title;
/// пустой текст не учим — L188–191).
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="toCol">Цель: <c>inbox</c> либо id доски (<c>b_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) | Card=null (карточки нет, 404) | Card — карточка после переноса.</returns>
public async Task<CardResultDto> MoveDashboardCardAsync(
string cardId,
string toCol,
CancellationToken ct)
{
ContainerDto? board = null;
if (toCol != CardIds.Inbox)
{
board = await _store.GetContainerAsync(toCol, ct);
if (board is null)
{
return new CardResultDto(MoveTargetInvalidDetail, null);
}
}
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
}
// Из archive/trash карточку выводит только restore (L204222): прямой move в доску
// прошёл бы мимо снятия у ML веса «спама» возврата из корзины (Ruling 4, L218221).
if (card.Col == CardIds.Archive || card.Col == CardIds.Trash)
{
return new CardResultDto(MoveSourceRestrictedDetail, null);
}
if (card.Col == toCol)
{
return new CardResultDto(null, card);
}
string text = LearningText(card);
IReadOnlyList<MatchHitDto> hits = toCol == CardIds.Inbox
? Array.Empty<MatchHitDto>()
: await ComputeHitsAsync(board!.Rules, text, ct);
await MoveToColumnAsync(card, toCol, hits, ActionMove, ct);
if (toCol != CardIds.Inbox && text.Length > 0)
{
await _mlClient.PushAsync(text, toCol, PushWeightUser, ct);
}
return new CardResultDto(null, await _store.GetCardAsync(cardId, ct));
}
/// <summary>
/// Перенос карточки в корзину (trash_lead L194201): col=trash, is_new=FALSE, matchHits пусто.
/// </summary>
/// <remarks>
/// Карточка уже в корзине — no-op (как в _move L165166). Журнал action=trash пишется при реальном
/// переносе; обучающий сигнал «спам» 1.0 — только если карточка была НЕ в trash/archive (L198201,
/// Ruling 4). Исключение archive: перенос архива в корзину не «переучивает» на спам.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public Task<CardDto?> TrashCardAsync(string cardId, CancellationToken ct)
{
return TrashCardAsync(cardId, teach: true, ct);
}
/// <summary>
/// Перенос карточки в корзину с управлением обучением ML (trash_lead L194201).
/// </summary>
/// <remarks>
/// <paramref name="teach"/> = false — «тихое» перемещение без сигнала «спам»: используется ручной
/// переклассификацией (leads.py reclassify_lead L311/L316), где обучение кладётся ЯВНО одним сигналом
/// с весом гипотезы ИИ (0.4), а не весом действия пользователя (1.0). Журнал action=trash пишется всегда.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public async Task<CardDto?> TrashCardAsync(
string cardId,
bool teach,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
if (card.Col == CardIds.Trash)
{
return card;
}
string text = LearningText(card);
await MoveToColumnAsync(card, CardIds.Trash, Array.Empty<MatchHitDto>(), ActionTrash, ct);
if (teach && card.Col != CardIds.Archive && text.Length > 0)
{
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct);
}
return await _store.GetCardAsync(cardId, ct);
}
/// <summary>
/// Возврат карточки из архива/корзины на канбан (restore_lead L204222).
/// </summary>
/// <remarks>
/// Куда возвращаем: prev_col, если это «Неразобранное» или существующая доска, иначе inbox (L209).
/// При возврате is_new=TRUE, prev_col='inbox', archived_at=NULL (Ruling 10), matchHits пересчитаны для
/// доски (Ruling 2), журнал action=restore. Возврат ИЗ корзины снимает метку спам:
/// PushAsync(text, "spam", 1.0) (L218221, Ruling 4); из архива сигнал не шлётся.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Колонка возврата (inbox/доска) либо null — карточки нет (404).</returns>
public async Task<string?> RestoreCardAsync(string cardId, CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
string back = await ResolveReturnColAsync(card.PrevCol, ct);
string text = LearningText(card);
IReadOnlyList<MatchHitDto> hits = back == CardIds.Inbox
? Array.Empty<MatchHitDto>()
: await ComputeHitsForBoardAsync(back, text, ct);
await _store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: cardId,
Col: back,
IsNew: true,
PrevCol: CardIds.Inbox,
ArchivedAt: null,
MatchHits: hits), ct);
await LogMoveAsync(cardId, ActionRestore, card.Col, back, ct);
if (card.Col == CardIds.Trash && text.Length > 0)
{
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUnlearn, ct);
}
return back;
}
/// <summary>
/// Полное удаление карточки (delete_forever L225234): Cards + комментарии (FK cascade),
/// журнал CardMoves/MlOutbox не трогаются (Ruling 10).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>False — карточки нет (404 «Карточка не найдена»); True — удалена.</returns>
public async Task<bool> DeleteForeverAsync(string cardId, CancellationToken ct)
{
if (await _store.GetCardAsync(cardId, ct) is null)
{
return false;
}
await _store.DeleteForeverAsync(cardId, ct);
return true;
}
/// <summary>
/// Полная ручная очистка служебной колонки trash/archive (clear_col L237247).
/// </summary>
/// <remarks>Другая колонка (inbox/доска/…) → 400 <see cref="ClearColInvalidDetail"/> (как ValueError L239240).</remarks>
/// <param name="col">Очищаемая колонка: <c>trash</c> | <c>archive</c>.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) либо Cleared — сколько карточек удалено навсегда.</returns>
public async Task<ClearColResultDto> ClearColAsync(string col, CancellationToken ct)
{
if (col != CardIds.Trash && col != CardIds.Archive)
{
return new ClearColResultDto(ClearColInvalidDetail, 0);
}
int cleared = await _store.ClearColAsync(col, ct);
return new ClearColResultDto(null, cleared);
}
// ── Комментарии (add_comment L259265) ──────────────────────────────────
/// <summary>
/// Добавляет комментарий к карточке: строка LeadComments (id <c>cm_</c>) + журнал action=comment.
/// </summary>
/// <remarks>
/// Текст Trim'ится (пустой после Trim → 400 <see cref="EmptyCommentDetail"/>, как dashboard_routes L240241);
/// автор — «Вы»; ответ — полный список комментариев (свежий — «только что», маппинг адаптера). Карточки
/// нет → Comments=null, Error=null (404 «Карточка не найдена» — сервис читает карточку до записи, порт
/// LeadComments ссылается FK, Task 4).
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="text">Текст комментария (непустой после Trim).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) | Comments=null (404) | Comments — список после добавления.</returns>
public async Task<AddCommentResultDto> AddCommentAsync(
string cardId,
string text,
CancellationToken ct)
{
string trimmed = (text ?? string.Empty).Trim();
if (trimmed.Length == 0)
{
return new AddCommentResultDto(EmptyCommentDetail, null);
}
if (await _store.GetCardAsync(cardId, ct) is null)
{
return new AddCommentResultDto(null, null);
}
await _store.AddCommentAsync(PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, trimmed, ct);
await LogMoveAsync(cardId, ActionComment, null, null, ct);
IReadOnlyList<CardCommentDto> comments = await _store.ListCommentsAsync(cardId, ct);
return new AddCommentResultDto(null, comments);
}
// ── Пометить прочитанным (mark_seen L250–256) ───────────────────────────
/// <summary>
/// Снимает флаг «новое»: с одной карточки (cardId), колонки (col) или всех (оба null/пустые).
/// </summary>
/// <remarks>Семантика 1:1 с mark_seen L250256 (проверка на truthiness: пустая строка = параметр не задан).
/// Эндпоинты этапа: mark-col-seen {col}, mark-all-seen (Ruling 11); /leads/{id}/seen фронтом не вызывается.</remarks>
/// <param name="cardId">Id карточки либо null/пусто.</param>
/// <param name="col">Колонка либо null/пусто (используется, когда cardId не задан).</param>
/// <param name="ct">Токен отмены.</param>
public Task MarkSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
{
return _store.UpdateSeenAsync(
string.IsNullOrEmpty(cardId) ? null : cardId,
string.IsNullOrEmpty(col) ? null : col,
ct);
}
// ── Счётчики (counts L268–279) ──────────────────────────────────────────
/// <summary>
/// Счётчики колонок (count+new по Cards) + статистика обучения/решений ML (learning/ml/ai).
/// </summary>
/// <remarks>
/// Форма CardCountsDto: Columns — только колонки с карточками; New — сумма «новых» по колонкам
/// (counts L270274). learning/ml/ai — из IMlClient.StatusAsync (L275278, план Task 7 L321): learning =
/// count(CardMoves), ml/ai — KV-счётчики решений пайплайна (на этапе 3 — 0, Ruling 4). Плоскую wire-форму
/// «{new, &lt;col&gt;:{…}, learning, ml, ai}» собирает эндпоинт Task 8.
/// </remarks>
/// <param name="ct">Токен отмены.</param>
/// <returns>Счётчики: колонки + learning/ml/ai (поля New/Learning/Ml/Ai и словарь Columns).</returns>
public async Task<CardCountsDto> CountsAsync(CancellationToken ct)
{
IReadOnlyDictionary<string, CardColumnCountDto> columns = await _store.CountCardsByColAsync(ct);
MlStatusResponseDto mlStatus = await _mlClient.StatusAsync(ct);
return new CardCountsDto
{
New = columns.Values.Sum(column => column.New),
Columns = columns,
Learning = mlStatus.Stats.Learning,
Ml = mlStatus.Stats.Ml,
Ai = mlStatus.Stats.Ai,
};
}
// ── Поиск (search L509551, LIKE-вариант Ruling 6) ──────────────────────
/// <summary>
/// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение (search L509551, Ruling 6/Task 12).
/// </summary>
/// <remarks>
/// q после Trim короче 2 символов → пусто, порт не вызывается (поведение этапа 3, L511–512). Сам поиск
/// выполняет адаптер — <see cref="ICardStore.SearchCardsAsync"/>: SearchTsv @@ plainto_tsquery('russian')
/// (морфология) LIKE по lower(title/summary/contact/source_msg), контейнеры-стадии «Выбранных»
/// исключены, порядок
/// ts_rank DESC, ReceivedAt DESC, результат ограничен <see cref="SearchLimit"/> = 12. messages: [] — на
/// совесть эндпоинта (Task 8). Запрос нормализуется trim+lowercase (как отсев-поиск Ruling 6).
/// </remarks>
/// <param name="query">Поисковый запрос (trim + lowercase внутри).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Найденные карточки (≤12); пусто — запрос короче 2 символов или нет совпадений.</returns>
public async Task<IReadOnlyList<CardDto>> SearchCardsAsync(string? query, CancellationToken ct)
{
string lowered = (query ?? string.Empty).Trim().ToLowerInvariant();
if (lowered.Length < MinSearchQueryLength)
{
return Array.Empty<CardDto>();
}
return await _store.SearchCardsAsync(lowered, SearchLimit, ct);
}
}
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Публичные операции карточек — partial-часть <see cref="CardsService"/> (C32: выделено из общего
/// файла по темам, поведение не менялось): чтение списка/карточки, переносы/корзина/возврат/удаление/
/// очистка колонки, комментарии, mark-seen, счётчики и поиск (leads.py L151279, L509551).
/// </summary>
public sealed partial class CardsService
{
// ── Чтение (list_leads/get_lead L151160) ───────────────────────────────
/// <summary>
/// Карточки колонки или всех колонок дашборда, received_at DESC (list_leads L151156).
/// </summary>
/// <param name="col">Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет.</returns>
public Task<IReadOnlyList<CardDto>> ListCardsAsync(string? col, CancellationToken ct)
{
return _store.ListCardsAsync(new CardsQuery(col), ct);
}
/// <summary>
/// Одна карточка по id (get_lead L159160; GET /api/cards/{id}).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns>
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
{
return _store.GetCardAsync(cardId, ct);
}
// ── Переносы / архив / корзина (L163–247) ───────────────────────────────
/// <summary>
/// Перенос карточки на доску или в «Неразобранное» (move_lead L177191 + _move L163174).
/// </summary>
/// <remarks>
/// Цель валидируется до чтения карточки: не inbox и не существующая доска → 400
/// <see cref="MoveTargetInvalidDetail"/>. Исходная колонка archive/trash для MoveLeadAsync недоступна
/// → 400 <see cref="MoveSourceRestrictedDetail"/>: вывод из них — только restore_lead (иначе перенос минует
/// снятие метки «спам» возврата из корзины, Ruling 4). Перенос «в ту же колонку» — no-op (карточка
/// возвращается без изменений; журнал и обучение не пишутся). При реальном переносе: колонка меняется
/// (is_new=FALSE, prev_col = прежняя колонка), matchHits пересчитываются для доски через
/// <see cref="ColumnRules.ComputeHits"/> (Ruling 2; для inbox — пусто), пишется строка журнала action=move,
/// а при to≠inbox — обучающий сигнал PushAsync(text, id доски, 1.0) (Ruling 4; текст = source_msg или title;
/// пустой текст не учим — L188–191).
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="toCol">Цель: <c>inbox</c> либо id доски (<c>b_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) | Card=null (карточки нет, 404) | Card — карточка после переноса.</returns>
public async Task<CardResultDto> MoveDashboardCardAsync(
string cardId,
string toCol,
CancellationToken ct)
{
ContainerDto? board = null;
if (toCol != CardIds.Inbox)
{
board = await _store.GetContainerAsync(toCol, ct);
if (board is null)
{
return new CardResultDto(MoveTargetInvalidDetail, null);
}
}
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
}
// Из archive/trash карточку выводит только restore (L204222): прямой move в доску
// прошёл бы мимо снятия у ML веса «спама» возврата из корзины (Ruling 4, L218221).
if (card.Col == CardIds.Archive || card.Col == CardIds.Trash)
{
return new CardResultDto(MoveSourceRestrictedDetail, null);
}
if (card.Col == toCol)
{
return new CardResultDto(null, card);
}
string text = LearningText(card);
IReadOnlyList<MatchHitDto> hits = toCol == CardIds.Inbox
? Array.Empty<MatchHitDto>()
: await ComputeHitsAsync(board!.Rules, text, ct);
await MoveToColumnAsync(card, toCol, hits, ActionMove, ct);
if (toCol != CardIds.Inbox && text.Length > 0)
{
await _mlClient.PushAsync(text, toCol, PushWeightUser, ct);
}
return new CardResultDto(null, await _store.GetCardAsync(cardId, ct));
}
/// <summary>
/// Перенос карточки в корзину (trash_lead L194201): col=trash, is_new=FALSE, matchHits пусто.
/// </summary>
/// <remarks>
/// Карточка уже в корзине — no-op (как в _move L165166). Журнал action=trash пишется при реальном
/// переносе; обучающий сигнал «спам» 1.0 — только если карточка была НЕ в trash/archive (L198201,
/// Ruling 4). Исключение archive: перенос архива в корзину не «переучивает» на спам.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public Task<CardDto?> TrashCardAsync(string cardId, CancellationToken ct)
{
return TrashCardAsync(cardId, teach: true, ct);
}
/// <summary>
/// Перенос карточки в корзину с управлением обучением ML (trash_lead L194201).
/// </summary>
/// <remarks>
/// <paramref name="teach"/> = false — «тихое» перемещение без сигнала «спам»: используется ручной
/// переклассификацией (leads.py reclassify_lead L311/L316), где обучение кладётся ЯВНО одним сигналом
/// с весом гипотезы ИИ (0.4), а не весом действия пользователя (1.0). Журнал action=trash пишется всегда.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public async Task<CardDto?> TrashCardAsync(
string cardId,
bool teach,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
if (card.Col == CardIds.Trash)
{
return card;
}
string text = LearningText(card);
await MoveToColumnAsync(card, CardIds.Trash, Array.Empty<MatchHitDto>(), ActionTrash, ct);
if (teach && card.Col != CardIds.Archive && text.Length > 0)
{
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct);
}
return await _store.GetCardAsync(cardId, ct);
}
/// <summary>
/// Возврат карточки из архива/корзины на канбан (restore_lead L204222).
/// </summary>
/// <remarks>
/// Куда возвращаем: prev_col, если это «Неразобранное» или существующая доска, иначе inbox (L209).
/// При возврате is_new=TRUE, prev_col='inbox', archived_at=NULL (Ruling 10), matchHits пересчитаны для
/// доски (Ruling 2), журнал action=restore. Возврат ИЗ корзины снимает метку спам:
/// PushAsync(text, "spam", 1.0) (L218221, Ruling 4); из архива сигнал не шлётся.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Колонка возврата (inbox/доска) либо null — карточки нет (404).</returns>
public async Task<string?> RestoreCardAsync(string cardId, CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
string back = await ResolveReturnColAsync(card.PrevCol, ct);
string text = LearningText(card);
IReadOnlyList<MatchHitDto> hits = back == CardIds.Inbox
? Array.Empty<MatchHitDto>()
: await ComputeHitsForBoardAsync(back, text, ct);
await _store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: cardId,
Col: back,
IsNew: true,
PrevCol: CardIds.Inbox,
ArchivedAt: null,
MatchHits: hits), ct);
await LogMoveAsync(cardId, ActionRestore, card.Col, back, ct);
if (card.Col == CardIds.Trash && text.Length > 0)
{
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUnlearn, ct);
}
return back;
}
/// <summary>
/// Полное удаление карточки (delete_forever L225234): Cards + комментарии (FK cascade),
/// журнал CardMoves/MlOutbox не трогаются (Ruling 10).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>False — карточки нет (404 «Карточка не найдена»); True — удалена.</returns>
public async Task<bool> DeleteForeverAsync(string cardId, CancellationToken ct)
{
if (await _store.GetCardAsync(cardId, ct) is null)
{
return false;
}
await _store.DeleteForeverAsync(cardId, ct);
return true;
}
/// <summary>
/// Полная ручная очистка служебной колонки trash/archive (clear_col L237247).
/// </summary>
/// <remarks>Другая колонка (inbox/доска/…) → 400 <see cref="ClearColInvalidDetail"/> (как ValueError L239240).</remarks>
/// <param name="col">Очищаемая колонка: <c>trash</c> | <c>archive</c>.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) либо Cleared — сколько карточек удалено навсегда.</returns>
public async Task<ClearColResultDto> ClearColAsync(string col, CancellationToken ct)
{
if (col != CardIds.Trash && col != CardIds.Archive)
{
return new ClearColResultDto(ClearColInvalidDetail, 0);
}
int cleared = await _store.ClearColAsync(col, ct);
return new ClearColResultDto(null, cleared);
}
// ── Комментарии (add_comment L259265) ──────────────────────────────────
/// <summary>
/// Добавляет комментарий к карточке: строка LeadComments (id <c>cm_</c>) + журнал action=comment.
/// </summary>
/// <remarks>
/// Текст Trim'ится (пустой после Trim → 400 <see cref="EmptyCommentDetail"/>, как dashboard_routes L240241);
/// автор — «Вы»; ответ — полный список комментариев (свежий — «только что», маппинг адаптера). Карточки
/// нет → Comments=null, Error=null (404 «Карточка не найдена» — сервис читает карточку до записи, порт
/// LeadComments ссылается FK, Task 4).
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="text">Текст комментария (непустой после Trim).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результат: Error (400) | Comments=null (404) | Comments — список после добавления.</returns>
public async Task<AddCommentResultDto> AddCommentAsync(
string cardId,
string text,
CancellationToken ct)
{
string trimmed = (text ?? string.Empty).Trim();
if (trimmed.Length == 0)
{
return new AddCommentResultDto(EmptyCommentDetail, null);
}
if (await _store.GetCardAsync(cardId, ct) is null)
{
return new AddCommentResultDto(null, null);
}
await _store.AddCommentAsync(PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, trimmed, ct);
await LogMoveAsync(cardId, ActionComment, null, null, ct);
IReadOnlyList<CardCommentDto> comments = await _store.ListCommentsAsync(cardId, ct);
return new AddCommentResultDto(null, comments);
}
// ── Пометить прочитанным (mark_seen L250–256) ───────────────────────────
/// <summary>
/// Снимает флаг «новое»: с одной карточки (cardId), колонки (col) или всех (оба null/пустые).
/// </summary>
/// <remarks>Семантика 1:1 с mark_seen L250256 (проверка на truthiness: пустая строка = параметр не задан).
/// Эндпоинты этапа: mark-col-seen {col}, mark-all-seen (Ruling 11); /leads/{id}/seen фронтом не вызывается.</remarks>
/// <param name="cardId">Id карточки либо null/пусто.</param>
/// <param name="col">Колонка либо null/пусто (используется, когда cardId не задан).</param>
/// <param name="ct">Токен отмены.</param>
public Task MarkSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
{
return _store.UpdateSeenAsync(
string.IsNullOrEmpty(cardId) ? null : cardId,
string.IsNullOrEmpty(col) ? null : col,
ct);
}
// ── Счётчики (counts L268–279) ──────────────────────────────────────────
/// <summary>
/// Счётчики колонок (count+new по Cards) + статистика обучения/решений ML (learning/ml/ai).
/// </summary>
/// <remarks>
/// Форма CardCountsDto: Columns — только колонки с карточками; New — сумма «новых» по колонкам
/// (counts L270274). learning/ml/ai — из IMlClient.StatusAsync (L275278, план Task 7 L321): learning =
/// count(CardMoves), ml/ai — KV-счётчики решений пайплайна (на этапе 3 — 0, Ruling 4). Плоскую wire-форму
/// «{new, &lt;col&gt;:{…}, learning, ml, ai}» собирает эндпоинт Task 8.
/// </remarks>
/// <param name="ct">Токен отмены.</param>
/// <returns>Счётчики: колонки + learning/ml/ai (поля New/Learning/Ml/Ai и словарь Columns).</returns>
public async Task<CardCountsDto> CountsAsync(CancellationToken ct)
{
IReadOnlyDictionary<string, CardColumnCountDto> columns = await _store.CountCardsByColAsync(ct);
MlStatusResponseDto mlStatus = await _mlClient.StatusAsync(ct);
return new CardCountsDto
{
New = columns.Values.Sum(column => column.New),
Columns = columns,
Learning = mlStatus.Stats.Learning,
Ml = mlStatus.Stats.Ml,
Ai = mlStatus.Stats.Ai,
};
}
// ── Поиск (search L509551, LIKE-вариант Ruling 6) ──────────────────────
/// <summary>
/// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение (search L509551, Ruling 6/Task 12).
/// </summary>
/// <remarks>
/// q после Trim короче 2 символов → пусто, порт не вызывается (поведение этапа 3, L511–512). Сам поиск
/// выполняет адаптер — <see cref="ICardStore.SearchCardsAsync"/>: SearchTsv @@ plainto_tsquery('russian')
/// (морфология) LIKE по lower(title/summary/contact/source_msg), контейнеры-стадии «Выбранных»
/// исключены, порядок
/// ts_rank DESC, ReceivedAt DESC, результат ограничен <see cref="SearchLimit"/> = 12. messages: [] — на
/// совесть эндпоинта (Task 8). Запрос нормализуется trim+lowercase (как отсев-поиск Ruling 6).
/// </remarks>
/// <param name="query">Поисковый запрос (trim + lowercase внутри).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Найденные карточки (≤12); пусто — запрос короче 2 символов или нет совпадений.</returns>
public async Task<IReadOnlyList<CardDto>> SearchCardsAsync(string? query, CancellationToken ct)
{
string lowered = (query ?? string.Empty).Trim().ToLowerInvariant();
if (lowered.Length < MinSearchQueryLength)
{
return Array.Empty<CardDto>();
}
return await _store.SearchCardsAsync(lowered, SearchLimit, ct);
}
}