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