using System.Text.Json; using Deal.Api.Endpoints.RequestModels; using Deal.Api.Extensions; using Deal.Api.Services; using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Tenants.Application.Models; namespace Deal.Api.Endpoints; /// /// Детальные операции карточки /// public static class CardDetailsEndpoints { // Префикс группы (общий с CardsEndpoints). private const string CardsGroupPrefix = "/api/cards"; // Статический сегмент «взять в работу» (регистрируется до /{cardId}). private const string TakePath = "/take"; // Статический сегмент очистки «Отклонено» (регистрируется до /{cardId}). private const string ClearRejectedPath = "/clear-rejected"; // Параметрический сегмент карточки (PATCH /{cardId}). private const string CardIdPath = "/{cardId}"; // Вложенный путь добавления ссылки. private const string LinksPath = "/{cardId}/links"; // Вложенный путь удаления ссылки. private const string LinkItemPath = "/{cardId}/links/{linkId}"; // Вложенный путь загрузки вложений (multipart, поле files). private const string FilesPath = "/{cardId}/files"; // Вложенный путь скачивания вложения (поток + attachment). private const string FileDownloadPath = "/{cardId}/files/{fileId}/download"; // Вложенный путь удаления вложения. private const string FileItemPath = "/{cardId}/files/{fileId}"; // Вложенный путь установки/снятия напоминания. private const string ReminderPath = "/{cardId}/reminder"; // Вложенный путь ленивой загрузки содержимого источника. private const string SourcePath = "/{cardId}/source"; // Вложенный путь «напомнить позже». private const string ReminderSnoozePath = "/{cardId}/reminder/snooze"; // OpenAPI-тег группы. private const string OpenApiTag = "cards"; // 404: карточка не найдена. private const string CardNotFoundDetail = "Карточка не найдена"; // 400: тело PATCH не JSON-объект. private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; // 400: POST файлов без multipart/form-data. private const string FormExpectedDetail = "Ожидается multipart/form-data"; // 400: POST напоминания без поля at. private const string ReminderAtMissingDetail = "Поле at (epoch-ms) обязательно"; // 404 download: объекта нет в хранилище. private const string FileNotFoundInStorageDetail = "Файл не найден в MinIO"; // 410 download: у записи файла нет objectKey. private const string FileNotSavedDetail = "Файл не сохранён в объектном хранилище"; // Content-Type скачивания по умолчанию. private const string DownloadContentTypeFallback = "application/octet-stream"; // Символ, убираемый из имени файла для Content-Disposition. private const string FileNameQuoteCharacter = "\""; /// /// Регистрирует уникальные подпути /api/cards /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. public static IEndpointRouteBuilder MapCardDetailsEndpoints(this IEndpointRouteBuilder app) { var cards = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag); // Статические сегменты (/take, /clear-rejected) ДО /{cardId}; вложенные — за /{cardId}. cards.MapPost("", CreateCardAsync); cards.MapPost(TakePath, TakeCardAsync); cards.MapPost(ClearRejectedPath, ClearRejectedAsync); cards.MapPatch(CardIdPath, PatchCardAsync); cards.MapPost(LinksPath, AddLinkAsync); cards.MapDelete(LinkItemPath, RemoveLinkAsync); cards.MapPost(FilesPath, UploadFilesAsync); cards.MapGet(FileDownloadPath, DownloadFileAsync); cards.MapDelete(FileItemPath, RemoveFileAsync); cards.MapGet(SourcePath, ReadSourceAsync); cards.MapPost(ReminderPath, SetReminderAsync); cards.MapDelete(ReminderPath, ClearReminderAsync); cards.MapPost(ReminderSnoozePath, SnoozeReminderAsync); return app; } // POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка. private static async Task CreateCardAsync( CreateCardRequest body, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService service = context.RequestServices.GetRequiredService(); CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct); return await ReadCardAsync(context, created.Id, ct); } // POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка. private static async Task TakeCardAsync( TakeCardRequest body, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService service = context.RequestServices.GetRequiredService(); CardDto card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct); return await ReadCardAsync(context, card.Id, ct); } // POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено». private static async Task ClearRejectedAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService service = context.RequestServices.GetRequiredService(); int cleared = await service.ClearRejectedAsync(ct); return Results.Ok(new { ok = true, cleared }); } // PATCH /api/cards/{cardId}: точечная правка полей (title/summary/contact/tzText/stack/budget). // Тело читается как произвольный JSON-объект (presence-aware): явный null чистящих полей // (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка. private static async Task PatchCardAsync( string cardId, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } IReadOnlyDictionary? body = await ReadPatchBodyAsync(context, ct); if (body is null) { return EndpointResults.BadRequest(InvalidBodyDetail); } CardsService service = context.RequestServices.GetRequiredService(); CardDto? card = await service.PatchCardAsync(cardId, body, ct); return card is null ? EndpointResults.NotFound(CardNotFoundDetail) : await ReadCardAsync(context, cardId, ct); } // POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка. private static async Task AddLinkAsync( string cardId, CardLinkRequest body, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService service = context.RequestServices.GetRequiredService(); CardResultDto result = await service.AddLinkAsync( cardId, body.Name ?? string.Empty, body.Url ?? string.Empty, ct); if (result.Error is not null) { return EndpointResults.BadRequest(result.Error); } return result.Card is null ? EndpointResults.NotFound(CardNotFoundDetail) : await ReadCardAsync(context, cardId, ct); } // DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка. private static async Task RemoveLinkAsync( string cardId, string linkId, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService service = context.RequestServices.GetRequiredService(); CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct); return result.Card is null ? EndpointResults.NotFound(CardNotFoundDetail) : await ReadCardAsync(context, cardId, ct); } // POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files). // Ответ — обновлённая карточка (с новыми files). Карточки нет → 404 до записи объектов. Каждый файл: // имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404). private static async Task UploadFilesAsync( string cardId, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); if (await cardsService.GetCardAsync(cardId, ct) is null) { return EndpointResults.NotFound(CardNotFoundDetail); } IFormCollection form; try { form = await context.Request.ReadFormAsync(ct); } catch (InvalidOperationException) { // Тело не multipart/form-data — ReadFormAsync бросает; фронт так не шлёт. return EndpointResults.BadRequest(FormExpectedDetail); } foreach (IFormFile file in form.Files) { await using Stream content = file.OpenReadStream(); await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct); } return await ReadCardAsync(context, cardId, ct); } // GET /api/cards/{cardId}/files/{fileId}/download: поток содержимого вложения. // Карточки/записи нет → 404; пустой objectKey → 410; объекта нет в хранилище/сбой → 404. Ответ — поток // с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек. private static async Task DownloadFileAsync( string cardId, string fileId, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); CardFileDto entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct); if (string.IsNullOrWhiteSpace(entry.ObjectKey)) { return EndpointResults.Gone(FileNotSavedDetail); } IFileStorage storage = context.RequestServices.GetRequiredService(); FileMeta? meta; Stream? stream; try { meta = await storage.StatAsync(entry.ObjectKey, ct); stream = meta is null ? null : await storage.GetAsync(entry.ObjectKey, ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception) { return EndpointResults.NotFound(FileNotFoundInStorageDetail); } if (meta is null || stream is null) { return EndpointResults.NotFound(FileNotFoundInStorageDetail); } context.Response.ContentLength = meta.Size; string contentType = string.IsNullOrWhiteSpace(meta.ContentType) ? DownloadContentTypeFallback : meta.ContentType; return Results.Stream(stream, contentType, fileDownloadName: ToDownloadFileName(entry.Name)); } // DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка. private static async Task RemoveFileAsync( string cardId, string fileId, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService cardsService = context.RequestServices.GetRequiredService(); await cardsService.RemoveFileAsync(cardId, fileId, ct); return await ReadCardAsync(context, cardId, ct); } // POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка. private static async Task SetReminderAsync( string cardId, ReminderSetRequest body, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } if (body.At is null) { return EndpointResults.BadRequest(ReminderAtMissingDetail); } CardsService service = context.RequestServices.GetRequiredService(); CardResultDto result = await service.SetReminderAsync(cardId, body.At.Value, ct); if (result.Error is not null) { return EndpointResults.BadRequest(result.Error); } return result.Card is null ? EndpointResults.NotFound(CardNotFoundDetail) : await ReadCardAsync(context, cardId, ct); } // DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка. private static async Task ClearReminderAsync( string cardId, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService service = context.RequestServices.GetRequiredService(); return await service.ClearReminderAsync(cardId, ct) ? await ReadCardAsync(context, cardId, ct) : EndpointResults.NotFound(CardNotFoundDetail); } // POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка. private static async Task SnoozeReminderAsync( string cardId, HttpContext context, CancellationToken ct) { if (!context.HasUser()) { return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } CardsService service = context.RequestServices.GetRequiredService(); return await service.SnoozeReminderAsync(cardId, ct) ? await ReadCardAsync(context, cardId, ct) : EndpointResults.NotFound(CardNotFoundDetail); } // Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет). // context: Контекст запроса (для резолва CardsService). // cardId: Id карточки. // ct: Токен отмены. // Возвращает: 200 с единой карточкой либо 404. // Читает содержимое источника карточки: провайдер по виду источника либо сохранённое в карточке. // context: Контекст запроса (для резолва CardsService). // cardId: Id карточки. // ct: Токен отмены. // Возвращает: 200 с SourceContent либо 404. private static async Task ReadSourceAsync( HttpContext context, string cardId, CancellationToken ct) { CardsService cardsService = context.RequestServices.GetRequiredService(); CardDto? card = await cardsService.GetCardAsync(cardId, ct); if (card is null) { return EndpointResults.NotFound(CardNotFoundDetail); } SourceContent content = await cardsService.ResolveSourceAsync(card, ct); return Results.Ok(content); } private static async Task ReadCardAsync( HttpContext context, string cardId, CancellationToken ct) { CardsService cardsService = context.RequestServices.GetRequiredService(); CardDto? card = await cardsService.GetCardAsync(cardId, ct); return card is null ? EndpointResults.NotFound(CardNotFoundDetail) : Results.Ok(card); } // Имя файла для Content-Disposition без кавычек «"». // name: Имя файла как в метаданных записи. // Возвращает: Имя, безопасное для заголовка. private static string ToDownloadFileName(string name) => name.Replace(FileNameQuoteCharacter, string.Empty); private static CardLocalCreateDto ToCreateLocalDto(CreateCardRequest body) { return new CardLocalCreateDto( Title: body.Title, Summary: body.Summary, Stack: body.Stack, Budget: body.Budget, Contact: body.Contact, TzText: body.TzText, ContainerId: body.ContainerId ?? body.Stage); } // Читает тело PATCH как произвольный JSON-объект: ключ → JsonElement (presence-aware). // context: Контекст запроса. // ct: Токен отмены. // Возвращает: Словарь ключей тела либо null — тело не JSON-объект. private static async Task?> ReadPatchBodyAsync(HttpContext context, CancellationToken ct) { try { return await JsonSerializer.DeserializeAsync>( context.Request.Body, options: null, cancellationToken: ct); } catch (JsonException) { return null; } } }