CardsService (карточка/файл) и ContainersService бросают NotFoundException вместо возврата null; эндпоинты больше не проверяют null — 404 отдаёт общий обработчик. Тесты обновлены под новое поведение.
389 lines
16 KiB
C#
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;
|
|
}
|
|
}
|