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

473 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Детальные операции карточки
/// </summary>
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 = "\"";
/// <summary>
/// Регистрирует уникальные подпути /api/cards
/// </summary>
/// <param name="app">Построитель маршрутов приложения.</param>
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
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<IResult> CreateCardAsync(
CreateCardRequest body,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> TakeCardAsync(
TakeCardRequest body,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> ClearRejectedAsync(HttpContext context, CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> PatchCardAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
IReadOnlyDictionary<string, JsonElement>? body = await ReadPatchBodyAsync(context, ct);
if (body is null)
{
return EndpointResults.BadRequest(InvalidBodyDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> AddLinkAsync(
string cardId,
CardLinkRequest body,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> RemoveLinkAsync(
string cardId,
string linkId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> UploadFilesAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> DownloadFileAsync(
string cardId,
string fileId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardFileDto entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct);
if (string.IsNullOrWhiteSpace(entry.ObjectKey))
{
return EndpointResults.Gone(FileNotSavedDetail);
}
IFileStorage storage = context.RequestServices.GetRequiredService<IFileStorage>();
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<IResult> RemoveFileAsync(
string cardId,
string fileId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
await cardsService.RemoveFileAsync(cardId, fileId, ct);
return await ReadCardAsync(context, cardId, ct);
}
// POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка.
private static async Task<IResult> 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<CardsService>();
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<IResult> ClearReminderAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> SnoozeReminderAsync(
string cardId,
HttpContext context,
CancellationToken ct)
{
if (!context.HasUser())
{
return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
}
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> ReadSourceAsync(
HttpContext context,
string cardId,
CancellationToken ct)
{
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
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<IResult> ReadCardAsync(
HttpContext context,
string cardId,
CancellationToken ct)
{
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
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<IReadOnlyDictionary<string, JsonElement>?> ReadPatchBodyAsync(HttpContext context, CancellationToken ct)
{
try
{
return await JsonSerializer.DeserializeAsync<Dictionary<string, JsonElement>>(
context.Request.Body,
options: null,
cancellationToken: ct);
}
catch (JsonException)
{
return null;
}
}
}