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; /// /// Операции пространства «Выбранные» — partial-часть /// public sealed partial class CardsService { /// /// 400 перенос по стадии /// public const string UnknownStageDetail = "Неизвестная стадия"; private const string PlannedStage = CardsDefaultContainers.Planned; private const string RejectedStage = CardsDefaultContainers.Rejected; private const string TakenCommentText = "Взял в работу."; /// /// 400 ссылка: пустой url после Trim. /// 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); /// /// Карточки пространства «Выбранные» /// /// Фильтр по контейнеру-стадии; null — все стадии «Выбранных». /// Полные карточки в порядке UpdatedAt DESC; пусто — карточек нет. public Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct) { return _store.ListSelectedCardsAsync(containerId, ct); } /// /// Ручное создание «локальной» карточки без внешнего источника. /// /// Начальные поля карточки (тело POST /api/cards, см. ). /// Созданная карточка (полное чтение после записи). public async Task 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(), 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); } /// /// «Взять в работу» /// /// Id карточки (c_...). /// Карточка в стадии planned. /// Карточка не найдена. public async Task 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); } /// /// Точечная правка полей карточки по телу PATCH — presence-aware. /// /// Id карточки (c_...). /// Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется). /// Обновлённая карточка. /// Карточка не найдена. public async Task PatchCardAsync( string cardId, IReadOnlyDictionary 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); } /// /// Добавляет ссылку карточке /// /// Id карточки (c_...). /// Название ссылки; пустое после Trim → name = url. /// URL ссылки (без схемы — добавится https://). /// Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой. public async Task 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); } /// /// Удаляет ссылку карточки по id /// /// Id карточки (c_...). /// Id удаляемой ссылки (pl_...). /// Результат: Card=null без Error (404) | Card — карточка без ссылки. public async Task 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); } /// /// Перенос карточки по контейнерам-стадиям «Выбранных» /// /// Id карточки (c_...). /// Новый контейнер-стадия — id каталога . /// Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | Card — карточка после переноса. public async Task 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); } /// /// Полная ручная очистка терминальной стадии «Отклонено» — hard-delete строк /// /// Сколько карточек удалено (0 — стадия пуста). public Task ClearRejectedAsync(CancellationToken ct) { return _store.ClearStageAsync(RejectedStage, ct); } private static CardPatch ResolvePatch(IReadOnlyDictionary body) { string? title = null; string? summary = null; string? contact = null; string? tzText = null; IReadOnlyList? 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 ReadStack(JsonElement element) { if (element.ValueKind != JsonValueKind.Array) { return Array.Empty(); } var items = new List(); 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; } }