Перевести ядро на единый контракт источника

Карточка, очередь и отсев работают с SourceItem (SourceRef + SourceContent); Telegram-поля убраны из домена, персистентности, конвейера и wire, остались только в адаптере приёма. Tenant-миграции пересозданы с нуля. Фронт переведён на generic source/content с просмотрщиком вложений.
This commit is contained in:
Rustam Khalimov
2026-09-11 14:58:54 +03:00
parent 770dba7257
commit ebc761d40e
94 changed files with 4150 additions and 9398 deletions
+4
View File
@@ -57,6 +57,10 @@
| TD-DBL-CLICK | Двойная перезагрузка доски у инициатора batch-reclassify (ответ + SSE) | этап 12, E | P3 | TECHDEBT | | TD-DBL-CLICK | Двойная перезагрузка доски у инициатора batch-reclassify (ответ + SSE) | этап 12, E | P3 | TECHDEBT |
| TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT | | TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT |
| TD-OLD-DOCS | Исторические доки несут старые термины под пометками (переписывать не нужно) | docs sweep | P3 | TECHDEBT | | TD-OLD-DOCS | Исторические доки несут старые термины под пометками (переписывать не нужно) | docs sweep | P3 | TECHDEBT |
| TD-SOURCE-PROVIDER | `GET /api/cards/{id}/source` + `ISourceContentProvider` + реестр провайдеров; ленивая догрузка `SourceContent` (сейчас контент отдаётся прямо в `CardDto`) | generic source 2026-09-11 | P1 | BACKLOG |
| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером + `TelegramSourceContentProvider` (только telegram-сервис) + рендер `DataRef` в UI | generic source 2026-09-11 | P1 | BACKLOG |
| TD-TG-CORE-SPLIT | Перенести оставшуюся Telegram-специфику ядра (`TelegramStore`, таблицы `Dialogs`/`TgMessages`, Discovery, ingress-proto) в telegram-сервис; generic ingress-контракт источников | generic source 2026-09-11 | P2 | TECHDEBT |
| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG |
| TD-APIMAP-COUNT | Ручной подсчёт числа операторских ручек в `api-map` (может расходиться с группировкой) | docs sweep | P3 | TECHDEBT | | TD-APIMAP-COUNT | Ручной подсчёт числа операторских ручек в `api-map` (может расходиться с группировкой) | docs sweep | P3 | TECHDEBT |
## 6. Manual-проверки (нужны внешние условия) ## 6. Manual-проверки (нужны внешние условия)
+8 -7
View File
@@ -232,16 +232,13 @@
"local": false, // создана локально, без внешнего источника "local": false, // создана локально, без внешнего источника
"title": "Разработка интернет-магазина", // string ≤140 "title": "Разработка интернет-магазина", // string ≤140
"summary": "Компания: …\nЗадача: …",// блок «О заявке» "summary": "Компания: …\nЗадача: …",// блок «О заявке»
"source": { "kind": "telegram", "displayName": "Канал заказов", "originRef": "123456789", "receivedAt": 1726000000000 }, "source": { "kind": "telegram", "externalId": "4242", "displayName": "Канал заказов", "originRef": "123456789", "author": "…", "receivedAt": "2026-09-11T10:00:00+00:00", "extra": {"hue": "#8b8ff8"} },
"sourceMsg": "Ищу разработчика…", // исходное сообщение "content": { "text": "Ищу разработчика…", "html": null, "author": "…", "subject": null, "data": [], "links": [], "contacts": [] },
"sourceDialogId": "123456789",
"sourceMsgId": 4242,
"stack": ["vue", "dotnet"], "stack": ["vue", "dotnet"],
"budget": {"from": 100000, "to": 200000, "cur": "RUB"}, "budget": {"from": 100000, "to": 200000, "cur": "RUB"},
"converted": {"from": 100000, "to": 200000, "cur": "RUB"}, "converted": {"from": 100000, "to": 200000, "cur": "RUB"},
"contact": "@client", "contact": "@client",
"contacts": [{"type": "tg", "value": "@client"}], "contacts": [{"type": "tg", "value": "@client"}],
"channel": {"name": "Канал заказов", "handle": "@orders", "hue": "#8b8ff8"}, // прежний ch
"matchHits": [{"label": "Стек", "term": "vue", "word": null}], "matchHits": [{"label": "Стек", "term": "vue", "word": null}],
"comments": [{"id": "cm_…", "by": "Вы", "text": "Позвонил", "time": "5 мин"}], "comments": [{"id": "cm_…", "by": "Вы", "text": "Позвонил", "time": "5 мин"}],
"links": [{"id": "pl_…", "name": "Бриф", "url": "https://example.com"}], "links": [{"id": "pl_…", "name": "Бриф", "url": "https://example.com"}],
@@ -257,11 +254,15 @@
} }
``` ```
Ключевые поля: `id/containerId/(col)` — принадлежность; `source/sourceMsg/sourceDialogId/sourceMsgId` Ключевые поля: `id/containerId/(col)` — принадлежность; `source` (`SourceRef`: вид, внешний id, подпись,
происхождение; `stack/budget/converted/contact/contacts/channel/matchHits` — данные заявки; `comments/ ссылка на оригинал, цвет в `extra.hue`) и `content` (`SourceContent`: текст, разметка, ссылки, контакты,
вложения `data`) — происхождение; `stack/budget/converted/contact/contacts/matchHits` — данные заявки; `comments/
links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/isVacancyKnown` — маркеры. Ровно links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/isVacancyKnown` — маркеры. Ровно
один из полей `history[].type`/`history[].stage` задан. один из полей `history[].type`/`history[].stage` задан.
Строки очереди и отсева «Обработки» отдают тот же generic `source`/`content` (раньше — `ch`); решение
отсева — `decidedBy`/`decidedByLabel` (stop|ml|ai|stale|dup).
**counts** (`GET /api/cards/counts`): `{new: int, "<containerId>": {count: int, new: int}, learning: int, ml: int, ai: int}` — плоская форма (совместима с прежним `/api/leads/counts`). **counts** (`GET /api/cards/counts`): `{new: int, "<containerId>": {count: int, new: int}, learning: int, ml: int, ai: int}` — плоская форма (совместима с прежним `/api/leads/counts`).
### 4.2 Контейнер (container) — `GET /api/containers`, `POST/PATCH` тела ### 4.2 Контейнер (container) — `GET /api/containers`, `POST/PATCH` тела
+14 -2
View File
@@ -1,7 +1,19 @@
# Дейл (Deal) — Статус разработки и прогресс # Дейл (Deal) — Статус разработки и прогресс
> Обновляется в конце каждого захода. Проект НЕ git — это главный борд состояния. > Обновляется в конце каждого захода. Проект в git (ветка `main`, коммиты на русском) — борд состояния + git-история.
> Дата последнего обновления: 2026-09-10. **Все этапы 0–12 выполнены (100%)** — см. roadmap > Дата последнего обновления: 2026-09-11.
>
> **2026-09-11 — единый контракт источника (generic source).** Ядро (домен Cards, Storage-сервис,
> персистентность, конвейер, wire, фронт) переведено с Telegram-полей карточки на generic-тип
> `SourceItem` (`SourceRef` + `SourceContent`, вложения — `DataRef` → общий Storage). Дашборды/канбан/конвейер
> больше не знают о Telegram; Telegram-специфика — только в тонком адаптере приёма. Tenant-миграции
> пересозданы с нуля (init). Ядро: build 4 sln 0/0, `Deal.Tests.Unit` **1277/1277 PASS**, фронт `build` +
> `lint:i18n` зелёные. Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
> (`TelegramStore`, `Dialogs`/`TgMessages`, Discovery) в telegram-сервис.
**Все этапы 0–12 выполнены (100%)** — см. roadmap
> `docs/superpowers/plans/2026-09-05-deal-roadmap.md`, план этапа 10 > `docs/superpowers/plans/2026-09-05-deal-roadmap.md`, план этапа 10
> `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md` и ledgers в `.superpowers/sdd/`. > `docs/superpowers/plans/2026-09-10-deal-stage10-operator-analytics.md` и ledgers в `.superpowers/sdd/`.
> Live-приёмки на Docker Desktop выполнены: dev-smoke 14/14, SaaS-контур 15/15, prod-контур+mTLS+ > Live-приёмки на Docker Desktop выполнены: dev-smoke 14/14, SaaS-контур 15/15, prod-контур+mTLS+
@@ -1,6 +1,6 @@
# Дизайн: единый контракт источника + общий Storage-сервис данных # Дизайн: единый контракт источника + общий Storage-сервис данных
Дата: 2026-09-11. Статус: на согласовании (контракт не плодит типы вложений; файлы — в общем Storage). Дата: 2026-09-11. Статус: реализовано в ядре (домен, Storage-сервис, персистентность, конвейер, wire, фронт); адаптер/провайдер telegram-сервиса и Storage-выгрузка — следующие шаги. Контракт не плодит типы вложений; файлы — в общем Storage.
## 1. Принцип ## 1. Принцип
@@ -127,3 +127,45 @@ API ядра: `GET /api/cards/{id}/source` → generic контент.
6. Frontend: generic источник + универсальный просмотрщик. 6. Frontend: generic источник + универсальный просмотрщик.
7. Telegram: адаптер + провайдер исходника (только в telegram-сервисе) + выгрузка в Storage. 7. Telegram: адаптер + провайдер исходника (только в telegram-сервисе) + выгрузка в Storage.
8. Комментарии: убрать упоминания Telegram из ядра и задачи/этапы — везде. 8. Комментарии: убрать упоминания Telegram из ядра и задачи/этапы — везде.
## 9. Реализация: зафиксированные сигнатуры
### Ядро: домен
- `SourceRefs` (Deal.Modules.Cards/Application/Sources): `Empty`, `DefaultHue = "#666"`,
`HueKey = "hue"`, расширения `DedupeKey()` (вид|оригинал|внешний id), `ResolveHue()`.
- `CardSnapshot`: вместо `ChannelName/ChannelHandle/ChannelHue/SourceMsg/SourceDialogId/SourceMsgId`
`SourceRef Source` + `SourceContent Content`; `ReceivedAt` остаётся.
- `CardDto`: вместо `Channel`/`SourceMsg`/`SourceDialogId`/`SourceMsgId`/прежнего `Source`
`SourceRef Source` + `SourceContent Content`; `ReceivedAtMs` остаётся. `CardChannelDto`/`CardSourceDto` удалены.
- `ICardStore.GetCardBySourceAsync(SourceRef source, CancellationToken ct)`.
### Ядро: конвейер
- `QueuedMessage { required SourceItem Item; bool Force; }`.
- `QueueItemDto { string Id; SourceRef Source; SourceContent Content; string Text; string Status;
long MsgAtMs; long QueuedAtMs; bool Force; }` (JsonIgnore на Force).
- `RejectRecord { SourceRef Source; SourceContent Content; string Text; long MsgAtMs;
string DecidedBy; string Stage; string Reason; string Kw; string? DeterministicId; }`
(`DeterministicId = r_{Kind}_{OriginRef}_{ExternalId}`).
- `RejectedItemDto`: `Source`/`Content`, `DecidedBy`/`DecidedByLabel` (решение), остальное как было.
- `IPipelineStore.ExistsDuplicateAsync(SourceRef source, CancellationToken ct)`.
- `PipelineChannelDto` удалён.
### Схема БД (схема тенанта)
- Cards: удалить `ChannelName/ChannelHandle/ChannelHue/SourceMsg/SourceDialogId/SourceMsgId`;
добавить `SourceKind`, `SourceExternalId`, `SourceOriginRef` (text, для запросов), `SourceJson` (text),
`ContentJson` (text), `SourceText` (text). FTS: Title+Summary+SourceText+Contact.
- QueueItems: удалить `DialogId/ChannelName/ChannelHandle/ChannelHue/MsgId`; добавить
`SourceKey` (text, уникальный ключ дедупа), `SourceJson`, `ContentJson`. `Text/MsgAt/Status/Force/CreatedAt/UpdatedAt` остаются.
- RejectedItems: удалить `DialogId/MsgId/ChannelName/ChannelHandle/ChannelHue`; добавить
`SourceKey`, `SourceJson`, `ContentJson`. FTS — по `Text`.
- Миграции tenant: старые удалить, сгенерировать новый init с нуля (данных нет).
### Маппинг источника
- Адаптер Telegram (в ядре — тонкий край приёма): `Kind="telegram"`, `ExternalId=MsgId`,
`OriginRef=DialogId`, `DisplayName=ChannelName`, `Extra["hue"]=ChannelHue` (иначе дефолт),
`ReceivedAt=msgAt`, `Content.Text=Text`, `Content.Author=ChannelName`.
- Дашборды/карточки/конвейер работают только с `SourceRef`/`SourceContent`; Telegram-поля не проходят дальше адаптера.
@@ -816,6 +816,14 @@ Contact) + GIN-индекс; tsvector-колонки авто-актуальны
(квалификация, ≤6, primary), поля канала `ch`, `sourceMsg`/`sourceDialogId`/`sourceMsgId`; колонка — (квалификация, ≤6, primary), поля канала `ch`, `sourceMsg`/`sourceDialogId`/`sourceMsgId`; колонка —
inbox либо доска по `BoardAccepts`+правилам; после успешной классификации карточка получает inbox либо доска по `BoardAccepts`+правилам; после успешной классификации карточка получает
`isVacancy`/`isVacancyKnown`; создание карточки связывает хэш в `DedupEntries` с её id. `isVacancy`/`isVacancyKnown`; создание карточки связывает хэш в `DedupEntries` с её id.
> **Актуально с 2026-09-11: единый контракт источника.** Карточка, очередь и отсев работают с generic-типом
> `SourceItem` (`SourceRef` + `SourceContent`). В таблицах `Cards`/`QueueItems`/`RejectedItems` вместо
> Telegram-колонок (`ChannelName`/`SourceMsg`/`SourceMsgId`…) хранятся `SourceKind`/`SourceExternalId`/
> `SourceOriginRef`/`SourceJson`/`ContentJson` (карточка — ещё `SourceText` и FTS по нему; очередь/отсев —
> `SourceKey` для дедупа). Вложение — `DataRef` (ссылка на общий Storage-сервис); контакты/ссылки — поля
> `SourceContent`. Telegram-поля остались только в тонком адаптере приёма telegram-сервиса. Tenant-миграции
> пересозданы с нуля (данных нет). Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
- Отсев — источник решения `stop|ml|ai|stale|dup` (подписи «правила/ML/ИИ/система») и этап - Отсев — источник решения `stop|ml|ai|stale|dup` (подписи «правила/ML/ИИ/система») и этап
`length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup` (подписи UI: «короткое сообщение», `length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup` (подписи UI: «короткое сообщение»,
«стоп-фраза», «резюме соискателя», «нет суммы», «устарело», «повтор»…), причина ≤500, kw — сработавшая «стоп-фраза», «резюме соискателя», «нет суммы», «устарело», «повтор»…), причина ≤500, kw — сработавшая
@@ -423,7 +423,7 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
IReadOnlyList<CardDto> leads = await cardsService.SearchCardsAsync(q, ct); IReadOnlyList<CardDto> leads = await cardsService.SearchCardsAsync(q, ct);
// messages всегда []: telegram-сообщений здесь нет, фронт их не читает. // messages всегда []: фронт их не читает.
return Results.Ok(new { cards = leads, messages = Array.Empty<object>() }); return Results.Ok(new { cards = leads, messages = Array.Empty<object>() });
} }
@@ -1,7 +1,9 @@
using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Deal.Api.Events; using Deal.Api.Events;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Telegram; using Deal.Grpc.Telegram;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
@@ -70,17 +72,30 @@ public sealed class TelegramIngressService(
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService<PipelineIngestService>(); PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService<PipelineIngestService>();
PipelineIngestResultDto result = await ingest.EnqueueAsync( string hue = string.IsNullOrWhiteSpace(request.ChannelHue) ? SourceRefs.DefaultHue : request.ChannelHue;
new QueuedMessage DateTimeOffset receivedAt = request.HasMsgAt
? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt)
: DateTimeOffset.UtcNow;
var item = new SourceItem
{
Source = new SourceRef
{ {
DialogId = request.DialogId, Kind = "telegram",
ChannelName = request.ChannelName, ExternalId = request.HasMsgId ? request.MsgId.ToString(CultureInfo.InvariantCulture) : null,
ChannelHandle = request.ChannelHandle, OriginRef = request.DialogId,
ChannelHue = request.ChannelHue, DisplayName = request.ChannelName,
MsgId = request.HasMsgId ? request.MsgId : null, ReceivedAt = receivedAt,
Text = request.Text, Extra = new Dictionary<string, string> { [SourceRefs.HueKey] = hue },
MsgAtMs = request.HasMsgAt ? request.MsgAt : null,
}, },
Content = new SourceContent
{
Text = request.Text,
Author = request.ChannelName,
},
};
PipelineIngestResultDto result = await ingest.EnqueueAsync(
new QueuedMessage { Item = item },
context.CancellationToken).ConfigureAwait(false); context.CancellationToken).ConfigureAwait(false);
await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false); await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false);
@@ -88,9 +88,9 @@ ContainersService containersService) : IColumnSuggester
List<string> texts = cards List<string> texts = cards
.Where(card => card.Col != CardIds.Trash .Where(card => card.Col != CardIds.Trash
&& card.Col != CardIds.Archive && card.Col != CardIds.Archive
&& card.SourceMsg.Trim().Length > 0) && (card.Content.Text ?? string.Empty).Trim().Length > 0)
.Take(SuggestHeuristics.KeywordsSampleLimit) .Take(SuggestHeuristics.KeywordsSampleLimit)
.Select(card => card.SourceMsg.Trim()) .Select(card => (card.Content.Text ?? string.Empty).Trim())
.ToList(); .ToList();
if (texts.Count < SuggestHeuristics.MinKeywordsSample) if (texts.Count < SuggestHeuristics.MinKeywordsSample)
{ {
@@ -115,7 +115,7 @@ ContainersService containersService) : IColumnSuggester
.Select(card => card.Id) .Select(card => card.Id)
.ToHashSet(StringComparer.Ordinal); .ToHashSet(StringComparer.Ordinal);
Dictionary<string, string> textByCardId = inbox Dictionary<string, string> textByCardId = inbox
.ToDictionary(card => card.Id, card => card.SourceMsg, StringComparer.Ordinal); .ToDictionary(card => card.Id, card => card.Content.Text ?? string.Empty, StringComparer.Ordinal);
int created = 0; int created = 0;
foreach (SuggestedColumnPlan plan in plans) foreach (SuggestedColumnPlan plan in plans)
@@ -1,49 +0,0 @@
// <auto-generated />
using System;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Configurations;
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
}
}
}
@@ -1,34 +0,0 @@
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");
}
}
}
@@ -1,307 +0,0 @@
// <auto-generated />
using System;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Configurations;
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
}
}
}
@@ -1,172 +0,0 @@
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");
}
}
}
@@ -1,462 +0,0 @@
// <auto-generated />
using System;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Configurations;
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
}
}
}
@@ -1,128 +0,0 @@
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");
}
}
}
@@ -1,549 +0,0 @@
// <auto-generated />
using System;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Configurations;
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
}
}
}
@@ -1,69 +0,0 @@
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");
}
}
}
@@ -1,619 +0,0 @@
// <auto-generated />
using System;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Configurations;
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
}
}
}
@@ -1,64 +0,0 @@
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");
}
}
}
@@ -1,801 +0,0 @@
// <auto-generated />
using System;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Configurations;
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
}
}
}
@@ -1,124 +0,0 @@
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");
}
}
}
@@ -1,879 +0,0 @@
// <auto-generated />
using System;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Configurations;
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
}
}
}
@@ -1,68 +0,0 @@
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");
}
}
}
@@ -1,823 +0,0 @@
// <auto-generated />
using System;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Configurations;
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
}
}
}
@@ -1,166 +0,0 @@
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]);
}
}
}
@@ -1,763 +0,0 @@
// <auto-generated />
using System;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Configurations;
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
}
}
}
@@ -1,50 +0,0 @@
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" });
}
}
}
@@ -1,40 +0,0 @@
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: "");
}
}
}
@@ -12,6 +12,9 @@ public sealed class CardConfiguration : IEntityTypeConfiguration<CardEntity>
// Максимальная длина Col: служебные значения + id доски (короткие строки b_...). // Максимальная длина Col: служебные значения + id доски (короткие строки b_...).
private const int ColMaxLength = 200; private const int ColMaxLength = 200;
// Максимальная длина вида источника.
private const int SourceKindMaxLength = 64;
public void Configure(EntityTypeBuilder<CardEntity> builder) public void Configure(EntityTypeBuilder<CardEntity> builder)
{ {
builder.ToTable("Cards"); builder.ToTable("Cards");
@@ -27,7 +30,12 @@ public sealed class CardConfiguration : IEntityTypeConfiguration<CardEntity>
builder.Property(x => x.LinksJson).HasColumnType("text"); builder.Property(x => x.LinksJson).HasColumnType("text");
builder.Property(x => x.FilesJson).HasColumnType("text"); builder.Property(x => x.FilesJson).HasColumnType("text");
builder.Property(x => x.HistoryJson).HasColumnType("text"); builder.Property(x => x.HistoryJson).HasColumnType("text");
builder.Property(x => x.SourceMsg).HasColumnType("text"); builder.Property(x => x.SourceKind).HasMaxLength(SourceKindMaxLength);
builder.Property(x => x.SourceExternalId).HasColumnType("text");
builder.Property(x => x.SourceOriginRef).HasColumnType("text");
builder.Property(x => x.SourceJson).HasColumnType("text");
builder.Property(x => x.ContentJson).HasColumnType("text");
builder.Property(x => x.SourceText).HasColumnType("text");
// Выборка колонки сортируется по времени получения (received_at DESC); счётчик новых — по (col, is_new). // Выборка колонки сортируется по времени получения (received_at DESC); счётчик новых — по (col, is_new).
builder.HasIndex(x => new { x.Col, x.ReceivedAt }).IsDescending(false, true); builder.HasIndex(x => new { x.Col, x.ReceivedAt }).IsDescending(false, true);
@@ -38,7 +46,7 @@ public sealed class CardConfiguration : IEntityTypeConfiguration<CardEntity>
builder.Property(x => x.SearchTsv) builder.Property(x => x.SearchTsv)
.HasComputedColumnSql( .HasComputedColumnSql(
"to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", "to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))",
stored: true); stored: true);
builder.HasIndex(x => x.SearchTsv).HasMethod("gin"); builder.HasIndex(x => x.SearchTsv).HasMethod("gin");
} }
@@ -16,8 +16,14 @@ public sealed class QueueItemConfiguration : IEntityTypeConfiguration<QueueItemE
builder.HasKey(x => x.Id); builder.HasKey(x => x.Id);
builder.Property(x => x.Text).HasColumnType("text"); builder.Property(x => x.Text).HasColumnType("text");
builder.Property(x => x.SourceKey).HasColumnType("text");
builder.Property(x => x.SourceJson).HasColumnType("text");
builder.Property(x => x.ContentJson).HasColumnType("text");
// Выборка pump'а идёт по статусу и времени постановки (status='new', лимит 12). // Выборка pump'а идёт по статусу и времени постановки (status='new', лимит 12).
builder.HasIndex(x => new { x.Status, x.CreatedAt }); builder.HasIndex(x => new { x.Status, x.CreatedAt });
// Дубль-гвард приёма ищет строку по ключу записи источника.
builder.HasIndex(x => x.SourceKey);
} }
} }
@@ -18,6 +18,9 @@ public sealed class RejectedItemConfiguration : IEntityTypeConfiguration<Rejecte
builder.Property(x => x.Text).HasColumnType("text"); builder.Property(x => x.Text).HasColumnType("text");
builder.Property(x => x.Reason).HasColumnType("text"); builder.Property(x => x.Reason).HasColumnType("text");
builder.Property(x => x.Kw).HasColumnType("text"); builder.Property(x => x.Kw).HasColumnType("text");
builder.Property(x => x.SourceKey).HasColumnType("text");
builder.Property(x => x.SourceJson).HasColumnType("text");
builder.Property(x => x.ContentJson).HasColumnType("text");
builder.Property(x => x.SearchTsv) builder.Property(x => x.SearchTsv)
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", stored: true); .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", stored: true);
@@ -92,40 +92,40 @@ public sealed class CardEntity
/// </summary> /// </summary>
public string ContactsJson { get; set; } = "[]"; 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>
/// Цвет канала-источника
/// </summary>
public string ChannelHue { get; set; } = "#666";
/// <summary> /// <summary>
/// Время получения исходного сообщения /// Время получения исходного сообщения
/// </summary> /// </summary>
public DateTimeOffset ReceivedAt { get; set; } public DateTimeOffset ReceivedAt { get; set; }
/// <summary> /// <summary>
/// Текст исходного сообщения /// Вид источника
/// </summary> /// </summary>
public string SourceMsg { get; set; } = string.Empty; public string SourceKind { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Id диалога исходного сообщения /// Идентификатор записи в источнике
/// </summary> /// </summary>
public string SourceDialogId { get; set; } = string.Empty; public string SourceExternalId { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Id исходного сообщения в Telegram, либо null. /// Ссылка на оригинал в источнике
/// </summary> /// </summary>
public long? SourceMsgId { get; set; } public string SourceOriginRef { get; set; } = string.Empty;
/// <summary>
/// Ссылка на источник, сериализованная в JSON
/// </summary>
public string SourceJson { get; set; } = string.Empty;
/// <summary>
/// Содержимое источника, сериализованное в JSON
/// </summary>
public string ContentJson { get; set; } = string.Empty;
/// <summary>
/// Текст источника для полнотекстового поиска
/// </summary>
public string SourceText { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Предыдущая колонка /// Предыдущая колонка
@@ -11,35 +11,25 @@ public sealed class QueueItemEntity
public string Id { get; set; } = string.Empty; public string Id { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Id диалога-источника /// Ключ дедупликации записи источника
/// </summary> /// </summary>
public string DialogId { get; set; } = string.Empty; public string SourceKey { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Имя канала/диалога-источника. /// Ссылка на источник, сериализованная в JSON
/// </summary> /// </summary>
public string ChannelName { get; set; } = string.Empty; public string SourceJson { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Handle канала-источника. /// Содержимое источника, сериализованное в JSON
/// </summary> /// </summary>
public string ChannelHandle { get; set; } = string.Empty; public string ContentJson { get; set; } = string.Empty;
/// <summary>
/// Цвет канала-источника
/// </summary>
public string ChannelHue { get; set; } = "#666";
/// <summary> /// <summary>
/// Текст сообщения /// Текст сообщения
/// </summary> /// </summary>
public string Text { get; set; } = string.Empty; public string Text { get; set; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram, либо null
/// </summary>
public long? MsgId { get; set; }
/// <summary> /// <summary>
/// Время получения исходного сообщения /// Время получения исходного сообщения
/// </summary> /// </summary>
@@ -13,29 +13,19 @@ public sealed class RejectedItemEntity
public string Id { get; set; } = string.Empty; public string Id { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Id диалога-источника /// Ключ дедупликации записи источника
/// </summary> /// </summary>
public string DialogId { get; set; } = string.Empty; public string SourceKey { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Id исходного сообщения в Telegram, либо null. /// Ссылка на источник, сериализованная в JSON
/// </summary> /// </summary>
public long? MsgId { get; set; } public string SourceJson { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Имя канала/диалога-источника. /// Содержимое источника, сериализованное в JSON
/// </summary> /// </summary>
public string ChannelName { get; set; } = string.Empty; public string ContentJson { get; set; } = string.Empty;
/// <summary>
/// Handle канала-источника.
/// </summary>
public string ChannelHandle { get; set; } = string.Empty;
/// <summary>
/// Цвет канала-источника
/// </summary>
public string ChannelHue { get; set; } = "#666";
/// <summary> /// <summary>
/// Текст сообщения /// Текст сообщения
@@ -1,7 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Configurations;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
@@ -11,11 +10,11 @@ using NpgsqlTypes;
#nullable disable #nullable disable
namespace Deal.Infrastructure.Migrations.TenantDb namespace Deal.Infrastructure.Persistence.Migrations.TenantDb
{ {
[DbContext(typeof(TenantDbContext))] [DbContext(typeof(TenantDbContext))]
[Migration("20260910134358_TenantContainerCleanup")] [Migration("20260911114400_InitialTenant")]
partial class TenantContainerCleanup partial class InitialTenant
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -45,18 +44,6 @@ namespace Deal.Infrastructure.Migrations.TenantDb
b.Property<double?>("BudgetTo") b.Property<double?>("BudgetTo")
.HasColumnType("double precision"); .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") b.Property<string>("Col")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
@@ -70,6 +57,10 @@ namespace Deal.Infrastructure.Migrations.TenantDb
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("ContentJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ConvCur") b.Property<string>("ConvCur")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -128,18 +119,28 @@ namespace Deal.Infrastructure.Migrations.TenantDb
.IsRequired() .IsRequired()
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
.HasColumnType("tsvector") .HasColumnType("tsvector")
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true); .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", true);
b.Property<string>("SourceDialogId") b.Property<string>("SourceExternalId")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("SourceMsg") b.Property<string>("SourceJson")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<long?>("SourceMsgId") b.Property<string>("SourceKind")
.HasColumnType("bigint"); .IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("SourceOriginRef")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SourceText")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StackJson") b.Property<string>("StackJson")
.IsRequired() .IsRequired()
@@ -574,33 +575,26 @@ namespace Deal.Infrastructure.Migrations.TenantDb
b.Property<string>("Id") b.Property<string>("Id")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("ChannelHandle") b.Property<string>("ContentJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ChannelHue")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ChannelName")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("DialogId")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("Force") b.Property<bool>("Force")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<DateTimeOffset>("MsgAt") b.Property<DateTimeOffset>("MsgAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<long?>("MsgId") b.Property<string>("SourceJson")
.HasColumnType("bigint"); .IsRequired()
.HasColumnType("text");
b.Property<string>("SourceKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status") b.Property<string>("Status")
.IsRequired() .IsRequired()
@@ -615,6 +609,8 @@ namespace Deal.Infrastructure.Migrations.TenantDb
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("SourceKey");
b.HasIndex("Status", "CreatedAt"); b.HasIndex("Status", "CreatedAt");
b.ToTable("QueueItems", (string)null); b.ToTable("QueueItems", (string)null);
@@ -625,19 +621,7 @@ namespace Deal.Infrastructure.Migrations.TenantDb
b.Property<string>("Id") b.Property<string>("Id")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("ChannelHandle") b.Property<string>("ContentJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ChannelHue")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ChannelName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("DialogId")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -648,9 +632,6 @@ namespace Deal.Infrastructure.Migrations.TenantDb
b.Property<DateTimeOffset>("MsgAt") b.Property<DateTimeOffset>("MsgAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<long?>("MsgId")
.HasColumnType("bigint");
b.Property<string>("Reason") b.Property<string>("Reason")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -678,6 +659,14 @@ namespace Deal.Infrastructure.Migrations.TenantDb
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("SourceJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SourceKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Stage") b.Property<string>("Stage")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -0,0 +1,462 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NpgsqlTypes;
#nullable disable
namespace Deal.Infrastructure.Persistence.Migrations.TenantDb
{
/// <inheritdoc />
public partial class InitialTenant : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
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),
Local = 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),
ReceivedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
SourceKind = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
SourceExternalId = table.Column<string>(type: "text", nullable: false),
SourceOriginRef = table.Column<string>(type: "text", nullable: false),
SourceJson = table.Column<string>(type: "text", nullable: false),
ContentJson = table.Column<string>(type: "text", nullable: false),
SourceText = table.Column<string>(type: "text", nullable: false),
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),
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),
SearchTsv = table.Column<NpgsqlTsVector>(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", stored: true),
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_Cards", x => x.Id);
});
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),
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.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: "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: "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.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: "QueueItems",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
SourceKey = table.Column<string>(type: "text", nullable: false),
SourceJson = table.Column<string>(type: "text", nullable: false),
ContentJson = 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),
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),
SourceKey = table.Column<string>(type: "text", nullable: false),
SourceJson = table.Column<string>(type: "text", nullable: false),
ContentJson = 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.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);
});
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.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_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_Cards_SearchTsv",
table: "Cards",
column: "SearchTsv")
.Annotation("Npgsql:IndexMethod", "gin");
migrationBuilder.CreateIndex(
name: "IX_Cards_UpdatedAt",
table: "Cards",
column: "UpdatedAt",
descending: new bool[0]);
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" });
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" });
migrationBuilder.CreateIndex(
name: "IX_LeadComments_CardId",
table: "LeadComments",
column: "CardId");
migrationBuilder.CreateIndex(
name: "IX_MlOutbox_CreatedAt",
table: "MlOutbox",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_QueueItems_SourceKey",
table: "QueueItems",
column: "SourceKey");
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");
migrationBuilder.CreateIndex(
name: "IX_TgMessages_DialogId_MsgAt",
table: "TgMessages",
columns: new[] { "DialogId", "MsgAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CardMoves");
migrationBuilder.DropTable(
name: "Containers");
migrationBuilder.DropTable(
name: "DedupEntries");
migrationBuilder.DropTable(
name: "Dialogs");
migrationBuilder.DropTable(
name: "DiscBlacklist");
migrationBuilder.DropTable(
name: "DiscCandidates");
migrationBuilder.DropTable(
name: "DiscLog");
migrationBuilder.DropTable(
name: "DiscTasks");
migrationBuilder.DropTable(
name: "LeadComments");
migrationBuilder.DropTable(
name: "MlOutbox");
migrationBuilder.DropTable(
name: "QueueItems");
migrationBuilder.DropTable(
name: "RejectedItems");
migrationBuilder.DropTable(
name: "settings");
migrationBuilder.DropTable(
name: "TgMessages");
migrationBuilder.DropTable(
name: "Cards");
}
}
}
@@ -1,7 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Configurations;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -10,7 +9,7 @@ using NpgsqlTypes;
#nullable disable #nullable disable
namespace Deal.Infrastructure.Migrations.TenantDb namespace Deal.Infrastructure.Persistence.Migrations.TenantDb
{ {
[DbContext(typeof(TenantDbContext))] [DbContext(typeof(TenantDbContext))]
partial class TenantDbContextModelSnapshot : ModelSnapshot partial class TenantDbContextModelSnapshot : ModelSnapshot
@@ -42,18 +41,6 @@ namespace Deal.Infrastructure.Migrations.TenantDb
b.Property<double?>("BudgetTo") b.Property<double?>("BudgetTo")
.HasColumnType("double precision"); .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") b.Property<string>("Col")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
@@ -67,6 +54,10 @@ namespace Deal.Infrastructure.Migrations.TenantDb
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("ContentJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ConvCur") b.Property<string>("ConvCur")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -125,18 +116,28 @@ namespace Deal.Infrastructure.Migrations.TenantDb
.IsRequired() .IsRequired()
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
.HasColumnType("tsvector") .HasColumnType("tsvector")
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true); .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", true);
b.Property<string>("SourceDialogId") b.Property<string>("SourceExternalId")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("SourceMsg") b.Property<string>("SourceJson")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<long?>("SourceMsgId") b.Property<string>("SourceKind")
.HasColumnType("bigint"); .IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("SourceOriginRef")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SourceText")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StackJson") b.Property<string>("StackJson")
.IsRequired() .IsRequired()
@@ -571,33 +572,26 @@ namespace Deal.Infrastructure.Migrations.TenantDb
b.Property<string>("Id") b.Property<string>("Id")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("ChannelHandle") b.Property<string>("ContentJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ChannelHue")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ChannelName")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("DialogId")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("Force") b.Property<bool>("Force")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<DateTimeOffset>("MsgAt") b.Property<DateTimeOffset>("MsgAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<long?>("MsgId") b.Property<string>("SourceJson")
.HasColumnType("bigint"); .IsRequired()
.HasColumnType("text");
b.Property<string>("SourceKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status") b.Property<string>("Status")
.IsRequired() .IsRequired()
@@ -612,6 +606,8 @@ namespace Deal.Infrastructure.Migrations.TenantDb
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("SourceKey");
b.HasIndex("Status", "CreatedAt"); b.HasIndex("Status", "CreatedAt");
b.ToTable("QueueItems", (string)null); b.ToTable("QueueItems", (string)null);
@@ -622,19 +618,7 @@ namespace Deal.Infrastructure.Migrations.TenantDb
b.Property<string>("Id") b.Property<string>("Id")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("ChannelHandle") b.Property<string>("ContentJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ChannelHue")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ChannelName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("DialogId")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -645,9 +629,6 @@ namespace Deal.Infrastructure.Migrations.TenantDb
b.Property<DateTimeOffset>("MsgAt") b.Property<DateTimeOffset>("MsgAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<long?>("MsgId")
.HasColumnType("bigint");
b.Property<string>("Reason") b.Property<string>("Reason")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -675,6 +656,14 @@ namespace Deal.Infrastructure.Migrations.TenantDb
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("SourceJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SourceKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Stage") b.Property<string>("Stage")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -1,4 +1,5 @@
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -49,7 +50,7 @@ public sealed partial class KanbanStore
AND ("SearchTsv" @@ plainto_tsquery('russian', {query}) AND ("SearchTsv" @@ plainto_tsquery('russian', {query})
OR lower("Title") LIKE {pattern} OR lower("Title") LIKE {pattern}
OR lower("Summary") LIKE {pattern} OR lower("Summary") LIKE {pattern}
OR lower("SourceMsg") LIKE {pattern} OR lower("SourceText") LIKE {pattern}
OR lower("Contact") LIKE {pattern}) OR lower("Contact") LIKE {pattern})
ORDER BY ts_rank("SearchTsv", plainto_tsquery('russian', {query})) DESC, "ReceivedAt" DESC ORDER BY ts_rank("SearchTsv", plainto_tsquery('russian', {query})) DESC, "ReceivedAt" DESC
LIMIT {limit} LIMIT {limit}
@@ -76,18 +77,21 @@ public sealed partial class KanbanStore
/// <inheritdoc /> /// <inheritdoc />
public async Task<CardDto?> GetCardBySourceAsync( public async Task<CardDto?> GetCardBySourceAsync(
string dialogId, SourceRef source,
long msgId,
CancellationToken ct) CancellationToken ct)
{ {
if (string.IsNullOrEmpty(dialogId)) if (string.IsNullOrEmpty(source.Kind))
{ {
return null; return null;
} }
string externalId = source.ExternalId ?? string.Empty;
string originRef = source.OriginRef ?? string.Empty;
CardEntity? entity = await _dbContext.Cards CardEntity? entity = await _dbContext.Cards
.AsNoTracking() .AsNoTracking()
.Where(card => card.SourceDialogId == dialogId && card.SourceMsgId == msgId) .Where(card => card.SourceKind == source.Kind
&& card.SourceExternalId == externalId
&& card.SourceOriginRef == originRef)
.OrderByDescending(card => card.ReceivedAt) .OrderByDescending(card => card.ReceivedAt)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
if (entity is null) if (entity is null)
@@ -71,10 +71,10 @@ public sealed partial class KanbanStore
card => card.Id, card => card.Id,
(move, card) => new { move, card }) (move, card) => new { move, card })
.Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction) .Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction)
&& joined.card.SourceMsg.Length > 0) && joined.card.SourceText.Length > 0)
.OrderByDescending(joined => joined.move.CreatedAt) .OrderByDescending(joined => joined.move.CreatedAt)
.Take(limit) .Take(limit)
.Select(joined => new AiMarkupExampleDto(joined.card.SourceMsg, joined.move.ToCol!)) .Select(joined => new AiMarkupExampleDto(joined.card.SourceText, joined.move.ToCol!))
.ToListAsync(ct); .ToListAsync(ct);
} }
@@ -123,7 +123,7 @@ public sealed partial class KanbanStore
{ {
List<CardEntity> entities = await _dbContext.Cards List<CardEntity> entities = await _dbContext.Cards
.AsNoTracking() .AsNoTracking()
.Where(card => card.Col == CardIds.Inbox && card.SourceMsg != string.Empty) .Where(card => card.Col == CardIds.Inbox && card.SourceText != string.Empty)
.OrderByDescending(card => card.ReceivedAt) .OrderByDescending(card => card.ReceivedAt)
.ToListAsync(ct); .ToListAsync(ct);
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList(); return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
@@ -120,11 +121,8 @@ public sealed partial class KanbanStore : ICardStore
IsVacancyKnown = entity.IsVacancyKnown, IsVacancyKnown = entity.IsVacancyKnown,
Title = entity.Title, Title = entity.Title,
Summary = entity.Summary, Summary = entity.Summary,
Source = new CardSourceDto( Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty),
Kind: ResolveSourceKind(entity), Content = FromJsonOr(entity.ContentJson, new SourceContent { Text = entity.SourceText }),
DisplayName: entity.ChannelName,
OriginRef: entity.SourceDialogId,
ReceivedAt: entity.ReceivedAt.ToUnixTimeMilliseconds()),
Stack = ToJsonList<string>(entity.StackJson), Stack = ToJsonList<string>(entity.StackJson),
Budget = entity.BudgetCur.Length == 0 Budget = entity.BudgetCur.Length == 0
? null ? null
@@ -134,12 +132,8 @@ public sealed partial class KanbanStore : ICardStore
: new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur), : new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur),
Contact = entity.Contact, Contact = entity.Contact,
Contacts = ToJsonList<CardContactDto>(entity.ContactsJson), Contacts = ToJsonList<CardContactDto>(entity.ContactsJson),
Channel = new CardChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
Time = HumanAge(entity.ReceivedAt), Time = HumanAge(entity.ReceivedAt),
ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(), ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(),
SourceMsg = entity.SourceMsg,
SourceDialogId = entity.SourceDialogId,
SourceMsgId = entity.SourceMsgId,
PrevCol = entity.PrevCol, PrevCol = entity.PrevCol,
MatchHits = ToJsonList<MatchHitDto>(entity.MatchHitsJson), MatchHits = ToJsonList<MatchHitDto>(entity.MatchHitsJson),
Comments = comments, Comments = comments,
@@ -152,17 +146,22 @@ public sealed partial class KanbanStore : ICardStore
UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(), UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(),
}; };
// Вид источника карточки (производная проекция): local/telegram/other. // Разбирает одиночный JSON-объект; пустая/битая строка — запасное значение.
// entity: Строка карточки. private static T FromJsonOr<T>(string json, T fallback)
// Возвращает: Вид источника (строка wire-контракта).
private static string ResolveSourceKind(CardEntity entity)
{ {
if (entity.Local) if (string.IsNullOrWhiteSpace(json))
{ {
return "local"; return fallback;
} }
return entity.ChannelName.Length > 0 || entity.SourceDialogId.Length > 0 ? "telegram" : "other"; try
{
return JsonSerializer.Deserialize<T>(json, JsonOptions) ?? fallback;
}
catch (JsonException)
{
return fallback;
}
} }
// Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now. // Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now.
@@ -185,13 +184,13 @@ public sealed partial class KanbanStore : ICardStore
ConvCur = snapshot.ConvCur, ConvCur = snapshot.ConvCur,
Contact = snapshot.Contact, Contact = snapshot.Contact,
ContactsJson = ToJson(snapshot.Contacts), ContactsJson = ToJson(snapshot.Contacts),
ChannelName = snapshot.ChannelName,
ChannelHandle = snapshot.ChannelHandle,
ChannelHue = snapshot.ChannelHue,
ReceivedAt = snapshot.ReceivedAt, ReceivedAt = snapshot.ReceivedAt,
SourceMsg = snapshot.SourceMsg, SourceKind = snapshot.Source.Kind,
SourceDialogId = snapshot.SourceDialogId, SourceExternalId = snapshot.Source.ExternalId ?? string.Empty,
SourceMsgId = snapshot.SourceMsgId, SourceOriginRef = snapshot.Source.OriginRef ?? string.Empty,
SourceJson = ToJson(snapshot.Source),
ContentJson = ToJson(snapshot.Content),
SourceText = snapshot.Content.Text ?? string.Empty,
PrevCol = snapshot.PrevCol, PrevCol = snapshot.PrevCol,
ArchivedAt = snapshot.ArchivedAt, ArchivedAt = snapshot.ArchivedAt,
MatchHitsJson = ToJson(snapshot.MatchHits), MatchHitsJson = ToJson(snapshot.MatchHits),
@@ -1,4 +1,6 @@
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
@@ -11,17 +13,21 @@ namespace Deal.Infrastructure.Persistence.Repositories;
/// </summary> /// </summary>
public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
{ {
// ── Очередь (QueueItems) ─────────────────────────────────────────────── private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
// ── Очередь (QueueItems) ───────────────────────────────────────
/// <inheritdoc /> /// <inheritdoc />
public Task<bool> ExistsDuplicateAsync( public Task<bool> ExistsDuplicateAsync(
string dialogId, SourceRef source,
long? msgId,
CancellationToken ct) CancellationToken ct)
{ {
return msgId is null string key = source.DedupeKey();
? Task.FromResult(false) return dbContext.QueueItems.AnyAsync(item => item.SourceKey == key, ct);
: dbContext.QueueItems.AnyAsync(item => item.DialogId == dialogId && item.MsgId == msgId, ct);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -89,16 +95,19 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected); string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected);
DateTimeOffset rejectedAt = DateTimeOffset.UtcNow; DateTimeOffset rejectedAt = DateTimeOffset.UtcNow;
string sourceKey = record.Source.DedupeKey();
string sourceJson = ToJson(record.Source);
string contentJson = ToJson(record.Content);
await dbContext.Database.ExecuteSqlInterpolatedAsync( await dbContext.Database.ExecuteSqlInterpolatedAsync(
$""" $"""
INSERT INTO "RejectedItems" ("Id", "DialogId", "MsgId", "ChannelName", "ChannelHandle", "ChannelHue", "Text", "Stage", "Reason", "Kw", "Source", "MsgAt", "RejectedAt", "Returned", "ReturnReason") INSERT INTO "RejectedItems" ("Id", "SourceKey", "SourceJson", "ContentJson", "Text", "Stage", "Reason", "Kw", "Source", "MsgAt", "RejectedAt", "Returned", "ReturnReason")
VALUES ({id}, {record.DialogId}, {record.MsgId}, {record.ChannelName}, {record.ChannelHandle}, {record.ChannelHue}, {record.Text}, {record.Stage}, {record.Reason}, {record.Kw}, {record.Source}, {FromEpochMs(record.MsgAtMs)}, {rejectedAt}, false, {string.Empty}) VALUES ({id}, {sourceKey}, {sourceJson}, {contentJson}, {record.Text}, {record.Stage}, {record.Reason}, {record.Kw}, {record.DecidedBy}, {FromEpochMs(record.MsgAtMs)}, {rejectedAt}, false, {string.Empty})
ON CONFLICT ("Id") DO UPDATE SET ON CONFLICT ("Id") DO UPDATE SET
"SourceKey" = excluded."SourceKey",
"SourceJson" = excluded."SourceJson",
"ContentJson" = excluded."ContentJson",
"Text" = excluded."Text", "Text" = excluded."Text",
"ChannelName" = excluded."ChannelName",
"ChannelHandle" = excluded."ChannelHandle",
"ChannelHue" = excluded."ChannelHue",
"Stage" = excluded."Stage", "Stage" = excluded."Stage",
"Reason" = excluded."Reason", "Reason" = excluded."Reason",
"Kw" = excluded."Kw", "Kw" = excluded."Kw",
@@ -156,8 +165,7 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
.AsNoTracking() .AsNoTracking()
.Where(item => EF.Functions.Like(item.Text.ToLower(), pattern) .Where(item => EF.Functions.Like(item.Text.ToLower(), pattern)
|| EF.Functions.Like(item.Reason.ToLower(), pattern) || EF.Functions.Like(item.Reason.ToLower(), pattern)
|| EF.Functions.Like(item.Kw.ToLower(), pattern) || EF.Functions.Like(item.Kw.ToLower(), pattern))
|| EF.Functions.Like(item.ChannelName.ToLower(), pattern))
.OrderByDescending(item => item.RejectedAt) .OrderByDescending(item => item.RejectedAt)
.Take(limitLike) .Take(limitLike)
.ToListAsync(ct); .ToListAsync(ct);
@@ -274,11 +282,10 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
private static QueueItemDto ToQueueItemDto(QueueItemEntity entity) => new() private static QueueItemDto ToQueueItemDto(QueueItemEntity entity) => new()
{ {
Id = entity.Id, Id = entity.Id,
DialogId = entity.DialogId, Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty),
MsgId = entity.MsgId, Content = FromJsonOr(entity.ContentJson, new SourceContent()),
Text = entity.Text, Text = entity.Text,
Status = entity.Status, Status = entity.Status,
Channel = new PipelineChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(), MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(),
QueuedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(), QueuedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(),
Force = entity.Force, Force = entity.Force,
@@ -291,13 +298,11 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
return new QueueItemEntity return new QueueItemEntity
{ {
Id = item.Id, Id = item.Id,
DialogId = item.DialogId, SourceKey = item.Source.DedupeKey(),
MsgId = item.MsgId, SourceJson = ToJson(item.Source),
ContentJson = ToJson(item.Content),
Text = item.Text, Text = item.Text,
Status = item.Status, Status = item.Status,
ChannelName = item.Channel.Name,
ChannelHandle = item.Channel.Handle,
ChannelHue = item.Channel.Hue,
MsgAt = FromEpochMs(item.MsgAtMs), MsgAt = FromEpochMs(item.MsgAtMs),
CreatedAt = queuedAt, CreatedAt = queuedAt,
UpdatedAt = queuedAt, UpdatedAt = queuedAt,
@@ -308,16 +313,15 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
private static RejectedItemDto ToRejectedItemDto(RejectedItemEntity entity) => new() private static RejectedItemDto ToRejectedItemDto(RejectedItemEntity entity) => new()
{ {
Id = entity.Id, Id = entity.Id,
DialogId = entity.DialogId, Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty),
MsgId = entity.MsgId, Content = FromJsonOr(entity.ContentJson, new SourceContent()),
Text = entity.Text, Text = entity.Text,
Stage = entity.Stage, Stage = entity.Stage,
StageLabel = PipelineRejectConstants.StageLabel(entity.Stage), StageLabel = PipelineRejectConstants.StageLabel(entity.Stage),
Reason = entity.Reason, Reason = entity.Reason,
Kw = entity.Kw, Kw = entity.Kw,
Source = entity.Source, DecidedBy = entity.Source,
SourceLabel = PipelineRejectConstants.SourceLabel(entity.Source), DecidedByLabel = PipelineRejectConstants.SourceLabel(entity.Source),
Channel = new PipelineChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(), MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(),
RejectedAtMs = entity.RejectedAt.ToUnixTimeMilliseconds(), RejectedAtMs = entity.RejectedAt.ToUnixTimeMilliseconds(),
Returned = entity.Returned, Returned = entity.Returned,
@@ -327,4 +331,25 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
// epoch-ms → DateTimeOffset (UTC-момент для timestamptz-колонки). // epoch-ms → DateTimeOffset (UTC-момент для timestamptz-колонки).
private static DateTimeOffset FromEpochMs(long epochMs) => DateTimeOffset.FromUnixTimeMilliseconds(epochMs); private static DateTimeOffset FromEpochMs(long epochMs) => DateTimeOffset.FromUnixTimeMilliseconds(epochMs);
// Сериализует значение в JSON (camelCase, конвенция value_json).
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
// Разбирает одиночный JSON-объект; пустая/битая строка — запасное значение.
private static T FromJsonOr<T>(string json, T fallback)
{
if (string.IsNullOrWhiteSpace(json))
{
return fallback;
}
try
{
return JsonSerializer.Deserialize<T>(json, JsonOptions) ?? fallback;
}
catch (JsonException)
{
return fallback;
}
}
} }
@@ -0,0 +1,47 @@
namespace Deal.Modules.Cards.Application.Sources;
/// <summary>
/// Общие значения и производные поля ссылки на источник.
/// </summary>
public static class SourceRefs
{
/// <summary>
/// Цвет источника по умолчанию.
/// </summary>
public const string DefaultHue = "#666";
/// <summary>
/// Ключ прочих метаданных с цветом источника.
/// </summary>
public const string HueKey = "hue";
/// <summary>
/// Пустая ссылка на источник.
/// </summary>
public static SourceRef Empty { get; } = new() { Kind = string.Empty };
/// <summary>
/// Ключ уникальности записи в источнике: вид + оригинал + внешний id.
/// </summary>
/// <param name="source">Ссылка на источник.</param>
/// <returns>Стабильный ключ для дедупликации.</returns>
public static string DedupeKey(this SourceRef source) =>
string.Concat(source.Kind, "|", source.OriginRef ?? string.Empty, "|", source.ExternalId ?? string.Empty);
/// <summary>
/// Цвет источника для интерфейса.
/// </summary>
/// <param name="source">Ссылка на источник.</param>
/// <returns>Цвет из метаданных либо цвет по умолчанию.</returns>
public static string ResolveHue(this SourceRef source)
{
if (source.Extra is not null
&& source.Extra.TryGetValue(HueKey, out string? hue)
&& !string.IsNullOrWhiteSpace(hue))
{
return hue;
}
return DefaultHue;
}
}
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.Abstractions; namespace Deal.Modules.Kanban.Application.Abstractions;
@@ -89,12 +90,10 @@ public interface ICardStore
/// <summary> /// <summary>
/// Карточка по исходному сообщению /// Карточка по исходному сообщению
/// </summary> /// </summary>
/// <param name="dialogId">Id диалога-источника сообщения.</param> /// <param name="source">Ссылка на источник: вид + оригинал + внешний id.</param>
/// <param name="msgId">Id исходного сообщения в Telegram.</param>
/// <returns>Карточка или null, если сообщение не становилось карточкой.</returns> /// <returns>Карточка или null, если сообщение не становилось карточкой.</returns>
public Task<CardDto?> GetCardBySourceAsync( public Task<CardDto?> GetCardBySourceAsync(
string dialogId, SourceRef source,
long msgId,
CancellationToken ct); CancellationToken ct);
/// <summary> /// <summary>
@@ -367,6 +366,6 @@ public interface ICardStore
/// <summary> /// <summary>
/// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest. /// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest.
/// </summary> /// </summary>
/// <returns>Карточки col='inbox' с непустым SourceMsg (частотные темы считаются по source_msg).</returns> /// <returns>Карточки col='inbox' с непустым текстом источника.</returns>
public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct); public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct);
} }
@@ -1,9 +0,0 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Канал-источник карточки — объект <c>ch</c>.
/// </summary>
public sealed record CardChannelDto(
string Name,
string Handle,
string Hue);
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.Models;
@@ -53,9 +54,14 @@ public sealed record CardDto
public string Summary { get; init; } = string.Empty; public string Summary { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Источник (производная проекция) /// Источник записи.
/// </summary> /// </summary>
public CardSourceDto Source { get; init; } = new(string.Empty, string.Empty, string.Empty, 0); public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary>
/// Содержимое записи источника.
/// </summary>
public SourceContent Content { get; init; } = new();
/// <summary> /// <summary>
/// Стек/направления /// Стек/направления
@@ -82,11 +88,6 @@ public sealed record CardDto
/// </summary> /// </summary>
public IReadOnlyList<CardContactDto> Contacts { get; init; } = Array.Empty<CardContactDto>(); public IReadOnlyList<CardContactDto> Contacts { get; init; } = Array.Empty<CardContactDto>();
/// <summary>
/// Канал-источник
/// </summary>
public CardChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty);
/// <summary> /// <summary>
/// Человеческая метка возраста /// Человеческая метка возраста
/// </summary> /// </summary>
@@ -98,21 +99,6 @@ public sealed record CardDto
[property: JsonPropertyName("receivedAt")] [property: JsonPropertyName("receivedAt")]
public long ReceivedAtMs { get; init; } public long ReceivedAtMs { get; init; }
/// <summary>
/// Исходное сообщение
/// </summary>
public string SourceMsg { get; init; } = string.Empty;
/// <summary>
/// Id диалога исходного сообщения
/// </summary>
public string SourceDialogId { get; init; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram, либо null.
/// </summary>
public long? SourceMsgId { get; init; }
/// <summary> /// <summary>
/// Предыдущая колонка /// Предыдущая колонка
/// </summary> /// </summary>
@@ -1,3 +1,5 @@
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.Models;
/// <summary> /// <summary>
@@ -91,40 +93,20 @@ public sealed record CardSnapshot
public IReadOnlyList<CardContactDto> Contacts { get; init; } = Array.Empty<CardContactDto>(); public IReadOnlyList<CardContactDto> Contacts { get; init; } = Array.Empty<CardContactDto>();
/// <summary> /// <summary>
/// Имя канала/диалога-источника. /// Источник записи.
/// </summary> /// </summary>
public string ChannelName { get; init; } = string.Empty; public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary> /// <summary>
/// Handle канала-источника. /// Содержимое записи источника.
/// </summary> /// </summary>
public string ChannelHandle { get; init; } = string.Empty; public SourceContent Content { get; init; } = new();
/// <summary>
/// Цвет канала-источника
/// </summary>
public string ChannelHue { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Время получения исходного сообщения /// Время получения исходного сообщения
/// </summary> /// </summary>
public DateTimeOffset ReceivedAt { get; init; } public DateTimeOffset ReceivedAt { get; init; }
/// <summary>
/// Исходное сообщение
/// </summary>
public string SourceMsg { get; init; } = string.Empty;
/// <summary>
/// Id диалога исходного сообщения.
/// </summary>
public string SourceDialogId { get; init; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram, либо null.
/// </summary>
public long? SourceMsgId { get; init; }
/// <summary> /// <summary>
/// Предыдущая колонка /// Предыдущая колонка
/// </summary> /// </summary>
@@ -1,14 +0,0 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Источник карточки на wire — объект <c>source</c> карточки.
/// </summary>
/// <param name="Kind">Вид источника: local/telegram/web/file/row/api/ai/composite/other.</param>
/// <param name="DisplayName">Имя источника (канал/диалог).</param>
/// <param name="OriginRef">Ссылка на первоисточник (id диалога/файла).</param>
/// <param name="ReceivedAt">Момент получения, epoch-ms.</param>
public sealed record CardSourceDto(
string Kind,
string DisplayName,
string OriginRef,
long ReceivedAt);
@@ -17,7 +17,7 @@ public sealed partial class CardsService
private static string LearningText(CardDto card) private static string LearningText(CardDto card)
{ {
string source = card.SourceMsg.Trim(); string source = (card.Content.Text ?? string.Empty).Trim();
return source.Length > 0 ? source : card.Title; return source.Length > 0 ? source : card.Title;
} }
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
@@ -68,6 +69,7 @@ public sealed partial class CardsService
public async Task<CardDto> CreateLocalCardAsync(CardLocalCreateDto draft, CancellationToken ct) public async Task<CardDto> CreateLocalCardAsync(CardLocalCreateDto draft, CancellationToken ct)
{ {
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
DateTimeOffset receivedAt = DateTimeOffset.FromUnixTimeMilliseconds(nowMs);
string containerId = draft.ContainerId is not null && CardsDefaultContainers.Contains(draft.ContainerId) string containerId = draft.ContainerId is not null && CardsDefaultContainers.Contains(draft.ContainerId)
? draft.ContainerId ? draft.ContainerId
: PlannedStage; : PlannedStage;
@@ -79,6 +81,9 @@ public sealed partial class CardsService
Local = true, Local = true,
Title = draft.Title.Trim(), Title = draft.Title.Trim(),
Summary = draft.Summary, Summary = draft.Summary,
Source = new SourceRef { Kind = SourceKinds.Local, ReceivedAt = receivedAt },
Content = new SourceContent { Text = draft.Summary },
ReceivedAt = receivedAt,
Stack = draft.Stack ?? Array.Empty<string>(), Stack = draft.Stack ?? Array.Empty<string>(),
BudgetFrom = draft.Budget?.From, BudgetFrom = draft.Budget?.From,
BudgetTo = draft.Budget?.To, BudgetTo = draft.Budget?.To,
@@ -90,13 +90,13 @@ public static class SuggestHeuristics
/// <summary> /// <summary>
/// Планы колонок-предложений по карточкам «Неразобранного». /// Планы колонок-предложений по карточкам «Неразобранного».
/// </summary> /// </summary>
/// <param name="inbox">Карточки «Неразобранного» с непустым source_msg (ICardStore.ListInboxWithSourceAsync).</param> /// <param name="inbox">Карточки «Неразобранного» с непустым текстом источника (ICardStore.ListInboxWithSourceAsync).</param>
/// <param name="existingBoardNames">Имена существующих (suggested=false) досок — похожие темы не предлагаются.</param> /// <param name="existingBoardNames">Имена существующих (suggested=false) досок — похожие темы не предлагаются.</param>
/// <returns>Планы колонок (≤4, каждая ≥2 карточки); пусто — мало карточек/нечего сгруппировать.</returns> /// <returns>Планы колонок (≤4, каждая ≥2 карточки); пусто — мало карточек/нечего сгруппировать.</returns>
public static IReadOnlyList<SuggestedColumnPlan> PlanColumns(IReadOnlyList<CardDto> inbox, IReadOnlyList<string> existingBoardNames) public static IReadOnlyList<SuggestedColumnPlan> PlanColumns(IReadOnlyList<CardDto> inbox, IReadOnlyList<string> existingBoardNames)
{ {
List<CardDto> window = inbox List<CardDto> window = inbox
.Where(card => card.SourceMsg.Trim().Length > 0) .Where(card => (card.Content.Text ?? string.Empty).Trim().Length > 0)
.OrderByDescending(card => card.ReceivedAtMs) .OrderByDescending(card => card.ReceivedAtMs)
.ThenBy(card => card.Id, StringComparer.Ordinal) .ThenBy(card => card.Id, StringComparer.Ordinal)
.Take(MaxText) .Take(MaxText)
@@ -111,7 +111,7 @@ public static class SuggestHeuristics
foreach (CardDto card in window) foreach (CardDto card in window)
{ {
// Distinct: у карточки слово учитывается один раз, сколько бы раз ни встречалось в тексте. // Distinct: у карточки слово учитывается один раз, сколько бы раз ни встречалось в тексте.
foreach (string term in Tokenize(card.SourceMsg).Distinct(StringComparer.Ordinal)) foreach (string term in Tokenize(card.Content.Text ?? string.Empty).Distinct(StringComparer.Ordinal))
{ {
if (!termCards.TryGetValue(term, out List<string>? cardIds)) if (!termCards.TryGetValue(term, out List<string>? cardIds))
{ {
@@ -166,7 +166,7 @@ public static class SuggestHeuristics
/// <summary> /// <summary>
/// Частотные слова-маркеры по текстам карточек. /// Частотные слова-маркеры по текстам карточек.
/// </summary> /// </summary>
/// <param name="texts">Тексты source_msg карточек выборки (непустые, свежие ≤40).</param> /// <param name="texts">Тексты источника карточек выборки (непустые, свежие ≤40).</param>
/// <returns>Слова-маркеры (≤60); пусто — нет слов, встречающихся в ≥2 текстах.</returns> /// <returns>Слова-маркеры (≤60); пусто — нет слов, встречающихся в ≥2 текстах.</returns>
public static IReadOnlyList<string> SuggestDomainKeywords(IReadOnlyList<string> texts) public static IReadOnlyList<string> SuggestDomainKeywords(IReadOnlyList<string> texts)
{ {
@@ -191,7 +191,7 @@ public static class SuggestHeuristics
} }
// Слова текста: подряд букв (латиница/кириллица), нижний регистр, длина 3..40, минус стоп-слова. // Слова текста: подряд букв (латиница/кириллица), нижний регистр, длина 3..40, минус стоп-слова.
// text: Исходный текст (source_msg карточки). // text: Исходный текст (текст источника карточки).
// Возвращает: Уникальные слова текста в порядке появления (Ordinal). // Возвращает: Уникальные слова текста в порядке появления (Ordinal).
private static List<string> Tokenize(string text) private static List<string> Tokenize(string text)
{ {
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Modules.Pipeline.Application.Abstractions; namespace Deal.Modules.Pipeline.Application.Abstractions;
@@ -10,14 +11,12 @@ public interface IPipelineStore
// ── Очередь (QueueItems) ─────────────────────────────────────────────── // ── Очередь (QueueItems) ───────────────────────────────────────────────
/// <summary> /// <summary>
/// Есть ли строка очереди с тем же сообщением диалога — дубль-гвард приёма. /// Есть ли строка очереди с той же записью источника — дубль-гвард приёма.
/// </summary> /// </summary>
/// <param name="dialogId">Id диалога-источника.</param> /// <param name="source">Ссылка на источник: вид + оригинал + внешний id.</param>
/// <param name="msgId">Id исходного сообщения; null — проверка не выполняется (вернёт false).</param> /// <returns>true — строка с такой записью уже в очереди.</returns>
/// <returns>true — строка с DialogId+MsgId уже в очереди (Telethon-дубль не пишем).</returns>
public Task<bool> ExistsDuplicateAsync( public Task<bool> ExistsDuplicateAsync(
string dialogId, SourceRef source,
long? msgId,
CancellationToken ct); CancellationToken ct);
/// <summary> /// <summary>
@@ -1,22 +1,24 @@
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Pipeline.Application.Models; namespace Deal.Modules.Pipeline.Application.Models;
/// <summary> /// <summary>
/// Сообщение-кандидат для ручной проверки ML. /// Запись-кандидат для ручной проверки ML.
/// </summary> /// </summary>
public sealed record MlCandidateDto public sealed record MlCandidateDto
{ {
/// <summary> /// <summary>
/// Id исходного сообщения в Telegram /// Источник записи.
/// </summary> /// </summary>
public long Id { get; init; } public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary> /// <summary>
/// Id диалога-источника сообщения. /// Содержимое записи источника.
/// </summary> /// </summary>
public string DialogId { get; init; } = string.Empty; public SourceContent Content { get; init; } = new();
/// <summary> /// <summary>
/// Текст сообщения. /// Текст записи.
/// </summary> /// </summary>
public string Text { get; init; } = string.Empty; public string Text { get; init; } = string.Empty;
@@ -1,9 +0,0 @@
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Канал-источник сообщения пайплайна — объект <c>ch</c> строк очереди/отсева.
/// </summary>
/// <param name="Name">Имя канала/диалога.</param>
/// <param name="Handle">Handle канала/диалога (t.me/&lt;handle&gt).</param>
/// <param name="Hue">Цвет канала.</param>
public sealed record PipelineChannelDto(string Name, string Handle, string Hue);
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Pipeline.Application.Models; namespace Deal.Modules.Pipeline.Application.Models;
@@ -13,14 +14,14 @@ public sealed record QueueItemDto
public string Id { get; init; } = string.Empty; public string Id { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Id диалога-источника сообщения. /// Источник записи.
/// </summary> /// </summary>
public string DialogId { get; init; } = string.Empty; public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary> /// <summary>
/// Id исходного сообщения в Telegram /// Содержимое записи источника.
/// </summary> /// </summary>
public long? MsgId { get; init; } public SourceContent Content { get; init; } = new();
/// <summary> /// <summary>
/// Текст сообщения. /// Текст сообщения.
@@ -33,25 +34,19 @@ public sealed record QueueItemDto
public string Status { get; init; } = string.Empty; public string Status { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Канал-источник /// Время исходного сообщения, epoch-ms.
/// </summary>
[property: JsonPropertyName("ch")]
public PipelineChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty);
/// <summary>
/// Время исходного сообщения, epoch-ms
/// </summary> /// </summary>
[property: JsonPropertyName("msgAt")] [property: JsonPropertyName("msgAt")]
public long MsgAtMs { get; init; } public long MsgAtMs { get; init; }
/// <summary> /// <summary>
/// Время постановки в очередь /// Время постановки в очередь, epoch-ms.
/// </summary> /// </summary>
[property: JsonPropertyName("queuedAt")] [property: JsonPropertyName("queuedAt")]
public long QueuedAtMs { get; init; } public long QueuedAtMs { get; init; }
/// <summary> /// <summary>
/// Внутренний флаг «возвращено пользователем из отсева» /// Внутренний флаг «возвращено пользователем из отсева».
/// </summary> /// </summary>
[JsonIgnore] [JsonIgnore]
public bool Force { get; init; } public bool Force { get; init; }
@@ -1,47 +1,19 @@
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Pipeline.Application.Models; namespace Deal.Modules.Pipeline.Application.Models;
/// <summary> /// <summary>
/// Команда приёма входящего сообщения — аргумент PipelineIngestService.EnqueueAsync. /// Команда приёма входящей записи — аргумент PipelineIngestService.EnqueueAsync.
/// </summary> /// </summary>
public sealed record QueuedMessage public sealed record QueuedMessage
{ {
/// <summary> /// <summary>
/// Id диалога-источника сообщения /// Запись источника: источник и содержимое.
/// </summary> /// </summary>
public string DialogId { get; init; } = string.Empty; public required SourceItem Item { get; init; }
/// <summary> /// <summary>
/// Имя канала/диалога-источника. /// Флаг «возвращено из отсева».
/// </summary>
public string ChannelName { get; init; } = string.Empty;
/// <summary>
/// Handle канала/диалога-источника.
/// </summary>
public string ChannelHandle { get; init; } = string.Empty;
/// <summary>
/// Цвет канала-источника
/// </summary>
public string ChannelHue { get; init; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram
/// </summary>
public long? MsgId { get; init; }
/// <summary>
/// Текст сообщения
/// </summary>
public string Text { get; init; } = string.Empty;
/// <summary>
/// Время исходного сообщения, epoch-ms; null — приём подставит now.
/// </summary>
public long? MsgAtMs { get; init; }
/// <summary>
/// Флаг «возвращено из отсева»
/// </summary> /// </summary>
public bool Force { get; init; } public bool Force { get; init; }
} }
@@ -1,3 +1,5 @@
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Pipeline.Application.Models; namespace Deal.Modules.Pipeline.Application.Models;
/// <summary> /// <summary>
@@ -6,44 +8,29 @@ namespace Deal.Modules.Pipeline.Application.Models;
public sealed record RejectRecord public sealed record RejectRecord
{ {
/// <summary> /// <summary>
/// Id диалога-источника сообщения /// Источник записи.
/// </summary> /// </summary>
public string DialogId { get; init; } = string.Empty; public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary> /// <summary>
/// Id исходного сообщения в Telegram, либо null. /// Содержимое записи источника.
/// </summary> /// </summary>
public long? MsgId { get; init; } public SourceContent Content { get; init; } = new();
/// <summary> /// <summary>
/// Текст отсеянного сообщения /// Текст отсеянного сообщения.
/// </summary> /// </summary>
public string Text { get; init; } = string.Empty; public string Text { get; init; } = string.Empty;
/// <summary>
/// Имя канала/диалога-источника.
/// </summary>
public string ChannelName { get; init; } = string.Empty;
/// <summary>
/// Handle канала/диалога-источника.
/// </summary>
public string ChannelHandle { get; init; } = string.Empty;
/// <summary>
/// Цвет канала-источника
/// </summary>
public string ChannelHue { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Время исходного сообщения, epoch-ms. /// Время исходного сообщения, epoch-ms.
/// </summary> /// </summary>
public long MsgAtMs { get; init; } public long MsgAtMs { get; init; }
/// <summary> /// <summary>
/// Источник решения /// Кто вынес решение: stop|ml|ai|stale|dup.
/// </summary> /// </summary>
public string Source { get; init; } = string.Empty; public string DecidedBy { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Этап отсева: length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup. /// Этап отсева: length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup.
@@ -56,13 +43,15 @@ public sealed record RejectRecord
public string Reason { get; init; } = string.Empty; public string Reason { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Конкретное слово/фраза стоп-списка, сработавшая правилом /// Конкретное слово/фраза стоп-списка, сработавшая правилом.
/// </summary> /// </summary>
public string Kw { get; init; } = string.Empty; public string Kw { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Детерминированный id записи /// Детерминированный id записи.
/// </summary> /// </summary>
public string? DeterministicId => public string? DeterministicId =>
DialogId.Length > 0 && MsgId is not null ? $"r_{DialogId}_{MsgId}" : null; Source.ExternalId is { Length: > 0 } externalId
? $"r_{Source.Kind}_{Source.OriginRef}_{externalId}"
: null;
} }
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Pipeline.Application.Models; namespace Deal.Modules.Pipeline.Application.Models;
@@ -13,14 +14,14 @@ public sealed record RejectedItemDto
public string Id { get; init; } = string.Empty; public string Id { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Id диалога-источника сообщения. /// Источник записи.
/// </summary> /// </summary>
public string DialogId { get; init; } = string.Empty; public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary> /// <summary>
/// Id исходного сообщения в Telegram, либо null. /// Содержимое записи источника.
/// </summary> /// </summary>
public long? MsgId { get; init; } public SourceContent Content { get; init; } = new();
/// <summary> /// <summary>
/// Текст отсеянного сообщения. /// Текст отсеянного сообщения.
@@ -28,12 +29,12 @@ public sealed record RejectedItemDto
public string Text { get; init; } = string.Empty; public string Text { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Этап отсева (см. словарь подписей <see cref="Deal.Modules.Pipeline.Application.Models.PipelineRejectConstants.StageLabels"/>). /// Этап отсева.
/// </summary> /// </summary>
public string Stage { get; init; } = string.Empty; public string Stage { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Человекочитаемая подпись для UI /// Человекочитаемая подпись для UI.
/// </summary> /// </summary>
public string StageLabel { get; init; } = string.Empty; public string StageLabel { get; init; } = string.Empty;
@@ -43,51 +44,45 @@ public sealed record RejectedItemDto
public string Reason { get; init; } = string.Empty; public string Reason { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Конкретное слово/фраза стоп-списка, сработавшая правилом /// Конкретное слово/фраза стоп-списка, сработавшая правилом.
/// </summary> /// </summary>
public string Kw { get; init; } = string.Empty; public string Kw { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Источник решения /// Кто вынес решение: stop|ml|ai|stale|dup.
/// </summary> /// </summary>
public string Source { get; init; } = string.Empty; public string DecidedBy { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Подпись источника /// Подпись источника решения.
/// </summary> /// </summary>
public string SourceLabel { get; init; } = string.Empty; public string DecidedByLabel { get; init; } = string.Empty;
/// <summary> /// <summary>
/// Канал-источник /// Время исходного сообщения, epoch-ms.
/// </summary>
[property: JsonPropertyName("ch")]
public PipelineChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty);
/// <summary>
/// Время исходного сообщения, epoch-ms
/// </summary> /// </summary>
[property: JsonPropertyName("msgAt")] [property: JsonPropertyName("msgAt")]
public long MsgAtMs { get; init; } public long MsgAtMs { get; init; }
/// <summary> /// <summary>
/// Время отсева, epoch-ms /// Время отсева, epoch-ms.
/// </summary> /// </summary>
[property: JsonPropertyName("rejectedAt")] [property: JsonPropertyName("rejectedAt")]
public long RejectedAtMs { get; init; } public long RejectedAtMs { get; init; }
/// <summary> /// <summary>
/// Флаг «возвращено в обработку» /// Флаг «возвращено в обработку».
/// </summary> /// </summary>
public bool Returned { get; init; } public bool Returned { get; init; }
/// <summary> /// <summary>
/// Время возврата, epoch-ms /// Время возврата, epoch-ms.
/// </summary> /// </summary>
[property: JsonPropertyName("returnedAt")] [property: JsonPropertyName("returnedAt")]
public long? ReturnedAtMs { get; init; } public long? ReturnedAtMs { get; init; }
/// <summary> /// <summary>
/// Причина возврата, указанная пользователем /// Причина возврата, указанная пользователем.
/// </summary> /// </summary>
public string ReturnReason { get; init; } = string.Empty; public string ReturnReason { get; init; } = string.Empty;
} }
@@ -17,15 +17,15 @@ public static class ContactsQualifier
private const int MaxContactLength = 300; private const int MaxContactLength = 300;
private const int MinTgNameLength = 4; private const int MinHandleLength = 4;
private const int MaxTgNameLength = 32; private const int MaxHandleLength = 32;
internal const int MaxNormalizedPhoneLength = 18; internal const int MaxNormalizedPhoneLength = 18;
private const string TgBotSuffix = "bot"; private const string BotSuffix = "bot";
private static readonly IReadOnlySet<string> TgServiceNames = new HashSet<string>(StringComparer.Ordinal) private static readonly IReadOnlySet<string> ReservedProfileNames = new HashSet<string>(StringComparer.Ordinal)
{ {
"joinchat", "share", "s", "c", "addstickers", "addtheme", "proxy", "bg", "login", "joinchat", "share", "s", "c", "addstickers", "addtheme", "proxy", "bg", "login",
}; };
@@ -35,9 +35,9 @@ public static class ContactsQualifier
"teletype.in", "forms.gle", "docs.google.com", "youtube.com", "youtu.be", "clck.ru", "teletype.in", "forms.gle", "docs.google.com", "youtube.com", "youtu.be", "clck.ru",
}; };
private static readonly Regex TelegramNameRe = new(@"\A[A-Za-z0-9_]{4,32}\z", RegexOptions.CultureInvariant); private static readonly Regex ProfileHandleRe = new(@"\A[A-Za-z0-9_]{4,32}\z", RegexOptions.CultureInvariant);
private static readonly Regex TelegramLinkRe = new( private static readonly Regex ProfileLinkRe = new(
@"\Ahttps?://(?:www\.)?t\.me/([A-Za-z0-9_]{4,32})/?\z", @"\Ahttps?://(?:www\.)?t\.me/([A-Za-z0-9_]{4,32})/?\z",
RegexOptions.CultureInvariant); RegexOptions.CultureInvariant);
@@ -78,7 +78,7 @@ public static class ContactsQualifier
/// <summary> /// <summary>
/// Классифицирует один сырой контакт → {type, value} или null. /// Классифицирует один сырой контакт → {type, value} или null.
/// </summary> /// </summary>
/// <param name="raw">Сырое значение контакта («@user», «https://t.me/x», телефон, email, ссылка).</param> /// <param name="raw">Сырое значение контакта («@user», ссылка на профиль, телефон, email, ссылка).</param>
/// <returns>Квалифицированный контакт <see cref="CardContactDto"/> либо null — пусто/мусор/бот/сервисная ссылка/ «постовый» сайт.</returns> /// <returns>Квалифицированный контакт <see cref="CardContactDto"/> либо null — пусто/мусор/бот/сервисная ссылка/ «постовый» сайт.</returns>
public static CardContactDto? Qualify(string? raw) public static CardContactDto? Qualify(string? raw)
{ {
@@ -91,7 +91,7 @@ public static class ContactsQualifier
if (s.StartsWith('@')) if (s.StartsWith('@'))
{ {
string name = s[1..].Trim(); string name = s[1..].Trim();
if (TelegramNameRe.IsMatch(name) && !name.EndsWith(TgBotSuffix, StringComparison.OrdinalIgnoreCase)) if (ProfileHandleRe.IsMatch(name) && !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
{ {
return new CardContactDto("tg", "@" + name); return new CardContactDto("tg", "@" + name);
} }
@@ -99,12 +99,12 @@ public static class ContactsQualifier
return null; return null;
} }
Match link = TelegramLinkRe.Match(s); Match link = ProfileLinkRe.Match(s);
if (link.Success) if (link.Success)
{ {
string name = link.Groups[1].Value; string name = link.Groups[1].Value;
if (!TgServiceNames.Contains(name.ToLowerInvariant()) if (!ReservedProfileNames.Contains(name.ToLowerInvariant())
&& !name.EndsWith(TgBotSuffix, StringComparison.OrdinalIgnoreCase)) && !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
{ {
return new CardContactDto("tg", "@" + name); return new CardContactDto("tg", "@" + name);
} }
@@ -1,5 +1,6 @@
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
@@ -92,10 +93,11 @@ public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings)
IReadOnlyList<CardContactDto> contacts = ContactsQualifier.Build( IReadOnlyList<CardContactDto> contacts = ContactsQualifier.Build(
parsed.Contacts.Select(contact => contact.Value).ToList(), message.Text); parsed.Contacts.Select(contact => contact.Value).ToList(), message.Text);
string contact = MessageTextCleaner.SliceCodePoints(ContactsQualifier.Primary(contacts), MaxPrimaryContactCodePoints); string contact = MessageTextCleaner.SliceCodePoints(ContactsQualifier.Primary(contacts), MaxPrimaryContactCodePoints);
string hue = string.IsNullOrWhiteSpace(message.Channel.Hue) ? SourceDefaults.DefaultHue : message.Channel.Hue;
DateTimeOffset receivedAt = message.MsgAtMs != 0 DateTimeOffset receivedAt = message.MsgAtMs != 0
? DateTimeOffset.FromUnixTimeMilliseconds(message.MsgAtMs) ? DateTimeOffset.FromUnixTimeMilliseconds(message.MsgAtMs)
: DateTimeOffset.UtcNow; // python row.get("msg_at") or now (L501) : message.Source.ReceivedAt != default
? message.Source.ReceivedAt
: DateTimeOffset.UtcNow;
return new CardSnapshot return new CardSnapshot
{ {
@@ -115,13 +117,9 @@ public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings)
ConvCur = converted?.Cur ?? string.Empty, ConvCur = converted?.Cur ?? string.Empty,
Contact = contact, Contact = contact,
Contacts = contacts, Contacts = contacts,
ChannelName = message.Channel.Name, Source = message.Source,
ChannelHandle = message.Channel.Handle, Content = message.Content with { Text = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints) },
ChannelHue = hue,
ReceivedAt = receivedAt, ReceivedAt = receivedAt,
SourceMsg = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints),
SourceDialogId = message.DialogId,
SourceMsgId = message.MsgId,
PrevCol = CardIds.Inbox, PrevCol = CardIds.Inbox,
ArchivedAt = null, ArchivedAt = null,
MatchHits = matchHits, MatchHits = matchHits,
@@ -94,7 +94,7 @@ public sealed class CardReclassifier(
try try
{ {
Pass pass = await CreatePassAsync(ct); Pass pass = await CreatePassAsync(ct);
if (string.IsNullOrWhiteSpace(card.SourceMsg)) if (string.IsNullOrWhiteSpace(card.Content.Text))
{ {
pass.Skipped++; pass.Skipped++;
return Build(pass, attempted: 1, started: false, reason: NoSourceTextReason); return Build(pass, attempted: 1, started: false, reason: NoSourceTextReason);
@@ -121,7 +121,7 @@ public sealed class CardReclassifier(
Pass pass, Pass pass,
CancellationToken ct) CancellationToken ct)
{ {
string text = card.SourceMsg; string text = card.Content.Text ?? string.Empty;
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
pass.Skipped++; pass.Skipped++;
@@ -225,16 +225,15 @@ public sealed class CardReclassifier(
// ── Сборка входа/выхода ──────────────────────────────────────────────── // ── Сборка входа/выхода ────────────────────────────────────────────────
// Собирает строку-сообщение для CardComposer из полей карточки (канал/время/исходный текст). // Собирает строку-сообщение для CardComposer из полей карточки (источник/время/исходный текст).
// card: Карточка-источник (алиасы канала/получателя/даты). // card: Карточка-источник (источник и содержимое сообщения).
// text: Исходный текст (source_msg). // text: Исходный текст записи источника.
// Возвращает: Строка очереди, эквивалентная исходному сообщению карточки. // Возвращает: Строка очереди, эквивалентная исходному сообщению карточки.
private static QueueItemDto BuildMessage(CardDto card, string text) => new() private static QueueItemDto BuildMessage(CardDto card, string text) => new()
{ {
DialogId = card.SourceDialogId, Source = card.Source,
MsgId = card.SourceMsgId, Content = card.Content with { Text = text },
Text = text, Text = text,
Channel = new PipelineChannelDto(card.Channel.Name, card.Channel.Handle, card.Channel.Hue),
MsgAtMs = card.ReceivedAtMs, MsgAtMs = card.ReceivedAtMs,
}; };
@@ -1,6 +1,8 @@
using System.Globalization;
using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
@@ -10,7 +12,7 @@ using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Modules.Pipeline.Application.Services; namespace Deal.Modules.Pipeline.Application.Services;
/// <summary> /// <summary>
/// Ручная проверка/разметка ML на сообщениях канала. /// Ручная проверка/разметка ML на записях источников.
/// </summary> /// </summary>
public sealed class MlReviewService( public sealed class MlReviewService(
IPipelineStore pipelineStore, IPipelineStore pipelineStore,
@@ -86,10 +88,10 @@ public sealed class MlReviewService(
private const double UserPushWeight = 1.0; private const double UserPushWeight = 1.0;
/// <summary> /// <summary>
/// Отбирает сообщения-кандидаты для проверки ML по каналу и/или размеру выборки. /// Отбирает записи-кандидаты для проверки ML по источнику и/или размеру выборки.
/// </summary> /// </summary>
/// <param name="dialogId">Id канала/диалога; пусто — выборка по всем источникам тенанта.</param> /// <param name="dialogId">Оригинал источника (OriginRef); пусто — выборка по всем источникам тенанта.</param>
/// <param name="limit">Сколько последних сообщений вернуть (кламп 1..<see cref="MaxCandidates"/>, дефолт вызывающего).</param> /// <param name="limit">Сколько последних записей вернуть (кламп 1..<see cref="MaxCandidates"/>, дефолт вызывающего).</param>
/// <returns>Кандидаты (свежие первыми): текст, текущий вердикт и мнение ML по каждому.</returns> /// <returns>Кандидаты (свежие первыми): текст, текущий вердикт и мнение ML по каждому.</returns>
public async Task<IReadOnlyList<MlCandidateDto>> CandidatesAsync( public async Task<IReadOnlyList<MlCandidateDto>> CandidatesAsync(
string? dialogId, string? dialogId,
@@ -103,36 +105,36 @@ public sealed class MlReviewService(
IReadOnlyList<RejectedItemDto> rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); IReadOnlyList<RejectedItemDto> rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct);
IReadOnlyList<CardDto> cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct); IReadOnlyList<CardDto> cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct);
// Объединение по (dialogId, msgId): очередь → отсев → карточка (последняя перекрывает предыдущие). // Объединение по ключу дедупа источника: очередь → отсев → карточка (последняя перекрывает предыдущие).
var merged = new Dictionary<(string Dialog, long MsgId), MlCandidateDto>(); var merged = new Dictionary<string, MlCandidateDto>(StringComparer.Ordinal);
foreach (QueueItemDto row in queue) foreach (QueueItemDto row in queue)
{ {
if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId)) if (!IsMessage(row.Source) || !MatchesDialog(dialog, row.Source.OriginRef))
{ {
continue; continue;
} }
merged[(row.DialogId, msgId)] = BuildQueued(row, msgId); merged[row.Source.DedupeKey()] = BuildQueued(row);
} }
foreach (RejectedItemDto row in rejected) foreach (RejectedItemDto row in rejected)
{ {
if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId)) if (!IsMessage(row.Source) || !MatchesDialog(dialog, row.Source.OriginRef))
{ {
continue; continue;
} }
merged[(row.DialogId, msgId)] = BuildRejected(row, msgId); merged[row.Source.DedupeKey()] = BuildRejected(row);
} }
foreach (CardDto card in cardList) foreach (CardDto card in cardList)
{ {
if (card.SourceMsgId is not { } msgId || !MatchesDialog(dialog, card.SourceDialogId)) if (!IsMessage(card.Source) || !MatchesDialog(dialog, card.Source.OriginRef))
{ {
continue; continue;
} }
merged[(card.SourceDialogId, msgId)] = BuildCard(card, msgId); merged[card.Source.DedupeKey()] = BuildCard(card);
} }
List<MlCandidateDto> ordered = merged.Values List<MlCandidateDto> ordered = merged.Values
@@ -150,12 +152,12 @@ public sealed class MlReviewService(
} }
/// <summary> /// <summary>
/// Применяет ручное решение по сообщению /// Применяет ручное решение по записи источника
/// </summary> /// </summary>
/// <param name="dialogId">Id канала/диалога сообщения.</param> /// <param name="dialogId">Оригинал источника (OriginRef) записи.</param>
/// <param name="msgId">Id исходного сообщения.</param> /// <param name="msgId">Внешний id записи в источнике.</param>
/// <param name="action">Действие: <c>skip</c> | <c>spam</c> | <c>board:&lt;id&gt;</c>.</param> /// <param name="action">Действие: <c>skip</c> | <c>spam</c> | <c>board:&lt;id&gt;</c>.</param>
/// <returns>Результат решения; null — исходное сообщение не найдено (404-семантика эндпоинта).</returns> /// <returns>Результат решения; null — исходная запись не найдена (404-семантика эндпоинта).</returns>
public async Task<MlApplyResult?> ApplyAsync( public async Task<MlApplyResult?> ApplyAsync(
string dialogId, string dialogId,
long msgId, long msgId,
@@ -164,12 +166,19 @@ public sealed class MlReviewService(
{ {
string normalized = (action ?? string.Empty).Trim(); string normalized = (action ?? string.Empty).Trim();
string dialog = (dialogId ?? string.Empty).Trim(); string dialog = (dialogId ?? string.Empty).Trim();
string externalId = msgId.ToString(CultureInfo.InvariantCulture);
CardDto? card = await cardStore.GetCardBySourceAsync(dialog, msgId, ct); SourceRef? source = await ResolveSourceAsync(dialog, externalId, ct);
string? text = await FindTextAsync(dialog, msgId, card, ct); if (source is null)
{
return null; // 404: исходная запись не найдена
}
CardDto? card = await cardStore.GetCardBySourceAsync(source, ct);
string? text = await FindTextAsync(dialog, externalId, card, ct);
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
return null; // 404: исходное сообщение не найдено return null; // 404: исходная запись не найдена
} }
if (normalized == ActionSkip) if (normalized == ActionSkip)
@@ -179,28 +188,28 @@ public sealed class MlReviewService(
if (normalized == ActionSpam) if (normalized == ActionSpam)
{ {
return await ApplySpamAsync(dialog, msgId, card, text, ct); return await ApplySpamAsync(dialog, externalId, card, text, ct);
} }
if (normalized.StartsWith(ActionBoardPrefix, StringComparison.Ordinal)) if (normalized.StartsWith(ActionBoardPrefix, StringComparison.Ordinal))
{ {
string boardId = normalized[ActionBoardPrefix.Length..].Trim(); string boardId = normalized[ActionBoardPrefix.Length..].Trim();
return await ApplyBoardAsync(dialog, msgId, boardId, card, text, ct); return await ApplyBoardAsync(boardId, card, text, ct);
} }
return new MlApplyResult(UnknownActionDetail, Ok: false, Learned: false, Moved: null, LeadId: null); return new MlApplyResult(UnknownActionDetail, Ok: false, Learned: false, Moved: null, LeadId: null);
} }
// Действие «спам»: карточку — в корзину (с обучением), сообщение из очереди — в отсев; иначе учим ML. // Действие «спам»: карточку — в корзину (с обучением), запись из очереди — в отсев; иначе учим ML.
// dialog: Id диалога. // dialog: Оригинал источника.
// msgId: Id сообщения. // externalId: Внешний id записи.
// card: Карточка сообщения (null — сообщение не становилось карточкой). // card: Карточка сообщения (null — сообщение не становилось карточкой).
// text: Текст сообщения. // text: Текст сообщения.
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Результат решения. // Возвращает: Результат решения.
private async Task<MlApplyResult> ApplySpamAsync( private async Task<MlApplyResult> ApplySpamAsync(
string dialog, string dialog,
long msgId, string externalId,
CardDto? card, CardDto? card,
string text, string text,
CancellationToken ct) CancellationToken ct)
@@ -215,19 +224,16 @@ public sealed class MlReviewService(
await mlClient.PushAsync(text, MlLearningLabels.Spam, UserPushWeight, ct); await mlClient.PushAsync(text, MlLearningLabels.Spam, UserPushWeight, ct);
// Сообщение ещё в очереди — отсеиваем его (решение пользователя), снимая строку. // Сообщение ещё в очереди — отсеиваем его (решение пользователя), снимая строку.
QueueItemDto? queued = await FindQueuedAsync(dialog, msgId, ct); QueueItemDto? queued = await FindQueuedAsync(dialog, externalId, ct);
if (queued is not null) if (queued is not null)
{ {
await processing.RejectAsync(new RejectRecord await processing.RejectAsync(new RejectRecord
{ {
DialogId = queued.DialogId, Source = queued.Source,
MsgId = queued.MsgId, Content = queued.Content,
Text = queued.Text, Text = queued.Text,
ChannelName = queued.Channel.Name,
ChannelHandle = queued.Channel.Handle,
ChannelHue = queued.Channel.Hue,
MsgAtMs = queued.MsgAtMs, MsgAtMs = queued.MsgAtMs,
Source = ManualSource, DecidedBy = ManualSource,
Stage = ManualSpamStage, Stage = ManualSpamStage,
Reason = ManualSpamReason, Reason = ManualSpamReason,
Kw = string.Empty, Kw = string.Empty,
@@ -239,16 +245,12 @@ public sealed class MlReviewService(
} }
// Действие «в колонку»: карточку — переносим, уже в колонке — только учим; иначе учим ML. // Действие «в колонку»: карточку — переносим, уже в колонке — только учим; иначе учим ML.
// dialog: Id диалога.
// msgId: Id сообщения.
// boardId: Id колонки-цели (inbox или b_...). // boardId: Id колонки-цели (inbox или b_...).
// card: Карточка сообщения (null — сообщение не становилось карточкой). // card: Карточка сообщения (null — сообщение не становилось карточкой).
// text: Текст сообщения. // text: Текст сообщения.
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Результат решения. // Возвращает: Результат решения.
private async Task<MlApplyResult> ApplyBoardAsync( private async Task<MlApplyResult> ApplyBoardAsync(
string dialog,
long msgId,
string boardId, string boardId,
CardDto? card, CardDto? card,
string text, string text,
@@ -281,24 +283,61 @@ public sealed class MlReviewService(
return new MlApplyResult(null, Ok: true, Learned: true, Moved: boardId, LeadId: card.Id); return new MlApplyResult(null, Ok: true, Learned: true, Moved: boardId, LeadId: card.Id);
} }
// Текст исходного сообщения: source_msg карточки, иначе текст строки очереди/записи отсева. // Ссылка на источник записи: очередь → карточка → отсев.
// dialog: Id диалога. // dialog: Оригинал источника.
// msgId: Id сообщения. // externalId: Внешний id записи.
// ct: Токен отмены.
// Возвращает: Ссылку на источник либо null, если записи нет.
private async Task<SourceRef?> ResolveSourceAsync(
string dialog,
string externalId,
CancellationToken ct)
{
QueueItemDto? queued = await FindQueuedAsync(dialog, externalId, ct);
if (queued is not null)
{
return queued.Source;
}
IReadOnlyList<CardDto> cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct);
foreach (CardDto card in cardList)
{
if (MatchesSource(card.Source, dialog, externalId))
{
return card.Source;
}
}
IReadOnlyList<RejectedItemDto> rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct);
foreach (RejectedItemDto row in rejected)
{
if (MatchesSource(row.Source, dialog, externalId))
{
return row.Source;
}
}
return null;
}
// Текст исходного сообщения: текст карточки, иначе текст строки очереди/записи отсева.
// dialog: Оригинал источника.
// externalId: Внешний id записи.
// card: Карточка сообщения (уже прочитана вызывающим). // card: Карточка сообщения (уже прочитана вызывающим).
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Непустой текст либо null, если сообщения нет ни в одном источнике. // Возвращает: Непустой текст либо null, если записи нет ни в одном источнике.
private async Task<string?> FindTextAsync( private async Task<string?> FindTextAsync(
string dialog, string dialog,
long msgId, string externalId,
CardDto? card, CardDto? card,
CancellationToken ct) CancellationToken ct)
{ {
if (card is not null && !string.IsNullOrWhiteSpace(card.SourceMsg)) if (card is not null && !string.IsNullOrWhiteSpace(card.Content.Text))
{ {
return card.SourceMsg; return card.Content.Text;
} }
QueueItemDto? queued = await FindQueuedAsync(dialog, msgId, ct); QueueItemDto? queued = await FindQueuedAsync(dialog, externalId, ct);
if (queued is not null && !string.IsNullOrWhiteSpace(queued.Text)) if (queued is not null && !string.IsNullOrWhiteSpace(queued.Text))
{ {
return queued.Text; return queued.Text;
@@ -307,7 +346,7 @@ public sealed class MlReviewService(
IReadOnlyList<RejectedItemDto> rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); IReadOnlyList<RejectedItemDto> rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct);
foreach (RejectedItemDto row in rejected) foreach (RejectedItemDto row in rejected)
{ {
if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal)) if (MatchesSource(row.Source, dialog, externalId))
{ {
return row.Text; return row.Text;
} }
@@ -316,20 +355,20 @@ public sealed class MlReviewService(
return null; return null;
} }
// Строка очереди сообщения (для отсева при ручной разметке «спам»). // Строка очереди записи (для отсева при ручной разметке «спам»).
// dialog: Id диалога. // dialog: Оригинал источника.
// msgId: Id сообщения. // externalId: Внешний id записи.
// ct: Токен отмены. // ct: Токен отмены.
// Возвращает: Строка очереди либо null — сообщение уже обработано/не в очереди. // Возвращает: Строка очереди либо null — сообщение уже обработано/не в очереди.
private async Task<QueueItemDto?> FindQueuedAsync( private async Task<QueueItemDto?> FindQueuedAsync(
string dialog, string dialog,
long msgId, string externalId,
CancellationToken ct) CancellationToken ct)
{ {
IReadOnlyList<QueueItemDto> queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); IReadOnlyList<QueueItemDto> queue = await pipelineStore.ListAsync(status: null, MaxScan, ct);
foreach (QueueItemDto row in queue) foreach (QueueItemDto row in queue)
{ {
if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal)) if (MatchesSource(row.Source, dialog, externalId))
{ {
return row; return row;
} }
@@ -360,21 +399,34 @@ public sealed class MlReviewService(
} }
} }
// Соответствует ли диалог фильтру канала (пустой фильтр — все диалоги). // Идентифицирована ли запись источника (есть внешний id).
// filter: Запрошенный канал (пусто — без фильтра). // source: Ссылка на источник.
// dialogId: Диалог сообщения. // Возвращает: True — запись адресуема как сообщение источника.
private static bool IsMessage(SourceRef source) => source.ExternalId is { Length: > 0 };
// Соответствует ли источник фильтру оригинала (пустой фильтр — все источники).
// filter: Запрошенный оригинал (пусто — без фильтра).
// originRef: Оригинал источника записи.
// Возвращает: True — кандидат подходит. // Возвращает: True — кандидат подходит.
private static bool MatchesDialog(string filter, string dialogId) => private static bool MatchesDialog(string filter, string? originRef) =>
filter.Length == 0 || string.Equals(filter, dialogId, StringComparison.Ordinal); filter.Length == 0 || string.Equals(filter, originRef ?? string.Empty, StringComparison.Ordinal);
// Совпадает ли источник с оригиналом и внешним id записи.
// source: Ссылка на источник.
// dialog: Оригинал источника.
// externalId: Внешний id записи.
// Возвращает: True — источник указывает на ту же запись.
private static bool MatchesSource(SourceRef source, string dialog, string externalId) =>
string.Equals(source.OriginRef ?? string.Empty, dialog, StringComparison.Ordinal)
&& string.Equals(source.ExternalId ?? string.Empty, externalId, StringComparison.Ordinal);
// Кандидат из строки очереди (вердикт queued). // Кандидат из строки очереди (вердикт queued).
// row: Строка очереди. // row: Строка очереди.
// msgId: Id сообщения.
// Возвращает: Кандидат. // Возвращает: Кандидат.
private static MlCandidateDto BuildQueued(QueueItemDto row, long msgId) => new() private static MlCandidateDto BuildQueued(QueueItemDto row) => new()
{ {
Id = msgId, Source = row.Source,
DialogId = row.DialogId, Content = row.Content,
Text = Truncate(row.Text), Text = Truncate(row.Text),
Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, Time = row.MsgAtMs == 0 ? null : row.MsgAtMs,
Lead = false, Lead = false,
@@ -384,12 +436,11 @@ public sealed class MlReviewService(
// Кандидат из записи отсева (вердикт rejected). // Кандидат из записи отсева (вердикт rejected).
// row: Запись отсева. // row: Запись отсева.
// msgId: Id сообщения.
// Возвращает: Кандидат. // Возвращает: Кандидат.
private static MlCandidateDto BuildRejected(RejectedItemDto row, long msgId) => new() private static MlCandidateDto BuildRejected(RejectedItemDto row) => new()
{ {
Id = msgId, Source = row.Source,
DialogId = row.DialogId, Content = row.Content,
Text = Truncate(row.Text), Text = Truncate(row.Text),
Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, Time = row.MsgAtMs == 0 ? null : row.MsgAtMs,
Lead = false, Lead = false,
@@ -400,13 +451,12 @@ public sealed class MlReviewService(
// Кандидат из карточки (вердикт card). // Кандидат из карточки (вердикт card).
// card: Карточка. // card: Карточка.
// msgId: Id сообщения.
// Возвращает: Кандидат. // Возвращает: Кандидат.
private static MlCandidateDto BuildCard(CardDto card, long msgId) => new() private static MlCandidateDto BuildCard(CardDto card) => new()
{ {
Id = msgId, Source = card.Source,
DialogId = card.SourceDialogId, Content = card.Content,
Text = Truncate(card.SourceMsg), Text = Truncate(card.Content.Text ?? string.Empty),
Time = card.ReceivedAtMs == 0 ? null : card.ReceivedAtMs, Time = card.ReceivedAtMs == 0 ? null : card.ReceivedAtMs,
Lead = true, Lead = true,
Verdict = VerdictCard, Verdict = VerdictCard,
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
@@ -6,42 +7,44 @@ using Deal.Modules.Pipeline.Application.Parse;
namespace Deal.Modules.Pipeline.Application.Services; namespace Deal.Modules.Pipeline.Application.Services;
/// <summary> /// <summary>
/// Приём входящих сообщений пайплайна — постановка сырого сообщения в очередь QueueItems. /// Приём входящих сообщений пайплайна — постановка сырой записи источника в очередь QueueItems.
/// </summary> /// </summary>
public sealed class PipelineIngestService(IPipelineStore store) public sealed class PipelineIngestService(IPipelineStore store)
{ {
private const int MaxQueueTextLength = 6000; private const int MaxQueueTextLength = 6000;
/// <summary> /// <summary>
/// Ставит входящее сообщение в очередь /// Ставит входящую запись источника в очередь
/// </summary> /// </summary>
/// <param name="message">Сырое сообщение: диалог, канальные поля, текст, время; Force — возврат из отсева.</param> /// <param name="message">Запись источника и флаг «возвращено из отсева».</param>
/// <returns>Результат: Id строки (<c>p_...</c>) либо null, если сообщение не принято; Duplicate — дубль диалога.</returns> /// <returns>Результат: Id строки (<c>p_...</c>) либо null, если запись не принята; Duplicate — дубль источника.</returns>
public async Task<PipelineIngestResultDto> EnqueueAsync(QueuedMessage message, CancellationToken ct) public async Task<PipelineIngestResultDto> EnqueueAsync(QueuedMessage message, CancellationToken ct)
{ {
string text = (message.Text ?? string.Empty).Trim(); string text = (message.Item.Content.Text ?? string.Empty).Trim();
if (text.Length == 0 || string.IsNullOrEmpty(message.DialogId)) if (text.Length == 0)
{ {
return new PipelineIngestResultDto(null, false); return new PipelineIngestResultDto(null, false);
} }
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (message.MsgId is not null if (await store.ExistsDuplicateAsync(message.Item.Source, ct))
&& await store.ExistsDuplicateAsync(message.DialogId, message.MsgId, ct))
{ {
return new PipelineIngestResultDto(null, true); return new PipelineIngestResultDto(null, true);
} }
string sliced = MessageTextCleaner.SliceCodePoints(text, MaxQueueTextLength);
DateTimeOffset receivedAt = message.Item.Source.ReceivedAt;
long msgAtMs = receivedAt == default ? 0 : receivedAt.ToUnixTimeMilliseconds();
var item = new QueueItemDto var item = new QueueItemDto
{ {
Id = PrefixId.New(PipelineIdPrefixes.Queue), Id = PrefixId.New(PipelineIdPrefixes.Queue),
DialogId = message.DialogId, Source = message.Item.Source,
MsgId = message.MsgId, Content = message.Item.Content with { Text = sliced },
Text = MessageTextCleaner.SliceCodePoints(text, MaxQueueTextLength), Text = sliced,
Status = PipelineQueueStatuses.New, Status = PipelineQueueStatuses.New,
Channel = new PipelineChannelDto(message.ChannelName, message.ChannelHandle, message.ChannelHue), MsgAtMs = msgAtMs,
MsgAtMs = message.MsgAtMs ?? nowMs,
QueuedAtMs = nowMs, QueuedAtMs = nowMs,
Force = message.Force, Force = message.Force,
}; };
@@ -1,6 +1,6 @@
using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Parse;
@@ -80,7 +80,6 @@ public sealed class PipelineProcessingService(
return store.UpsertAsync(record with return store.UpsertAsync(record with
{ {
Text = MessageTextCleaner.SliceCodePoints(record.Text, MaxRejectedTextLength), Text = MessageTextCleaner.SliceCodePoints(record.Text, MaxRejectedTextLength),
ChannelHue = string.IsNullOrEmpty(record.ChannelHue) ? SourceDefaults.DefaultHue : record.ChannelHue,
Reason = MessageTextCleaner.SliceCodePoints(record.Reason, MaxReasonLength), Reason = MessageTextCleaner.SliceCodePoints(record.Reason, MaxReasonLength),
Kw = MessageTextCleaner.SliceCodePoints(record.Kw, MaxKwLength), Kw = MessageTextCleaner.SliceCodePoints(record.Kw, MaxKwLength),
}, ct); }, ct);
@@ -172,7 +171,7 @@ public sealed class PipelineProcessingService(
return new RejectReturnResultDto(AlreadyReturnedDetail, rejectedId, false, 0); return new RejectReturnResultDto(AlreadyReturnedDetail, rejectedId, false, 0);
} }
if (row.Source == DuplicateSource) if (row.DecidedBy == DuplicateSource)
{ {
return new RejectReturnResultDto(DuplicateReturnDetail, rejectedId, false, 0); return new RejectReturnResultDto(DuplicateReturnDetail, rejectedId, false, 0);
} }
@@ -195,38 +194,21 @@ public sealed class PipelineProcessingService(
DateTimeOffset.FromUnixTimeMilliseconds(nowMs), DateTimeOffset.FromUnixTimeMilliseconds(nowMs),
ct); ct);
long msgAtMs = row.MsgAtMs != 0 ? row.MsgAtMs : nowMs; var extra = new Dictionary<string, string>(row.Source.Extra ?? new Dictionary<string, string>())
string hue = string.IsNullOrEmpty(row.Channel.Hue) ? SourceDefaults.DefaultHue : row.Channel.Hue;
if (row.DialogId.Length > 0 && row.MsgId is not null)
{ {
await ingest.EnqueueAsync(new QueuedMessage [SourceRefs.HueKey] = row.Source.ResolveHue(),
{ };
DialogId = row.DialogId, SourceRef source = row.Source with { Extra = extra };
ChannelName = row.Channel.Name,
ChannelHandle = row.Channel.Handle, await ingest.EnqueueAsync(new QueuedMessage
ChannelHue = hue,
MsgId = row.MsgId,
Text = text,
MsgAtMs = msgAtMs,
Force = true,
}, ct);
}
else
{ {
var item = new QueueItemDto Item = new SourceItem
{ {
Id = PrefixId.New(PipelineIdPrefixes.Queue), Source = source,
DialogId = row.DialogId, Content = row.Content with { Text = text },
MsgId = row.MsgId, },
Text = MessageTextCleaner.SliceCodePoints(text, MaxRejectedTextLength), Force = true,
Status = PipelineQueueStatuses.New, }, ct);
Channel = new PipelineChannelDto(row.Channel.Name, row.Channel.Handle, hue),
MsgAtMs = msgAtMs,
QueuedAtMs = nowMs,
Force = true,
};
await store.AddAsync(item, ct);
}
return new RejectReturnResultDto(null, rejectedId, true, nowMs); return new RejectReturnResultDto(null, rejectedId, true, nowMs);
} }
@@ -20,14 +20,11 @@ public sealed partial class PipelineWorkerService
{ {
return _processing.RejectAsync(new RejectRecord return _processing.RejectAsync(new RejectRecord
{ {
DialogId = row.DialogId, Source = row.Source,
MsgId = row.MsgId, Content = row.Content,
Text = row.Text, Text = row.Text,
ChannelName = row.Channel.Name,
ChannelHandle = row.Channel.Handle,
ChannelHue = row.Channel.Hue,
MsgAtMs = row.MsgAtMs, MsgAtMs = row.MsgAtMs,
Source = source, DecidedBy = source,
Stage = stage, Stage = stage,
Reason = reason, Reason = reason,
Kw = kw, Kw = kw,
@@ -70,7 +70,6 @@ public sealed class RuntimeDepthsCollectorTests
private static QueueItemDto QueueItem(string id) => new() private static QueueItemDto QueueItem(string id) => new()
{ {
Id = id, Id = id,
DialogId = "d_1",
Text = "текст", Text = "текст",
Status = PipelineQueueStatuses.New, Status = PipelineQueueStatuses.New,
}; };
@@ -1,4 +1,6 @@
using System.Globalization;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
@@ -6,6 +8,7 @@ using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services; using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -64,15 +67,17 @@ public sealed class CardComposerTests
snapshot.Contacts); snapshot.Contacts);
Assert.Equal("@ivan_py", snapshot.Contact); Assert.Equal("@ivan_py", snapshot.Contact);
Assert.Equal(new CardChannelDto("Канал найма", "hire_ch", "#ababab"), Channel(snapshot)); Assert.Equal("test", snapshot.Source.Kind);
Assert.Equal("d_1", snapshot.Source.OriginRef);
Assert.Equal("123", snapshot.Source.ExternalId);
Assert.Equal("Канал найма", snapshot.Source.DisplayName);
Assert.Equal("#ababab", snapshot.Source.ResolveHue());
Assert.Equal(1_700_000_000_000L, snapshot.ReceivedAt.ToUnixTimeMilliseconds()); Assert.Equal(1_700_000_000_000L, snapshot.ReceivedAt.ToUnixTimeMilliseconds());
Assert.Equal("ООО Ромашка ищет Python-разработчика на бота для CRM, удалённо.", snapshot.SourceMsg); Assert.Equal("ООО Ромашка ищет Python-разработчика на бота для CRM, удалённо.", snapshot.Content.Text);
Assert.Equal("d_1", snapshot.SourceDialogId);
Assert.Equal(123, snapshot.SourceMsgId);
} }
[Fact] [Fact]
public async Task BuildAsync_LongSourceText_TruncatesSourceMsgTo4000CodePoints() public async Task BuildAsync_LongSourceText_TruncatesContentTextTo4000CodePoints()
{ {
(CardComposer composer, _, _) = Create(); (CardComposer composer, _, _) = Create();
string longText = new string('а', 4500); string longText = new string('а', 4500);
@@ -83,7 +88,7 @@ public sealed class CardComposerTests
cardId: "l_test2", cardId: "l_test2",
CancellationToken.None); CancellationToken.None);
Assert.Equal(4000, snapshot.SourceMsg.EnumerateRunes().Count()); // python text[:4000] (L503) Assert.Equal(4000, (snapshot.Content.Text ?? string.Empty).EnumerateRunes().Count()); // python text[:4000] (L503)
} }
[Fact] [Fact]
@@ -223,20 +228,6 @@ public sealed class CardComposerTests
Assert.Equal("@client_py", snapshot.Contact); // primary_contact: tg важнее почты Assert.Equal("@client_py", snapshot.Contact); // primary_contact: tg важнее почты
} }
[Fact]
public async Task BuildAsync_EmptyChannelHue_DefaultsTo666()
{
(CardComposer composer, _, _) = Create();
CardSnapshot snapshot = await composer.BuildAsync(
Parsed(title: "Заголовок"),
Message("Сообщение", channelHue: string.Empty),
cardId: "l_test11",
CancellationToken.None);
Assert.Equal("#666", snapshot.ChannelHue);
}
// ─── Хелперы ────────────────────────────────────────────────────────────────────────── // ─── Хелперы ──────────────────────────────────────────────────────────────────────────
// Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок). // Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок).
@@ -277,19 +268,17 @@ public sealed class CardComposerTests
string dialogId = "d_1", string dialogId = "d_1",
long? msgId = 123, long? msgId = 123,
string channelName = "Канал найма", string channelName = "Канал найма",
string channelHandle = "hire_ch",
string channelHue = "#ababab", string channelHue = "#ababab",
long msgAtMs = 1_700_000_000_000) => new() long msgAtMs = 1_700_000_000_000) => SourceItemFactory.Queue(
{ "p_1",
Id = "p_1", text,
DialogId = dialogId, PipelineQueueStatuses.Filtered,
MsgId = msgId, origin: dialogId,
Text = text, externalId: msgId?.ToString(CultureInfo.InvariantCulture),
Status = PipelineQueueStatuses.Filtered, name: channelName,
Channel = new PipelineChannelDto(channelName, channelHandle, channelHue), hue: channelHue,
MsgAtMs = msgAtMs, msgAtMs: msgAtMs,
QueuedAtMs = msgAtMs, queuedAtMs: msgAtMs);
};
// Доска с правилами (для сценариев ContainerAccepts-страховки). // Доска с правилами (для сценариев ContainerAccepts-страховки).
// id: Id доски (b_...). // id: Id доски (b_...).
@@ -316,8 +305,4 @@ public sealed class CardComposerTests
private static CardBudgetDto? Converted(CardSnapshot snapshot) => snapshot.ConvCur.Length == 0 private static CardBudgetDto? Converted(CardSnapshot snapshot) => snapshot.ConvCur.Length == 0
? null ? null
: new CardBudgetDto(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur); : new CardBudgetDto(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur);
// Канал снимка (как карточка CardDto.Channel после записи адаптера).
private static CardChannelDto Channel(CardSnapshot snapshot) =>
new(snapshot.ChannelName, snapshot.ChannelHandle, snapshot.ChannelHue);
} }
@@ -1,4 +1,5 @@
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Cards;
@@ -210,7 +211,7 @@ public sealed class CardsServiceTests
} }
[Fact] [Fact]
public async Task Move_EmptySourceMsg_UsesTitleForMatchHitsAndPush() public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush()
{ {
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
@@ -677,7 +678,7 @@ public sealed class CardsServiceTests
} }
[Fact] [Fact]
public async Task Search_FindsByTitleSummaryContactSourceMsg_OrderedByReceivedAtDesc() public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc()
{ {
(CardsService service, FakeKanjStore store, _, _) = Create(); (CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300)); store.SeedCard(Card("l_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
@@ -759,7 +760,7 @@ public sealed class CardsServiceTests
Title = title, Title = title,
Summary = summary, Summary = summary,
Contact = contact, Contact = contact,
SourceMsg = sourceMsg, Content = new SourceContent { Text = sourceMsg },
PrevCol = prevCol, PrevCol = prevCol,
ReceivedAtMs = receivedAtMs, ReceivedAtMs = receivedAtMs,
Comments = comments ?? Array.Empty<CardCommentDto>(), Comments = comments ?? Array.Empty<CardCommentDto>(),
@@ -5,6 +5,7 @@ using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -110,15 +111,10 @@ public sealed class PipelineCardWriterTests
private static QueueItemDto Message( private static QueueItemDto Message(
string text, string text,
string dialogId = "d_1", string dialogId = "d_1",
long? msgId = 123) => new() long? msgId = 123) => SourceItemFactory.Queue(
{ "p_1",
Id = "p_1", text,
DialogId = dialogId, PipelineQueueStatuses.Filtered,
MsgId = msgId, origin: dialogId,
Text = text, externalId: msgId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
Status = PipelineQueueStatuses.Filtered,
Channel = new PipelineChannelDto("Канал", "ch", "#666"),
MsgAtMs = 1_700_000_000_000,
QueuedAtMs = 1_700_000_000_000,
};
} }
@@ -202,18 +202,11 @@ public sealed class PipelineWorkerGrpcAiTests
// id: Id строки (p_…). // id: Id строки (p_…).
// text: Текст сообщения. // text: Текст сообщения.
// Возвращает: Строка как если бы была переведена в filtered воркером. // Возвращает: Строка как если бы была переведена в filtered воркером.
private static QueueItemDto QueueRowFiltered(string id, string text) => new() private static QueueItemDto QueueRowFiltered(string id, string text) => SourceItemFactory.Queue(
{ id,
Id = id, text,
DialogId = "d_1", PipelineQueueStatuses.Filtered,
MsgId = 100, msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
Text = text,
Status = PipelineQueueStatuses.Filtered,
Channel = new PipelineChannelDto("Канал А", "channel_a", "#333"),
MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
QueuedAtMs = 1_700_000_000_000,
Force = false,
};
// Неготовая модель: «не уверена» (ML-слот воркера не решает — сообщение уходит к ИИ). // Неготовая модель: «не уверена» (ML-слот воркера не решает — сообщение уходит к ИИ).
// Возвращает: Фиксированный «не готов/не уверен». // Возвращает: Фиксированный «не готов/не уверен».
@@ -1,6 +1,7 @@
using Deal.Infrastructure.Services; using Deal.Infrastructure.Services;
using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
@@ -44,7 +45,7 @@ public sealed class CardMoverTests
{ {
(CardMover mover, FakeKanjStore store) = Create(); (CardMover mover, FakeKanjStore store) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" }); store.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" });
store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, SourceMsg = "Нужен Python" }); store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Нужен Python" } });
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_py", UserMove, CancellationToken.None); CardMoveResultDto result = await mover.MoveAsync("c_1", "b_py", UserMove, CancellationToken.None);
@@ -58,7 +59,7 @@ public sealed class CardMoverTests
public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError() public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError()
{ {
(CardMover mover, FakeKanjStore store) = Create(); (CardMover mover, FakeKanjStore store) = Create();
store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, SourceMsg = "Текст" }); store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Текст" } });
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None); CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None);
@@ -1,4 +1,5 @@
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
@@ -175,7 +176,7 @@ public class FakeKanjStore : ICardStore
.Where(card => card.Title.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal) .Where(card => card.Title.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| card.Summary.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal) || card.Summary.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| card.Contact.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal) || card.Contact.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| card.SourceMsg.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)) || (card.Content.Text ?? string.Empty).ToLowerInvariant().Contains(lowered, StringComparison.Ordinal))
.OrderByDescending(card => card.ReceivedAtMs) .OrderByDescending(card => card.ReceivedAtMs)
.Take(limit) .Take(limit)
.ToList(); .ToList();
@@ -190,17 +191,20 @@ public class FakeKanjStore : ICardStore
/// <inheritdoc /> /// <inheritdoc />
public Task<CardDto?> GetCardBySourceAsync( public Task<CardDto?> GetCardBySourceAsync(
string dialogId, SourceRef source,
long msgId,
CancellationToken ct) CancellationToken ct)
{ {
if (string.IsNullOrEmpty(dialogId)) if (string.IsNullOrEmpty(source.Kind))
{ {
return Task.FromResult<CardDto?>(null); return Task.FromResult<CardDto?>(null);
} }
string externalId = source.ExternalId ?? string.Empty;
string originRef = source.OriginRef ?? string.Empty;
CardDto? match = _cards CardDto? match = _cards
.Where(card => card.SourceDialogId == dialogId && card.SourceMsgId == msgId) .Where(card => card.Source.Kind == source.Kind
&& (card.Source.ExternalId ?? string.Empty) == externalId
&& (card.Source.OriginRef ?? string.Empty) == originRef)
.OrderByDescending(card => card.ReceivedAtMs) .OrderByDescending(card => card.ReceivedAtMs)
.FirstOrDefault(); .FirstOrDefault();
return Task.FromResult(match); return Task.FromResult(match);
@@ -233,12 +237,10 @@ public class FakeKanjStore : ICardStore
: new CardBudgetDto(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur), : new CardBudgetDto(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur),
Contact = snapshot.Contact, Contact = snapshot.Contact,
Contacts = snapshot.Contacts, Contacts = snapshot.Contacts,
Channel = new CardChannelDto(snapshot.ChannelName, snapshot.ChannelHandle, snapshot.ChannelHue), Source = snapshot.Source,
Content = snapshot.Content,
Time = "только что", Time = "только что",
ReceivedAtMs = snapshot.ReceivedAt.ToUnixTimeMilliseconds(), ReceivedAtMs = snapshot.ReceivedAt.ToUnixTimeMilliseconds(),
SourceMsg = snapshot.SourceMsg,
SourceDialogId = snapshot.SourceDialogId,
SourceMsgId = snapshot.SourceMsgId,
PrevCol = snapshot.PrevCol, PrevCol = snapshot.PrevCol,
MatchHits = snapshot.MatchHits, MatchHits = snapshot.MatchHits,
TzText = snapshot.TzText, TzText = snapshot.TzText,
@@ -418,12 +420,13 @@ public class FakeKanjStore : ICardStore
} }
CardDto? card = _cards.FirstOrDefault(item => item.Id == move.LeadId); CardDto? card = _cards.FirstOrDefault(item => item.Id == move.LeadId);
if (card is null || card.SourceMsg.Length == 0) string sourceText = card?.Content.Text ?? string.Empty;
if (card is null || sourceText.Length == 0)
{ {
continue; continue;
} }
examples.Add(new AiMarkupExampleDto(card.SourceMsg, move.ToCol)); examples.Add(new AiMarkupExampleDto(sourceText, move.ToCol));
} }
return Task.FromResult<IReadOnlyList<AiMarkupExampleDto>>(examples); return Task.FromResult<IReadOnlyList<AiMarkupExampleDto>>(examples);
@@ -844,7 +847,7 @@ public class FakeKanjStore : ICardStore
public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct) public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
{ {
IReadOnlyList<CardDto> result = _cards IReadOnlyList<CardDto> result = _cards
.Where(card => card.Col == KanbanColumns.Inbox && card.SourceMsg != string.Empty) .Where(card => card.Col == KanbanColumns.Inbox && (card.Content.Text ?? string.Empty) != string.Empty)
.OrderByDescending(card => card.ReceivedAtMs) .OrderByDescending(card => card.ReceivedAtMs)
.ToList(); .ToList();
return Task.FromResult(result); return Task.FromResult(result);
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
@@ -174,7 +175,7 @@ public sealed class AiClassifyContextBuilderTests
string board, string board,
string text) string text)
{ {
kanj.SeedCard(new CardDto { Id = cardId, Col = board, SourceMsg = text }); kanj.SeedCard(new CardDto { Id = cardId, Col = board, Content = new SourceContent { Text = text } });
kanj.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult(); kanj.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult();
} }
} }
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
@@ -45,12 +46,11 @@ public class FakePipelineStore : IPipelineStore
/// <inheritdoc /> /// <inheritdoc />
public Task<bool> ExistsDuplicateAsync( public Task<bool> ExistsDuplicateAsync(
string dialogId, SourceRef source,
long? msgId,
CancellationToken ct) CancellationToken ct)
{ {
bool found = msgId is not null string key = source.DedupeKey();
&& _queue.Any(item => item.DialogId == dialogId && item.MsgId == msgId); bool found = _queue.Any(item => item.Source.DedupeKey() == key);
return Task.FromResult(found); return Task.FromResult(found);
} }
@@ -163,7 +163,7 @@ public class FakePipelineStore : IPipelineStore
.Where(item => LikeField(item.Text, query) .Where(item => LikeField(item.Text, query)
|| LikeField(item.Reason, query) || LikeField(item.Reason, query)
|| LikeField(item.Kw, query) || LikeField(item.Kw, query)
|| LikeField(item.Channel.Name, query)) || LikeField(item.Source.DisplayName ?? string.Empty, query))
.OrderByDescending(item => item.RejectedAtMs) .OrderByDescending(item => item.RejectedAtMs)
.Take(limitLike) .Take(limitLike)
.ToList(); .ToList();
@@ -300,16 +300,15 @@ public class FakePipelineStore : IPipelineStore
long rejectedAtMs) => new() long rejectedAtMs) => new()
{ {
Id = id, Id = id,
DialogId = record.DialogId, Source = record.Source,
MsgId = record.MsgId, Content = record.Content,
Text = record.Text, Text = record.Text,
Stage = record.Stage, Stage = record.Stage,
StageLabel = PipelineRejectConstants.StageLabel(record.Stage), StageLabel = PipelineRejectConstants.StageLabel(record.Stage),
Reason = record.Reason, Reason = record.Reason,
Kw = record.Kw, Kw = record.Kw,
Source = record.Source, DecidedBy = record.DecidedBy,
SourceLabel = PipelineRejectConstants.SourceLabel(record.Source), DecidedByLabel = PipelineRejectConstants.SourceLabel(record.DecidedBy),
Channel = new PipelineChannelDto(record.ChannelName, record.ChannelHandle, record.ChannelHue),
MsgAtMs = record.MsgAtMs, MsgAtMs = record.MsgAtMs,
RejectedAtMs = rejectedAtMs, RejectedAtMs = rejectedAtMs,
}; };
@@ -1,3 +1,5 @@
using System.Globalization;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
@@ -5,6 +7,7 @@ using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Kanban; namespace Deal.Tests.Unit.Modules.Kanban;
@@ -30,31 +33,26 @@ public sealed class MlReviewServiceTests
private static QueueItemDto QueueRow( private static QueueItemDto QueueRow(
long msgId, long msgId,
string text, string text,
string dialog = Dialog) => new() string dialog = Dialog) => SourceItemFactory.Queue(
{ $"p_{msgId}",
Id = $"p_{msgId}", text,
DialogId = dialog, PipelineQueueStatuses.New,
MsgId = msgId, origin: dialog,
Text = text, externalId: msgId.ToString(CultureInfo.InvariantCulture),
Status = PipelineQueueStatuses.New, msgAtMs: 1_700_000_000_000 + msgId);
Channel = new PipelineChannelDto("Канал", "ch", "#333"),
MsgAtMs = 1_700_000_000_000 + msgId,
};
private static RejectedItemDto RejectedRow( private static RejectedItemDto RejectedRow(
long msgId, long msgId,
string text, string text,
string dialog = Dialog) => new() string dialog = Dialog) => SourceItemFactory.Rejected(
{ $"r_{msgId}",
Id = $"r_{msgId}", text,
DialogId = dialog, stage: "spam_ml",
MsgId = msgId, decidedBy: "ml",
Text = text, reason: "спам",
Stage = "spam_ml", origin: dialog,
Reason = "спам", externalId: msgId.ToString(CultureInfo.InvariantCulture),
Source = "ml", msgAtMs: 1_700_000_000_000 + msgId);
MsgAtMs = 1_700_000_000_000 + msgId,
};
private static CardDto Card( private static CardDto Card(
long msgId, long msgId,
@@ -65,9 +63,10 @@ public sealed class MlReviewServiceTests
Id = $"c_{msgId}", Id = $"c_{msgId}",
Col = col, Col = col,
Title = text, Title = text,
SourceMsg = text, Source = SourceItemFactory.Source(
SourceDialogId = dialog, origin: dialog,
SourceMsgId = msgId, externalId: msgId.ToString(CultureInfo.InvariantCulture)),
Content = SourceItemFactory.Content(text),
ReceivedAtMs = 1_700_000_000_000 + msgId, ReceivedAtMs = 1_700_000_000_000 + msgId,
}; };
@@ -84,9 +83,9 @@ public sealed class MlReviewServiceTests
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None); IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
Assert.Equal(3, items.Count); Assert.Equal(3, items.Count);
Assert.Contains(items, item => item.Id == 101 && item.Verdict == MlReviewService.VerdictQueued && !item.Lead); Assert.Contains(items, item => item.Source.ExternalId == "101" && item.Verdict == MlReviewService.VerdictQueued && !item.Lead);
Assert.Contains(items, item => item.Id == 102 && item.Verdict == MlReviewService.VerdictRejected); Assert.Contains(items, item => item.Source.ExternalId == "102" && item.Verdict == MlReviewService.VerdictRejected);
MlCandidateDto cardItem = Assert.Single(items, item => item.Id == 103); MlCandidateDto cardItem = Assert.Single(items, item => item.Source.ExternalId == "103");
Assert.True(cardItem.Lead); Assert.True(cardItem.Lead);
Assert.Equal(MlReviewService.VerdictCard, cardItem.Verdict); Assert.Equal(MlReviewService.VerdictCard, cardItem.Verdict);
Assert.Equal("inbox", cardItem.Col); Assert.Equal("inbox", cardItem.Col);
@@ -103,7 +102,7 @@ public sealed class MlReviewServiceTests
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None); IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
MlCandidateDto only = Assert.Single(items); MlCandidateDto only = Assert.Single(items);
Assert.Equal(101, only.Id); Assert.Equal("101", only.Source.ExternalId);
} }
[Fact] [Fact]
@@ -186,7 +185,7 @@ public sealed class MlReviewServiceTests
Assert.Empty(pipeline.Queue); Assert.Empty(pipeline.Queue);
RejectedItemDto rejected = Assert.Single(pipeline.Rejected); RejectedItemDto rejected = Assert.Single(pipeline.Rejected);
Assert.Equal("spam_ml", rejected.Stage); Assert.Equal("spam_ml", rejected.Stage);
Assert.Equal("ml", rejected.Source); Assert.Equal("ml", rejected.DecidedBy);
Assert.Contains(("рекламный текст", "spam", 1.0), ml.Pushed); Assert.Contains(("рекламный текст", "spam", 1.0), ml.Pushed);
} }
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
@@ -10,17 +11,17 @@ public sealed class SuggestHeuristicsTests
{ {
// Карточка «Неразобранного» с текстом (helper; время — порядок окна, по умолчанию 0). // Карточка «Неразобранного» с текстом (helper; время — порядок окна, по умолчанию 0).
// id: Id карточки. // id: Id карточки.
// sourceMsg: Исходное сообщение (source_msg). // sourceText: Исходное сообщение.
// receivedAtMs: Время получения, epoch-ms. // receivedAtMs: Время получения, epoch-ms.
// Возвращает: Карточка для входа PlanColumns. // Возвращает: Карточка для входа PlanColumns.
private static CardDto InboxCard( private static CardDto InboxCard(
string id, string id,
string sourceMsg, string sourceText,
long receivedAtMs = 0) => new() long receivedAtMs = 0) => new()
{ {
Id = id, Id = id,
Col = KanbanColumns.Inbox, Col = KanbanColumns.Inbox,
SourceMsg = sourceMsg, Content = new SourceContent { Text = sourceText },
ReceivedAtMs = receivedAtMs, ReceivedAtMs = receivedAtMs,
}; };
@@ -1,6 +1,8 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Pipeline; namespace Deal.Tests.Unit.Modules.Pipeline;
@@ -19,42 +21,43 @@ public sealed class PipelineIngestServiceTests
PipelineIngestResultDto result = await service.EnqueueAsync( PipelineIngestResultDto result = await service.EnqueueAsync(
Message( Message(
dialogId: "d_1", " Нужен Python-разработчик, удалёнка ",
msgId: 42, origin: "d_1",
text: " Нужен Python-разработчик, удалёнка ", externalId: "42",
channelName: "Канал", name: "Канал",
channelHandle: "ch", hue: "#ababab",
channelHue: "#ababab", receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(1_000)),
msgAtMs: 1_000),
CancellationToken.None); CancellationToken.None);
Assert.StartsWith(PipelineIdPrefixes.Queue, result.Id); Assert.StartsWith(PipelineIdPrefixes.Queue, result.Id);
Assert.False(result.Duplicate); Assert.False(result.Duplicate);
QueueItemDto item = Assert.Single(store.Queue); QueueItemDto item = Assert.Single(store.Queue);
Assert.Equal(result.Id, item.Id); Assert.Equal(result.Id, item.Id);
Assert.Equal("d_1", item.DialogId); Assert.Equal("d_1", item.Source.OriginRef);
Assert.Equal(42, item.MsgId); Assert.Equal("42", item.Source.ExternalId);
Assert.Equal("Нужен Python-разработчик, удалёнка", item.Text); // trim краёв (enqueue L68) Assert.Equal("Канал", item.Source.DisplayName);
Assert.Equal("#ababab", item.Source.ResolveHue());
Assert.Equal("Нужен Python-разработчик, удалёнка", item.Content.Text); // trim краёв (enqueue L68)
Assert.Equal("Нужен Python-разработчик, удалёнка", item.Text);
Assert.Equal(PipelineQueueStatuses.New, item.Status); Assert.Equal(PipelineQueueStatuses.New, item.Status);
Assert.Equal("Канал", item.Channel.Name);
Assert.Equal("ch", item.Channel.Handle);
Assert.Equal("#ababab", item.Channel.Hue);
Assert.Equal(1_000, item.MsgAtMs); Assert.Equal(1_000, item.MsgAtMs);
Assert.True(item.QueuedAtMs > 0, "queuedAt = now (CreatedAt/UpdatedAt строки)"); Assert.True(item.QueuedAtMs > 0, "queuedAt = now (CreatedAt/UpdatedAt строки)");
Assert.False(item.Force); Assert.False(item.Force);
} }
[Fact] [Fact]
public async Task EnqueueAsync_MsgAtAbsent_UsesNow() public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow()
{ {
FakePipelineStore store = new(); FakePipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store);
await service.EnqueueAsync(Message(text: "Сообщение без времени"), CancellationToken.None); await service.EnqueueAsync(
Message("Сообщение без времени", receivedAt: default(DateTimeOffset)),
CancellationToken.None);
QueueItemDto item = Assert.Single(store.Queue); QueueItemDto item = Assert.Single(store.Queue);
Assert.True(item.MsgAtMs > 0, "msgAt = now, если время сообщения не передано (Ruling 2)"); Assert.Equal(0, item.MsgAtMs); // нет времени источника — нулевая отметка (Ruling 2)
Assert.Equal(item.MsgAtMs, item.QueuedAtMs); Assert.True(item.QueuedAtMs > 0, "queuedAt = now");
} }
[Fact] [Fact]
@@ -63,12 +66,12 @@ public sealed class PipelineIngestServiceTests
FakePipelineStore store = new(); FakePipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store);
await service.EnqueueAsync(Message(text: "Возврат из отсева", force: true), CancellationToken.None); await service.EnqueueAsync(Message("Возврат из отсева", force: true), CancellationToken.None);
Assert.True(Assert.Single(store.Queue).Force); Assert.True(Assert.Single(store.Queue).Force);
} }
// ─── Приём: no-op (пустой текст / нет диалога) ────────────────────────── // ─── Приём: no-op (пустой текст) ────────────────────────────────────────
[Fact] [Fact]
public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop() public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop()
@@ -85,63 +88,53 @@ public sealed class PipelineIngestServiceTests
Assert.Empty(store.Queue); // пустой текст — no-op (enqueue L69) Assert.Empty(store.Queue); // пустой текст — no-op (enqueue L69)
} }
// ─── Приём: дубль-гвард (ключ записи источника) ─────────────────────────
[Fact] [Fact]
public async Task EnqueueAsync_MissingDialogId_Noop() public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate()
{ {
FakePipelineStore store = new(); FakePipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store);
QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7");
PipelineIngestResultDto result = await service.EnqueueAsync(Message(text: "Текст без диалога", dialogId: string.Empty), CancellationToken.None); QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7");
Assert.Null(result.Id);
Assert.Empty(store.Queue); // нет dialogId — no-op (enqueue L6970)
}
// ─── Приём: дубль-гвард (dialogId + msgId) ──────────────────────────────
[Fact]
public async Task EnqueueAsync_SameDialogAndMsgIdTwice_SecondIsDuplicate()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
QueuedMessage first = Message(dialogId: "d_1", msgId: 7, text: "Сообщение");
QueuedMessage second = Message(dialogId: "d_1", msgId: 7, text: "Сообщение снова");
PipelineIngestResultDto accepted = await service.EnqueueAsync(first, CancellationToken.None); PipelineIngestResultDto accepted = await service.EnqueueAsync(first, CancellationToken.None);
PipelineIngestResultDto duplicate = await service.EnqueueAsync(second, CancellationToken.None); PipelineIngestResultDto duplicate = await service.EnqueueAsync(second, CancellationToken.None);
Assert.NotNull(accepted.Id); Assert.NotNull(accepted.Id);
Assert.Null(duplicate.Id); Assert.Null(duplicate.Id);
Assert.True(duplicate.Duplicate); // Telethon-повтор не пишем (enqueue L7480) Assert.True(duplicate.Duplicate); // повтор записи источника не пишем
Assert.Single(store.Queue); Assert.Single(store.Queue);
Assert.Equal("Сообщение", Assert.Single(store.Queue).Text); // первая вставка не перезаписана Assert.Equal("Сообщение", Assert.Single(store.Queue).Text); // первая вставка не перезаписана
} }
[Fact] [Fact]
public async Task EnqueueAsync_SameDialogDifferentMsgIds_BothAccepted() public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted()
{ {
FakePipelineStore store = new(); FakePipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store);
await service.EnqueueAsync(Message(dialogId: "d_1", msgId: 1, text: "Первое"), CancellationToken.None); await service.EnqueueAsync(Message("Первое", origin: "d_1", externalId: "1"), CancellationToken.None);
PipelineIngestResultDto second = await service.EnqueueAsync(Message(dialogId: "d_1", msgId: 2, text: "Второе"), CancellationToken.None); PipelineIngestResultDto second = await service.EnqueueAsync(Message("Второе", origin: "d_1", externalId: "2"), CancellationToken.None);
Assert.NotNull(second.Id); Assert.NotNull(second.Id);
Assert.Equal(2, store.Queue.Count); // гвард — по паре dialog+msgId, разные сообщения проходят Assert.Equal(2, store.Queue.Count); // гвард — по ключу источника, разные записи проходят
} }
[Fact] [Fact]
public async Task EnqueueAsync_NoMsgId_SameDialogTwice_BothAccepted() public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate()
{ {
FakePipelineStore store = new(); FakePipelineStore store = new();
PipelineIngestService service = new(store); PipelineIngestService service = new(store);
PipelineIngestResultDto first = await service.EnqueueAsync(Message(dialogId: "d_1", text: "Первое"), CancellationToken.None); PipelineIngestResultDto first = await service.EnqueueAsync(
PipelineIngestResultDto second = await service.EnqueueAsync(Message(dialogId: "d_1", text: "Второе"), CancellationToken.None); Message("Первое", origin: "d_1", externalId: null), CancellationToken.None);
PipelineIngestResultDto second = await service.EnqueueAsync(
Message("Второе", origin: "d_1", externalId: null), CancellationToken.None);
Assert.NotNull(first.Id); Assert.NotNull(first.Id);
Assert.NotNull(second.Id); // дубль-гвард работает только при msgId (enqueue L75: if msg_id) Assert.True(second.Duplicate); // ключ источника без внешнего id совпадает
Assert.Equal(2, store.Queue.Count); Assert.Single(store.Queue);
} }
// ─── Приём: лимит текста 6000 (text[:6000]) ───────────────────────────── // ─── Приём: лимит текста 6000 (text[:6000]) ─────────────────────────────
@@ -153,7 +146,7 @@ public sealed class PipelineIngestServiceTests
PipelineIngestService service = new(store); PipelineIngestService service = new(store);
string longText = new string('а', 7_000); string longText = new string('а', 7_000);
await service.EnqueueAsync(Message(text: longText), CancellationToken.None); await service.EnqueueAsync(Message(longText), CancellationToken.None);
QueueItemDto item = Assert.Single(store.Queue); QueueItemDto item = Assert.Single(store.Queue);
Assert.Equal(6_000, item.Text.EnumerateRunes().Count()); // python text[:6000] — кодовые точки Assert.Equal(6_000, item.Text.EnumerateRunes().Count()); // python text[:6000] — кодовые точки
@@ -164,21 +157,10 @@ public sealed class PipelineIngestServiceTests
// Команда приёма со значениями по умолчанию (сценарий теста перекрывает нужные поля). // Команда приёма со значениями по умолчанию (сценарий теста перекрывает нужные поля).
private static QueuedMessage Message( private static QueuedMessage Message(
string text, string text,
string dialogId = "d_1", string? origin = SourceItemFactory.Origin,
long? msgId = null, string? externalId = SourceItemFactory.ExternalId,
string channelName = "", string? name = SourceItemFactory.Name,
string channelHandle = "", string? hue = SourceItemFactory.Hue,
string channelHue = "", DateTimeOffset? receivedAt = null,
long? msgAtMs = null, bool force = false) => SourceItemFactory.Message(text, origin, externalId, name, hue, receivedAt, force);
bool force = false) => new()
{
DialogId = dialogId,
MsgId = msgId,
Text = text,
ChannelName = channelName,
ChannelHandle = channelHandle,
ChannelHue = channelHue,
MsgAtMs = msgAtMs,
Force = force,
};
} }
@@ -1,7 +1,10 @@
using System.Globalization;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Pipeline; namespace Deal.Tests.Unit.Modules.Pipeline;
@@ -16,7 +19,7 @@ public sealed class PipelineProcessingServiceTests
{ {
(PipelineProcessingService service, FakePipelineStore store, _) = Create(); (PipelineProcessingService service, FakePipelineStore store, _) = Create();
await service.RejectAsync(new RejectRecord { Text = " ", Source = "stop", Stage = "length" }, CancellationToken.None); await service.RejectAsync(new RejectRecord { Text = " ", DecidedBy = "stop", Stage = "length" }, CancellationToken.None);
Assert.Empty(store.Rejected); // record L7374: пустой текст — no-op Assert.Empty(store.Rejected); // record L7374: пустой текст — no-op
} }
@@ -28,23 +31,23 @@ public sealed class PipelineProcessingServiceTests
await service.RejectAsync(new RejectRecord await service.RejectAsync(new RejectRecord
{ {
DialogId = "d_1", Source = SourceItemFactory.Source(origin: "d_1", externalId: "7", name: string.Empty, hue: null),
MsgId = 7, Content = SourceItemFactory.Content(new string('а', 7_000), string.Empty),
Text = new string('а', 7_000), Text = new string('а', 7_000),
Reason = new string('р', 600), Reason = new string('р', 600),
Kw = new string('к', 300), Kw = new string('к', 300),
Source = "stop", DecidedBy = "stop",
Stage = "stop", Stage = "stop",
}, CancellationToken.None); }, CancellationToken.None);
RejectedItemDto row = Assert.Single(store.Rejected); RejectedItemDto row = Assert.Single(store.Rejected);
Assert.Equal("r_d_1_7", row.Id); // детерминированный id по dialog+msgId (Ruling 1, L77) Assert.Equal("r_test_d_1_7", row.Id); // детерминированный id по источнику (Ruling 1, L77)
Assert.Equal(6_000, row.Text.EnumerateRunes().Count()); // text[:6000] Assert.Equal(6_000, row.Text.EnumerateRunes().Count()); // text[:6000]
Assert.Equal(500, row.Reason.EnumerateRunes().Count()); // reason[:500] Assert.Equal(500, row.Reason.EnumerateRunes().Count()); // reason[:500]
Assert.Equal(200, row.Kw.EnumerateRunes().Count()); // kw[:200] Assert.Equal(200, row.Kw.EnumerateRunes().Count()); // kw[:200]
Assert.Equal("#666", row.Channel.Hue); // row.get("ch_hue") or "#666" (L93) Assert.Equal("#666", row.Source.ResolveHue()); // hue не задан — дефолт (L93)
Assert.Equal("stop", row.Stage); Assert.Equal("stop", row.Stage);
Assert.Equal("stop", row.Source); Assert.Equal("stop", row.DecidedBy);
} }
[Fact] [Fact]
@@ -56,7 +59,7 @@ public sealed class PipelineProcessingServiceTests
await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None); await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None);
RejectedItemDto row = Assert.Single(store.Rejected); // повторное отбрасывание обновляет запись (L79–101) RejectedItemDto row = Assert.Single(store.Rejected); // повторное отбрасывание обновляет запись (L79–101)
Assert.Equal("r_d_1_7", row.Id); Assert.Equal("r_test_d_1_7", row.Id);
Assert.Equal("stop", row.Stage); Assert.Equal("stop", row.Stage);
Assert.Equal("стоп-фраза «реклама»", row.Reason); Assert.Equal("стоп-фраза «реклама»", row.Reason);
} }
@@ -127,23 +130,23 @@ public sealed class PipelineProcessingServiceTests
} }
[Fact] [Fact]
public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndChannel() public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndSourceName()
{ {
(PipelineProcessingService service, FakePipelineStore store, _) = Create(); (PipelineProcessingService service, FakePipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_text", text: "Middle Python разработчик", rejectedAtMs: 1_000)); store.SeedRejected(Rejected("r_text", text: "Middle Python разработчик", rejectedAtMs: 1_000));
store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_000)); store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_000));
store.SeedRejected(Rejected("r_kw", kw: "спам", rejectedAtMs: 3_000)); store.SeedRejected(Rejected("r_kw", kw: "спам", rejectedAtMs: 3_000));
store.SeedRejected(Rejected("r_ch", text: "обычный текст", channelName: "Хороший чат", rejectedAtMs: 4_000)); store.SeedRejected(Rejected("r_ch", text: "обычный текст", name: "Хороший чат", rejectedAtMs: 4_000));
RejectedPageDto byText = await service.ListRejectedAsync("Python", 0, 10, CancellationToken.None); RejectedPageDto byText = await service.ListRejectedAsync("Python", 0, 10, CancellationToken.None);
RejectedPageDto byReason = await service.ListRejectedAsync("реклама", 0, 10, CancellationToken.None); RejectedPageDto byReason = await service.ListRejectedAsync("реклама", 0, 10, CancellationToken.None);
RejectedPageDto byKw = await service.ListRejectedAsync("спам", 0, 10, CancellationToken.None); RejectedPageDto byKw = await service.ListRejectedAsync("спам", 0, 10, CancellationToken.None);
RejectedPageDto byChannel = await service.ListRejectedAsync("хороший", 0, 10, CancellationToken.None); RejectedPageDto bySource = await service.ListRejectedAsync("хороший", 0, 10, CancellationToken.None);
Assert.Equal("r_text", Assert.Single(byText.Items).Id); // LIKE по text; регистр q не важен Assert.Equal("r_text", Assert.Single(byText.Items).Id); // LIKE по text; регистр q не важен
Assert.Equal("r_reason", Assert.Single(byReason.Items).Id); // LIKE по reason Assert.Equal("r_reason", Assert.Single(byReason.Items).Id); // LIKE по reason
Assert.Equal("r_kw", Assert.Single(byKw.Items).Id); // LIKE по kw Assert.Equal("r_kw", Assert.Single(byKw.Items).Id); // LIKE по kw
Assert.Equal("r_ch", Assert.Single(byChannel.Items).Id); // LIKE по ch_name Assert.Equal("r_ch", Assert.Single(bySource.Items).Id); // LIKE по подписи источника
} }
[Fact] [Fact]
@@ -193,7 +196,7 @@ public sealed class PipelineProcessingServiceTests
public async Task ReturnAsync_DuplicateSource_Returns400() public async Task ReturnAsync_DuplicateSource_Returns400()
{ {
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", source: "dup", stage: "dup")); store.SeedRejected(Rejected("r_1", decidedBy: "dup", stage: "dup"));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None); RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
@@ -225,13 +228,13 @@ public sealed class PipelineProcessingServiceTests
"r_1", "r_1",
text: " Продвижение в каналах ", text: " Продвижение в каналах ",
stage: "spam_ai", stage: "spam_ai",
source: "ai", decidedBy: "ai",
dialogId: "d_9", origin: "d_9",
msgId: 5, externalId: "5",
channelName: "Спам", name: "Спам",
channelHandle: "spam_ch", hue: "#ff0000",
channelHue: "#ff0000", msgAtMs: 5_000,
msgAtMs: 5_000)); receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(5_000)));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", "не спам, вернуть", CancellationToken.None); RejectReturnResultDto? result = await service.ReturnAsync("r_1", "не спам, вернуть", CancellationToken.None);
@@ -255,12 +258,11 @@ public sealed class PipelineProcessingServiceTests
Assert.StartsWith(PipelineIdPrefixes.Queue, queued.Id); Assert.StartsWith(PipelineIdPrefixes.Queue, queued.Id);
Assert.Equal(PipelineQueueStatuses.New, queued.Status); Assert.Equal(PipelineQueueStatuses.New, queued.Status);
Assert.True(queued.Force); // возврат: причины отсева игнорируются воркером (Ruling 5/10) Assert.True(queued.Force); // возврат: причины отсева игнорируются воркером (Ruling 5/10)
Assert.Equal("d_9", queued.DialogId); Assert.Equal("d_9", queued.Source.OriginRef);
Assert.Equal(5, queued.MsgId); Assert.Equal("5", queued.Source.ExternalId);
Assert.Equal("Продвижение в каналах", queued.Text); Assert.Equal("Продвижение в каналах", queued.Text);
Assert.Equal("Спам", queued.Channel.Name); Assert.Equal("Спам", queued.Source.DisplayName);
Assert.Equal("spam_ch", queued.Channel.Handle); Assert.Equal("#ff0000", queued.Source.ResolveHue());
Assert.Equal("#ff0000", queued.Channel.Hue);
Assert.Equal(5_000, queued.MsgAtMs); // msg_at исходного сообщения сохраняется (L171) Assert.Equal(5_000, queued.MsgAtMs); // msg_at исходного сообщения сохраняется (L171)
Assert.True(queued.QueuedAtMs > 0); Assert.True(queued.QueuedAtMs > 0);
} }
@@ -273,10 +275,11 @@ public sealed class PipelineProcessingServiceTests
"r_1", "r_1",
text: "Короткое сообщение", text: "Короткое сообщение",
stage: "length", stage: "length",
source: "stop", decidedBy: "stop",
dialogId: "d_1", origin: "d_1",
msgId: 3, externalId: "3",
msgAtMs: 9_000)); msgAtMs: 9_000,
receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(9_000)));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None); RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
@@ -294,7 +297,7 @@ public sealed class PipelineProcessingServiceTests
public async Task ReturnAsync_LongReason_TruncatedTo500CodePointsOnRecord() public async Task ReturnAsync_LongReason_TruncatedTo500CodePointsOnRecord()
{ {
(PipelineProcessingService service, FakePipelineStore store, _) = Create(); (PipelineProcessingService service, FakePipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1", text: "Текст", stage: "stale", source: "stale", dialogId: "d_1")); store.SeedRejected(Rejected("r_1", text: "Текст", stage: "stale", decidedBy: "stale", origin: "d_1"));
await service.ReturnAsync("r_1", new string('п', 700), CancellationToken.None); await service.ReturnAsync("r_1", new string('п', 700), CancellationToken.None);
@@ -304,14 +307,19 @@ public sealed class PipelineProcessingServiceTests
} }
[Fact] [Fact]
public async Task ReturnAsync_NoDialogOrMsgId_EnqueuesDirectlyWithFallbackNow() public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue()
{ {
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
store.SeedRejected(Rejected( store.SeedRejected(Rejected(
"r_old", "r_old",
text: "Старая запись до сохранения dialog_id", text: "Старая запись без ссылки на исходное",
stage: "spam_ml", stage: "spam_ml",
source: "ml")); decidedBy: "ml",
origin: string.Empty,
externalId: null,
name: string.Empty,
hue: null,
receivedAt: default(DateTimeOffset)));
RejectReturnResultDto? result = await service.ReturnAsync("r_old", string.Empty, CancellationToken.None); RejectReturnResultDto? result = await service.ReturnAsync("r_old", string.Empty, CancellationToken.None);
@@ -322,10 +330,10 @@ public sealed class PipelineProcessingServiceTests
QueueItemDto queued = Assert.Single(store.Queue); // прямая вставка без ссылки на исходное (L174–192) QueueItemDto queued = Assert.Single(store.Queue); // прямая вставка без ссылки на исходное (L174–192)
Assert.StartsWith(PipelineIdPrefixes.Queue, queued.Id); Assert.StartsWith(PipelineIdPrefixes.Queue, queued.Id);
Assert.True(queued.Force); Assert.True(queued.Force);
Assert.Equal(string.Empty, queued.DialogId); Assert.Equal(string.Empty, queued.Source.OriginRef);
Assert.Null(queued.MsgId); Assert.Null(queued.Source.ExternalId);
Assert.Equal("#666", queued.Channel.Hue); // hue не сохранён — дефолт (L181) Assert.Equal("#666", queued.Source.ResolveHue()); // hue не сохранён — дефолт (L181)
Assert.True(queued.MsgAtMs > 0); // msg_at нет — now (row.get("msg_at") or now) Assert.Equal(0, queued.MsgAtMs); // времени источника нет — нулевая отметка
} }
@@ -387,63 +395,57 @@ public sealed class PipelineProcessingServiceTests
string dialogId, string dialogId,
long? msgId, long? msgId,
string stage, string stage,
string reason) => new() string reason) => SourceItemFactory.Reject(
{ "Текст сообщения",
DialogId = dialogId, origin: dialogId,
MsgId = msgId, externalId: msgId?.ToString(CultureInfo.InvariantCulture),
Text = "Текст сообщения", name: string.Empty,
Stage = stage, hue: null,
Source = "stop", stage: stage,
Reason = reason, decidedBy: "stop",
}; reason: reason);
// Строка очереди для сидирования (id/статус/время постановки). // Строка очереди для сидирования (id/статус/время постановки).
private static QueueItemDto QueueItem( private static QueueItemDto QueueItem(
string id, string id,
string status, string status,
long queuedAtMs) => new() long queuedAtMs) => SourceItemFactory.Queue(
{ id,
Id = id, "текст",
DialogId = "d_1", status,
Text = "текст", msgAtMs: queuedAtMs,
Status = status, queuedAtMs: queuedAtMs);
Channel = new PipelineChannelDto(string.Empty, string.Empty, string.Empty),
MsgAtMs = queuedAtMs,
QueuedAtMs = queuedAtMs,
};
// Запись отсева для сидирования (как строка БД после маппинга адаптера). // Запись отсева для сидирования (как строка БД после маппинга адаптера).
private static RejectedItemDto Rejected( private static RejectedItemDto Rejected(
string id, string id,
string text = "Текст сообщения", string text = "Текст сообщения",
string stage = "length", string stage = "length",
string source = "stop", string decidedBy = "stop",
string dialogId = "",
long? msgId = null,
string reason = "", string reason = "",
string kw = "", string kw = "",
string channelName = "", string? origin = SourceItemFactory.Origin,
string channelHandle = "", string? externalId = SourceItemFactory.ExternalId,
string channelHue = "", string? name = SourceItemFactory.Name,
string? hue = SourceItemFactory.Hue,
long msgAtMs = 0, long msgAtMs = 0,
long rejectedAtMs = 1_000, long rejectedAtMs = 1_000,
bool returned = false, bool returned = false,
string returnReason = "") => new() string returnReason = "",
{ DateTimeOffset? receivedAt = null) => SourceItemFactory.Rejected(
Id = id, id,
DialogId = dialogId, text,
MsgId = msgId, stage,
Text = text, decidedBy,
Stage = stage, reason,
StageLabel = PipelineRejectConstants.StageLabel(stage), kw,
Reason = reason, origin,
Kw = kw, externalId,
Source = source, name,
SourceLabel = PipelineRejectConstants.SourceLabel(source), hue,
Channel = new PipelineChannelDto(channelName, channelHandle, channelHue), msgAtMs,
MsgAtMs = msgAtMs, rejectedAtMs,
RejectedAtMs = rejectedAtMs, returned,
Returned = returned, returnReason,
ReturnReason = returnReason, receivedAt);
};
} }
@@ -275,17 +275,11 @@ public sealed class AdminTickOrchestratorTests
} }
// Строка очереди (status new; сценарий перекрывает текст/время). // Строка очереди (status new; сценарий перекрывает текст/время).
private static QueueItemDto QueueRow(string id, string text) => new() private static QueueItemDto QueueRow(string id, string text) => SourceItemFactory.Queue(
{ id,
Id = id, text,
DialogId = "d_1", PipelineQueueStatuses.New,
MsgId = 100, msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
Text = text,
Status = PipelineQueueStatuses.New,
Channel = new PipelineChannelDto("Канал А", "channel_a", "#333"),
MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
QueuedAtMs = 1_700_000_000_000,
};
// Минимальный разбор карточки (карточку домыслит CardComposer; колонка не назначена → inbox). // Минимальный разбор карточки (карточку домыслит CardComposer; колонка не назначена → inbox).
private static AiParsedCardDto Parsed(string title, bool isVacancy = false) => new( private static AiParsedCardDto Parsed(string title, bool isVacancy = false) => new(
@@ -306,23 +300,16 @@ public sealed class AdminTickOrchestratorTests
Board: null); Board: null);
// Запись отсева для сидирования (как строка БД после маппинга адаптера). // Запись отсева для сидирования (как строка БД после маппинга адаптера).
private static RejectedItemDto Rejected(string id, long rejectedAtMs) => new() private static RejectedItemDto Rejected(string id, long rejectedAtMs) => SourceItemFactory.Rejected(
{ id,
Id = id, origin: string.Empty,
DialogId = string.Empty, externalId: null,
Text = "Текст сообщения", name: string.Empty,
Stage = "length", hue: null,
StageLabel = PipelineRejectConstants.StageLabel("length"), stage: "length",
Reason = string.Empty, decidedBy: "stop",
Kw = string.Empty, msgAtMs: 0,
Source = "stop", rejectedAtMs: rejectedAtMs);
SourceLabel = PipelineRejectConstants.SourceLabel("stop"),
Channel = new PipelineChannelDto(string.Empty, string.Empty, string.Empty),
MsgAtMs = 0,
RejectedAtMs = rejectedAtMs,
Returned = false,
ReturnReason = string.Empty,
};
// Вычитывает все события канала: (тип, JSON) в порядке публикации. // Вычитывает все события канала: (тип, JSON) в порядке публикации.
// subscription: Подписка канала тенанта. // subscription: Подписка канала тенанта.
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
@@ -50,19 +51,19 @@ public sealed class CardReclassifierTests
// Карточка «Неразобранного» с исходным текстом (поля, нужные классификации). // Карточка «Неразобранного» с исходным текстом (поля, нужные классификации).
// id: Id карточки. // id: Id карточки.
// sourceMsg: Исходный текст. // sourceText: Исходный текст.
// contact: Старый контакт карточки. // contact: Старый контакт карточки.
// receivedAtMs: Время получения (для порядка батча). // receivedAtMs: Время получения (для порядка батча).
// Возвращает: Карточка как если бы была сохранена в БД. // Возвращает: Карточка как если бы была сохранена в БД.
private static CardDto Card( private static CardDto Card(
string id, string id,
string sourceMsg, string sourceText,
string contact = "", string contact = "",
long receivedAtMs = 1_700_000_000_000) => new() long receivedAtMs = 1_700_000_000_000) => new()
{ {
Id = id, Id = id,
Col = "inbox", Col = "inbox",
SourceMsg = sourceMsg, Content = new SourceContent { Text = sourceText },
Contact = contact, Contact = contact,
ReceivedAtMs = receivedAtMs, ReceivedAtMs = receivedAtMs,
}; };
@@ -1,6 +1,7 @@
using System.Globalization; using System.Globalization;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
@@ -34,7 +35,7 @@ public sealed class LocalColumnSuggesterTests
Id = id, Id = id,
Col = KanbanColumns.Inbox, Col = KanbanColumns.Inbox,
IsNew = true, IsNew = true,
SourceMsg = text, Content = new SourceContent { Text = text },
ReceivedAtMs = receivedAtMs, ReceivedAtMs = receivedAtMs,
}); });
@@ -225,9 +226,9 @@ public sealed class LocalColumnSuggesterTests
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); (FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_1", "нужен python", store); SeedInbox("l_1", "нужен python", store);
SeedInbox("l_2", "нужен vue", store); SeedInbox("l_2", "нужен vue", store);
store.SeedCard(new CardDto { Id = "l_t1", Col = KanbanColumns.Trash, SourceMsg = "ищу такси" }); store.SeedCard(new CardDto { Id = "l_t1", Col = KanbanColumns.Trash, Content = new SourceContent { Text = "ищу такси" } });
store.SeedCard(new CardDto { Id = "l_t2", Col = KanbanColumns.Trash, SourceMsg = "ищу такси" }); store.SeedCard(new CardDto { Id = "l_t2", Col = KanbanColumns.Trash, Content = new SourceContent { Text = "ищу такси" } });
store.SeedCard(new CardDto { Id = "l_a1", Col = KanbanColumns.Archive, SourceMsg = "ищу такси" }); store.SeedCard(new CardDto { Id = "l_a1", Col = KanbanColumns.Archive, Content = new SourceContent { Text = "ищу такси" } });
SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(CancellationToken.None); SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(CancellationToken.None);
@@ -225,17 +225,12 @@ public sealed class PipelineWorkerSchedulerTests
private static QueueItemDto QueueRow( private static QueueItemDto QueueRow(
string id, string id,
string text, string text,
string dialogId) => new() string dialogId) => SourceItemFactory.Queue(
{ id,
Id = id, text,
DialogId = dialogId, PipelineQueueStatuses.New,
MsgId = 100, origin: dialogId,
Text = text, msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
Status = PipelineQueueStatuses.New,
Channel = new PipelineChannelDto("Канал", "channel", "#333"),
MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
QueuedAtMs = 1_700_000_000_000,
};
// Минимальный разбор карточки (карточку домыслит CardComposer; колонка не назначена → inbox). // Минимальный разбор карточки (карточку домыслит CardComposer; колонка не назначена → inbox).
private static AiParsedCardDto Parsed(string title, bool isVacancy = false) => new( private static AiParsedCardDto Parsed(string title, bool isVacancy = false) => new(
@@ -102,18 +102,12 @@ public sealed class PipelineWorkerServiceTests
string id, string id,
string text, string text,
long? msgAtMs = null, long? msgAtMs = null,
bool force = false) => new() bool force = false) => SourceItemFactory.Queue(
{ id,
Id = id, text,
DialogId = "d_1", PipelineQueueStatuses.New,
MsgId = 100, msgAtMs: msgAtMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
Text = text, force: force);
Status = PipelineQueueStatuses.New,
Channel = new PipelineChannelDto("Канал А", "channel_a", "#333"),
MsgAtMs = msgAtMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
QueuedAtMs = 1_700_000_000_000,
Force = force,
};
private static MlPredictResultDto NotReadyPrediction() => new( private static MlPredictResultDto NotReadyPrediction() => new(
Take: false, Take: false,
@@ -168,7 +162,7 @@ public sealed class PipelineWorkerServiceTests
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("length", rejected.Stage); Assert.Equal("length", rejected.Stage);
Assert.Equal("stop", rejected.Source); Assert.Equal("stop", rejected.DecidedBy);
Assert.Equal("короче 24 символов", rejected.Reason); Assert.Equal("короче 24 символов", rejected.Reason);
Assert.Empty(rejected.Kw); Assert.Empty(rejected.Kw);
Assert.Empty(ctx.PipelineStore.Queue); Assert.Empty(ctx.PipelineStore.Queue);
@@ -190,7 +184,7 @@ public sealed class PipelineWorkerServiceTests
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("stop", rejected.Stage); Assert.Equal("stop", rejected.Stage);
Assert.Equal("stop", rejected.Source); Assert.Equal("stop", rejected.DecidedBy);
Assert.Equal("взаимный пиар", rejected.Kw); Assert.Equal("взаимный пиар", rejected.Kw);
Assert.Equal("стоп-фраза «взаимный пиар»", rejected.Reason); Assert.Equal("стоп-фраза «взаимный пиар»", rejected.Reason);
Assert.Empty(ctx.PipelineStore.Queue); Assert.Empty(ctx.PipelineStore.Queue);
@@ -211,7 +205,7 @@ public sealed class PipelineWorkerServiceTests
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("exclude_kw", rejected.Stage); Assert.Equal("exclude_kw", rejected.Stage);
Assert.Equal("stop", rejected.Source); Assert.Equal("stop", rejected.DecidedBy);
Assert.Equal("крипт", rejected.Kw); Assert.Equal("крипт", rejected.Kw);
Assert.Equal("глобальное исключение: слово/технология «крипт»", rejected.Reason); Assert.Equal("глобальное исключение: слово/технология «крипт»", rejected.Reason);
Assert.Empty(ctx.PipelineStore.Queue); Assert.Empty(ctx.PipelineStore.Queue);
@@ -234,7 +228,7 @@ public sealed class PipelineWorkerServiceTests
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("exclude_budget", rejected.Stage); Assert.Equal("exclude_budget", rejected.Stage);
Assert.Equal("stop", rejected.Source); Assert.Equal("stop", rejected.DecidedBy);
Assert.Equal(0, ctx.MlClient.PredictCalls); Assert.Equal(0, ctx.MlClient.PredictCalls);
} }
@@ -254,7 +248,7 @@ public sealed class PipelineWorkerServiceTests
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("resume", rejected.Stage); Assert.Equal("resume", rejected.Stage);
Assert.Equal("stop", rejected.Source); Assert.Equal("stop", rejected.DecidedBy);
Assert.Equal("готов к собеседованию", rejected.Kw); Assert.Equal("готов к собеседованию", rejected.Kw);
Assert.Equal("резюме соискателя («готов к собеседованию»)", rejected.Reason); Assert.Equal("резюме соискателя («готов к собеседованию»)", rejected.Reason);
Assert.Empty(ctx.PipelineStore.Queue); Assert.Empty(ctx.PipelineStore.Queue);
@@ -276,7 +270,7 @@ public sealed class PipelineWorkerServiceTests
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("dup", rejected.Stage); Assert.Equal("dup", rejected.Stage);
Assert.Equal("dup", rejected.Source); Assert.Equal("dup", rejected.DecidedBy);
Assert.Equal("сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", rejected.Reason); Assert.Equal("сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", rejected.Reason);
Assert.Empty(ctx.PipelineStore.Queue); Assert.Empty(ctx.PipelineStore.Queue);
Assert.Equal(1, result.Staged); Assert.Equal(1, result.Staged);
@@ -300,7 +294,7 @@ public sealed class PipelineWorkerServiceTests
Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("dup", rejected.Stage); Assert.Equal("dup", rejected.Stage);
Assert.Equal("dup", rejected.Source); Assert.Equal("dup", rejected.DecidedBy);
Assert.Equal("сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", rejected.Reason); Assert.Equal("сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", rejected.Reason);
Assert.Empty(ctx.PipelineStore.Queue); Assert.Empty(ctx.PipelineStore.Queue);
Assert.Empty(ctx.KanjStore.CardDtos); // второй карточки нет Assert.Empty(ctx.KanjStore.CardDtos); // второй карточки нет
@@ -326,7 +320,7 @@ public sealed class PipelineWorkerServiceTests
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("stale", rejected.Stage); Assert.Equal("stale", rejected.Stage);
Assert.Equal("stale", rejected.Source); Assert.Equal("stale", rejected.DecidedBy);
Assert.Equal("сообщение старше 1 дн. (срок до автоархива) — не заводим в систему", rejected.Reason); Assert.Equal("сообщение старше 1 дн. (срок до автоархива) — не заводим в систему", rejected.Reason);
Assert.Empty(ctx.PipelineStore.Queue); Assert.Empty(ctx.PipelineStore.Queue);
Assert.Empty(result.CreatedCards); // устаревшие не попадают в систему ни карточкой, ни ML/ИИ Assert.Empty(result.CreatedCards); // устаревшие не попадают в систему ни карточкой, ни ML/ИИ
@@ -377,7 +371,7 @@ public sealed class PipelineWorkerServiceTests
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("budget", rejected.Stage); Assert.Equal("budget", rejected.Stage);
Assert.Equal("stop", rejected.Source); Assert.Equal("stop", rejected.DecidedBy);
Assert.Equal("включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет", rejected.Reason); Assert.Equal("включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет", rejected.Reason);
Assert.Empty(ctx.PipelineStore.Queue); Assert.Empty(ctx.PipelineStore.Queue);
Assert.Empty(ctx.KanjStore.CardDtos); Assert.Empty(ctx.KanjStore.CardDtos);
@@ -409,7 +403,7 @@ public sealed class PipelineWorkerServiceTests
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("spam_ml", rejected.Stage); Assert.Equal("spam_ml", rejected.Stage);
Assert.Equal("ml", rejected.Source); Assert.Equal("ml", rejected.DecidedBy);
Assert.Equal("ML уверен, что это спам/не заявка (score 0.90)", rejected.Reason); Assert.Equal("ML уверен, что это спам/не заявка (score 0.90)", rejected.Reason);
Assert.Empty(ctx.PipelineStore.Queue); Assert.Empty(ctx.PipelineStore.Queue);
Assert.Empty(ctx.KanjStore.CardDtos); Assert.Empty(ctx.KanjStore.CardDtos);
@@ -556,7 +550,7 @@ public sealed class PipelineWorkerServiceTests
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("spam_ai", rejected.Stage); Assert.Equal("spam_ai", rejected.Stage);
Assert.Equal("ai", rejected.Source); Assert.Equal("ai", rejected.DecidedBy);
Assert.Equal("ИИ: не заявка — спам, реклама, скам или служебное сообщение", rejected.Reason); Assert.Equal("ИИ: не заявка — спам, реклама, скам или служебное сообщение", rejected.Reason);
Assert.Empty(ctx.PipelineStore.Queue); Assert.Empty(ctx.PipelineStore.Queue);
Assert.Empty(ctx.KanjStore.CardDtos); Assert.Empty(ctx.KanjStore.CardDtos);
@@ -599,7 +593,7 @@ public sealed class PipelineWorkerServiceTests
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("filter_ai", rejected.Stage); Assert.Equal("filter_ai", rejected.Stage);
Assert.Equal("ai", rejected.Source); Assert.Equal("ai", rejected.DecidedBy);
Assert.Equal("ИИ-фильтр: реклама и скам", rejected.Reason); Assert.Equal("ИИ-фильтр: реклама и скам", rejected.Reason);
Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // заблокировано фильтром — классификацию не звали (L1108) Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // заблокировано фильтром — классификацию не звали (L1108)
Assert.Equal(1, result.AiDrop); Assert.Equal(1, result.AiDrop);
@@ -0,0 +1,201 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Сборка записей источника и строк конвейера для тестов.
/// </summary>
public static class SourceItemFactory
{
/// <summary>
/// Вид источника по умолчанию для сценариев.
/// </summary>
public const string Kind = "test";
/// <summary>
/// Оригинал источника по умолчанию.
/// </summary>
public const string Origin = "d_1";
/// <summary>
/// Внешний id записи по умолчанию.
/// </summary>
public const string ExternalId = "100";
/// <summary>
/// Подпись источника по умолчанию.
/// </summary>
public const string Name = "Канал";
/// <summary>
/// Цвет источника по умолчанию.
/// </summary>
public const string Hue = "#333";
/// <summary>
/// Время получения записи по умолчанию.
/// </summary>
public static readonly DateTimeOffset ReceivedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000);
/// <summary>
/// Ссылка на источник; сценарий перекрывает нужные поля.
/// </summary>
/// <param name="origin">Оригинал (url/путь/диалог); null — без оригинала.</param>
/// <param name="externalId">Внешний id записи; null — записи без id.</param>
/// <param name="name">Подпись источника; null — без подписи.</param>
/// <param name="hue">Цвет источника; null — без цвета.</param>
/// <param name="kind">Вид источника.</param>
/// <param name="receivedAt">Время получения; null — значение по умолчанию.</param>
/// <returns>Ссылка на источник.</returns>
public static SourceRef Source(
string? origin = Origin,
string? externalId = ExternalId,
string? name = Name,
string? hue = Hue,
string kind = Kind,
DateTimeOffset? receivedAt = null)
{
IReadOnlyDictionary<string, string>? extra = hue is null
? null
: new Dictionary<string, string> { [SourceRefs.HueKey] = hue };
return new SourceRef
{
Kind = kind,
OriginRef = origin,
ExternalId = externalId,
DisplayName = name,
ReceivedAt = receivedAt ?? ReceivedAt,
Extra = extra,
};
}
/// <summary>
/// Содержимое записи источника.
/// </summary>
/// <param name="text">Основной текст.</param>
/// <param name="author">Отправитель; null — подпись источника по умолчанию.</param>
/// <returns>Содержимое записи.</returns>
public static SourceContent Content(string? text, string? author = null) => new()
{
Text = text,
Author = author ?? Name,
};
/// <summary>
/// Запись источника: ссылка + содержимое.
/// </summary>
public static SourceItem Item(
string? text,
string? origin = Origin,
string? externalId = ExternalId,
string? name = Name,
string? hue = Hue,
DateTimeOffset? receivedAt = null) => new()
{
Source = Source(origin, externalId, name, hue, Kind, receivedAt),
Content = Content(text, name),
};
/// <summary>
/// Команда приёма записи.
/// </summary>
public static QueuedMessage Message(
string? text,
string? origin = Origin,
string? externalId = ExternalId,
string? name = Name,
string? hue = Hue,
DateTimeOffset? receivedAt = null,
bool force = false) => new()
{
Item = Item(text, origin, externalId, name, hue, receivedAt),
Force = force,
};
/// <summary>
/// Строка очереди для сидирования.
/// </summary>
public static QueueItemDto Queue(
string id,
string? text,
string status,
string? origin = Origin,
string? externalId = ExternalId,
string? name = Name,
string? hue = Hue,
long msgAtMs = 1_700_000_000_000,
long queuedAtMs = 1_700_000_000_000,
bool force = false) => new()
{
Id = id,
Source = Source(origin, externalId, name, hue),
Content = Content(text, name),
Text = text ?? string.Empty,
Status = status,
MsgAtMs = msgAtMs,
QueuedAtMs = queuedAtMs,
Force = force,
};
/// <summary>
/// Команда записи отсева.
/// </summary>
public static RejectRecord Reject(
string? text,
string? origin = Origin,
string? externalId = ExternalId,
string? name = Name,
string? hue = Hue,
string stage = "length",
string decidedBy = "stop",
string reason = "",
string kw = "",
long msgAtMs = 0) => new()
{
Source = Source(origin, externalId, name, hue),
Content = Content(text, name),
Text = text ?? string.Empty,
Stage = stage,
DecidedBy = decidedBy,
Reason = reason,
Kw = kw,
MsgAtMs = msgAtMs,
};
/// <summary>
/// Запись отсева для сидирования.
/// </summary>
public static RejectedItemDto Rejected(
string id,
string? text = "Текст сообщения",
string stage = "length",
string decidedBy = "stop",
string reason = "",
string kw = "",
string? origin = Origin,
string? externalId = ExternalId,
string? name = Name,
string? hue = Hue,
long msgAtMs = 0,
long rejectedAtMs = 1_000,
bool returned = false,
string returnReason = "",
DateTimeOffset? receivedAt = null) => new()
{
Id = id,
Source = Source(origin, externalId, name, hue, Kind, receivedAt),
Content = Content(text, name),
Text = text ?? string.Empty,
Stage = stage,
StageLabel = PipelineRejectConstants.StageLabel(stage),
Reason = reason,
Kw = kw,
DecidedBy = decidedBy,
DecidedByLabel = PipelineRejectConstants.SourceLabel(decidedBy),
MsgAtMs = msgAtMs,
RejectedAtMs = rejectedAtMs,
Returned = returned,
ReturnReason = returnReason,
};
}
@@ -0,0 +1,43 @@
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Тесты производных полей ссылки на источник — SourceRefs.
/// </summary>
public sealed class SourceRefsTests
{
[Fact]
public void ResolveHue_NoExtra_ReturnsDefault()
{
SourceRef source = SourceItemFactory.Source(hue: null);
Assert.Equal(SourceRefs.DefaultHue, source.ResolveHue());
}
[Fact]
public void ResolveHue_EmptyOrWhitespaceExtra_ReturnsDefault()
{
SourceRef empty = SourceItemFactory.Source(hue: string.Empty);
SourceRef whitespace = SourceItemFactory.Source(hue: " ");
Assert.Equal(SourceRefs.DefaultHue, empty.ResolveHue());
Assert.Equal(SourceRefs.DefaultHue, whitespace.ResolveHue());
}
[Fact]
public void ResolveHue_ExtraHue_ReturnsIt()
{
SourceRef source = SourceItemFactory.Source(hue: "#ababab");
Assert.Equal("#ababab", source.ResolveHue());
}
[Fact]
public void DedupeKey_JoinsKindOriginAndExternalId()
{
SourceRef source = SourceItemFactory.Source(origin: "d_1", externalId: "42", kind: "test");
Assert.Equal("test|d_1|42", source.DedupeKey());
}
}
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Deal.Api.Events; using Deal.Api.Events;
using Deal.Grpc.Telegram; using Deal.Grpc.Telegram;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
@@ -54,12 +55,12 @@ public sealed class TelegramIngressServiceTests
Assert.False(reply.Duplicate); Assert.False(reply.Duplicate);
QueueItemDto row = Assert.Single(store.Queue); QueueItemDto row = Assert.Single(store.Queue);
Assert.Equal(DialogId, row.DialogId); Assert.Equal("telegram", row.Source.Kind);
Assert.Equal(7, row.MsgId); Assert.Equal(DialogId, row.Source.OriginRef);
Assert.Equal("7", row.Source.ExternalId);
Assert.Equal("Вакансия Python-разработчика", row.Text); Assert.Equal("Вакансия Python-разработчика", row.Text);
Assert.Equal("Канал", row.Channel.Name); Assert.Equal("Канал", row.Source.DisplayName);
Assert.Equal("kanal_handle", row.Channel.Handle); Assert.Equal("#a33", row.Source.ResolveHue());
Assert.Equal("#a33", row.Channel.Hue);
}); });
} }
+3 -3
View File
@@ -17,7 +17,7 @@ import {
colMeta, colMeta,
fmtDateTime, fmtDateTime,
} from '../../store.js' } from '../../store.js'
import { cleanTitle, tgSourceUrl } from '../../utils.js' import { cleanTitle, sourceOriginUrl } from '../../utils.js'
import { useCardDrag } from '../../composables/dnd.js' import { useCardDrag } from '../../composables/dnd.js'
import Icon from '../Icon.vue' import Icon from '../Icon.vue'
import MoveMenu from './MoveMenu.vue' import MoveMenu from './MoveMenu.vue'
@@ -31,8 +31,8 @@ const props = defineProps({
const isSelected = computed(() => props.mode === 'selected') const isSelected = computed(() => props.mode === 'selected')
const meta = computed(() => colMeta(props.card.containerId)) const meta = computed(() => colMeta(props.card.containerId))
const clean = computed(() => cleanTitle(props.card.title)) const clean = computed(() => cleanTitle(props.card.title))
// ссылка на исходное сообщение в Telegram (null данных для ссылки нет) // ссылка на исходник (null originRef не является абсолютным URL)
const sourceUrl = computed(() => tgSourceUrl(props.card)) const sourceUrl = computed(() => sourceOriginUrl(props.card))
const terminal = computed(() => meta.value?.kind === 'terminal' || !!meta.value?.policy?.isTerminal) const terminal = computed(() => meta.value?.kind === 'terminal' || !!meta.value?.policy?.isTerminal)
// архив и корзина не перетаскиваются и не комментируются, только восстановление // архив и корзина не перетаскиваются и не комментируются, только восстановление
const isService = computed(() => ['archive', 'trash'].includes(props.card.containerId)) const isService = computed(() => ['archive', 'trash'].includes(props.card.containerId))
+19 -27
View File
@@ -28,11 +28,13 @@ import {
fmtDateTime, fmtDateTime,
} from '../../store.js' } from '../../store.js'
import { CURRENCIES } from '../../data.js' import { CURRENCIES } from '../../data.js'
import { contactInfo, renderSourceMessage, tgSourceUrl, copyText, humanSize, toFloat } from '../../utils.js' import { contactInfo, sourceOriginUrl, copyText, humanSize, toFloat } from '../../utils.js'
import { FILE_ICON, FILE_STYLE } from '../ui/fileMeta.js'
import Icon from '../Icon.vue' import Icon from '../Icon.vue'
import MoveMenu from './MoveMenu.vue' import MoveMenu from './MoveMenu.vue'
import BaseModal from '../ui/BaseModal.vue' import BaseModal from '../ui/BaseModal.vue'
import CommentSection from '../ui/CommentSection.vue' import CommentSection from '../ui/CommentSection.vue'
import SourceContentView from '../ui/SourceContentView.vue'
import Field from '../ui/Field.vue' import Field from '../ui/Field.vue'
const card = computed(() => cardById(state.drawerId)) const card = computed(() => cardById(state.drawerId))
@@ -88,9 +90,17 @@ function contactType(c) {
function contactHref(c) { function contactHref(c) {
return contactInfo(c?.value)?.href || null return contactInfo(c?.value)?.href || null
} }
const sourceUrl = computed(() => tgSourceUrl(card.value)) const sourceUrl = computed(() => sourceOriginUrl(card.value))
// HTML исходного сообщения один раз на карточку, а не на каждый ре-рендер // есть что показать в блоке исходника (текст/ссылки/контакты/вложения)
const sourceHtml = computed(() => renderSourceMessage(card.value?.sourceMsg || '')) const hasSource = computed(() => {
const c = card.value?.content
if (!c) return false
return !!(c.text || c.links?.length || c.contacts?.length || c.data?.length)
})
const sourceSummary = computed(() => {
const name = card.value?.source?.displayName || t('drawer.kanal')
return `${name} · ${card.value?.time || ''} ${t('drawer.pokazat-tekst')}`.trim()
})
// «Выбранные»: редактирование // «Выбранные»: редактирование
const historyReversed = computed(() => [...(card.value?.history || [])].reverse()) const historyReversed = computed(() => [...(card.value?.history || [])].reverse())
@@ -108,16 +118,6 @@ const stackInput = ref('')
const tzText = ref('') const tzText = ref('')
const fileInput = ref(null) const fileInput = ref(null)
const FILE_ICON = { image: 'image', video: 'video', audio: 'audio', archive: 'archive', document: 'file', other: 'file' }
const FILE_STYLE = {
image: 'text-sky-400 bg-sky-400/10',
video: 'text-rose-400 bg-rose-400/10',
audio: 'text-amber-400 bg-amber-400/10',
archive: 'text-warn bg-warn/10',
document: 'text-brand bg-brand/10',
other: 'text-mid bg-white/5',
}
// страховка для ссылок из БД: открываем только http(s) // страховка для ссылок из БД: открываем только http(s)
function safeUrl(u) { function safeUrl(u) {
return /^https?:\/\//i.test(String(u || '')) ? u : null return /^https?:\/\//i.test(String(u || '')) ? u : null
@@ -237,8 +237,8 @@ async function copyProjectContact() {
<div class="min-w-0"> <div class="min-w-0">
<div class="text-[13px] font-medium text-hi leading-tight">{{ meta?.name }}</div> <div class="text-[13px] font-medium text-hi leading-tight">{{ meta?.name }}</div>
<div class="text-[11px] text-low num mt-0.5 flex items-center gap-1.5"> <div class="text-[11px] text-low num mt-0.5 flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full inline-block" :style="{ background: card.channel?.hue }"></span> <span class="w-1.5 h-1.5 rounded-full inline-block" :style="{ background: card.source?.extra?.hue || '#666' }"></span>
{{ card.channel?.name }} · {{ card.time }} {{ card.source?.displayName }} · {{ card.time }}
</div> </div>
</div> </div>
<button <button
@@ -516,7 +516,7 @@ async function copyProjectContact() {
</div> </div>
<!-- сообщение-источник (дашборд) --> <!-- сообщение-источник (дашборд) -->
<div v-if="!isSelected && card.sourceMsg" class="rounded-xl border border-edge bg-ink/40 p-3.5"> <div v-if="!isSelected && hasSource" class="rounded-xl border border-edge bg-ink/40 p-3.5">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<div class="text-[10.5px] uppercase tracking-wider text-low/80 font-medium">{{ $t('drawer.ishodnoe-soobshchenie') }}</div> <div class="text-[10.5px] uppercase tracking-wider text-low/80 font-medium">{{ $t('drawer.ishodnoe-soobshchenie') }}</div>
<a <a
@@ -525,21 +525,13 @@ async function copyProjectContact() {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="ml-auto inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-brand/12 text-brand border border-brand/25 text-[12px] font-medium hover:bg-brand/20 transition-colors" class="ml-auto inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-brand/12 text-brand border border-brand/25 text-[12px] font-medium hover:bg-brand/20 transition-colors"
:title="$t('drawer.otkryt-ishodnoe-soobshchenie-v-telegram')" :title="$t('drawer.otkryt-ishodnoe-soobshchenie')"
> >
<Icon name="external" :size="13" /> <Icon name="external" :size="13" />
{{ $t('drawer.otkryt-ishodnik') }} {{ $t('drawer.otkryt-ishodnik') }}
</a> </a>
</div> </div>
<details class="group"> <SourceContentView :content="card.content" :summary="sourceSummary" />
<summary class="cursor-pointer list-none text-[11px] text-low/80 hover:text-mid transition-colors select-none">
{{ card.channel?.name || $t('drawer.kanal') }} · {{ card.time }} {{ $t('drawer.pokazat-tekst') }}
</summary>
<div
class="mt-2 rounded-lg bg-ink/60 border border-white/5 px-3.5 py-3 text-[12px] leading-relaxed text-mid tgmd whitespace-pre-wrap max-h-64 overflow-y-auto"
v-html="sourceHtml"
></div>
</details>
</div> </div>
<!-- ссылки (только «Выбранные») --> <!-- ссылки (только «Выбранные») -->
@@ -1,10 +1,15 @@
<script setup> <script setup>
// Аватар канала/чата: инициалы + стабильный цвет по имени (без картинок). // Аватар канала/чата: инициалы + стабильный цвет. Если источник передал
// Цвет детерминированный (hash от handle), чтобы аватар не «прыгал» между рендерами. // собственный цвет (`source.extra.hue`) используем его, иначе цвет
// детерминированный (hash от handle), чтобы аватар не «прыгал» между рендерами.
import { computed } from 'vue'
const props = defineProps({ const props = defineProps({
name: { type: String, default: '' }, name: { type: String, default: '' },
// handle без «@» участвует в расчёте цвета // handle без «@» участвует в расчёте цвета, если свой цвет не задан
handle: { type: String, default: '' }, handle: { type: String, default: '' },
// цвет источника (напр. `extra.hue`); пусто берём из hash(handle)
color: { type: String, default: '' },
// размер в Tailwind-классах (w-*/h-*), по умолчанию 24px // размер в Tailwind-классах (w-*/h-*), по умолчанию 24px
size: { type: String, default: 'w-6 h-6' }, size: { type: String, default: 'w-6 h-6' },
// размер шрифта инициалов // размер шрифта инициалов
@@ -25,16 +30,22 @@ function hue(handle) {
for (const ch of handle || '?') h = (h * 31 + ch.codePointAt(0)) % 360 for (const ch of handle || '?') h = (h * 31 + ch.codePointAt(0)) % 360
return h return h
} }
const avatarStyle = computed(() =>
props.color
? { background: props.color, color: '#fff' }
: {
background: `hsl(${hue(props.handle)} 28% 50% / 0.2)`,
color: `hsl(${hue(props.handle)} 45% 65%)`,
},
)
</script> </script>
<template> <template>
<span <span
class="rounded-full grid place-items-center font-bold shrink-0" class="rounded-full grid place-items-center font-bold shrink-0"
:class="[size, text]" :class="[size, text]"
:style="{ :style="avatarStyle"
background: `hsl(${hue(handle)} 28% 50% / 0.2)`,
color: `hsl(${hue(handle)} 45% 65%)`,
}"
> >
{{ initials(name) }} {{ initials(name) }}
</span> </span>
@@ -1,18 +1,28 @@
<script setup> <script setup>
// «Подробнее» записи пайплайна (очередь/отсев): метаданные сообщения, ссылка // «Подробнее» записи пайплайна (очередь/отсев): метаданные записи, ссылка
// на исходник в Telegram и само исходное сообщение с форматированием. // на оригинал и универсальное содержимое источника. Одинаковый блок у карточек
// Одинаковый блок у карточек очереди и отсева вынесен, чтобы шаблоны не // очереди и отсева вынесен, чтобы шаблоны не дублировали разметку.
// дублировали ~30 строк. import { computed } from 'vue'
import Icon from '../Icon.vue' import Icon from '../Icon.vue'
import SourceContentView from './SourceContentView.vue'
defineProps({ const props = defineProps({
// [{ label, text, mono?, num? }] строки метаданных (id, время) // [{ label, text, mono?, num? }] строки метаданных (id, время)
meta: { type: Array, default: () => [] }, meta: { type: Array, default: () => [] },
// SourceContent: текст/ссылки/контакты/вложения
content: { type: Object, default: null },
// HTML исходного сообщения (уже отрендерен и мемоизирован родителем) // HTML исходного сообщения (уже отрендерен и мемоизирован родителем)
sourceHtml: { type: String, default: '' }, sourceHtml: { type: String, default: '' },
// ссылка «Открыть исходник в Telegram» (null не показывать) // ссылка на оригинал (null не показывать)
sourceUrl: { type: String, default: null }, sourceUrl: { type: String, default: null },
}) })
// показываем блок содержимого, только если есть что рендерить
const hasContent = computed(() => {
if (props.sourceHtml) return true
const c = props.content
return !!(c && (c.text || c.links?.length || c.contacts?.length || c.data?.length))
})
</script> </script>
<template> <template>
@@ -41,16 +51,12 @@ defineProps({
</a> </a>
</div> </div>
<details v-if="sourceHtml" class="mt-1.5 group"> <SourceContentView
<summary v-if="hasContent"
class="cursor-pointer list-none text-[11px] text-low/70 hover:text-mid transition-colors select-none" class="mt-1.5"
> :content="content"
{{ $t('common.sourceMessage') }} :html="sourceHtml"
</summary> :summary="$t('common.sourceMessage')"
<div />
class="mt-1.5 rounded-lg bg-ink/60 border border-white/5 px-3 py-2.5 text-[12px] leading-relaxed text-mid tgmd whitespace-pre-wrap max-h-64 overflow-y-auto"
v-html="sourceHtml"
></div>
</details>
</div> </div>
</template> </template>
@@ -0,0 +1,168 @@
<script setup>
// Универсальный просмотрщик исходника: текст под спойлером, ссылки, контакты и
// вложения (DataRef). Не знает про конкретные источники работает с generic
// SourceContent. Одинаково используется в карточке и в деталях пайплайна.
import { computed } from 'vue'
import { renderSourceMessage, humanSize } from '../../utils.js'
import { fileIcon, fileStyle, safeHref } from './fileMeta.js'
import Icon from '../Icon.vue'
const props = defineProps({
// SourceContent: { text?, html?, links?, contacts?, data?, ... }
content: { type: Object, default: null },
// Заранее отрендеренный HTML текста (мемоизация у родителя); если null
// рендерим из content.text внутри.
html: { type: String, default: null },
// Подпись спойлера с текстом («канал · время показать текст»).
summary: { type: String, default: '' },
})
const renderedHtml = computed(() =>
props.html != null ? props.html : renderSourceMessage(props.content?.text || ''),
)
const links = computed(() =>
(Array.isArray(props.content?.links) ? props.content.links : []).map((u) => String(u || '')).filter(Boolean),
)
const contacts = computed(() =>
Array.isArray(props.content?.contacts) ? props.content.contacts.filter(Boolean) : [],
)
const attachments = computed(() => {
const list = Array.isArray(props.content?.data) ? [...props.content.data] : []
return list.sort((a, b) => (a?.order ?? 0) - (b?.order ?? 0))
})
function isImage(d) {
return d?.kind === 'image'
}
function imageSrc(d) {
return isImage(d) ? safeHref(d?.ref) : null
}
function telHref(phone) {
const digits = String(phone || '').replace(/[^+\d]/g, '')
return digits ? `tel:${digits}` : null
}
function fmtDuration(sec) {
const total = Math.max(0, Math.round(Number(sec) || 0))
const m = Math.floor(total / 60)
const s = String(total % 60).padStart(2, '0')
return `${m}:${s}`
}
function attachmentMeta(d) {
const parts = []
if (d?.mimeType) parts.push(d.mimeType)
if (d?.size) parts.push(humanSize(d.size))
if (d?.width && d?.height) parts.push(`${d.width}×${d.height}`)
if (d?.durationSec) parts.push(fmtDuration(d.durationSec))
return parts.join(' · ')
}
</script>
<template>
<div class="space-y-2.5">
<!-- текст исходника -->
<details v-if="renderedHtml" class="group">
<summary
class="cursor-pointer list-none text-[11px] text-low/80 hover:text-mid transition-colors select-none"
>
{{ summary || $t('common.sourceMessage') }}
</summary>
<div
class="mt-2 rounded-lg bg-ink/60 border border-white/5 px-3.5 py-3 text-[12px] leading-relaxed text-mid tgmd whitespace-pre-wrap max-h-64 overflow-y-auto"
v-html="renderedHtml"
></div>
</details>
<!-- ссылки -->
<div v-if="links.length">
<div class="text-[10.5px] uppercase tracking-wider text-low/80 font-medium mb-1.5">
{{ $t('source.links') }}
</div>
<div class="space-y-1">
<a
v-for="(u, i) in links"
:key="i"
:href="safeHref(u)"
target="_blank"
rel="noreferrer"
class="flex items-center gap-2 rounded-lg bg-ink/50 border border-white/5 px-3 py-1.5 text-[12px] text-brand hover:bg-brand/10 transition-colors"
>
<Icon name="link" :size="13" class="shrink-0" />
<span class="truncate">{{ u }}</span>
</a>
</div>
</div>
<!-- контакты -->
<div v-if="contacts.length">
<div class="text-[10.5px] uppercase tracking-wider text-low/80 font-medium mb-1.5">
{{ $t('source.contacts') }}
</div>
<div class="space-y-1">
<div
v-for="(c, i) in contacts"
:key="i"
class="rounded-lg bg-ink/50 border border-white/5 px-3 py-1.5 text-[12px] flex flex-wrap items-center gap-x-3 gap-y-0.5"
>
<span class="text-hi">{{ c.name || c.kind || $t('source.contact') }}</span>
<span v-if="c.name && c.kind" class="text-[10.5px] text-low/70 uppercase tracking-wide">{{ c.kind }}</span>
<a v-if="c.email" :href="`mailto:${c.email}`" class="text-brand hover:underline">{{ c.email }}</a>
<a v-if="c.phone" :href="telHref(c.phone)" class="text-brand hover:underline">{{ c.phone }}</a>
<a
v-if="safeHref(c.url)"
:href="c.url"
target="_blank"
rel="noreferrer"
class="text-brand hover:underline truncate flex items-center gap-1"
>
<Icon name="external" :size="11" class="shrink-0" />
<span class="truncate">{{ c.url }}</span>
</a>
</div>
</div>
</div>
<!-- вложения -->
<div v-if="attachments.length">
<div class="text-[10.5px] uppercase tracking-wider text-low/80 font-medium mb-1.5">
{{ $t('source.attachments') }}
</div>
<div class="space-y-1.5">
<div v-for="d in attachments" :key="d.id || d.ref" class="rounded-lg bg-ink/50 border border-white/5 overflow-hidden">
<a v-if="imageSrc(d)" :href="imageSrc(d)" target="_blank" rel="noreferrer" class="block">
<img :src="imageSrc(d)" :alt="d.fileName || d.caption || ''" class="max-h-56 w-full object-cover" />
</a>
<div class="flex items-center gap-2.5 px-3 py-2">
<span class="w-8 h-8 rounded-lg grid place-items-center shrink-0" :class="fileStyle(d.kind)">
<Icon :name="fileIcon(d.kind)" :size="15" />
</span>
<span class="min-w-0 flex-1">
<span class="block text-[12px] text-hi truncate">
{{ d.fileName || d.caption || $t('source.attachment') }}
</span>
<span v-if="attachmentMeta(d)" class="block text-[10.5px] text-low mt-0.5 num">
{{ attachmentMeta(d) }}
</span>
</span>
<a
v-if="safeHref(d.ref)"
:href="d.ref"
target="_blank"
rel="noreferrer"
class="text-low hover:text-brand transition-colors shrink-0 p-1"
:title="$t('source.download')"
>
<Icon name="download" :size="14" />
</a>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
// ─── Типы вложений: иконка и цвет ─────────────────────────────────────────
// Kind заполняет сервис данных (image/video/audio/document/archive/other).
// Общая карта для файлов карточки и вложений исходника — без дублирования.
export const FILE_ICON = {
image: 'image',
video: 'video',
audio: 'audio',
archive: 'archive',
document: 'file',
other: 'file',
}
export const FILE_STYLE = {
image: 'text-sky-400 bg-sky-400/10',
video: 'text-rose-400 bg-rose-400/10',
audio: 'text-amber-400 bg-amber-400/10',
archive: 'text-warn bg-warn/10',
document: 'text-brand bg-brand/10',
other: 'text-mid bg-white/5',
}
export function fileIcon(kind) {
return FILE_ICON[kind] || FILE_ICON.other
}
export function fileStyle(kind) {
return FILE_STYLE[kind] || FILE_STYLE.other
}
// Только абсолютный http(s)-URL годится для href/src (страховка от мусора).
export function safeHref(url) {
return /^https?:\/\//i.test(String(url || '')) ? url : null
}
+13 -2
View File
@@ -148,7 +148,7 @@ export const ru = {
"prev": "Назад", "prev": "Назад",
"next": "Вперёд", "next": "Вперёд",
"range": "{from}{to} из {total}", "range": "{from}{to} из {total}",
"openSource": "Открыть исходник в Telegram", "openSource": "Открыть исходник",
"sourceMessage": "Исходное сообщение (с форматированием)", "sourceMessage": "Исходное сообщение (с форматированием)",
"comments": "Комментарии", "comments": "Комментарии",
"noComments": "Комментариев пока нет", "noComments": "Комментариев пока нет",
@@ -392,8 +392,19 @@ export const ru = {
"colSaved": "Колонка «{name}» сохранена", "colSaved": "Колонка «{name}» сохранена",
"colCreated": "Колонка «{name}» создана — заявки будут скапливаться здесь" "colCreated": "Колонка «{name}» создана — заявки будут скапливаться здесь"
}, },
"source": {
"links": "Ссылки",
"contacts": "Контакты",
"contact": "Контакт",
"attachments": "Вложения",
"attachment": "Вложение",
"download": "Скачать"
},
"processing": { "processing": {
"server-nedostupen-proverte-chto-backend": "Сервер недоступен — проверьте, что backend запущен (порт 8000)", "server-nedostupen-proverte-chto-backend": "Сервер недоступен — проверьте, что backend запущен (порт 8000)",
"author": "Автор",
"externalId": "ID в источнике",
"originRef": "Оригинал",
"zapis-otseva-udalena": "Запись отсева удалена", "zapis-otseva-udalena": "Запись отсева удалена",
"udaleno": "Удалено", "udaleno": "Удалено",
"vozvrashcheno-v-obrabotku-prichiny-otseva": "Возвращено в обработку — причины отсева сняты", "vozvrashcheno-v-obrabotku-prichiny-otseva": "Возвращено в обработку — причины отсева сняты",
@@ -1018,7 +1029,7 @@ export const ru = {
"otkryt": "Открыть", "otkryt": "Открыть",
"kopirovat": "Копировать", "kopirovat": "Копировать",
"ishodnoe-soobshchenie": "Исходное сообщение", "ishodnoe-soobshchenie": "Исходное сообщение",
"otkryt-ishodnoe-soobshchenie-v-telegram": "Открыть исходное сообщение в Telegram", "otkryt-ishodnoe-soobshchenie": "Открыть исходное сообщение",
"otkryt-ishodnik": "Открыть исходник", "otkryt-ishodnik": "Открыть исходник",
"pokazat-tekst": "— показать текст", "pokazat-tekst": "— показать текст",
"udalit-ssylku": "Удалить ссылку", "udalit-ssylku": "Удалить ссылку",
+7 -11
View File
@@ -152,17 +152,13 @@ export function contactInfo(contact = '') {
return null return null
} }
// Ссылка на исходное сообщение в Telegram по сохранённым id (если доступна) // Ссылка на исходник: отдаём `source.originRef`, только если это абсолютный
export function tgSourceUrl(lead) { // http(s)-URL. Глубокие ссылки конкретных источников здесь не собираем — их
if (!lead || !lead.sourceMsgId) return null // формирует сам источник и кладёт в originRef. Иначе ссылка недоступна.
const id = lead.sourceMsgId export function sourceOriginUrl(item) {
const handle = lead.channel?.handle || lead.ch?.handle const ref = item?.source?.originRef
if (handle) return `https://t.me/${handle}/${id}` const url = typeof ref === 'string' ? ref.trim() : ''
// приватный канал: t.me/c/<peer>/<msg> (без префикса -100) return /^https?:\/\//i.test(url) ? url : null
const dialog = String(lead.sourceDialogId || '')
const peer = dialog.startsWith('-100') ? dialog.slice(4) : dialog
if (/^\d+$/.test(peer)) return `https://t.me/c/${peer}/${id}`
return null
} }
// Чистый заголовок карточки: без markdown-ссылок и служебной разметки // Чистый заголовок карточки: без markdown-ссылок и служебной разметки
+24 -23
View File
@@ -17,7 +17,7 @@ import {
relTime, relTime,
} from '../store.js' } from '../store.js'
import Icon from '../components/Icon.vue' import Icon from '../components/Icon.vue'
import { renderSourceMessage, tgSourceUrl } from '../utils.js' import { renderSourceMessage, sourceOriginUrl } from '../utils.js'
import BaseModal from '../components/ui/BaseModal.vue' import BaseModal from '../components/ui/BaseModal.vue'
import ChannelAvatar from '../components/ui/ChannelAvatar.vue' import ChannelAvatar from '../components/ui/ChannelAvatar.vue'
import PipelineDetails from '../components/ui/PipelineDetails.vue' import PipelineDetails from '../components/ui/PipelineDetails.vue'
@@ -50,8 +50,11 @@ function srcHtml(item) {
// строки метаданных для «Подробнее» очередь и отсев почти одинаковы // строки метаданных для «Подробнее» очередь и отсев почти одинаковы
function metaRows(item, extra = []) { function metaRows(item, extra = []) {
const rows = [] const rows = []
if (item.dialogId) rows.push({ label: t('processing.dialog'), text: item.dialogId, mono: true }) const src = item.source || {}
if (item.msgId) rows.push({ label: t('processing.id-soobshcheniya'), text: item.msgId, mono: true }) if (src.displayName) rows.push({ label: t('processing.istochnik'), text: src.displayName })
if (src.author) rows.push({ label: t('processing.author'), text: src.author })
if (src.externalId) rows.push({ label: t('processing.externalId'), text: src.externalId, mono: true })
if (src.originRef) rows.push({ label: t('processing.originRef'), text: src.originRef, mono: true })
if (item.msgAt) rows.push({ label: t('processing.otpravleno'), text: fmtDateTimeCompact(item.msgAt), num: true }) if (item.msgAt) rows.push({ label: t('processing.otpravleno'), text: fmtDateTimeCompact(item.msgAt), num: true })
for (const e of extra) { for (const e of extra) {
if (item[e.key]) rows.push({ label: e.label, text: fmtDateTimeCompact(item[e.key]), num: true }) if (item[e.key]) rows.push({ label: e.label, text: fmtDateTimeCompact(item[e.key]), num: true })
@@ -90,13 +93,9 @@ function toggleDetail(id) {
detailOpen.value = s detailOpen.value = s
} }
// ссылка на исходное сообщение в Telegram: @handle/msg или приватный канал // ссылка на исходник: originRef источника, если это абсолютный URL
function sourceUrl(item) { function sourceUrl(item) {
return tgSourceUrl({ return sourceOriginUrl(item)
ch: item.ch,
sourceDialogId: item.dialogId,
sourceMsgId: item.msgId,
})
} }
async function reloadAll() { async function reloadAll() {
@@ -134,7 +133,7 @@ async function onClearAll() {
// можно ли вернуть запись в обработку: повтор возвращать нечего (карточка уже есть) // можно ли вернуть запись в обработку: повтор возвращать нечего (карточка уже есть)
function canReturn(r) { function canReturn(r) {
return !!r && !r.returned && r.source !== 'dup' return !!r && !r.returned && r.decidedBy !== 'dup'
} }
function openReturn(item) { function openReturn(item) {
@@ -309,11 +308,11 @@ onBeforeUnmount(() => {
:class="m.status === 'filtered' ? 'border-warn/20' : ''" :class="m.status === 'filtered' ? 'border-warn/20' : ''"
> >
<div class="flex items-center gap-2 mb-1.5"> <div class="flex items-center gap-2 mb-1.5">
<ChannelAvatar :name="m.ch.name" :handle="m.ch.handle" /> <ChannelAvatar :name="m.source?.displayName || ''" :handle="m.source?.displayName || ''" :color="m.source?.extra?.hue" />
<span class="text-[12px] font-medium text-mid truncate" :title="m.ch.handle ? '@' + m.ch.handle : ''"> <span class="text-[12px] font-medium text-mid truncate" :title="m.source?.originRef || ''">
{{ m.ch.name }} {{ m.source?.displayName || $t('processing.istochnik') }}
<template v-if="m.ch.handle"> <template v-if="m.source?.originRef">
<span class="text-low/60 font-mono">@{{ m.ch.handle }}</span> <span class="text-low/60 font-mono">{{ m.source.originRef }}</span>
</template> </template>
</span> </span>
<span <span
@@ -354,6 +353,7 @@ onBeforeUnmount(() => {
<PipelineDetails <PipelineDetails
v-if="detailOpen.has(m.id)" v-if="detailOpen.has(m.id)"
:meta="metaRows(m, [{ key: 'queuedAt', label: $t('processing.v-ocheredi-2') }])" :meta="metaRows(m, [{ key: 'queuedAt', label: $t('processing.v-ocheredi-2') }])"
:content="m.content"
:source-html="srcHtml(m)" :source-html="srcHtml(m)"
:source-url="sourceUrl(m)" :source-url="sourceUrl(m)"
/> />
@@ -391,18 +391,18 @@ onBeforeUnmount(() => {
> >
<!-- Шапка: источник + причина --> <!-- Шапка: источник + причина -->
<div class="flex items-center gap-2 mb-1.5 flex-wrap"> <div class="flex items-center gap-2 mb-1.5 flex-wrap">
<ChannelAvatar :name="r.ch.name" :handle="r.ch.handle" /> <ChannelAvatar :name="r.source?.displayName || ''" :handle="r.source?.displayName || ''" :color="r.source?.extra?.hue" />
<span class="text-[12px] font-medium text-mid truncate" :title="r.ch.handle ? '@' + r.ch.handle : ''"> <span class="text-[12px] font-medium text-mid truncate" :title="r.source?.originRef || ''">
{{ r.ch.name || $t('processing.istochnik') }} {{ r.source?.displayName || $t('processing.istochnik') }}
<template v-if="r.ch.handle"> <template v-if="r.source?.originRef">
<span class="text-low/60 font-mono">@{{ r.ch.handle }}</span> <span class="text-low/60 font-mono">{{ r.source.originRef }}</span>
</template> </template>
</span> </span>
<span <span
class="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide rounded border px-1.5 py-px shrink-0" class="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide rounded border px-1.5 py-px shrink-0"
:class="srcStyle(r.source).cls" :class="srcStyle(r.decidedBy).cls"
> >
{{ srcStyle(r.source).label }} {{ r.decidedByLabel || srcStyle(r.decidedBy).label }}
</span> </span>
<span <span
class="text-[10.5px] text-low bg-white/5 rounded px-1.5 py-px shrink-0" class="text-[10.5px] text-low bg-white/5 rounded px-1.5 py-px shrink-0"
@@ -455,7 +455,7 @@ onBeforeUnmount(() => {
<Icon name="restore" :size="11" /> <Icon name="restore" :size="11" />
{{ $t('processing.vozvrashcheno-v-obrabotku') }} {{ $t('processing.vozvrashcheno-v-obrabotku') }}
</span> </span>
<span v-else-if="r.source === 'dup'" class="text-[10.5px] text-low/60 shrink-0"> <span v-else-if="r.decidedBy === 'dup'" class="text-[10.5px] text-low/60 shrink-0">
{{ $t('processing.povtor-kartochka-uzhe-est-v-sisteme') }} {{ $t('processing.povtor-kartochka-uzhe-est-v-sisteme') }}
</span> </span>
<button <button
@@ -478,6 +478,7 @@ onBeforeUnmount(() => {
<PipelineDetails <PipelineDetails
v-if="detailOpen.has(r.id)" v-if="detailOpen.has(r.id)"
:meta="metaRows(r, [{ key: 'rejectedAt', label: $t('processing.otseyano') }, { key: 'returnedAt', label: $t('processing.vozvrashcheno') }])" :meta="metaRows(r, [{ key: 'rejectedAt', label: $t('processing.otseyano') }, { key: 'returnedAt', label: $t('processing.vozvrashcheno') }])"
:content="r.content"
:source-html="srcHtml(r)" :source-html="srcHtml(r)"
:source-url="sourceUrl(r)" :source-url="sourceUrl(r)"
/> />