diff --git a/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs b/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs
index 0e34a66..094cf06 100644
--- a/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs
+++ b/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs
@@ -1,10 +1,8 @@
+using System.Text.Json.Nodes;
using Deal.Ai.Llm;
using Deal.Grpc.Ai;
using Grpc.Core;
-using Grpc.Net.Client;
using Microsoft.Extensions.DependencyInjection;
-using System.Text.Json.Nodes;
-using Deal.Ai.Tests.Support;
namespace Deal.Ai.Tests.Ai;
diff --git a/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs b/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs
index dff36d3..d342cbd 100644
--- a/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs
+++ b/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs
@@ -1,13 +1,11 @@
-using Deal.Ai;
+using System.Net;
+using System.Net.Sockets;
using Deal.Ai.Llm;
using Deal.Grpc.Hosting;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
-using System.Net;
-using System.Net.Sockets;
-using Deal.Ai.Tests.Support;
namespace Deal.Ai.Tests.Ai;
diff --git a/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs b/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs
index 5b8dee3..e5d7f75 100644
--- a/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs
+++ b/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs
@@ -1,5 +1,4 @@
using Deal.Ai.Llm;
-using Deal.Ai.Tests.Support;
namespace Deal.Ai.Tests.Ai;
diff --git a/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs b/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs
index 718379b..df2749c 100644
--- a/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs
+++ b/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs
@@ -1,6 +1,5 @@
using Deal.Ai.Llm;
using Microsoft.Extensions.Logging.Abstractions;
-using Deal.Ai.Tests.Support;
namespace Deal.Ai.Tests.Ai;
diff --git a/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs b/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs
index 7be3743..c8d3f25 100644
--- a/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs
+++ b/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs
@@ -1,5 +1,4 @@
using Deal.Ai.Llm;
-using Deal.Ai.Tests.Support;
namespace Deal.Ai.Tests.Ai;
diff --git a/src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs b/src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs
index cb37830..83c835f 100644
--- a/src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs
+++ b/src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs
@@ -1,5 +1,3 @@
-using Xunit;
-
// In-proc gRPC-тесты ai-service поднимают реальные Kestrel-хосты и меняют процесс-глобальную
// env-переменную DEAL_SERVICE_TOKEN на время сценария (AiTestHost и AiServiceHostTests).
// Параллельный прогон классов дал бы гонки на env — тесты сериализованы (тот же шаблон, что
diff --git a/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs b/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs
index c628090..d7b1fdc 100644
--- a/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs
+++ b/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs
@@ -1,13 +1,10 @@
+using System.Net;
+using System.Net.Sockets;
using Deal.Grpc.Ai;
-using Deal.Ai;
-using Microsoft.AspNetCore.Builder;
using Grpc.Core;
using Grpc.Health.V1;
using Grpc.Net.Client;
-using System.Net;
-using System.Net.Sockets;
-using Xunit;
-using Deal.Ai.Tests.Support;
+using Microsoft.AspNetCore.Builder;
namespace Deal.Ai.Tests.Grpc;
diff --git a/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs b/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs
index 8e7c20b..9fc1499 100644
--- a/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs
+++ b/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs
@@ -1,7 +1,6 @@
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
-using Deal.Ai.Llm;
namespace Deal.Ai.Tests.Support;
diff --git a/src/core/Deal.Api/Configuration/CookieOptions.cs b/src/core/Deal.Api/Configuration/CookieOptions.cs
index d8d954a..1531337 100644
--- a/src/core/Deal.Api/Configuration/CookieOptions.cs
+++ b/src/core/Deal.Api/Configuration/CookieOptions.cs
@@ -1,39 +1,33 @@
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.Modules.Tenants.Application.Extensions;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.Modules.Tenants.Application.Registrars;
-using Deal.Modules.Tenants.Application.Services;
-using Deal.Api.Services;
-using Deal.Api.Dtos;
-
-namespace Deal.Api.Configuration;
-
-///
-/// Настройки httpOnly-куки сессии. Привязываются из секции "Cookies" конфигурации (IOptions).
-///
-///
-/// Источник значений — конфигурация: секция Cookies в appsettings.json /
-/// appsettings.Development.json и переменные окружения Cookies__* (имя, срок, Secure).
-///
-/// Срок жизни по умолчанию — единый источник числа «30 дней»: константа модуля
-/// , на которую ссылается код-дефолт свойства
-/// . Значение из конфигурации (Cookies__Days) при необходимости перекрывает его.
-///
-///
-public sealed class CookieOptions
-{
- ///
- /// Имя куки (Ruling 6: deal_session).
- ///
- public string Name { get; set; } = "deal_session";
-
- ///
- /// Срок жизни куки в днях; совпадает со сроком жизни сессии (Ruling 6).
- ///
- public int Days { get; set; } = AuthService.SessionLifetimeDays;
-
- ///
- /// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 6).
- ///
- public bool Secure { get; set; }
-}
+using Deal.Modules.Tenants.Application.Services;
+
+namespace Deal.Api.Configuration;
+
+///
+/// Настройки httpOnly-куки сессии. Привязываются из секции "Cookies" конфигурации (IOptions).
+///
+///
+/// Источник значений — конфигурация: секция Cookies в appsettings.json /
+/// appsettings.Development.json и переменные окружения Cookies__* (имя, срок, Secure).
+///
+/// Срок жизни по умолчанию — единый источник числа «30 дней»: константа модуля
+/// , на которую ссылается код-дефолт свойства
+/// . Значение из конфигурации (Cookies__Days) при необходимости перекрывает его.
+///
+///
+public sealed class CookieOptions
+{
+ ///
+ /// Имя куки (Ruling 6: deal_session).
+ ///
+ public string Name { get; set; } = "deal_session";
+
+ ///
+ /// Срок жизни куки в днях; совпадает со сроком жизни сессии (Ruling 6).
+ ///
+ public int Days { get; set; } = AuthService.SessionLifetimeDays;
+
+ ///
+ /// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 6).
+ ///
+ public bool Secure { get; set; }
+}
diff --git a/src/core/Deal.Api/Configuration/DataRetentionOptions.cs b/src/core/Deal.Api/Configuration/DataRetentionOptions.cs
index a0b849a..66faf83 100644
--- a/src/core/Deal.Api/Configuration/DataRetentionOptions.cs
+++ b/src/core/Deal.Api/Configuration/DataRetentionOptions.cs
@@ -1,6 +1,3 @@
-using Deal.Api.Services;
-using Deal.Api.Dtos;
-
namespace Deal.Api.Configuration;
///
diff --git a/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs b/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs
index 1a4ea7a..deeb96e 100644
--- a/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs
+++ b/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs
@@ -1,42 +1,39 @@
-using Deal.Api.Services;
-using Deal.Api.Dtos;
-
-namespace Deal.Api.Configuration;
-
-///
-/// Настройки доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11 к Ruling 5/10): секция
-/// ForwardedHeaders конфигурации (appsettings.json + переменные окружения ForwardedHeaders__*).
-///
-///
-/// В PROD наружу смотрит только Caddy (compose-prod, Task 14), core принимает соединения от него:
-/// без обработки X-Forwarded-For/X-Forwarded-Proto RemoteIpAddress (HttpContext.Connection)
-/// всех запросов — адрес Caddy, и audit-IP (ClientIp эндпоинтов, Ruling 4) и rate-limit-по-IP
-/// (политики Ruling 5, LoginAttemptGuard) схлопываются в один бакет прокси. UseForwardedHeaders
-/// доверяет заголовкам только клиентов из (IP-адреса) и
-/// (подсети CIDR).
-///
-/// Enabled=false — код-дефолт и значение dev/тестов: прокси в dev-стеке нет (compose.dev —
-/// core наружу напрямую :5080), curl-приёмки от заголовков не зависят. PROD включает env
-/// (ForwardedHeaders__Enabled=true) и перечисляет Caddy: KnownProxies (его IP) либо KnownNetworks
-/// (узкий CIDR compose-сети). Пустые KnownProxies/KnownNetworks у ForwardedHeadersMiddleware означают
-/// «доверять любому клиенту» — Program.BuildForwardedHeadersOptions не допускает пустоту и добавляет
-/// loopback-фолбэк (dev-прокси на хосте: vite/локальный Caddy); явное перечисление в конфиге замещает его.
-///
-///
-public sealed class ForwardedHeadersConfig
-{
- ///
- /// Включена ли обработка прокси-заголовков (dev/тесты — false; PROD за Caddy — true).
- ///
- public bool Enabled { get; set; }
-
- ///
- /// Доверенные прокси-адреса: IP клиентов, которым можно верить в X-Forwarded-For/Proto.
- ///
- public string[] KnownProxies { get; set; } = [];
-
- ///
- /// Доверенные подсети прокси в CIDR (например "172.16.0.0/12" — compose-сеть PROD).
- ///
- public string[] KnownNetworks { get; set; } = [];
-}
+namespace Deal.Api.Configuration;
+
+///
+/// Настройки доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11 к Ruling 5/10): секция
+/// ForwardedHeaders конфигурации (appsettings.json + переменные окружения ForwardedHeaders__*).
+///
+///
+/// В PROD наружу смотрит только Caddy (compose-prod, Task 14), core принимает соединения от него:
+/// без обработки X-Forwarded-For/X-Forwarded-Proto RemoteIpAddress (HttpContext.Connection)
+/// всех запросов — адрес Caddy, и audit-IP (ClientIp эндпоинтов, Ruling 4) и rate-limit-по-IP
+/// (политики Ruling 5, LoginAttemptGuard) схлопываются в один бакет прокси. UseForwardedHeaders
+/// доверяет заголовкам только клиентов из (IP-адреса) и
+/// (подсети CIDR).
+///
+/// Enabled=false — код-дефолт и значение dev/тестов: прокси в dev-стеке нет (compose.dev —
+/// core наружу напрямую :5080), curl-приёмки от заголовков не зависят. PROD включает env
+/// (ForwardedHeaders__Enabled=true) и перечисляет Caddy: KnownProxies (его IP) либо KnownNetworks
+/// (узкий CIDR compose-сети). Пустые KnownProxies/KnownNetworks у ForwardedHeadersMiddleware означают
+/// «доверять любому клиенту» — Program.BuildForwardedHeadersOptions не допускает пустоту и добавляет
+/// loopback-фолбэк (dev-прокси на хосте: vite/локальный Caddy); явное перечисление в конфиге замещает его.
+///
+///
+public sealed class ForwardedHeadersConfig
+{
+ ///
+ /// Включена ли обработка прокси-заголовков (dev/тесты — false; PROD за Caddy — true).
+ ///
+ public bool Enabled { get; set; }
+
+ ///
+ /// Доверенные прокси-адреса: IP клиентов, которым можно верить в X-Forwarded-For/Proto.
+ ///
+ public string[] KnownProxies { get; set; } = [];
+
+ ///
+ /// Доверенные подсети прокси в CIDR (например "172.16.0.0/12" — compose-сеть PROD).
+ ///
+ public string[] KnownNetworks { get; set; } = [];
+}
diff --git a/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs b/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs
index 0828b95..0524791 100644
--- a/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs
+++ b/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs
@@ -1,41 +1,35 @@
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.Modules.Tenants.Application.Extensions;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.Modules.Tenants.Application.Registrars;
-using Deal.Modules.Tenants.Application.Services;
-using Deal.Api.Services;
-using Deal.Api.Dtos;
-
-namespace Deal.Api.Configuration;
-
-///
-/// Настройки httpOnly-куки сессии оператора. Привязываются из секции "OperatorCookies" конфигурации (IOptions).
-///
-///
-/// Источник значений — конфигурация: секция OperatorCookies в appsettings.json и переменные
-/// окружения OperatorCookies__* (имя, срок, Secure). Имя по умолчанию — deal_operator_session:
-/// отдельная от тенантной deal_session кука (Ruling 1 этапа 7) — операторская сессия не может быть
-/// подменена тенантной и наоборот (сессии разрешаются разными middleware).
-///
-/// Срок жизни по умолчанию — единый источник числа «12 часов»: константа модуля
-/// , на которую ссылается код-дефолт свойства
-/// . Значение из конфигурации (OperatorCookies__Hours) при необходимости перекрывает его.
-///
-///
-public sealed class OperatorCookieOptions
-{
- ///
- /// Имя куки (Ruling 1: deal_operator_session).
- ///
- public string Name { get; set; } = "deal_operator_session";
-
- ///
- /// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора (Ruling 1: 12).
- ///
- public int Hours { get; set; } = OperatorAuthService.SessionLifetimeHours;
-
- ///
- /// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 1).
- ///
- public bool Secure { get; set; }
-}
+using Deal.Modules.Tenants.Application.Services;
+
+namespace Deal.Api.Configuration;
+
+///
+/// Настройки httpOnly-куки сессии оператора. Привязываются из секции "OperatorCookies" конфигурации (IOptions).
+///
+///
+/// Источник значений — конфигурация: секция OperatorCookies в appsettings.json и переменные
+/// окружения OperatorCookies__* (имя, срок, Secure). Имя по умолчанию — deal_operator_session:
+/// отдельная от тенантной deal_session кука (Ruling 1 этапа 7) — операторская сессия не может быть
+/// подменена тенантной и наоборот (сессии разрешаются разными middleware).
+///
+/// Срок жизни по умолчанию — единый источник числа «12 часов»: константа модуля
+/// , на которую ссылается код-дефолт свойства
+/// . Значение из конфигурации (OperatorCookies__Hours) при необходимости перекрывает его.
+///
+///
+public sealed class OperatorCookieOptions
+{
+ ///
+ /// Имя куки (Ruling 1: deal_operator_session).
+ ///
+ public string Name { get; set; } = "deal_operator_session";
+
+ ///
+ /// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора (Ruling 1: 12).
+ ///
+ public int Hours { get; set; } = OperatorAuthService.SessionLifetimeHours;
+
+ ///
+ /// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 1).
+ ///
+ public bool Secure { get; set; }
+}
diff --git a/src/core/Deal.Api/Configuration/RateLimitOptions.cs b/src/core/Deal.Api/Configuration/RateLimitOptions.cs
index 9ca2b77..0970c26 100644
--- a/src/core/Deal.Api/Configuration/RateLimitOptions.cs
+++ b/src/core/Deal.Api/Configuration/RateLimitOptions.cs
@@ -1,46 +1,43 @@
-using Deal.Api.Services;
-using Deal.Api.Dtos;
-
-namespace Deal.Api.Configuration;
-
-///
-/// Настройки rate limiting и защиты входа (план Task 11, Ruling 5): секция RateLimit конфигурации
-/// (appsettings.json + переменные окружения RateLimit__*).
-///
-///
-/// Enabled=false — код-дефолт и значение dev/тестов: политики и middleware не регистрируются вовсе,
-/// curl-приёмки и unit-хосты не режутся. PROD включает env-переопределением (RateLimit__Enabled=true
-/// в compose-prod, Task 14). Все окна политик — фиксированные, 1 минута (имена свойств — «PerMinute»).
-///
-public sealed class RateLimitOptions
-{
- ///
- /// Включены ли rate limiting и LoginAttemptGuard (dev/тесты — false, Ruling 5).
- ///
- public bool Enabled { get; set; }
-
- ///
- /// Лимит политики "auth" (фиксированное окно в минуту на IP) для /api/auth/login и /api/operator/auth/login.
- ///
- public int AuthPerMinute { get; set; } = 10;
-
- ///
- /// Лимит политики "api" (в минуту на тенанта либо IP анонима) для остальных /api-эндпоинтов.
- ///
- public int ApiPerMinute { get; set; } = 600;
-
- ///
- /// Лимит gRPC-ингресса (в минуту на tenant-id из metadata; интерцептор IngressRateLimitInterceptor).
- ///
- public int GrpcIngressPerMinute { get; set; } = 600;
-
- ///
- /// Порог неудачных попыток входа ключа ip|login до блокировки (LoginAttemptGuard).
- ///
- public int LoginAttemptsMax { get; set; } = 5;
-
- ///
- /// Окно учёта неудачных попыток входа в минутах (LoginAttemptGuard; текст 429 — фиксированный «15 минут»).
- ///
- public int LoginAttemptWindowMin { get; set; } = 15;
-}
+namespace Deal.Api.Configuration;
+
+///
+/// Настройки rate limiting и защиты входа (план Task 11, Ruling 5): секция RateLimit конфигурации
+/// (appsettings.json + переменные окружения RateLimit__*).
+///
+///
+/// Enabled=false — код-дефолт и значение dev/тестов: политики и middleware не регистрируются вовсе,
+/// curl-приёмки и unit-хосты не режутся. PROD включает env-переопределением (RateLimit__Enabled=true
+/// в compose-prod, Task 14). Все окна политик — фиксированные, 1 минута (имена свойств — «PerMinute»).
+///
+public sealed class RateLimitOptions
+{
+ ///
+ /// Включены ли rate limiting и LoginAttemptGuard (dev/тесты — false, Ruling 5).
+ ///
+ public bool Enabled { get; set; }
+
+ ///
+ /// Лимит политики "auth" (фиксированное окно в минуту на IP) для /api/auth/login и /api/operator/auth/login.
+ ///
+ public int AuthPerMinute { get; set; } = 10;
+
+ ///
+ /// Лимит политики "api" (в минуту на тенанта либо IP анонима) для остальных /api-эндпоинтов.
+ ///
+ public int ApiPerMinute { get; set; } = 600;
+
+ ///
+ /// Лимит gRPC-ингресса (в минуту на tenant-id из metadata; интерцептор IngressRateLimitInterceptor).
+ ///
+ public int GrpcIngressPerMinute { get; set; } = 600;
+
+ ///
+ /// Порог неудачных попыток входа ключа ip|login до блокировки (LoginAttemptGuard).
+ ///
+ public int LoginAttemptsMax { get; set; } = 5;
+
+ ///
+ /// Окно учёта неудачных попыток входа в минутах (LoginAttemptGuard; текст 429 — фиксированный «15 минут»).
+ ///
+ public int LoginAttemptWindowMin { get; set; } = 15;
+}
diff --git a/src/core/Deal.Api/Configuration/SecurityOptions.cs b/src/core/Deal.Api/Configuration/SecurityOptions.cs
index a391004..7f20ba6 100644
--- a/src/core/Deal.Api/Configuration/SecurityOptions.cs
+++ b/src/core/Deal.Api/Configuration/SecurityOptions.cs
@@ -1,28 +1,25 @@
-using Deal.Api.Services;
-using Deal.Api.Dtos;
-
-namespace Deal.Api.Configuration;
-
-///
-/// Настройки безопасности HTTP (план Task 12, Ruling 10(2)/9): секция Security конфигурации
-/// (appsettings.json + переменные окружения Security__*).
-///
-///
-/// — единый явный allowlist для Origin-проверки мутаций
-/// () и CORS-политики. Пустой список — dev-режим:
-/// OriginGuard принимает только «свой» origin запроса (схема + Host, для не-GET запросов /api),
-/// CORS разрешает любой origin (текущее поведение «как в прототипе»). Непустой список (PROD,
-/// compose-prod, Ruling 9) — CORS становится строгим allowlist + credentials; OriginGuard дополнительно
-/// принимает перечисленные origin'ы (в т.ч. когда запрос идёт не от «своего» Host — фронт за прокси).
-///
-/// Записи — полные origin'ы в том виде, в каком их шлёт браузер: схема://хост[:порт], без завершающего
-/// слэша (например https://deal.example, http://localhost:5173). Сравнение регистронезависимо.
-///
-///
-public sealed class SecurityOptions
-{
- ///
- /// Явный allowlist Origin/CORS (схема://хост[:порт]); пусто — dev-режим «любой origin + свой Host».
- ///
- public string[] AllowedOrigins { get; set; } = [];
-}
+namespace Deal.Api.Configuration;
+
+///
+/// Настройки безопасности HTTP (план Task 12, Ruling 10(2)/9): секция Security конфигурации
+/// (appsettings.json + переменные окружения Security__*).
+///
+///
+/// — единый явный allowlist для Origin-проверки мутаций
+/// () и CORS-политики. Пустой список — dev-режим:
+/// OriginGuard принимает только «свой» origin запроса (схема + Host, для не-GET запросов /api),
+/// CORS разрешает любой origin (текущее поведение «как в прототипе»). Непустой список (PROD,
+/// compose-prod, Ruling 9) — CORS становится строгим allowlist + credentials; OriginGuard дополнительно
+/// принимает перечисленные origin'ы (в т.ч. когда запрос идёт не от «своего» Host — фронт за прокси).
+///
+/// Записи — полные origin'ы в том виде, в каком их шлёт браузер: схема://хост[:порт], без завершающего
+/// слэша (например https://deal.example, http://localhost:5173). Сравнение регистронезависимо.
+///
+///
+public sealed class SecurityOptions
+{
+ ///
+ /// Явный allowlist Origin/CORS (схема://хост[:порт]); пусто — dev-режим «любой origin + свой Host».
+ ///
+ public string[] AllowedOrigins { get; set; } = [];
+}
diff --git a/src/core/Deal.Api/Dtos/AdminTickResultDto.cs b/src/core/Deal.Api/Dtos/AdminTickResultDto.cs
index 962b492..09db2e5 100644
--- a/src/core/Deal.Api/Dtos/AdminTickResultDto.cs
+++ b/src/core/Deal.Api/Dtos/AdminTickResultDto.cs
@@ -1,29 +1,26 @@
-using Deal.Modules.Kanban.Application.Models;
-using Deal.Api.Services;
-using Deal.Api;
-using Deal.Api.Dtos;
-
-namespace Deal.Api.Dtos;
-
-///
-/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue} (dashboard_routes.py admin_tick L327–337, план Task 10).
-///
-///
-/// Поля 1:1 с прототипом: storage — статистика тика правил хранения с очисткой отсева
-/// (, purgedRejected объединяет purge пайплайна — Ruling 9, tick_storage
-/// L485–493); reminders — «выстрелившие» напоминания «Отложено» этапа 5 (план Task 11, Ruling 3/8): те же
-/// записи {id,title,containerId}, что ушли SSE-событиями reminder_due (check_reminders admin_tick L334/L337),
-/// пусто — сработавших нет либо проверка недоступна; pipeline — счётчики одного прохода pump (ключи словаря
-/// python L921: staged/rulesStored/mlStored/mlDrop/typeDrop/aiStored/aiDrop/aiFail/noBudget; пусто — pump не
-/// выполнялся/сбой, как {} при занятом локе прототипа); queue — число строк очереди входящих после pump
-/// (queue_len L337). Наружу сериализуется в camelCase (storage/reminders/pipeline/queue).
-///
-/// Статистика тика правил хранения (включая purgedRejected — очистку отсева 3 суток).
-/// «Выстрелившие» напоминания {id,title,containerId} — список SSE reminder_due тика.
-/// Счётчики pump: словарь ключей прототипа; пуст, если pump не дал результата.
-/// Строк очереди входящих после прохода pump (queue_len).
-public sealed record AdminTickResultDto(
- StorageTickStatsDto Storage,
- IReadOnlyList Reminders,
- IReadOnlyDictionary Pipeline,
- int Queue);
+using Deal.Modules.Kanban.Application.Models;
+
+namespace Deal.Api.Dtos;
+
+///
+/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue} (dashboard_routes.py admin_tick L327–337, план Task 10).
+///
+///
+/// Поля 1:1 с прототипом: storage — статистика тика правил хранения с очисткой отсева
+/// (, purgedRejected объединяет purge пайплайна — Ruling 9, tick_storage
+/// L485–493); reminders — «выстрелившие» напоминания «Отложено» этапа 5 (план Task 11, Ruling 3/8): те же
+/// записи {id,title,containerId}, что ушли SSE-событиями reminder_due (check_reminders admin_tick L334/L337),
+/// пусто — сработавших нет либо проверка недоступна; pipeline — счётчики одного прохода pump (ключи словаря
+/// python L921: staged/rulesStored/mlStored/mlDrop/typeDrop/aiStored/aiDrop/aiFail/noBudget; пусто — pump не
+/// выполнялся/сбой, как {} при занятом локе прототипа); queue — число строк очереди входящих после pump
+/// (queue_len L337). Наружу сериализуется в camelCase (storage/reminders/pipeline/queue).
+///
+/// Статистика тика правил хранения (включая purgedRejected — очистку отсева 3 суток).
+/// «Выстрелившие» напоминания {id,title,containerId} — список SSE reminder_due тика.
+/// Счётчики pump: словарь ключей прототипа; пуст, если pump не дал результата.
+/// Строк очереди входящих после прохода pump (queue_len).
+public sealed record AdminTickResultDto(
+ StorageTickStatsDto Storage,
+ IReadOnlyList Reminders,
+ IReadOnlyDictionary Pipeline,
+ int Queue);
diff --git a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs
index 72646f2..b2ead6f 100644
--- a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs
+++ b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs
@@ -1,176 +1,172 @@
-using System.Text.Json;
-using Deal.Api.Extensions;
-using Deal.Api.Models;
-using Deal.Api.Services;
-using Deal.Modules.Settings.Application.Abstractions;
-using Deal.Modules.Settings.Application.Models;
-using Deal.Modules.Settings.Application.Registrars;
-using Deal.Modules.Settings.Application.Services;
-using Deal.Api.Dtos;
-
-namespace Deal.Api.Endpoints;
-
-///
-/// Эндпоинт проверки подключения AI-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10).
-///
-///
-/// «Только для Settings-экрана» (Ruling 8): фронт жмёт «Проверить подключение» (store.js
-/// checkAiConnection) БЕЗ тела — сервер читает АКТИВНУЮ конфигурацию провайдера тенанта
-/// (настройки aiProvider + aiConfigs с расшифровкой ключа через ;
-/// 1:1 с ai_svc._cfg(), ai.py L25–33), вызывает порт и отдаёт
-/// {ok, message} + статус провайдера. Требует сессию: 401 {detail} (формат прототипа).
-/// Резолв scoped-зависимостей — через RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints:
-/// DI-биндинг параметров выполняется до тела, а ISettingsStore требует tenant-контекст запроса).
-///
-public static class AiCheckEndpoint
-{
- // Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
- private const string ApiGroupPrefix = "/api";
-
- // Путь проверки подключения AI-провайдера.
- private const string AiCheckPath = "/ai/check";
-
- // OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
- private const string OpenApiTag = "settings";
-
- ///
- /// Регистрирует POST /api/ai/check.
- ///
- /// Построитель маршрутов приложения.
- /// Построитель маршрутов для цепочки вызовов.
- public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app)
- {
- var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
- group.MapPost(AiCheckPath, CheckAsync);
- return app;
- }
-
- // POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта.
- private static async Task CheckAsync(HttpContext context, CancellationToken ct)
- {
- if (context.GetCurrentUser() is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- // Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса).
- ISettingsStore store = context.RequestServices.GetRequiredService();
- ISecretCipher secretCipher = context.RequestServices.GetRequiredService();
- IAiConnectionChecker checker = context.RequestServices.GetRequiredService();
-
- AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct);
- AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct);
- return Results.Ok(result);
- }
-
- // Собирает запрос проверки из активной конфигурации провайдера (1:1 с ai_svc._cfg()).
- // store: KV-хранилище настроек тенанта.
- // secretCipher: Шифр секретов (расшифровка apiKey).
- // ct: Токен отмены.
- // Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ.
- // Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными
- // переопределениями (Ruling 1); пустое переопределение base/model → дефолт каталога
- // (семантика «cfg.get(...) or meta[...]» прототипа).
- private static async Task BuildActiveCheckRequestAsync(
- ISettingsStore store,
- ISecretCipher secretCipher,
- CancellationToken ct)
- {
- string providerId = await ReadActiveProviderIdAsync(store, ct);
- AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId);
-
- // Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить):
- // HTTP не выполняется — ответит SSRF-гейт checker (allowlist).
- if (meta is null)
- {
- return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null);
- }
-
- AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct);
- string apiKey = secretCipher.Decrypt(config.ApiKey);
- string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl;
- string model = string.IsNullOrEmpty(config.Model)
- ? meta.Models.FirstOrDefault() ?? string.Empty
- : config.Model;
-
- return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle);
- }
-
- // Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт).
- // store: KV-хранилище настроек тенанта.
- // ct: Токен отмены.
- // Возвращает: id провайдера.
- private static async Task ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct)
- {
- SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct);
- if (row is null)
- {
- return SettingsDefaults.AiProvider;
- }
-
- try
- {
- using JsonDocument document = JsonDocument.Parse(row.ValueJson);
- if (document.RootElement.ValueKind == JsonValueKind.String)
- {
- return document.RootElement.GetString() ?? SettingsDefaults.AiProvider;
- }
- }
- catch (JsonException)
- {
- // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
- }
-
- return SettingsDefaults.AiProvider;
- }
-
- // Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs.
- // store: KV-хранилище настроек тенанта.
- // providerId: Активный провайдер (id из каталога).
- // ct: Токен отмены.
- // Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт.
- private static async Task ReadEffectiveConfigAsync(
- ISettingsStore store,
- string providerId,
- CancellationToken ct)
- {
- AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId];
- SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct);
- if (row is null)
- {
- return defaults;
- }
-
- try
- {
- using JsonDocument document = JsonDocument.Parse(row.ValueJson);
- JsonElement root = document.RootElement;
- if (root.ValueKind == JsonValueKind.Object
- && root.TryGetProperty(providerId, out JsonElement entry)
- && entry.ValueKind == JsonValueKind.Object)
- {
- return new AiConfigSetting(
- ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey,
- BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl,
- Model: ReadField(entry, "model") ?? defaults.Model);
- }
- }
- catch (JsonException)
- {
- // Повреждённая строка — дефолт (не роняем проверку).
- }
-
- return defaults;
- }
-
- // Читает строковое поле объекта конфигурации (имена полей camelCase, как пишет SettingsService).
- // entry: JSON-объект конфигурации провайдера.
- // field: Имя поля (apiKey/baseUrl/model).
- // Возвращает: Значение или null, если поле отсутствует/не строка.
- private static string? ReadField(JsonElement entry, string field)
- {
- return entry.TryGetProperty(field, out JsonElement value) && value.ValueKind == JsonValueKind.String
- ? value.GetString()
- : null;
- }
-}
+using System.Text.Json;
+using Deal.Api.Extensions;
+using Deal.Api.Services;
+using Deal.Modules.Settings.Application.Abstractions;
+using Deal.Modules.Settings.Application.Models;
+
+namespace Deal.Api.Endpoints;
+
+///
+/// Эндпоинт проверки подключения AI-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10).
+///
+///
+/// «Только для Settings-экрана» (Ruling 8): фронт жмёт «Проверить подключение» (store.js
+/// checkAiConnection) БЕЗ тела — сервер читает АКТИВНУЮ конфигурацию провайдера тенанта
+/// (настройки aiProvider + aiConfigs с расшифровкой ключа через ;
+/// 1:1 с ai_svc._cfg(), ai.py L25–33), вызывает порт и отдаёт
+/// {ok, message} + статус провайдера. Требует сессию: 401 {detail} (формат прототипа).
+/// Резолв scoped-зависимостей — через RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints:
+/// DI-биндинг параметров выполняется до тела, а ISettingsStore требует tenant-контекст запроса).
+///
+public static class AiCheckEndpoint
+{
+ // Префикс группы API (общий для эндпоинтов этапа, Ruling 8).
+ private const string ApiGroupPrefix = "/api";
+
+ // Путь проверки подключения AI-провайдера.
+ private const string AiCheckPath = "/ai/check";
+
+ // OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py).
+ private const string OpenApiTag = "settings";
+
+ ///
+ /// Регистрирует POST /api/ai/check.
+ ///
+ /// Построитель маршрутов приложения.
+ /// Построитель маршрутов для цепочки вызовов.
+ public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag);
+ group.MapPost(AiCheckPath, CheckAsync);
+ return app;
+ }
+
+ // POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта.
+ private static async Task CheckAsync(HttpContext context, CancellationToken ct)
+ {
+ if (context.GetCurrentUser() is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
+ }
+
+ // Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса).
+ ISettingsStore store = context.RequestServices.GetRequiredService();
+ ISecretCipher secretCipher = context.RequestServices.GetRequiredService();
+ IAiConnectionChecker checker = context.RequestServices.GetRequiredService();
+
+ AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct);
+ AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct);
+ return Results.Ok(result);
+ }
+
+ // Собирает запрос проверки из активной конфигурации провайдера (1:1 с ai_svc._cfg()).
+ // store: KV-хранилище настроек тенанта.
+ // secretCipher: Шифр секретов (расшифровка apiKey).
+ // ct: Токен отмены.
+ // Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ.
+ // Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными
+ // переопределениями (Ruling 1); пустое переопределение base/model → дефолт каталога
+ // (семантика «cfg.get(...) or meta[...]» прототипа).
+ private static async Task BuildActiveCheckRequestAsync(
+ ISettingsStore store,
+ ISecretCipher secretCipher,
+ CancellationToken ct)
+ {
+ string providerId = await ReadActiveProviderIdAsync(store, ct);
+ AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId);
+
+ // Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить):
+ // HTTP не выполняется — ответит SSRF-гейт checker (allowlist).
+ if (meta is null)
+ {
+ return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null);
+ }
+
+ AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct);
+ string apiKey = secretCipher.Decrypt(config.ApiKey);
+ string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl;
+ string model = string.IsNullOrEmpty(config.Model)
+ ? meta.Models.FirstOrDefault() ?? string.Empty
+ : config.Model;
+
+ return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle);
+ }
+
+ // Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт).
+ // store: KV-хранилище настроек тенанта.
+ // ct: Токен отмены.
+ // Возвращает: id провайдера.
+ private static async Task ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct)
+ {
+ SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct);
+ if (row is null)
+ {
+ return SettingsDefaults.AiProvider;
+ }
+
+ try
+ {
+ using JsonDocument document = JsonDocument.Parse(row.ValueJson);
+ if (document.RootElement.ValueKind == JsonValueKind.String)
+ {
+ return document.RootElement.GetString() ?? SettingsDefaults.AiProvider;
+ }
+ }
+ catch (JsonException)
+ {
+ // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
+ }
+
+ return SettingsDefaults.AiProvider;
+ }
+
+ // Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs.
+ // store: KV-хранилище настроек тенанта.
+ // providerId: Активный провайдер (id из каталога).
+ // ct: Токен отмены.
+ // Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт.
+ private static async Task ReadEffectiveConfigAsync(
+ ISettingsStore store,
+ string providerId,
+ CancellationToken ct)
+ {
+ AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId];
+ SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct);
+ if (row is null)
+ {
+ return defaults;
+ }
+
+ try
+ {
+ using JsonDocument document = JsonDocument.Parse(row.ValueJson);
+ JsonElement root = document.RootElement;
+ if (root.ValueKind == JsonValueKind.Object
+ && root.TryGetProperty(providerId, out JsonElement entry)
+ && entry.ValueKind == JsonValueKind.Object)
+ {
+ return new AiConfigSetting(
+ ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey,
+ BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl,
+ Model: ReadField(entry, "model") ?? defaults.Model);
+ }
+ }
+ catch (JsonException)
+ {
+ // Повреждённая строка — дефолт (не роняем проверку).
+ }
+
+ return defaults;
+ }
+
+ // Читает строковое поле объекта конфигурации (имена полей camelCase, как пишет SettingsService).
+ // entry: JSON-объект конфигурации провайдера.
+ // field: Имя поля (apiKey/baseUrl/model).
+ // Возвращает: Значение или null, если поле отсутствует/не строка.
+ private static string? ReadField(JsonElement entry, string field)
+ {
+ return entry.TryGetProperty(field, out JsonElement value) && value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+ }
+}
diff --git a/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs b/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs
index 85e6b21..b172654 100644
--- a/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs
@@ -1,99 +1,97 @@
-using Deal.Api.Events;
-using Deal.Api.Extensions;
-using Deal.Api.Models;
-using Deal.Api.Services;
-using Deal.Contracts.Integrations.Abstractions;
-using Deal.Contracts.Integrations.Models;
-using Deal.Api.Dtos;
-
-namespace Deal.Api.Endpoints;
-
-///
-/// Эндпоинты ИИ-предложений: POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords
-/// (план Task 14 L476–479; прототип dashboard_routes.py L395–409).
-///
-///
-/// Контракт 1:1 с прототипом и api-map §3.2 L120–121: suggest-columns → {ok:true, created:N}
-/// либо {ok:false, reason, cooldown?}; suggest-keywords → {ok:true, keywords:[…]} либо
-/// {ok:false, reason}. Причины — мягкие ошибки (HTTP 200 с ok:false + reason), статусы 4xx/5xx
-/// не мапятся. Оба требуют сессию: 401 {detail} без куки (как остальные эндпоинты контейнеров); порт
-/// IColumnSuggester резолвится из RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст
-/// запроса). При успехе suggest-columns публикуется SSE-toast «ИИ предложил колонок: N — откройте и
-/// решите» (sparkles, 1:1 с suggest.py L162); boards_changed НЕ шлём (Ruling 5: фронт перечитывает
-/// доски сам после ok). Публикации — из эндпоинта (Ruling 5): без подписчиков — no-op.
-///
-public static class AiSuggestEndpoints
-{
- // Префикс группы AI-эндпоинтов этапа (роутер dashboard, prefix="/api"; пути L395/L406).
- private const string AiGroupPrefix = "/api/ai";
-
- // Путь предложения колонок (dashboard_routes.py L395).
- private const string SuggestColumnsPath = "/suggest-columns";
-
- // Путь предложения ключевых слов (dashboard_routes.py L404).
- private const string SuggestKeywordsPath = "/suggest-keywords";
-
- // OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
- private const string OpenApiTag = "dashboard";
-
- // SSE-тип события тоста (Ruling 5; api.js слушает 'toast').
- private const string ToastEventType = "toast";
-
- // Текст тоста после успешных предложений колонок (suggest.py L162, 1:1).
- private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите";
-
- // Иконка тоста предложений колонок (sparkles, 1:1 с прототипом).
- private const string SparklesIcon = "sparkles";
-
- ///
- /// Регистрирует POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords.
- ///
- /// Построитель маршрутов приложения.
- /// Построитель маршрутов для цепочки вызовов.
- public static IEndpointRouteBuilder MapAiSuggestEndpoints(this IEndpointRouteBuilder app)
- {
- var group = app.MapGroup(AiGroupPrefix).WithTags(OpenApiTag);
- group.MapPost(SuggestColumnsPath, SuggestColumnsAsync);
- group.MapPost(SuggestKeywordsPath, SuggestKeywordsAsync);
- return app;
- }
-
- // POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений.
- // Ответ — результат порта 1:1: {ok:true, created:N} — доски suggested=true созданы (эндпоинт шлёт
- // SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/
- // «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3).
- private static async Task SuggestColumnsAsync(HttpContext context, CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- IColumnSuggester suggester = context.RequestServices.GetRequiredService();
- SuggestColumnsResultDto result = await suggester.SuggestColumnsAsync(ct);
- if (result.Ok)
- {
- SseBroker broker = context.RequestServices.GetRequiredService();
- broker.Publish(
- context.GetCurrentUser()!.TenantId,
- ToastEventType,
- new { text = string.Format(SuggestToastTextFormat, result.Created), icon = SparklesIcon });
- }
-
- return Results.Ok(result);
- }
-
- // POST /api/ai/suggest-keywords: слова-маркеры сферы по карточкам (настройки «Сфера и ключи»).
- // Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200).
- private static async Task SuggestKeywordsAsync(HttpContext context, CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- IColumnSuggester suggester = context.RequestServices.GetRequiredService();
- SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(ct);
- return Results.Ok(result);
- }
-}
+using Deal.Api.Events;
+using Deal.Api.Extensions;
+using Deal.Api.Services;
+using Deal.Contracts.Integrations.Abstractions;
+using Deal.Contracts.Integrations.Models;
+
+namespace Deal.Api.Endpoints;
+
+///
+/// Эндпоинты ИИ-предложений: POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords
+/// (план Task 14 L476–479; прототип dashboard_routes.py L395–409).
+///
+///
+/// Контракт 1:1 с прототипом и api-map §3.2 L120–121: suggest-columns → {ok:true, created:N}
+/// либо {ok:false, reason, cooldown?}; suggest-keywords → {ok:true, keywords:[…]} либо
+/// {ok:false, reason}. Причины — мягкие ошибки (HTTP 200 с ok:false + reason), статусы 4xx/5xx
+/// не мапятся. Оба требуют сессию: 401 {detail} без куки (как остальные эндпоинты контейнеров); порт
+/// IColumnSuggester резолвится из RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст
+/// запроса). При успехе suggest-columns публикуется SSE-toast «ИИ предложил колонок: N — откройте и
+/// решите» (sparkles, 1:1 с suggest.py L162); boards_changed НЕ шлём (Ruling 5: фронт перечитывает
+/// доски сам после ok). Публикации — из эндпоинта (Ruling 5): без подписчиков — no-op.
+///
+public static class AiSuggestEndpoints
+{
+ // Префикс группы AI-эндпоинтов этапа (роутер dashboard, prefix="/api"; пути L395/L406).
+ private const string AiGroupPrefix = "/api/ai";
+
+ // Путь предложения колонок (dashboard_routes.py L395).
+ private const string SuggestColumnsPath = "/suggest-columns";
+
+ // Путь предложения ключевых слов (dashboard_routes.py L404).
+ private const string SuggestKeywordsPath = "/suggest-keywords";
+
+ // OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
+ private const string OpenApiTag = "dashboard";
+
+ // SSE-тип события тоста (Ruling 5; api.js слушает 'toast').
+ private const string ToastEventType = "toast";
+
+ // Текст тоста после успешных предложений колонок (suggest.py L162, 1:1).
+ private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите";
+
+ // Иконка тоста предложений колонок (sparkles, 1:1 с прототипом).
+ private const string SparklesIcon = "sparkles";
+
+ ///
+ /// Регистрирует POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords.
+ ///
+ /// Построитель маршрутов приложения.
+ /// Построитель маршрутов для цепочки вызовов.
+ public static IEndpointRouteBuilder MapAiSuggestEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup(AiGroupPrefix).WithTags(OpenApiTag);
+ group.MapPost(SuggestColumnsPath, SuggestColumnsAsync);
+ group.MapPost(SuggestKeywordsPath, SuggestKeywordsAsync);
+ return app;
+ }
+
+ // POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений.
+ // Ответ — результат порта 1:1: {ok:true, created:N} — доски suggested=true созданы (эндпоинт шлёт
+ // SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/
+ // «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3).
+ private static async Task SuggestColumnsAsync(HttpContext context, CancellationToken ct)
+ {
+ if (!context.HasUser())
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
+ }
+
+ IColumnSuggester suggester = context.RequestServices.GetRequiredService();
+ SuggestColumnsResultDto result = await suggester.SuggestColumnsAsync(ct);
+ if (result.Ok)
+ {
+ SseBroker broker = context.RequestServices.GetRequiredService();
+ broker.Publish(
+ context.GetCurrentUser()!.TenantId,
+ ToastEventType,
+ new { text = string.Format(SuggestToastTextFormat, result.Created), icon = SparklesIcon });
+ }
+
+ return Results.Ok(result);
+ }
+
+ // POST /api/ai/suggest-keywords: слова-маркеры сферы по карточкам (настройки «Сфера и ключи»).
+ // Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200).
+ private static async Task SuggestKeywordsAsync(HttpContext context, CancellationToken ct)
+ {
+ if (!context.HasUser())
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
+ }
+
+ IColumnSuggester suggester = context.RequestServices.GetRequiredService();
+ SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(ct);
+ return Results.Ok(result);
+ }
+}
diff --git a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs
index 2dcfa90..0497ed8 100644
--- a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs
@@ -1,217 +1,213 @@
-using Deal.Api.Extensions;
-using Deal.Api.Models;
-using Deal.Api.Services;
-using Deal.Api.Middleware;
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.Modules.Tenants.Application.Extensions;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.Modules.Tenants.Application.Registrars;
-using Deal.Modules.Tenants.Application.Services;
-using Microsoft.Extensions.Options;
-using Deal.Api.Dtos;
-// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
-using CookieOptions = Deal.Api.Configuration.CookieOptions;
-
-namespace Deal.Api.Endpoints;
-
-///
-/// HTTP-эндпоинты аутентификации (группа /api/auth). Контракт 1:1 с прототипом auth_routes.py.
-///
-///
-/// Успех-ответы — {ok:true,...}, ошибки — HTTP-код + {"detail":"..."} (Ruling 10).
-/// Кука сессии выставляется на login и change-password (свежий токен). Сообщения об ошибках —
-/// фиксированные строки прототипа.
-///
-public static class AuthEndpoints
-{
- private const string InvalidCredentialsDetail = "Неверный логин или пароль";
- private const string WrongOldPasswordDetail = "Текущий пароль неверен";
- private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
- private const string TenantSuspendedDetail = "Учётная запись приостановлена. Обратитесь к оператору";
- private const string AuthGroupPrefix = "/api/auth";
- private const string AuthOpenApiTag = "auth";
-
- ///
- /// Регистрирует группу /api/auth: login, logout, me, change-password.
- ///
- /// Построитель маршрутов приложения.
- /// Построитель маршрутов для цепочки вызовов.
- public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
- {
- var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag);
-
- // Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP
- // ручки входа; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
- group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
- group.MapPost("/logout", LogoutAsync);
- group.MapGet("/me", MeAsync);
- group.MapPost("/change-password", ChangePasswordAsync);
-
- return app;
- }
-
- // POST /api/auth/login: проверка учётных данных, выдача куки сессии; результат пишется в аудит (Task 4/7).
- // До AuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
- private static async Task LoginAsync(
- LoginRequest body,
- AuthService authService,
- AuditService auditService,
- IOptions cookieOptions,
- HttpContext context,
- CancellationToken ct,
- LoginAttemptGuard loginAttemptGuard)
- {
- string? attemptedLogin = NormalizeLogin(body.Login);
-
- // Защита входа (план Task 11, Ruling 5): блокировка ключа ip|login до проверки учётных данных —
- // 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен).
- if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
- {
- return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
- }
-
- var result = await authService.LoginAsync(body.Login, body.Password, ct);
-
- // Приостановленный тенант: вход заблокирован (Ruling 10(5)). Отдельный текст от «неверных учётных
- // данных»; tenant_login_failed пишется с tenantId и ActorId (ревью Task 4: failed-логины suspended-тенанта).
- // Решение Task 7: HTTP-код 403 (а не 401) — учётка существует, доступ запрещён; приёмочный текст плана
- // Task 16 формулирует «login 401» — отклонение зафиксировано для api-map/техдок в task-7-report.md.
- if (result.Error == LoginResultDto.ErrorTenantSuspended)
- {
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.TenantLoginFailed,
- AuditActorTypes.Tenant,
- ActorId: result.UserId,
- TenantId: result.TenantId,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
-
- return EndpointResults.Forbidden(TenantSuspendedDetail);
- }
-
- if (result.Login is null || result.Token is null)
- {
- // Пустой/пробельный login и неверные учётные данные — одно сообщение (семантика прототипа).
- // Аудит tenant_login_failed пишем только для реальной попытки (непустой логин), без пароля (Ruling 4);
- // счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика).
- if (attemptedLogin is not null)
- {
- await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.TenantLoginFailed,
- AuditActorTypes.Tenant,
- ActorId: null,
- TenantId: null,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
- }
-
- return EndpointResults.Unauthorized(InvalidCredentialsDetail);
- }
-
- // Успешный вход сбрасывает счётчик неудач ключа ip|login (Ruling 5).
- await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
-
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.TenantLoginOk,
- AuditActorTypes.Tenant,
- ActorId: result.UserId,
- TenantId: result.TenantId,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
-
- SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
- return Results.Ok(new { ok = true, login = result.Login });
- }
-
- // POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok).
- // Если удалённая сессия была impersonation — пишется аудит impersonation_stopped (Task 7, ревью: полный аудит).
- private static async Task LogoutAsync(
- AuthService authService,
- AuditService auditService,
- IOptions cookieOptions,
- HttpContext context,
- CancellationToken ct)
- {
- var cookieName = cookieOptions.Value.Name;
- var rawToken = context.Request.Cookies[cookieName];
- // Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса).
- CurrentUser? user = context.GetCurrentUser();
- var logout = await authService.LogoutAsync(rawToken, ct);
- if (logout is not null)
- {
- // Актор — оператор, начавший impersonation (маркер сессии); тенант — для фильтра TenantId.
- await auditService.AppendAsync(new AuditRecordDto(
- AuditEvents.ImpersonationStopped,
- AuditActorTypes.Operator,
- ActorId: logout.OperatorId,
- TenantId: logout.TenantId,
- Ip: ClientIp(context),
- DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
- }
-
- // Выход пользователя тенанта (этап 10, T1): событие пишется при живой разрешённой сессии.
- if (user is not null)
- {
- await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
- }
-
- context.Response.Cookies.Delete(cookieName);
- return Results.Ok(new { ok = true });
- }
-
- // GET /api/auth/me: проверка живой сессии.
- private static IResult MeAsync(HttpContext context)
- {
- var user = context.GetCurrentUser();
- if (user is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- return Results.Ok(new { login = user.Login, ok = true });
- }
-
- // POST /api/auth/change-password: смена пароля и перевыпуск куки (свежая сессия).
- private static async Task ChangePasswordAsync(
- ChangePasswordRequest body,
- AuthService authService,
- IOptions cookieOptions,
- HttpContext context,
- CancellationToken ct)
- {
- var user = context.GetCurrentUser();
- if (user is null)
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
- if (!result.Ok || result.NewToken is null)
- {
- // Семантика прототипа: код ошибки различает «старый пароль неверен» и «слишком короткий».
- var detail = result.Error == ChangePasswordResultDto.ErrorTooShort
- ? PasswordTooShortDetail
- : WrongOldPasswordDetail;
- return EndpointResults.BadRequest(detail);
- }
-
- // Старые сессии удалены внутри сервиса; выдаём клиенту свежую куку.
- SessionCookieWriter.Append(context, cookieOptions.Value, result.NewToken);
- return Results.Ok(new { ok = true });
- }
-
- // Нормализованная попытка логина для аудита (нижний регистр/обрезка, как AuthService); null — писать нечего.
- // login: Логин из тела запроса.
- // Возвращает: Нормализованный логин или null при пустом/пробельном входе.
- private static string? NormalizeLogin(string? login)
- {
- string? normalized = login?.Trim().ToLowerInvariant();
- return string.IsNullOrEmpty(normalized) ? null : normalized;
- }
-
- // IP-адрес клиента для аудита (без порта; null, если недоступен).
- // context: Контекст запроса.
- // Возвращает: Строковое представление IP или null.
- private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
-}
+using Deal.Api.Extensions;
+using Deal.Api.Middleware;
+using Deal.Api.Models;
+using Deal.Api.Services;
+using Deal.Modules.Tenants.Application.Models;
+using Deal.Modules.Tenants.Application.Services;
+using Microsoft.Extensions.Options;
+// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
+using CookieOptions = Deal.Api.Configuration.CookieOptions;
+
+namespace Deal.Api.Endpoints;
+
+///
+/// HTTP-эндпоинты аутентификации (группа /api/auth). Контракт 1:1 с прототипом auth_routes.py.
+///
+///
+/// Успех-ответы — {ok:true,...}, ошибки — HTTP-код + {"detail":"..."} (Ruling 10).
+/// Кука сессии выставляется на login и change-password (свежий токен). Сообщения об ошибках —
+/// фиксированные строки прототипа.
+///
+public static class AuthEndpoints
+{
+ private const string InvalidCredentialsDetail = "Неверный логин или пароль";
+ private const string WrongOldPasswordDetail = "Текущий пароль неверен";
+ private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)";
+ private const string TenantSuspendedDetail = "Учётная запись приостановлена. Обратитесь к оператору";
+ private const string AuthGroupPrefix = "/api/auth";
+ private const string AuthOpenApiTag = "auth";
+
+ ///
+ /// Регистрирует группу /api/auth: login, logout, me, change-password.
+ ///
+ /// Построитель маршрутов приложения.
+ /// Построитель маршрутов для цепочки вызовов.
+ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag);
+
+ // Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP
+ // ручки входа; остальные ручки группы — под глобальной API-политикой (по тенанту/IP).
+ group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy);
+ group.MapPost("/logout", LogoutAsync);
+ group.MapGet("/me", MeAsync);
+ group.MapPost("/change-password", ChangePasswordAsync);
+
+ return app;
+ }
+
+ // POST /api/auth/login: проверка учётных данных, выдача куки сессии; результат пишется в аудит (Task 4/7).
+ // До AuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5).
+ private static async Task LoginAsync(
+ LoginRequest body,
+ AuthService authService,
+ AuditService auditService,
+ IOptions cookieOptions,
+ HttpContext context,
+ CancellationToken ct,
+ LoginAttemptGuard loginAttemptGuard)
+ {
+ string? attemptedLogin = NormalizeLogin(body.Login);
+
+ // Защита входа (план Task 11, Ruling 5): блокировка ключа ip|login до проверки учётных данных —
+ // 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен).
+ if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
+ {
+ return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
+ }
+
+ var result = await authService.LoginAsync(body.Login, body.Password, ct);
+
+ // Приостановленный тенант: вход заблокирован (Ruling 10(5)). Отдельный текст от «неверных учётных
+ // данных»; tenant_login_failed пишется с tenantId и ActorId (ревью Task 4: failed-логины suspended-тенанта).
+ // Решение Task 7: HTTP-код 403 (а не 401) — учётка существует, доступ запрещён; приёмочный текст плана
+ // Task 16 формулирует «login 401» — отклонение зафиксировано для api-map/техдок в task-7-report.md.
+ if (result.Error == LoginResultDto.ErrorTenantSuspended)
+ {
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.TenantLoginFailed,
+ AuditActorTypes.Tenant,
+ ActorId: result.UserId,
+ TenantId: result.TenantId,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct);
+
+ return EndpointResults.Forbidden(TenantSuspendedDetail);
+ }
+
+ if (result.Login is null || result.Token is null)
+ {
+ // Пустой/пробельный login и неверные учётные данные — одно сообщение (семантика прототипа).
+ // Аудит tenant_login_failed пишем только для реальной попытки (непустой логин), без пароля (Ruling 4);
+ // счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика).
+ if (attemptedLogin is not null)
+ {
+ await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct);
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.TenantLoginFailed,
+ AuditActorTypes.Tenant,
+ ActorId: null,
+ TenantId: null,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct);
+ }
+
+ return EndpointResults.Unauthorized(InvalidCredentialsDetail);
+ }
+
+ // Успешный вход сбрасывает счётчик неудач ключа ip|login (Ruling 5).
+ await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct);
+
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.TenantLoginOk,
+ AuditActorTypes.Tenant,
+ ActorId: result.UserId,
+ TenantId: result.TenantId,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct);
+
+ SessionCookieWriter.Append(context, cookieOptions.Value, result.Token);
+ return Results.Ok(new { ok = true, login = result.Login });
+ }
+
+ // POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok).
+ // Если удалённая сессия была impersonation — пишется аудит impersonation_stopped (Task 7, ревью: полный аудит).
+ private static async Task LogoutAsync(
+ AuthService authService,
+ AuditService auditService,
+ IOptions cookieOptions,
+ HttpContext context,
+ CancellationToken ct)
+ {
+ var cookieName = cookieOptions.Value.Name;
+ var rawToken = context.Request.Cookies[cookieName];
+ // Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса).
+ CurrentUser? user = context.GetCurrentUser();
+ var logout = await authService.LogoutAsync(rawToken, ct);
+ if (logout is not null)
+ {
+ // Актор — оператор, начавший impersonation (маркер сессии); тенант — для фильтра TenantId.
+ await auditService.AppendAsync(new AuditRecordDto(
+ AuditEvents.ImpersonationStopped,
+ AuditActorTypes.Operator,
+ ActorId: logout.OperatorId,
+ TenantId: logout.TenantId,
+ Ip: ClientIp(context),
+ DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct);
+ }
+
+ // Выход пользователя тенанта (этап 10, T1): событие пишется при живой разрешённой сессии.
+ if (user is not null)
+ {
+ await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct);
+ }
+
+ context.Response.Cookies.Delete(cookieName);
+ return Results.Ok(new { ok = true });
+ }
+
+ // GET /api/auth/me: проверка живой сессии.
+ private static IResult MeAsync(HttpContext context)
+ {
+ var user = context.GetCurrentUser();
+ if (user is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
+ }
+
+ return Results.Ok(new { login = user.Login, ok = true });
+ }
+
+ // POST /api/auth/change-password: смена пароля и перевыпуск куки (свежая сессия).
+ private static async Task ChangePasswordAsync(
+ ChangePasswordRequest body,
+ AuthService authService,
+ IOptions cookieOptions,
+ HttpContext context,
+ CancellationToken ct)
+ {
+ var user = context.GetCurrentUser();
+ if (user is null)
+ {
+ return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
+ }
+
+ var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
+ if (!result.Ok || result.NewToken is null)
+ {
+ // Семантика прототипа: код ошибки различает «старый пароль неверен» и «слишком короткий».
+ var detail = result.Error == ChangePasswordResultDto.ErrorTooShort
+ ? PasswordTooShortDetail
+ : WrongOldPasswordDetail;
+ return EndpointResults.BadRequest(detail);
+ }
+
+ // Старые сессии удалены внутри сервиса; выдаём клиенту свежую куку.
+ SessionCookieWriter.Append(context, cookieOptions.Value, result.NewToken);
+ return Results.Ok(new { ok = true });
+ }
+
+ // Нормализованная попытка логина для аудита (нижний регистр/обрезка, как AuthService); null — писать нечего.
+ // login: Логин из тела запроса.
+ // Возвращает: Нормализованный логин или null при пустом/пробельном входе.
+ private static string? NormalizeLogin(string? login)
+ {
+ string? normalized = login?.Trim().ToLowerInvariant();
+ return string.IsNullOrEmpty(normalized) ? null : normalized;
+ }
+
+ // IP-адрес клиента для аудита (без порта; null, если недоступен).
+ // context: Контекст запроса.
+ // Возвращает: Строковое представление IP или null.
+ private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
+}
diff --git a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs
index 2a6d8bf..c8316fc 100644
--- a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs
@@ -1,478 +1,469 @@
-using System.Text.Json;
-using Deal.Api.Endpoints.RequestModels;
-using Deal.Api.Extensions;
-using Deal.Api.Models;
-using Deal.Api.Services;
-using Deal.Contracts.Integrations.Abstractions;
-using Deal.Contracts.Integrations.Models;
-using Deal.Modules.Kanban.Application.Abstractions;
-using Deal.Modules.Kanban.Application.Extensions;
-using Deal.Modules.Kanban.Application.Models;
-using Deal.Modules.Kanban.Application.Registrars;
-using Deal.Modules.Kanban.Application.Services;
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.Modules.Tenants.Application.Extensions;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.Modules.Tenants.Application.Registrars;
-using Deal.Modules.Tenants.Application.Services;
-using Deal.Api.Dtos;
-
-namespace Deal.Api.Endpoints;
-
-///
-/// Детальные операции карточки: создание локальной, «взять в работу», патч, ссылки, файлы,
-/// напоминания, очистка «Отклонено» — продолжение группы /api/cards (этап 9, T6).
-///
-///
-/// Единый контракт /api/cards (R5): операции проектной карточки (патч полей, ссылки, файлы, напоминания)
-/// теперь живут на том же ресурсе карточки. Список/чтение/перенос/комментарии/корзина — в
-/// ; здесь — уникальные подпути. Все мутации возвращают обновлённую
-/// единую карточку (чтение после записи через ). Все эндпоинты требуют сессию.
-///
-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 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 (создание, take, патч, ссылки, файлы, напоминания).
- ///
- /// Построитель маршрутов приложения.
- /// Построитель маршрутов для цепочки вызовов.
- 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.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);
-
- // Аудит создания карточки (этап 10, T1).
- 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 card is null
- ? EndpointResults.NotFound(CardNotFoundDetail)
- : 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();
- 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 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 (entry is null)
- {
- return EndpointResults.NotFound(CardNotFoundDetail);
- }
-
- 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();
- 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 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.
- 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);
-
- // Переводит тело POST /api/cards в начальные поля сервиса (поля 1:1 с CardLocalCreateDto).
- // body: Тело запроса.
- // Возвращает: DTO модуля для CardsService.CreateLocalCardAsync.
- 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;
- }
- }
-}
+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.Kanban.Application.Models;
+using Deal.Modules.Kanban.Application.Services;
+using Deal.Modules.Tenants.Application.Models;
+
+namespace Deal.Api.Endpoints;
+
+///
+/// Детальные операции карточки: создание локальной, «взять в работу», патч, ссылки, файлы,
+/// напоминания, очистка «Отклонено» — продолжение группы /api/cards (этап 9, T6).
+///
+///
+/// Единый контракт /api/cards (R5): операции проектной карточки (патч полей, ссылки, файлы, напоминания)
+/// теперь живут на том же ресурсе карточки. Список/чтение/перенос/комментарии/корзина — в
+/// ; здесь — уникальные подпути. Все мутации возвращают обновлённую
+/// единую карточку (чтение после записи через ). Все эндпоинты требуют сессию.
+///
+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 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 (создание, take, патч, ссылки, файлы, напоминания).
+ ///
+ /// Построитель маршрутов приложения.
+ /// Построитель маршрутов для цепочки вызовов.
+ 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.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);
+
+ // Аудит создания карточки (этап 10, T1).
+ 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 card is null
+ ? EndpointResults.NotFound(CardNotFoundDetail)
+ : 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();
+ 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 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 (entry is null)
+ {
+ return EndpointResults.NotFound(CardNotFoundDetail);
+ }
+
+ 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();
+ 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 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.
+ 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);
+
+ // Переводит тело POST /api/cards в начальные поля сервиса (поля 1:1 с CardLocalCreateDto).
+ // body: Тело запроса.
+ // Возвращает: DTO модуля для CardsService.CreateLocalCardAsync.
+ 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;
+ }
+ }
+}
diff --git a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs
index f8f3635..b96e0d1 100644
--- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs
+++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs
@@ -1,502 +1,491 @@
-using Deal.Api.Endpoints.RequestModels;
-using Deal.Api.Events;
-using Deal.Api.Extensions;
-using Deal.Api.Models;
-using Deal.Api.Services;
-using Deal.Modules.Cards.Application.Abstractions;
-using Deal.Modules.Cards.Application.Dtos;
-using Deal.Modules.Cards.Application.Models;
-using Deal.Modules.Kanban.Application.Abstractions;
-using Deal.Modules.Kanban.Application.Extensions;
-using Deal.Modules.Kanban.Application.Models;
-using Deal.Modules.Kanban.Application.Registrars;
-using Deal.Modules.Kanban.Application.Services;
-using Deal.Modules.Pipeline.Application.Abstractions;
-using Deal.Modules.Pipeline.Application.Models;
-using Deal.Modules.Pipeline.Application.Registrars;
-using Deal.Modules.Pipeline.Application.Services;
-using Deal.Modules.Tenants.Application.Abstractions;
-using Deal.Modules.Tenants.Application.Extensions;
-using Deal.Modules.Tenants.Application.Models;
-using Deal.Modules.Tenants.Application.Registrars;
-using Deal.Modules.Tenants.Application.Services;
-using Deal.Api.Dtos;
-
-namespace Deal.Api.Endpoints;
-
-///
-/// Эндпоинты карточек и поиска: GET /api/cards[?containerId=], /cards/counts, /cards/{id},
-/// mark-all-seen/mark-col-seen, move/trash/restore/DELETE, clear-col, comments, reclassify (batch + {id}),
-/// GET /api/search (этап 9, T6).
-///
-///
-/// Единый контракт /api/cards (R5): старые ручки /api/leads, /api/projects, /api/boards и /api/columns
-/// упразднены. GET /cards →
-/// {items}; counts — плоская wire-форма {new, <col>: {count, new}, learning, ml, ai}; move → обновлённая
-/// карточка; restore → {ok, col}; clear-col → {ok, cleared}; comments → {comments}; search →
-/// {cards, messages: []}. 400 «Неизвестный контейнер» при несуществующем containerId; 404 «Карточка не
-/// найдена» — null-результаты сервисов, 400-тексты — константы CardsService.
-/// ⚠ Статические сегменты (counts, mark-all-seen, mark-col-seen, clear-col, reclassify) регистрируются ДО
-/// /cards/{cardId}. Все эндпоинты требуют сессию: 401 {detail}; сервисы резолвятся из RequestServices
-/// ПОСЛЕ проверки сессии.
-///
-public static class CardsEndpoints
-{
- // Префикс группы карточек.
- private const string CardsGroupPrefix = "/api/cards";
-
- // Префикс группы поиска (единственный эндпоинт группы — /api/search).
- private const string ApiGroupPrefix = "/api";
-
- // Путь поиска (GET).
- private const string SearchPath = "/search";
-
- // OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py).
- private const string OpenApiTag = "dashboard";
-
- // 404: карточка не найдена (dashboard_routes.py _lead_or_404 L92–96).
- private const string CardNotFoundDetail = "Карточка не найдена";
-
- // 400 GET /cards: containerId не существует.
- private const string UnknownColumnDetail = "Неизвестный контейнер";
-
- // Инициатор перехода при ручном переносе — действие пользователя (R4 этапа 9).
- private const string UserActor = "user";
-
- // SSE-тип события завершения переклассификации (этап 12, остаток 2; api.js слушает 'cards_reclassified').
- private const string ReclassifiedEventType = "cards_reclassified";
-
- // Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса.
- private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true };
-
- ///
- /// Регистрирует группы /api/cards и /api (карточки + поиск). Статические сегменты — до /cards/{cardId}.
- ///
- /// Построитель маршрутов приложения.
- /// Построитель маршрутов для цепочки вызовов.
- public static IEndpointRouteBuilder MapCardsEndpoints(this IEndpointRouteBuilder app)
- {
- var leads = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag);
-
- // Статические сегменты ДО /cards/{cardId}: ASP.NET Core отдаёт приоритет литералам, порядок регистрации
- // сохранён для читаемости.
- leads.MapGet("", ListCardsAsync);
- leads.MapGet("/counts", CountsAsync);
- leads.MapPost("/mark-all-seen", MarkAllSeenAsync);
- leads.MapPost("/mark-col-seen", MarkColSeenAsync);
- leads.MapPost("/clear-col", ClearColAsync);
- leads.MapPost("/reclassify", ReclassifyAsync);
- leads.MapGet("/{cardId}", GetCardAsync);
- leads.MapPost("/{cardId}/reclassify", ReclassifyOneAsync);
- leads.MapPost("/{cardId}/move", MoveAsync);
- leads.MapPost("/{cardId}/trash", TrashAsync);
- leads.MapPost("/{cardId}/restore", RestoreAsync);
- leads.MapDelete("/{cardId}", DeleteAsync);
- leads.MapPost("/{cardId}/comments", AddCommentAsync);
-
- app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag).MapGet(SearchPath, SearchAsync);
-
- return app;
- }
-
- // GET /api/cards?containerId=: карточки контейнера (или все карточки дашборда); 400 «Неизвестный контейнер».
- // Параметр col принят как алиас containerId (совместимость со старым фронтом).
- private static async Task ListCardsAsync(
- string? containerId,
- string? col,
- HttpContext context,
- CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- string? target = string.IsNullOrEmpty(containerId) ? col : containerId;
- CardsService cardsService = context.RequestServices.GetRequiredService();
- if (!string.IsNullOrEmpty(target) && !await IsKnownContainerAsync(target, context, ct))
- {
- return EndpointResults.BadRequest(UnknownColumnDetail);
- }
-
- IReadOnlyList cards = await cardsService.ListCardsAsync(target, ct);
- return Results.Ok(new { items = cards });
- }
-
- // GET /api/cards/counts: плоская wire-форма счётчиков {new, <col>:{count,new}, learning, ml, ai} (L161–163, §4.1 L257).
- private static async Task CountsAsync(HttpContext context, CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- CardsService cardsService = context.RequestServices.GetRequiredService();
- CardCountsDto counts = await cardsService.CountsAsync(ct);
-
- // Разворачивание CardCountsDto: колонки — корневые ключи (counts L268–279), служебные — фиксированные.
- var wire = new Dictionary { ["new"] = counts.New };
- foreach ((string col, CardColumnCountDto column) in counts.Columns)
- {
- wire[col] = column;
- }
-
- wire["learning"] = counts.Learning;
- wire["ml"] = counts.Ml;
- wire["ai"] = counts.Ai;
- return Results.Ok(wire);
- }
-
- // GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168).
- private static async Task GetCardAsync(
- string cardId,
- HttpContext context,
- CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- CardsService cardsService = context.RequestServices.GetRequiredService();
- CardDto? card = await cardsService.GetCardAsync(cardId, ct);
- return card is null
- ? EndpointResults.NotFound(CardNotFoundDetail)
- : Results.Ok(card);
- }
-
- // POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}.
- private static async Task MarkAllSeenAsync(HttpContext context, CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- CardsService cardsService = context.RequestServices.GetRequiredService();
- await cardsService.MarkSeenAsync(cardId: null, col: null, ct);
- return Results.Ok(new { ok = true });
- }
-
- // POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {ok:true}.
- private static async Task MarkColSeenAsync(
- MarkColBody body,
- HttpContext context,
- CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- if (body.Col is null)
- {
- // Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ
- // карточек (truthiness python, L250–256) — эндпоинт защищает от вызова с null (прототип: 422).
- return EndpointResults.BadRequest(UnknownColumnDetail);
- }
-
- CardsService cardsService = context.RequestServices.GetRequiredService();
- await cardsService.MarkSeenAsync(cardId: null, col: body.Col, ct);
- return Results.Ok(new { ok = true });
- }
-
- // POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4).
- // Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый
- // доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания
- // (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при
- // несуществующем контейнере, 404 — карточки нет.
- private static async Task MoveAsync(
- string cardId,
- MoveBody body,
- HttpContext context,
- CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- ICardMover mover = context.RequestServices.GetRequiredService();
- CardMoveResultDto outcome = await mover.MoveAsync(cardId, body.To ?? string.Empty, UserMoveContext, ct);
- if (outcome.Error is not null)
- {
- return EndpointResults.BadRequest(outcome.Error);
- }
-
- if (!outcome.Exists)
- {
- return EndpointResults.NotFound(CardNotFoundDetail);
- }
-
- // Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор.
- await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
-
- CardsService cardsService = context.RequestServices.GetRequiredService();
- CardDto? unified = await cardsService.GetCardAsync(cardId, ct);
- return unified is null
- ? EndpointResults.NotFound(CardNotFoundDetail)
- : Results.Ok(unified);
- }
-
- // POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203–207); ответ {ok:true}; 404.
- private static async Task TrashAsync(
- string cardId,
- HttpContext context,
- CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- CardsService cardsService = context.RequestServices.GetRequiredService();
- CardDto? card = await cardsService.TrashCardAsync(cardId, ct);
- if (card is null)
- {
- return EndpointResults.NotFound(CardNotFoundDetail);
- }
-
- // Аудит отправки карточки в корзину (этап 10, T1).
- await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct);
- return Results.Ok(new { ok = true });
- }
-
- // POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404.
- private static async Task RestoreAsync(
- string cardId,
- HttpContext context,
- CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- CardsService cardsService = context.RequestServices.GetRequiredService();
- string? col = await cardsService.RestoreCardAsync(cardId, ct);
- if (col is null)
- {
- return EndpointResults.NotFound(CardNotFoundDetail);
- }
-
- // Аудит возврата карточки из корзины/архива (этап 10, T1).
- await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct);
- return Results.Ok(new { ok = true, col });
- }
-
- // DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217–221); ответ {ok:true}; 404.
- private static async Task DeleteAsync(
- string cardId,
- HttpContext context,
- CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- CardsService cardsService = context.RequestServices.GetRequiredService();
- bool deleted = await cardsService.DeleteForeverAsync(cardId, ct);
- if (!deleted)
- {
- return EndpointResults.NotFound(CardNotFoundDetail);
- }
-
- // Аудит удаления карточки навсегда (этап 10, T1).
- await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct);
- return Results.Ok(new { ok = true });
- }
-
- // POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400.
- private static async Task ClearColAsync(
- ClearColBody body,
- HttpContext context,
- CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- CardsService cardsService = context.RequestServices.GetRequiredService();
- ClearColResultDto result = await cardsService.ClearColAsync(body.Col ?? string.Empty, ct);
- return result.Error is not null
- ? EndpointResults.BadRequest(result.Error)
- : Results.Ok(new { ok = true, cleared = result.Cleared });
- }
-
- // POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404.
- private static async Task AddCommentAsync(
- string cardId,
- CommentBody body,
- HttpContext context,
- CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- CardsService cardsService = context.RequestServices.GetRequiredService();
- AddCommentResultDto result = await cardsService.AddCommentAsync(cardId, body.Text ?? string.Empty, ct);
- if (result.Error is not null)
- {
- return EndpointResults.BadRequest(result.Error);
- }
-
- if (result.Comments is null)
- {
- return EndpointResults.NotFound(CardNotFoundDetail);
- }
-
- // Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется.
- await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct);
- return Results.Ok(new { comments = result.Comments });
- }
-
- // POST /api/cards/reclassify: переклассификация «Неразобранного» (все карточки либо ids).
- // Тело ids опционально (фронт шлёт запрос без тела — все карточки inbox). Проход синхронный; при занятом
- // проходе ответ {started:false, busy:true}. Поля started/busy/attempted сохранены ради совместимости,
- // добавлены reclassified/moved/kept/trashed/skipped/usedAi/reason. Аудит — card_reclassified; после
- // успешного прохода (reclassified > 0) публикуется SSE cards_reclassified {reclassified,moved}.
- // body: Тело запроса (ids — опционально).
- // context: Контекст запроса.
- // ct: Токен отмены.
- private static async Task ReclassifyAsync(
- ReclassifyBody? body,
- HttpContext context,
- CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- CardReclassifier reclassifier = context.RequestServices.GetRequiredService();
- ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct);
- await AppendReclassifyAuditAsync(context, result, ct);
- PublishReclassified(context, result);
- return Results.Ok(ToReclassifyWire(result));
- }
-
- // POST /api/cards/{cardId}/reclassify: переклассификация одной карточки; 404 «Карточка не найдена».
- // cardId: Id карточки (c_...).
- // context: Контекст запроса.
- // ct: Токен отмены.
- private static async Task ReclassifyOneAsync(
- string cardId,
- HttpContext context,
- CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- CardsService cardsService = context.RequestServices.GetRequiredService();
- CardDto? card = await cardsService.GetCardAsync(cardId, ct);
- if (card is null)
- {
- return EndpointResults.NotFound(CardNotFoundDetail);
- }
-
- CardReclassifier reclassifier = context.RequestServices.GetRequiredService();
- ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct);
- await AppendReclassifyAuditAsync(context, result, ct);
- PublishReclassified(context, result);
- return Results.Ok(ToReclassifyWire(result));
- }
-
- // Wire-форма итога переклассификации (camelCase; сохранены started/busy/attempted).
- // result: Итог прохода.
- // Возвращает: Объект ответа эндпоинта.
- private static object ToReclassifyWire(ReclassifyResultDto result) => new
- {
- started = result.Started,
- busy = result.Busy,
- attempted = result.Attempted,
- reclassified = result.Reclassified,
- moved = result.Moved,
- kept = result.Kept,
- trashed = result.Trashed,
- skipped = result.Skipped,
- usedAi = result.UsedAi,
- reason = result.Reason,
- };
-
- // Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api).
- // Публикуется только когда проход реально выполнен и что-то изменил (started и
- // reclassified > 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка
- // минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op.
- // context: Контекст запроса (тенант-канал сессии).
- // result: Итог прохода.
- private static void PublishReclassified(HttpContext context, ReclassifyResultDto result)
- {
- if (!result.Started || result.Reclassified == 0)
- {
- return;
- }
-
- SseBroker broker = context.RequestServices.GetRequiredService();
- broker.Publish(
- context.GetCurrentUser()!.TenantId,
- ReclassifiedEventType,
- new { reclassified = result.Reclassified, moved = result.Moved });
- }
-
- // Аудит переклассификации: пишется только когда проход реально что-то изменил.
- // context: Контекст запроса.
- // result: Итог прохода.
- // ct: Токен отмены.
- private static Task AppendReclassifyAuditAsync(
- HttpContext context,
- ReclassifyResultDto result,
- CancellationToken ct)
- {
- if (result.Reclassified == 0)
- {
- return Task.CompletedTask;
- }
-
- return AuditAppender.AppendTenantAsync(
- context,
- AuditEvents.CardReclassified,
- new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed },
- ct);
- }
-
- // GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254–256). Ответ {leads, messages: []}.
- private static async Task SearchAsync(
- string? q,
- HttpContext context,
- CancellationToken ct)
- {
- if (!context.HasUser())
- {
- return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail);
- }
-
- CardsService cardsService = context.RequestServices.GetRequiredService();
- IReadOnlyList leads = await cardsService.SearchCardsAsync(q, ct);
-
- // messages всегда []: telegram-сообщений здесь нет, фронт их не читает.
- return Results.Ok(new { cards = leads, messages = Array.Empty