Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
using Deal.SharedKernel.Tenants;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Deal.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Строит строку подключения к Postgres с учётом схемы тенанта.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Две строки (Security review, least privilege): <c>ConnectionStrings:DealPostgres</c> — прикладная роль
|
||||
/// runtime (без DDL в проде); <c>ConnectionStrings:DealMigrator</c> (опционально) — служебная роль для DDL
|
||||
/// (CREATE SCHEMA/миграции схемы). Если мигратор-строка не задана (dev/тесты/один пользователь) — DDL
|
||||
/// выполняется прикладной строкой (текущее поведение).
|
||||
/// </remarks>
|
||||
public sealed class ConnectionStringProvider
|
||||
{
|
||||
private readonly string _baseConnectionString;
|
||||
private readonly string? _migratorConnectionString;
|
||||
|
||||
public ConnectionStringProvider(IConfiguration configuration)
|
||||
{
|
||||
_baseConnectionString = configuration.GetConnectionString("DealPostgres")
|
||||
?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
|
||||
// Опциональная роль мигратора: задаётся только в проде (см. техдок §10/§13).
|
||||
_migratorConnectionString = configuration.GetConnectionString("DealMigrator");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строка подключения; при tenantId не null добавляет search_path к схеме тенанта.
|
||||
/// </summary>
|
||||
public string ForTenant(TenantId? tenantId)
|
||||
{
|
||||
if (tenantId is null)
|
||||
{
|
||||
return _baseConnectionString;
|
||||
}
|
||||
|
||||
return $"{_baseConnectionString};Search Path={tenantId.Value.SchemaName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строка подключения для DDL (провижининг схемы/миграции): мигратор-роль, если задана,
|
||||
/// иначе прикладная (dev/тесты). Search Path — как в <see cref="ForTenant"/>.
|
||||
/// </summary>
|
||||
public string ForSchemaDdl(TenantId? tenantId)
|
||||
{
|
||||
string baseString = _migratorConnectionString ?? _baseConnectionString;
|
||||
if (tenantId is null)
|
||||
{
|
||||
return baseString;
|
||||
}
|
||||
|
||||
return $"{baseString};Search Path={tenantId.Value.SchemaName}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Deal.SharedKernel.Tenants;
|
||||
|
||||
namespace Deal.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Контекст тенанта на AsyncLocal: пробрасывается через весь запрос.
|
||||
/// </summary>
|
||||
public sealed class TenantContext : ITenantContext
|
||||
{
|
||||
private static readonly AsyncLocal<TenantId?> Current = new();
|
||||
|
||||
public TenantId? TenantId => Current.Value;
|
||||
|
||||
public bool HasTenant => Current.Value is not null;
|
||||
|
||||
public string? SchemaName => Current.Value?.SchemaName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetTenant(TenantId tenantId) => Current.Value = tenantId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Reset() => Current.Value = null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Deal.SharedKernel\Deal.SharedKernel.csproj" />
|
||||
<ProjectReference Include="..\Deal.Contracts\Deal.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\contracts\Deal.Proto.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Tenants\Deal.Modules.Tenants.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Settings\Deal.Modules.Settings.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Kanban\Deal.Modules.Kanban.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Cards\Deal.Modules.Cards.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Pipeline\Deal.Modules.Pipeline.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Telegram\Deal.Modules.Telegram.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Discovery\Deal.Modules.Discovery.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" />
|
||||
<!-- gRPC-клиент ml-service (план Task 16, Ruling 1): GrpcChannel/вызовы MlService; типы контрактов
|
||||
и Grpc.Core.Api приходят ProjectReference'ом Deal.Proto. -->
|
||||
<PackageReference Include="Grpc.Net.Client" Version="2.83.0" />
|
||||
<!-- Клиент grpc.health.v1 автономных сервисов (план Task 10, Ruling 3/6): операторская health-проба
|
||||
ml/ai/telegram (ServiceHealthProbe); типы Grpc.Health.V1 приходят этим пакетом (как Grpc.AspNetCore.HealthChecks
|
||||
в Deal.Api зависит от него — дубликатов типов нет). -->
|
||||
<PackageReference Include="Grpc.HealthCheck" Version="2.83.0" />
|
||||
<!-- MinIO S3-клиент (Ruling 4, план Task 6): единственный новый пакет этапа файлов. -->
|
||||
<PackageReference Include="Minio" Version="7.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Маркер слоя Infrastructure: используется для DI-сканирования и тестов.
|
||||
/// </summary>
|
||||
public sealed class InfrastructureMarker
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-реализация проверки подключения к AI-провайдеру (Ruling 7; 1:1 settings_routes.py L195–219).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Лёгкая проверка БЕЗ LLM-вызовов: для OpenAI-совместимых — GET {base}/models, для Anthropic
|
||||
/// (api_style <c>"anthropic"</c>) — GET {base}/v1/models c заголовком x-api-key. Без ключа и для
|
||||
/// локальных провайдеров (Ollama/LM Studio) HTTP не выполняется — короткие ветки ответа.
|
||||
/// Таймаут клиента — 12 с (HttpClient настраивается DI-регистрацией AddHttpClient в Deal.Api,
|
||||
/// см. <see cref="RequestTimeoutSeconds"/>). Ключ в ответ не попадает: только keySet/keyMasked
|
||||
/// (маска — <c>ai.py</c> mask_key L53–58).
|
||||
/// SSRF-контур dev-режима (см. отчёт Task 6): провайдер обязан быть из фиксированного каталога
|
||||
/// <see cref="AiProviders"/> (allowlist), base URL — только абсолютный http(s)-адрес; host-level
|
||||
/// рестрикции нет (локальные серверы на LAN + ветка «недоступный хост» приёмки плана).
|
||||
/// </remarks>
|
||||
public sealed class AiConnectionChecker : IAiConnectionChecker
|
||||
{
|
||||
/// <summary>
|
||||
/// Таймаут HTTP-запроса проверки в секундах (Ruling 7 — 12 с); применяется DI-регистрацией клиента.
|
||||
/// </summary>
|
||||
public const int RequestTimeoutSeconds = 12;
|
||||
|
||||
// ── Фиксированные сообщения веток (Ruling 7, 1:1 с прототипом) ──
|
||||
|
||||
// Сообщение ветки «локальный провайдер» (вместо HTTP — ping на этапе 6).
|
||||
private const string LocalServerMessageTemplate = "Локальный сервер «{0}» (ping в проде)";
|
||||
|
||||
// Сообщение ветки «API-ключ не задан».
|
||||
private const string NoApiKeyMessage = "Не задан API-ключ";
|
||||
|
||||
// Сообщение успешной проверки (HTTP < 400).
|
||||
private const string SuccessMessage = "Подключение успешно";
|
||||
|
||||
// Сообщение ветки «ключ не принят» (HTTP 401/403).
|
||||
private const string KeyRejectedMessageTemplate = "Ключ не принят (HTTP {0}) — проверьте ключ и доступ к модели";
|
||||
|
||||
// Сообщение ветки «иной HTTP-код» (≥ 400, кроме 401/403).
|
||||
private const string HttpErrorMessageTemplate = "HTTP {0} — проверьте Base URL и модель";
|
||||
|
||||
// Сообщение ветки сетевого сбоя (деталь — текст исключения).
|
||||
private const string ConnectionErrorMessageTemplate = "Ошибка соединения: {0}";
|
||||
|
||||
// Сообщение ветки таймаута HttpClient.
|
||||
private const string TimeoutMessage = "Ошибка соединения: превышен таймаут ожидания";
|
||||
|
||||
// Сообщение SSRF-гейта: провайдер вне фиксированного каталога (allowlist).
|
||||
private const string ProviderNotAllowedMessage = "Провайдер не из списка разрешённых";
|
||||
|
||||
// Сообщение SSRF-гейта: base URL не абсолютный http(s).
|
||||
private const string InvalidBaseUrlMessage = "Недопустимый Base URL (ожидается http/https)";
|
||||
|
||||
// ── Константы протокола (референс settings_routes.py L206–209) ──
|
||||
|
||||
// Значение api_style провайдера Anthropic (AiProviderDefinition.ApiStyle).
|
||||
private const string AnthropicApiStyle = "anthropic";
|
||||
|
||||
// Путь списка моделей Anthropic: {base}/v1/models.
|
||||
private const string AnthropicModelsPath = "/v1/models";
|
||||
|
||||
// Путь списка моделей OpenAI-совместимых: {base}/models.
|
||||
private const string OpenAiModelsPath = "/models";
|
||||
|
||||
// Сообщение SSRF-гейта: base URL указывает на приватный/локальный адрес.
|
||||
private const string PrivateEndpointNotAllowedMessage =
|
||||
"Недопустимый Base URL (приватный/локальный адрес недоступен для проверки)";
|
||||
|
||||
// Заголовок ключа Anthropic.
|
||||
private const string ApiKeyHeaderName = "x-api-key";
|
||||
|
||||
// Заголовок версии протокола Anthropic.
|
||||
private const string AnthropicVersionHeaderName = "anthropic-version";
|
||||
|
||||
// Значение версии протокола Anthropic.
|
||||
private const string AnthropicVersionHeaderValue = "2023-06-01";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт проверку поверх HttpClient.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">Клиент с таймаутом 12 с (DI: AddHttpClient в Deal.Api).</param>
|
||||
public AiConnectionChecker(HttpClient httpClient)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpClient);
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiCheckResultDto> CheckAsync(AiCheckRequest request, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == request.ProviderId);
|
||||
string name = meta?.Name ?? request.ProviderId;
|
||||
|
||||
// SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного
|
||||
// каталога AiProviders. В штатном потоке недостижимо (PATCH-гейт aiProvider/aiConfigs в
|
||||
// SettingsService) — защита от ручного изменения БД/повреждённого хранилища.
|
||||
if (meta is null)
|
||||
{
|
||||
return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage);
|
||||
}
|
||||
|
||||
// Локальный провайдер (Ollama/LM Studio): HTTP наружу не ходим (Ruling 7 — ветка до ключа).
|
||||
if (request.IsLocal)
|
||||
{
|
||||
return BuildResult(request, name, ok: true, message: string.Format(LocalServerMessageTemplate, name));
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.ApiKey))
|
||||
{
|
||||
return BuildResult(request, name, ok: false, message: NoApiKeyMessage);
|
||||
}
|
||||
|
||||
if (!TryBuildModelsUri(request.BaseUrl, request.ApiStyle, out Uri? modelsUri))
|
||||
{
|
||||
return BuildResult(request, name, ok: false, message: InvalidBaseUrlMessage);
|
||||
}
|
||||
|
||||
// SSRF-гейт (Security review): проверка подключения выполняется только к публичным адресам.
|
||||
// Private/loopback/link-local литералы и localhost запрещены для не-local провайдеров (локальные
|
||||
// провайдеры — ветка IsLocal выше, HTTP для них не выполняется вовсе). DNS-имена не резолвятся
|
||||
// здесь (полный egress-контроль с резолвом — на уровне сетевого периметра/прокси).
|
||||
if (IsPrivateEndpoint(modelsUri))
|
||||
{
|
||||
return BuildResult(request, name, ok: false, message: PrivateEndpointNotAllowedMessage);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using HttpRequestMessage httpRequest = new(HttpMethod.Get, modelsUri);
|
||||
AddAuthHeaders(httpRequest, request.ApiKey, request.ApiStyle);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest, ct);
|
||||
int statusCode = (int)response.StatusCode;
|
||||
|
||||
if (statusCode < 400)
|
||||
{
|
||||
return BuildResult(request, name, ok: true, message: SuccessMessage);
|
||||
}
|
||||
|
||||
if (statusCode is 401 or 403)
|
||||
{
|
||||
return BuildResult(request, name, ok: false,
|
||||
message: string.Format(KeyRejectedMessageTemplate, statusCode));
|
||||
}
|
||||
|
||||
return BuildResult(request, name, ok: false,
|
||||
message: string.Format(HttpErrorMessageTemplate, statusCode));
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Сработал HttpClient.Timeout (12 с) — ветка сетевого сбоя (прототип ловит все исключения).
|
||||
return BuildResult(request, name, ok: false, message: TimeoutMessage);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
return BuildResult(request, name, ok: false,
|
||||
message: string.Format(ConnectionErrorMessageTemplate, ConnectionErrorDetail(exception)));
|
||||
}
|
||||
}
|
||||
|
||||
// Собирает ответ ветки: {ok, message} + статус провайдера (Ruling 7).
|
||||
// request: Запрос проверки (поля статуса провайдера).
|
||||
// name: Имя провайдера из каталога AiProviders.
|
||||
// ok: Результат подключения.
|
||||
// message: Сообщение ветки.
|
||||
// Возвращает: DTO ответа (наружу — camelCase).
|
||||
private static AiCheckResultDto BuildResult(AiCheckRequest request, string name, bool ok, string message)
|
||||
{
|
||||
return new AiCheckResultDto(
|
||||
Ok: ok,
|
||||
Message: message,
|
||||
Provider: request.ProviderId,
|
||||
Name: name,
|
||||
Base: request.BaseUrl,
|
||||
Model: request.Model,
|
||||
Local: request.IsLocal,
|
||||
KeySet: !string.IsNullOrEmpty(request.ApiKey),
|
||||
KeyMasked: MaskKey(request.ApiKey));
|
||||
}
|
||||
|
||||
// Маска ключа: пусто → "", len ≤ 8 → «x…», иначе «1234…5678» (ai.py mask_key L53–58).
|
||||
// key: Ключ открытым текстом.
|
||||
// Возвращает: Маскированная строка.
|
||||
private static string MaskKey(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (key.Length <= 8)
|
||||
{
|
||||
return string.Concat(key.AsSpan(0, 1), "…");
|
||||
}
|
||||
|
||||
return string.Concat(key.AsSpan(0, 4), "…", key.AsSpan(key.Length - 4));
|
||||
}
|
||||
|
||||
// Строит URL проверки: {base}/models или {base}/v1/models (Anthropic), как в ai_check L206–207.
|
||||
// baseUrl: Эффективный базовый URL из конфигурации провайдера.
|
||||
// apiStyle: Стиль API провайдера (null — OpenAI-совместимый).
|
||||
// modelsUri: URL списка моделей (валиден только при возврате true).
|
||||
// Возвращает: True — URL построен; False — base URL не абсолютный http(s) (SSRF-гейт).
|
||||
private static bool TryBuildModelsUri(string baseUrl, string? apiStyle, [NotNullWhen(true)] out Uri? modelsUri)
|
||||
{
|
||||
modelsUri = null;
|
||||
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? parsed)
|
||||
|| (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// rstrip("/") как в прототипе: baseUrl из настроек может заканчиваться слэшем.
|
||||
string root = baseUrl.TrimEnd('/');
|
||||
string relativePath = apiStyle == AnthropicApiStyle ? AnthropicModelsPath : OpenAiModelsPath;
|
||||
if (!Uri.TryCreate(root + relativePath, UriKind.Absolute, out Uri? endpoint))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
modelsUri = endpoint;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Заголовки аутентификации: Bearer (OpenAI-совместимые) или x-api-key + версия (Anthropic).
|
||||
// httpRequest: Запрос списка моделей.
|
||||
// apiKey: Ключ открытым текстом (непустой — ветка ключа пройдена).
|
||||
// apiStyle: Стиль API провайдера.
|
||||
private static void AddAuthHeaders(HttpRequestMessage httpRequest, string apiKey, string? apiStyle)
|
||||
{
|
||||
if (apiStyle == AnthropicApiStyle)
|
||||
{
|
||||
httpRequest.Headers.TryAddWithoutValidation(ApiKeyHeaderName, apiKey);
|
||||
httpRequest.Headers.TryAddWithoutValidation(AnthropicVersionHeaderName, AnthropicVersionHeaderValue);
|
||||
return;
|
||||
}
|
||||
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
}
|
||||
|
||||
// Проверяет, указывает ли URL на приватный/loopback/link-local адрес (SSRF-гейт).
|
||||
// Распознаются IP-литералы (IPv4/IPv6) и имя localhost; DNS-имена считаются публичными
|
||||
// (полный egress-контроль с резолвом выполняется на сетевом периметре).
|
||||
// uri: Абсолютный http(s)-адрес.
|
||||
// Возвращает: True — адрес приватный/локальный (HTTP к нему запрещён).
|
||||
private static bool IsPrivateEndpoint(Uri uri)
|
||||
{
|
||||
string host = uri.Host;
|
||||
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!IPAddress.TryParse(host, out IPAddress? address))
|
||||
{
|
||||
return false; // DNS-имя — резолв вне этого слоя
|
||||
}
|
||||
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
{
|
||||
address = address.MapToIPv4();
|
||||
}
|
||||
|
||||
if (IPAddress.IsLoopback(address))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
return bytes[0] == 10
|
||||
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|
||||
|| (bytes[0] == 192 && bytes[1] == 168)
|
||||
|| bytes[0] == 169 && bytes[1] == 254 // link-local (включая 169.254.169.254 metadata)
|
||||
|| bytes[0] == 0;
|
||||
}
|
||||
|
||||
// IPv6: уникальные локальные (fc00::/7) и link-local (fe80::/10).
|
||||
byte[] v6 = address.GetAddressBytes();
|
||||
return (v6[0] & 0xFE) == 0xFC || (v6[0] == 0xFE && (v6[1] & 0xC0) == 0x80);
|
||||
}
|
||||
/// <param name="exception">Исключение HTTP-слоя.</param>
|
||||
/// <returns>Человекочитаемый текст причины.</returns>
|
||||
private static string ConnectionErrorDetail(HttpRequestException exception)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(exception.Message))
|
||||
{
|
||||
return exception.Message;
|
||||
}
|
||||
|
||||
return exception.InnerException?.Message ?? exception.GetType().Name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Deal.Grpc.Ai;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Транспорт gRPC-клиентов ai-service: общий канал + обязательные metadata (Ruling 1, эталон
|
||||
/// <c>MlGrpcConnection</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Singleton (канал живёт долго и переиспользуется всеми вызовами): endpoint из <see cref="AiServiceOptions"/>,
|
||||
/// service-token — из env <c>DEAL_SERVICE_TOKEN</c> (Ruling 13: секреты только в env). Dev-транспорт без TLS
|
||||
/// (Ruling 2); mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом
|
||||
/// и проверяет CA сервера (сертификаты передаются <see cref="MtlsCertificates"/>). Пустой endpoint либо
|
||||
/// пустой токен при создании — ошибка конфигурации (fail-closed: без токена сервис отвергнет каждый вызов
|
||||
/// UNAUTHENTICATED, Ruling 1). Автоповторы Grpc.Net.Client отключены (MaxRetryAttempts=0): стратегию повторов
|
||||
/// держит ai-service (retry 2 с паузами 0.8/2 с, Ruling 5) — ядро повторно не ждёт.
|
||||
/// </remarks>
|
||||
public sealed class AiGrpcConnection : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor сервисов, Ruling 1).
|
||||
/// </summary>
|
||||
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с tenant-id (зеркало AiServiceImpl, Ruling 1).
|
||||
/// </summary>
|
||||
public const string TenantIdMetadataKey = "tenant-id";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с service-token (зеркало AiServiceImpl, Ruling 1).
|
||||
/// </summary>
|
||||
public const string ServiceTokenMetadataKey = "service-token";
|
||||
|
||||
private readonly GrpcChannel _channel;
|
||||
private readonly string _serviceToken;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт транспорт ai-service по конфигурации и env-токену (валидация fail-closed).
|
||||
/// </summary>
|
||||
/// <param name="options">Конфигурация секции <c>Services:Ai</c> (endpoint).</param>
|
||||
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6): null — plaintext-канал (dev, флаг выключен).</param>
|
||||
/// <exception cref="InvalidOperationException">Пустой endpoint или пустой DEAL_SERVICE_TOKEN.</exception>
|
||||
public AiGrpcConnection(AiServiceOptions options, MtlsCertificates? mtlsCertificates = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (string.IsNullOrWhiteSpace(options.Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"AiGrpcConnection: не задан endpoint ai-service (секция \"{AiServiceOptions.SectionName}:Endpoint\").");
|
||||
}
|
||||
|
||||
_serviceToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey) ?? string.Empty;
|
||||
if (_serviceToken.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"AiGrpcConnection: не задан env {ServiceTokenEnvKey} — ai-service отвергнет вызовы (fail-closed, Ruling 1).");
|
||||
}
|
||||
|
||||
GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0 };
|
||||
if (mtlsCertificates is not null)
|
||||
{
|
||||
channelOptions.HttpHandler = mtlsCertificates.CreateClientHttpHandler();
|
||||
}
|
||||
|
||||
_channel = GrpcChannel.ForAddress(options.Endpoint, channelOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт клиент RPC AiService поверх общего канала (клиент — лёгкий, на каждый вызов).
|
||||
/// </summary>
|
||||
/// <returns>Клиент сервиса AI (Filter/Classify/GenerateKeywords/EvaluateFit).</returns>
|
||||
public AiService.AiServiceClient CreateClient() => new(_channel);
|
||||
|
||||
/// <summary>
|
||||
/// Собирает обязательные metadata вызова: tenant-id + service-token (Ruling 1).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (строка, формат N — как в ai-service).</param>
|
||||
/// <returns>Metadata для CallOptions вызова.</returns>
|
||||
public Metadata CreateMetadata(string tenantId)
|
||||
{
|
||||
var metadata = new Metadata
|
||||
{
|
||||
{ TenantIdMetadataKey, tenantId },
|
||||
{ ServiceTokenMetadataKey, _serviceToken },
|
||||
};
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _channel.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Собирает конфиг активного ИИ-провайдера для запросов ai-service (Ruling 5: ядро расшифровывает
|
||||
/// aiConfigs и передаёт ProviderConfig в теле каждого запроса; сервис настроек тенанта не знает).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Эффективный конфиг 1:1 с python <c>ai.py _cfg()</c> L25–33 и формой ProviderConfig (ai.proto L70–85):
|
||||
/// активный провайдер — настройка <c>aiProvider</c> (дефолт «deepseek»), каталог — <see cref="AiProviders"/>
|
||||
/// (fallback на первый — deepseek, как python L28); из переопределения <c>aiConfigs</c> берутся
|
||||
/// apiKey/baseUrl/model, отсутствующие поля дополняются дефолтами каталога (base провайдера, первая модель);
|
||||
/// apiKey расшифровывается (значения <c>enc:</c>+… через <see cref="ISecretCipher"/>; незашифрованные ранних
|
||||
/// версий — как есть, python crypto.decrypt_text L52–61); api_style провайдера — из каталога (Anthropic —
|
||||
/// «anthropic», остальные — пусто = OpenAI-совместимый). Scoped: читает KV-настройки тенанта (ISettingsStore →
|
||||
/// scoped TenantDbContext запроса), как LocalAiClassifier/GrpcMlClient.
|
||||
/// </remarks>
|
||||
public sealed class AiProviderConfigBuilder
|
||||
{
|
||||
// Ключ aiConfigs: поле apiKey переопределения провайдера.
|
||||
private const string ApiKeyField = "apiKey";
|
||||
|
||||
// Ключ aiConfigs: поле baseUrl переопределения провайдера.
|
||||
private const string BaseUrlField = "baseUrl";
|
||||
|
||||
// Ключ aiConfigs: поле model переопределения провайдера.
|
||||
private const string ModelField = "model";
|
||||
|
||||
// Префикс зашифрованного значения apiKey (crypto.py L49: enc: + Base64(nonce‖ct‖tag)).
|
||||
private const string EncryptedPrefix = "enc:";
|
||||
|
||||
private readonly ISettingsStore _store;
|
||||
private readonly ISecretCipher _secretCipher;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт сборщик конфига провайдера.
|
||||
/// </summary>
|
||||
/// <param name="store">KV-хранилище настроек тенанта (aiProvider/aiConfigs).</param>
|
||||
/// <param name="secretCipher">Расшифровка секрета aiConfigs.apiKey (AES-GCM, Ruling 2).</param>
|
||||
public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(secretCipher);
|
||||
_store = store;
|
||||
_secretCipher = secretCipher;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Собирает ProviderConfig активного провайдера для тела запроса ai-service.
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto).</returns>
|
||||
public async Task<ProviderConfig> BuildAsync(CancellationToken ct)
|
||||
{
|
||||
string providerId = await ReadProviderIdAsync(ct);
|
||||
AiProviderDefinition meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId)
|
||||
?? AiProviders.All[0]; // неизвестный id — дефолтный провайдер (python L28)
|
||||
|
||||
JsonObject? overrides = await ReadAiConfigsOverrideAsync(ct);
|
||||
JsonObject? raw = overrides?[meta.Id] as JsonObject;
|
||||
|
||||
string apiKey = ReadField(raw, ApiKeyField);
|
||||
if (apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
apiKey = _secretCipher.Decrypt(apiKey);
|
||||
}
|
||||
|
||||
string baseUrl = ReadField(raw, BaseUrlField);
|
||||
if (baseUrl.Length == 0)
|
||||
{
|
||||
baseUrl = meta.Base; // python L90: cfg.baseUrl or meta.base
|
||||
}
|
||||
|
||||
string model = ReadField(raw, ModelField);
|
||||
if (model.Length == 0)
|
||||
{
|
||||
model = meta.Models.FirstOrDefault() ?? string.Empty; // python L91: cfg.model or models[0]
|
||||
}
|
||||
|
||||
var config = new ProviderConfig
|
||||
{
|
||||
ProviderId = meta.Id,
|
||||
BaseUrl = baseUrl,
|
||||
Model = model,
|
||||
};
|
||||
if (apiKey.Length > 0)
|
||||
{
|
||||
config.ApiKey = apiKey;
|
||||
}
|
||||
|
||||
if (meta.ApiStyle is { Length: > 0 } apiStyle)
|
||||
{
|
||||
config.ApiStyle = apiStyle;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Активный провайдер из настройки aiProvider (дефолт «deepseek», python L26).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Id провайдера (каталога AiProviders).
|
||||
private async Task<string> ReadProviderIdAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await _store.GetAsync(SettingsKeys.AiProvider, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return SettingsDefaults.AiProvider;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
JsonNode? value = JsonNode.Parse(row.ValueJson);
|
||||
if (value is JsonValue scalar && scalar.TryGetValue<string>(out string? providerId)
|
||||
&& !string.IsNullOrWhiteSpace(providerId))
|
||||
{
|
||||
return providerId;
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
|
||||
}
|
||||
|
||||
return SettingsDefaults.AiProvider;
|
||||
}
|
||||
|
||||
// Переопределение aiConfigs тенанта (JSON-объект «id провайдера → конфиг»); null — дефолты.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Объект переопределения или null.
|
||||
private async Task<JsonObject?> ReadAiConfigsOverrideAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await _store.GetAsync(SettingsKeys.AiConfigs, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(row.ValueJson) as JsonObject;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
// Повреждённая строка — дефолты (мягкая семантика, как в SettingsService).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Строковое поле конфига провайдера (отсутствие/null/не-строка → пустая строка).
|
||||
// config: Объект конфига провайдера (может быть null — дефолты).
|
||||
// field: Имя поля (apiKey/baseUrl/model).
|
||||
// Возвращает: Значение строкой или пустая строка.
|
||||
private static string ReadField(JsonObject? config, string field)
|
||||
{
|
||||
if (config is null || !config.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue value)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return value.TryGetValue<string>(out string? text) ? text ?? string.Empty : string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Конфигурация клиента AI-сервиса — секция <c>Services:Ai</c> (Ruling 6, план Task 15).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// По умолчанию dev = Local-адаптеры: <c>UseLocal=true</c> регистрирует LocalAiClassifier/LocalAiTools
|
||||
/// (фолбэк этапов 4–5: локальный разбор ядра, фильтр пропускает, ИИ-инструменты не поддерживаются), реальный
|
||||
/// ai-service подключается <c>Services:Ai:UseLocal=false</c> + endpoint (env
|
||||
/// <c>SERVICES__AI__USELOCAL=false</c>, <c>SERVICES__AI__ENDPOINT=http://localhost:5102</c>, compose — Ruling 12).
|
||||
/// Выбор реализации — на старте, логики переключения в рантайме нет (Ruling 6).
|
||||
/// </remarks>
|
||||
public sealed class AiServiceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя секции конфигурации (appsettings.json / env-префикс SERVICES__AI__*).
|
||||
/// </summary>
|
||||
public const string SectionName = "Services:Ai";
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint ai-service по умолчанию (dev-порт сервиса, Ruling 12).
|
||||
/// </summary>
|
||||
public const string DefaultEndpoint = "http://localhost:5102";
|
||||
|
||||
/// <summary>
|
||||
/// True — Local-адаптеры (default), false — gRPC-клиенты GrpcAiClassifier/GrpcAiTools.
|
||||
/// </summary>
|
||||
public bool UseLocal { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Базовый адрес ai-service (http://host:port; только без TLS — Ruling 2).
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = DefaultEndpoint;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Сбой вызова ИИ-сервиса (провайдер недоступен/не ответил корректно либо ответ без разбора) —
|
||||
/// сигнал порта IAiClassifier/IAiTools для веток фолбэка вызывающего.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Семантика 1:1 с прототипом, где <c>chat_json</c> бросает RuntimeError (ai.py L115–117): воркер Pipeline
|
||||
/// ловит исключение классификатора → локальный разбор (aiFail, python L1108–1114), ИИ-фильтр → «пропустить»
|
||||
/// (L1102–1106); Discovery-воркер при сбое EvaluateFit падает в эвристику (discovery_eval L186–194). Локальные
|
||||
/// реализации (LocalAiClassifier) детерминированы и этого исключения не бросают. Текст — стабильная строка
|
||||
/// без секретов и тел ответов (Ruling 13); detail gRPC-ошибки (дружелюбный текст ai-service «ИИ (имя) не
|
||||
/// ответил корректно…») пробрасывается, когда он есть.
|
||||
/// </remarks>
|
||||
public sealed class AiUnavailableException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Создаёт исключение сбоя ИИ-вызова.
|
||||
/// </summary>
|
||||
/// <param name="message">Текст ошибки (стабильный/из detail gRPC-ошибки).</param>
|
||||
public AiUnavailableException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Декоратор бюджетного гейта порта <see cref="IAiClassifier"/> (Ruling 3, Task 9): поверх «платного»
|
||||
/// исполнителя (gRPC-адаптер <see cref="GrpcAiClassifier"/>) перед каждым вызовом спрашивает гейт и при запрете
|
||||
/// ИИ уводит вызов на бесплатную локальную реализацию <see cref="LocalAiClassifier"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Гейт — <see cref="ITenantLimitStore.GetStateAsync"/> (public.tenant_limits + статус тенанта, Task 8):
|
||||
/// вызов разрешён, когда <see cref="BudgetStateDto.Allowed"/> — тенант active и бюджет периода не исчерпан
|
||||
/// (UsedTokens ≥ BudgetTokens; лимит 0 запрещает ИИ уже с нулевого расхода). Запрещено — исчерпание бюджета
|
||||
/// либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). При запрете фильтр/классификация
|
||||
/// выполняются Local-реализацией — семантика aiEnabled=false/aiFail этапов 4–5: фильтр {pass:true, skipped:true},
|
||||
/// разбор ядра <see cref="LocalFieldsParser"/> (детерминированный, бесплатный) — приём и обработка сообщений не
|
||||
/// блокируются, платный ИИ не зовётся и бюджет не расходуется. Списание usage остаётся внутри gRPC-адаптера
|
||||
/// (<see cref="TokenUsageRecorder"/>, Task 8) и выполняется только по реальным платным ответам. Регистрируется
|
||||
/// в <c>AddDealIntegrations</c> только при <c>Services:Ai:UseLocal=false</c> (порядок Grpc → Budgeted → наружу);
|
||||
/// в Local-режиме адаптер и так бесплатен — декоратор не нужен. Ошибки платного исполнителя
|
||||
/// (<see cref="AiUnavailableException"/>) пробрасываются как раньше — ветки фолбэка воркера не меняются.
|
||||
/// SSE-уведомления о пересечении порогов 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь
|
||||
/// запрет только логируется (Ruling 13: стабильные строки, без секретов).
|
||||
/// </remarks>
|
||||
public sealed class BudgetedAiClassifier : IAiClassifier
|
||||
{
|
||||
// Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту).
|
||||
private const string NoTenantContextText =
|
||||
"BudgetedAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid).";
|
||||
|
||||
// Причина запрета в логе (общий текст для исчерпания и приостановки).
|
||||
private const string GateDeniedLogText = "бюджет исчерпан или тенант приостановлен";
|
||||
|
||||
private readonly IAiClassifier _paidClassifier;
|
||||
private readonly LocalAiClassifier _localClassifier;
|
||||
private readonly ITenantLimitStore _tenantLimits;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<BudgetedAiClassifier> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт декоратор бюджетного гейта классификатора.
|
||||
/// </summary>
|
||||
/// <param name="paidClassifier">Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed).</param>
|
||||
/// <param name="localClassifier">Бесплатный локальный разбор/фильтр (fallback при запрете гейта).</param>
|
||||
/// <param name="tenantLimits">Хранилище лимитов бюджета (public.tenant_limits; источник гейта).</param>
|
||||
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal; tenantId запроса).</param>
|
||||
/// <param name="logger">Логгер переходов на локальный путь.</param>
|
||||
public BudgetedAiClassifier(
|
||||
IAiClassifier paidClassifier,
|
||||
LocalAiClassifier localClassifier,
|
||||
ITenantLimitStore tenantLimits,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<BudgetedAiClassifier> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paidClassifier);
|
||||
ArgumentNullException.ThrowIfNull(localClassifier);
|
||||
ArgumentNullException.ThrowIfNull(tenantLimits);
|
||||
ArgumentNullException.ThrowIfNull(tenantContext);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_paidClassifier = paidClassifier;
|
||||
_localClassifier = localClassifier;
|
||||
_tenantLimits = tenantLimits;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
|
||||
{
|
||||
if (await IsPaidAllowedAsync(ct))
|
||||
{
|
||||
return await _paidClassifier.FilterAsync(text, ct);
|
||||
}
|
||||
|
||||
// Запрет гейта — фильтр через Local-реализацию {pass:true, skipped:true} (семантика «фильтр недоступен»,
|
||||
// Ruling 3): сообщение не блокируется, платный фильтр не зовётся.
|
||||
_logger.LogDebug(
|
||||
"ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
|
||||
return await _localClassifier.FilterAsync(text, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
|
||||
{
|
||||
if (await IsPaidAllowedAsync(ct))
|
||||
{
|
||||
return await _paidClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
|
||||
// Запрет гейта — локальный разбор ядра (семантика aiEnabled=false/aiFail, Ruling 3): карточка строится
|
||||
// без платного ИИ, приём не блокируется.
|
||||
_logger.LogDebug(
|
||||
"ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog());
|
||||
return await _localClassifier.ClassifyAsync(text, ct);
|
||||
}
|
||||
|
||||
// Бюджетный гейт вызова (Ruling 3): true — платный ИИ разрешён (тенант active и бюджет не исчерпан).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True — можно звать платного исполнителя.
|
||||
private async Task<bool> IsPaidAllowedAsync(CancellationToken ct)
|
||||
{
|
||||
BudgetStateDto state = await _tenantLimits.GetStateAsync(RequireTenantId(), ct);
|
||||
return state.Allowed;
|
||||
}
|
||||
|
||||
// Текущий тенант scope как Guid строки public.tenants (гейт читает лимиты по тенанту).
|
||||
// Возвращает: Идентификатор тенанта (Guid).
|
||||
// Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id.
|
||||
private Guid RequireTenantId()
|
||||
{
|
||||
TenantId? tenantId = _tenantContext.TenantId;
|
||||
if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id))
|
||||
{
|
||||
throw new InvalidOperationException(NoTenantContextText);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// Id тенанта для лога («-» вне контекста — недостижимо после RequireTenantId).
|
||||
// Возвращает: Строка id тенанта.
|
||||
private string TenantIdForLog() => _tenantContext.TenantId?.Value ?? "-";
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Декоратор бюджетного гейта порта <see cref="IAiTools"/> (Ruling 3, Task 9): поверх «платного»
|
||||
/// исполнителя (gRPC-адаптер <see cref="GrpcAiTools"/>) перед каждым вызовом спрашивает гейт и при запрете ИИ
|
||||
/// не зовёт платный инструмент: <see cref="EvaluateFitAsync"/> бросает <see cref="AiUnavailableException"/>
|
||||
/// (вызывающий — воркер Discovery — сам уходит в эвристику, код не меняется, Ruling 10),
|
||||
/// <see cref="GenerateKeywordsAsync"/> отдаёт мягкую ошибку {ok:false, keywords:[], error} (Ruling 11: эндпоинт
|
||||
/// отвечает HTTP 200 {keywords: [], error}).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Гейт — <see cref="ITenantLimitStore.GetStateAsync"/> (public.tenant_limits + статус тенанта, Task 8): вызов
|
||||
/// разрешён, когда <see cref="BudgetStateDto.Allowed"/> — тенант active и бюджет периода не исчерпан; запрещено —
|
||||
/// исчерпание либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). Тексты запрета различают
|
||||
/// приостановку и исчерпание по <see cref="BudgetStateDto.Status"/> (стабильные строки без секретов, Ruling 13).
|
||||
/// Ошибки платного исполнителя (<see cref="AiUnavailableException"/>) пробрасываются как раньше — ветки фолбэка
|
||||
/// Discovery не меняются. Списание usage остаётся внутри gRPC-адаптера (<see cref="TokenUsageRecorder"/>, Task 8)
|
||||
/// и выполняется только по реальным платным ответам. Регистрируется в <c>AddDealIntegrations</c> только при
|
||||
/// <c>Services:Ai:UseLocal=false</c> (порядок Grpc → Budgeted → наружу). SSE-уведомления о пересечении порогов
|
||||
/// 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь запрет только логируется.
|
||||
/// </remarks>
|
||||
public sealed class BudgetedAiTools : IAiTools
|
||||
{
|
||||
// Текст мягкой ошибки generate-keywords при исчерпанном бюджете (Ruling 3).
|
||||
private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна";
|
||||
|
||||
// Текст мягкой ошибки generate-keywords при приостановке тенанта (Ruling 3/10(5)).
|
||||
private const string SuspendedKeywordsError = "Тенант приостановлен — генерация ключевых слов недоступна";
|
||||
|
||||
// Текст исключения EvaluateFit при исчерпанном бюджете (семантика локальной обработки, Ruling 3).
|
||||
private const string ExhaustedFitError = "ИИ-бюджет исчерпан — обработка в локальном режиме";
|
||||
|
||||
// Текст исключения EvaluateFit при приостановке тенанта (Ruling 3/10(5)).
|
||||
private const string SuspendedFitError = "Тенант приостановлен — ИИ-оценка заморожена";
|
||||
|
||||
private readonly IAiTools _paidTools;
|
||||
private readonly ITenantLimitStore _tenantLimits;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<BudgetedAiTools> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт декоратор бюджетного гейта ИИ-инструментов.
|
||||
/// </summary>
|
||||
/// <param name="paidTools">Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed).</param>
|
||||
/// <param name="tenantLimits">Хранилище лимитов бюджета (public.tenant_limits; источник гейта).</param>
|
||||
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal; tenantId запроса).</param>
|
||||
/// <param name="logger">Логгер переходов на локальный путь.</param>
|
||||
public BudgetedAiTools(
|
||||
IAiTools paidTools,
|
||||
ITenantLimitStore tenantLimits,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<BudgetedAiTools> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paidTools);
|
||||
ArgumentNullException.ThrowIfNull(tenantLimits);
|
||||
ArgumentNullException.ThrowIfNull(tenantContext);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_paidTools = paidTools;
|
||||
_tenantLimits = tenantLimits;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
|
||||
{
|
||||
BudgetStateDto state = await GateStateAsync(ct);
|
||||
if (state.Allowed)
|
||||
{
|
||||
return await _paidTools.GenerateKeywordsAsync(description, ct);
|
||||
}
|
||||
|
||||
// Мягкая ошибка для UI (Ruling 3/11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200.
|
||||
_logger.LogDebug(
|
||||
"generate-keywords: {Reason} — мягкая ошибка (тенант {TenantId})",
|
||||
DenyLogText(state),
|
||||
TenantIdForLog());
|
||||
return new AiGenerateKeywordsResultDto(
|
||||
Ok: false,
|
||||
Keywords: Array.Empty<string>(),
|
||||
Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiEvaluateFitResultDto> EvaluateFitAsync(
|
||||
string text, string description, IReadOnlyCollection<string> keywords, CancellationToken ct)
|
||||
{
|
||||
BudgetStateDto state = await GateStateAsync(ct);
|
||||
if (state.Allowed)
|
||||
{
|
||||
return await _paidTools.EvaluateFitAsync(text, description, keywords, ct);
|
||||
}
|
||||
|
||||
// Сбой ИИ-оценки не роняет оценку кандидата: воркер Discovery падает в эвристику (Ruling 3/10,
|
||||
// python L186–194 — код вызывающего не меняется).
|
||||
_logger.LogDebug(
|
||||
"evaluate-fit: {Reason} — эвристика (тенант {TenantId})",
|
||||
DenyLogText(state),
|
||||
TenantIdForLog());
|
||||
throw new AiUnavailableException(
|
||||
state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError);
|
||||
}
|
||||
|
||||
// Текущее состояние бюджета тенанта (ленивый reset периода + Allowed/Status для гейта, Task 9).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Состояние бюджета тенанта на сейчас.
|
||||
private async Task<BudgetStateDto> GateStateAsync(CancellationToken ct)
|
||||
=> await _tenantLimits.GetStateAsync(RequireTenantId(), ct);
|
||||
|
||||
// Причина запрета в логе: приостановка и исчерпание различаются (короткая строка без секретов).
|
||||
// state: Состояние бюджета тенанта.
|
||||
// Возвращает: Текст причины.
|
||||
private static string DenyLogText(BudgetStateDto state)
|
||||
=> state.Status == TenantStatuses.Suspended ? "тенант приостановлен" : "ИИ-бюджет исчерпан";
|
||||
|
||||
// Текущий тенант scope как Guid строки public.tenants (гейт читает лимиты по тенанту).
|
||||
// Возвращает: Идентификатор тенанта (Guid).
|
||||
// Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id.
|
||||
private Guid RequireTenantId()
|
||||
{
|
||||
TenantId? tenantId = _tenantContext.TenantId;
|
||||
if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"BudgetedAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid).");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// Id тенанта для лога («-» вне контекста — недостижимо после RequireTenantId).
|
||||
// Возвращает: Строка id тенанта.
|
||||
private string TenantIdForLog() => _tenantContext.TenantId?.Value ?? "-";
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-источник курсов ЦБ РФ: GET daily_json.js (Ruling 6, Task 8; 1:1 rates.py L43–59).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Запрос — GET <c>https://www.cbr-xml-daily.ru/daily_json.js</c> (JSON-зеркало ЦБ). SSRF-контур: URL —
|
||||
/// фиксированная константа (allowlist), тенант не управляет адресом источника (в отличие от baseUrl
|
||||
/// AI-провайдеров, Task 6). Таймаут клиента — 15 с (python: <c>httpx timeout=15</c>), задаётся
|
||||
/// DI-регистрацией AddHttpClient в Deal.Api. Парсинг: <c>Valute[code].Value / Nominal</c> (1 единица
|
||||
/// валюты в рублях; Nominal может быть > 1, напр. 100 KZT), к курсам добавляется <c>RUB:1</c>.
|
||||
/// Любой сбой (HTTP-код ≠ 2xx, нераспознанное тело/запись, сетевая ошибка) → null + warning — кэш
|
||||
/// RatesService при этом не трогает (Ruling 6). Отмена вызывающего пробрасывается (не «сбой»).
|
||||
/// </remarks>
|
||||
public sealed class CbrRateSource : IRatesSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Таймаут HTTP-запроса в секундах (python rates.py L46: <c>timeout=15</c>).
|
||||
/// </summary>
|
||||
public const int RequestTimeoutSeconds = 15;
|
||||
|
||||
// URL JSON-зеркала курсов ЦБ (constants.py L52). Фиксированный — SSRF-allowlist.
|
||||
private const string CbrUrl = "https://www.cbr-xml-daily.ru/daily_json.js";
|
||||
|
||||
// Корневой объект ответа: валюта → {Value, Nominal, …}.
|
||||
private const string ValutePropertyName = "Valute";
|
||||
|
||||
// Курс единицы валюты в рублях (число).
|
||||
private const string ValuePropertyName = "Value";
|
||||
|
||||
// Номинал (сколько единиц за курс Value; может быть > 1).
|
||||
private const string NominalPropertyName = "Nominal";
|
||||
|
||||
// Базовая валюта ответа: курсы даются к рублю.
|
||||
private const string BaseCurrency = "RUB";
|
||||
|
||||
// Курс рубля к рублю (всегда 1.0, rates.py L50).
|
||||
private const double RubToRubRate = 1.0;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<CbrRateSource> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт источник поверх HttpClient.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">Клиент с таймаутом 15 с (DI: AddHttpClient в Deal.Api).</param>
|
||||
/// <param name="logger">Логгер предупреждений о сбоях.</param>
|
||||
public CbrRateSource(HttpClient httpClient, ILogger<CbrRateSource> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpClient);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<string, double>?> FetchAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(CbrUrl, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await ParseRatesAsync(response, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Отменил вызывающий (обрыв запроса/фоновой задачи) — пробрасываем, это не «сбой источника».
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Любой сбой HTTP/парсинга = неуспех источника (python ловит все исключения, rates.py L57–59).
|
||||
_logger.LogWarning("CBR fetch failed: {Reason}", exception.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Разбирает тело daily_json.js в курсы к рублю; нераспознанное тело/запись — null.
|
||||
// response: Успешный HTTP-ответ (статус 2xx).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Словарь «код валюты → курс к RUB» (RUB:1 в начале) или null.
|
||||
private static async Task<Dictionary<string, double>?> ParseRatesAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
using Stream content = await response.Content.ReadAsStreamAsync(ct);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(content, cancellationToken: ct);
|
||||
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty(ValutePropertyName, out JsonElement valute)
|
||||
|| valute.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
// Тело не похоже на daily_json.js (нет объекта Valute) — это не курсы ЦБ.
|
||||
return null;
|
||||
}
|
||||
|
||||
var rates = new Dictionary<string, double> { [BaseCurrency] = RubToRubRate };
|
||||
foreach (JsonProperty currency in valute.EnumerateObject())
|
||||
{
|
||||
if (!TryParseCurrency(currency, out double rate))
|
||||
{
|
||||
// Нераспознанная запись валюты: как и исключение python внутри цикла, роняет весь fetch.
|
||||
return null;
|
||||
}
|
||||
|
||||
rates[currency.Name] = rate;
|
||||
}
|
||||
|
||||
return rates;
|
||||
}
|
||||
|
||||
// Разбирает одну запись валюты: курс = Value / Nominal, округлён до 6 знаков (rates.py L51–55).
|
||||
// currency: Пара «код валюты → объект {Value, Nominal}».
|
||||
// rate: Курс единицы валюты к рублю (валиден при возврате true).
|
||||
// Возвращает: True — запись распознана; False — повреждённая запись (весь fetch — сбой).
|
||||
private static bool TryParseCurrency(JsonProperty currency, out double rate)
|
||||
{
|
||||
rate = 0;
|
||||
if (currency.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonElement item = currency.Value;
|
||||
|
||||
// Значение по умолчанию, как в python: отсутствующий Value → 0, Nominal → 1 (иначе — сбой).
|
||||
double value = 0;
|
||||
if (item.TryGetProperty(ValuePropertyName, out JsonElement valueElement))
|
||||
{
|
||||
if (!TryReadDouble(valueElement, out value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
double nominal = 1;
|
||||
if (item.TryGetProperty(NominalPropertyName, out JsonElement nominalElement))
|
||||
{
|
||||
if (!TryReadDouble(nominalElement, out nominal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (nominal == 0)
|
||||
{
|
||||
nominal = 1; // python: int(...) or 1 — нулевой номинал трактуем как 1
|
||||
}
|
||||
|
||||
rate = Math.Round(value / nominal, 6);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Читает число из JSON-элемента (число или строка, как их отдаёт зеркало).
|
||||
// element: JSON-элемент записи валюты.
|
||||
// value: Прочитанное число (инвариантная культура).
|
||||
// Возвращает: True — элемент распознан как число.
|
||||
private static bool TryReadDouble(JsonElement element, out double value)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Number:
|
||||
value = element.GetDouble();
|
||||
return true;
|
||||
|
||||
case JsonValueKind.String:
|
||||
return double.TryParse(element.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||
|
||||
default:
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Modules.Pipeline.Application;
|
||||
using Deal.SharedKernel.Tenants;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="IAiClassifier"/> к автономному ai-service (Ruling 5/6, план Task 15
|
||||
/// L412–434).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется вместо Local-реализации при <c>Services:Ai:UseLocal=false</c> (выбор на старте, Ruling 6).
|
||||
/// Поведение 1:1 с <c>backend/app/services/ai.py</c> filter_incoming L188–198 / classify L218–258 и ai.proto:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="FilterAsync"/> — RPC Filter (deadline 120 с, README контрактов): заполненный aiFilterPrompt
|
||||
/// из настроек (<see cref="AiClassifyContextBuilder"/>) + текст ≤4000; недоступность провайдера → RPC-ошибка →
|
||||
/// <see cref="AiUnavailableException"/> (воркер отвечает «пропустить», python L1102–1106);</item>
|
||||
/// <item><see cref="ClassifyAsync"/> — RPC Classify: system_prompt = aiPrompt+cardPrompt, user-контекст «Доски +
|
||||
/// примеры разметки + Сообщение» (собирает билдер по данным тенанта, python L226–251); ok=false/сбой →
|
||||
/// <see cref="AiUnavailableException"/> (воркер собирает локальный разбор, aiFail, python L1108–1114);
|
||||
/// ok=true → строгий маппинг JSON в <see cref="AiParsedCardDto"/> (<see cref="AiRawCardMapper"/>, 1:1
|
||||
/// normalize_stack/clean_budget/build_contacts);</item>
|
||||
/// <item>конфиг провайдера на каждый запрос — <see cref="AiProviderConfigBuilder"/> (Ruling 5: core читает
|
||||
/// aiConfigs тенанта, расшифровывает apiKey); usage ответов списывается с бюджета тенанта в tenant_limits и
|
||||
/// копится в lifetime-KV aiTokenUsage (<see cref="TokenUsageRecorder"/>, Ruling 3 этапа 7).</item>
|
||||
/// </list>
|
||||
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="AiGrpcConnection"/>, Ruling 1). Scoped:
|
||||
/// настройки/доски/журнал тенанта читаются через scoped-хранилища (ISettingsStore/ICardStore), как
|
||||
/// LocalAiClassifier/GrpcMlClient. Ветки выключателей aiEnabled/aiFilterEnabled порт не читает — их
|
||||
/// отрабатывает воркер (Ruling 5 этапа 4).
|
||||
/// </remarks>
|
||||
public sealed class GrpcAiClassifier : IAiClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с).
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит текста сообщения фильтра (ai.py filter_incoming L193: text[:4000]).
|
||||
/// </summary>
|
||||
public const int MaxFilterTextCodePoints = 4000;
|
||||
|
||||
// Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен).
|
||||
private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд";
|
||||
|
||||
// Текст ошибки ветки «модель не вернула разбор» (ClassifyReply.ok=false, README ai.proto).
|
||||
private const string NoJsonAnswerText = "ИИ не дал разбора — ответ модели без JSON (повторите попытку через несколько секунд)";
|
||||
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly AiGrpcConnection _connection;
|
||||
private readonly AiProviderConfigBuilder _providerConfigBuilder;
|
||||
private readonly AiClassifyContextBuilder _contextBuilder;
|
||||
private readonly TokenUsageRecorder _usageRecorder;
|
||||
private readonly ILogger<GrpcAiClassifier> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт gRPC-адаптер классификатора ai-service.
|
||||
/// </summary>
|
||||
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами).</param>
|
||||
/// <param name="connection">Транспорт ai-service (singleton-канал + service-token).</param>
|
||||
/// <param name="providerConfigBuilder">Конфиг активного провайдера из настроек тенанта.</param>
|
||||
/// <param name="contextBuilder">Промпты и user-контекст классификации (модуль Pipeline).</param>
|
||||
/// <param name="usageRecorder">Списание usage ответов: бюджет периода tenant_limits + lifetime-KV aiTokenUsage.</param>
|
||||
/// <param name="logger">Логгер сбоев.</param>
|
||||
public GrpcAiClassifier(
|
||||
ITenantContext tenantContext,
|
||||
AiGrpcConnection connection,
|
||||
AiProviderConfigBuilder providerConfigBuilder,
|
||||
AiClassifyContextBuilder contextBuilder,
|
||||
TokenUsageRecorder usageRecorder,
|
||||
ILogger<GrpcAiClassifier> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tenantContext);
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentNullException.ThrowIfNull(providerConfigBuilder);
|
||||
ArgumentNullException.ThrowIfNull(contextBuilder);
|
||||
ArgumentNullException.ThrowIfNull(usageRecorder);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_tenantContext = tenantContext;
|
||||
_connection = connection;
|
||||
_providerConfigBuilder = providerConfigBuilder;
|
||||
_contextBuilder = contextBuilder;
|
||||
_usageRecorder = usageRecorder;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
string prompt = await _contextBuilder.BuildFilterPromptAsync(ct);
|
||||
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
|
||||
AiService.AiServiceClient client = _connection.CreateClient();
|
||||
FilterReply reply = await client.FilterAsync(
|
||||
new FilterRequest
|
||||
{
|
||||
Prompt = prompt,
|
||||
Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000]
|
||||
ProviderConfig = providerConfig,
|
||||
},
|
||||
CallOptions(tenantId.Value, ct));
|
||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||
|
||||
// Фильтр применён (воркер звал его только при aiFilterEnabled и не force) — skipped=false
|
||||
// (python filter_incoming L194–198: {pass, reason, skipped:false}).
|
||||
return new AiFilterResultDto(
|
||||
Pass: reply.Pass,
|
||||
Reason: reply.HasReason ? reply.Reason : null,
|
||||
Skipped: false);
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
// Провайдер/сервис недоступен — воркер отвечает «пропустить» (python L1102–1106: r2=pass+skipped).
|
||||
_logger.LogDebug(exception, "ИИ-фильтр недоступен (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(ErrorText(exception));
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "ИИ-фильтр недоступен (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(ServiceUnavailableText);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
string systemPrompt = await _contextBuilder.BuildClassifySystemPromptAsync(ct);
|
||||
string userContext = await _contextBuilder.BuildClassifyUserContextAsync(text, ct);
|
||||
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
|
||||
|
||||
AiService.AiServiceClient client = _connection.CreateClient();
|
||||
ClassifyReply reply;
|
||||
try
|
||||
{
|
||||
reply = await client.ClassifyAsync(
|
||||
new ClassifyRequest
|
||||
{
|
||||
SystemPrompt = systemPrompt,
|
||||
UserContext = userContext,
|
||||
ProviderConfig = providerConfig,
|
||||
},
|
||||
CallOptions(tenantId.Value, ct));
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
// Классификатор недоступен — как raw={} в прототипе (L1112–1114): локальный разбор, aiFail.
|
||||
_logger.LogDebug(exception, "ИИ-классификация недоступна (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(ErrorText(exception));
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "ИИ-классификация недоступна (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(ServiceUnavailableText);
|
||||
}
|
||||
|
||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||
if (!reply.Ok)
|
||||
{
|
||||
// Модель не вернула разбираемый JSON после ретраев — контрактная ok=false (README ai.proto):
|
||||
// ядро трактует как «разбора нет» и падает в локальный путь (python: RuntimeError → raw={}).
|
||||
_logger.LogWarning("ИИ-классификация: ok=false (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(NoJsonAnswerText);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return AiRawCardMapper.Map(reply.HasJson ? reply.Json : string.Empty, text);
|
||||
}
|
||||
catch (System.Text.Json.JsonException exception)
|
||||
{
|
||||
// ok=true, но тело не объект/не разбирается — защита от нарушения контракта сервисом: как ok=false.
|
||||
_logger.LogWarning(exception, "ИИ-классификация: ответ ok=true без разбора (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(NoJsonAnswerText);
|
||||
}
|
||||
}
|
||||
|
||||
// Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла).
|
||||
// Возвращает: Идентификатор тенанта.
|
||||
// Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация).
|
||||
private TenantId RequireTenant()
|
||||
=> _tenantContext.TenantId
|
||||
?? throw new InvalidOperationException(
|
||||
"GrpcAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
|
||||
|
||||
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// ct: Токен отмены вызова.
|
||||
// Возвращает: Опции вызова с заголовками, deadline и отменой.
|
||||
private CallOptions CallOptions(string tenantId, CancellationToken ct)
|
||||
=> new(
|
||||
headers: _connection.CreateMetadata(tenantId),
|
||||
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
|
||||
cancellationToken: ct);
|
||||
|
||||
// Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13:
|
||||
// секреты/тела ответов не логируются и в текст не попадают).
|
||||
// exception: Исключение RPC-вызова.
|
||||
// Возвращает: Текст ошибки.
|
||||
private static string ErrorText(RpcException exception)
|
||||
{
|
||||
string detail = exception.Status.Detail?.Trim() ?? string.Empty;
|
||||
return detail.Length > 0 ? detail : ServiceUnavailableText;
|
||||
}
|
||||
|
||||
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
|
||||
// text: Строка.
|
||||
// max: Лимит.
|
||||
// Возвращает: Усечённая строка.
|
||||
private static string SliceCodePoints(string text, int max)
|
||||
{
|
||||
return text.Length <= max ? text : SliceByCodePoints(text, max);
|
||||
}
|
||||
|
||||
// Ручной срез по кодовым точкам (суррогатная пара не разрывается).
|
||||
// text: Строка длиннее лимита.
|
||||
// max: Максимум кодовых точек.
|
||||
// Возвращает: Усечённая строка.
|
||||
private static string SliceByCodePoints(string text, int max)
|
||||
{
|
||||
var builder = new System.Text.StringBuilder(max);
|
||||
int taken = 0;
|
||||
for (int index = 0; index < text.Length && taken < max; index++)
|
||||
{
|
||||
bool pair = char.IsHighSurrogate(text[index])
|
||||
&& index + 1 < text.Length
|
||||
&& char.IsLowSurrogate(text[index + 1]);
|
||||
builder.Append(text[index]);
|
||||
if (pair)
|
||||
{
|
||||
index++;
|
||||
builder.Append(text[index]);
|
||||
}
|
||||
|
||||
taken++;
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.SharedKernel.Tenants;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="IAiTools"/> к автономному ai-service (Ruling 9, план Task 15/18/19).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется вместо Local-реализации при <c>Services:Ai:UseLocal=false</c> (Ruling 6). Поведение 1:1 с
|
||||
/// ai.proto GenerateKeywords/EvaluateFit и прототипом:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="GenerateKeywordsAsync"/> — RPC GenerateKeywords (deadline 120 с): конфиг провайдера из
|
||||
/// настроек, описание ≤4000 (discovery_routes L29); недоступность — мягкий {ok:false, keywords:[], error}
|
||||
/// (Ruling 11: generate-keywords-эндпоинт отдаёт HTTP 200 {keywords: [], error});</item>
|
||||
/// <item><see cref="EvaluateFitAsync"/> — RPC EvaluateFit: текст ≤4000 (discovery_eval L41) + описание и ключи
|
||||
/// задачи; сбой — <see cref="AiUnavailableException"/> (воркер Discovery падает в эвристику, Ruling 10);
|
||||
/// успех — {fit, reason} (потолок причины 200 задаёт сервис, _AI_REASON_LIMIT L43);</item>
|
||||
/// <item>usage ответов списывается с бюджета тенанта в tenant_limits и копится в lifetime-KV aiTokenUsage
|
||||
/// (<see cref="TokenUsageRecorder"/>, Ruling 3 этапа 7), как у классификатора.</item>
|
||||
/// </list>
|
||||
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="AiGrpcConnection"/>, Ruling 1). Scoped:
|
||||
/// настройки провайдера читаются через scoped-хранилище тенанта (ISettingsStore), как GrpcAiClassifier.
|
||||
/// Выключатель aiEnabled порт не читает — его отрабатывает вызывающий (воркер/эндпоинт Discovery, Ruling 10/11).
|
||||
/// </remarks>
|
||||
public sealed class GrpcAiTools : IAiTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с).
|
||||
/// </summary>
|
||||
public const int RpcDeadlineSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит описания ниши generate-keywords (discovery_routes L29: обрезает до 4000).
|
||||
/// </summary>
|
||||
public const int MaxDescriptionCodePoints = 4000;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит текста сообщения evaluate-fit (discovery_eval L41: _AI_TEXT_LIMIT=4000).
|
||||
/// </summary>
|
||||
public const int MaxEvalTextCodePoints = 4000;
|
||||
|
||||
// Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен).
|
||||
private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд";
|
||||
|
||||
// Причина по умолчанию при fit=true, если сервис причину не вернул (1:1 _AI_REASON_LIMIT L170).
|
||||
private const string FitReasonDefault = "подходит";
|
||||
|
||||
// Причина по умолчанию при fit=false, если сервис причину не вернул (1:1 L170).
|
||||
private const string NotFitReasonDefault = "не подходит";
|
||||
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly AiGrpcConnection _connection;
|
||||
private readonly AiProviderConfigBuilder _providerConfigBuilder;
|
||||
private readonly TokenUsageRecorder _usageRecorder;
|
||||
private readonly ILogger<GrpcAiTools> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт gRPC-адаптер ИИ-инструментов ai-service.
|
||||
/// </summary>
|
||||
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами).</param>
|
||||
/// <param name="connection">Транспорт ai-service (singleton-канал + service-token).</param>
|
||||
/// <param name="providerConfigBuilder">Конфиг активного провайдера из настроек тенанта.</param>
|
||||
/// <param name="usageRecorder">Списание usage ответов: бюджет периода tenant_limits + lifetime-KV aiTokenUsage.</param>
|
||||
/// <param name="logger">Логгер сбоев.</param>
|
||||
public GrpcAiTools(
|
||||
ITenantContext tenantContext,
|
||||
AiGrpcConnection connection,
|
||||
AiProviderConfigBuilder providerConfigBuilder,
|
||||
TokenUsageRecorder usageRecorder,
|
||||
ILogger<GrpcAiTools> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tenantContext);
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentNullException.ThrowIfNull(providerConfigBuilder);
|
||||
ArgumentNullException.ThrowIfNull(usageRecorder);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_tenantContext = tenantContext;
|
||||
_connection = connection;
|
||||
_providerConfigBuilder = providerConfigBuilder;
|
||||
_usageRecorder = usageRecorder;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
AiService.AiServiceClient client = _connection.CreateClient();
|
||||
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
|
||||
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest
|
||||
{
|
||||
Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints),
|
||||
ProviderConfig = providerConfig,
|
||||
},
|
||||
CallOptions(tenantId.Value, ct));
|
||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||
return new AiGenerateKeywordsResultDto(
|
||||
Ok: true,
|
||||
Keywords: reply.Keywords.ToList(),
|
||||
Error: null);
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
// Мягкая ошибка для UI (Ruling 11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200.
|
||||
_logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value);
|
||||
return new AiGenerateKeywordsResultDto(Ok: false, Keywords: Array.Empty<string>(), Error: ErrorText(exception));
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value);
|
||||
return new AiGenerateKeywordsResultDto(
|
||||
Ok: false, Keywords: Array.Empty<string>(), Error: ServiceUnavailableText);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiEvaluateFitResultDto> EvaluateFitAsync(
|
||||
string text, string description, IReadOnlyCollection<string> keywords, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
AiService.AiServiceClient client = _connection.CreateClient();
|
||||
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
|
||||
var request = new EvaluateFitRequest
|
||||
{
|
||||
Text = SliceCodePoints(text ?? string.Empty, MaxEvalTextCodePoints),
|
||||
Description = description ?? string.Empty,
|
||||
ProviderConfig = providerConfig,
|
||||
};
|
||||
foreach (string keyword in keywords ?? Array.Empty<string>())
|
||||
{
|
||||
if (!string.IsNullOrEmpty(keyword))
|
||||
{
|
||||
request.Keywords.Add(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
EvaluateFitReply reply = await client.EvaluateFitAsync(request, CallOptions(tenantId.Value, ct));
|
||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||
return new AiEvaluateFitResultDto(
|
||||
Fit: reply.Fit,
|
||||
Reason: reply.HasReason && reply.Reason.Length > 0
|
||||
? reply.Reason
|
||||
: (reply.Fit ? FitReasonDefault : NotFitReasonDefault));
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
// Сбой ИИ-оценки не роняет оценку кандидата — воркер падает в эвристику (Ruling 10, python L191–192).
|
||||
_logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(ErrorText(exception));
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value);
|
||||
throw new AiUnavailableException(ServiceUnavailableText);
|
||||
}
|
||||
}
|
||||
|
||||
// Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла).
|
||||
// Возвращает: Идентификатор тенанта.
|
||||
// Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация).
|
||||
private TenantId RequireTenant()
|
||||
=> _tenantContext.TenantId
|
||||
?? throw new InvalidOperationException(
|
||||
"GrpcAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
|
||||
|
||||
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// ct: Токен отмены вызова.
|
||||
// Возвращает: Опции вызова с заголовками, deadline и отменой.
|
||||
private CallOptions CallOptions(string tenantId, CancellationToken ct)
|
||||
=> new(
|
||||
headers: _connection.CreateMetadata(tenantId),
|
||||
deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)),
|
||||
cancellationToken: ct);
|
||||
|
||||
// Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13:
|
||||
// секреты/тела ответов не логируются и в текст не попадают).
|
||||
// exception: Исключение RPC-вызова.
|
||||
// Возвращает: Текст ошибки.
|
||||
private static string ErrorText(RpcException exception)
|
||||
{
|
||||
string detail = exception.Status.Detail?.Trim() ?? string.Empty;
|
||||
return detail.Length > 0 ? detail : ServiceUnavailableText;
|
||||
}
|
||||
|
||||
// Первые max кодовых точек строки (python-срез без разрыва суррогатных пар).
|
||||
// text: Строка.
|
||||
// max: Лимит.
|
||||
// Возвращает: Усечённая строка.
|
||||
private static string SliceCodePoints(string text, int max)
|
||||
{
|
||||
if (text.Length <= max)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
var builder = new System.Text.StringBuilder(max);
|
||||
int taken = 0;
|
||||
for (int index = 0; index < text.Length && taken < max; index++)
|
||||
{
|
||||
bool pair = char.IsHighSurrogate(text[index])
|
||||
&& index + 1 < text.Length
|
||||
&& char.IsLowSurrogate(text[index + 1]);
|
||||
builder.Append(text[index]);
|
||||
if (pair)
|
||||
{
|
||||
index++;
|
||||
builder.Append(text[index]);
|
||||
}
|
||||
|
||||
taken++;
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ml;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.SharedKernel.Tenants;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта IMlClient к автономному ml-service (Ruling 4/6, план Task 16 L438–445).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется вместо Local-заглушки при <c>Services:Ml:UseLocal=false</c> (выбор на старте, Ruling 6).
|
||||
/// Поведение 1:1 с <c>backend/app/services/ml_client.py</c> и ml.proto:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="StatusAsync"/> — статус модели из ml-service (RPC Status, deadline 10 с) с кэшем 15 с
|
||||
/// (<see cref="MlStatusCache"/>, python L30–31/127–135) + локальная статистика тенанта из KV/таблиц
|
||||
/// (счётчики ml/ai, learning = count(CardMoves), outbox = count(MlOutbox)); сервис недоступен — старые
|
||||
/// данные кэша (или «не готова») и <c>reachable=false</c>;</item>
|
||||
/// <item><see cref="PredictAsync"/> — RPC Predict (deadline 5 с); сбой/недоступность → фиксированный «не
|
||||
/// уверен» (python L101–107: решит ИИ/локальный путь воркера);</item>
|
||||
/// <item><see cref="ResetAsync"/> — RPC Reset (deadline 10 с); при успехе — очистка своей очереди
|
||||
/// MlOutbox (reset_model L110–124) и инвалидация кэша статуса; сбой — мягкий <c>{ok:false,error}</c>,
|
||||
/// очередь не трогается;</item>
|
||||
/// <item><see cref="PushAsync"/> — ВСЕГДА запись в MlOutbox через <see cref="IMlLearningStore"/> (Ruling 6:
|
||||
/// обучение гарантированно и локально; отправку батчами делает <c>MlOutboxFlushScheduler</c>);</item>
|
||||
/// <item><see cref="TrainBatchAsync"/> (IMlTrainClient) — RPC TrainBatch (deadline 30 с) для фонового флашера.</item>
|
||||
/// </list>
|
||||
/// Каждый вызов несёт metadata tenant-id + service-token (<see cref="MlGrpcConnection"/>, Ruling 1). Scoped:
|
||||
/// локальная статистика читает KV-настройки и таблицы тенанта (ISettingsStore/IMlLearningStore → scoped
|
||||
/// TenantDbContext), как LocalMlClient.
|
||||
/// </remarks>
|
||||
public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline Predict — 5 с (README контрактов: локальная модель).
|
||||
/// </summary>
|
||||
public const int PredictDeadlineSeconds = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline Status/Reset — 10 с (README контрактов).
|
||||
/// </summary>
|
||||
public const int StatusDeadlineSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline TrainBatch — 30 с (README контрактов: батч ≤100, 1 транзакция).
|
||||
/// </summary>
|
||||
public const int TrainBatchDeadlineSeconds = 30;
|
||||
|
||||
// Текст мягкой ошибки, когда сервис вернул ResetReply.ok=false без error (резерв).
|
||||
private const string DefaultResetError = "ML-сервис не смог сбросить модель";
|
||||
|
||||
// Пустой словарь весов предсказания/классов неготовой модели.
|
||||
private static readonly IReadOnlyDictionary<string, double> EmptyScores = new Dictionary<string, double>();
|
||||
|
||||
// Контекст текущего тенанта (id — в metadata вызовов; scoped-хранилища строятся от него же).
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
// KV-хранилище настроек тенанта (выключатель mlEnabled, счётчики ml/ai).
|
||||
private readonly ISettingsStore _store;
|
||||
|
||||
// Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves.
|
||||
private readonly IMlLearningStore _learningStore;
|
||||
|
||||
// Транспорт gRPC ml-service (канал + metadata).
|
||||
private readonly MlGrpcConnection _connection;
|
||||
|
||||
// Кэш статуса сервиса на тенанта (15 с).
|
||||
private readonly MlStatusCache _statusCache;
|
||||
|
||||
// Recorder истории расхода (ML-событие расхода, этап 10, T2): оценка токенов входного текста.
|
||||
private readonly TokenUsageRecorder _usageRecorder;
|
||||
|
||||
// Логгер сбоев вызовов ml-service.
|
||||
private readonly ILogger<GrpcMlClient> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт gRPC-адаптер клиента ML-сервиса.
|
||||
/// </summary>
|
||||
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами).</param>
|
||||
/// <param name="store">KV-хранилище настроек тенанта.</param>
|
||||
/// <param name="learningStore">Хранилище обучения ML (очередь MlOutbox + журнал).</param>
|
||||
/// <param name="connection">Транспорт ml-service (singleton-канал + service-token).</param>
|
||||
/// <param name="statusCache">Кэш статуса сервиса на тенанта (singleton).</param>
|
||||
/// <param name="usageRecorder">Recorder истории расхода (ML-событие predict, этап 10, T2).</param>
|
||||
/// <param name="logger">Логгер сбоев.</param>
|
||||
public GrpcMlClient(
|
||||
ITenantContext tenantContext,
|
||||
ISettingsStore store,
|
||||
IMlLearningStore learningStore,
|
||||
MlGrpcConnection connection,
|
||||
MlStatusCache statusCache,
|
||||
TokenUsageRecorder usageRecorder,
|
||||
ILogger<GrpcMlClient> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tenantContext);
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(learningStore);
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentNullException.ThrowIfNull(statusCache);
|
||||
ArgumentNullException.ThrowIfNull(usageRecorder);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_tenantContext = tenantContext;
|
||||
_store = store;
|
||||
_learningStore = learningStore;
|
||||
_connection = connection;
|
||||
_statusCache = statusCache;
|
||||
_usageRecorder = usageRecorder;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MlStatusResponseDto> StatusAsync(CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
MlStatusCache.Snapshot snapshot = await GetServiceSnapshotAsync(tenantId, ct);
|
||||
|
||||
bool enabled = await ReadMlEnabledAsync(ct);
|
||||
int mlDecisions = await ReadCounterAsync(SettingsKeys.MlDecisions, ct);
|
||||
int aiDecisions = await ReadCounterAsync(SettingsKeys.AiDecisions, ct);
|
||||
int learning = await _learningStore.CountLearningAsync(ct);
|
||||
int outbox = await _learningStore.CountOutboxAsync(ct);
|
||||
|
||||
var stats = new MlStatsDto(
|
||||
Ml: mlDecisions,
|
||||
Ai: aiDecisions,
|
||||
Learning: learning,
|
||||
Ready: snapshot.Service.Ready,
|
||||
Classes: snapshot.Service.Classes,
|
||||
Learned: snapshot.Service.Learned,
|
||||
Reachable: snapshot.Reachable,
|
||||
Outbox: outbox);
|
||||
|
||||
return new MlStatusResponseDto(Enabled: enabled, Service: snapshot.Service, Reachable: snapshot.Reachable, Stats: stats);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
MlService.MlServiceClient client = _connection.CreateClient();
|
||||
PredictReply reply = await client.PredictAsync(
|
||||
new PredictRequest { Text = text ?? string.Empty },
|
||||
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct));
|
||||
|
||||
// История расхода (этап 10, T2): ML-ответ токенов не несёт — оценка входного текста (≈chars/4),
|
||||
// бюджет/lifetime AI-счётчик не затрагиваются (локальная модель бесплатна).
|
||||
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
|
||||
return MapPredict(reply);
|
||||
}
|
||||
catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException)
|
||||
{
|
||||
// Сервис недоступен/таймаут/отмена — «не уверен» (python predict L101–107): решит ИИ/локальный путь.
|
||||
_logger.LogDebug(exception, "ML predict недоступен (тенант {TenantId})", tenantId.Value);
|
||||
return NotReadyPrediction;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MlResetResultDto> ResetAsync(CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
ResetReply reply;
|
||||
try
|
||||
{
|
||||
MlService.MlServiceClient client = _connection.CreateClient();
|
||||
reply = await client.ResetAsync(
|
||||
new ResetRequest(),
|
||||
CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct));
|
||||
}
|
||||
catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException)
|
||||
{
|
||||
// Мягкая ошибка реального сервиса (python reset_model L117–121): ok:false + текст; outbox не трогаем.
|
||||
_logger.LogWarning(exception, "ML reset не удался (тенант {TenantId})", tenantId.Value);
|
||||
return new MlResetResultDto(Ok: false, Error: ErrorText(exception));
|
||||
}
|
||||
|
||||
if (!reply.Ok)
|
||||
{
|
||||
return new MlResetResultDto(Ok: false, Error: reply.HasError ? reply.Error : DefaultResetError);
|
||||
}
|
||||
|
||||
// 1:1 reset_model L122–123: после успешного сброса сервиса — очистка своей очереди + свежий статус.
|
||||
await _learningStore.ClearOutboxAsync(ct);
|
||||
_statusCache.Invalidate(tenantId.Value);
|
||||
return new MlResetResultDto(Ok: true, Error: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PushAsync(string text, string label, double delta, CancellationToken ct)
|
||||
{
|
||||
// Обучение гарантированно и локально (Ruling 6): сигнал всегда пишется в MlOutbox, отправку батчами
|
||||
// делает MlOutboxFlushScheduler — и в Local-, и в gRPC-режиме (ml_client.py L6–7).
|
||||
await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
var request = new TrainBatchRequest();
|
||||
foreach (MlOutboxEntryDto item in items)
|
||||
{
|
||||
request.Items.Add(new TrainExample
|
||||
{
|
||||
Text = item.Text,
|
||||
Label = item.Label,
|
||||
Delta = item.Delta,
|
||||
});
|
||||
}
|
||||
|
||||
MlService.MlServiceClient client = _connection.CreateClient();
|
||||
TrainBatchReply reply = await client.TrainBatchAsync(
|
||||
request,
|
||||
CallOptions(tenantId.Value, TimeSpan.FromSeconds(TrainBatchDeadlineSeconds), ct));
|
||||
return reply.Learned;
|
||||
}
|
||||
|
||||
// Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла).
|
||||
// Возвращает: Идентификатор тенанта.
|
||||
// Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация).
|
||||
private TenantId RequireTenant()
|
||||
=> _tenantContext.TenantId
|
||||
?? throw new InvalidOperationException(
|
||||
"GrpcMlClient запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
|
||||
|
||||
// Возвращает статус модели из кэша либо обновляет его вызовом ml-service (кэш 15 с).
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Свежая запись кэша (при сбое сервиса — старые данные + reachable=false).
|
||||
private async Task<MlStatusCache.Snapshot> GetServiceSnapshotAsync(TenantId tenantId, CancellationToken ct)
|
||||
{
|
||||
if (_statusCache.TryGetFresh(tenantId.Value, out MlStatusCache.Snapshot fresh))
|
||||
{
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// Последние известные данные (при сбое refresh останутся они — python refresh_status L132–135).
|
||||
_statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot stale);
|
||||
MlServiceStatusDto previous = stale?.Service ?? NotReadyServiceStatus;
|
||||
|
||||
try
|
||||
{
|
||||
MlService.MlServiceClient client = _connection.CreateClient();
|
||||
StatusReply reply = await client.StatusAsync(
|
||||
new StatusRequest(),
|
||||
CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct));
|
||||
MlServiceStatusDto service = MapStatus(reply);
|
||||
_statusCache.Set(tenantId.Value, service, reachable: true);
|
||||
return _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot updated)
|
||||
? updated
|
||||
: new MlStatusCache.Snapshot(service, Reachable: true, UpdatedAtMs: 0);
|
||||
}
|
||||
catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException)
|
||||
{
|
||||
_logger.LogDebug(exception, "ML status недоступен (тенант {TenantId})", tenantId.Value);
|
||||
_statusCache.Set(tenantId.Value, previous, reachable: false);
|
||||
return new MlStatusCache.Snapshot(previous, false, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
}
|
||||
}
|
||||
|
||||
// Маппит ответ Status в контрактный статус модели (поля 1:1 с MlServiceStatusDto).
|
||||
// reply: Ответ ml-service.
|
||||
// Возвращает: DTO статуса модели.
|
||||
private static MlServiceStatusDto MapStatus(StatusReply reply)
|
||||
{
|
||||
return new MlServiceStatusDto(
|
||||
Ready: reply.Ready,
|
||||
Classes: new Dictionary<string, double>(reply.Classes),
|
||||
Learned: reply.Learned,
|
||||
Eval: new MlEvalDto(
|
||||
Count: reply.Eval?.Count ?? 0,
|
||||
Correct: reply.Eval?.Correct ?? 0,
|
||||
Accuracy: reply.Eval?.Accuracy ?? 0.0));
|
||||
}
|
||||
|
||||
// Маппит ответ Predict в контрактный результат (поля 1:1 с MlPredictResultDto).
|
||||
// reply: Ответ ml-service.
|
||||
// Возвращает: DTO предсказания.
|
||||
private static MlPredictResultDto MapPredict(PredictReply reply)
|
||||
{
|
||||
return new MlPredictResultDto(
|
||||
Take: reply.Take,
|
||||
Label: reply.HasLabel ? reply.Label : null,
|
||||
Scores: new Dictionary<string, double>(reply.Scores),
|
||||
Hits: reply.Hits,
|
||||
Ready: reply.Ready,
|
||||
Margin: reply.HasMargin ? reply.Margin : null,
|
||||
Terms: reply.Terms.ToList(),
|
||||
Type: MapTypeDecision(reply.Type));
|
||||
}
|
||||
|
||||
// Маппит решение о типе заявки (null — модель тип не определила).
|
||||
// decision: Ответ ml-service (TypeDecision) или null.
|
||||
// Возвращает: DTO типа заявки или null.
|
||||
private static MlTypeDecisionDto? MapTypeDecision(TypeDecision? decision)
|
||||
{
|
||||
return decision is null
|
||||
? null
|
||||
: new MlTypeDecisionDto(
|
||||
Take: decision.Take,
|
||||
Label: decision.Label,
|
||||
Value: decision.Value,
|
||||
Margin: decision.Margin);
|
||||
}
|
||||
|
||||
// CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1).
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// deadline: Лимит времени вызова.
|
||||
// ct: Токен отмены вызова.
|
||||
// Возвращает: Опции вызова с заголовками, deadline и отменой.
|
||||
private CallOptions CallOptions(string tenantId, TimeSpan deadline, CancellationToken ct)
|
||||
=> new(
|
||||
headers: _connection.CreateMetadata(tenantId),
|
||||
deadline: DateTime.UtcNow.Add(deadline),
|
||||
cancellationToken: ct);
|
||||
|
||||
// Краткий текст ошибки для мягкого {ok:false,error} (секреты/тела ответов не логируются).
|
||||
// exception: Исключение вызова.
|
||||
// Возвращает: Текст ошибки.
|
||||
private static string ErrorText(Exception exception)
|
||||
=> exception is RpcException rpc && rpc.StatusCode == StatusCode.Unavailable
|
||||
? "ML-сервис недоступен"
|
||||
: "ML-сервис не ответил — повторите попытку через несколько секунд";
|
||||
|
||||
// Фиксированный ответ неготовой/недоступной модели: «не уверен» (Ruling 5, ml.proto L21–23).
|
||||
private static MlPredictResultDto NotReadyPrediction => new(
|
||||
Take: false,
|
||||
Label: null,
|
||||
Scores: EmptyScores,
|
||||
Hits: 0,
|
||||
Ready: false,
|
||||
Margin: null,
|
||||
Terms: Array.Empty<string>(),
|
||||
Type: null);
|
||||
|
||||
// Статус модели по умолчанию (нет данных кэша и сервис недоступен): «не готова».
|
||||
private static MlServiceStatusDto NotReadyServiceStatus => new(
|
||||
Ready: false,
|
||||
Classes: EmptyScores,
|
||||
Learned: 0,
|
||||
Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0));
|
||||
|
||||
// Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
|
||||
private async Task<bool> ReadMlEnabledAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await _store.GetAsync(SettingsKeys.MlEnabled, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return SettingsDefaults.MlEnabled;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
if (document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
return document.RootElement.GetBoolean();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
|
||||
}
|
||||
|
||||
return SettingsDefaults.MlEnabled;
|
||||
}
|
||||
|
||||
// Читает целочисленный счётчик (mlDecisions/aiDecisions); отсутствие/повреждение → 0.
|
||||
// key: Внутренний KV-ключ счётчика.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Значение счётчика из хранилища или 0.
|
||||
private async Task<int> ReadCounterAsync(string key, CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await _store.GetAsync(key, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Number
|
||||
&& document.RootElement.TryGetInt64(out long wide))
|
||||
{
|
||||
return (int)Math.Clamp(wide, int.MinValue, int.MaxValue);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — 0 (мягкая семантика, как в SettingsService).
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Telegram;
|
||||
using Deal.SharedKernel.Tenants;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC-адаптер порта <see cref="ITelegramGateway"/> к автономному telegram-service (Ruling 6/7, план Task 14).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется вместо Local-заглушки при <c>Services:Telegram:UseLocal=false</c> (выбор на старте, Ruling 6).
|
||||
/// Каждый RPC telegram.proto (TelegramService) маппится 1:1 в метод порта: подключение/отключение аккаунта
|
||||
/// (StartPhone/StartQr/SendCode/SendPassword/Logout), каталог диалогов (RefreshDialogs), мониторинг
|
||||
/// (SetMonitor/SetMonitorAll), backfill (Backfill), превью (ReadRecent) и discovery-операции (Search/GetInfo/
|
||||
/// ReadForEval/Join/Leave). Каждый вызов несёт metadata tenant-id + service-token
|
||||
/// (<see cref="TelegramGrpcConnection"/>, Ruling 1) и deadline по README контрактов (src/contracts L62–74).
|
||||
/// <para>
|
||||
/// Ошибки домена telegram-service приходят RPC-статусами с каноническими detail («Telegram не подключён»,
|
||||
/// «Сначала сохраните Telegram api_id и api_hash в настройках», «Неверный код», …) — RpcException
|
||||
/// пробрасывается наружу без изменений, текст причины решает HTTP-слой эндпоинтов (Ruling 7/8). Транспортные
|
||||
/// сбои (сервис недоступен/таймаут) нормализуются в RpcException Unavailable с detail «Telegram не подключён»
|
||||
/// — ветки эндпоинтов отвечают «не подключён», как при недоступном сервисе.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GrpcTelegramClient : ITelegramGateway
|
||||
{
|
||||
/// <summary>
|
||||
/// Deadline локальных команд статуса/зеркала — 10 с (README L66).
|
||||
/// </summary>
|
||||
public const int ShortDeadlineSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline сетевых команд Telegram — 60 с (README L67: паузы анти-бана внутри сервиса).
|
||||
/// </summary>
|
||||
public const int CommandDeadlineSeconds = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Deadline тяжёлых команд каталога/backfill — 120 с (README L68: iter_dialogs 500, backfill).
|
||||
/// </summary>
|
||||
public const int LongDeadlineSeconds = 120;
|
||||
|
||||
// Detail недоступного telegram-service (Ruling 7: «недоступность сервиса → не подключён»).
|
||||
private const string NotConnectedDetail = "Telegram не подключён";
|
||||
|
||||
// Контекст текущего тенанта (id — в metadata вызовов, Ruling 1).
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
// Транспорт gRPC telegram-service (канал + metadata).
|
||||
private readonly TelegramGrpcConnection _connection;
|
||||
|
||||
// Логгер сбоев вызовов telegram-service.
|
||||
private readonly ILogger<GrpcTelegramClient> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт gRPC-адаптер гейта telegram-service.
|
||||
/// </summary>
|
||||
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами).</param>
|
||||
/// <param name="connection">Транспорт telegram-service (singleton-канал + service-token).</param>
|
||||
/// <param name="logger">Логгер сбоев.</param>
|
||||
public GrpcTelegramClient(
|
||||
ITenantContext tenantContext,
|
||||
TelegramGrpcConnection connection,
|
||||
ILogger<GrpcTelegramClient> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tenantContext);
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_tenantContext = tenantContext;
|
||||
_connection = connection;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
GetStatusReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct,
|
||||
(client, options) => client.GetStatusAsync(new GetStatusRequest(), options));
|
||||
return new TelegramAccountStatusDto(
|
||||
Phase: reply.Phase,
|
||||
Connected: reply.Connected,
|
||||
Listener: reply.Listener,
|
||||
Account: reply.Account,
|
||||
Error: reply.HasError ? reply.Error : null,
|
||||
QrUrl: reply.HasQrUrl ? reply.QrUrl : null);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "status");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TelegramAuthResultDto> StartPhoneAsync(string phone, int apiId, string apiHash, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
StartPhoneReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
|
||||
(client, options) => client.StartPhoneAsync(
|
||||
new StartPhoneRequest { Phone = phone ?? string.Empty, ApiId = apiId, ApiHash = apiHash ?? string.Empty },
|
||||
options));
|
||||
return new TelegramAuthResultDto(Phase: reply.Phase, QrUrl: null);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "start_phone");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TelegramAuthResultDto> StartQrAsync(int apiId, string apiHash, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
StartQrReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
|
||||
(client, options) => client.StartQrAsync(
|
||||
new StartQrRequest { ApiId = apiId, ApiHash = apiHash ?? string.Empty },
|
||||
options));
|
||||
return new TelegramAuthResultDto(
|
||||
Phase: reply.Phase,
|
||||
QrUrl: string.IsNullOrEmpty(reply.QrUrl) ? null : reply.QrUrl);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "start_qr");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> SendCodeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
SendCodeReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
|
||||
(client, options) => client.SendCodeAsync(
|
||||
new SendCodeRequest { Code = code ?? string.Empty }, options));
|
||||
return reply.Phase;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "send_code");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> SendPasswordAsync(string password, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
SendPasswordReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
|
||||
(client, options) => client.SendPasswordAsync(
|
||||
new SendPasswordRequest { Password = password ?? string.Empty }, options));
|
||||
return reply.Phase;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "send_password");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task LogoutAsync(CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct,
|
||||
(client, options) => client.LogoutAsync(new LogoutRequest(), options));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "logout");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
RefreshDialogsReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(LongDeadlineSeconds), ct,
|
||||
(client, options) => client.RefreshDialogsAsync(new RefreshDialogsRequest(), options));
|
||||
return MapEntries(reply.Entries);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "refresh_dialogs");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetMonitorAsync(string dialogId, bool enabled, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct,
|
||||
(client, options) => client.SetMonitorAsync(
|
||||
new SetMonitorRequest { DialogId = dialogId ?? string.Empty, Enabled = enabled }, options));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "set_monitor");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetMonitorAllAsync(bool enabled, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct,
|
||||
(client, options) => client.SetMonitorAllAsync(
|
||||
new SetMonitorAllRequest { Enabled = enabled }, options));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "set_monitor_all");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> BackfillAsync(string dialogId, bool force, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
BackfillReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(LongDeadlineSeconds), ct,
|
||||
(client, options) => client.BackfillAsync(
|
||||
new BackfillRequest { DialogId = dialogId ?? string.Empty, Force = force }, options));
|
||||
return reply.Processed;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "backfill");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(string dialogId, int limit, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
ReadRecentReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
|
||||
(client, options) => client.ReadRecentAsync(
|
||||
new ReadRecentRequest { DialogId = dialogId ?? string.Empty, Limit = limit }, options));
|
||||
return reply.Messages
|
||||
.Select(message => new TelegramRecentMessageDto(message.Id, message.Text, message.Time))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "read_recent");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(string query, int limit, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
SearchReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
|
||||
(client, options) => client.SearchAsync(
|
||||
new SearchRequest { Query = query ?? string.Empty, Limit = limit }, options));
|
||||
return MapEntries(reply.Results);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "search");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
GetInfoReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
|
||||
(client, options) => client.GetInfoAsync(
|
||||
new GetInfoRequest { DialogId = dialogId ?? string.Empty }, options));
|
||||
ChannelInfo info = reply.Info;
|
||||
return new TelegramChannelInfoDto(
|
||||
Id: info.Id,
|
||||
Name: info.Name,
|
||||
Username: info.Username,
|
||||
Kind: info.Kind,
|
||||
Hue: info.Hue,
|
||||
Participants: info.HasParticipants ? info.Participants : null,
|
||||
IsForum: info.IsForum);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "get_info");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TelegramEvalReadDto> ReadForEvalAsync(string dialogId, int limit, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
ReadForEvalReply reply = await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
|
||||
(client, options) => client.ReadForEvalAsync(
|
||||
new ReadForEvalRequest { DialogId = dialogId ?? string.Empty, Limit = limit }, options));
|
||||
return new TelegramEvalReadDto(
|
||||
Ok: reply.Ok,
|
||||
Error: reply.HasError ? reply.Error : null,
|
||||
Messages: reply.Messages
|
||||
.Select(message => new TelegramEvalMessageDto(
|
||||
Id: message.Id,
|
||||
Text: message.Text,
|
||||
DateMs: message.DateMs,
|
||||
TopicId: message.HasTopicId ? message.TopicId : null,
|
||||
TopicTitle: message.HasTopicTitle ? message.TopicTitle : null))
|
||||
.ToList());
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "read_for_eval");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task JoinAsync(string username, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
|
||||
(client, options) => client.JoinAsync(
|
||||
new JoinRequest { Username = username ?? string.Empty }, options));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "join");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task LeaveAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
TenantId tenantId = RequireTenant();
|
||||
try
|
||||
{
|
||||
await CallAsync(
|
||||
tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct,
|
||||
(client, options) => client.LeaveAsync(
|
||||
new LeaveRequest { DialogId = dialogId ?? string.Empty }, options));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw TranslateTransportFailure(exception, tenantId, "leave");
|
||||
}
|
||||
}
|
||||
|
||||
// Текущий тенант scope (без него metadata вызовов не имеет смысла).
|
||||
// Возвращает: Идентификатор тенанта.
|
||||
// Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация).
|
||||
private TenantId RequireTenant()
|
||||
=> _tenantContext.TenantId
|
||||
?? throw new InvalidOperationException(
|
||||
"GrpcTelegramClient запрошен вне tenant-контекста (ITenantContext.TenantId == null).");
|
||||
|
||||
// Выполняет unary RPC с metadata tenant-id/service-token, deadline и токеном отмены (Ruling 1).
|
||||
// TReply: Тип ответа RPC.
|
||||
// tenantId: Id тенанта (формат N).
|
||||
// deadline: Лимит времени вызова (README контрактов L62–74).
|
||||
// ct: Токен отмены вызова.
|
||||
// call: Вызов клиента (принимает клиент и CallOptions).
|
||||
// Возвращает: Ответ RPC.
|
||||
private async Task<TReply> CallAsync<TReply>(
|
||||
TenantId tenantId, TimeSpan deadline, CancellationToken ct,
|
||||
Func<TelegramService.TelegramServiceClient, CallOptions, AsyncUnaryCall<TReply>> call)
|
||||
where TReply : class
|
||||
{
|
||||
TelegramService.TelegramServiceClient client = _connection.CreateClient();
|
||||
var options = new CallOptions(
|
||||
headers: _connection.CreateMetadata(tenantId.Value),
|
||||
deadline: DateTime.UtcNow.Add(deadline),
|
||||
cancellationToken: ct);
|
||||
return await call(client, options);
|
||||
}
|
||||
|
||||
// Нормализует транспортные сбои в RpcException «Telegram не подключён»; RpcException домена — как есть.
|
||||
// Отмена по токену вызывающего пробрасывается без нормализации (не сбой сервиса). Доменные
|
||||
// RPC-ошибки (INVALID_ARGUMENT/FAILED_PRECONDITION/…) несут канонический detail — их трогать нельзя:
|
||||
// текст причины 1:1 уходит в {detail} эндпоинтов (Ruling 7/8).
|
||||
// exception: Исключение вызова.
|
||||
// tenantId: Id тенанта (лог).
|
||||
// operation: Имя RPC (лог-аудит).
|
||||
// Возвращает: Исключение для проброса: транспортный сбой — нормализованный RpcException.
|
||||
private Exception TranslateTransportFailure(Exception exception, TenantId tenantId, string operation)
|
||||
{
|
||||
// Отмена по токену вызывающего — не ошибка сервиса (пробрасываем как обычно).
|
||||
if (exception is OperationCanceledException)
|
||||
{
|
||||
return exception;
|
||||
}
|
||||
|
||||
// Доменная RPC-ошибка сервиса (INVALID_ARGUMENT/FAILED_PRECONDITION/NOT_FOUND…) несёт канонический
|
||||
// detail (Ruling 1) — пробрасываем без изменений, текст причины 1:1 уходит в {detail} эндпоинтов.
|
||||
// Unavailable с detail (сервис сам ответил причиной) — тоже как есть.
|
||||
if (exception is RpcException rpc &&
|
||||
(rpc.StatusCode != StatusCode.Unavailable || !string.IsNullOrEmpty(rpc.Status.Detail)))
|
||||
{
|
||||
return rpc;
|
||||
}
|
||||
|
||||
_logger.LogWarning(exception, "Telegram {Operation} недоступен (тенант {TenantId})", operation, tenantId.Value);
|
||||
return new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail));
|
||||
}
|
||||
|
||||
// Маппит записи каталога proto (DialogEntry) в контрактный DTO каталога/поиска (Ruling 7).
|
||||
// entries: Записи каталога telegram-service.
|
||||
// Возвращает: Записи в форме контракта (username → handle).
|
||||
private static IReadOnlyList<TelegramDialogEntryDto> MapEntries(Google.Protobuf.Collections.RepeatedField<DialogEntry> entries)
|
||||
{
|
||||
return entries
|
||||
.Select(entry => new TelegramDialogEntryDto(
|
||||
Id: entry.Id,
|
||||
Name: entry.Name,
|
||||
Handle: entry.Username,
|
||||
Kind: entry.Kind,
|
||||
Hue: entry.Hue))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Порт отправки батча обучения в ml-service (RPC TrainBatch, ml.proto L52–55) — для MlOutboxFlushScheduler.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Отдельный от <see cref="Deal.Contracts.Integrations.IMlClient"/> порт: сигнатура IMlClient не меняется
|
||||
/// (Self-Review плана L530), а выгрузку очереди делает фоновый флашер (Ruling 6), которому нужен только
|
||||
/// TrainBatch. Реализуется gRPC-адаптером <see cref="GrpcMlClient"/> и регистрируется только при
|
||||
/// <c>Services:Ml:UseLocal=false</c> (Local-режиму ml-service не нужен — очередь копится, как в этапе 3).
|
||||
/// </remarks>
|
||||
public interface IMlTrainClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Отправляет порцию очереди обучения в ml-service (одна транзакция learn_batch, ml.proto L52–55).
|
||||
/// </summary>
|
||||
/// <param name="items">Строки outbox (text/label/delta; id в запрос не уходит — нужен вызывающему для удаления).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Число применённых примеров (= len(items) при успехе; no-op-пропуски сервис не считает).</returns>
|
||||
/// <exception cref="Grpc.Core.RpcException">Сервис недоступен/отклонил батч — строки НЕ удаляются (Ruling 6).</exception>
|
||||
public Task<int> TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Pipeline.Application;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Локальная реализация <see cref="IAiClassifier"/> без внешнего ИИ-сервиса (Ruling 5, план Task 6 L370–388).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Адаптер поверх чистого ядра разбора модуля Pipeline (эталон — <see cref="LocalColumnSuggester"/>: ядро
|
||||
/// владельца + тонкий адаптер): <see cref="ClassifyAsync"/> разбирает сообщение <see cref="LocalFieldsParser"/>
|
||||
/// (pipeline.py _local_fields L718–798 — заголовок, суть, стек/грейд/бюджет/контакты по меткам и fallback,
|
||||
/// is_vacancy по hire-маркерам) и маппит в контрактный <see cref="AiParsedCardDto"/> через
|
||||
/// <see cref="AiCardMapper.FromLocal"/> (Ruling 7 — модульный маппинг используют и локальные пути воркера):
|
||||
/// бюджет нормализуется, контакты квалифицируются, блок «О заявке» заполняет только
|
||||
/// legacy-суть, тип — маркерная гипотеза: is_vacancy_known=false, board=null («смысловые колонки до ИИ не
|
||||
/// назначаем», python L954–958; карточку в колонку кладёт воркер после ContainerAccepts). Фильтр всегда
|
||||
/// <c>{pass:true, skipped:true}</c> — реального ИИ-фильтра нет, а выключатель aiFilterEnabled порт не читает
|
||||
/// (ветки выключателя отрабатывает воркер, как filter_incoming L190–192 и L1103–1106). На этапе 6 адаптер
|
||||
/// заменяется gRPC-клиентом ai-service с тем же контрактом. Scoped: LocalFieldsParser читает KV-настройки
|
||||
/// тенанта (ISettingsStore → scoped TenantDbContext запроса).
|
||||
/// </remarks>
|
||||
/// <param name="fieldsParser">Локальный структуратор модуля Pipeline (маркеры hireMarkers/levelTerms — из настроек).</param>
|
||||
public sealed class LocalAiClassifier(LocalFieldsParser fieldsParser) : IAiClassifier
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
|
||||
{
|
||||
// Реального ИИ-фильтра нет (Ruling 5): локальная реализация всегда пропускает. Семантика ответа 1:1
|
||||
// с ветками прототипа, где фильтр недоступен/выключен: {pass:true, reason:null, skipped:true}
|
||||
// (filter_incoming L190–198, сбой L1103–1106). Отсевы spam_ai/filter_ai станут достижимы этапом 6.
|
||||
return Task.FromResult(new AiFilterResultDto(Pass: true, Reason: null, Skipped: true));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
|
||||
{
|
||||
LocalParsedFields fields = await fieldsParser.ParseAsync(text, ct);
|
||||
return AiCardMapper.FromLocal(fields, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Локальная реализация <see cref="IAiTools"/> без внешнего ИИ-сервиса (Ruling 9, план Task 15).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется при <c>Services:Ai:UseLocal=true</c> (default, Ruling 6). Методы НЕ поддерживаются — на
|
||||
/// этапе 6 локальной генерации ключей/оценки fit нет: Discovery-воркер сам выбирает эвристику (при
|
||||
/// aiEnabled=false или сбое, python discovery_eval L186–194), а generate-keywords-эндпоинт (Task 19) ловит
|
||||
/// исключение и отдаёт мягкую ошибку {keywords: [], error} (Ruling 11). NotSupportedException — явный сигнал
|
||||
/// «вызов порта в локальном режиме — ошибка сценария», чтобы будущий потребитель (Discovery) не получил
|
||||
/// молча пустые ключи/ложный fit. Scoped-зависимостей нет (экземпляр лёгкий, как LocalAiClassifier на дефолты).
|
||||
/// </remarks>
|
||||
public sealed class LocalAiTools : IAiTools
|
||||
{
|
||||
// Сообщение исключения методов (локальный режим = ai-service не подключён).
|
||||
private const string NotSupportedMessage =
|
||||
"ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false).";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
|
||||
=> throw new NotSupportedException(NotSupportedMessage);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AiEvaluateFitResultDto> EvaluateFitAsync(
|
||||
string text, string description, IReadOnlyCollection<string> keywords, CancellationToken ct)
|
||||
=> throw new NotSupportedException(NotSupportedMessage);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) —
|
||||
// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство.
|
||||
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Реализует порт <see cref="IColumnSuggester"/> поверх порта <see cref="ICardStore"/> и чистого ядра
|
||||
/// <see cref="SuggestHeuristics"/> (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync),
|
||||
/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]},
|
||||
/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE,
|
||||
/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные
|
||||
/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже
|
||||
/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ <see cref="SettingsKeys.LastSuggestAt"/>
|
||||
/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при
|
||||
/// раскладке НЕ пишутся (suggest.py _assign_ids L220–239 — это не действие пользователя, а предложение).
|
||||
/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172–178).
|
||||
/// </remarks>
|
||||
/// <param name="store">Порт хранилища (карточки «Неразобранного», переносы в колонки-доски).</param>
|
||||
/// <param name="settings">KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52).</param>
|
||||
/// <param name="containersService">Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами.</param>
|
||||
public sealed class LocalColumnSuggester(
|
||||
ICardStore store,
|
||||
ISettingsStore settings,
|
||||
ContainersService containersService) : IColumnSuggester
|
||||
{
|
||||
// ── Кулдаун повторов (suggest.py COOLDOWN_S L51; KEY lastSuggestAt L52) ──
|
||||
|
||||
// Как часто можно переспрашивать ИИ-предложения: 20 минут (COOLDOWN_S = 20 * 60, L51).
|
||||
private const long CooldownSeconds = 20 * 60;
|
||||
|
||||
// ── Детерминированные причины (Ruling 3; строки прототипа suggest.py) ──
|
||||
|
||||
// Кулдаун: повторный вызов слишком рано (suggest.py L95 «недавно предлагали — подождите»).
|
||||
private const string CooldownReason = "недавно предлагали — подождите";
|
||||
|
||||
// Мало карточек в «Неразобранном»: {0} — порог MIN_INBOX (suggest.py L102).
|
||||
private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})";
|
||||
|
||||
// Групп не вышло: темы похожи на существующие доски или карточкам нечего разделить (L159).
|
||||
private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать";
|
||||
|
||||
// Мало карточек для ключей: нужно хотя бы 3 (suggest_domain_keywords L178).
|
||||
private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)";
|
||||
|
||||
// Повторяющихся слов-маркеров не нашлось (suggest_domain_keywords L187, текст прототипа).
|
||||
private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз";
|
||||
|
||||
// Режим правил колонки-предложения: «любое из условий» (suggest.py _rules_for L68 mode: any).
|
||||
private const string RulesModeAny = "any";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SuggestColumnsResultDto> SuggestColumnsAsync(CancellationToken ct)
|
||||
{
|
||||
if (await WithinCooldownAsync(ct))
|
||||
{
|
||||
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true);
|
||||
}
|
||||
|
||||
IReadOnlyList<CardDto> inbox = await store.ListInboxWithSourceAsync(ct);
|
||||
if (inbox.Count < SuggestHeuristics.MinInbox)
|
||||
{
|
||||
return new SuggestColumnsResultDto(
|
||||
Ok: false,
|
||||
Created: 0,
|
||||
Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox),
|
||||
Cooldown: false);
|
||||
}
|
||||
|
||||
// Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138–139).
|
||||
IReadOnlyList<ContainerDto> containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct);
|
||||
IReadOnlyList<string> existingNames = containers
|
||||
.Where(container => !container.Suggested)
|
||||
.Select(container => container.Name)
|
||||
.ToList();
|
||||
|
||||
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, existingNames);
|
||||
if (plans.Count == 0)
|
||||
{
|
||||
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
|
||||
}
|
||||
|
||||
int created = await StoreSuggestedColumnsAsync(inbox, plans, ct);
|
||||
if (created == 0)
|
||||
{
|
||||
// Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153–156).
|
||||
return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false);
|
||||
}
|
||||
|
||||
await WriteLastSuggestAtAsync(ct);
|
||||
return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SuggestKeywordsResultDto> SuggestKeywordsAsync(CancellationToken ct)
|
||||
{
|
||||
// Выборка ключей — как suggest_domain_keywords L172–176: карточки вне trash/archive с текстом,
|
||||
// свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC).
|
||||
IReadOnlyList<CardDto> cards = await store.ListCardsAsync(new CardsQuery(null), ct);
|
||||
List<string> texts = cards
|
||||
.Where(card => card.Col != CardIds.Trash
|
||||
&& card.Col != CardIds.Archive
|
||||
&& card.SourceMsg.Trim().Length > 0)
|
||||
.Take(SuggestHeuristics.KeywordsSampleLimit)
|
||||
.Select(card => card.SourceMsg.Trim())
|
||||
.ToList();
|
||||
if (texts.Count < SuggestHeuristics.MinKeywordsSample)
|
||||
{
|
||||
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason);
|
||||
}
|
||||
|
||||
IReadOnlyList<string> keywords = SuggestHeuristics.SuggestDomainKeywords(texts);
|
||||
if (keywords.Count == 0)
|
||||
{
|
||||
return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason);
|
||||
}
|
||||
|
||||
return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null);
|
||||
}
|
||||
|
||||
// Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129–156).
|
||||
// inbox: Снимок «Неразобранного» (карточки планов берутся из него).
|
||||
// plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек).
|
||||
// Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием.
|
||||
// Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и
|
||||
// раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231–233. Если в
|
||||
// колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested
|
||||
// L242–248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся.
|
||||
private async Task<int> StoreSuggestedColumnsAsync(
|
||||
IReadOnlyList<CardDto> inbox,
|
||||
IReadOnlyList<SuggestedColumnPlan> plans,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231–233).
|
||||
HashSet<string> inboxIds = (await store.ListInboxWithSourceAsync(ct))
|
||||
.Select(card => card.Id)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
Dictionary<string, string> textByCardId = inbox
|
||||
.ToDictionary(card => card.Id, card => card.SourceMsg, StringComparer.Ordinal);
|
||||
|
||||
int created = 0;
|
||||
foreach (SuggestedColumnPlan plan in plans)
|
||||
{
|
||||
var rules = new ContainerRulesDto(
|
||||
Mode: RulesModeAny,
|
||||
Direction: Array.Empty<string>(),
|
||||
Keywords: [plan.Word],
|
||||
Stack: Array.Empty<string>(),
|
||||
Grade: Array.Empty<string>(),
|
||||
Exclude: Array.Empty<string>(),
|
||||
Budget: null);
|
||||
ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto(
|
||||
Name: plan.Name,
|
||||
Description: string.Empty,
|
||||
Color: null,
|
||||
Space: ContainerSpaces.Dashboard,
|
||||
Kind: ContainerKinds.Board,
|
||||
Suggested: true,
|
||||
Rules: rules,
|
||||
Note: plan.Note), ct);
|
||||
|
||||
int placed = 0;
|
||||
foreach (string cardId in plan.CardIds)
|
||||
{
|
||||
if (!inboxIds.Contains(cardId))
|
||||
{
|
||||
continue; // карточка уже разобрана другим предложением/пользователем (L231–233)
|
||||
}
|
||||
|
||||
IReadOnlyList<MatchHitDto> hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]);
|
||||
await store.UpdateColumnAsync(new CardColumnUpdateDto(
|
||||
CardId: cardId,
|
||||
Col: container.Id,
|
||||
IsNew: true,
|
||||
PrevCol: CardIds.Inbox,
|
||||
ArchivedAt: null,
|
||||
MatchHits: hits), ct);
|
||||
placed++;
|
||||
}
|
||||
|
||||
if (placed == 0)
|
||||
{
|
||||
// Ничего не легло — пустое предложение не нужно (suggest.py L152–156).
|
||||
await containersService.DeleteAsync(container.Id, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
created++;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
// Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут.
|
||||
// Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение
|
||||
// пишется только после успеха, L160–161; битый KV — дефолт «никогда»).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}).
|
||||
private async Task<bool> WithinCooldownAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Number)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long lastSuggestAt = document.RootElement.GetInt64();
|
||||
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false; // повреждённое значение не должно блокировать предложения
|
||||
}
|
||||
}
|
||||
|
||||
// Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())).
|
||||
// ct: Токен отмены.
|
||||
private Task WriteLastSuggestAtAsync(CancellationToken ct) =>
|
||||
settings.SetAsync(
|
||||
SettingsKeys.LastSuggestAt,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
|
||||
ct);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Локальная реализация <see cref="IMlClient"/> без внешнего ML-сервиса (Ruling 4, план Task 5 L266–286).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Этап 3: обучение копится локально в очередь MlOutbox (отправка в ML-сервис — фоновый воркер
|
||||
/// этапа 6), счётчики learning/outbox читаются из таблиц схемы тенанта (Ruling 4). Поведение 1:1
|
||||
/// с <c>backend/app/services/ml_client.py</c>: <c>PushAsync</c> = push L40–49 (trim text/label,
|
||||
/// пустые — no-op, text[:6000], id <c>mle_</c>+12 hex); <c>StatusAsync</c> = snapshot L138–150
|
||||
/// (learning = count(CardMoves), outbox = count(MlOutbox), ml/ai — KV-счётчики решений, на этапе 3
|
||||
/// всегда 0 — не инкрементируются); <c>ResetAsync</c> = reset_model L110–124 (чистится только
|
||||
/// MlOutbox, журнал и KV не трогаются). Модель «не готова» до этапа 4 (ready=false, classes пусты,
|
||||
/// learned=0, eval обнулён), предсказание — фиксированный «не уверен» (Ruling 5 L79–80); заглушка
|
||||
/// «жива»: reachable=true. Зависимости — порты (ISettingsStore, IMlLearningStore), а не EF:
|
||||
/// LocalMlClient остаётся unit-чистым (план Task 5). На этапе 6 адаптер заменяется gRPC-клиентом
|
||||
/// с тем же контрактом (Ruling 4 L73–74).
|
||||
/// </remarks>
|
||||
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
|
||||
/// <param name="learningStore">Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves.</param>
|
||||
public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learningStore) : IMlClient
|
||||
{
|
||||
// Пустой словарь классов модели (неготовая модель, Ruling 5).
|
||||
private static readonly IReadOnlyDictionary<string, double> EmptyClasses = new Dictionary<string, double>();
|
||||
|
||||
// Пустой словарь весов предсказания (неготовая модель, Ruling 5).
|
||||
private static readonly IReadOnlyDictionary<string, double> EmptyScores = new Dictionary<string, double>();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MlStatusResponseDto> StatusAsync(CancellationToken ct)
|
||||
{
|
||||
// Статус самой модели: обучение копится в outbox, реальная модель появится этапом 4 —
|
||||
// сейчас модель всегда не готова (Ruling 5).
|
||||
var service = new MlServiceStatusDto(
|
||||
Ready: false,
|
||||
Classes: EmptyClasses,
|
||||
Learned: 0,
|
||||
Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0));
|
||||
|
||||
bool enabled = await ReadMlEnabledAsync(ct);
|
||||
int mlDecisions = await ReadCounterAsync(SettingsKeys.MlDecisions, ct);
|
||||
int aiDecisions = await ReadCounterAsync(SettingsKeys.AiDecisions, ct);
|
||||
|
||||
// Локальная статистика (ml_client.snapshot L138–150): learning = count(CardMoves),
|
||||
// outbox = count(MlOutbox) (Ruling 4); ml/ai — KV-счётчики РЕШЕНИЙ пайплайна (этап 4):
|
||||
// на этапе 3 не инкрементируются и всегда 0.
|
||||
int learning = await learningStore.CountLearningAsync(ct);
|
||||
int outbox = await learningStore.CountOutboxAsync(ct);
|
||||
|
||||
var stats = new MlStatsDto(
|
||||
Ml: mlDecisions,
|
||||
Ai: aiDecisions,
|
||||
Learning: learning,
|
||||
Ready: service.Ready,
|
||||
Classes: service.Classes,
|
||||
Learned: service.Learned,
|
||||
Reachable: true,
|
||||
Outbox: outbox);
|
||||
|
||||
return new MlStatusResponseDto(Enabled: enabled, Service: service, Reachable: true, Stats: stats);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
|
||||
{
|
||||
// Неготовая модель ничего не решает (Ruling 5 L79–80) — текст не влияет на ответ.
|
||||
return Task.FromResult(new MlPredictResultDto(
|
||||
Take: false,
|
||||
Label: null,
|
||||
Scores: EmptyScores,
|
||||
Hits: 0,
|
||||
Ready: false,
|
||||
Margin: null,
|
||||
Terms: Array.Empty<string>(),
|
||||
Type: null));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MlResetResultDto> ResetAsync(CancellationToken ct)
|
||||
{
|
||||
// Сброс 1:1 с reset_model (L110–124): чистится только очередь обучения MlOutbox; журнал
|
||||
// CardMoves и KV-счётчики не трогаются (Ruling 4, план L275–276). Реального сервиса нет — ok.
|
||||
await learningStore.ClearOutboxAsync(ct);
|
||||
return new MlResetResultDto(Ok: true, Error: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task PushAsync(string text, string label, double delta, CancellationToken ct)
|
||||
{
|
||||
// Обучение гарантированно и локально (ml_client.push L40–49): действие пользователя — строка
|
||||
// очереди MlOutbox (отправку в ML-сервис делает воркер этапа 6). Общая логика (trim text/label,
|
||||
// пустые — тихий no-op, text[:6000], id mle_+hex) — в MlOutboxQueue, общем для Local/Grpc-адаптеров.
|
||||
await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct);
|
||||
}
|
||||
|
||||
// Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true.
|
||||
private async Task<bool> ReadMlEnabledAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await store.GetAsync(SettingsKeys.MlEnabled, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return SettingsDefaults.MlEnabled;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
if (document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
return document.RootElement.GetBoolean();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — дефолт (мягкая семантика, как в SettingsService).
|
||||
}
|
||||
|
||||
return SettingsDefaults.MlEnabled;
|
||||
}
|
||||
|
||||
// Читает целочисленный счётчик (mlDecisions/aiDecisions); отсутствие/повреждение → 0.
|
||||
// key: Внутренний KV-ключ счётчика.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Значение счётчика из хранилища или 0.
|
||||
private async Task<int> ReadCounterAsync(string key, CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await store.GetAsync(key, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Number
|
||||
&& document.RootElement.TryGetInt64(out long wide))
|
||||
{
|
||||
return (int)Math.Clamp(wide, int.MinValue, int.MaxValue);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — 0 (мягкая семантика, как в SettingsService).
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Локальная заглушка <see cref="ITelegramGateway"/> без telegram-service (Ruling 6, план Task 13/14).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется как дефолт dev (до появления gRPC-клиента GrpcTelegramClient под флагом
|
||||
/// Services:Telegram:UseLocal=false — Ruling 6): реальный telegram-service в dev не поднят, поэтому гейт
|
||||
/// нейтрален — статус «idle/не подключён» (1:1 форма «сервис недоступен → idle-форма», Ruling 8), команды —
|
||||
/// no-op, выборки пусты. На этапе 6 (Task 14 curl-приёмка) эндпоинты тестируются фейк-реализацией гейта в
|
||||
/// тестах (не этой заглушкой); заглушка гарантирует разрешимость графа DI до подключения сервиса.
|
||||
/// Команды подключения (StartPhone/StartQr/SendCode/SendPassword/Logout) и discovery-операции (Search/Info/
|
||||
/// ReadForEval/Join/Leave) без сервиса не имеют смысла — их ветки эндпоинтов/воркера отдают ошибку
|
||||
/// «Telegram не подключён» по статусу подключения (Ruling 7), сам гейт их не вызывает.
|
||||
/// </remarks>
|
||||
public sealed class LocalTelegramGateway : ITelegramGateway
|
||||
{
|
||||
// Фаза idle-формы (аккаунт не подключён — сервиса нет).
|
||||
private const string IdlePhase = "idle";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
|
||||
{
|
||||
// «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты.
|
||||
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TelegramAuthResultDto> StartPhoneAsync(string phone, int apiId, string apiHash, CancellationToken ct)
|
||||
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TelegramAuthResultDto> StartQrAsync(int apiId, string apiHash, CancellationToken ct)
|
||||
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> SendCodeAsync(string code, CancellationToken ct) => Task.FromResult(IdlePhase);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task LogoutAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetMonitorAsync(string dialogId, bool enabled, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> BackfillAsync(string dialogId, bool force, CancellationToken ct) => Task.FromResult(0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(string dialogId, int limit, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(string query, int limit, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct)
|
||||
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TelegramEvalReadDto> ReadForEvalAsync(string dialogId, int limit, CancellationToken ct)
|
||||
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Deal.Grpc.Ml;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Транспорт gRPC-клиента ml-service: общий канал + обязательные metadata (Ruling 1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Singleton (канал живёт долго и переиспользуется всеми вызовами): endpoint из <see cref="MlServiceOptions"/>,
|
||||
/// service-token — из env <c>DEAL_SERVICE_TOKEN</c> (Ruling 13: секреты только в env). Dev-транспорт без TLS
|
||||
/// (Ruling 2); mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом
|
||||
/// и проверяет CA сервера (сертификаты передаются <see cref="MtlsCertificates"/>). Пустой endpoint либо
|
||||
/// пустой токен при создании — ошибка конфигурации (fail-closed: без токена сервис отвергнет каждый вызов
|
||||
/// UNAUTHENTICATED, Ruling 1). Автоповторы Grpc.Net.Client отключены (MaxRetryAttempts=0): стратегию повторов
|
||||
/// реализует вызывающий (флашер MlOutboxFlushScheduler оставляет строки и пробует в следующем цикле).
|
||||
/// </remarks>
|
||||
public sealed class MlGrpcConnection : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor сервисов, Ruling 1).
|
||||
/// </summary>
|
||||
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl, Ruling 1).
|
||||
/// </summary>
|
||||
public const string TenantIdMetadataKey = "tenant-id";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с service-token (зеркало MlServiceImpl, Ruling 1).
|
||||
/// </summary>
|
||||
public const string ServiceTokenMetadataKey = "service-token";
|
||||
|
||||
private readonly GrpcChannel _channel;
|
||||
private readonly string _serviceToken;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт транспорт ml-service по конфигурации и env-токену (валидация fail-closed).
|
||||
/// </summary>
|
||||
/// <param name="options">Конфигурация секции <c>Services:Ml</c> (endpoint).</param>
|
||||
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6): null — plaintext-канал (dev, флаг выключен).</param>
|
||||
/// <exception cref="InvalidOperationException">Пустой endpoint или пустой DEAL_SERVICE_TOKEN.</exception>
|
||||
public MlGrpcConnection(MlServiceOptions options, MtlsCertificates? mtlsCertificates = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (string.IsNullOrWhiteSpace(options.Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MlGrpcConnection: не задан endpoint ml-service (секция \"{MlServiceOptions.SectionName}:Endpoint\").");
|
||||
}
|
||||
|
||||
_serviceToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey) ?? string.Empty;
|
||||
if (_serviceToken.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MlGrpcConnection: не задан env {ServiceTokenEnvKey} — ml-service отвергнет вызовы (fail-closed, Ruling 1).");
|
||||
}
|
||||
|
||||
GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0 };
|
||||
if (mtlsCertificates is not null)
|
||||
{
|
||||
channelOptions.HttpHandler = mtlsCertificates.CreateClientHttpHandler();
|
||||
}
|
||||
|
||||
_channel = GrpcChannel.ForAddress(options.Endpoint, channelOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт клиент RPC MlService поверх общего канала (клиент — лёгкий, на каждый вызов).
|
||||
/// </summary>
|
||||
/// <returns>Клиент сервиса ML (Predict/Status/Reset/TrainBatch).</returns>
|
||||
public MlService.MlServiceClient CreateClient() => new(_channel);
|
||||
|
||||
/// <summary>
|
||||
/// Собирает обязательные metadata вызова: tenant-id + service-token (Ruling 1).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (строка, формат N — как в пуле модели ml-service).</param>
|
||||
/// <returns>Metadata для CallOptions вызова.</returns>
|
||||
public Metadata CreateMetadata(string tenantId)
|
||||
{
|
||||
var metadata = new Metadata
|
||||
{
|
||||
{ TenantIdMetadataKey, tenantId },
|
||||
{ ServiceTokenMetadataKey, _serviceToken },
|
||||
};
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _channel.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Security.Cryptography;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
// Общая запись обучающего сигнала в очередь MlOutbox (ml_client.push L40–49) для адаптеров IMlClient.
|
||||
// Поведение 1:1 с прототипом и с LocalMlClient.PushAsync этапа 3: пустые после trim text/label —
|
||||
// тихий no-op, text обрезается до 6000 символов (без разрыва суррогатной пары), id — mle_ +
|
||||
// 12 случайных hex (store.uid L48). Обучение идёт ВСЕГДА (выключатель mlEnabled его не трогает) —
|
||||
// и в Local-, и в gRPC-режиме сигнал сначала пишется в outbox, отправку в ml-service делает фоновый
|
||||
// MlOutboxFlushScheduler (Ruling 6: PushAsync ВСЕГДА пишет MlOutbox).
|
||||
internal static class MlOutboxQueue
|
||||
{
|
||||
// Максимальная длина текста обучающего примера (ml_client.push L48: text[:6000]).
|
||||
internal const int MaxLearningTextLength = 6000;
|
||||
|
||||
// Случайный хвост id outbox: 6 байт → 12 hex-символов (прототип store.uid — uuid4().hex[:12]).
|
||||
private const int OutboxIdRandomBytes = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Пишет строку очереди обучения: trim text/label (пустые — no-op), text[:6000], id mle_+hex.
|
||||
/// </summary>
|
||||
/// <param name="learningStore">Хранилище обучения (таблица MlOutbox схемы тенанта).</param>
|
||||
/// <param name="text">Текст обучающего примера (source_msg карточки или title).</param>
|
||||
/// <param name="label">Метка: id доски (<c>b_...</c>), <c>spam</c> либо <c>t:hire|t:order</c>.</param>
|
||||
/// <param name="delta">Вес сигнала (1.0 — учить, −1.0 — снять метку).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Задача завершается после записи строки (отправку делает фоновый флашер).</returns>
|
||||
public static async Task PushAsync(IMlLearningStore learningStore, string text, string label, double delta, CancellationToken ct)
|
||||
{
|
||||
string trimmedText = (text ?? string.Empty).Trim();
|
||||
string trimmedLabel = (label ?? string.Empty).Trim();
|
||||
if (trimmedText.Length == 0 || trimmedLabel.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await learningStore.AddOutboxAsync(
|
||||
NewOutboxId(),
|
||||
TruncateText(trimmedText),
|
||||
trimmedLabel,
|
||||
delta,
|
||||
ct);
|
||||
}
|
||||
|
||||
// Генерирует id строки outbox: префикс mle_ + 12 случайных hex-символов (прототип store.uid).
|
||||
// Возвращает: Короткий id записи очереди.
|
||||
private static string NewOutboxId()
|
||||
=> KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant();
|
||||
|
||||
// Обрезает текст до MaxLearningTextLength символов, не разбивая суррогатную пару на конце.
|
||||
// text: Текст (уже trim-нут).
|
||||
// Возвращает: Первые 6000 символов (или весь текст, если короче).
|
||||
// .NET-срез идёт по UTF-16-единицам и может разбить суррогатную пару; Python-срез прототипа
|
||||
// (text[:6000]) режет по code points — хвостовой high-surrogate убираем, чтобы в БД не ушла «битая» пара.
|
||||
private static string TruncateText(string text)
|
||||
{
|
||||
if (text.Length <= MaxLearningTextLength)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
string cut = text[..MaxLearningTextLength];
|
||||
return char.IsHighSurrogate(cut[^1]) ? cut[..^1] : cut;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Конфигурация клиента ML-сервиса — секция <c>Services:Ml</c> (Ruling 6, план Task 16).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// По умолчанию dev = Local-заглушка: <c>UseLocal=true</c> регистрирует <c>LocalMlClient</c>
|
||||
/// (фолбэк этапов 2–5), реальный ml-service подключается <c>Services:Ml:UseLocal=false</c> +
|
||||
/// endpoint (env <c>SERVICES__ML__USELOCAL=false</c>, <c>SERVICES__ML__ENDPOINT=http://localhost:5103</c>,
|
||||
/// compose — Ruling 12). Выбор реализации — на старте, логики переключения в рантайме нет (Ruling 6).
|
||||
/// </remarks>
|
||||
public sealed class MlServiceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя секции конфигурации (appsettings.json / env-префикс SERVICES__ML__*).
|
||||
/// </summary>
|
||||
public const string SectionName = "Services:Ml";
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint ml-service по умолчанию (dev-порт сервиса, Ruling 12).
|
||||
/// </summary>
|
||||
public const string DefaultEndpoint = "http://localhost:5103";
|
||||
|
||||
/// <summary>
|
||||
/// True — Local-заглушка LocalMlClient (default), false — gRPC-клиент GrpcMlClient.
|
||||
/// </summary>
|
||||
public bool UseLocal { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Базовый адрес ml-service (http://host:port; только без TLS — Ruling 2).
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = DefaultEndpoint;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Кэш статуса ML-сервиса на тенанта (python ml_client L30–31 + refresh_status L127–135; Ruling 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Кэш живёт 15 секунд и хранит последний известный статус + флаг <c>reachable</c>: обновление происходит
|
||||
/// при вызове <c>GrpcMlClient.StatusAsync</c>, когда запись устарела/отсутствует; при сбое сервиса строка
|
||||
/// остаётся со старыми данными и <c>reachable=false</c> (python L132–135). Singleton: кэш переживает scope
|
||||
/// запросов (в /api/ml/status и фоновых циклах тенант один и тот же), ключ — id тенанта (формат N).
|
||||
/// </remarks>
|
||||
public sealed class MlStatusCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Время жизни кэша статуса сервиса — 15 с (refresh_status python L30–31).
|
||||
/// </summary>
|
||||
public const int CacheTtlSeconds = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Запись кэша: статус модели + доступность сервиса + момент обновления.
|
||||
/// </summary>
|
||||
/// <param name="Service">Последний известный статус модели тенанта.</param>
|
||||
/// <param name="Reachable">Сервис ответил на последнем обновлении.</param>
|
||||
/// <param name="UpdatedAtMs">Момент обновления (epoch-мс, UTC).</param>
|
||||
public sealed record Snapshot(MlServiceStatusDto Service, bool Reachable, long UpdatedAtMs);
|
||||
|
||||
private readonly ConcurrentDictionary<string, Snapshot> _entries = new(StringComparer.Ordinal);
|
||||
|
||||
// Часы кэша (в проде — UtcNow; тесты подменяют для проверки TTL).
|
||||
private readonly Func<DateTimeOffset> _utcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт кэш с системными часами (DateTimeOffset.UtcNow).
|
||||
/// </summary>
|
||||
public MlStatusCache()
|
||||
: this(() => DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт кэш с заданными часами (тесты TTL 15 с).
|
||||
/// </summary>
|
||||
/// <param name="utcNow">Источник текущего времени (UTC).</param>
|
||||
public MlStatusCache(Func<DateTimeOffset> utcNow)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(utcNow);
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает свежую запись кэша (возраст ≤ <see cref="CacheTtlSeconds"/>).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (формат N).</param>
|
||||
/// <param name="snapshot">Свежая запись (если есть).</param>
|
||||
/// <returns>True, если запись есть и не устарела.</returns>
|
||||
public bool TryGetFresh(string tenantId, out Snapshot snapshot)
|
||||
{
|
||||
if (_entries.TryGetValue(tenantId, out Snapshot? cached))
|
||||
{
|
||||
long ageMs = _utcNow().ToUnixTimeMilliseconds() - cached.UpdatedAtMs;
|
||||
if (ageMs < TimeSpan.FromSeconds(CacheTtlSeconds).TotalMilliseconds)
|
||||
{
|
||||
snapshot = cached;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает последнюю запись независимо от возраста (для «старые данные при сбое», python L134).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (формат N).</param>
|
||||
/// <param name="snapshot">Последняя запись (если есть).</param>
|
||||
/// <returns>True, если запись есть.</returns>
|
||||
public bool TryGet(string tenantId, out Snapshot snapshot) => _entries.TryGetValue(tenantId, out snapshot!);
|
||||
|
||||
/// <summary>
|
||||
/// Сохраняет запись статуса (момент обновления — сейчас).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (формат N).</param>
|
||||
/// <param name="service">Статус модели.</param>
|
||||
/// <param name="reachable">Доступность сервиса.</param>
|
||||
public void Set(string tenantId, MlServiceStatusDto service, bool reachable)
|
||||
{
|
||||
_entries[tenantId] = new Snapshot(service, reachable, _utcNow().ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Помечает запись устаревшей (сброс модели, python reset_model L123 — refresh после сброса).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (формат N).</param>
|
||||
public void Invalidate(string tenantId) => _entries.TryRemove(tenantId, out _);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Загруженный набор сертификатов mTLS внутреннего gRPC (Ruling 6, план Task 13).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Создаётся один раз на старте процесса, когда <see cref="MtlsOptions.Enabled"/>=true, из файлов
|
||||
/// deploy/certs (генерация — scripts/mtls-certs.sh); при выключенном флаге <see cref="Load"/> возвращает
|
||||
/// null — процесс остаётся на plaintext + service-token (Ruling 2 этапа 6). Экземпляр живёт до конца
|
||||
/// процесса: сертификаты держат Kestrel (серверный) и исходящие gRPC-каналы (клиентский), поэтому
|
||||
/// IDisposable сознательно нет — преждевременный Dispose сломал бы живые соединения. Fail-fast: при
|
||||
/// включённом флаге любой пустой/битый путь или пароль — <see cref="InvalidOperationException"/> на старте.
|
||||
///
|
||||
/// Проверка второй стороны — цепочка на нашу CA (CustomRootTrust, без revocation): dev-CA не в системном
|
||||
/// хранилище, поэтому стандартная проверка доверия дала бы RemoteCertificateChainErrors и без кастомного
|
||||
/// билда цепочки каждое соединение отвергалось бы.
|
||||
/// </remarks>
|
||||
public sealed class MtlsCertificates
|
||||
{
|
||||
// Роль в сообщениях об ошибках: CA-сертификат (проверка второй стороны).
|
||||
private const string CaRoleName = "CA-сертификат (проверка второй стороны)";
|
||||
|
||||
// Роль в сообщениях об ошибках: серверный сертификат Kestrel-gRPC.
|
||||
private const string ServerRoleName = "серверный сертификат Kestrel-gRPC процесса";
|
||||
|
||||
// Роль в сообщениях об ошибках: клиентский сертификат исходящих каналов.
|
||||
private const string ClientRoleName = "клиентский сертификат исходящих каналов (deal-client)";
|
||||
|
||||
private MtlsCertificates(
|
||||
X509Certificate2 caCertificate,
|
||||
X509Certificate2 serverCertificate,
|
||||
X509Certificate2 clientCertificate)
|
||||
{
|
||||
CaCertificate = caCertificate;
|
||||
ServerCertificate = serverCertificate;
|
||||
ClientCertificate = clientCertificate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CA-сертификат из CaPem: корень доверия для проверки второй стороны.
|
||||
/// </summary>
|
||||
public X509Certificate2 CaCertificate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Серверный сертификат процесса из PFX (подпись своего Kestrel-gRPC-эндпоинта).
|
||||
/// </summary>
|
||||
public X509Certificate2 ServerCertificate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Клиентский сертификат из PFX (подпись исходящих каналов, общий deal-client).
|
||||
/// </summary>
|
||||
public X509Certificate2 ClientCertificate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Загружает сертификаты из <paramref name="options"/>: null при выключенном флаге (режим plaintext),
|
||||
/// иначе — CA + серверный + клиентский с fail-fast на битые пути/пароли.
|
||||
/// </summary>
|
||||
/// <param name="options">Опции mTLS (env DEAL_MTLS_*).</param>
|
||||
/// <returns>Набор сертификатов либо null (флаг выключен).</returns>
|
||||
/// <exception cref="InvalidOperationException">Флаг включён, а путь не задан/файл не найден/не читается.</exception>
|
||||
public static MtlsCertificates? Load(MtlsOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
X509Certificate2 ca = LoadCaFromPem(options);
|
||||
X509Certificate2 server = LoadPfx(options.ServerCertPfx, options.ServerCertPassword, MtlsOptions.ServerCertPfxEnvKey, MtlsOptions.ServerCertPasswordEnvKey, ServerRoleName);
|
||||
X509Certificate2 client = LoadPfx(options.ClientCertPfx, options.ClientCertPassword, MtlsOptions.ClientCertPfxEnvKey, MtlsOptions.ClientCertPasswordEnvKey, ClientRoleName);
|
||||
return new MtlsCertificates(ca, server, client);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Серверная проверка клиентского сертификата для Kestrel (ClientCertificateValidation): сертификат
|
||||
/// обязан быть подписан нашей CA (цепочка до CaPem). Стандартные ошибки цепочки (наша CA вне системного
|
||||
/// хранилища) пересобираются кастомным билдом; иные ошибки (нет сертификата/недоступен) — отказ.
|
||||
/// </summary>
|
||||
/// <param name="certificate">Клиентский сертификат из рукопожатия (null — RequireCertificate не выполнен).</param>
|
||||
/// <param name="chain">Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA).</param>
|
||||
/// <param name="sslPolicyErrors">Ошибки стандартной проверки TLS.</param>
|
||||
public bool ValidateClientCertificate(X509Certificate2? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors)
|
||||
{
|
||||
if (certificate is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sslPolicyErrors == SslPolicyErrors.None)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors)
|
||||
{
|
||||
return IsTrustedByCa(certificate);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт HTTP/2-хендлер исходящего канала: клиентский сертификат + проверка CA сервера.
|
||||
/// </summary>
|
||||
/// <returns>Новый SocketsHttpHandler (владелец — создатель; канал GrpcChannel закроет его вместе с собой).</returns>
|
||||
public SocketsHttpHandler CreateClientHttpHandler()
|
||||
{
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
ClientCertificates = new X509CertificateCollection { ClientCertificate },
|
||||
RemoteCertificateValidationCallback = ValidateServerCertificate,
|
||||
},
|
||||
};
|
||||
return handler;
|
||||
}
|
||||
|
||||
// Клиентская проверка сертификата сервера (RemoteCertificateValidationCallback): имя из SAN +
|
||||
// цепочка до нашей CA; сертификаты не нашей CA/чужое имя — отказ.
|
||||
// sender: Отправитель (не используется).
|
||||
// certificate: Сертификат сервера из рукопожатия.
|
||||
// chain: Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA).
|
||||
// sslPolicyErrors: Ошибки стандартной проверки TLS.
|
||||
private bool ValidateServerCertificate(object? sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors)
|
||||
{
|
||||
if (certificate is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sslPolicyErrors == SslPolicyErrors.None)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Имя хоста проверяется отдельно от доверия: несовпадение SAN (подключились не к тому сервису) —
|
||||
// безусловный отказ, даже если цепочка сошлась бы на нашу CA.
|
||||
if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0
|
||||
|| (sslPolicyErrors & SslPolicyErrors.RemoteCertificateNotAvailable) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateChainErrors) != 0)
|
||||
{
|
||||
using var leaf = new X509Certificate2(certificate);
|
||||
return IsTrustedByCa(leaf);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Строит цепочку candidate → наша CA (CustomRootTrust, без revocation) — признак «свой» сертификат.
|
||||
// candidate: Проверяемый сертификат второй стороны.
|
||||
private bool IsTrustedByCa(X509Certificate2 candidate)
|
||||
{
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(CaCertificate);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
return chain.Build(candidate);
|
||||
}
|
||||
|
||||
// Читает CA из PEM/DER (только публичный сертификат — ключ CA нужен лишь скрипту генерации).
|
||||
// options: Опции mTLS.
|
||||
private static X509Certificate2 LoadCaFromPem(MtlsOptions options)
|
||||
{
|
||||
string path = RequireExistingFile(options.CaPem, MtlsOptions.CaPemEnvKey, CaRoleName);
|
||||
try
|
||||
{
|
||||
return X509CertificateLoader.LoadCertificateFromFile(path);
|
||||
}
|
||||
catch (CryptographicException exception)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{CaRoleName} ({MtlsOptions.CaPemEnvKey}): не удалось прочитать \"{path}\" — ожидается PEM/DER X.509.",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
// Читает PFX (серверный/клиентский) с паролем; EphemeralKeySet — ключ не оседает в хранилище ОС.
|
||||
// configuredPath: Путь из env.
|
||||
// password: Пароль PFX.
|
||||
// pathEnvKey: Env-ключ пути (для сообщения об ошибке).
|
||||
// passwordEnvKey: Env-ключ пароля (для сообщения об ошибке).
|
||||
// role: Роль сертификата (для сообщения об ошибке).
|
||||
private static X509Certificate2 LoadPfx(
|
||||
string configuredPath,
|
||||
string password,
|
||||
string pathEnvKey,
|
||||
string passwordEnvKey,
|
||||
string role)
|
||||
{
|
||||
string path = RequireExistingFile(configuredPath, pathEnvKey, role);
|
||||
try
|
||||
{
|
||||
return X509CertificateLoader.LoadPkcs12FromFile(
|
||||
path,
|
||||
password,
|
||||
X509KeyStorageFlags.EphemeralKeySet);
|
||||
}
|
||||
catch (CryptographicException exception)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{role} ({pathEnvKey}): не удалось открыть \"{path}\" — проверьте путь и пароль ({passwordEnvKey}).",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-fast: путь обязан быть задан и указывать на существующий файл.
|
||||
// configuredPath: Путь из env.
|
||||
// envKey: Env-ключ пути (для сообщения об ошибке).
|
||||
// role: Роль сертификата (для сообщения об ошибке).
|
||||
private static string RequireExistingFile(string configuredPath, string envKey, string role)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"mTLS включён (DEAL_MTLS_ENABLED=1), но не задан путь {role}: env {envKey}.");
|
||||
}
|
||||
|
||||
string path = configuredPath.Trim();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new InvalidOperationException($"{role} ({envKey}): файл не найден \"{path}\".");
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Конфигурация mTLS-транспорта внутреннего gRPC (Ruling 6, план Task 13).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Только env (Ruling 13: секреты/пути сертификатов не читаются из appsettings): флаг
|
||||
/// <c>DEAL_MTLS_ENABLED</c> и пути/пароли <c>DEAL_MTLS_*</c> из Ruling 6. Dev-дефолт — выключено
|
||||
/// (<see cref="Enabled"/> = false): процессы остаются на plaintext + service-token (Ruling 2 этапа 6);
|
||||
/// PROD включает флаг env из compose-prod (Task 14; файлы монтируются из deploy/certs/, генерация —
|
||||
/// scripts/mtls-certs.sh). Каждый процесс несёт и серверную, и клиентскую роль (Ruling 6): серверный PFX —
|
||||
/// для своего Kestrel-gRPC (у сервисов свой, у core — ингресс :5082), клиентский — для исходящих каналов
|
||||
/// (общий deal-client), CA — для проверки второй стороны.
|
||||
/// </remarks>
|
||||
public sealed class MtlsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Env-ключ флага: 1/true включает mTLS (как прочие env-флаги сервиса).
|
||||
/// </summary>
|
||||
public const string EnabledEnvKey = "DEAL_MTLS_ENABLED";
|
||||
|
||||
/// <summary>
|
||||
/// Env-ключ пути к PFX серверного сертификата процесса (Kestrel-gRPC).
|
||||
/// </summary>
|
||||
public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX";
|
||||
|
||||
/// <summary>
|
||||
/// Env-ключ пароля серверного PFX.
|
||||
/// </summary>
|
||||
public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Env-ключ пути к PFX клиентского сертификата (общий deal-client исходящих каналов).
|
||||
/// </summary>
|
||||
public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX";
|
||||
|
||||
/// <summary>
|
||||
/// Env-ключ пароля клиентского PFX.
|
||||
/// </summary>
|
||||
public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Env-ключ пути к PEM dev-CA (проверка сертификата второй стороны).
|
||||
/// </summary>
|
||||
public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM";
|
||||
|
||||
/// <summary>
|
||||
/// True — транспорт внутренних gRPC-эндпоинтов и исходящих каналов под mTLS.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Путь к PFX серверного сертификата процесса (см. <see cref="ServerCertPfxEnvKey"/>).
|
||||
/// </summary>
|
||||
public string ServerCertPfx { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Пароль серверного PFX (см. <see cref="ServerCertPasswordEnvKey"/>).
|
||||
/// </summary>
|
||||
public string ServerCertPassword { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Путь к PFX клиентского сертификата (см. <see cref="ClientCertPfxEnvKey"/>).
|
||||
/// </summary>
|
||||
public string ClientCertPfx { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Пароль клиентского PFX (см. <see cref="ClientCertPasswordEnvKey"/>).
|
||||
/// </summary>
|
||||
public string ClientCertPassword { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Путь к PEM-файлу dev-CA (см. <see cref="CaPemEnvKey"/>).
|
||||
/// </summary>
|
||||
public string CaPem { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Читает опции из конфигурации хоста (env-ключи DEAL_MTLS_*, только env — Ruling 13).
|
||||
/// </summary>
|
||||
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
|
||||
/// <returns>Опции mTLS (флаг выключен — остальные поля пустые).</returns>
|
||||
public static MtlsOptions FromConfiguration(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
return new MtlsOptions
|
||||
{
|
||||
Enabled = IsEnabled(configuration[EnabledEnvKey]),
|
||||
ServerCertPfx = Trimmed(configuration[ServerCertPfxEnvKey]),
|
||||
ServerCertPassword = configuration[ServerCertPasswordEnvKey] ?? string.Empty,
|
||||
ClientCertPfx = Trimmed(configuration[ClientCertPfxEnvKey]),
|
||||
ClientCertPassword = configuration[ClientCertPasswordEnvKey] ?? string.Empty,
|
||||
CaPem = Trimmed(configuration[CaPemEnvKey]),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает значение флага DEAL_MTLS_ENABLED: «1»/«true» (без учёта регистра) — включено.
|
||||
/// </summary>
|
||||
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
|
||||
public static bool IsEnabled(string? rawValue)
|
||||
=> string.Equals(rawValue, "1", StringComparison.Ordinal)
|
||||
|| string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API).
|
||||
// rawValue: Сырое значение env.
|
||||
private static string Trimmed(string? rawValue)
|
||||
=> rawValue is null ? string.Empty : rawValue.Trim();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Grpc.Core;
|
||||
using Grpc.Health.V1;
|
||||
using Grpc.Net.Client;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Health-проба grpc.health.v1 автономных сервисов (ml/ai/telegram) для операторского health
|
||||
/// (план Task 10: GET /api/operator/health, Ruling 3/6/9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Каждый вызов строит свой короткоживущий канал к <c>endpoint</c> сервиса (dev — без TLS, Ruling 2;
|
||||
/// mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом и
|
||||
/// проверяет CA сервера — сертификаты передаются <see cref="MtlsCertificates"/> в конструктор) и спрашивает
|
||||
/// Health.Check("") с дедлайном 3 с — health не должен висеть дольше таймаута. Классификация: ответ
|
||||
/// <c>SERVING</c> → Reachable+Serving; ответ с иным статусом → Reachable без
|
||||
/// Serving; таймаут/нет соединения (Unavailable/DeadlineExceeded, HTTP-транспорт) и сервис без health-контракта
|
||||
/// (Unimplemented) → <see cref="ServiceHealthResult.Unreachable"/>.
|
||||
/// </remarks>
|
||||
public sealed class ServiceHealthProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Дедлайн health-RPC, секунд (Ruling 3/9: операторский health отвечает за ~3 с на сервис).
|
||||
/// </summary>
|
||||
public const int HealthTimeoutSeconds = 3;
|
||||
|
||||
private readonly MtlsCertificates? _mtlsCertificates;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт пробу; mTLS-каналы — при переданных сертификатах (иначе plaintext, dev).
|
||||
/// </summary>
|
||||
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал.</param>
|
||||
public ServiceHealthProbe(MtlsCertificates? mtlsCertificates = null)
|
||||
{
|
||||
_mtlsCertificates = mtlsCertificates;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет health-контракт gRPC-сервиса по базовому адресу (grpc.health.v1, сервис "").
|
||||
/// </summary>
|
||||
/// <param name="endpoint">Базовый адрес сервиса (http://host:port; пустой/пробельный — ошибка аргумента).</param>
|
||||
/// <param name="ct">Токен отмены вызывающего.</param>
|
||||
/// <returns>Результат пробы (см. <see cref="ServiceHealthResult"/>).</returns>
|
||||
public async Task<ServiceHealthResult> ProbeAsync(string endpoint, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(endpoint);
|
||||
using SocketsHttpHandler? handler = _mtlsCertificates?.CreateClientHttpHandler();
|
||||
GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0, HttpHandler = handler };
|
||||
using var channel = GrpcChannel.ForAddress(endpoint, channelOptions);
|
||||
var client = new Health.HealthClient(channel);
|
||||
try
|
||||
{
|
||||
HealthCheckResponse response = await client.CheckAsync(
|
||||
new HealthCheckRequest { Service = string.Empty },
|
||||
deadline: DateTime.UtcNow.AddSeconds(HealthTimeoutSeconds),
|
||||
cancellationToken: ct);
|
||||
return new ServiceHealthResult(
|
||||
Reachable: true,
|
||||
Serving: response.Status == HealthCheckResponse.Types.ServingStatus.Serving);
|
||||
}
|
||||
catch (RpcException exception) when (IsCommunicationFailure(exception))
|
||||
{
|
||||
return ServiceHealthResult.Unreachable;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
// Ошибка транспорта HTTP/2 (DNS/соединение) — до gRPC-статуса не дошло.
|
||||
return ServiceHealthResult.Unreachable;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Сработал дедлайн пробы (отмена вызывающего выше пробросилась бы дальше) — сервис не ответил за 3 с.
|
||||
return ServiceHealthResult.Unreachable;
|
||||
}
|
||||
}
|
||||
|
||||
// Ошибки коммуникации, при которых сервис считается недоступным (всё, кроме прикладных статусов).
|
||||
// exception: Исключение RPC.
|
||||
// Возвращает: True — транспорта/контракта health нет (down), false — прикладной статус (не наша зона).
|
||||
private static bool IsCommunicationFailure(RpcException exception) =>
|
||||
exception.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Unimplemented;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Результат health-пробы grpc.health.v1 автономного сервиса (Task 10; формирует <see cref="ServiceHealthProbe"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>Reachable</c> — сервис ответил на health-RPC (канал/транспорт жив); <c>Serving</c> — статус ответа
|
||||
/// <c>SERVING</c> (health-контракт в порядке). Комбинации: (true, true) = ok; (true, false) = сервис жив, но
|
||||
/// не готов (NOT_SERVING/SERVICE_UNKNOWN — «unhealthy»); (false, false) = недоступен (таймаут/нет слушателя —
|
||||
/// «down»). Значение-сирота (false, true) не возникает (Serving=true без ответа невозможно).
|
||||
/// </remarks>
|
||||
public sealed record ServiceHealthResult(bool Reachable, bool Serving)
|
||||
{
|
||||
/// <summary>
|
||||
/// Недоступен: RPC не выполнен (нет соединения/дедлайн/ошибка транспорта).
|
||||
/// </summary>
|
||||
public static ServiceHealthResult Unreachable { get; } = new(Reachable: false, Serving: false);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// DI-регистрация файлового хранилища: выбор Local/MinIO по конфигурации (Ruling 4, Task 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="AddDealFileStorage"/> читает секцию <c>Storage</c> (см. <see cref="StorageOptions"/>) и выбирает
|
||||
/// адаптер по правилу Ruling 4: секция Minio заполнена (Endpoint и AccessKey/SecretKey) → регистрируется
|
||||
/// <see cref="MinioFileStorage"/>; иначе — <see cref="LocalFileStorage"/> (root из Storage:Local:Root либо
|
||||
/// дефолт <c>data/attachments</c> под ContentRoot) — dev/curl/unit идут БЕЗ MinIO (требование «заглушка-
|
||||
/// адаптер, если MinIO недоступен»). Значения секции Storage:Minio дублируются env-алиасами
|
||||
/// <c>DEAL_MINIO_ENDPOINT</c>/<c>DEAL_MINIO_ACCESS_KEY</c>/<c>DEAL_MINIO_SECRET_KEY</c>/<c>DEAL_MINIO_BUCKET</c>/
|
||||
/// <c>DEAL_MINIO_SECURE</c> (аналог LEADRADAR_MINIO_* прототипа config.py): секция (appsettings/env
|
||||
/// Storage__Minio__*) имеет приоритет, алиасы заполняют незаданные поля. Оба адаптера — singleton:
|
||||
/// хранилище не привязано к схеме тенанта (объекты — в едином бакете/каталоге; мульти-аренда объектного
|
||||
/// хранилища — этап 7 SaaS), реализации потокобезопасны. Вызывается из Program.cs
|
||||
/// (после AddDealIntegrations; contentRoot — IWebHostEnvironment.ContentRootPath).
|
||||
/// </remarks>
|
||||
public static class FileStorageRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя секции конфигурации файлового хранилища (<c>Storage</c>).
|
||||
/// </summary>
|
||||
public const string ConfigurationSectionName = "Storage";
|
||||
|
||||
/// <summary>
|
||||
/// Дефолтный каталог вложений локального режима относительно ContentRoot (fallback прототипа: FILES_DIR = DATA_DIR/attachments).
|
||||
/// </summary>
|
||||
public const string DefaultAttachmentsRelativePath = "data/attachments";
|
||||
|
||||
private const string SectionKeyLocalRoot = "Storage:Local:Root";
|
||||
private const string SectionKeyMinioEndpoint = "Storage:Minio:Endpoint";
|
||||
private const string SectionKeyMinioAccessKey = "Storage:Minio:AccessKey";
|
||||
private const string SectionKeyMinioSecretKey = "Storage:Minio:SecretKey";
|
||||
private const string SectionKeyMinioBucket = "Storage:Minio:Bucket";
|
||||
private const string SectionKeyMinioSecure = "Storage:Minio:Secure";
|
||||
|
||||
private const string MinioEnvironmentEndpointVariableName = "DEAL_MINIO_ENDPOINT";
|
||||
private const string MinioEnvironmentAccessKeyVariableName = "DEAL_MINIO_ACCESS_KEY";
|
||||
private const string MinioEnvironmentSecretKeyVariableName = "DEAL_MINIO_SECRET_KEY";
|
||||
private const string MinioEnvironmentBucketVariableName = "DEAL_MINIO_BUCKET";
|
||||
private const string MinioEnvironmentSecureVariableName = "DEAL_MINIO_SECURE";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует IFileStorage — LocalFileStorage или MinioFileStorage по конфигурации (Ruling 4).
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов.</param>
|
||||
/// <param name="configuration">Конфигурация приложения (секция Storage + env-алиасы DEAL_MINIO_*).</param>
|
||||
/// <param name="contentRootPath">ContentRoot приложения — база для дефолтного корня data/attachments.</param>
|
||||
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
|
||||
public static IServiceCollection AddDealFileStorage(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string contentRootPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
StorageOptions options = ReadOptions(configuration);
|
||||
|
||||
// MinIO-режим: только когда секция/алиасы заполнены (Ruling 4: «заглушка-адаптер, если MinIO
|
||||
// недоступен» — dev/curl/unit по умолчанию работают на LocalFileStorage без MinIO).
|
||||
if (MinioConfigured(options.Minio))
|
||||
{
|
||||
services.AddSingleton<IFileStorage>(serviceProvider =>
|
||||
new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService<ILogger<MinioFileStorage>>()));
|
||||
return services;
|
||||
}
|
||||
|
||||
string rootPath = ResolveLocalRoot(options.Local, contentRootPath);
|
||||
services.AddSingleton<IFileStorage>(new LocalFileStorage(rootPath));
|
||||
return services;
|
||||
}
|
||||
|
||||
// Читает секцию Storage и заполняет незаданные поля Minio из env-алиасов DEAL_MINIO_*.
|
||||
// configuration: Конфигурация приложения.
|
||||
// Возвращает: Настройки хранилища (дефолты Local.Root=null и Minio.Bucket=deal-files сохранены).
|
||||
private static StorageOptions ReadOptions(IConfiguration configuration)
|
||||
{
|
||||
StorageOptions options = new();
|
||||
|
||||
options.Local.Root = ReadSetting(configuration, SectionKeyLocalRoot);
|
||||
|
||||
// Бакет/дефолты не затираем: присваиваем только найденные значения.
|
||||
string? endpoint = ReadSetting(configuration, SectionKeyMinioEndpoint);
|
||||
string? accessKey = ReadSetting(configuration, SectionKeyMinioAccessKey);
|
||||
string? secretKey = ReadSetting(configuration, SectionKeyMinioSecretKey);
|
||||
string? bucket = ReadSetting(configuration, SectionKeyMinioBucket);
|
||||
string? secureRaw = ReadSetting(configuration, SectionKeyMinioSecure);
|
||||
if (endpoint is not null)
|
||||
{
|
||||
options.Minio.Endpoint = endpoint;
|
||||
}
|
||||
|
||||
if (accessKey is not null)
|
||||
{
|
||||
options.Minio.AccessKey = accessKey;
|
||||
}
|
||||
|
||||
if (secretKey is not null)
|
||||
{
|
||||
options.Minio.SecretKey = secretKey;
|
||||
}
|
||||
|
||||
if (bucket is not null)
|
||||
{
|
||||
options.Minio.Bucket = bucket;
|
||||
}
|
||||
|
||||
if (secureRaw is not null)
|
||||
{
|
||||
options.Minio.Secure = IsTrue(secureRaw);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// True — секция Minio заполнена настолько, что возможен Minio-адаптер (Ruling 4: Endpoint + креды).
|
||||
private static bool MinioConfigured(MinioStorageOptions minio)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(minio.Endpoint)
|
||||
&& !string.IsNullOrWhiteSpace(minio.AccessKey)
|
||||
&& !string.IsNullOrWhiteSpace(minio.SecretKey);
|
||||
}
|
||||
|
||||
// Резолвит корень локального хранилища: дефолт data/attachments под ContentRoot; относительный Root — под ContentRoot; абсолютный — как есть.
|
||||
private static string ResolveLocalRoot(LocalStorageOptions local, string contentRootPath)
|
||||
{
|
||||
string? root = local.Root;
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
return Path.Combine(contentRootPath, DefaultAttachmentsRelativePath);
|
||||
}
|
||||
|
||||
return Path.IsPathRooted(root) ? root : Path.Combine(contentRootPath, root);
|
||||
}
|
||||
|
||||
// Значение настройки: секция конфигурации (Storage:… / env Storage__…__…) приоритетнее, иначе env-алиас DEAL_MINIO_*; null — не задано.
|
||||
private static string? ReadSetting(IConfiguration configuration, string sectionKey)
|
||||
{
|
||||
string? fromSection = configuration[sectionKey];
|
||||
if (!string.IsNullOrWhiteSpace(fromSection))
|
||||
{
|
||||
return fromSection;
|
||||
}
|
||||
|
||||
string? alias = EnvironmentAliasFor(sectionKey);
|
||||
if (alias is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? fromEnvironment = Environment.GetEnvironmentVariable(alias);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? null : fromEnvironment;
|
||||
}
|
||||
|
||||
// Env-алиас DEAL_MINIO_* для ключа секции Storage:Minio (аналог LEADRADAR_MINIO_*); Local-ключи алиасов не имеют.
|
||||
private static string? EnvironmentAliasFor(string sectionKey)
|
||||
{
|
||||
return sectionKey switch
|
||||
{
|
||||
SectionKeyMinioEndpoint => MinioEnvironmentEndpointVariableName,
|
||||
SectionKeyMinioAccessKey => MinioEnvironmentAccessKeyVariableName,
|
||||
SectionKeyMinioSecretKey => MinioEnvironmentSecretKeyVariableName,
|
||||
SectionKeyMinioBucket => MinioEnvironmentBucketVariableName,
|
||||
SectionKeyMinioSecure => MinioEnvironmentSecureVariableName,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsTrue(string raw)
|
||||
{
|
||||
return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || raw == "1";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Локальное файловое хранилище вложений — каталог на диске (Ruling 4, Task 6; 1:1 object_store.py L54–79).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Dev/curl/unit-режим по умолчанию: используется, когда MinIO не сконфигурирован (Ruling 4 — «заглушка-
|
||||
/// адаптер, если MinIO недоступен»). Root — абсолютный каталог (по умолчанию <c>data/attachments</c> под
|
||||
/// ContentRoot, резолвит <see cref="FileStorageRegistrar"/>). Путь из objectKey строится безопасно:
|
||||
/// ключ делится на сегменты по <c>/</c> (и <c>\</c> — защита не зависит от ОС), сегменты <c>.</c>/<c>..</c>
|
||||
/// запрещены, итоговый полный путь обязан лежать внутри root (object_store.py L54–79 — «не даём выйти за
|
||||
/// FILES_DIR»). Put — mkdir родителя + запись потока с позиции 0 (Ruling T6: перемотаемый поток сбрасывается
|
||||
/// в 0 — в отличие от MinIO-адаптера локальный поток не буферизуется: длина тут не нужна); Get — FileStream|null;
|
||||
/// Stat — FileInfo-дескриптор (размер; contentType пуст — см. ниже); Delete — удаление файла. ContentType не хранится (как
|
||||
/// прототип: локально пишутся только байты) — дескриптор StatAsync несёт пустой MIME, и download-эндпоинт (Task 9)
|
||||
/// отвечает фиксированным application/octet-stream (Ruling 4/T6).
|
||||
/// Потокобезопасен (состояние — только root); регистрируется singleton.
|
||||
/// </remarks>
|
||||
public sealed class LocalFileStorage : IFileStorage
|
||||
{
|
||||
// Размер буфера чтения при скачивании (async FileStream).
|
||||
private const int FileBufferSize = 81920;
|
||||
|
||||
private readonly string _rootPath;
|
||||
private readonly string _rootFullPath;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт хранилище с корневым каталогом.
|
||||
/// </summary>
|
||||
/// <param name="rootPath">Абсолютный путь корня вложений (создаётся при первом put).</param>
|
||||
public LocalFileStorage(string rootPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rootPath);
|
||||
_rootPath = rootPath;
|
||||
_rootFullPath = Path.GetFullPath(rootPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Описание режима для стартового лога Api (приёмка Task 6: LocalFileStorage + путь data/attachments).
|
||||
/// </summary>
|
||||
/// <returns>Строка вида <c>LocalFileStorage (root: …)</c>.</returns>
|
||||
public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> PutAsync(string objectKey, Stream content, string contentType, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
// Контракт порта (Ruling T6): Put читает ВСЁ содержимое с позиции 0 — поток-источник (multipart)
|
||||
// может быть прочитан эндпоинтом раньше; перемотаемые потоки сбрасываем (неперемотаемые читаются
|
||||
// с текущей позиции, как есть). Выравнивание с Minio-адаптером PutAsync.
|
||||
if (content.CanSeek && content.Position != 0)
|
||||
{
|
||||
content.Position = 0;
|
||||
}
|
||||
|
||||
string path = ResolvePath(objectKey);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await using (FileStream output = new(path, FileMode.Create, FileAccess.Write, FileShare.None, FileBufferSize, FileOptions.Asynchronous))
|
||||
{
|
||||
await content.CopyToAsync(output, ct);
|
||||
}
|
||||
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream?> GetAsync(string objectKey, CancellationToken ct)
|
||||
{
|
||||
string path = ResolvePath(objectKey);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
// FileStream отдаётся вызывающему «как есть» (владелец — вызывающий, он же закрывает; python — BytesIO).
|
||||
FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read, FileBufferSize, FileOptions.Asynchronous);
|
||||
return Task.FromResult<Stream?>(stream);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FileMeta?> StatAsync(string objectKey, CancellationToken ct)
|
||||
{
|
||||
string path = ResolvePath(objectKey);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return Task.FromResult<FileMeta?>(null);
|
||||
}
|
||||
|
||||
// ContentType локально не хранится (put пишет только байты, как прототип) — дескриптор несёт пустой
|
||||
// MIME (см. FileMeta); download-эндпоинт (Task 9) отвечает application/octet-stream (Ruling 4/T6).
|
||||
FileInfo info = new(path);
|
||||
return Task.FromResult<FileMeta?>(new FileMeta(objectKey, info.Length, string.Empty));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string objectKey, CancellationToken ct)
|
||||
{
|
||||
string path = ResolvePath(objectKey);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Безопасно резолвит objectKey в путь внутри root (object_store.py _local_path L54–79).
|
||||
// objectKey: Ключ объекта (сегменты по '/', без «.»/«..»).
|
||||
// Возвращает: Полный путь файла под root.
|
||||
// Исключение ArgumentException: objectKey пуст либо содержит обходные сегменты «.»/«..».
|
||||
private string ResolvePath(string objectKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(objectKey))
|
||||
{
|
||||
throw new ArgumentException("objectKey не может быть пустым.", nameof(objectKey));
|
||||
}
|
||||
|
||||
string normalized = objectKey.Replace('\\', '/');
|
||||
string[] segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
throw new ArgumentException($"objectKey «{objectKey}» не содержит сегментов пути.", nameof(objectKey));
|
||||
}
|
||||
|
||||
foreach (string segment in segments)
|
||||
{
|
||||
if (segment is "." or "..")
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"objectKey «{objectKey}» содержит обходной сегмент «{segment}»: выход за пределы хранилища запрещён.",
|
||||
nameof(objectKey));
|
||||
}
|
||||
}
|
||||
|
||||
string fullPath = Path.GetFullPath(Path.Combine(_rootFullPath, Path.Combine(segments)));
|
||||
if (!IsInsideRoot(fullPath))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"objectKey «{objectKey}» выходит за пределы каталога хранилища «{_rootPath}».",
|
||||
nameof(objectKey));
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
// True — путь равен root или лежит внутри него (контрольная проверка после GetFullPath).
|
||||
private bool IsInsideRoot(string fullPath)
|
||||
{
|
||||
if (string.Equals(fullPath, _rootFullPath, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string prefix = _rootFullPath.EndsWith(Path.DirectorySeparatorChar)
|
||||
? _rootFullPath
|
||||
: _rootFullPath + Path.DirectorySeparatorChar;
|
||||
return fullPath.StartsWith(prefix, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Deal.Infrastructure.Integrations.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Локальный режим файлового хранилища — секция <c>Storage:Local</c> (Ruling 4, Task 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Root — каталог вложений: относительный путь резолвится от ContentRoot приложения, абсолютный — как есть
|
||||
/// (см. <see cref="FileStorageRegistrar.AddDealFileStorage"/>). Пустая секция → дефолт
|
||||
/// <c>data/attachments</c> под ContentRoot (fallback прототипа object_store.py L54–79: FILES_DIR =
|
||||
/// DATA_DIR/attachments). Режим Local — dev/curl/unit по умолчанию: выбирается, когда MinIO не сконфигурирован.
|
||||
/// </remarks>
|
||||
public sealed class LocalStorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Каталог вложений (относительно ContentRoot либо абсолютный); пусто — data/attachments.
|
||||
/// </summary>
|
||||
public string? Root { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Minio;
|
||||
using Minio.DataModel;
|
||||
using Minio.DataModel.Args;
|
||||
using Minio.Exceptions;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Хранилище вложений на MinIO (S3-совместимое) — Minio .NET SDK (Ruling 4, Task 6; 1:1 object_store.py L26–107).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется, только когда MinIO сконфигурирован (секция Storage:Minio / env DEAL_MINIO_* заполнена —
|
||||
/// см. <see cref="FileStorageRegistrar"/>); иначе действует LocalFileStorage. Клиент строится в конструкторе
|
||||
/// (без сети), бакет проверяется/создаётся ЛЕНИВО при первом put (object_store.py L26–51: bucket_exists/
|
||||
/// make_bucket один раз; сбой проверки — warning-лог, put продолжит и упадёт — 1:1 с python L47–51). Put —
|
||||
/// буферизация потока в память: MinIO-пути нужна известная длина (Content-Length), а прототип и так держит
|
||||
/// байты файла в памяти (put L67–73); Get — GetObjectAsync с callback-потоком (буфер MemoryStream);
|
||||
/// Stat — StatObjectAsync → FileMeta (размер + contentType, сохранённый при put);
|
||||
/// отсутствие объекта (ObjectNotFoundException) → null (как GetAsync порта). Delete гасит MinioException
|
||||
/// warning-логом (remove L96–108: метаданные карточки чистит сервис в любом случае). Единственный бакет,
|
||||
/// tenant-префикса в ключах нет — мульти-аренда объектного хранилища этапом 7 SaaS. Потокобезопасен
|
||||
/// (клиент SDK thread-safe, проверка бакета под gate); регистрируется singleton.
|
||||
/// </remarks>
|
||||
public sealed class MinioFileStorage : IFileStorage
|
||||
{
|
||||
// ContentType по умолчанию, когда загрузка не указала MIME (object_store.py L72).
|
||||
private const string DefaultContentType = "application/octet-stream";
|
||||
|
||||
private readonly IMinioClient _client;
|
||||
private readonly string _endpoint;
|
||||
private readonly string _bucket;
|
||||
private readonly ILogger<MinioFileStorage> _logger;
|
||||
|
||||
// Семафор ленивой проверки/создания бакета (гонка первых put, object_store.py L44–51).
|
||||
private readonly SemaphoreSlim _bucketCheckGate = new(1, 1);
|
||||
|
||||
private bool _bucketChecked;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт адаптер поверх настроек MinIO.
|
||||
/// </summary>
|
||||
/// <param name="options">Настройки секции Storage:Minio (Endpoint/AccessKey/SecretKey обязательны).</param>
|
||||
/// <param name="logger">Логгер предупреждений о сбоях MinIO.</param>
|
||||
/// <exception cref="InvalidOperationException">Секция MinIO не заполнена (конфигурационная ошибка регистрации).</exception>
|
||||
public MinioFileStorage(MinioStorageOptions options, ILogger<MinioFileStorage> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Endpoint)
|
||||
|| string.IsNullOrWhiteSpace(options.AccessKey)
|
||||
|| string.IsNullOrWhiteSpace(options.SecretKey))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"MinioFileStorage требует заполненные Storage:Minio Endpoint и AccessKey/SecretKey "
|
||||
+ "(регистратор выбирает Minio-адаптер только при заполненной секции, Ruling 4).");
|
||||
}
|
||||
|
||||
_endpoint = options.Endpoint;
|
||||
_bucket = string.IsNullOrWhiteSpace(options.Bucket)
|
||||
? MinioStorageOptions.DefaultBucketName
|
||||
: options.Bucket;
|
||||
_logger = logger;
|
||||
|
||||
// Клиент без сетевых вызовов: endpoint — «host:port» без схемы; WithSSL — по настройке Secure
|
||||
// (dev-compose deal-minio — http, Secure=false).
|
||||
_client = new MinioClient()
|
||||
.WithEndpoint(options.Endpoint)
|
||||
.WithCredentials(options.AccessKey, options.SecretKey)
|
||||
.WithSSL(options.Secure)
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Описание режима для стартового лога Api (endpoint/бакет, без секретов).
|
||||
/// </summary>
|
||||
/// <returns>Строка вида <c>MinioFileStorage (endpoint: …; bucket: …)</c>.</returns>
|
||||
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> PutAsync(string objectKey, Stream content, string contentType, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
await EnsureBucketAsync(ct);
|
||||
|
||||
// Прототип держит байты файла в памяти (put L67–73); MinIO-пути нужна известная длина объекта
|
||||
// (Content-Length), поэтому поток буферизуется — Local-адаптер буферизации не требует.
|
||||
if (content.CanSeek && content.Position != 0)
|
||||
{
|
||||
content.Position = 0;
|
||||
}
|
||||
|
||||
using MemoryStream buffer = new();
|
||||
await content.CopyToAsync(buffer, ct);
|
||||
buffer.Position = 0;
|
||||
|
||||
await _client.PutObjectAsync(
|
||||
new PutObjectArgs()
|
||||
.WithBucket(_bucket)
|
||||
.WithObject(objectKey)
|
||||
.WithStreamData(buffer)
|
||||
.WithObjectSize(buffer.Length)
|
||||
.WithContentType(string.IsNullOrWhiteSpace(contentType) ? DefaultContentType : contentType),
|
||||
ct);
|
||||
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream?> GetAsync(string objectKey, CancellationToken ct)
|
||||
{
|
||||
MemoryStream buffer = new();
|
||||
try
|
||||
{
|
||||
// В 7.0 SDK содержимое приходит в callback-поток, GetObjectAsync возвращает ObjectStat (стат не нужен);
|
||||
// объекта нет → SDK бросает ObjectNotFoundException → null-семантика порта.
|
||||
await _client.GetObjectAsync(
|
||||
new GetObjectArgs()
|
||||
.WithBucket(_bucket)
|
||||
.WithObject(objectKey)
|
||||
.WithCallbackStream(async (stream, token) => await stream.CopyToAsync(buffer, token)),
|
||||
ct);
|
||||
}
|
||||
catch (ObjectNotFoundException)
|
||||
{
|
||||
buffer.Dispose();
|
||||
return null;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Любая иная ошибка (сеть/MinIO недоступен и т.п.): частично заполненный буфер не течёт (Ruling T6),
|
||||
// ошибка уходит вызывающему (эндпоинт Task 9 мапит её в 404 «Файл не найден в MinIO»).
|
||||
buffer.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
buffer.Position = 0;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FileMeta?> StatAsync(string objectKey, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Стат объекта: размер и contentType (кладётся при put, см. PutAsync) — download-эндпоинт (Task 9)
|
||||
// отвечает Content-Length/Content-Type из дескриптора (Ruling T6; объекта нет → ObjectNotFoundException
|
||||
// → null-семантика порта). Иные ошибки (MinIO недоступен) уходят вызывающему — он мапит их в 404.
|
||||
ObjectStat stat = await _client.StatObjectAsync(
|
||||
new StatObjectArgs().WithBucket(_bucket).WithObject(objectKey),
|
||||
ct);
|
||||
return new FileMeta(objectKey, stat.Size, stat.ContentType ?? string.Empty);
|
||||
}
|
||||
catch (ObjectNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(string objectKey, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Удаление отсутствующего объекта — успех (MinIO отвечает 204): исключений не будет.
|
||||
await _client.RemoveObjectAsync(
|
||||
new RemoveObjectArgs().WithBucket(_bucket).WithObject(objectKey),
|
||||
ct);
|
||||
}
|
||||
catch (MinioException exception)
|
||||
{
|
||||
// 1:1 object_store.py remove L96–108: сбой MinIO (недоступен, бакет не создан) гасим warning-логом —
|
||||
// метаданные карточки (FilesJson) чистит сервис в любом случае (Task 7).
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Не удалось удалить объект MinIO «{ObjectKey}» из бакета «{Bucket}»: {Message}",
|
||||
objectKey,
|
||||
_bucket,
|
||||
exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Ленивая проверка/создание бакета при первом put (object_store.py L26–51).
|
||||
// ct: Токен отмены.
|
||||
private async Task EnsureBucketAsync(CancellationToken ct)
|
||||
{
|
||||
if (_bucketChecked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _bucketCheckGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_bucketChecked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
bool exists = await _client.BucketExistsAsync(new BucketExistsArgs().WithBucket(_bucket), ct);
|
||||
if (!exists)
|
||||
{
|
||||
await _client.MakeBucketAsync(new MakeBucketArgs().WithBucket(_bucket), ct);
|
||||
}
|
||||
}
|
||||
catch (MinioException exception)
|
||||
{
|
||||
// Бакет не проверить/создать (MinIO недоступен и т.п.): put продолжит и упадёт с понятной
|
||||
// ошибкой; 1:1 object_store.py L47–51 (python логирует warning и не бросает на проверке).
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Не удалось проверить/создать бакет MinIO «{Bucket}»: {Message}",
|
||||
_bucket,
|
||||
exception.Message);
|
||||
}
|
||||
|
||||
_bucketChecked = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_bucketCheckGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace Deal.Infrastructure.Integrations.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// MinIO-режим файлового хранилища — секция <c>Storage:Minio</c> (Ruling 4, Task 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Источник — секция <c>Storage:Minio</c> (appsettings.json + env <c>Storage__Minio__Endpoint</c>,
|
||||
/// <c>Storage__Minio__AccessKey</c>, <c>Storage__Minio__SecretKey</c>, <c>Storage__Minio__Bucket</c>,
|
||||
/// <c>Storage__Minio__Secure</c>; 1:1 с Ruling 4 «креды Storage:Minio … из appsettings/env Storage__Minio__*»).
|
||||
/// Если секция не задана, регистратор заполняет её из env-алиасов <c>DEAL_MINIO_ENDPOINT</c>/
|
||||
/// <c>DEAL_MINIO_ACCESS_KEY</c>/<c>DEAL_MINIO_SECRET_KEY</c>/<c>DEAL_MINIO_BUCKET</c>/<c>DEAL_MINIO_SECURE</c>
|
||||
/// (аналог LEADRADAR_MINIO_* config.py прототипа). Адаптер <see cref="MinioFileStorage"/> регистрируется,
|
||||
/// только когда Endpoint и AccessKey/SecretKey заполнены (Ruling 4: иначе LocalFileStorage — «заглушка,
|
||||
/// если MinIO недоступен»). Бакет — единственный (объекты всех карточек в одном бакете, как в прототипе;
|
||||
/// мульти-аренда объектного хранилища — этап 7 SaaS), по умолчанию deal-files.
|
||||
/// </remarks>
|
||||
public sealed class MinioStorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя бакета по умолчанию (Ruling 4; python MINIO_BUCKET дефолт из config).
|
||||
/// </summary>
|
||||
public const string DefaultBucketName = "deal-files";
|
||||
|
||||
/// <summary>
|
||||
/// Хост:порт MinIO (например, localhost:9000 или play.min.io).
|
||||
/// </summary>
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Access Key (пользователь S3).
|
||||
/// </summary>
|
||||
public string? AccessKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Secret Key (пароль пользователя S3).
|
||||
/// </summary>
|
||||
public string? SecretKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Имя бакета объектов; пусто — <see cref="DefaultBucketName"/>.
|
||||
/// </summary>
|
||||
public string Bucket { get; set; } = DefaultBucketName;
|
||||
|
||||
/// <summary>
|
||||
/// True — HTTPS (WithSSL); dev-compose deal-minio — false (http).
|
||||
/// </summary>
|
||||
public bool Secure { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Deal.Infrastructure.Integrations.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Настройки файлового хранилища — секция <c>Storage</c> конфигурации (Ruling 4, Task 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Источник — секция <c>Storage</c> (appsettings.json + env <c>Storage__Local__Root</c>,
|
||||
/// <c>Storage__Minio__Endpoint</c> и т.д.; см. <see cref="MinioStorageOptions"/>) — плюс env-алиасы
|
||||
/// <c>DEAL_MINIO_*</c> (аналог LEADRADAR_MINIO_* прототипа), которые заполняют секцию Minio, если она не
|
||||
/// задана (см. <see cref="FileStorageRegistrar.AddDealFileStorage"/>). Читается регистратором вручную
|
||||
/// (секция маленькая; Binder в Infrastructure не тянем). LocalFileStorage — dev/unit по умолчанию;
|
||||
/// MinioFileStorage регистрируется, только когда Minio сконфигурирован (Ruling 4).
|
||||
/// </remarks>
|
||||
public sealed class StorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Настройки локального режима (root-каталог относительно ContentRoot).
|
||||
/// </summary>
|
||||
public LocalStorageOptions Local { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Настройки MinIO-режима (endpoint/креды/бакет).
|
||||
/// </summary>
|
||||
public MinioStorageOptions Minio { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Deal.Grpc.Telegram;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Транспорт gRPC-клиента telegram-service: общий канал + обязательные metadata (Ruling 1, план Task 14).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Singleton (канал живёт долго и переиспользуется всеми вызовами): endpoint из
|
||||
/// <see cref="TelegramServiceOptions"/>, service-token — из env <c>DEAL_SERVICE_TOKEN</c> (Ruling 13: секреты
|
||||
/// только в env). Dev-транспорт без TLS (Ruling 2); mTLS (Ruling 6, Task 13): при включённом флаге канал
|
||||
/// подписывает запрос клиентским сертификатом и проверяет CA сервера (сертификаты передаются
|
||||
/// <see cref="MtlsCertificates"/>). Пустой endpoint либо пустой токен при создании — ошибка конфигурации (fail-closed:
|
||||
/// без токена сервис отвергнет каждый вызов UNAUTHENTICATED, Ruling 1). Автоповторы Grpc.Net.Client отключены
|
||||
/// (MaxRetryAttempts=0): стратегию повторов реализует вызывающий (фоновые циклы Api пробуют в следующем тике).
|
||||
/// </remarks>
|
||||
public sealed class TelegramGrpcConnection : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor сервисов, Ruling 1).
|
||||
/// </summary>
|
||||
public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с tenant-id (зеркало TelegramServiceImpl, Ruling 1).
|
||||
/// </summary>
|
||||
public const string TenantIdMetadataKey = "tenant-id";
|
||||
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с service-token (зеркало TelegramServiceImpl, Ruling 1).
|
||||
/// </summary>
|
||||
public const string ServiceTokenMetadataKey = "service-token";
|
||||
|
||||
private readonly GrpcChannel _channel;
|
||||
private readonly string _serviceToken;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт транспорт telegram-service по конфигурации и env-токену (валидация fail-closed).
|
||||
/// </summary>
|
||||
/// <param name="options">Конфигурация секции <c>Services:Telegram</c> (endpoint).</param>
|
||||
/// <param name="mtlsCertificates">Сертификаты mTLS (Ruling 6): null — plaintext-канал (dev, флаг выключен).</param>
|
||||
/// <exception cref="InvalidOperationException">Пустой endpoint или пустой DEAL_SERVICE_TOKEN.</exception>
|
||||
public TelegramGrpcConnection(TelegramServiceOptions options, MtlsCertificates? mtlsCertificates = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (string.IsNullOrWhiteSpace(options.Endpoint))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"TelegramGrpcConnection: не задан endpoint telegram-service (секция \"{TelegramServiceOptions.SectionName}:Endpoint\").");
|
||||
}
|
||||
|
||||
_serviceToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey) ?? string.Empty;
|
||||
if (_serviceToken.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"TelegramGrpcConnection: не задан env {ServiceTokenEnvKey} — telegram-service отвергнет вызовы (fail-closed, Ruling 1).");
|
||||
}
|
||||
|
||||
GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0 };
|
||||
if (mtlsCertificates is not null)
|
||||
{
|
||||
channelOptions.HttpHandler = mtlsCertificates.CreateClientHttpHandler();
|
||||
}
|
||||
|
||||
_channel = GrpcChannel.ForAddress(options.Endpoint, channelOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт клиент RPC TelegramService поверх общего канала (клиент — лёгкий, на каждый вызов).
|
||||
/// </summary>
|
||||
/// <returns>Клиент сервиса Telegram (команды ядра наружу).</returns>
|
||||
public TelegramService.TelegramServiceClient CreateClient() => new(_channel);
|
||||
|
||||
/// <summary>
|
||||
/// Собирает обязательные metadata вызова: tenant-id + service-token (Ruling 1).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Id тенанта (строка, формат N — как в сессиях telegram-service).</param>
|
||||
/// <returns>Metadata для CallOptions вызова.</returns>
|
||||
public Metadata CreateMetadata(string tenantId)
|
||||
{
|
||||
var metadata = new Metadata
|
||||
{
|
||||
{ TenantIdMetadataKey, tenantId },
|
||||
{ ServiceTokenMetadataKey, _serviceToken },
|
||||
};
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _channel.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Конфигурация клиента telegram-service — секция <c>Services:Telegram</c> (Ruling 6, план Task 14).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// По умолчанию dev = Local-заглушка: <c>UseLocal=true</c> регистрирует <c>LocalTelegramGateway</c>
|
||||
/// (нейтральный no-op/idle — реальный telegram-service в dev не поднят), реальный сервис подключается
|
||||
/// <c>Services:Telegram:UseLocal=false</c> + endpoint (env <c>SERVICES__TELEGRAM__USELOCAL=false</c>,
|
||||
/// <c>SERVICES__TELEGRAM__ENDPOINT=http://localhost:5101</c>, compose — Ruling 12). Выбор реализации — на
|
||||
/// старте, логики переключения в рантайме нет (Ruling 6).
|
||||
/// </remarks>
|
||||
public sealed class TelegramServiceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя секции конфигурации (appsettings.json / env-префикс SERVICES__TELEGRAM__*).
|
||||
/// </summary>
|
||||
public const string SectionName = "Services:Telegram";
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint telegram-service по умолчанию (dev-порт сервиса, Ruling 12).
|
||||
/// </summary>
|
||||
public const string DefaultEndpoint = "http://localhost:5101";
|
||||
|
||||
/// <summary>
|
||||
/// True — Local-заглушка LocalTelegramGateway (default), false — gRPC-клиент GrpcTelegramClient.
|
||||
/// </summary>
|
||||
public bool UseLocal { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Базовый адрес telegram-service (http://host:port; только без TLS — Ruling 2).
|
||||
/// </summary>
|
||||
public string Endpoint { get; set; } = DefaultEndpoint;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Observability;
|
||||
using Deal.SharedKernel.Tenants;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations;
|
||||
|
||||
/// <summary>
|
||||
/// Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): успешный RPC ai-service
|
||||
/// (Filter/Classify/GenerateKeywords/EvaluateFit) списывает usage с бюджета тенанта, копит lifetime-сумму
|
||||
/// в tenant-KV и пишет событие в public.token_usage_events; локальный ML-вызов пишет событие (kind=ml).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Точка вызова — та же, что у этапа 6 (GrpcAiClassifier/GrpcAiTools после успешного RPC; ML — GrpcMlClient/
|
||||
/// LocalMlClient.Predict). Три учёта:
|
||||
/// <list type="number">
|
||||
/// <item><b>Бюджет периода</b> — <c>ITenantLimitStore.AddUsageAsync</c>: инкремент UsedTokens в public.tenant_limits
|
||||
/// (тот же scoped DealDbContext запроса) с ленивым reset периода; источник истины бюджетного гейта Task 9.
|
||||
/// Только для платных AI-вызовов (ML бюджет не расходует).</item>
|
||||
/// <item><b>Lifetime-счётчик</b> — tenant-KV ключ aiTokenUsage ({prompt, completion, total}, существующий формат
|
||||
/// этапа 6): «всего» за всё время. Только для AI (ML — локальный, aiTokenUsage не засоряет).</item>
|
||||
/// <item><b>История событий</b> — <c>TokenUsageEventService.AppendAsync</c> (public.token_usage_events): провайдер,
|
||||
/// модель, вид (ai|ml), токены; основа time-series аналитики оператора (этап 10, T3).</item>
|
||||
/// </list>
|
||||
/// Списание в tenant_limits выполняется только при Total>0 (нулевой usage ответа моделью не заводит строку
|
||||
/// лимита); lifetime-KV пишется всегда, как раньше. Scoped: пишет в KV-хранилище тенанта запроса (ISettingsStore
|
||||
/// → scoped TenantDbContext), в public.tenant_limits/токен-историю — через scoped DealDbContext.
|
||||
/// </remarks>
|
||||
public sealed class TokenUsageRecorder
|
||||
{
|
||||
// Имена полей значения aiTokenUsage (1:1 с Usage ai.proto: prompt/completion/total).
|
||||
private const string PromptField = "prompt";
|
||||
|
||||
private const string CompletionField = "completion";
|
||||
|
||||
private const string TotalField = "total";
|
||||
|
||||
// Оценка токенов по символам, символов на токен (конвенция проекта ai.proto Ruling 5: ≈chars/4).
|
||||
private const int CharsPerToken = 4;
|
||||
|
||||
private readonly ISettingsStore _store;
|
||||
private readonly ITenantLimitStore _tenantLimits;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly TokenUsageEventService _events;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт recorder расхода токенов.
|
||||
/// </summary>
|
||||
/// <param name="store">KV-хранилище настроек тенанта (ключ aiTokenUsage, lifetime-счётчик).</param>
|
||||
/// <param name="tenantLimits">Хранилище лимитов бюджета (public.tenant_limits, списание периода).</param>
|
||||
/// <param name="tenantContext">Контекст текущего тенанта (AsyncLocal; tenantId списания/события).</param>
|
||||
/// <param name="events">Сервис истории расхода (public.token_usage_events, этап 10).</param>
|
||||
public TokenUsageRecorder(
|
||||
ISettingsStore store,
|
||||
ITenantLimitStore tenantLimits,
|
||||
ITenantContext tenantContext,
|
||||
TokenUsageEventService events)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
ArgumentNullException.ThrowIfNull(tenantLimits);
|
||||
ArgumentNullException.ThrowIfNull(tenantContext);
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
_store = store;
|
||||
_tenantLimits = tenantLimits;
|
||||
_tenantContext = tenantContext;
|
||||
_events = events;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Списывает usage ответа ai-service: (1) инкремент бюджета периода в tenant_limits, (2) lifetime-сумму
|
||||
/// в KV aiTokenUsage, (3) событие истории (kind=ai). usage null — no-op (успешный RPC без оценки токенов).
|
||||
/// </summary>
|
||||
/// <param name="usage">Оценка токенов ответа (Usage ai.proto; reply без usage — нули; null — no-op).</param>
|
||||
/// <param name="provider">Id активного провайдера (deepseek/openai/anthropic/…; событие истории).</param>
|
||||
/// <param name="model">Модель провайдера (событие истории).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public async Task AddAsync(Usage? usage, string provider, string model, CancellationToken ct)
|
||||
{
|
||||
if (usage is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (usage.Total > 0)
|
||||
{
|
||||
await _tenantLimits.AddUsageAsync(RequireTenantId(), usage.Total, ct);
|
||||
}
|
||||
|
||||
await AddToLifetimeAsync(usage, ct);
|
||||
// Прикладная метрика (этап 12, пакет A): счётчик вызовов/токенов ИИ — та же точка, что и событие
|
||||
// token_usage_events (без tenantId в метках).
|
||||
DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion);
|
||||
await RecordEventAsync(
|
||||
provider,
|
||||
model,
|
||||
TokenUsageEventKinds.Ai,
|
||||
promptTokens: usage.Prompt,
|
||||
completionTokens: usage.Completion,
|
||||
totalTokens: usage.Total,
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записывает событие локального ML-вызова (kind=ml) с оценкой токенов по длине входного текста
|
||||
/// (≈chars/4, конвенция ai.proto): бюджет/lifetime aiTokenUsage ML не затрагивает.
|
||||
/// </summary>
|
||||
/// <param name="text">Входной текст предсказания (оценка токенов запроса; null — 0).</param>
|
||||
/// <param name="provider">Провайдер/источник события (для локальной ML-модели — "local").</param>
|
||||
/// <param name="model">Модель/вид локального ML-вызова (событие истории).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Оценка токенов (для тестов/наблюдаемости).</returns>
|
||||
public async Task<long> AddEstimatedAsync(string? text, string provider, string model, CancellationToken ct)
|
||||
{
|
||||
long promptTokens = EstimateTokens(text);
|
||||
// Прикладная метрика (этап 12, пакет A): вызов локального ML + оценка токенов (та же точка,
|
||||
// что и событие token_usage_events, kind=ml).
|
||||
DealMetrics.RecordMlUsage(promptTokens);
|
||||
await RecordEventAsync(
|
||||
provider,
|
||||
model,
|
||||
TokenUsageEventKinds.Ml,
|
||||
promptTokens: promptTokens,
|
||||
completionTokens: 0,
|
||||
totalTokens: promptTokens,
|
||||
ct);
|
||||
return promptTokens;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Оценка токенов по символам (≈chars/4; конвенция проекта, ai.proto Ruling 5).
|
||||
/// </summary>
|
||||
/// <param name="text">Текст (null/пустой — 0).</param>
|
||||
/// <returns>Оценка токенов (неотрицательная).</returns>
|
||||
public static long EstimateTokens(string? text) =>
|
||||
string.IsNullOrEmpty(text) ? 0 : text.Length / CharsPerToken;
|
||||
|
||||
// Пишет событие истории расхода токенов (public.token_usage_events, tenant-id текущего scope).
|
||||
// provider: Провайдер/источник.
|
||||
// model: Модель.
|
||||
// kind: Вид вызова ai|ml.
|
||||
// promptTokens: Токены запроса.
|
||||
// completionTokens: Токены ответа.
|
||||
// totalTokens: Всего токенов.
|
||||
// ct: Токен отмены.
|
||||
private async Task RecordEventAsync(
|
||||
string provider,
|
||||
string model,
|
||||
string kind,
|
||||
long promptTokens,
|
||||
long completionTokens,
|
||||
long totalTokens,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Секретов в DetailJson нет: событие хранит только провайдера/модель/вид/токены.
|
||||
await _events.AppendAsync(
|
||||
new TokenUsageEventDto(
|
||||
TenantId: RequireTenantId(),
|
||||
At: default,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
Kind: kind,
|
||||
PromptTokens: promptTokens,
|
||||
CompletionTokens: completionTokens,
|
||||
TotalTokens: totalTokens,
|
||||
DetailJson: null),
|
||||
ct);
|
||||
}
|
||||
|
||||
// Текущий тенант scope как Guid строки public.tenants (без него списание не имеет смысла).
|
||||
// Возвращает: Идентификатор тенанта (Guid).
|
||||
// Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id.
|
||||
private Guid RequireTenantId()
|
||||
{
|
||||
TenantId? tenantId = _tenantContext.TenantId;
|
||||
if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"TokenUsageRecorder запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid).");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// Прибавляет usage к накопленному значению aiTokenUsage (lifetime-счётчик, формат этапа 6).
|
||||
// usage: Оценка токенов ответа.
|
||||
// ct: Токен отмены.
|
||||
private async Task AddToLifetimeAsync(Usage usage, CancellationToken ct)
|
||||
{
|
||||
JsonObject? current = await ReadAsync(ct);
|
||||
long prompt = ReadBound(current, PromptField) + usage.Prompt;
|
||||
long completion = ReadBound(current, CompletionField) + usage.Completion;
|
||||
long total = ReadBound(current, TotalField) + usage.Total;
|
||||
|
||||
var updated = new JsonObject
|
||||
{
|
||||
[PromptField] = ClampToUint(prompt),
|
||||
[CompletionField] = ClampToUint(completion),
|
||||
[TotalField] = ClampToUint(total),
|
||||
};
|
||||
await _store.SetAsync(SettingsKeys.AiTokenUsage, updated.ToJsonString(), ct);
|
||||
}
|
||||
|
||||
// Текущее значение aiTokenUsage (JSON-объект) или null — строки нет.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Объект значения или null.
|
||||
private async Task<JsonObject?> ReadAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await _store.GetAsync(SettingsKeys.AiTokenUsage, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(row.ValueJson) as JsonObject;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Повреждённая строка — нули (мягкая семантика, как в SettingsService).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Число поля значения: отсутствие/не-число → 0 (без clamp: сумма ограничивается при записи).
|
||||
// value: Объект значения aiTokenUsage (может быть null).
|
||||
// field: Имя поля (prompt/completion/total).
|
||||
// Возвращает: Значение поля или 0.
|
||||
private static long ReadBound(JsonObject? value, string field)
|
||||
{
|
||||
if (value is null || !value.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue scalar)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return scalar.TryGetValue<long>(out long number) && number > 0 ? number : 0;
|
||||
}
|
||||
|
||||
// Ограничивает сумму диапазоном uint32 (proto Usage — uint; переполнение не ожидается).
|
||||
// value: Накопленная сумма.
|
||||
// Возвращает: Значение в диапазоне uint32.
|
||||
private static JsonNode ClampToUint(long value)
|
||||
=> JsonValue.Create(Math.Clamp(value, 0, uint.MaxValue))!;
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(DealDbContext))]
|
||||
[Migration("20260905192825_InitialSystem")]
|
||||
partial class InitialSystem
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("tenants", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("users", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSystem : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "public");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenants",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tenants", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "users",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Login = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false, defaultValue: "active"),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_users", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_users_tenants_TenantId",
|
||||
column: x => x.TenantId,
|
||||
principalSchema: "public",
|
||||
principalTable: "tenants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sessions",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Login = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_sessions", x => x.TokenHash);
|
||||
table.ForeignKey(
|
||||
name: "FK_sessions_users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalSchema: "public",
|
||||
principalTable: "users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sessions_ExpiresAt",
|
||||
schema: "public",
|
||||
table: "sessions",
|
||||
column: "ExpiresAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_sessions_UserId",
|
||||
schema: "public",
|
||||
table: "sessions",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_users_Login",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
column: "Login",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_users_TenantId",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
column: "TenantId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "sessions",
|
||||
schema: "public");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "users",
|
||||
schema: "public");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenants",
|
||||
schema: "public");
|
||||
}
|
||||
}
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(DealDbContext))]
|
||||
[Migration("20260907181413_SystemSaaS")]
|
||||
partial class SystemSaaS
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActorType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DetailJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Ip")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("At");
|
||||
|
||||
b.HasIndex("TenantId", "EventType");
|
||||
|
||||
b.ToTable("audit_log", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Code")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedById")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("pending");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Code");
|
||||
|
||||
b.HasIndex("CreatedById");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Status\" = 'pending'");
|
||||
|
||||
b.ToTable("invites", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("operators", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OperatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("OperatorId");
|
||||
|
||||
b.ToTable("operator_sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("tenants", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("BudgetTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("NotifiedExhausted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Period")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("month");
|
||||
|
||||
b.Property<DateTimeOffset>("PeriodStart")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UsedTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("Warned80")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("TenantId");
|
||||
|
||||
b.ToTable("tenant_limits", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("users", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedById")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OperatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SystemSaaS : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "audit_log",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
At = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ActorType = table.Column<string>(type: "text", nullable: false),
|
||||
ActorId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
TenantId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
EventType = table.Column<string>(type: "text", nullable: false),
|
||||
Ip = table.Column<string>(type: "text", nullable: true),
|
||||
DetailJson = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_audit_log", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "operators",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Login = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false, defaultValue: "active"),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_operators", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_limits",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
BudgetTokens = table.Column<long>(type: "bigint", nullable: false),
|
||||
Period = table.Column<string>(type: "text", nullable: false, defaultValue: "month"),
|
||||
PeriodStart = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UsedTokens = table.Column<long>(type: "bigint", nullable: false),
|
||||
Warned80 = table.Column<bool>(type: "boolean", nullable: false),
|
||||
NotifiedExhausted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tenant_limits", x => x.TenantId);
|
||||
table.ForeignKey(
|
||||
name: "FK_tenant_limits_tenants_TenantId",
|
||||
column: x => x.TenantId,
|
||||
principalSchema: "public",
|
||||
principalTable: "tenants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "invites",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
Code = table.Column<string>(type: "text", nullable: false),
|
||||
Email = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
TenantId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Status = table.Column<string>(type: "text", nullable: false, defaultValue: "pending"),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ActivatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedById = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_invites", x => x.Code);
|
||||
table.ForeignKey(
|
||||
name: "FK_invites_operators_CreatedById",
|
||||
column: x => x.CreatedById,
|
||||
principalSchema: "public",
|
||||
principalTable: "operators",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "operator_sessions",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
OperatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Login = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_operator_sessions", x => x.TokenHash);
|
||||
table.ForeignKey(
|
||||
name: "FK_operator_sessions_operators_OperatorId",
|
||||
column: x => x.OperatorId,
|
||||
principalSchema: "public",
|
||||
principalTable: "operators",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_audit_log_At",
|
||||
schema: "public",
|
||||
table: "audit_log",
|
||||
column: "At");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_audit_log_TenantId_EventType",
|
||||
schema: "public",
|
||||
table: "audit_log",
|
||||
columns: new[] { "TenantId", "EventType" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_invites_CreatedById",
|
||||
schema: "public",
|
||||
table: "invites",
|
||||
column: "CreatedById");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_invites_Email",
|
||||
schema: "public",
|
||||
table: "invites",
|
||||
column: "Email",
|
||||
unique: true,
|
||||
filter: "\"Status\" = 'pending'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_operator_sessions_ExpiresAt",
|
||||
schema: "public",
|
||||
table: "operator_sessions",
|
||||
column: "ExpiresAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_operator_sessions_OperatorId",
|
||||
schema: "public",
|
||||
table: "operator_sessions",
|
||||
column: "OperatorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_operators_Login",
|
||||
schema: "public",
|
||||
table: "operators",
|
||||
column: "Login",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "audit_log",
|
||||
schema: "public");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "invites",
|
||||
schema: "public");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "operator_sessions",
|
||||
schema: "public");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_limits",
|
||||
schema: "public");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "operators",
|
||||
schema: "public");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+346
@@ -0,0 +1,346 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(DealDbContext))]
|
||||
[Migration("20260907192419_SessionsImpersonationMark")]
|
||||
partial class SessionsImpersonationMark
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActorType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DetailJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Ip")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("At");
|
||||
|
||||
b.HasIndex("TenantId", "EventType");
|
||||
|
||||
b.ToTable("audit_log", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Code")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedById")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("pending");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Code");
|
||||
|
||||
b.HasIndex("CreatedById");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Status\" = 'pending'");
|
||||
|
||||
b.ToTable("invites", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("operators", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OperatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("OperatorId");
|
||||
|
||||
b.ToTable("operator_sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ImpersonatedByOperatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("tenants", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("BudgetTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("NotifiedExhausted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Period")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("month");
|
||||
|
||||
b.Property<DateTimeOffset>("PeriodStart")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UsedTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("Warned80")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("TenantId");
|
||||
|
||||
b.ToTable("tenant_limits", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("users", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedById")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OperatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SessionsImpersonationMark : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "ImpersonatedByOperatorId",
|
||||
schema: "public",
|
||||
table: "sessions",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ImpersonatedByOperatorId",
|
||||
schema: "public",
|
||||
table: "sessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(DealDbContext))]
|
||||
[Migration("20260910152246_AddTokenUsageEvents")]
|
||||
partial class AddTokenUsageEvents
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActorType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DetailJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Ip")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("At");
|
||||
|
||||
b.HasIndex("TenantId", "EventType");
|
||||
|
||||
b.ToTable("audit_log", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Code")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedById")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("pending");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Code");
|
||||
|
||||
b.HasIndex("CreatedById");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Status\" = 'pending'");
|
||||
|
||||
b.ToTable("invites", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("operators", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OperatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("OperatorId");
|
||||
|
||||
b.ToTable("operator_sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ImpersonatedByOperatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("tenants", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("BudgetTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("NotifiedExhausted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Period")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("month");
|
||||
|
||||
b.Property<DateTimeOffset>("PeriodStart")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UsedTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("Warned80")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("TenantId");
|
||||
|
||||
b.ToTable("tenant_limits", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("CompletionTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("DetailJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("PromptTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("TotalTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("At");
|
||||
|
||||
b.HasIndex("TenantId", "At");
|
||||
|
||||
b.ToTable("token_usage_events", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("users", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedById")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OperatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTokenUsageEvents : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "token_usage_events",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
At = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
Provider = table.Column<string>(type: "text", nullable: false),
|
||||
Model = table.Column<string>(type: "text", nullable: false),
|
||||
Kind = table.Column<string>(type: "text", nullable: false),
|
||||
PromptTokens = table.Column<long>(type: "bigint", nullable: false),
|
||||
CompletionTokens = table.Column<long>(type: "bigint", nullable: false),
|
||||
TotalTokens = table.Column<long>(type: "bigint", nullable: false),
|
||||
DetailJson = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_token_usage_events", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_token_usage_events_tenants_TenantId",
|
||||
column: x => x.TenantId,
|
||||
principalSchema: "public",
|
||||
principalTable: "tenants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_token_usage_events_At",
|
||||
schema: "public",
|
||||
table: "token_usage_events",
|
||||
column: "At");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_token_usage_events_TenantId_At",
|
||||
schema: "public",
|
||||
table: "token_usage_events",
|
||||
columns: new[] { "TenantId", "At" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "token_usage_events",
|
||||
schema: "public");
|
||||
}
|
||||
}
|
||||
}
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(DealDbContext))]
|
||||
[Migration("20260910172545_RateLimitCounters")]
|
||||
partial class RateLimitCounters
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActorType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DetailJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Ip")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("At");
|
||||
|
||||
b.HasIndex("TenantId", "EventType");
|
||||
|
||||
b.ToTable("audit_log", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Code")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedById")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("pending");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Code");
|
||||
|
||||
b.HasIndex("CreatedById");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Status\" = 'pending'");
|
||||
|
||||
b.ToTable("invites", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("operators", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OperatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("OperatorId");
|
||||
|
||||
b.ToTable("operator_sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RateLimitCounterEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("WindowStart")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.ToTable("rate_limit_counters", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ImpersonatedByOperatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("tenants", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("BudgetTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("NotifiedExhausted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Period")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("month");
|
||||
|
||||
b.Property<DateTimeOffset>("PeriodStart")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UsedTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("Warned80")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("TenantId");
|
||||
|
||||
b.ToTable("tenant_limits", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("CompletionTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("DetailJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("PromptTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("TotalTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("At");
|
||||
|
||||
b.HasIndex("TenantId", "At");
|
||||
|
||||
b.ToTable("token_usage_events", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("users", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedById")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OperatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RateLimitCounters : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "rate_limit_counters",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "text", nullable: false),
|
||||
WindowStart = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
Count = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_rate_limit_counters", x => x.Key);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_rate_limit_counters_ExpiresAt",
|
||||
schema: "public",
|
||||
table: "rate_limit_counters",
|
||||
column: "ExpiresAt");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "rate_limit_counters",
|
||||
schema: "public");
|
||||
}
|
||||
}
|
||||
}
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(DealDbContext))]
|
||||
[Migration("20260910194443_GlobalSettings")]
|
||||
partial class GlobalSettings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActorType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DetailJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Ip")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("At");
|
||||
|
||||
b.HasIndex("TenantId", "EventType");
|
||||
|
||||
b.ToTable("audit_log", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.GlobalSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("global_settings", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Code")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedById")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("pending");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Code");
|
||||
|
||||
b.HasIndex("CreatedById");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Status\" = 'pending'");
|
||||
|
||||
b.ToTable("invites", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("operators", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OperatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("OperatorId");
|
||||
|
||||
b.ToTable("operator_sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RateLimitCounterEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("WindowStart")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.ToTable("rate_limit_counters", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ImpersonatedByOperatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("tenants", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("BudgetTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("NotifiedExhausted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Period")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("month");
|
||||
|
||||
b.Property<DateTimeOffset>("PeriodStart")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UsedTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("Warned80")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("TenantId");
|
||||
|
||||
b.ToTable("tenant_limits", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("CompletionTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("DetailJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("PromptTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("TotalTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("At");
|
||||
|
||||
b.HasIndex("TenantId", "At");
|
||||
|
||||
b.ToTable("token_usage_events", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("users", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedById")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OperatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class GlobalSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "global_settings",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Value = table.Column<string>(type: "text", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_global_settings", x => x.Key);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "global_settings",
|
||||
schema: "public");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(DealDbContext))]
|
||||
partial class DealDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActorType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DetailJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Ip")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("At");
|
||||
|
||||
b.HasIndex("TenantId", "EventType");
|
||||
|
||||
b.ToTable("audit_log", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.GlobalSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("global_settings", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Code")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedById")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("pending");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Code");
|
||||
|
||||
b.HasIndex("CreatedById");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Status\" = 'pending'");
|
||||
|
||||
b.ToTable("invites", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("operators", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OperatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("OperatorId");
|
||||
|
||||
b.ToTable("operator_sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RateLimitCounterEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("WindowStart")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.ToTable("rate_limit_counters", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("TokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ImpersonatedByOperatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("TokenHash");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("sessions", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("tenants", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("BudgetTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("NotifiedExhausted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Period")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("month");
|
||||
|
||||
b.Property<DateTimeOffset>("PeriodStart")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UsedTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("Warned80")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("TenantId");
|
||||
|
||||
b.ToTable("tenant_limits", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("CompletionTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("DetailJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("PromptTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("TotalTokens")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("At");
|
||||
|
||||
b.HasIndex("TenantId", "At");
|
||||
|
||||
b.ToTable("token_usage_events", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Login")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("active");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("users", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedById")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OperatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
[DbContext(typeof(TenantDbContext))]
|
||||
[Migration("20260905193010_InitialTenant")]
|
||||
partial class InitialTenant
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialTenant : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "settings",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
ValueJson = table.Column<string>(type: "text", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_settings", x => x.Key);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
[DbContext(typeof(TenantDbContext))]
|
||||
[Migration("20260906125058_TenantKanban")]
|
||||
partial class TenantKanban
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.BoardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VisibleFieldsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Width")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Boards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Col")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConvCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("ConvFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("ConvTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancyKnown")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MatchHitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrevCol")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SourceDialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceMsg")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long?>("SourceMsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Col", "IsNew");
|
||||
|
||||
b.HasIndex("Col", "ReceivedAt")
|
||||
.IsDescending(false, true);
|
||||
|
||||
b.ToTable("Cards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FromCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ToCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CardMoves", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("By")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.ToTable("LeadComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("MlOutbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TenantKanban : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Boards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: false),
|
||||
Color = table.Column<string>(type: "text", nullable: false),
|
||||
Width = table.Column<string>(type: "text", nullable: false),
|
||||
Position = table.Column<int>(type: "integer", nullable: false),
|
||||
KeywordsJson = table.Column<string>(type: "text", nullable: false),
|
||||
Prompt = table.Column<string>(type: "text", nullable: false),
|
||||
VisibleFieldsJson = table.Column<string>(type: "text", nullable: false),
|
||||
Collapsed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Suggested = table.Column<bool>(type: "boolean", nullable: false),
|
||||
RulesJson = table.Column<string>(type: "text", nullable: false),
|
||||
Note = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Boards", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CardMoves",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
LeadId = table.Column<string>(type: "text", nullable: false),
|
||||
Action = table.Column<string>(type: "text", nullable: false),
|
||||
FromCol = table.Column<string>(type: "text", nullable: true),
|
||||
ToCol = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CardMoves", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Cards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Col = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
IsNew = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsVacancy = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsVacancyKnown = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
Summary = table.Column<string>(type: "text", nullable: false),
|
||||
StackJson = table.Column<string>(type: "text", nullable: false),
|
||||
BudgetFrom = table.Column<double>(type: "double precision", nullable: true),
|
||||
BudgetTo = table.Column<double>(type: "double precision", nullable: true),
|
||||
BudgetCur = table.Column<string>(type: "text", nullable: false),
|
||||
ConvFrom = table.Column<double>(type: "double precision", nullable: true),
|
||||
ConvTo = table.Column<double>(type: "double precision", nullable: true),
|
||||
ConvCur = table.Column<string>(type: "text", nullable: false),
|
||||
Contact = table.Column<string>(type: "text", nullable: false),
|
||||
ContactsJson = table.Column<string>(type: "text", nullable: false),
|
||||
ChannelName = table.Column<string>(type: "text", nullable: false),
|
||||
ChannelHandle = table.Column<string>(type: "text", nullable: false),
|
||||
ChannelHue = table.Column<string>(type: "text", nullable: false),
|
||||
ReceivedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
SourceMsg = table.Column<string>(type: "text", nullable: false),
|
||||
SourceDialogId = table.Column<string>(type: "text", nullable: false),
|
||||
SourceMsgId = table.Column<long>(type: "bigint", nullable: true),
|
||||
PrevCol = table.Column<string>(type: "text", nullable: false),
|
||||
ArchivedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
MatchHitsJson = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Cards", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MlOutbox",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
Label = table.Column<string>(type: "text", nullable: false),
|
||||
Delta = table.Column<double>(type: "double precision", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MlOutbox", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LeadComments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
CardId = table.Column<string>(type: "text", nullable: false),
|
||||
By = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LeadComments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_LeadComments_Cards_CardId",
|
||||
column: x => x.CardId,
|
||||
principalTable: "Cards",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Boards_Suggested_Position",
|
||||
table: "Boards",
|
||||
columns: new[] { "Suggested", "Position" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Cards_Col_IsNew",
|
||||
table: "Cards",
|
||||
columns: new[] { "Col", "IsNew" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Cards_Col_ReceivedAt",
|
||||
table: "Cards",
|
||||
columns: new[] { "Col", "ReceivedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LeadComments_CardId",
|
||||
table: "LeadComments",
|
||||
column: "CardId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MlOutbox_CreatedAt",
|
||||
table: "MlOutbox",
|
||||
column: "CreatedAt");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Boards");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CardMoves");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LeadComments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MlOutbox");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Cards");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+461
@@ -0,0 +1,461 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
[DbContext(typeof(TenantDbContext))]
|
||||
[Migration("20260906165058_TenantPipeline")]
|
||||
partial class TenantPipeline
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.BoardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VisibleFieldsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Width")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Boards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Col")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConvCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("ConvFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("ConvTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancyKnown")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MatchHitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrevCol")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true);
|
||||
|
||||
b.Property<string>("SourceDialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceMsg")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long?>("SourceMsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("Col", "IsNew");
|
||||
|
||||
b.HasIndex("Col", "ReceivedAt")
|
||||
.IsDescending(false, true);
|
||||
|
||||
b.ToTable("Cards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FromCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ToCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CardMoves", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b =>
|
||||
{
|
||||
b.Property<string>("Hash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Hash");
|
||||
|
||||
b.ToTable("DedupEntries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("By")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.ToTable("LeadComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("MlOutbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Force")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("QueueItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kw")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("RejectedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReturnReason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Returned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReturnedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true);
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RejectedAt");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.ToTable("RejectedItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TenantPipeline : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<NpgsqlTsVector>(
|
||||
name: "SearchTsv",
|
||||
table: "Cards",
|
||||
type: "tsvector",
|
||||
nullable: false,
|
||||
computedColumnSql: "to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))",
|
||||
stored: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DedupEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Hash = table.Column<string>(type: "text", nullable: false),
|
||||
LeadId = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DedupEntries", x => x.Hash);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "QueueItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
DialogId = table.Column<string>(type: "text", nullable: false),
|
||||
ChannelName = table.Column<string>(type: "text", nullable: false),
|
||||
ChannelHandle = table.Column<string>(type: "text", nullable: false),
|
||||
ChannelHue = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
MsgId = table.Column<long>(type: "bigint", nullable: true),
|
||||
MsgAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
Force = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_QueueItems", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RejectedItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
DialogId = table.Column<string>(type: "text", nullable: false),
|
||||
MsgId = table.Column<long>(type: "bigint", nullable: true),
|
||||
ChannelName = table.Column<string>(type: "text", nullable: false),
|
||||
ChannelHandle = table.Column<string>(type: "text", nullable: false),
|
||||
ChannelHue = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
Stage = table.Column<string>(type: "text", nullable: false),
|
||||
Reason = table.Column<string>(type: "text", nullable: false),
|
||||
Kw = table.Column<string>(type: "text", nullable: false),
|
||||
Source = table.Column<string>(type: "text", nullable: false),
|
||||
MsgAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
RejectedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
Returned = table.Column<bool>(type: "boolean", nullable: false),
|
||||
ReturnedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
ReturnReason = table.Column<string>(type: "text", nullable: false),
|
||||
SearchTsv = table.Column<NpgsqlTsVector>(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Text\",''))", stored: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RejectedItems", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Cards_SearchTsv",
|
||||
table: "Cards",
|
||||
column: "SearchTsv")
|
||||
.Annotation("Npgsql:IndexMethod", "gin");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_QueueItems_Status_CreatedAt",
|
||||
table: "QueueItems",
|
||||
columns: new[] { "Status", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RejectedItems_RejectedAt",
|
||||
table: "RejectedItems",
|
||||
column: "RejectedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RejectedItems_SearchTsv",
|
||||
table: "RejectedItems",
|
||||
column: "SearchTsv")
|
||||
.Annotation("Npgsql:IndexMethod", "gin");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DedupEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "QueueItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RejectedItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Cards_SearchTsv",
|
||||
table: "Cards");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SearchTsv",
|
||||
table: "Cards");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+548
@@ -0,0 +1,548 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
[DbContext(typeof(TenantDbContext))]
|
||||
[Migration("20260906200342_TenantProjects")]
|
||||
partial class TenantProjects
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.BoardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VisibleFieldsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Width")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Boards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Col")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConvCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("ConvFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("ConvTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancyKnown")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MatchHitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrevCol")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true);
|
||||
|
||||
b.Property<string>("SourceDialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceMsg")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long?>("SourceMsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("Col", "IsNew");
|
||||
|
||||
b.HasIndex("Col", "ReceivedAt")
|
||||
.IsDescending(false, true);
|
||||
|
||||
b.ToTable("Cards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FromCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ToCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CardMoves", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b =>
|
||||
{
|
||||
b.Property<string>("Hash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Hash");
|
||||
|
||||
b.ToTable("DedupEntries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("By")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.ToTable("LeadComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("MlOutbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ProjectCardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FilesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("HistoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LinksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Local")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReminderAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("ReminderFired")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TzText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LeadId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"LeadId\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("Stage");
|
||||
|
||||
b.HasIndex("UpdatedAt")
|
||||
.IsDescending();
|
||||
|
||||
b.ToTable("ProjectCards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Force")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("QueueItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kw")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("RejectedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReturnReason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Returned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReturnedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true);
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RejectedAt");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.ToTable("RejectedItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TenantProjects : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ProjectCards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Stage = table.Column<string>(type: "text", nullable: false),
|
||||
Local = table.Column<bool>(type: "boolean", nullable: false),
|
||||
LeadId = table.Column<string>(type: "text", nullable: true),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
Summary = table.Column<string>(type: "text", nullable: false),
|
||||
StackJson = table.Column<string>(type: "text", nullable: false),
|
||||
BudgetFrom = table.Column<double>(type: "double precision", nullable: true),
|
||||
BudgetTo = table.Column<double>(type: "double precision", nullable: true),
|
||||
BudgetCur = table.Column<string>(type: "text", nullable: false),
|
||||
Contact = table.Column<string>(type: "text", nullable: false),
|
||||
CommentsJson = table.Column<string>(type: "text", nullable: false),
|
||||
LinksJson = table.Column<string>(type: "text", nullable: false),
|
||||
FilesJson = table.Column<string>(type: "text", nullable: false),
|
||||
HistoryJson = table.Column<string>(type: "text", nullable: false),
|
||||
TzText = table.Column<string>(type: "text", nullable: false),
|
||||
ReminderAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
ReminderFired = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ProjectCards", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProjectCards_LeadId",
|
||||
table: "ProjectCards",
|
||||
column: "LeadId",
|
||||
unique: true,
|
||||
filter: "\"LeadId\" IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProjectCards_Stage",
|
||||
table: "ProjectCards",
|
||||
column: "Stage");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProjectCards_UpdatedAt",
|
||||
table: "ProjectCards",
|
||||
column: "UpdatedAt",
|
||||
descending: new bool[0]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ProjectCards");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+618
@@ -0,0 +1,618 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
[DbContext(typeof(TenantDbContext))]
|
||||
[Migration("20260907141242_TenantTelegram")]
|
||||
partial class TenantTelegram
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.BoardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VisibleFieldsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Width")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Boards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Col")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConvCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("ConvFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("ConvTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancyKnown")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MatchHitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrevCol")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true);
|
||||
|
||||
b.Property<string>("SourceDialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceMsg")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long?>("SourceMsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("Col", "IsNew");
|
||||
|
||||
b.HasIndex("Col", "ReceivedAt")
|
||||
.IsDescending(false, true);
|
||||
|
||||
b.ToTable("Cards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FromCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ToCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CardMoves", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b =>
|
||||
{
|
||||
b.Property<string>("Hash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Hash");
|
||||
|
||||
b.ToTable("DedupEntries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Backfilled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Handle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("#666");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Monitor")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Dialogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("By")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.ToTable("LeadComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("MlOutbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ProjectCardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FilesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("HistoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LinksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Local")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReminderAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("ReminderFired")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TzText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LeadId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"LeadId\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("Stage");
|
||||
|
||||
b.HasIndex("UpdatedAt")
|
||||
.IsDescending();
|
||||
|
||||
b.ToTable("ProjectCards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Force")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("QueueItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kw")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("RejectedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReturnReason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Returned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReturnedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true);
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RejectedAt");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.ToTable("RejectedItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DialogId", "MsgAt");
|
||||
|
||||
b.ToTable("TgMessages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TenantTelegram : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Dialogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Handle = table.Column<string>(type: "text", nullable: false),
|
||||
Kind = table.Column<string>(type: "text", nullable: false),
|
||||
Hue = table.Column<string>(type: "text", nullable: false, defaultValue: "#666"),
|
||||
Monitor = table.Column<bool>(type: "boolean", nullable: false),
|
||||
LastText = table.Column<string>(type: "text", nullable: false),
|
||||
LastAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
Backfilled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Dialogs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TgMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
DialogId = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
MsgAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
LeadId = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TgMessages", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TgMessages_DialogId_MsgAt",
|
||||
table: "TgMessages",
|
||||
columns: new[] { "DialogId", "MsgAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Dialogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TgMessages");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+800
@@ -0,0 +1,800 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
[DbContext(typeof(TenantDbContext))]
|
||||
[Migration("20260907155333_TenantDiscovery")]
|
||||
partial class TenantDiscovery
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.BoardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VisibleFieldsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Width")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Boards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Col")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConvCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("ConvFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("ConvTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancyKnown")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MatchHitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrevCol")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true);
|
||||
|
||||
b.Property<string>("SourceDialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceMsg")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long?>("SourceMsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("Col", "IsNew");
|
||||
|
||||
b.HasIndex("Col", "ReceivedAt")
|
||||
.IsDescending(false, true);
|
||||
|
||||
b.ToTable("Cards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FromCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ToCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CardMoves", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b =>
|
||||
{
|
||||
b.Property<string>("Hash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Hash");
|
||||
|
||||
b.ToTable("DedupEntries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Backfilled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Handle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("#666");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Monitor")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Dialogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.ToTable("DiscBlacklist", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoined")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double?>("FitRatio")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("JoinFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool?>("LangRu")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MarksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("Participants")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TopicsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.HasIndex("TaskId", "Status");
|
||||
|
||||
b.ToTable("DiscCandidates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscLogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Event")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId", "CreatedAt");
|
||||
|
||||
b.ToTable("DiscLog", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscTaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Evaluated")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Found")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Joined")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Lang")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("MinSubscribers")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("PlanJoins")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Rejected")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SampleSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("SearchDone")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("SearchIdx")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Threshold")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DiscTasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("By")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.ToTable("LeadComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("MlOutbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ProjectCardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FilesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("HistoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LinksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Local")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReminderAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("ReminderFired")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TzText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LeadId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"LeadId\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("Stage");
|
||||
|
||||
b.HasIndex("UpdatedAt")
|
||||
.IsDescending();
|
||||
|
||||
b.ToTable("ProjectCards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Force")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("QueueItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kw")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("RejectedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReturnReason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Returned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReturnedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true);
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RejectedAt");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.ToTable("RejectedItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DialogId", "MsgAt");
|
||||
|
||||
b.ToTable("TgMessages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TenantDiscovery : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DiscBlacklist",
|
||||
columns: table => new
|
||||
{
|
||||
DialogId = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Reason = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DiscBlacklist", x => x.DialogId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DiscCandidates",
|
||||
columns: table => new
|
||||
{
|
||||
DialogId = table.Column<string>(type: "text", nullable: false),
|
||||
TaskId = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Username = table.Column<string>(type: "text", nullable: false),
|
||||
Kind = table.Column<string>(type: "text", nullable: false),
|
||||
Hue = table.Column<string>(type: "text", nullable: false),
|
||||
Participants = table.Column<int>(type: "integer", nullable: true),
|
||||
LangRu = table.Column<bool>(type: "boolean", nullable: true),
|
||||
MarksJson = table.Column<string>(type: "text", nullable: false),
|
||||
TopicsJson = table.Column<string>(type: "text", nullable: false),
|
||||
FitRatio = table.Column<double>(type: "double precision", nullable: true),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
AutoJoined = table.Column<bool>(type: "boolean", nullable: false),
|
||||
JoinFailures = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DiscCandidates", x => x.DialogId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DiscLog",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
TaskId = table.Column<string>(type: "text", nullable: false),
|
||||
Event = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DiscLog", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DiscTasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: false),
|
||||
KeywordsJson = table.Column<string>(type: "text", nullable: false),
|
||||
MinSubscribers = table.Column<int>(type: "integer", nullable: false),
|
||||
Lang = table.Column<string>(type: "text", nullable: false),
|
||||
Threshold = table.Column<int>(type: "integer", nullable: false),
|
||||
SampleSize = table.Column<int>(type: "integer", nullable: false),
|
||||
PlanJoins = table.Column<int>(type: "integer", nullable: false),
|
||||
AutoJoin = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
SearchIdx = table.Column<int>(type: "integer", nullable: false),
|
||||
SearchDone = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Found = table.Column<int>(type: "integer", nullable: false),
|
||||
Evaluated = table.Column<int>(type: "integer", nullable: false),
|
||||
Joined = table.Column<int>(type: "integer", nullable: false),
|
||||
Rejected = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DiscTasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DiscCandidates_TaskId_Status",
|
||||
table: "DiscCandidates",
|
||||
columns: new[] { "TaskId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DiscLog_TaskId_CreatedAt",
|
||||
table: "DiscLog",
|
||||
columns: new[] { "TaskId", "CreatedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DiscBlacklist");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DiscCandidates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DiscLog");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DiscTasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+878
@@ -0,0 +1,878 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
[DbContext(typeof(TenantDbContext))]
|
||||
[Migration("20260909222322_TenantContainers")]
|
||||
partial class TenantContainers
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.BoardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VisibleFieldsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Width")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Boards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Col")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConvCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("ConvFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("ConvTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancyKnown")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MatchHitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrevCol")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true);
|
||||
|
||||
b.Property<string>("SourceDialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceMsg")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long?>("SourceMsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("Col", "IsNew");
|
||||
|
||||
b.HasIndex("Col", "ReceivedAt")
|
||||
.IsDescending(false, true);
|
||||
|
||||
b.ToTable("Cards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FromCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ToCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CardMoves", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PolicyJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true);
|
||||
|
||||
b.Property<string>("Space")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VisibleFieldsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("Space", "Position");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Containers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b =>
|
||||
{
|
||||
b.Property<string>("Hash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Hash");
|
||||
|
||||
b.ToTable("DedupEntries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Backfilled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Handle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("#666");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Monitor")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Dialogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.ToTable("DiscBlacklist", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoined")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double?>("FitRatio")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("JoinFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool?>("LangRu")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MarksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("Participants")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TopicsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.HasIndex("TaskId", "Status");
|
||||
|
||||
b.ToTable("DiscCandidates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscLogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Event")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId", "CreatedAt");
|
||||
|
||||
b.ToTable("DiscLog", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscTaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Evaluated")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Found")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Joined")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Lang")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("MinSubscribers")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("PlanJoins")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Rejected")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SampleSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("SearchDone")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("SearchIdx")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Threshold")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DiscTasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("By")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.ToTable("LeadComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("MlOutbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ProjectCardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FilesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("HistoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LinksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Local")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReminderAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("ReminderFired")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TzText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LeadId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"LeadId\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("Stage");
|
||||
|
||||
b.HasIndex("UpdatedAt")
|
||||
.IsDescending();
|
||||
|
||||
b.ToTable("ProjectCards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Force")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("QueueItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kw")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("RejectedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReturnReason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Returned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReturnedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true);
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RejectedAt");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.ToTable("RejectedItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DialogId", "MsgAt");
|
||||
|
||||
b.ToTable("TgMessages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TenantContainers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Containers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: false),
|
||||
Color = table.Column<string>(type: "text", nullable: false),
|
||||
Position = table.Column<int>(type: "integer", nullable: false),
|
||||
Kind = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Space = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
KeywordsJson = table.Column<string>(type: "text", nullable: false),
|
||||
VisibleFieldsJson = table.Column<string>(type: "text", nullable: false),
|
||||
Collapsed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Suggested = table.Column<bool>(type: "boolean", nullable: false),
|
||||
RulesJson = table.Column<string>(type: "text", nullable: false),
|
||||
Note = table.Column<string>(type: "text", nullable: false),
|
||||
PolicyJson = table.Column<string>(type: "text", nullable: false),
|
||||
SearchTsv = table.Column<NpgsqlTsVector>(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", stored: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Containers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Containers_SearchTsv",
|
||||
table: "Containers",
|
||||
column: "SearchTsv")
|
||||
.Annotation("Npgsql:IndexMethod", "gin");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Containers_Space_Position",
|
||||
table: "Containers",
|
||||
columns: new[] { "Space", "Position" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Containers_Suggested_Position",
|
||||
table: "Containers",
|
||||
columns: new[] { "Suggested", "Position" });
|
||||
|
||||
// Состав контейнеров по умолчанию НЕ сидируется миграцией: единственный источник —
|
||||
// CardsDefaultContainers/CardIds, строки идемпотентно создаёт DefaultContainerProvisioner.EnsureAsync
|
||||
// при провижининге тенанта (TenantProvisioningService) сразу после применения миграций.
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Containers");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+822
@@ -0,0 +1,822 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
[DbContext(typeof(TenantDbContext))]
|
||||
[Migration("20260910132805_TenantUnifiedCard")]
|
||||
partial class TenantUnifiedCard
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.BoardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VisibleFieldsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Width")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Boards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Col")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConvCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("ConvFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("ConvTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FilesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("HistoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancyKnown")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LinksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Local")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MatchHitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrevCol")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReminderAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("ReminderFired")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true);
|
||||
|
||||
b.Property<string>("SourceDialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceMsg")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long?>("SourceMsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TzText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("UpdatedAt")
|
||||
.IsDescending();
|
||||
|
||||
b.HasIndex("Col", "IsNew");
|
||||
|
||||
b.HasIndex("Col", "ReceivedAt")
|
||||
.IsDescending(false, true);
|
||||
|
||||
b.ToTable("Cards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FromCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ToCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CardMoves", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PolicyJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true);
|
||||
|
||||
b.Property<string>("Space")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VisibleFieldsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("Space", "Position");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Containers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b =>
|
||||
{
|
||||
b.Property<string>("Hash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Hash");
|
||||
|
||||
b.ToTable("DedupEntries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Backfilled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Handle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("#666");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Monitor")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Dialogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.ToTable("DiscBlacklist", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoined")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double?>("FitRatio")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("JoinFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool?>("LangRu")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MarksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("Participants")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TopicsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.HasIndex("TaskId", "Status");
|
||||
|
||||
b.ToTable("DiscCandidates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscLogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Event")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId", "CreatedAt");
|
||||
|
||||
b.ToTable("DiscLog", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscTaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Evaluated")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Found")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Joined")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Lang")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("MinSubscribers")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("PlanJoins")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Rejected")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SampleSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("SearchDone")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("SearchIdx")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Threshold")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DiscTasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("By")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.ToTable("LeadComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("MlOutbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Force")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("QueueItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kw")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("RejectedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReturnReason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Returned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReturnedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true);
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RejectedAt");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.ToTable("RejectedItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DialogId", "MsgAt");
|
||||
|
||||
b.ToTable("TgMessages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TenantUnifiedCard : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ProjectCards");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FilesJson",
|
||||
table: "Cards",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "HistoryJson",
|
||||
table: "Cards",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LinksJson",
|
||||
table: "Cards",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Local",
|
||||
table: "Cards",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "ReminderAt",
|
||||
table: "Cards",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ReminderFired",
|
||||
table: "Cards",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TzText",
|
||||
table: "Cards",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "UpdatedAt",
|
||||
table: "Cards",
|
||||
type: "timestamp with time zone",
|
||||
nullable: false,
|
||||
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Cards_UpdatedAt",
|
||||
table: "Cards",
|
||||
column: "UpdatedAt",
|
||||
descending: new bool[0]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Cards_UpdatedAt",
|
||||
table: "Cards");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FilesJson",
|
||||
table: "Cards");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HistoryJson",
|
||||
table: "Cards");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LinksJson",
|
||||
table: "Cards");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Local",
|
||||
table: "Cards");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReminderAt",
|
||||
table: "Cards");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReminderFired",
|
||||
table: "Cards");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TzText",
|
||||
table: "Cards");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UpdatedAt",
|
||||
table: "Cards");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ProjectCards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
BudgetCur = table.Column<string>(type: "text", nullable: false),
|
||||
BudgetFrom = table.Column<double>(type: "double precision", nullable: true),
|
||||
BudgetTo = table.Column<double>(type: "double precision", nullable: true),
|
||||
CommentsJson = table.Column<string>(type: "text", nullable: false),
|
||||
Contact = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
FilesJson = table.Column<string>(type: "text", nullable: false),
|
||||
HistoryJson = table.Column<string>(type: "text", nullable: false),
|
||||
LeadId = table.Column<string>(type: "text", nullable: true),
|
||||
LinksJson = table.Column<string>(type: "text", nullable: false),
|
||||
Local = table.Column<bool>(type: "boolean", nullable: false),
|
||||
ReminderAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
ReminderFired = table.Column<bool>(type: "boolean", nullable: false),
|
||||
StackJson = table.Column<string>(type: "text", nullable: false),
|
||||
Stage = table.Column<string>(type: "text", nullable: false),
|
||||
Summary = table.Column<string>(type: "text", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
TzText = table.Column<string>(type: "text", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ProjectCards", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProjectCards_LeadId",
|
||||
table: "ProjectCards",
|
||||
column: "LeadId",
|
||||
unique: true,
|
||||
filter: "\"LeadId\" IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProjectCards_Stage",
|
||||
table: "ProjectCards",
|
||||
column: "Stage");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProjectCards_UpdatedAt",
|
||||
table: "ProjectCards",
|
||||
column: "UpdatedAt",
|
||||
descending: new bool[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+762
@@ -0,0 +1,762 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
[DbContext(typeof(TenantDbContext))]
|
||||
[Migration("20260910134104_TenantContainerRegistry")]
|
||||
partial class TenantContainerRegistry
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Col")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConvCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("ConvFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("ConvTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FilesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("HistoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancyKnown")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LinksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Local")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MatchHitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrevCol")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReminderAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("ReminderFired")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true);
|
||||
|
||||
b.Property<string>("SourceDialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceMsg")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long?>("SourceMsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TzText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("UpdatedAt")
|
||||
.IsDescending();
|
||||
|
||||
b.HasIndex("Col", "IsNew");
|
||||
|
||||
b.HasIndex("Col", "ReceivedAt")
|
||||
.IsDescending(false, true);
|
||||
|
||||
b.ToTable("Cards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FromCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ToCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CardMoves", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PolicyJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true);
|
||||
|
||||
b.Property<string>("Space")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VisibleFieldsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("Space", "Position");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Containers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b =>
|
||||
{
|
||||
b.Property<string>("Hash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Hash");
|
||||
|
||||
b.ToTable("DedupEntries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Backfilled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Handle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("#666");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Monitor")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Dialogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.ToTable("DiscBlacklist", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoined")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double?>("FitRatio")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("JoinFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool?>("LangRu")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MarksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("Participants")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TopicsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.HasIndex("TaskId", "Status");
|
||||
|
||||
b.ToTable("DiscCandidates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscLogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Event")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId", "CreatedAt");
|
||||
|
||||
b.ToTable("DiscLog", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscTaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Evaluated")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Found")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Joined")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Lang")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("MinSubscribers")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("PlanJoins")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Rejected")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SampleSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("SearchDone")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("SearchIdx")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Threshold")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DiscTasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("By")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.ToTable("LeadComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("MlOutbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Force")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("QueueItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kw")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("RejectedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReturnReason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Returned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReturnedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true);
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RejectedAt");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.ToTable("RejectedItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DialogId", "MsgAt");
|
||||
|
||||
b.ToTable("TgMessages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TenantContainerRegistry : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Boards");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Boards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Collapsed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Color = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: false),
|
||||
KeywordsJson = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Note = table.Column<string>(type: "text", nullable: false),
|
||||
Position = table.Column<int>(type: "integer", nullable: false),
|
||||
Prompt = table.Column<string>(type: "text", nullable: false),
|
||||
RulesJson = table.Column<string>(type: "text", nullable: false),
|
||||
Suggested = table.Column<bool>(type: "boolean", nullable: false),
|
||||
VisibleFieldsJson = table.Column<string>(type: "text", nullable: false),
|
||||
Width = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Boards", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Boards_Suggested_Position",
|
||||
table: "Boards",
|
||||
columns: new[] { "Suggested", "Position" });
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+754
@@ -0,0 +1,754 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
[DbContext(typeof(TenantDbContext))]
|
||||
[Migration("20260910134358_TenantContainerCleanup")]
|
||||
partial class TenantContainerCleanup
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Col")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConvCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("ConvFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("ConvTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FilesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("HistoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancyKnown")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LinksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Local")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MatchHitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrevCol")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReminderAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("ReminderFired")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true);
|
||||
|
||||
b.Property<string>("SourceDialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceMsg")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long?>("SourceMsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TzText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("UpdatedAt")
|
||||
.IsDescending();
|
||||
|
||||
b.HasIndex("Col", "IsNew");
|
||||
|
||||
b.HasIndex("Col", "ReceivedAt")
|
||||
.IsDescending(false, true);
|
||||
|
||||
b.ToTable("Cards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FromCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ToCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CardMoves", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PolicyJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true);
|
||||
|
||||
b.Property<string>("Space")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("Space", "Position");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Containers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b =>
|
||||
{
|
||||
b.Property<string>("Hash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Hash");
|
||||
|
||||
b.ToTable("DedupEntries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Backfilled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Handle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("#666");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Monitor")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Dialogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.ToTable("DiscBlacklist", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoined")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double?>("FitRatio")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("JoinFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool?>("LangRu")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MarksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("Participants")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TopicsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.HasIndex("TaskId", "Status");
|
||||
|
||||
b.ToTable("DiscCandidates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscLogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Event")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId", "CreatedAt");
|
||||
|
||||
b.ToTable("DiscLog", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscTaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Evaluated")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Found")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Joined")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Lang")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("MinSubscribers")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("PlanJoins")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Rejected")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SampleSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("SearchDone")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("SearchIdx")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Threshold")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DiscTasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("By")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.ToTable("LeadComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("MlOutbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Force")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("QueueItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kw")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("RejectedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReturnReason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Returned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReturnedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true);
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RejectedAt");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.ToTable("RejectedItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DialogId", "MsgAt");
|
||||
|
||||
b.ToTable("TgMessages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TenantContainerCleanup : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "KeywordsJson",
|
||||
table: "Containers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VisibleFieldsJson",
|
||||
table: "Containers");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "KeywordsJson",
|
||||
table: "Containers",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "VisibleFieldsJson",
|
||||
table: "Containers",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Migrations.TenantDb
|
||||
{
|
||||
[DbContext(typeof(TenantDbContext))]
|
||||
partial class TenantDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Col")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConvCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("ConvFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("ConvTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FilesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("HistoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancyKnown")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LinksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Local")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MatchHitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrevCol")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReminderAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("ReminderFired")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true);
|
||||
|
||||
b.Property<string>("SourceDialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceMsg")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long?>("SourceMsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TzText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("UpdatedAt")
|
||||
.IsDescending();
|
||||
|
||||
b.HasIndex("Col", "IsNew");
|
||||
|
||||
b.HasIndex("Col", "ReceivedAt")
|
||||
.IsDescending(false, true);
|
||||
|
||||
b.ToTable("Cards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FromCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ToCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CardMoves", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PolicyJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true);
|
||||
|
||||
b.Property<string>("Space")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("Space", "Position");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Containers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b =>
|
||||
{
|
||||
b.Property<string>("Hash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Hash");
|
||||
|
||||
b.ToTable("DedupEntries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Backfilled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Handle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("#666");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Monitor")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Dialogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.ToTable("DiscBlacklist", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoined")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double?>("FitRatio")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("JoinFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool?>("LangRu")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MarksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("Participants")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TopicsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.HasIndex("TaskId", "Status");
|
||||
|
||||
b.ToTable("DiscCandidates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscLogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Event")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId", "CreatedAt");
|
||||
|
||||
b.ToTable("DiscLog", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscTaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Evaluated")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Found")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Joined")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Lang")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("MinSubscribers")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("PlanJoins")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Rejected")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SampleSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("SearchDone")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("SearchIdx")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Threshold")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DiscTasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("By")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.ToTable("LeadComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("MlOutbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Force")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("QueueItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelHue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChannelName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kw")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("MsgId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("RejectedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReturnReason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Returned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReturnedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true);
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RejectedAt");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.ToTable("RejectedItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DialogId", "MsgAt");
|
||||
|
||||
b.ToTable("TgMessages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Infrastructure.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Миграции схем тенантов. Чистые функции формирования SQL.
|
||||
/// </summary>
|
||||
public static class TenantSchemaMigrator
|
||||
{
|
||||
/// <summary>
|
||||
/// SQL создания схемы тенанта. Имя экранируется (не интерполируется из ввода).
|
||||
/// </summary>
|
||||
public static string CreateSchemaSql(string schemaName)
|
||||
{
|
||||
var escaped = schemaName.Replace("\"", "\"\"");
|
||||
return $"CREATE SCHEMA IF NOT EXISTS \"{escaped}\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Имена схем тенантов из БД.
|
||||
/// </summary>
|
||||
public static string ListTenantSchemasSql() =>
|
||||
"SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant\\_%' ESCAPE '\\'";
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация записи аудита: таблица audit_log в схеме public (append-only).
|
||||
/// </summary>
|
||||
public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLogEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AuditLogEntity> builder)
|
||||
{
|
||||
builder.ToTable("audit_log", "public");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.At).IsRequired();
|
||||
builder.Property(x => x.ActorType).IsRequired();
|
||||
builder.Property(x => x.EventType).IsRequired();
|
||||
builder.Property(x => x.DetailJson).HasColumnType("text");
|
||||
|
||||
builder.HasIndex(x => x.At);
|
||||
builder.HasIndex(x => new { x.TenantId, x.EventType });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация карточки канбана: таблица Cards (модель без схемы).
|
||||
/// </summary>
|
||||
public sealed class CardConfiguration : IEntityTypeConfiguration<CardEntity>
|
||||
{
|
||||
// Максимальная длина Col: служебные значения + id доски (короткие строки b_...).
|
||||
private const int ColMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<CardEntity> builder)
|
||||
{
|
||||
builder.ToTable("Cards");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Col).HasMaxLength(ColMaxLength);
|
||||
|
||||
// JSON-поля храним текстом с сериализованным JSON (как value_json настроек).
|
||||
builder.Property(x => x.StackJson).HasColumnType("text");
|
||||
builder.Property(x => x.ContactsJson).HasColumnType("text");
|
||||
builder.Property(x => x.MatchHitsJson).HasColumnType("text");
|
||||
builder.Property(x => x.LinksJson).HasColumnType("text");
|
||||
builder.Property(x => x.FilesJson).HasColumnType("text");
|
||||
builder.Property(x => x.HistoryJson).HasColumnType("text");
|
||||
builder.Property(x => x.SourceMsg).HasColumnType("text");
|
||||
|
||||
// Выборка колонки сортируется по времени получения (received_at DESC); счётчик новых — по (col, is_new).
|
||||
builder.HasIndex(x => new { x.Col, x.ReceivedAt }).IsDescending(false, true);
|
||||
builder.HasIndex(x => new { x.Col, x.IsNew });
|
||||
|
||||
// Сортировка пространства «Выбранные» — updated_at DESC.
|
||||
builder.HasIndex(x => x.UpdatedAt).IsDescending();
|
||||
|
||||
// Полнотекстовый вектор карточки (russian): Title+Summary+SourceMsg+Contact — вычисляемая STORED-
|
||||
// колонка (Ruling 6). Поиск /api/search идёт по SearchTsv @@ plainto_tsquery с LIKE-дополнением.
|
||||
builder.Property(x => x.SearchTsv)
|
||||
.HasComputedColumnSql(
|
||||
"to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))",
|
||||
stored: true);
|
||||
builder.HasIndex(x => x.SearchTsv).HasMethod("gin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация журнала действий над карточками: таблица CardMoves (модель без схемы).
|
||||
/// </summary>
|
||||
/// <remarks>Без внешних ключей: журнал живёт дольше карточки (прототип _hard_delete его не чистит).</remarks>
|
||||
public sealed class CardMoveConfiguration : IEntityTypeConfiguration<CardMoveEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CardMoveEntity> builder)
|
||||
{
|
||||
builder.ToTable("CardMoves");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация единого контейнера: таблица Containers (модель без схемы).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Аддитивный слой этапа 9: единый реестр контейнеров (колонки/стадии/зоны) вместо прежних board-строк.
|
||||
/// переключение хранилища/сервисов — следующими задачами (T3/T4). SearchTsv — полнотекстовый индекс
|
||||
/// для поиска контейнеров (title/description), как Cards.SearchTsv.
|
||||
/// </remarks>
|
||||
public sealed class ContainerConfiguration : IEntityTypeConfiguration<ContainerEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ContainerEntity> builder)
|
||||
{
|
||||
builder.ToTable("Containers");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Kind).HasMaxLength(20);
|
||||
builder.Property(x => x.Space).HasMaxLength(20);
|
||||
|
||||
// JSON-поля храним текстом с сериализованным JSON (конвенция value_json).
|
||||
builder.Property(x => x.RulesJson).HasColumnType("text");
|
||||
builder.Property(x => x.PolicyJson).HasColumnType("text");
|
||||
builder.Property(x => x.Note).HasColumnType("text");
|
||||
|
||||
// Выборка пространства сортируется по позиции; ИИ-предложения — отдельно (как Boards).
|
||||
builder.HasIndex(x => new { x.Space, x.Position });
|
||||
builder.HasIndex(x => new { x.Suggested, x.Position });
|
||||
|
||||
// Полнотекстовый поиск по контейнерам (title/description) — STORED-колонка, как Cards.SearchTsv.
|
||||
builder.Property(x => x.SearchTsv)
|
||||
.HasComputedColumnSql(
|
||||
"to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))",
|
||||
stored: true);
|
||||
builder.HasIndex(x => x.SearchTsv).HasMethod("gin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Системный DbContext: схема public (тенанты, пользователи, сессии, операторы, инвайты, лимиты, аудит).
|
||||
/// </summary>
|
||||
public sealed class DealDbContext(DbContextOptions<DealDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<TenantEntity> Tenants => Set<TenantEntity>();
|
||||
|
||||
public DbSet<UserEntity> Users => Set<UserEntity>();
|
||||
|
||||
public DbSet<SessionEntity> Sessions => Set<SessionEntity>();
|
||||
|
||||
public DbSet<OperatorEntity> Operators => Set<OperatorEntity>();
|
||||
|
||||
public DbSet<OperatorSessionEntity> OperatorSessions => Set<OperatorSessionEntity>();
|
||||
|
||||
public DbSet<InviteEntity> Invites => Set<InviteEntity>();
|
||||
|
||||
public DbSet<TenantLimitEntity> TenantLimits => Set<TenantLimitEntity>();
|
||||
|
||||
public DbSet<AuditLogEntity> AuditLog => Set<AuditLogEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// История расхода токенов (time-series аналитики, этап 10, T2).
|
||||
/// </summary>
|
||||
public DbSet<TokenUsageEventEntity> TokenUsageEvents => Set<TokenUsageEventEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Счётчики фиксированного окна (этап 12, пакет B): распределённый rate-limit и учёт
|
||||
/// попыток входа (public.rate_limit_counters).
|
||||
/// </summary>
|
||||
public DbSet<RateLimitCounterEntity> RateLimitCounters => Set<RateLimitCounterEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Глобальные (системные) настройки оператора: ключи Telegram и др. (public.global_settings).
|
||||
/// </summary>
|
||||
public DbSet<GlobalSettingEntity> GlobalSettings => Set<GlobalSettingEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfiguration(new TenantConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new UserConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SessionConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new OperatorConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new OperatorSessionConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new InviteConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new TenantLimitConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new AuditLogConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new TokenUsageEventConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new RateLimitCounterConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new GlobalSettingConfiguration());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Фабрика для dotnet-ef (миграции). Читает строку подключения из env.
|
||||
/// </summary>
|
||||
public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDbContext>
|
||||
{
|
||||
public DealDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
|
||||
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
|
||||
var options = new DbContextOptionsBuilder<DealDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
return new DealDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация дедуп-хэшей пайплайна: таблица DedupEntries (модель без схемы).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Без внешних ключей: LeadId — «мягкая» ссылка на Cards; при жёстком удалении карточки строки чистит
|
||||
/// приложение (Ruling 3), чтобы «сирота» не блокировала повторное создание карточки.
|
||||
/// </remarks>
|
||||
public sealed class DedupEntryConfiguration : IEntityTypeConfiguration<DedupEntryEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DedupEntryEntity> builder)
|
||||
{
|
||||
builder.ToTable("DedupEntries");
|
||||
|
||||
builder.HasKey(x => x.Hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация каталога диалогов: таблица Dialogs (модель без схемы; Ruling 7).
|
||||
/// </summary>
|
||||
/// <remarks>Индексов нет — каталог читается целиком (список вкладки ≤500 диалогов) и по PK.</remarks>
|
||||
public sealed class DialogConfiguration : IEntityTypeConfiguration<DialogEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DialogEntity> builder)
|
||||
{
|
||||
builder.ToTable("Dialogs");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
// Дефолт цвета каталога (1:1 db.py L81 — hue VARCHAR NOT NULL DEFAULT '#666').
|
||||
builder.Property(x => x.Hue).HasDefaultValue("#666");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация чёрного списка Discovery: таблица DiscBlacklist (модель без схемы; Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 1:1 db.py L180–185. Список читается целиком (вкладка) и по PK (проверки add_candidate/воркера) — индексов
|
||||
/// не требуется. Повторная вставка того же диалога — ON CONFLICT DO UPDATE name/reason (python L572–575):
|
||||
/// адаптер реализует upsert кодом (CreatedAt сохраняется).
|
||||
/// </remarks>
|
||||
public sealed class DiscBlacklistConfiguration : IEntityTypeConfiguration<DiscBlacklistEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DiscBlacklistEntity> builder)
|
||||
{
|
||||
builder.ToTable("DiscBlacklist");
|
||||
|
||||
builder.HasKey(x => x.DialogId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация кандидата Discovery: таблица DiscCandidates (модель без схемы; Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 1:1 db.py L159–177. Marks/Topics — JSON-массивы в text. Составной индекс (TaskId, Status) — python
|
||||
/// idx_disc_cand_task L177: выборка кандидатов задачи по статусу (списки воркера/вкладки). Без FK на DiscTasks:
|
||||
/// удаление задачи чистит кандидатов каскадом в коде сервиса (delete_task L314–318).
|
||||
/// </remarks>
|
||||
public sealed class DiscCandidateConfiguration : IEntityTypeConfiguration<DiscCandidateEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DiscCandidateEntity> builder)
|
||||
{
|
||||
builder.ToTable("DiscCandidates");
|
||||
|
||||
builder.HasKey(x => x.DialogId);
|
||||
|
||||
builder.Property(x => x.MarksJson).HasColumnType("text");
|
||||
builder.Property(x => x.TopicsJson).HasColumnType("text");
|
||||
|
||||
builder.HasIndex(x => new { x.TaskId, x.Status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация лога Discovery: таблица DiscLog (модель без схемы; Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 1:1 db.py L189–196. Составной индекс (TaskId, CreatedAt) — python idx_disc_log_task L196: последние события
|
||||
/// задачи (ORDER BY created_at DESC). Без FK на DiscTasks: лог чистится каскадом delete_task в коде сервиса.
|
||||
/// </remarks>
|
||||
public sealed class DiscLogConfiguration : IEntityTypeConfiguration<DiscLogEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DiscLogEntity> builder)
|
||||
{
|
||||
builder.ToTable("DiscLog");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.HasIndex(x => new { x.TaskId, x.CreatedAt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация задачи поиска Discovery: таблица DiscTasks (модель без схемы; Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 1:1 db.py L136–156. Keywords — JSON-массив в text (конвенция JSON-колонок этапов 1–5). Индексов нет —
|
||||
/// python idx для disc_tasks не создаёт: задачи читаются списком целиком (воркер/список вкладки) и по PK.
|
||||
/// </remarks>
|
||||
public sealed class DiscTaskConfiguration : IEntityTypeConfiguration<DiscTaskEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DiscTaskEntity> builder)
|
||||
{
|
||||
builder.ToTable("DiscTasks");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.KeywordsJson).HasColumnType("text");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Запись аудита (append-only) в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class AuditLogEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public DateTimeOffset At { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Тип актора: operator|tenant|system.
|
||||
/// </summary>
|
||||
public string ActorType { get; set; } = string.Empty;
|
||||
|
||||
public Guid? ActorId { get; set; }
|
||||
|
||||
public Guid? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Тип события — строковая константа каталога AuditEvents.
|
||||
/// </summary>
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
|
||||
public string? Ip { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Детали события в JSON (без секретов).
|
||||
/// </summary>
|
||||
public string? DetailJson { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Карточка канбана: таблица Cards в схеме тенанта. Соответствует таблице leads прототипа.
|
||||
/// </summary>
|
||||
public sealed class CardEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id карточки (префикс <c>c_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Колонка карточки: служебные <c>inbox|archive|trash|taken</c> либо id доски (<c>b_...</c>).
|
||||
/// Ссылочной целостности нет — существование доски валидирует приложение.
|
||||
/// </summary>
|
||||
public string Col { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Признак новой карточки (подсветка «новое» в колонке).
|
||||
/// </summary>
|
||||
public bool IsNew { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Признак «создано локально вручную» (карточка без внешнего первоисточника).
|
||||
/// </summary>
|
||||
public bool Local { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак «найм/разовое», проставленный эвристикой (маркерная гипотеза, не ИИ).
|
||||
/// </summary>
|
||||
public bool IsVacancy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True, когда тип «найм/разовое» подтверждён ИИ по контексту сообщения.
|
||||
/// </summary>
|
||||
public bool IsVacancyKnown { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Заголовок карточки (очищенный, до 140 символов — режет сервис).
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Краткое содержание карточки (очищенное, до 2000 символов — режет сервис).
|
||||
/// </summary>
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Стек/направления, сериализованные в JSON (text).
|
||||
/// </summary>
|
||||
public string StackJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Нижняя граница бюджета (валюта — BudgetCur), либо null.
|
||||
/// </summary>
|
||||
public double? BudgetFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница бюджета (валюта — BudgetCur), либо null.
|
||||
/// </summary>
|
||||
public double? BudgetTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Валюта бюджета (код или символ из исходного сообщения); пусто — бюджет не задан.
|
||||
/// </summary>
|
||||
public string BudgetCur { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Сконвертированная нижняя граница бюджета в целевую валюту, либо null.
|
||||
/// </summary>
|
||||
public double? ConvFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сконвертированная верхняя граница бюджета в целевую валюту, либо null.
|
||||
/// </summary>
|
||||
public double? ConvTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Валюта сконвертированного бюджета (целевая валюта тенанта); пусто — конверсия не сделана.
|
||||
/// </summary>
|
||||
public string ConvCur { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Контактная строка «как в сообщении» (fallback, если ContactsJson пуст).
|
||||
/// </summary>
|
||||
public string Contact { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Квалифицированные контакты, сериализованные в JSON (text).
|
||||
/// </summary>
|
||||
public string ContactsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Имя канала/диалога-источника.
|
||||
/// </summary>
|
||||
public string ChannelName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Handle канала-источника.
|
||||
/// </summary>
|
||||
public string ChannelHandle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Цвет канала-источника (hex).
|
||||
/// </summary>
|
||||
public string ChannelHue { get; set; } = "#666";
|
||||
|
||||
/// <summary>
|
||||
/// Время получения исходного сообщения (сортировка карточек, автоархив).
|
||||
/// </summary>
|
||||
public DateTimeOffset ReceivedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Текст исходного сообщения (для переобучения ML и поиска).
|
||||
/// </summary>
|
||||
public string SourceMsg { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id диалога исходного сообщения (для «открыть исходник»).
|
||||
/// </summary>
|
||||
public string SourceDialogId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id исходного сообщения в Telegram, либо null.
|
||||
/// </summary>
|
||||
public long? SourceMsgId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Предыдущая колонка (для возврата из архива/корзины).
|
||||
/// </summary>
|
||||
public string PrevCol { get; set; } = "inbox";
|
||||
|
||||
/// <summary>
|
||||
/// Время помещения в архив (для правила «архив очищается через N дней»), либо null.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ArchivedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Совпавшие критерии правил при попадании в колонку, сериализованные в JSON (text).
|
||||
/// </summary>
|
||||
public string MatchHitsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Ссылки карточки, сериализованные в JSON (text; элементы {id,name,url}).
|
||||
/// </summary>
|
||||
public string LinksJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Файлы карточки, сериализованные в JSON (text; элементы {id,name,size,kind,label,objectKey}).
|
||||
/// </summary>
|
||||
public string FilesJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// История движения карточки, сериализованная в JSON (text; элементы {id,at,type|stage}).
|
||||
/// </summary>
|
||||
public string HistoryJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Текст технического задания по карточке (заметка-задание).
|
||||
/// </summary>
|
||||
public string TzText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время напоминания об отложенной карточке, либо null (напоминание не задано/сброшено).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReminderAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак «напоминание уже выстрелило» (повторно не срабатывает до переноса/переустановки).
|
||||
/// </summary>
|
||||
public bool ReminderFired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Полнотекстовый вектор (tsvector, конфигурация russian) для поиска карточек — вычисляемая STORED-колонка БД.
|
||||
/// </summary>
|
||||
public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время создания карточки.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время последнего изменения карточки (сортировка пространства «Выбранные» — UpdatedAt DESC).
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Запись журнала действий над карточкой: таблица CardMoves в схеме тенанта. Соответствует таблице learning_log прототипа.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Журнал живёт дольше карточки (прототип <c>_hard_delete</c> его не чистит), поэтому ссылки на
|
||||
/// карточку внешним ключом не связаны — только значение LeadId.
|
||||
/// </remarks>
|
||||
public sealed class CardMoveEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id записи журнала (префикс <c>lm_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id карточки (Cards.Id), над которой выполнено действие. Без FK — журнал хранится и после удаления карточки.
|
||||
/// </summary>
|
||||
public string LeadId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Действие: <c>move|trash|restore|comment</c> (счётчик learning = число записей).
|
||||
/// </summary>
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Колонка-источник переноса, либо null (комментарий).
|
||||
/// </summary>
|
||||
public string? FromCol { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Колонка-назначение переноса, либо null (комментарий).
|
||||
/// </summary>
|
||||
public string? ToCol { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Единый контейнер карточек: колонка дашборда, стадия «Выбранных» или служебная зона (таблица Containers).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Приходит на смену разрозненным сущностям: Boards (колонки дашборда) + предзаданный каталог стадий
|
||||
/// (planned…rejected, жил константой модуля) + строковые служебные зоны (inbox/archive/trash — жили
|
||||
/// значениями Cards.Col). Одна таблица: kind (board|stage|service|terminal), space (dashboard|selected),
|
||||
/// правила фильтрации (RulesJson), политика (PolicyJson: CanRestore/IsTerminal/RetentionDays).
|
||||
/// Служебные и стадии провижининг сидирует из реестров модуля Cards (CardsDefaultContainers/CardIds);
|
||||
/// доски создаёт пользователь/ИИ (kind=board, как Boards раньше).
|
||||
/// </remarks>
|
||||
public sealed class ContainerEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id контейнера (доски <c>b_…</c>, стадии <c>planned…</c>, служебные inbox/archive/trash).
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Имя для отображения («WPF», «В работе», «Архив»).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Описание контейнера (для пользователя и подсказки ИИ/ML).
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Цвет (hex).
|
||||
/// </summary>
|
||||
public string Color { get; set; } = "#818cf8";
|
||||
|
||||
/// <summary>
|
||||
/// Позиция в пространстве (ORDER BY space, position).
|
||||
/// </summary>
|
||||
public int Position { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Вид контейнера: board (колонка-фильтр) | stage (стадия) | service (inbox/archive/trash) | terminal (finished/rejected).
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = "board";
|
||||
|
||||
/// <summary>
|
||||
/// Пространство: dashboard | selected (вид дашборда, к которому принадлежит контейнер).
|
||||
/// </summary>
|
||||
public string Space { get; set; } = "dashboard";
|
||||
|
||||
/// <summary>
|
||||
/// Свёрнутость колонки на дашборде (состояние UI).
|
||||
/// </summary>
|
||||
public bool Collapsed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак ИИ-предложения: контейнер ждёт решения пользователя.
|
||||
/// </summary>
|
||||
public bool Suggested { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Правила маршрутизации (IContainerRules), сериализованные в JSON (text).
|
||||
/// </summary>
|
||||
public string RulesJson { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Заметка контейнера (например, сгенерированное описание правил / обоснование ИИ).
|
||||
/// </summary>
|
||||
public string Note { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Политика контейнера (CanRestore/IsTerminal/RetentionDays), сериализованная в JSON (text).
|
||||
/// </summary>
|
||||
public string PolicyJson { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Полнотекстовый вектор поиска по контейнерам (title/description) — вычисляемая STORED-колонка.
|
||||
/// </summary>
|
||||
public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Дедуп-хэш текста сообщения: таблица DedupEntries в схеме тенанта. Соответствует таблице dedup прототипа.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Защита от повторного заведения карточки (одинаковый текст дважды). LeadId — «мягкая» ссылка на Cards без FK:
|
||||
/// чистку строки при жёстком удалении карточки выполняет приложение (Ruling 3 этапа 4).
|
||||
/// </remarks>
|
||||
public sealed class DedupEntryEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Хэш нормализованного текста (SHA1 hex, без префикса), первичный ключ.
|
||||
/// </summary>
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id созданной карточки (<c>l_...</c>), либо null — хэш занят в обработке (claim).
|
||||
/// </summary>
|
||||
public string? LeadId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время записи/занятия хэша.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Диалог/канал каталога тенанта: таблица Dialogs в схеме тенанта (Ruling 7, db.py L76–86).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Зеркало каталога диалогов аккаунта Telegram: владелец — модуль Deal.Modules.Telegram (Task 13). Kind хранит
|
||||
/// EN-канон контракта (channel|group|forum|chat) — 1:1 с entries SyncDialogs/refresh (proto DialogEntry).
|
||||
/// Без FK — каталог независим от сообщений/карточек.
|
||||
/// </remarks>
|
||||
public sealed class DialogEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Отображаемое имя диалога (title/first_name).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Username (handle) источника; пуст, если нет публичного username.
|
||||
/// </summary>
|
||||
public string Handle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Тип источника: channel|group|forum|chat (EN-канон telegram.proto).
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»); дефолт «#666» (db.py L81).
|
||||
/// </summary>
|
||||
public string Hue { get; set; } = "#666";
|
||||
|
||||
/// <summary>
|
||||
/// Признак мониторинга: сообщения диалога → PushMessage в очередь пайплайна (db.py L82).
|
||||
/// </summary>
|
||||
public bool Monitor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Текст последнего принятого сообщения (обрезается до 200, python L272).
|
||||
/// </summary>
|
||||
public string LastText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Момент последнего принятого сообщения (UTC); null — сообщений ещё не было.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак «канал разобран» (первый backfill завершён; python ALTER backfilled, L284).
|
||||
/// </summary>
|
||||
public bool Backfilled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент последнего изменения строки (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Чёрный список Discovery: таблица DiscBlacklist в схеме тенанта (db.py L180–185, Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Владелец — модуль Deal.Modules.Discovery (Task 17). Список общий для всех задач: источники из него
|
||||
/// пропускаются поиском (add_candidate) и повторной проверкой перед авто-вступлением (воркер). Снимается
|
||||
/// вручную или при ручном join. Повторное добавление обновляет Name/Reason и сохраняет CreatedAt
|
||||
/// (ON CONFLICT DO UPDATE — python L572–575).
|
||||
/// </remarks>
|
||||
public sealed class DiscBlacklistEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Подписанный id источника, первичный ключ.
|
||||
/// </summary>
|
||||
public string DialogId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Имя источника (пусто → DialogId).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Причина добавления («отклонено вручную», метка воркера).
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Момент первого добавления (UTC; при перезаписи сохраняется).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Кандидат задачи Discovery: таблица DiscCandidates в схеме тенанта (db.py L159–176, Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Владелец — модуль Deal.Modules.Discovery (Task 17). Первичный ключ — DialogId (источник может быть кандидатом
|
||||
/// только одной задачи/одного статуса — python). Marks/Topics — JSON-колонки (text): marks — строки-метки оценки,
|
||||
/// topics — элементы {topicId,title,fitCount,total,fitRatio,passed} для форумов. Status: new|review|joined|rejected.
|
||||
/// Без FK — DiscTasks/Dialogs удаляются/живут независимо (каталог кандидата может пережить задачу до delete_task).
|
||||
/// </remarks>
|
||||
public sealed class DiscCandidateEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Подписанный id источника (каналы «-100…», группы «-…»), первичный ключ.
|
||||
/// </summary>
|
||||
public string DialogId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id задачи поиска, которой принадлежит кандидат.
|
||||
/// </summary>
|
||||
public string TaskId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Отображаемое имя источника (пусто → DialogId).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Username (handle) источника; пуст, если нет публичного username.
|
||||
/// </summary>
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Тип источника: channel|group|forum.
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = "channel";
|
||||
|
||||
/// <summary>
|
||||
/// Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»); дефолт «#666».
|
||||
/// </summary>
|
||||
public string Hue { get; set; } = "#666";
|
||||
|
||||
/// <summary>
|
||||
/// Число участников источника; null — неизвестно (до discovery_info).
|
||||
/// </summary>
|
||||
public int? Participants { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Язык источника: true — русский, false — не русский; null — не определён.
|
||||
/// </summary>
|
||||
public bool? LangRu { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Метки оценки, сериализованные в JSON (text; дефолт «[]»).
|
||||
/// </summary>
|
||||
public string MarksJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Оценка тем форума, сериализованная в JSON (text; дефолт «[]»).
|
||||
/// </summary>
|
||||
public string TopicsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Доля подходящих сообщений оценки (0..1); null — контент не оценён.
|
||||
/// </summary>
|
||||
public double? FitRatio { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Статус кандидата: new|review|joined|rejected.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "new";
|
||||
|
||||
/// <summary>
|
||||
/// Вступили автоматически (воркером); false — вручную.
|
||||
/// </summary>
|
||||
public bool AutoJoined { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Неудачные авто-вступления подряд (3 → кандидат удаляется, Task 18).
|
||||
/// </summary>
|
||||
public int JoinFailures { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент добавления кандидата (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент последнего изменения (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Лог событий задачи Discovery: таблица DiscLog в схеме тенанта (db.py L189–195, Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Владелец — модуль Deal.Modules.Discovery (Task 17). Event — каталог модуля: search|skip|review|join_auto|
|
||||
/// join_manual|leave|reject|flood|error|done. Чтение — последние события задачи (ORDER BY created_at DESC),
|
||||
/// поэтому создан индекс (TaskId, CreatedAt) — python idx_disc_log_task L196.
|
||||
/// </remarks>
|
||||
public sealed class DiscLogEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id записи (префикс <c>dl_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id задачи поиска.
|
||||
/// </summary>
|
||||
public string TaskId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Событие (search|skip|review|join_auto|join_manual|leave|reject|flood|error|done).
|
||||
/// </summary>
|
||||
public string Event { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст/детали события (русская строка 1:1 с прототипом).
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Момент события (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Задача поиска Discovery: таблица DiscTasks в схеме тенанта (db.py L136–156, Ruling 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Владелец — модуль Deal.Modules.Discovery (Task 17). Keywords хранит JSON-массив (text); search_*/счётчики —
|
||||
/// живой прогресс по задаче (воркер Task 18). Status: draft|running|paused|done|failed (каталог модуля).
|
||||
/// </remarks>
|
||||
public sealed class DiscTaskEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id задачи (префикс <c>dt_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Название задачи (обязательное, Trim).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Описание ниши/цели (источник для ИИ-генерации ключей).
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Ключевые слова поиска, сериализованные в JSON (text; дефолт «[]»).
|
||||
/// </summary>
|
||||
public string KeywordsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Минимальное число участников источника (0 — не фильтровать).
|
||||
/// </summary>
|
||||
public int MinSubscribers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Язык источников: ru|any.
|
||||
/// </summary>
|
||||
public string Lang { get; set; } = "ru";
|
||||
|
||||
/// <summary>
|
||||
/// Порог подходящих сообщений оценки, % (1..100; дефолт 40 — discEvalThreshold).
|
||||
/// </summary>
|
||||
public int Threshold { get; set; } = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Размер выборки сообщений при оценке (дефолт 10 — discEvalSample).
|
||||
/// </summary>
|
||||
public int SampleSize { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// План авто-вступлений (1..discJoinLimit; занимает суточный бюджет).
|
||||
/// </summary>
|
||||
public int PlanJoins { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Авто-вступления воркером включены.
|
||||
/// </summary>
|
||||
public bool AutoJoin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Статус задачи: draft|running|paused|done|failed.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "draft";
|
||||
|
||||
/// <summary>
|
||||
/// Индекс текущего ключа поиска (прогресс прохода по keywords).
|
||||
/// </summary>
|
||||
public int SearchIdx { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Проход по всем ключам завершён.
|
||||
/// </summary>
|
||||
public bool SearchDone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Найдено кандидатов поиском.
|
||||
/// </summary>
|
||||
public int Found { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Оценено/пропущено кандидатов.
|
||||
/// </summary>
|
||||
public int Evaluated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Вступили (joined ≥ plan_joins → задача done).
|
||||
/// </summary>
|
||||
public int Joined { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Отклонено кандидатов.
|
||||
/// </summary>
|
||||
public int Rejected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент создания задачи (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент последнего изменения (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Глобальная (системная) настройка оператора: таблица public.global_settings.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единое KV-хранилище всего SaaS-контура (ТЗ §4.1/§8.1): значения задаёт оператор, видят все
|
||||
/// тенанты. Секреты хранятся зашифрованными (префикс <c>enc:</c>), формат значения определяет ключ
|
||||
/// (<see cref="Deal.Modules.Settings.Application.GlobalSettingsKeys"/>).
|
||||
/// </remarks>
|
||||
public sealed class GlobalSettingEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Ключ глобальной настройки (PK).
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Значение настройки, сериализованное в JSON.
|
||||
/// </summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время последнего изменения (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Приглашение на регистрацию (invite) в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class InviteEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Одноразовый код приглашения (url-safe, 16 симв.) — первичный ключ.
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Email приглашённого, нормализованный (нижний регистр); уникален среди активных.
|
||||
/// </summary>
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Целевой тенант; null — при активации создаётся новый тенант.
|
||||
/// </summary>
|
||||
public Guid? TenantId { get; set; }
|
||||
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент активации (null, пока инвайт не использован).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Оператор, создавший приглашение.
|
||||
/// </summary>
|
||||
public Guid CreatedById { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Комментарий карточки: таблица LeadComments в схеме тенанта. Нормализация массива comments строки leads прототипа.
|
||||
/// </summary>
|
||||
public sealed class LeadCommentEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id комментария (префикс <c>cm_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id карточки (Cards.Id), внешний ключ с каскадным удалением.
|
||||
/// </summary>
|
||||
public string CardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Автор комментария (в прототипе — «Вы»), отдаётся как <c>by</c>.
|
||||
/// </summary>
|
||||
public string By { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст комментария.
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время добавления комментария (человеческую метку <c>time</c> считает маппинг).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Строка очереди обучающих сигналов ML: таблица MlOutbox в схеме тенанта. Соответствует таблице ml_outbox прототипа.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Действия пользователя всегда пишутся сюда синхронно; фоновый воркер отправляет строки в ML-сервис
|
||||
/// (этап 3 — только накопление; отправка — этап 6). Без FK — очередь не зависит от карточек.
|
||||
/// </remarks>
|
||||
public sealed class MlOutboxEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id записи outbox (префикс <c>mle_</c>), первичный ключ.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст обучающего примера (обрезается до 6000 символов при записи).
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Метка обучения: id доски (<c>b_...</c>), <c>spam</c> либо <c>t:hire|t:order</c>.
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Весовой коэффициент сигнала (1.0 — учить, −1.0 — снять метку).
|
||||
/// </summary>
|
||||
public double Delta { get; set; } = 1.0;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user