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); } }