using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services;
///
/// Публичные операции карточек — partial-часть
///
public sealed partial class CardsService
{
///
/// Карточки колонки или всех колонок дашборда, received_at DESC.
///
/// Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда.
/// Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет.
public Task> ListCardsAsync(string? col, CancellationToken ct)
{
return _store.ListCardsAsync(new CardsQuery(col), ct);
}
///
/// Одна карточка по id.
///
/// Id карточки (c_...).
/// Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).
///
/// Одна карточка по id.
///
/// Id карточки (c_...).
/// Карточка.
/// Карточка не найдена.
public async Task GetCardAsync(string cardId, CancellationToken ct)
{
return await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
}
///
/// Перенос карточки на доску или в «Неразобранное».
///
/// Id карточки (c_...).
/// Цель: inbox либо id доски (b_...).
/// Результат: Error (400) | Card=null (карточки нет, 404) | Card — карточка после переноса.
public async Task 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)
{
throw new NotFoundException(CardEntityName, cardId);
}
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 hits = toCol == CardIds.Inbox
? Array.Empty()
: 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)
?? throw new NotFoundException(CardEntityName, cardId));
}
///
/// Перенос карточки в корзину
///
/// Id карточки (c_...).
/// Карточка после переноса (при no-op — как была).
/// Карточка не найдена.
public Task TrashCardAsync(string cardId, CancellationToken ct)
{
return TrashCardAsync(cardId, teach: true, ct);
}
///
/// Перенос карточки в корзину с управлением обучением ML.
///
/// Id карточки (c_...).
/// True — писать сигнал «спам» (действие пользователя); false — не писать.
/// Карточка после переноса (при no-op — как была).
/// Карточка не найдена.
public async Task TrashCardAsync(
string cardId,
bool teach,
CancellationToken ct)
{
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
if (card.Col == CardIds.Trash)
{
return card;
}
string text = LearningText(card);
await MoveToColumnAsync(card, CardIds.Trash, Array.Empty(), 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)
?? throw new NotFoundException(CardEntityName, cardId);
}
///
/// Возврат карточки из архива/корзины на канбан.
///
/// Id карточки (c_...).
/// Колонка возврата (inbox/доска).
/// Карточка не найдена.
public async Task RestoreCardAsync(string cardId, CancellationToken ct)
{
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
string back = await ResolveReturnColAsync(card.PrevCol, ct);
string text = LearningText(card);
IReadOnlyList hits = back == CardIds.Inbox
? Array.Empty()
: 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;
}
///
/// Полное удаление карточки
///
/// Id карточки (c_...).
/// False — карточки нет (404 «Карточка не найдена»); True — удалена.
public async Task DeleteForeverAsync(string cardId, CancellationToken ct)
{
if (await _store.GetCardAsync(cardId, ct) is null)
{
return false;
}
await _store.DeleteForeverAsync(cardId, ct);
return true;
}
///
/// Полная ручная очистка служебной колонки trash/archive.
///
/// Очищаемая колонка: trash | archive.
/// Результат: Error (400) либо Cleared — сколько карточек удалено навсегда.
public async Task 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);
}
///
/// Добавляет комментарий к карточке
///
/// Id карточки (c_...).
/// Текст комментария (непустой после Trim).
/// Результат: Error (400) | Comments=null (404) | Comments — список после добавления.
public async Task 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 comments = await _store.ListCommentsAsync(cardId, ct);
return new AddCommentResultDto(null, comments);
}
///
/// Снимает флаг «новое»
///
/// Id карточки либо null/пусто.
/// Колонка либо null/пусто (используется, когда cardId не задан).
public Task MarkSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
{
return _store.UpdateSeenAsync(
string.IsNullOrEmpty(cardId) ? null : cardId,
string.IsNullOrEmpty(col) ? null : col,
ct);
}
///
/// Счётчики колонок
///
/// Счётчики: колонки + learning/ml/ai (поля New/Learning/Ml/Ai и словарь Columns).
public async Task CountsAsync(CancellationToken ct)
{
IReadOnlyDictionary 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,
};
}
///
/// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение.
///
/// Поисковый запрос (trim + lowercase внутри).
/// Найденные карточки (≤12); пусто — запрос короче 2 символов или нет совпадений.
public async Task> SearchCardsAsync(string? query, CancellationToken ct)
{
string lowered = (query ?? string.Empty).Trim().ToLowerInvariant();
if (lowered.Length < MinSearchQueryLength)
{
return Array.Empty();
}
return await _store.SearchCardsAsync(lowered, SearchLimit, ct);
}
}