Files
Deal/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs
T
stepan 685c5fdf46 Перевести «не найдено» карточек и колонок на исключения
CardsService (карточка/файл) и ContainersService бросают NotFoundException вместо возврата null; эндпоинты больше не проверяют null — 404 отдаёт общий обработчик. Тесты обновлены под новое поведение.
2026-09-13 14:32:24 +03:00

389 lines
16 KiB
C#

using System.Text.Json;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
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>
/// 400 перенос по стадии
/// </summary>
public const string UnknownStageDetail = "Неизвестная стадия";
private const string PlannedStage = CardsDefaultContainers.Planned;
private const string RejectedStage = CardsDefaultContainers.Rejected;
private const string TakenCommentText = "Взял в работу.";
/// <summary>
/// 400 ссылка: пустой url после Trim.
/// </summary>
public const string EmptyLinkDetail = "Пустая ссылка";
private const string HttpsUrlScheme = "https://";
private const string HttpUrlScheme = "http://";
// Ключ тела PATCH: заголовок (JSON-строка).
private const string PatchKeyTitle = "title";
// Ключ тела PATCH: краткое содержание (JSON-строка).
private const string PatchKeySummary = "summary";
// Ключ тела PATCH: контактная строка (JSON-строка).
private const string PatchKeyContact = "contact";
// Ключ тела PATCH: текст технического задания (JSON-строка).
private const string PatchKeyTzText = "tzText";
// Ключ тела PATCH: стек (JSON-массив строк либо null — очистка).
private const string PatchKeyStack = "stack";
// Ключ тела PATCH: бюджет (JSON-объект {from,to,cur} либо null/не-объект — очистка).
private const string PatchKeyBudget = "budget";
private static readonly CardBudgetDto ClearedBudget = new(From: null, To: null, Cur: string.Empty);
/// <summary>
/// Карточки пространства «Выбранные»
/// </summary>
/// <param name="containerId">Фильтр по контейнеру-стадии; null — все стадии «Выбранных».</param>
/// <returns>Полные карточки в порядке UpdatedAt DESC; пусто — карточек нет.</returns>
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
{
return _store.ListSelectedCardsAsync(containerId, ct);
}
/// <summary>
/// Ручное создание «локальной» карточки без внешнего источника.
/// </summary>
/// <param name="draft">Начальные поля карточки (тело POST /api/cards, см. <see cref="CardLocalCreateDto"/>).</param>
/// <returns>Созданная карточка (полное чтение после записи).</returns>
public async Task<CardDto> CreateLocalCardAsync(CardLocalCreateDto draft, CancellationToken ct)
{
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
DateTimeOffset receivedAt = DateTimeOffset.FromUnixTimeMilliseconds(nowMs);
string containerId = draft.ContainerId is not null && CardsDefaultContainers.Contains(draft.ContainerId)
? draft.ContainerId
: PlannedStage;
var snapshot = new CardSnapshot
{
Id = PrefixId.New(KanbanIdPrefixes.Card),
Col = containerId,
IsNew = false,
Local = true,
Title = draft.Title.Trim(),
Summary = draft.Summary,
Source = new SourceRef { Kind = SourceKinds.Local, ReceivedAt = receivedAt },
Content = new SourceContent { Text = draft.Summary },
ReceivedAt = receivedAt,
Stack = draft.Stack ?? Array.Empty<string>(),
BudgetFrom = draft.Budget?.From,
BudgetTo = draft.Budget?.To,
BudgetCur = draft.Budget?.Cur ?? string.Empty,
Contact = draft.Contact,
TzText = draft.TzText,
History = new[] { new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, "createdLocal", null) },
};
await _store.AddCardAsync(snapshot, ct);
return await _store.GetCardAsync(snapshot.Id, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после создания: " + snapshot.Id);
}
/// <summary>
/// «Взять в работу»
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка в стадии planned.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> TakeCardAsync(string cardId, CancellationToken ct)
{
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
if (CardsDefaultContainers.Contains(card.Col))
{
return card;
}
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, PlannedStage);
if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct))
{
// Карточка исчезла между чтением и переносом (гонка с удалением).
throw new NotFoundException(CardEntityName, cardId);
}
await _store.AddCommentAsync(
PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct);
return await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
}
/// <summary>
/// Точечная правка полей карточки по телу PATCH — presence-aware.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param>
/// <returns>Обновлённая карточка.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> PatchCardAsync(
string cardId,
IReadOnlyDictionary<string, JsonElement> body,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(body);
bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct);
if (!updated)
{
throw new NotFoundException(CardEntityName, cardId);
}
return await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
}
/// <summary>
/// Добавляет ссылку карточке
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="name">Название ссылки; пустое после Trim → name = url.</param>
/// <param name="url">URL ссылки (без схемы — добавится https://).</param>
/// <returns>Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой.</returns>
public async Task<CardResultDto> AddLinkAsync(
string cardId,
string name,
string url,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
}
string normalizedUrl = (url ?? string.Empty).Trim();
if (normalizedUrl.Length == 0)
{
return new CardResultDto(EmptyLinkDetail, null);
}
if (!normalizedUrl.StartsWith(HttpsUrlScheme, StringComparison.Ordinal)
&& !normalizedUrl.StartsWith(HttpUrlScheme, StringComparison.Ordinal))
{
normalizedUrl = HttpsUrlScheme + normalizedUrl;
}
string trimmedName = (name ?? string.Empty).Trim();
var link = new CardLinkDto(
PrefixId.New(KanbanIdPrefixes.Link),
trimmedName.Length == 0 ? normalizedUrl : trimmedName,
normalizedUrl);
if (!await _store.AddLinkAsync(cardId, link, ct))
{
return new CardResultDto(null, null);
}
CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после добавления ссылки: " + cardId);
return new CardResultDto(null, saved);
}
/// <summary>
/// Удаляет ссылку карточки по id
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
/// <returns>Результат: Card=null без Error (404) | Card — карточка без ссылки.</returns>
public async Task<CardResultDto> RemoveLinkAsync(
string cardId,
string linkId,
CancellationToken ct)
{
if (!await _store.RemoveLinkAsync(cardId, linkId, ct))
{
return new CardResultDto(null, null);
}
CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после удаления ссылки: " + cardId);
return new CardResultDto(null, saved);
}
/// <summary>
/// Перенос карточки по контейнерам-стадиям «Выбранных»
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="containerId">Новый контейнер-стадия — id каталога <see cref="CardsDefaultContainers"/>.</param>
/// <returns>Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | Card — карточка после переноса.</returns>
public async Task<CardResultDto> MoveStageCardAsync(
string cardId,
string containerId,
CancellationToken ct)
{
if (!CardsDefaultContainers.Contains(containerId))
{
return new CardResultDto(UnknownStageDetail, null);
}
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, containerId);
bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct);
if (!moved)
{
return new CardResultDto(null, null);
}
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после move: " + cardId);
return new CardResultDto(null, card);
}
/// <summary>
/// Полная ручная очистка терминальной стадии «Отклонено» — hard-delete строк
/// </summary>
/// <returns>Сколько карточек удалено (0 — стадия пуста).</returns>
public Task<int> ClearRejectedAsync(CancellationToken ct)
{
return _store.ClearStageAsync(RejectedStage, ct);
}
private static CardPatch ResolvePatch(IReadOnlyDictionary<string, JsonElement> body)
{
string? title = null;
string? summary = null;
string? contact = null;
string? tzText = null;
IReadOnlyList<string>? stack = null;
CardBudgetDto? budget = null;
foreach ((string key, JsonElement value) in body)
{
switch (key)
{
case PatchKeyTitle:
if (ReadTextValue(value, out string parsedTitle))
{
title = parsedTitle;
}
break;
case PatchKeySummary:
if (ReadTextValue(value, out string parsedSummary))
{
summary = parsedSummary;
}
break;
case PatchKeyContact:
if (ReadTextValue(value, out string parsedContact))
{
contact = parsedContact;
}
break;
case PatchKeyTzText:
if (ReadTextValue(value, out string parsedTzText))
{
tzText = parsedTzText;
}
break;
case PatchKeyStack:
stack = ReadStack(value);
break;
case PatchKeyBudget:
budget = ReadBudget(value);
break;
}
}
return new CardPatch(title, summary, contact, tzText, stack, budget, null, null, null);
}
// Читает JSON-строку текстового поля; null/не-строка → ключ не применяется.
// element: Значение ключа тела.
// text: Прочитанная строка (при успехе).
// Возвращает: True — значение строковое (в т.ч. пустое — очистка текста).
private static bool ReadTextValue(JsonElement element, out string text)
{
if (element.ValueKind == JsonValueKind.String)
{
text = element.GetString() ?? string.Empty;
return true;
}
text = string.Empty;
return false;
}
// Читает стек: массив строк (не-строки отбрасываются); null/не-массив → пустой стек.
// element: Значение ключа stack.
// Возвращает: Новый стек (полная замена массива).
private static IReadOnlyList<string> ReadStack(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Array)
{
return Array.Empty<string>();
}
var items = new List<string>();
foreach (JsonElement item in element.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
items.Add(item.GetString() ?? string.Empty);
}
}
return items;
}
// Читает бюджет: объект {from,to,cur} → пара границ + валюта; null/не-объект → очистка.
// element: Значение ключа budget.
// Возвращает: Бюджет патча: валюта пуста — «бюджета нет» (очистка).
private static CardBudgetDto ReadBudget(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
return ClearedBudget;
}
double? from = ReadNumber(element, "from");
double? to = ReadNumber(element, "to");
string cur = ReadText(element, "cur");
return new CardBudgetDto(from, to, cur);
}
// Читает число поля объекта бюджета: число → значение; иное — пусто.
// obj: Объект бюджета.
// fieldName: Имя поля (from/to).
// Возвращает: Граница либо null.
private static double? ReadNumber(JsonElement obj, string fieldName)
{
return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.Number
? element.GetDouble()
: null;
}
// Читает строку поля объекта бюджета: строка → значение; отсутствие/иное → пусто.
// obj: Объект бюджета.
// fieldName: Имя поля (cur).
// Возвращает: Код валюты либо пустая строка.
private static string ReadText(JsonElement obj, string fieldName)
{
return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.String
? element.GetString() ?? string.Empty
: string.Empty;
}
}