Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,484 @@
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 card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: 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();
CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct);
if (entry is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
}
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 (entry is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
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>();
CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct);
return card is null
? EndpointResults.NotFound(CardNotFoundDetail)
: 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;
}
}
}