CardsService (get/move/links/reminders) и CardMover бросают NotFoundException вместо null/CardResultDto(null,null)/Exists=false. CardMoveResultDto — только Error; эндпоинты без локальных 404-проверок.
288 lines
12 KiB
C#
288 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Публичные операции карточек — partial-часть <see cref="CardsService"/>
|
|
/// </summary>
|
|
public sealed partial class CardsService
|
|
{
|
|
|
|
/// <summary>
|
|
/// Карточки колонки или всех колонок дашборда, received_at DESC.
|
|
/// </summary>
|
|
/// <param name="col">Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда.</param>
|
|
/// <returns>Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет.</returns>
|
|
public Task<IReadOnlyList<CardDto>> ListCardsAsync(string? col, CancellationToken ct)
|
|
{
|
|
return _store.ListCardsAsync(new CardsQuery(col), ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Одна карточка по id.
|
|
/// </summary>
|
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
|
/// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns>
|
|
/// <summary>
|
|
/// Одна карточка по id.
|
|
/// </summary>
|
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
|
/// <returns>Карточка.</returns>
|
|
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
|
public async Task<CardDto> GetCardAsync(string cardId, CancellationToken ct)
|
|
{
|
|
return await _store.GetCardAsync(cardId, ct)
|
|
?? throw new NotFoundException(CardEntityName, cardId);
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Перенос карточки на доску или в «Неразобранное».
|
|
/// </summary>
|
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
|
/// <param name="toCol">Цель: <c>inbox</c> либо id доски (<c>b_...</c>).</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)
|
|
{
|
|
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<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)
|
|
?? throw new NotFoundException(CardEntityName, cardId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Перенос карточки в корзину
|
|
/// </summary>
|
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
|
/// <returns>Карточка после переноса (при no-op — как была).</returns>
|
|
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
|
public Task<CardDto> TrashCardAsync(string cardId, CancellationToken ct)
|
|
{
|
|
return TrashCardAsync(cardId, teach: true, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Перенос карточки в корзину с управлением обучением ML.
|
|
/// </summary>
|
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
|
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
|
|
/// <returns>Карточка после переноса (при no-op — как была).</returns>
|
|
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
|
public async Task<CardDto> 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<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)
|
|
?? throw new NotFoundException(CardEntityName, cardId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Возврат карточки из архива/корзины на канбан.
|
|
/// </summary>
|
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
|
/// <returns>Колонка возврата (inbox/доска).</returns>
|
|
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
|
public async Task<string> 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<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>
|
|
/// Полное удаление карточки
|
|
/// </summary>
|
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</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.
|
|
/// </summary>
|
|
/// <param name="col">Очищаемая колонка: <c>trash</c> | <c>archive</c>.</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);
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Добавляет комментарий к карточке
|
|
/// </summary>
|
|
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
|
/// <param name="text">Текст комментария (непустой после Trim).</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);
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Снимает флаг «новое»
|
|
/// </summary>
|
|
/// <param name="cardId">Id карточки либо null/пусто.</param>
|
|
/// <param name="col">Колонка либо null/пусто (используется, когда cardId не задан).</param>
|
|
public Task MarkSeenAsync(
|
|
string? cardId,
|
|
string? col,
|
|
CancellationToken ct)
|
|
{
|
|
return _store.UpdateSeenAsync(
|
|
string.IsNullOrEmpty(cardId) ? null : cardId,
|
|
string.IsNullOrEmpty(col) ? null : col,
|
|
ct);
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Счётчики колонок
|
|
/// </summary>
|
|
/// <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,
|
|
};
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение.
|
|
/// </summary>
|
|
/// <param name="query">Поисковый запрос (trim + lowercase внутри).</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);
|
|
}
|
|
|
|
}
|