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

Карточка, очередь и отсев работают с 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-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | 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 |
## 6. Manual-проверки (нужны внешние условия)
+8 -7
View File
@@ -232,16 +232,13 @@
"local": false, // создана локально, без внешнего источника
"title": "Разработка интернет-магазина", // string ≤140
"summary": "Компания: …\nЗадача: …",// блок «О заявке»
"source": { "kind": "telegram", "displayName": "Канал заказов", "originRef": "123456789", "receivedAt": 1726000000000 },
"sourceMsg": "Ищу разработчика…", // исходное сообщение
"sourceDialogId": "123456789",
"sourceMsgId": 4242,
"source": { "kind": "telegram", "externalId": "4242", "displayName": "Канал заказов", "originRef": "123456789", "author": "…", "receivedAt": "2026-09-11T10:00:00+00:00", "extra": {"hue": "#8b8ff8"} },
"content": { "text": "Ищу разработчика…", "html": null, "author": "…", "subject": null, "data": [], "links": [], "contacts": [] },
"stack": ["vue", "dotnet"],
"budget": {"from": 100000, "to": 200000, "cur": "RUB"},
"converted": {"from": 100000, "to": 200000, "cur": "RUB"},
"contact": "@client",
"contacts": [{"type": "tg", "value": "@client"}],
"channel": {"name": "Канал заказов", "handle": "@orders", "hue": "#8b8ff8"}, // прежний ch
"matchHits": [{"label": "Стек", "term": "vue", "word": null}],
"comments": [{"id": "cm_…", "by": "Вы", "text": "Позвонил", "time": "5 мин"}],
"links": [{"id": "pl_…", "name": "Бриф", "url": "https://example.com"}],
@@ -257,11 +254,15 @@
}
```
Ключевые поля: `id/containerId/(col)` — принадлежность; `source/sourceMsg/sourceDialogId/sourceMsgId`
происхождение; `stack/budget/converted/contact/contacts/channel/matchHits` — данные заявки; `comments/
Ключевые поля: `id/containerId/(col)` — принадлежность; `source` (`SourceRef`: вид, внешний id, подпись,
ссылка на оригинал, цвет в `extra.hue`) и `content` (`SourceContent`: текст, разметка, ссылки, контакты,
вложения `data`) — происхождение; `stack/budget/converted/contact/contacts/matchHits` — данные заявки; `comments/
links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/isVacancyKnown` — маркеры. Ровно
один из полей `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`).
### 4.2 Контейнер (container) — `GET /api/containers`, `POST/PATCH` тела
+14 -2
View File
@@ -1,7 +1,19 @@
# Дейл (Deal) — Статус разработки и прогресс
> Обновляется в конце каждого захода. Проект НЕ git — это главный борд состояния.
> Дата последнего обновления: 2026-09-10. **Все этапы 0–12 выполнены (100%)** — см. roadmap
> Обновляется в конце каждого захода. Проект в git (ветка `main`, коммиты на русском) — борд состояния + git-история.
> Дата последнего обновления: 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-10-deal-stage10-operator-analytics.md` и ledgers в `.superpowers/sdd/`.
> Live-приёмки на Docker Desktop выполнены: dev-smoke 14/14, SaaS-контур 15/15, prod-контур+mTLS+
@@ -1,6 +1,6 @@
# Дизайн: единый контракт источника + общий Storage-сервис данных
Дата: 2026-09-11. Статус: на согласовании (контракт не плодит типы вложений; файлы — в общем Storage).
Дата: 2026-09-11. Статус: реализовано в ядре (домен, Storage-сервис, персистентность, конвейер, wire, фронт); адаптер/провайдер telegram-сервиса и Storage-выгрузка — следующие шаги. Контракт не плодит типы вложений; файлы — в общем Storage.
## 1. Принцип
@@ -127,3 +127,45 @@ API ядра: `GET /api/cards/{id}/source` → generic контент.
6. Frontend: generic источник + универсальный просмотрщик.
7. Telegram: адаптер + провайдер исходника (только в telegram-сервисе) + выгрузка в Storage.
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`; колонка —
inbox либо доска по `BoardAccepts`+правилам; после успешной классификации карточка получает
`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/ИИ/система») и этап
`length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup` (подписи UI: «короткое сообщение»,
«стоп-фраза», «резюме соискателя», «нет суммы», «устарело», «повтор»…), причина ≤500, kw — сработавшая
@@ -423,7 +423,7 @@ public static class CardsEndpoints
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
IReadOnlyList<CardDto> leads = await cardsService.SearchCardsAsync(q, ct);
// messages всегда []: telegram-сообщений здесь нет, фронт их не читает.
// messages всегда []: фронт их не читает.
return Results.Ok(new { cards = leads, messages = Array.Empty<object>() });
}
@@ -1,7 +1,9 @@
using System.Globalization;
using System.Text.Json;
using Deal.Api.Events;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Telegram;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
@@ -70,17 +72,30 @@ public sealed class TelegramIngressService(
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService<PipelineIngestService>();
PipelineIngestResultDto result = await ingest.EnqueueAsync(
new QueuedMessage
string hue = string.IsNullOrWhiteSpace(request.ChannelHue) ? SourceRefs.DefaultHue : request.ChannelHue;
DateTimeOffset receivedAt = request.HasMsgAt
? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt)
: DateTimeOffset.UtcNow;
var item = new SourceItem
{
Source = new SourceRef
{
DialogId = request.DialogId,
ChannelName = request.ChannelName,
ChannelHandle = request.ChannelHandle,
ChannelHue = request.ChannelHue,
MsgId = request.HasMsgId ? request.MsgId : null,
Text = request.Text,
MsgAtMs = request.HasMsgAt ? request.MsgAt : null,
Kind = "telegram",
ExternalId = request.HasMsgId ? request.MsgId.ToString(CultureInfo.InvariantCulture) : null,
OriginRef = request.DialogId,
DisplayName = request.ChannelName,
ReceivedAt = receivedAt,
Extra = new Dictionary<string, string> { [SourceRefs.HueKey] = hue },
},
Content = new SourceContent
{
Text = request.Text,
Author = request.ChannelName,
},
};
PipelineIngestResultDto result = await ingest.EnqueueAsync(
new QueuedMessage { Item = item },
context.CancellationToken).ConfigureAwait(false);
await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false);
@@ -88,9 +88,9 @@ ContainersService containersService) : IColumnSuggester
List<string> texts = cards
.Where(card => card.Col != CardIds.Trash
&& card.Col != CardIds.Archive
&& card.SourceMsg.Trim().Length > 0)
&& (card.Content.Text ?? string.Empty).Trim().Length > 0)
.Take(SuggestHeuristics.KeywordsSampleLimit)
.Select(card => card.SourceMsg.Trim())
.Select(card => (card.Content.Text ?? string.Empty).Trim())
.ToList();
if (texts.Count < SuggestHeuristics.MinKeywordsSample)
{
@@ -115,7 +115,7 @@ ContainersService containersService) : IColumnSuggester
.Select(card => card.Id)
.ToHashSet(StringComparer.Ordinal);
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;
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_...).
private const int ColMaxLength = 200;
// Максимальная длина вида источника.
private const int SourceKindMaxLength = 64;
public void Configure(EntityTypeBuilder<CardEntity> builder)
{
builder.ToTable("Cards");
@@ -27,7 +30,12 @@ public sealed class CardConfiguration : IEntityTypeConfiguration<CardEntity>
builder.Property(x => x.LinksJson).HasColumnType("text");
builder.Property(x => x.FilesJson).HasColumnType("text");
builder.Property(x => x.HistoryJson).HasColumnType("text");
builder.Property(x => x.SourceMsg).HasColumnType("text");
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).
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)
.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);
builder.HasIndex(x => x.SearchTsv).HasMethod("gin");
}
@@ -16,8 +16,14 @@ public sealed class QueueItemConfiguration : IEntityTypeConfiguration<QueueItemE
builder.HasKey(x => x.Id);
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).
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.Reason).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)
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", stored: true);
@@ -92,40 +92,40 @@ public sealed class CardEntity
/// </summary>
public string ContactsJson { get; set; } = "[]";
/// <summary>
/// Имя канала/диалога-источника.
/// </summary>
public string ChannelName { get; set; } = string.Empty;
/// <summary>
/// Handle канала-источника.
/// </summary>
public string ChannelHandle { get; set; } = string.Empty;
/// <summary>
/// Цвет канала-источника
/// </summary>
public string ChannelHue { get; set; } = "#666";
/// <summary>
/// Время получения исходного сообщения
/// </summary>
public DateTimeOffset ReceivedAt { get; set; }
/// <summary>
/// Текст исходного сообщения
/// Вид источника
/// </summary>
public string SourceMsg { get; set; } = string.Empty;
public string SourceKind { get; set; } = string.Empty;
/// <summary>
/// Id диалога исходного сообщения
/// Идентификатор записи в источнике
/// </summary>
public string SourceDialogId { get; set; } = string.Empty;
public string SourceExternalId { get; set; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram, либо null.
/// Ссылка на оригинал в источнике
/// </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>
/// Предыдущая колонка
@@ -11,35 +11,25 @@ public sealed class QueueItemEntity
public string Id { get; set; } = string.Empty;
/// <summary>
/// Id диалога-источника
/// Ключ дедупликации записи источника
/// </summary>
public string DialogId { get; set; } = string.Empty;
public string SourceKey { get; set; } = string.Empty;
/// <summary>
/// Имя канала/диалога-источника.
/// Ссылка на источник, сериализованная в JSON
/// </summary>
public string ChannelName { get; set; } = string.Empty;
public string SourceJson { get; set; } = string.Empty;
/// <summary>
/// Handle канала-источника.
/// Содержимое источника, сериализованное в JSON
/// </summary>
public string ChannelHandle { get; set; } = string.Empty;
/// <summary>
/// Цвет канала-источника
/// </summary>
public string ChannelHue { get; set; } = "#666";
public string ContentJson { get; set; } = string.Empty;
/// <summary>
/// Текст сообщения
/// </summary>
public string Text { get; set; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram, либо null
/// </summary>
public long? MsgId { get; set; }
/// <summary>
/// Время получения исходного сообщения
/// </summary>
@@ -13,29 +13,19 @@ public sealed class RejectedItemEntity
public string Id { get; set; } = string.Empty;
/// <summary>
/// Id диалога-источника
/// Ключ дедупликации записи источника
/// </summary>
public string DialogId { get; set; } = string.Empty;
public string SourceKey { get; set; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram, либо null.
/// Ссылка на источник, сериализованная в JSON
/// </summary>
public long? MsgId { get; set; }
public string SourceJson { get; set; } = string.Empty;
/// <summary>
/// Имя канала/диалога-источника.
/// Содержимое источника, сериализованное в JSON
/// </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";
public string ContentJson { get; set; } = string.Empty;
/// <summary>
/// Текст сообщения
@@ -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,4 +1,5 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore;
@@ -49,7 +50,7 @@ public sealed partial class KanbanStore
AND ("SearchTsv" @@ plainto_tsquery('russian', {query})
OR lower("Title") LIKE {pattern}
OR lower("Summary") LIKE {pattern}
OR lower("SourceMsg") LIKE {pattern}
OR lower("SourceText") LIKE {pattern}
OR lower("Contact") LIKE {pattern})
ORDER BY ts_rank("SearchTsv", plainto_tsquery('russian', {query})) DESC, "ReceivedAt" DESC
LIMIT {limit}
@@ -76,18 +77,21 @@ public sealed partial class KanbanStore
/// <inheritdoc />
public async Task<CardDto?> GetCardBySourceAsync(
string dialogId,
long msgId,
SourceRef source,
CancellationToken ct)
{
if (string.IsNullOrEmpty(dialogId))
if (string.IsNullOrEmpty(source.Kind))
{
return null;
}
string externalId = source.ExternalId ?? string.Empty;
string originRef = source.OriginRef ?? string.Empty;
CardEntity? entity = await _dbContext.Cards
.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)
.FirstOrDefaultAsync(ct);
if (entity is null)
@@ -71,10 +71,10 @@ public sealed partial class KanbanStore
card => card.Id,
(move, card) => new { move, card })
.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)
.Take(limit)
.Select(joined => new AiMarkupExampleDto(joined.card.SourceMsg, joined.move.ToCol!))
.Select(joined => new AiMarkupExampleDto(joined.card.SourceText, joined.move.ToCol!))
.ToListAsync(ct);
}
@@ -123,7 +123,7 @@ public sealed partial class KanbanStore
{
List<CardEntity> entities = await _dbContext.Cards
.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)
.ToListAsync(ct);
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
@@ -1,6 +1,7 @@
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
@@ -120,11 +121,8 @@ public sealed partial class KanbanStore : ICardStore
IsVacancyKnown = entity.IsVacancyKnown,
Title = entity.Title,
Summary = entity.Summary,
Source = new CardSourceDto(
Kind: ResolveSourceKind(entity),
DisplayName: entity.ChannelName,
OriginRef: entity.SourceDialogId,
ReceivedAt: entity.ReceivedAt.ToUnixTimeMilliseconds()),
Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty),
Content = FromJsonOr(entity.ContentJson, new SourceContent { Text = entity.SourceText }),
Stack = ToJsonList<string>(entity.StackJson),
Budget = entity.BudgetCur.Length == 0
? null
@@ -134,12 +132,8 @@ public sealed partial class KanbanStore : ICardStore
: new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur),
Contact = entity.Contact,
Contacts = ToJsonList<CardContactDto>(entity.ContactsJson),
Channel = new CardChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
Time = HumanAge(entity.ReceivedAt),
ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(),
SourceMsg = entity.SourceMsg,
SourceDialogId = entity.SourceDialogId,
SourceMsgId = entity.SourceMsgId,
PrevCol = entity.PrevCol,
MatchHits = ToJsonList<MatchHitDto>(entity.MatchHitsJson),
Comments = comments,
@@ -152,17 +146,22 @@ public sealed partial class KanbanStore : ICardStore
UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(),
};
// Вид источника карточки (производная проекция): local/telegram/other.
// entity: Строка карточки.
// Возвращает: Вид источника (строка wire-контракта).
private static string ResolveSourceKind(CardEntity entity)
// Разбирает одиночный JSON-объект; пустая/битая строка — запасное значение.
private static T FromJsonOr<T>(string json, T fallback)
{
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.
@@ -185,13 +184,13 @@ public sealed partial class KanbanStore : ICardStore
ConvCur = snapshot.ConvCur,
Contact = snapshot.Contact,
ContactsJson = ToJson(snapshot.Contacts),
ChannelName = snapshot.ChannelName,
ChannelHandle = snapshot.ChannelHandle,
ChannelHue = snapshot.ChannelHue,
ReceivedAt = snapshot.ReceivedAt,
SourceMsg = snapshot.SourceMsg,
SourceDialogId = snapshot.SourceDialogId,
SourceMsgId = snapshot.SourceMsgId,
SourceKind = snapshot.Source.Kind,
SourceExternalId = snapshot.Source.ExternalId ?? string.Empty,
SourceOriginRef = snapshot.Source.OriginRef ?? string.Empty,
SourceJson = ToJson(snapshot.Source),
ContentJson = ToJson(snapshot.Content),
SourceText = snapshot.Content.Text ?? string.Empty,
PrevCol = snapshot.PrevCol,
ArchivedAt = snapshot.ArchivedAt,
MatchHitsJson = ToJson(snapshot.MatchHits),
@@ -1,4 +1,6 @@
using System.Text.Json;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
@@ -11,17 +13,21 @@ namespace Deal.Infrastructure.Persistence.Repositories;
/// </summary>
public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
{
// ── Очередь (QueueItems) ───────────────────────────────────────────────
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
// ── Очередь (QueueItems) ───────────────────────────────────────
/// <inheritdoc />
public Task<bool> ExistsDuplicateAsync(
string dialogId,
long? msgId,
SourceRef source,
CancellationToken ct)
{
return msgId is null
? Task.FromResult(false)
: dbContext.QueueItems.AnyAsync(item => item.DialogId == dialogId && item.MsgId == msgId, ct);
string key = source.DedupeKey();
return dbContext.QueueItems.AnyAsync(item => item.SourceKey == key, ct);
}
/// <inheritdoc />
@@ -89,16 +95,19 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected);
DateTimeOffset rejectedAt = DateTimeOffset.UtcNow;
string sourceKey = record.Source.DedupeKey();
string sourceJson = ToJson(record.Source);
string contentJson = ToJson(record.Content);
await dbContext.Database.ExecuteSqlInterpolatedAsync(
$"""
INSERT INTO "RejectedItems" ("Id", "DialogId", "MsgId", "ChannelName", "ChannelHandle", "ChannelHue", "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})
INSERT INTO "RejectedItems" ("Id", "SourceKey", "SourceJson", "ContentJson", "Text", "Stage", "Reason", "Kw", "Source", "MsgAt", "RejectedAt", "Returned", "ReturnReason")
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
"SourceKey" = excluded."SourceKey",
"SourceJson" = excluded."SourceJson",
"ContentJson" = excluded."ContentJson",
"Text" = excluded."Text",
"ChannelName" = excluded."ChannelName",
"ChannelHandle" = excluded."ChannelHandle",
"ChannelHue" = excluded."ChannelHue",
"Stage" = excluded."Stage",
"Reason" = excluded."Reason",
"Kw" = excluded."Kw",
@@ -156,8 +165,7 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
.AsNoTracking()
.Where(item => EF.Functions.Like(item.Text.ToLower(), pattern)
|| EF.Functions.Like(item.Reason.ToLower(), pattern)
|| EF.Functions.Like(item.Kw.ToLower(), pattern)
|| EF.Functions.Like(item.ChannelName.ToLower(), pattern))
|| EF.Functions.Like(item.Kw.ToLower(), pattern))
.OrderByDescending(item => item.RejectedAt)
.Take(limitLike)
.ToListAsync(ct);
@@ -274,11 +282,10 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
private static QueueItemDto ToQueueItemDto(QueueItemEntity entity) => new()
{
Id = entity.Id,
DialogId = entity.DialogId,
MsgId = entity.MsgId,
Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty),
Content = FromJsonOr(entity.ContentJson, new SourceContent()),
Text = entity.Text,
Status = entity.Status,
Channel = new PipelineChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(),
QueuedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(),
Force = entity.Force,
@@ -291,13 +298,11 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
return new QueueItemEntity
{
Id = item.Id,
DialogId = item.DialogId,
MsgId = item.MsgId,
SourceKey = item.Source.DedupeKey(),
SourceJson = ToJson(item.Source),
ContentJson = ToJson(item.Content),
Text = item.Text,
Status = item.Status,
ChannelName = item.Channel.Name,
ChannelHandle = item.Channel.Handle,
ChannelHue = item.Channel.Hue,
MsgAt = FromEpochMs(item.MsgAtMs),
CreatedAt = queuedAt,
UpdatedAt = queuedAt,
@@ -308,16 +313,15 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
private static RejectedItemDto ToRejectedItemDto(RejectedItemEntity entity) => new()
{
Id = entity.Id,
DialogId = entity.DialogId,
MsgId = entity.MsgId,
Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty),
Content = FromJsonOr(entity.ContentJson, new SourceContent()),
Text = entity.Text,
Stage = entity.Stage,
StageLabel = PipelineRejectConstants.StageLabel(entity.Stage),
Reason = entity.Reason,
Kw = entity.Kw,
Source = entity.Source,
SourceLabel = PipelineRejectConstants.SourceLabel(entity.Source),
Channel = new PipelineChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue),
DecidedBy = entity.Source,
DecidedByLabel = PipelineRejectConstants.SourceLabel(entity.Source),
MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(),
RejectedAtMs = entity.RejectedAt.ToUnixTimeMilliseconds(),
Returned = entity.Returned,
@@ -327,4 +331,25 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
// epoch-ms → DateTimeOffset (UTC-момент для timestamptz-колонки).
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;
namespace Deal.Modules.Kanban.Application.Abstractions;
@@ -89,12 +90,10 @@ public interface ICardStore
/// <summary>
/// Карточка по исходному сообщению
/// </summary>
/// <param name="dialogId">Id диалога-источника сообщения.</param>
/// <param name="msgId">Id исходного сообщения в Telegram.</param>
/// <param name="source">Ссылка на источник: вид + оригинал + внешний id.</param>
/// <returns>Карточка или null, если сообщение не становилось карточкой.</returns>
public Task<CardDto?> GetCardBySourceAsync(
string dialogId,
long msgId,
SourceRef source,
CancellationToken ct);
/// <summary>
@@ -367,6 +366,6 @@ public interface ICardStore
/// <summary>
/// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest.
/// </summary>
/// <returns>Карточки col='inbox' с непустым SourceMsg (частотные темы считаются по source_msg).</returns>
/// <returns>Карточки col='inbox' с непустым текстом источника.</returns>
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 Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Kanban.Application.Models;
@@ -53,9 +54,14 @@ public sealed record CardDto
public string Summary { get; init; } = string.Empty;
/// <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>
/// Стек/направления
@@ -82,11 +88,6 @@ public sealed record CardDto
/// </summary>
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>
@@ -98,21 +99,6 @@ public sealed record CardDto
[property: JsonPropertyName("receivedAt")]
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>
@@ -1,3 +1,5 @@
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
@@ -91,40 +93,20 @@ public sealed record CardSnapshot
public IReadOnlyList<CardContactDto> Contacts { get; init; } = Array.Empty<CardContactDto>();
/// <summary>
/// Имя канала/диалога-источника.
/// Источник записи.
/// </summary>
public string ChannelName { get; init; } = string.Empty;
public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary>
/// Handle канала-источника.
/// Содержимое записи источника.
/// </summary>
public string ChannelHandle { get; init; } = string.Empty;
/// <summary>
/// Цвет канала-источника
/// </summary>
public string ChannelHue { get; init; } = string.Empty;
public SourceContent Content { get; init; } = new();
/// <summary>
/// Время получения исходного сообщения
/// </summary>
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>
@@ -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)
{
string source = card.SourceMsg.Trim();
string source = (card.Content.Text ?? string.Empty).Trim();
return source.Length > 0 ? source : card.Title;
}
@@ -1,5 +1,6 @@
using System.Text.Json;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
@@ -68,6 +69,7 @@ public sealed partial class CardsService
public async Task<CardDto> CreateLocalCardAsync(CardLocalCreateDto draft, CancellationToken ct)
{
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
DateTimeOffset receivedAt = DateTimeOffset.FromUnixTimeMilliseconds(nowMs);
string containerId = draft.ContainerId is not null && CardsDefaultContainers.Contains(draft.ContainerId)
? draft.ContainerId
: PlannedStage;
@@ -79,6 +81,9 @@ public sealed partial class CardsService
Local = true,
Title = draft.Title.Trim(),
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>(),
BudgetFrom = draft.Budget?.From,
BudgetTo = draft.Budget?.To,
@@ -90,13 +90,13 @@ public static class SuggestHeuristics
/// <summary>
/// Планы колонок-предложений по карточкам «Неразобранного».
/// </summary>
/// <param name="inbox">Карточки «Неразобранного» с непустым source_msg (ICardStore.ListInboxWithSourceAsync).</param>
/// <param name="inbox">Карточки «Неразобранного» с непустым текстом источника (ICardStore.ListInboxWithSourceAsync).</param>
/// <param name="existingBoardNames">Имена существующих (suggested=false) досок — похожие темы не предлагаются.</param>
/// <returns>Планы колонок (≤4, каждая ≥2 карточки); пусто — мало карточек/нечего сгруппировать.</returns>
public static IReadOnlyList<SuggestedColumnPlan> PlanColumns(IReadOnlyList<CardDto> inbox, IReadOnlyList<string> existingBoardNames)
{
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)
.ThenBy(card => card.Id, StringComparer.Ordinal)
.Take(MaxText)
@@ -111,7 +111,7 @@ public static class SuggestHeuristics
foreach (CardDto card in window)
{
// 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))
{
@@ -166,7 +166,7 @@ public static class SuggestHeuristics
/// <summary>
/// Частотные слова-маркеры по текстам карточек.
/// </summary>
/// <param name="texts">Тексты source_msg карточек выборки (непустые, свежие ≤40).</param>
/// <param name="texts">Тексты источника карточек выборки (непустые, свежие ≤40).</param>
/// <returns>Слова-маркеры (≤60); пусто — нет слов, встречающихся в ≥2 текстах.</returns>
public static IReadOnlyList<string> SuggestDomainKeywords(IReadOnlyList<string> texts)
{
@@ -191,7 +191,7 @@ public static class SuggestHeuristics
}
// Слова текста: подряд букв (латиница/кириллица), нижний регистр, длина 3..40, минус стоп-слова.
// text: Исходный текст (source_msg карточки).
// text: Исходный текст (текст источника карточки).
// Возвращает: Уникальные слова текста в порядке появления (Ordinal).
private static List<string> Tokenize(string text)
{
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Modules.Pipeline.Application.Abstractions;
@@ -10,14 +11,12 @@ public interface IPipelineStore
// ── Очередь (QueueItems) ───────────────────────────────────────────────
/// <summary>
/// Есть ли строка очереди с тем же сообщением диалога — дубль-гвард приёма.
/// Есть ли строка очереди с той же записью источника — дубль-гвард приёма.
/// </summary>
/// <param name="dialogId">Id диалога-источника.</param>
/// <param name="msgId">Id исходного сообщения; null — проверка не выполняется (вернёт false).</param>
/// <returns>true — строка с DialogId+MsgId уже в очереди (Telethon-дубль не пишем).</returns>
/// <param name="source">Ссылка на источник: вид + оригинал + внешний id.</param>
/// <returns>true — строка с такой записью уже в очереди.</returns>
public Task<bool> ExistsDuplicateAsync(
string dialogId,
long? msgId,
SourceRef source,
CancellationToken ct);
/// <summary>
@@ -1,22 +1,24 @@
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Сообщение-кандидат для ручной проверки ML.
/// Запись-кандидат для ручной проверки ML.
/// </summary>
public sealed record MlCandidateDto
{
/// <summary>
/// Id исходного сообщения в Telegram
/// Источник записи.
/// </summary>
public long Id { get; init; }
public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary>
/// Id диалога-источника сообщения.
/// Содержимое записи источника.
/// </summary>
public string DialogId { get; init; } = string.Empty;
public SourceContent Content { get; init; } = new();
/// <summary>
/// Текст сообщения.
/// Текст записи.
/// </summary>
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 Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Pipeline.Application.Models;
@@ -13,14 +14,14 @@ public sealed record QueueItemDto
public string Id { get; init; } = string.Empty;
/// <summary>
/// Id диалога-источника сообщения.
/// Источник записи.
/// </summary>
public string DialogId { get; init; } = string.Empty;
public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary>
/// Id исходного сообщения в Telegram
/// Содержимое записи источника.
/// </summary>
public long? MsgId { get; init; }
public SourceContent Content { get; init; } = new();
/// <summary>
/// Текст сообщения.
@@ -33,25 +34,19 @@ public sealed record QueueItemDto
public string Status { get; init; } = string.Empty;
/// <summary>
/// Канал-источник
/// </summary>
[property: JsonPropertyName("ch")]
public PipelineChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty);
/// <summary>
/// Время исходного сообщения, epoch-ms
/// Время исходного сообщения, epoch-ms.
/// </summary>
[property: JsonPropertyName("msgAt")]
public long MsgAtMs { get; init; }
/// <summary>
/// Время постановки в очередь
/// Время постановки в очередь, epoch-ms.
/// </summary>
[property: JsonPropertyName("queuedAt")]
public long QueuedAtMs { get; init; }
/// <summary>
/// Внутренний флаг «возвращено пользователем из отсева»
/// Внутренний флаг «возвращено пользователем из отсева».
/// </summary>
[JsonIgnore]
public bool Force { get; init; }
@@ -1,47 +1,19 @@
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
/// Команда приёма входящего сообщения — аргумент PipelineIngestService.EnqueueAsync.
/// Команда приёма входящей записи — аргумент PipelineIngestService.EnqueueAsync.
/// </summary>
public sealed record QueuedMessage
{
/// <summary>
/// Id диалога-источника сообщения
/// Запись источника: источник и содержимое.
/// </summary>
public string DialogId { get; init; } = string.Empty;
public required SourceItem Item { get; init; }
/// <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>
public bool Force { get; init; }
}
@@ -1,3 +1,5 @@
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Pipeline.Application.Models;
/// <summary>
@@ -6,44 +8,29 @@ namespace Deal.Modules.Pipeline.Application.Models;
public sealed record RejectRecord
{
/// <summary>
/// Id диалога-источника сообщения
/// Источник записи.
/// </summary>
public string DialogId { get; init; } = string.Empty;
public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary>
/// Id исходного сообщения в Telegram, либо null.
/// Содержимое записи источника.
/// </summary>
public long? MsgId { get; init; }
public SourceContent Content { get; init; } = new();
/// <summary>
/// Текст отсеянного сообщения
/// Текст отсеянного сообщения.
/// </summary>
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>
/// Время исходного сообщения, epoch-ms.
/// </summary>
public long MsgAtMs { get; init; }
/// <summary>
/// Источник решения
/// Кто вынес решение: stop|ml|ai|stale|dup.
/// </summary>
public string Source { get; init; } = string.Empty;
public string DecidedBy { get; init; } = string.Empty;
/// <summary>
/// Этап отсева: 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;
/// <summary>
/// Конкретное слово/фраза стоп-списка, сработавшая правилом
/// Конкретное слово/фраза стоп-списка, сработавшая правилом.
/// </summary>
public string Kw { get; init; } = string.Empty;
/// <summary>
/// Детерминированный id записи
/// Детерминированный id записи.
/// </summary>
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 Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Pipeline.Application.Models;
@@ -13,14 +14,14 @@ public sealed record RejectedItemDto
public string Id { get; init; } = string.Empty;
/// <summary>
/// Id диалога-источника сообщения.
/// Источник записи.
/// </summary>
public string DialogId { get; init; } = string.Empty;
public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary>
/// Id исходного сообщения в Telegram, либо null.
/// Содержимое записи источника.
/// </summary>
public long? MsgId { get; init; }
public SourceContent Content { get; init; } = new();
/// <summary>
/// Текст отсеянного сообщения.
@@ -28,12 +29,12 @@ public sealed record RejectedItemDto
public string Text { get; init; } = string.Empty;
/// <summary>
/// Этап отсева (см. словарь подписей <see cref="Deal.Modules.Pipeline.Application.Models.PipelineRejectConstants.StageLabels"/>).
/// Этап отсева.
/// </summary>
public string Stage { get; init; } = string.Empty;
/// <summary>
/// Человекочитаемая подпись для UI
/// Человекочитаемая подпись для UI.
/// </summary>
public string StageLabel { get; init; } = string.Empty;
@@ -43,51 +44,45 @@ public sealed record RejectedItemDto
public string Reason { get; init; } = string.Empty;
/// <summary>
/// Конкретное слово/фраза стоп-списка, сработавшая правилом
/// Конкретное слово/фраза стоп-списка, сработавшая правилом.
/// </summary>
public string Kw { get; init; } = string.Empty;
/// <summary>
/// Источник решения
/// Кто вынес решение: stop|ml|ai|stale|dup.
/// </summary>
public string Source { get; init; } = string.Empty;
public string DecidedBy { get; init; } = string.Empty;
/// <summary>
/// Подпись источника
/// Подпись источника решения.
/// </summary>
public string SourceLabel { get; init; } = string.Empty;
public string DecidedByLabel { get; init; } = string.Empty;
/// <summary>
/// Канал-источник
/// </summary>
[property: JsonPropertyName("ch")]
public PipelineChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty);
/// <summary>
/// Время исходного сообщения, epoch-ms
/// Время исходного сообщения, epoch-ms.
/// </summary>
[property: JsonPropertyName("msgAt")]
public long MsgAtMs { get; init; }
/// <summary>
/// Время отсева, epoch-ms
/// Время отсева, epoch-ms.
/// </summary>
[property: JsonPropertyName("rejectedAt")]
public long RejectedAtMs { get; init; }
/// <summary>
/// Флаг «возвращено в обработку»
/// Флаг «возвращено в обработку».
/// </summary>
public bool Returned { get; init; }
/// <summary>
/// Время возврата, epoch-ms
/// Время возврата, epoch-ms.
/// </summary>
[property: JsonPropertyName("returnedAt")]
public long? ReturnedAtMs { get; init; }
/// <summary>
/// Причина возврата, указанная пользователем
/// Причина возврата, указанная пользователем.
/// </summary>
public string ReturnReason { get; init; } = string.Empty;
}
@@ -17,15 +17,15 @@ public static class ContactsQualifier
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;
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",
};
@@ -35,9 +35,9 @@ public static class ContactsQualifier
"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",
RegexOptions.CultureInvariant);
@@ -78,7 +78,7 @@ public static class ContactsQualifier
/// <summary>
/// Классифицирует один сырой контакт → {type, value} или null.
/// </summary>
/// <param name="raw">Сырое значение контакта («@user», «https://t.me/x», телефон, email, ссылка).</param>
/// <param name="raw">Сырое значение контакта («@user», ссылка на профиль, телефон, email, ссылка).</param>
/// <returns>Квалифицированный контакт <see cref="CardContactDto"/> либо null — пусто/мусор/бот/сервисная ссылка/ «постовый» сайт.</returns>
public static CardContactDto? Qualify(string? raw)
{
@@ -91,7 +91,7 @@ public static class ContactsQualifier
if (s.StartsWith('@'))
{
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);
}
@@ -99,12 +99,12 @@ public static class ContactsQualifier
return null;
}
Match link = TelegramLinkRe.Match(s);
Match link = ProfileLinkRe.Match(s);
if (link.Success)
{
string name = link.Groups[1].Value;
if (!TgServiceNames.Contains(name.ToLowerInvariant())
&& !name.EndsWith(TgBotSuffix, StringComparison.OrdinalIgnoreCase))
if (!ReservedProfileNames.Contains(name.ToLowerInvariant())
&& !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase))
{
return new CardContactDto("tg", "@" + name);
}
@@ -1,5 +1,6 @@
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
@@ -92,10 +93,11 @@ public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings)
IReadOnlyList<CardContactDto> contacts = ContactsQualifier.Build(
parsed.Contacts.Select(contact => contact.Value).ToList(), message.Text);
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.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
{
@@ -115,13 +117,9 @@ public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings)
ConvCur = converted?.Cur ?? string.Empty,
Contact = contact,
Contacts = contacts,
ChannelName = message.Channel.Name,
ChannelHandle = message.Channel.Handle,
ChannelHue = hue,
Source = message.Source,
Content = message.Content with { Text = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints) },
ReceivedAt = receivedAt,
SourceMsg = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints),
SourceDialogId = message.DialogId,
SourceMsgId = message.MsgId,
PrevCol = CardIds.Inbox,
ArchivedAt = null,
MatchHits = matchHits,
@@ -94,7 +94,7 @@ public sealed class CardReclassifier(
try
{
Pass pass = await CreatePassAsync(ct);
if (string.IsNullOrWhiteSpace(card.SourceMsg))
if (string.IsNullOrWhiteSpace(card.Content.Text))
{
pass.Skipped++;
return Build(pass, attempted: 1, started: false, reason: NoSourceTextReason);
@@ -121,7 +121,7 @@ public sealed class CardReclassifier(
Pass pass,
CancellationToken ct)
{
string text = card.SourceMsg;
string text = card.Content.Text ?? string.Empty;
if (string.IsNullOrWhiteSpace(text))
{
pass.Skipped++;
@@ -225,16 +225,15 @@ public sealed class CardReclassifier(
// ── Сборка входа/выхода ────────────────────────────────────────────────
// Собирает строку-сообщение для CardComposer из полей карточки (канал/время/исходный текст).
// card: Карточка-источник (алиасы канала/получателя/даты).
// text: Исходный текст (source_msg).
// Собирает строку-сообщение для CardComposer из полей карточки (источник/время/исходный текст).
// card: Карточка-источник (источник и содержимое сообщения).
// text: Исходный текст записи источника.
// Возвращает: Строка очереди, эквивалентная исходному сообщению карточки.
private static QueueItemDto BuildMessage(CardDto card, string text) => new()
{
DialogId = card.SourceDialogId,
MsgId = card.SourceMsgId,
Source = card.Source,
Content = card.Content with { Text = text },
Text = text,
Channel = new PipelineChannelDto(card.Channel.Name, card.Channel.Handle, card.Channel.Hue),
MsgAtMs = card.ReceivedAtMs,
};
@@ -1,6 +1,8 @@
using System.Globalization;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
@@ -10,7 +12,7 @@ using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Modules.Pipeline.Application.Services;
/// <summary>
/// Ручная проверка/разметка ML на сообщениях канала.
/// Ручная проверка/разметка ML на записях источников.
/// </summary>
public sealed class MlReviewService(
IPipelineStore pipelineStore,
@@ -86,10 +88,10 @@ public sealed class MlReviewService(
private const double UserPushWeight = 1.0;
/// <summary>
/// Отбирает сообщения-кандидаты для проверки ML по каналу и/или размеру выборки.
/// Отбирает записи-кандидаты для проверки ML по источнику и/или размеру выборки.
/// </summary>
/// <param name="dialogId">Id канала/диалога; пусто — выборка по всем источникам тенанта.</param>
/// <param name="limit">Сколько последних сообщений вернуть (кламп 1..<see cref="MaxCandidates"/>, дефолт вызывающего).</param>
/// <param name="dialogId">Оригинал источника (OriginRef); пусто — выборка по всем источникам тенанта.</param>
/// <param name="limit">Сколько последних записей вернуть (кламп 1..<see cref="MaxCandidates"/>, дефолт вызывающего).</param>
/// <returns>Кандидаты (свежие первыми): текст, текущий вердикт и мнение ML по каждому.</returns>
public async Task<IReadOnlyList<MlCandidateDto>> CandidatesAsync(
string? dialogId,
@@ -103,36 +105,36 @@ public sealed class MlReviewService(
IReadOnlyList<RejectedItemDto> rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, 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)
{
if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId))
if (!IsMessage(row.Source) || !MatchesDialog(dialog, row.Source.OriginRef))
{
continue;
}
merged[(row.DialogId, msgId)] = BuildQueued(row, msgId);
merged[row.Source.DedupeKey()] = BuildQueued(row);
}
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;
}
merged[(row.DialogId, msgId)] = BuildRejected(row, msgId);
merged[row.Source.DedupeKey()] = BuildRejected(row);
}
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;
}
merged[(card.SourceDialogId, msgId)] = BuildCard(card, msgId);
merged[card.Source.DedupeKey()] = BuildCard(card);
}
List<MlCandidateDto> ordered = merged.Values
@@ -150,12 +152,12 @@ public sealed class MlReviewService(
}
/// <summary>
/// Применяет ручное решение по сообщению
/// Применяет ручное решение по записи источника
/// </summary>
/// <param name="dialogId">Id канала/диалога сообщения.</param>
/// <param name="msgId">Id исходного сообщения.</param>
/// <param name="dialogId">Оригинал источника (OriginRef) записи.</param>
/// <param name="msgId">Внешний id записи в источнике.</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(
string dialogId,
long msgId,
@@ -164,12 +166,19 @@ public sealed class MlReviewService(
{
string normalized = (action ?? string.Empty).Trim();
string dialog = (dialogId ?? string.Empty).Trim();
string externalId = msgId.ToString(CultureInfo.InvariantCulture);
CardDto? card = await cardStore.GetCardBySourceAsync(dialog, msgId, ct);
string? text = await FindTextAsync(dialog, msgId, card, ct);
SourceRef? source = await ResolveSourceAsync(dialog, externalId, 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))
{
return null; // 404: исходное сообщение не найдено
return null; // 404: исходная запись не найдена
}
if (normalized == ActionSkip)
@@ -179,28 +188,28 @@ public sealed class MlReviewService(
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))
{
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);
}
// Действие «спам»: карточку — в корзину (с обучением), сообщение из очереди — в отсев; иначе учим ML.
// dialog: Id диалога.
// msgId: Id сообщения.
// Действие «спам»: карточку — в корзину (с обучением), запись из очереди — в отсев; иначе учим ML.
// dialog: Оригинал источника.
// externalId: Внешний id записи.
// card: Карточка сообщения (null — сообщение не становилось карточкой).
// text: Текст сообщения.
// ct: Токен отмены.
// Возвращает: Результат решения.
private async Task<MlApplyResult> ApplySpamAsync(
string dialog,
long msgId,
string externalId,
CardDto? card,
string text,
CancellationToken ct)
@@ -215,19 +224,16 @@ public sealed class MlReviewService(
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)
{
await processing.RejectAsync(new RejectRecord
{
DialogId = queued.DialogId,
MsgId = queued.MsgId,
Source = queued.Source,
Content = queued.Content,
Text = queued.Text,
ChannelName = queued.Channel.Name,
ChannelHandle = queued.Channel.Handle,
ChannelHue = queued.Channel.Hue,
MsgAtMs = queued.MsgAtMs,
Source = ManualSource,
DecidedBy = ManualSource,
Stage = ManualSpamStage,
Reason = ManualSpamReason,
Kw = string.Empty,
@@ -239,16 +245,12 @@ public sealed class MlReviewService(
}
// Действие «в колонку»: карточку — переносим, уже в колонке — только учим; иначе учим ML.
// dialog: Id диалога.
// msgId: Id сообщения.
// boardId: Id колонки-цели (inbox или b_...).
// card: Карточка сообщения (null — сообщение не становилось карточкой).
// text: Текст сообщения.
// ct: Токен отмены.
// Возвращает: Результат решения.
private async Task<MlApplyResult> ApplyBoardAsync(
string dialog,
long msgId,
string boardId,
CardDto? card,
string text,
@@ -281,24 +283,61 @@ public sealed class MlReviewService(
return new MlApplyResult(null, Ok: true, Learned: true, Moved: boardId, LeadId: card.Id);
}
// Текст исходного сообщения: source_msg карточки, иначе текст строки очереди/записи отсева.
// dialog: Id диалога.
// msgId: Id сообщения.
// Ссылка на источник записи: очередь → карточка → отсев.
// dialog: Оригинал источника.
// 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: Карточка сообщения (уже прочитана вызывающим).
// ct: Токен отмены.
// Возвращает: Непустой текст либо null, если сообщения нет ни в одном источнике.
// Возвращает: Непустой текст либо null, если записи нет ни в одном источнике.
private async Task<string?> FindTextAsync(
string dialog,
long msgId,
string externalId,
CardDto? card,
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))
{
return queued.Text;
@@ -307,7 +346,7 @@ public sealed class MlReviewService(
IReadOnlyList<RejectedItemDto> rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct);
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;
}
@@ -316,20 +355,20 @@ public sealed class MlReviewService(
return null;
}
// Строка очереди сообщения (для отсева при ручной разметке «спам»).
// dialog: Id диалога.
// msgId: Id сообщения.
// Строка очереди записи (для отсева при ручной разметке «спам»).
// dialog: Оригинал источника.
// externalId: Внешний id записи.
// ct: Токен отмены.
// Возвращает: Строка очереди либо null — сообщение уже обработано/не в очереди.
private async Task<QueueItemDto?> FindQueuedAsync(
string dialog,
long msgId,
string externalId,
CancellationToken ct)
{
IReadOnlyList<QueueItemDto> queue = await pipelineStore.ListAsync(status: null, MaxScan, ct);
foreach (QueueItemDto row in queue)
{
if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal))
if (MatchesSource(row.Source, dialog, externalId))
{
return row;
}
@@ -360,21 +399,34 @@ public sealed class MlReviewService(
}
}
// Соответствует ли диалог фильтру канала (пустой фильтр — все диалоги).
// filter: Запрошенный канал (пусто — без фильтра).
// dialogId: Диалог сообщения.
// Идентифицирована ли запись источника (есть внешний id).
// source: Ссылка на источник.
// Возвращает: True — запись адресуема как сообщение источника.
private static bool IsMessage(SourceRef source) => source.ExternalId is { Length: > 0 };
// Соответствует ли источник фильтру оригинала (пустой фильтр — все источники).
// filter: Запрошенный оригинал (пусто — без фильтра).
// originRef: Оригинал источника записи.
// Возвращает: True — кандидат подходит.
private static bool MatchesDialog(string filter, string dialogId) =>
filter.Length == 0 || string.Equals(filter, dialogId, StringComparison.Ordinal);
private static bool MatchesDialog(string filter, string? originRef) =>
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).
// row: Строка очереди.
// msgId: Id сообщения.
// Возвращает: Кандидат.
private static MlCandidateDto BuildQueued(QueueItemDto row, long msgId) => new()
private static MlCandidateDto BuildQueued(QueueItemDto row) => new()
{
Id = msgId,
DialogId = row.DialogId,
Source = row.Source,
Content = row.Content,
Text = Truncate(row.Text),
Time = row.MsgAtMs == 0 ? null : row.MsgAtMs,
Lead = false,
@@ -384,12 +436,11 @@ public sealed class MlReviewService(
// Кандидат из записи отсева (вердикт rejected).
// row: Запись отсева.
// msgId: Id сообщения.
// Возвращает: Кандидат.
private static MlCandidateDto BuildRejected(RejectedItemDto row, long msgId) => new()
private static MlCandidateDto BuildRejected(RejectedItemDto row) => new()
{
Id = msgId,
DialogId = row.DialogId,
Source = row.Source,
Content = row.Content,
Text = Truncate(row.Text),
Time = row.MsgAtMs == 0 ? null : row.MsgAtMs,
Lead = false,
@@ -400,13 +451,12 @@ public sealed class MlReviewService(
// Кандидат из карточки (вердикт card).
// card: Карточка.
// msgId: Id сообщения.
// Возвращает: Кандидат.
private static MlCandidateDto BuildCard(CardDto card, long msgId) => new()
private static MlCandidateDto BuildCard(CardDto card) => new()
{
Id = msgId,
DialogId = card.SourceDialogId,
Text = Truncate(card.SourceMsg),
Source = card.Source,
Content = card.Content,
Text = Truncate(card.Content.Text ?? string.Empty),
Time = card.ReceivedAtMs == 0 ? null : card.ReceivedAtMs,
Lead = true,
Verdict = VerdictCard,
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
@@ -6,42 +7,44 @@ using Deal.Modules.Pipeline.Application.Parse;
namespace Deal.Modules.Pipeline.Application.Services;
/// <summary>
/// Приём входящих сообщений пайплайна — постановка сырого сообщения в очередь QueueItems.
/// Приём входящих сообщений пайплайна — постановка сырой записи источника в очередь QueueItems.
/// </summary>
public sealed class PipelineIngestService(IPipelineStore store)
{
private const int MaxQueueTextLength = 6000;
/// <summary>
/// Ставит входящее сообщение в очередь
/// Ставит входящую запись источника в очередь
/// </summary>
/// <param name="message">Сырое сообщение: диалог, канальные поля, текст, время; Force — возврат из отсева.</param>
/// <returns>Результат: Id строки (<c>p_...</c>) либо null, если сообщение не принято; Duplicate — дубль диалога.</returns>
/// <param name="message">Запись источника и флаг «возвращено из отсева».</param>
/// <returns>Результат: Id строки (<c>p_...</c>) либо null, если запись не принята; Duplicate — дубль источника.</returns>
public async Task<PipelineIngestResultDto> EnqueueAsync(QueuedMessage message, CancellationToken ct)
{
string text = (message.Text ?? string.Empty).Trim();
if (text.Length == 0 || string.IsNullOrEmpty(message.DialogId))
string text = (message.Item.Content.Text ?? string.Empty).Trim();
if (text.Length == 0)
{
return new PipelineIngestResultDto(null, false);
}
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (message.MsgId is not null
&& await store.ExistsDuplicateAsync(message.DialogId, message.MsgId, ct))
if (await store.ExistsDuplicateAsync(message.Item.Source, ct))
{
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
{
Id = PrefixId.New(PipelineIdPrefixes.Queue),
DialogId = message.DialogId,
MsgId = message.MsgId,
Text = MessageTextCleaner.SliceCodePoints(text, MaxQueueTextLength),
Source = message.Item.Source,
Content = message.Item.Content with { Text = sliced },
Text = sliced,
Status = PipelineQueueStatuses.New,
Channel = new PipelineChannelDto(message.ChannelName, message.ChannelHandle, message.ChannelHue),
MsgAtMs = message.MsgAtMs ?? nowMs,
MsgAtMs = msgAtMs,
QueuedAtMs = nowMs,
Force = message.Force,
};
@@ -1,6 +1,6 @@
using Deal.Contracts.Integrations.Abstractions;
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.Models;
using Deal.Modules.Pipeline.Application.Parse;
@@ -80,7 +80,6 @@ public sealed class PipelineProcessingService(
return store.UpsertAsync(record with
{
Text = MessageTextCleaner.SliceCodePoints(record.Text, MaxRejectedTextLength),
ChannelHue = string.IsNullOrEmpty(record.ChannelHue) ? SourceDefaults.DefaultHue : record.ChannelHue,
Reason = MessageTextCleaner.SliceCodePoints(record.Reason, MaxReasonLength),
Kw = MessageTextCleaner.SliceCodePoints(record.Kw, MaxKwLength),
}, ct);
@@ -172,7 +171,7 @@ public sealed class PipelineProcessingService(
return new RejectReturnResultDto(AlreadyReturnedDetail, rejectedId, false, 0);
}
if (row.Source == DuplicateSource)
if (row.DecidedBy == DuplicateSource)
{
return new RejectReturnResultDto(DuplicateReturnDetail, rejectedId, false, 0);
}
@@ -195,38 +194,21 @@ public sealed class PipelineProcessingService(
DateTimeOffset.FromUnixTimeMilliseconds(nowMs),
ct);
long msgAtMs = row.MsgAtMs != 0 ? row.MsgAtMs : nowMs;
string hue = string.IsNullOrEmpty(row.Channel.Hue) ? SourceDefaults.DefaultHue : row.Channel.Hue;
if (row.DialogId.Length > 0 && row.MsgId is not null)
var extra = new Dictionary<string, string>(row.Source.Extra ?? new Dictionary<string, string>())
{
await ingest.EnqueueAsync(new QueuedMessage
{
DialogId = row.DialogId,
ChannelName = row.Channel.Name,
ChannelHandle = row.Channel.Handle,
ChannelHue = hue,
MsgId = row.MsgId,
Text = text,
MsgAtMs = msgAtMs,
Force = true,
}, ct);
}
else
[SourceRefs.HueKey] = row.Source.ResolveHue(),
};
SourceRef source = row.Source with { Extra = extra };
await ingest.EnqueueAsync(new QueuedMessage
{
var item = new QueueItemDto
Item = new SourceItem
{
Id = PrefixId.New(PipelineIdPrefixes.Queue),
DialogId = row.DialogId,
MsgId = row.MsgId,
Text = MessageTextCleaner.SliceCodePoints(text, MaxRejectedTextLength),
Status = PipelineQueueStatuses.New,
Channel = new PipelineChannelDto(row.Channel.Name, row.Channel.Handle, hue),
MsgAtMs = msgAtMs,
QueuedAtMs = nowMs,
Force = true,
};
await store.AddAsync(item, ct);
}
Source = source,
Content = row.Content with { Text = text },
},
Force = true,
}, ct);
return new RejectReturnResultDto(null, rejectedId, true, nowMs);
}
@@ -20,14 +20,11 @@ public sealed partial class PipelineWorkerService
{
return _processing.RejectAsync(new RejectRecord
{
DialogId = row.DialogId,
MsgId = row.MsgId,
Source = row.Source,
Content = row.Content,
Text = row.Text,
ChannelName = row.Channel.Name,
ChannelHandle = row.Channel.Handle,
ChannelHue = row.Channel.Hue,
MsgAtMs = row.MsgAtMs,
Source = source,
DecidedBy = source,
Stage = stage,
Reason = reason,
Kw = kw,
@@ -70,7 +70,6 @@ public sealed class RuntimeDepthsCollectorTests
private static QueueItemDto QueueItem(string id) => new()
{
Id = id,
DialogId = "d_1",
Text = "текст",
Status = PipelineQueueStatuses.New,
};
@@ -1,4 +1,6 @@
using System.Globalization;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
@@ -6,6 +8,7 @@ using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts;
@@ -64,15 +67,17 @@ public sealed class CardComposerTests
snapshot.Contacts);
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("ООО Ромашка ищет Python-разработчика на бота для CRM, удалённо.", snapshot.SourceMsg);
Assert.Equal("d_1", snapshot.SourceDialogId);
Assert.Equal(123, snapshot.SourceMsgId);
Assert.Equal("ООО Ромашка ищет Python-разработчика на бота для CRM, удалённо.", snapshot.Content.Text);
}
[Fact]
public async Task BuildAsync_LongSourceText_TruncatesSourceMsgTo4000CodePoints()
public async Task BuildAsync_LongSourceText_TruncatesContentTextTo4000CodePoints()
{
(CardComposer composer, _, _) = Create();
string longText = new string('а', 4500);
@@ -83,7 +88,7 @@ public sealed class CardComposerTests
cardId: "l_test2",
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]
@@ -223,20 +228,6 @@ public sealed class CardComposerTests
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",
long? msgId = 123,
string channelName = "Канал найма",
string channelHandle = "hire_ch",
string channelHue = "#ababab",
long msgAtMs = 1_700_000_000_000) => new()
{
Id = "p_1",
DialogId = dialogId,
MsgId = msgId,
Text = text,
Status = PipelineQueueStatuses.Filtered,
Channel = new PipelineChannelDto(channelName, channelHandle, channelHue),
MsgAtMs = msgAtMs,
QueuedAtMs = msgAtMs,
};
long msgAtMs = 1_700_000_000_000) => SourceItemFactory.Queue(
"p_1",
text,
PipelineQueueStatuses.Filtered,
origin: dialogId,
externalId: msgId?.ToString(CultureInfo.InvariantCulture),
name: channelName,
hue: channelHue,
msgAtMs: msgAtMs,
queuedAtMs: msgAtMs);
// Доска с правилами (для сценариев ContainerAccepts-страховки).
// id: Id доски (b_...).
@@ -316,8 +305,4 @@ public sealed class CardComposerTests
private static CardBudgetDto? Converted(CardSnapshot snapshot) => snapshot.ConvCur.Length == 0
? null
: 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.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
@@ -210,7 +211,7 @@ public sealed class CardsServiceTests
}
[Fact]
public async Task Move_EmptySourceMsg_UsesTitleForMatchHitsAndPush()
public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
@@ -677,7 +678,7 @@ public sealed class CardsServiceTests
}
[Fact]
public async Task Search_FindsByTitleSummaryContactSourceMsg_OrderedByReceivedAtDesc()
public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
@@ -759,7 +760,7 @@ public sealed class CardsServiceTests
Title = title,
Summary = summary,
Contact = contact,
SourceMsg = sourceMsg,
Content = new SourceContent { Text = sourceMsg },
PrevCol = prevCol,
ReceivedAtMs = receivedAtMs,
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.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts;
@@ -110,15 +111,10 @@ public sealed class PipelineCardWriterTests
private static QueueItemDto Message(
string text,
string dialogId = "d_1",
long? msgId = 123) => new()
{
Id = "p_1",
DialogId = dialogId,
MsgId = msgId,
Text = text,
Status = PipelineQueueStatuses.Filtered,
Channel = new PipelineChannelDto("Канал", "ch", "#666"),
MsgAtMs = 1_700_000_000_000,
QueuedAtMs = 1_700_000_000_000,
};
long? msgId = 123) => SourceItemFactory.Queue(
"p_1",
text,
PipelineQueueStatuses.Filtered,
origin: dialogId,
externalId: msgId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
@@ -202,18 +202,11 @@ public sealed class PipelineWorkerGrpcAiTests
// id: Id строки (p_…).
// text: Текст сообщения.
// Возвращает: Строка как если бы была переведена в filtered воркером.
private static QueueItemDto QueueRowFiltered(string id, string text) => new()
{
Id = id,
DialogId = "d_1",
MsgId = 100,
Text = text,
Status = PipelineQueueStatuses.Filtered,
Channel = new PipelineChannelDto("Канал А", "channel_a", "#333"),
MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
QueuedAtMs = 1_700_000_000_000,
Force = false,
};
private static QueueItemDto QueueRowFiltered(string id, string text) => SourceItemFactory.Queue(
id,
text,
PipelineQueueStatuses.Filtered,
msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
// Неготовая модель: «не уверена» (ML-слот воркера не решает — сообщение уходит к ИИ).
// Возвращает: Фиксированный «не готов/не уверен».
@@ -1,6 +1,7 @@
using Deal.Infrastructure.Services;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Contracts;
@@ -44,7 +45,7 @@ public sealed class CardMoverTests
{
(CardMover mover, FakeKanjStore store) = Create();
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);
@@ -58,7 +59,7 @@ public sealed class CardMoverTests
public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError()
{
(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);
@@ -1,4 +1,5 @@
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
@@ -175,7 +176,7 @@ public class FakeKanjStore : ICardStore
.Where(card => card.Title.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| card.Summary.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)
.Take(limit)
.ToList();
@@ -190,17 +191,20 @@ public class FakeKanjStore : ICardStore
/// <inheritdoc />
public Task<CardDto?> GetCardBySourceAsync(
string dialogId,
long msgId,
SourceRef source,
CancellationToken ct)
{
if (string.IsNullOrEmpty(dialogId))
if (string.IsNullOrEmpty(source.Kind))
{
return Task.FromResult<CardDto?>(null);
}
string externalId = source.ExternalId ?? string.Empty;
string originRef = source.OriginRef ?? string.Empty;
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)
.FirstOrDefault();
return Task.FromResult(match);
@@ -233,12 +237,10 @@ public class FakeKanjStore : ICardStore
: new CardBudgetDto(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur),
Contact = snapshot.Contact,
Contacts = snapshot.Contacts,
Channel = new CardChannelDto(snapshot.ChannelName, snapshot.ChannelHandle, snapshot.ChannelHue),
Source = snapshot.Source,
Content = snapshot.Content,
Time = "только что",
ReceivedAtMs = snapshot.ReceivedAt.ToUnixTimeMilliseconds(),
SourceMsg = snapshot.SourceMsg,
SourceDialogId = snapshot.SourceDialogId,
SourceMsgId = snapshot.SourceMsgId,
PrevCol = snapshot.PrevCol,
MatchHits = snapshot.MatchHits,
TzText = snapshot.TzText,
@@ -418,12 +420,13 @@ public class FakeKanjStore : ICardStore
}
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;
}
examples.Add(new AiMarkupExampleDto(card.SourceMsg, move.ToCol));
examples.Add(new AiMarkupExampleDto(sourceText, move.ToCol));
}
return Task.FromResult<IReadOnlyList<AiMarkupExampleDto>>(examples);
@@ -844,7 +847,7 @@ public class FakeKanjStore : ICardStore
public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
{
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)
.ToList();
return Task.FromResult(result);
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models;
@@ -174,7 +175,7 @@ public sealed class AiClassifyContextBuilderTests
string board,
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();
}
}
@@ -1,3 +1,4 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
@@ -45,12 +46,11 @@ public class FakePipelineStore : IPipelineStore
/// <inheritdoc />
public Task<bool> ExistsDuplicateAsync(
string dialogId,
long? msgId,
SourceRef source,
CancellationToken ct)
{
bool found = msgId is not null
&& _queue.Any(item => item.DialogId == dialogId && item.MsgId == msgId);
string key = source.DedupeKey();
bool found = _queue.Any(item => item.Source.DedupeKey() == key);
return Task.FromResult(found);
}
@@ -163,7 +163,7 @@ public class FakePipelineStore : IPipelineStore
.Where(item => LikeField(item.Text, query)
|| LikeField(item.Reason, query)
|| LikeField(item.Kw, query)
|| LikeField(item.Channel.Name, query))
|| LikeField(item.Source.DisplayName ?? string.Empty, query))
.OrderByDescending(item => item.RejectedAtMs)
.Take(limitLike)
.ToList();
@@ -300,16 +300,15 @@ public class FakePipelineStore : IPipelineStore
long rejectedAtMs) => new()
{
Id = id,
DialogId = record.DialogId,
MsgId = record.MsgId,
Source = record.Source,
Content = record.Content,
Text = record.Text,
Stage = record.Stage,
StageLabel = PipelineRejectConstants.StageLabel(record.Stage),
Reason = record.Reason,
Kw = record.Kw,
Source = record.Source,
SourceLabel = PipelineRejectConstants.SourceLabel(record.Source),
Channel = new PipelineChannelDto(record.ChannelName, record.ChannelHandle, record.ChannelHue),
DecidedBy = record.DecidedBy,
DecidedByLabel = PipelineRejectConstants.SourceLabel(record.DecidedBy),
MsgAtMs = record.MsgAtMs,
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.Services;
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.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Kanban;
@@ -30,31 +33,26 @@ public sealed class MlReviewServiceTests
private static QueueItemDto QueueRow(
long msgId,
string text,
string dialog = Dialog) => new()
{
Id = $"p_{msgId}",
DialogId = dialog,
MsgId = msgId,
Text = text,
Status = PipelineQueueStatuses.New,
Channel = new PipelineChannelDto("Канал", "ch", "#333"),
MsgAtMs = 1_700_000_000_000 + msgId,
};
string dialog = Dialog) => SourceItemFactory.Queue(
$"p_{msgId}",
text,
PipelineQueueStatuses.New,
origin: dialog,
externalId: msgId.ToString(CultureInfo.InvariantCulture),
msgAtMs: 1_700_000_000_000 + msgId);
private static RejectedItemDto RejectedRow(
long msgId,
string text,
string dialog = Dialog) => new()
{
Id = $"r_{msgId}",
DialogId = dialog,
MsgId = msgId,
Text = text,
Stage = "spam_ml",
Reason = "спам",
Source = "ml",
MsgAtMs = 1_700_000_000_000 + msgId,
};
string dialog = Dialog) => SourceItemFactory.Rejected(
$"r_{msgId}",
text,
stage: "spam_ml",
decidedBy: "ml",
reason: "спам",
origin: dialog,
externalId: msgId.ToString(CultureInfo.InvariantCulture),
msgAtMs: 1_700_000_000_000 + msgId);
private static CardDto Card(
long msgId,
@@ -65,9 +63,10 @@ public sealed class MlReviewServiceTests
Id = $"c_{msgId}",
Col = col,
Title = text,
SourceMsg = text,
SourceDialogId = dialog,
SourceMsgId = msgId,
Source = SourceItemFactory.Source(
origin: dialog,
externalId: msgId.ToString(CultureInfo.InvariantCulture)),
Content = SourceItemFactory.Content(text),
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);
Assert.Equal(3, items.Count);
Assert.Contains(items, item => item.Id == 101 && item.Verdict == MlReviewService.VerdictQueued && !item.Lead);
Assert.Contains(items, item => item.Id == 102 && item.Verdict == MlReviewService.VerdictRejected);
MlCandidateDto cardItem = Assert.Single(items, item => item.Id == 103);
Assert.Contains(items, item => item.Source.ExternalId == "101" && item.Verdict == MlReviewService.VerdictQueued && !item.Lead);
Assert.Contains(items, item => item.Source.ExternalId == "102" && item.Verdict == MlReviewService.VerdictRejected);
MlCandidateDto cardItem = Assert.Single(items, item => item.Source.ExternalId == "103");
Assert.True(cardItem.Lead);
Assert.Equal(MlReviewService.VerdictCard, cardItem.Verdict);
Assert.Equal("inbox", cardItem.Col);
@@ -103,7 +102,7 @@ public sealed class MlReviewServiceTests
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
MlCandidateDto only = Assert.Single(items);
Assert.Equal(101, only.Id);
Assert.Equal("101", only.Source.ExternalId);
}
[Fact]
@@ -186,7 +185,7 @@ public sealed class MlReviewServiceTests
Assert.Empty(pipeline.Queue);
RejectedItemDto rejected = Assert.Single(pipeline.Rejected);
Assert.Equal("spam_ml", rejected.Stage);
Assert.Equal("ml", rejected.Source);
Assert.Equal("ml", rejected.DecidedBy);
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.Services;
@@ -10,17 +11,17 @@ public sealed class SuggestHeuristicsTests
{
// Карточка «Неразобранного» с текстом (helper; время — порядок окна, по умолчанию 0).
// id: Id карточки.
// sourceMsg: Исходное сообщение (source_msg).
// sourceText: Исходное сообщение.
// receivedAtMs: Время получения, epoch-ms.
// Возвращает: Карточка для входа PlanColumns.
private static CardDto InboxCard(
string id,
string sourceMsg,
string sourceText,
long receivedAtMs = 0) => new()
{
Id = id,
Col = KanbanColumns.Inbox,
SourceMsg = sourceMsg,
Content = new SourceContent { Text = sourceText },
ReceivedAtMs = receivedAtMs,
};
@@ -1,6 +1,8 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Pipeline;
@@ -19,42 +21,43 @@ public sealed class PipelineIngestServiceTests
PipelineIngestResultDto result = await service.EnqueueAsync(
Message(
dialogId: "d_1",
msgId: 42,
text: " Нужен Python-разработчик, удалёнка ",
channelName: "Канал",
channelHandle: "ch",
channelHue: "#ababab",
msgAtMs: 1_000),
" Нужен Python-разработчик, удалёнка ",
origin: "d_1",
externalId: "42",
name: "Канал",
hue: "#ababab",
receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(1_000)),
CancellationToken.None);
Assert.StartsWith(PipelineIdPrefixes.Queue, result.Id);
Assert.False(result.Duplicate);
QueueItemDto item = Assert.Single(store.Queue);
Assert.Equal(result.Id, item.Id);
Assert.Equal("d_1", item.DialogId);
Assert.Equal(42, item.MsgId);
Assert.Equal("Нужен Python-разработчик, удалёнка", item.Text); // trim краёв (enqueue L68)
Assert.Equal("d_1", item.Source.OriginRef);
Assert.Equal("42", item.Source.ExternalId);
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("Канал", item.Channel.Name);
Assert.Equal("ch", item.Channel.Handle);
Assert.Equal("#ababab", item.Channel.Hue);
Assert.Equal(1_000, item.MsgAtMs);
Assert.True(item.QueuedAtMs > 0, "queuedAt = now (CreatedAt/UpdatedAt строки)");
Assert.False(item.Force);
}
[Fact]
public async Task EnqueueAsync_MsgAtAbsent_UsesNow()
public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow()
{
FakePipelineStore store = new();
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);
Assert.True(item.MsgAtMs > 0, "msgAt = now, если время сообщения не передано (Ruling 2)");
Assert.Equal(item.MsgAtMs, item.QueuedAtMs);
Assert.Equal(0, item.MsgAtMs); // нет времени источника — нулевая отметка (Ruling 2)
Assert.True(item.QueuedAtMs > 0, "queuedAt = now");
}
[Fact]
@@ -63,12 +66,12 @@ public sealed class PipelineIngestServiceTests
FakePipelineStore store = new();
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);
}
// ─── Приём: no-op (пустой текст / нет диалога) ──────────────────────────
// ─── Приём: no-op (пустой текст) ────────────────────────────────────────
[Fact]
public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop()
@@ -85,63 +88,53 @@ public sealed class PipelineIngestServiceTests
Assert.Empty(store.Queue); // пустой текст — no-op (enqueue L69)
}
// ─── Приём: дубль-гвард (ключ записи источника) ─────────────────────────
[Fact]
public async Task EnqueueAsync_MissingDialogId_Noop()
public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
PipelineIngestResultDto result = await service.EnqueueAsync(Message(text: "Текст без диалога", dialogId: string.Empty), CancellationToken.None);
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: "Сообщение снова");
QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7");
QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7");
PipelineIngestResultDto accepted = await service.EnqueueAsync(first, CancellationToken.None);
PipelineIngestResultDto duplicate = await service.EnqueueAsync(second, CancellationToken.None);
Assert.NotNull(accepted.Id);
Assert.Null(duplicate.Id);
Assert.True(duplicate.Duplicate); // Telethon-повтор не пишем (enqueue L7480)
Assert.True(duplicate.Duplicate); // повтор записи источника не пишем
Assert.Single(store.Queue);
Assert.Equal("Сообщение", Assert.Single(store.Queue).Text); // первая вставка не перезаписана
}
[Fact]
public async Task EnqueueAsync_SameDialogDifferentMsgIds_BothAccepted()
public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
await service.EnqueueAsync(Message(dialogId: "d_1", msgId: 1, text: "Первое"), CancellationToken.None);
PipelineIngestResultDto second = await service.EnqueueAsync(Message(dialogId: "d_1", msgId: 2, text: "Второе"), CancellationToken.None);
await service.EnqueueAsync(Message("Первое", origin: "d_1", externalId: "1"), CancellationToken.None);
PipelineIngestResultDto second = await service.EnqueueAsync(Message("Второе", origin: "d_1", externalId: "2"), CancellationToken.None);
Assert.NotNull(second.Id);
Assert.Equal(2, store.Queue.Count); // гвард — по паре dialog+msgId, разные сообщения проходят
Assert.Equal(2, store.Queue.Count); // гвард — по ключу источника, разные записи проходят
}
[Fact]
public async Task EnqueueAsync_NoMsgId_SameDialogTwice_BothAccepted()
public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
PipelineIngestResultDto first = await service.EnqueueAsync(Message(dialogId: "d_1", text: "Первое"), CancellationToken.None);
PipelineIngestResultDto second = await service.EnqueueAsync(Message(dialogId: "d_1", text: "Второе"), CancellationToken.None);
PipelineIngestResultDto first = await service.EnqueueAsync(
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(second.Id); // дубль-гвард работает только при msgId (enqueue L75: if msg_id)
Assert.Equal(2, store.Queue.Count);
Assert.True(second.Duplicate); // ключ источника без внешнего id совпадает
Assert.Single(store.Queue);
}
// ─── Приём: лимит текста 6000 (text[:6000]) ─────────────────────────────
@@ -153,7 +146,7 @@ public sealed class PipelineIngestServiceTests
PipelineIngestService service = new(store);
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);
Assert.Equal(6_000, item.Text.EnumerateRunes().Count()); // python text[:6000] — кодовые точки
@@ -164,21 +157,10 @@ public sealed class PipelineIngestServiceTests
// Команда приёма со значениями по умолчанию (сценарий теста перекрывает нужные поля).
private static QueuedMessage Message(
string text,
string dialogId = "d_1",
long? msgId = null,
string channelName = "",
string channelHandle = "",
string channelHue = "",
long? msgAtMs = null,
bool force = false) => new()
{
DialogId = dialogId,
MsgId = msgId,
Text = text,
ChannelName = channelName,
ChannelHandle = channelHandle,
ChannelHue = channelHue,
MsgAtMs = msgAtMs,
Force = force,
};
string? origin = SourceItemFactory.Origin,
string? externalId = SourceItemFactory.ExternalId,
string? name = SourceItemFactory.Name,
string? hue = SourceItemFactory.Hue,
DateTimeOffset? receivedAt = null,
bool force = false) => SourceItemFactory.Message(text, origin, externalId, name, hue, receivedAt, 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.Services;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Pipeline;
@@ -16,7 +19,7 @@ public sealed class PipelineProcessingServiceTests
{
(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
}
@@ -28,23 +31,23 @@ public sealed class PipelineProcessingServiceTests
await service.RejectAsync(new RejectRecord
{
DialogId = "d_1",
MsgId = 7,
Source = SourceItemFactory.Source(origin: "d_1", externalId: "7", name: string.Empty, hue: null),
Content = SourceItemFactory.Content(new string('а', 7_000), string.Empty),
Text = new string('а', 7_000),
Reason = new string('р', 600),
Kw = new string('к', 300),
Source = "stop",
DecidedBy = "stop",
Stage = "stop",
}, CancellationToken.None);
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(500, row.Reason.EnumerateRunes().Count()); // reason[:500]
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.Source);
Assert.Equal("stop", row.DecidedBy);
}
[Fact]
@@ -56,7 +59,7 @@ public sealed class PipelineProcessingServiceTests
await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None);
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("стоп-фраза «реклама»", row.Reason);
}
@@ -127,23 +130,23 @@ public sealed class PipelineProcessingServiceTests
}
[Fact]
public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndChannel()
public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndSourceName()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_text", text: "Middle Python разработчик", rejectedAtMs: 1_000));
store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_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 byReason = 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_reason", Assert.Single(byReason.Items).Id); // LIKE по reason
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]
@@ -193,7 +196,7 @@ public sealed class PipelineProcessingServiceTests
public async Task ReturnAsync_DuplicateSource_Returns400()
{
(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);
@@ -225,13 +228,13 @@ public sealed class PipelineProcessingServiceTests
"r_1",
text: " Продвижение в каналах ",
stage: "spam_ai",
source: "ai",
dialogId: "d_9",
msgId: 5,
channelName: "Спам",
channelHandle: "spam_ch",
channelHue: "#ff0000",
msgAtMs: 5_000));
decidedBy: "ai",
origin: "d_9",
externalId: "5",
name: "Спам",
hue: "#ff0000",
msgAtMs: 5_000,
receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(5_000)));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", "не спам, вернуть", CancellationToken.None);
@@ -255,12 +258,11 @@ public sealed class PipelineProcessingServiceTests
Assert.StartsWith(PipelineIdPrefixes.Queue, queued.Id);
Assert.Equal(PipelineQueueStatuses.New, queued.Status);
Assert.True(queued.Force); // возврат: причины отсева игнорируются воркером (Ruling 5/10)
Assert.Equal("d_9", queued.DialogId);
Assert.Equal(5, queued.MsgId);
Assert.Equal("d_9", queued.Source.OriginRef);
Assert.Equal("5", queued.Source.ExternalId);
Assert.Equal("Продвижение в каналах", queued.Text);
Assert.Equal("Спам", queued.Channel.Name);
Assert.Equal("spam_ch", queued.Channel.Handle);
Assert.Equal("#ff0000", queued.Channel.Hue);
Assert.Equal("Спам", queued.Source.DisplayName);
Assert.Equal("#ff0000", queued.Source.ResolveHue());
Assert.Equal(5_000, queued.MsgAtMs); // msg_at исходного сообщения сохраняется (L171)
Assert.True(queued.QueuedAtMs > 0);
}
@@ -273,10 +275,11 @@ public sealed class PipelineProcessingServiceTests
"r_1",
text: "Короткое сообщение",
stage: "length",
source: "stop",
dialogId: "d_1",
msgId: 3,
msgAtMs: 9_000));
decidedBy: "stop",
origin: "d_1",
externalId: "3",
msgAtMs: 9_000,
receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(9_000)));
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()
{
(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);
@@ -304,14 +307,19 @@ public sealed class PipelineProcessingServiceTests
}
[Fact]
public async Task ReturnAsync_NoDialogOrMsgId_EnqueuesDirectlyWithFallbackNow()
public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_old",
text: "Старая запись до сохранения dialog_id",
text: "Старая запись без ссылки на исходное",
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);
@@ -322,10 +330,10 @@ public sealed class PipelineProcessingServiceTests
QueueItemDto queued = Assert.Single(store.Queue); // прямая вставка без ссылки на исходное (L174–192)
Assert.StartsWith(PipelineIdPrefixes.Queue, queued.Id);
Assert.True(queued.Force);
Assert.Equal(string.Empty, queued.DialogId);
Assert.Null(queued.MsgId);
Assert.Equal("#666", queued.Channel.Hue); // hue не сохранён — дефолт (L181)
Assert.True(queued.MsgAtMs > 0); // msg_at нет — now (row.get("msg_at") or now)
Assert.Equal(string.Empty, queued.Source.OriginRef);
Assert.Null(queued.Source.ExternalId);
Assert.Equal("#666", queued.Source.ResolveHue()); // hue не сохранён — дефолт (L181)
Assert.Equal(0, queued.MsgAtMs); // времени источника нет — нулевая отметка
}
@@ -387,63 +395,57 @@ public sealed class PipelineProcessingServiceTests
string dialogId,
long? msgId,
string stage,
string reason) => new()
{
DialogId = dialogId,
MsgId = msgId,
Text = "Текст сообщения",
Stage = stage,
Source = "stop",
Reason = reason,
};
string reason) => SourceItemFactory.Reject(
"Текст сообщения",
origin: dialogId,
externalId: msgId?.ToString(CultureInfo.InvariantCulture),
name: string.Empty,
hue: null,
stage: stage,
decidedBy: "stop",
reason: reason);
// Строка очереди для сидирования (id/статус/время постановки).
private static QueueItemDto QueueItem(
string id,
string status,
long queuedAtMs) => new()
{
Id = id,
DialogId = "d_1",
Text = "текст",
Status = status,
Channel = new PipelineChannelDto(string.Empty, string.Empty, string.Empty),
MsgAtMs = queuedAtMs,
QueuedAtMs = queuedAtMs,
};
long queuedAtMs) => SourceItemFactory.Queue(
id,
"текст",
status,
msgAtMs: queuedAtMs,
queuedAtMs: queuedAtMs);
// Запись отсева для сидирования (как строка БД после маппинга адаптера).
private static RejectedItemDto Rejected(
string id,
string text = "Текст сообщения",
string stage = "length",
string source = "stop",
string dialogId = "",
long? msgId = null,
string decidedBy = "stop",
string reason = "",
string kw = "",
string channelName = "",
string channelHandle = "",
string channelHue = "",
string? origin = SourceItemFactory.Origin,
string? externalId = SourceItemFactory.ExternalId,
string? name = SourceItemFactory.Name,
string? hue = SourceItemFactory.Hue,
long msgAtMs = 0,
long rejectedAtMs = 1_000,
bool returned = false,
string returnReason = "") => new()
{
Id = id,
DialogId = dialogId,
MsgId = msgId,
Text = text,
Stage = stage,
StageLabel = PipelineRejectConstants.StageLabel(stage),
Reason = reason,
Kw = kw,
Source = source,
SourceLabel = PipelineRejectConstants.SourceLabel(source),
Channel = new PipelineChannelDto(channelName, channelHandle, channelHue),
MsgAtMs = msgAtMs,
RejectedAtMs = rejectedAtMs,
Returned = returned,
ReturnReason = returnReason,
};
string returnReason = "",
DateTimeOffset? receivedAt = null) => SourceItemFactory.Rejected(
id,
text,
stage,
decidedBy,
reason,
kw,
origin,
externalId,
name,
hue,
msgAtMs,
rejectedAtMs,
returned,
returnReason,
receivedAt);
}
@@ -275,17 +275,11 @@ public sealed class AdminTickOrchestratorTests
}
// Строка очереди (status new; сценарий перекрывает текст/время).
private static QueueItemDto QueueRow(string id, string text) => new()
{
Id = id,
DialogId = "d_1",
MsgId = 100,
Text = text,
Status = PipelineQueueStatuses.New,
Channel = new PipelineChannelDto("Канал А", "channel_a", "#333"),
MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
QueuedAtMs = 1_700_000_000_000,
};
private static QueueItemDto QueueRow(string id, string text) => SourceItemFactory.Queue(
id,
text,
PipelineQueueStatuses.New,
msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
// Минимальный разбор карточки (карточку домыслит CardComposer; колонка не назначена → inbox).
private static AiParsedCardDto Parsed(string title, bool isVacancy = false) => new(
@@ -306,23 +300,16 @@ public sealed class AdminTickOrchestratorTests
Board: null);
// Запись отсева для сидирования (как строка БД после маппинга адаптера).
private static RejectedItemDto Rejected(string id, long rejectedAtMs) => new()
{
Id = id,
DialogId = string.Empty,
Text = "Текст сообщения",
Stage = "length",
StageLabel = PipelineRejectConstants.StageLabel("length"),
Reason = string.Empty,
Kw = string.Empty,
Source = "stop",
SourceLabel = PipelineRejectConstants.SourceLabel("stop"),
Channel = new PipelineChannelDto(string.Empty, string.Empty, string.Empty),
MsgAtMs = 0,
RejectedAtMs = rejectedAtMs,
Returned = false,
ReturnReason = string.Empty,
};
private static RejectedItemDto Rejected(string id, long rejectedAtMs) => SourceItemFactory.Rejected(
id,
origin: string.Empty,
externalId: null,
name: string.Empty,
hue: null,
stage: "length",
decidedBy: "stop",
msgAtMs: 0,
rejectedAtMs: rejectedAtMs);
// Вычитывает все события канала: (тип, JSON) в порядке публикации.
// subscription: Подписка канала тенанта.
@@ -1,5 +1,6 @@
using System.Text.Json;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models;
@@ -50,19 +51,19 @@ public sealed class CardReclassifierTests
// Карточка «Неразобранного» с исходным текстом (поля, нужные классификации).
// id: Id карточки.
// sourceMsg: Исходный текст.
// sourceText: Исходный текст.
// contact: Старый контакт карточки.
// receivedAtMs: Время получения (для порядка батча).
// Возвращает: Карточка как если бы была сохранена в БД.
private static CardDto Card(
string id,
string sourceMsg,
string sourceText,
string contact = "",
long receivedAtMs = 1_700_000_000_000) => new()
{
Id = id,
Col = "inbox",
SourceMsg = sourceMsg,
Content = new SourceContent { Text = sourceText },
Contact = contact,
ReceivedAtMs = receivedAtMs,
};
@@ -1,6 +1,7 @@
using System.Globalization;
using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models;
@@ -34,7 +35,7 @@ public sealed class LocalColumnSuggesterTests
Id = id,
Col = KanbanColumns.Inbox,
IsNew = true,
SourceMsg = text,
Content = new SourceContent { Text = text },
ReceivedAtMs = receivedAtMs,
});
@@ -225,9 +226,9 @@ public sealed class LocalColumnSuggesterTests
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_1", "нужен python", store);
SeedInbox("l_2", "нужен vue", store);
store.SeedCard(new CardDto { Id = "l_t1", Col = KanbanColumns.Trash, SourceMsg = "ищу такси" });
store.SeedCard(new CardDto { Id = "l_t2", Col = KanbanColumns.Trash, SourceMsg = "ищу такси" });
store.SeedCard(new CardDto { Id = "l_a1", Col = KanbanColumns.Archive, 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, Content = new SourceContent { Text = "ищу такси" } });
store.SeedCard(new CardDto { Id = "l_a1", Col = KanbanColumns.Archive, Content = new SourceContent { Text = "ищу такси" } });
SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(CancellationToken.None);
@@ -225,17 +225,12 @@ public sealed class PipelineWorkerSchedulerTests
private static QueueItemDto QueueRow(
string id,
string text,
string dialogId) => new()
{
Id = id,
DialogId = dialogId,
MsgId = 100,
Text = text,
Status = PipelineQueueStatuses.New,
Channel = new PipelineChannelDto("Канал", "channel", "#333"),
MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
QueuedAtMs = 1_700_000_000_000,
};
string dialogId) => SourceItemFactory.Queue(
id,
text,
PipelineQueueStatuses.New,
origin: dialogId,
msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
// Минимальный разбор карточки (карточку домыслит CardComposer; колонка не назначена → inbox).
private static AiParsedCardDto Parsed(string title, bool isVacancy = false) => new(
File diff suppressed because it is too large Load Diff
@@ -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 Deal.Api.Events;
using Deal.Grpc.Telegram;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
@@ -54,12 +55,12 @@ public sealed class TelegramIngressServiceTests
Assert.False(reply.Duplicate);
QueueItemDto row = Assert.Single(store.Queue);
Assert.Equal(DialogId, row.DialogId);
Assert.Equal(7, row.MsgId);
Assert.Equal("telegram", row.Source.Kind);
Assert.Equal(DialogId, row.Source.OriginRef);
Assert.Equal("7", row.Source.ExternalId);
Assert.Equal("Вакансия Python-разработчика", row.Text);
Assert.Equal("Канал", row.Channel.Name);
Assert.Equal("kanal_handle", row.Channel.Handle);
Assert.Equal("#a33", row.Channel.Hue);
Assert.Equal("Канал", row.Source.DisplayName);
Assert.Equal("#a33", row.Source.ResolveHue());
});
}
+3 -3
View File
@@ -17,7 +17,7 @@ import {
colMeta,
fmtDateTime,
} from '../../store.js'
import { cleanTitle, tgSourceUrl } from '../../utils.js'
import { cleanTitle, sourceOriginUrl } from '../../utils.js'
import { useCardDrag } from '../../composables/dnd.js'
import Icon from '../Icon.vue'
import MoveMenu from './MoveMenu.vue'
@@ -31,8 +31,8 @@ const props = defineProps({
const isSelected = computed(() => props.mode === 'selected')
const meta = computed(() => colMeta(props.card.containerId))
const clean = computed(() => cleanTitle(props.card.title))
// ссылка на исходное сообщение в Telegram (null данных для ссылки нет)
const sourceUrl = computed(() => tgSourceUrl(props.card))
// ссылка на исходник (null originRef не является абсолютным URL)
const sourceUrl = computed(() => sourceOriginUrl(props.card))
const terminal = computed(() => meta.value?.kind === 'terminal' || !!meta.value?.policy?.isTerminal)
// архив и корзина не перетаскиваются и не комментируются, только восстановление
const isService = computed(() => ['archive', 'trash'].includes(props.card.containerId))
+19 -27
View File
@@ -28,11 +28,13 @@ import {
fmtDateTime,
} from '../../store.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 MoveMenu from './MoveMenu.vue'
import BaseModal from '../ui/BaseModal.vue'
import CommentSection from '../ui/CommentSection.vue'
import SourceContentView from '../ui/SourceContentView.vue'
import Field from '../ui/Field.vue'
const card = computed(() => cardById(state.drawerId))
@@ -88,9 +90,17 @@ function contactType(c) {
function contactHref(c) {
return contactInfo(c?.value)?.href || null
}
const sourceUrl = computed(() => tgSourceUrl(card.value))
// HTML исходного сообщения один раз на карточку, а не на каждый ре-рендер
const sourceHtml = computed(() => renderSourceMessage(card.value?.sourceMsg || ''))
const sourceUrl = computed(() => sourceOriginUrl(card.value))
// есть что показать в блоке исходника (текст/ссылки/контакты/вложения)
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())
@@ -108,16 +118,6 @@ const stackInput = ref('')
const tzText = ref('')
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)
function safeUrl(u) {
return /^https?:\/\//i.test(String(u || '')) ? u : null
@@ -237,8 +237,8 @@ async function copyProjectContact() {
<div class="min-w-0">
<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">
<span class="w-1.5 h-1.5 rounded-full inline-block" :style="{ background: card.channel?.hue }"></span>
{{ card.channel?.name }} · {{ card.time }}
<span class="w-1.5 h-1.5 rounded-full inline-block" :style="{ background: card.source?.extra?.hue || '#666' }"></span>
{{ card.source?.displayName }} · {{ card.time }}
</div>
</div>
<button
@@ -516,7 +516,7 @@ async function copyProjectContact() {
</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="text-[10.5px] uppercase tracking-wider text-low/80 font-medium">{{ $t('drawer.ishodnoe-soobshchenie') }}</div>
<a
@@ -525,21 +525,13 @@ async function copyProjectContact() {
target="_blank"
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"
:title="$t('drawer.otkryt-ishodnoe-soobshchenie-v-telegram')"
:title="$t('drawer.otkryt-ishodnoe-soobshchenie')"
>
<Icon name="external" :size="13" />
{{ $t('drawer.otkryt-ishodnik') }}
</a>
</div>
<details class="group">
<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>
<SourceContentView :content="card.content" :summary="sourceSummary" />
</div>
<!-- ссылки (только «Выбранные») -->
@@ -1,10 +1,15 @@
<script setup>
// Аватар канала/чата: инициалы + стабильный цвет по имени (без картинок).
// Цвет детерминированный (hash от handle), чтобы аватар не «прыгал» между рендерами.
// Аватар канала/чата: инициалы + стабильный цвет. Если источник передал
// собственный цвет (`source.extra.hue`) используем его, иначе цвет
// детерминированный (hash от handle), чтобы аватар не «прыгал» между рендерами.
import { computed } from 'vue'
const props = defineProps({
name: { type: String, default: '' },
// handle без «@» участвует в расчёте цвета
// handle без «@» участвует в расчёте цвета, если свой цвет не задан
handle: { type: String, default: '' },
// цвет источника (напр. `extra.hue`); пусто берём из hash(handle)
color: { type: String, default: '' },
// размер в Tailwind-классах (w-*/h-*), по умолчанию 24px
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
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>
<template>
<span
class="rounded-full grid place-items-center font-bold shrink-0"
:class="[size, text]"
:style="{
background: `hsl(${hue(handle)} 28% 50% / 0.2)`,
color: `hsl(${hue(handle)} 45% 65%)`,
}"
:style="avatarStyle"
>
{{ initials(name) }}
</span>
@@ -1,18 +1,28 @@
<script setup>
// «Подробнее» записи пайплайна (очередь/отсев): метаданные сообщения, ссылка
// на исходник в Telegram и само исходное сообщение с форматированием.
// Одинаковый блок у карточек очереди и отсева вынесен, чтобы шаблоны не
// дублировали ~30 строк.
// «Подробнее» записи пайплайна (очередь/отсев): метаданные записи, ссылка
// на оригинал и универсальное содержимое источника. Одинаковый блок у карточек
// очереди и отсева вынесен, чтобы шаблоны не дублировали разметку.
import { computed } from 'vue'
import Icon from '../Icon.vue'
import SourceContentView from './SourceContentView.vue'
defineProps({
const props = defineProps({
// [{ label, text, mono?, num? }] строки метаданных (id, время)
meta: { type: Array, default: () => [] },
// SourceContent: текст/ссылки/контакты/вложения
content: { type: Object, default: null },
// HTML исходного сообщения (уже отрендерен и мемоизирован родителем)
sourceHtml: { type: String, default: '' },
// ссылка «Открыть исходник в Telegram» (null не показывать)
// ссылка на оригинал (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>
<template>
@@ -41,16 +51,12 @@ defineProps({
</a>
</div>
<details v-if="sourceHtml" class="mt-1.5 group">
<summary
class="cursor-pointer list-none text-[11px] text-low/70 hover:text-mid transition-colors select-none"
>
{{ $t('common.sourceMessage') }}
</summary>
<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>
<SourceContentView
v-if="hasContent"
class="mt-1.5"
:content="content"
:html="sourceHtml"
:summary="$t('common.sourceMessage')"
/>
</div>
</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": "Назад",
"next": "Вперёд",
"range": "{from}{to} из {total}",
"openSource": "Открыть исходник в Telegram",
"openSource": "Открыть исходник",
"sourceMessage": "Исходное сообщение (с форматированием)",
"comments": "Комментарии",
"noComments": "Комментариев пока нет",
@@ -392,8 +392,19 @@ export const ru = {
"colSaved": "Колонка «{name}» сохранена",
"colCreated": "Колонка «{name}» создана — заявки будут скапливаться здесь"
},
"source": {
"links": "Ссылки",
"contacts": "Контакты",
"contact": "Контакт",
"attachments": "Вложения",
"attachment": "Вложение",
"download": "Скачать"
},
"processing": {
"server-nedostupen-proverte-chto-backend": "Сервер недоступен — проверьте, что backend запущен (порт 8000)",
"author": "Автор",
"externalId": "ID в источнике",
"originRef": "Оригинал",
"zapis-otseva-udalena": "Запись отсева удалена",
"udaleno": "Удалено",
"vozvrashcheno-v-obrabotku-prichiny-otseva": "Возвращено в обработку — причины отсева сняты",
@@ -1018,7 +1029,7 @@ export const ru = {
"otkryt": "Открыть",
"kopirovat": "Копировать",
"ishodnoe-soobshchenie": "Исходное сообщение",
"otkryt-ishodnoe-soobshchenie-v-telegram": "Открыть исходное сообщение в Telegram",
"otkryt-ishodnoe-soobshchenie": "Открыть исходное сообщение",
"otkryt-ishodnik": "Открыть исходник",
"pokazat-tekst": "— показать текст",
"udalit-ssylku": "Удалить ссылку",
+178 -182
View File
@@ -1,183 +1,179 @@
import { t } from '@/i18n/index.js'
// ─── Мелкие утилиты: звук, буфер обмена ──────────────────────────────────
let audioCtx = null
function ctx() {
if (!audioCtx) {
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)()
} catch {
audioCtx = null
}
}
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume()
return audioCtx
}
function tone(freq, start, dur, vol) {
const ac = ctx()
if (!ac) return
const osc = ac.createOscillator()
const gain = ac.createGain()
osc.type = 'sine'
osc.frequency.value = freq
gain.gain.setValueAtTime(0.0001, ac.currentTime + start)
gain.gain.exponentialRampToValueAtTime(vol, ac.currentTime + start + 0.02)
gain.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + start + dur)
osc.connect(gain).connect(ac.destination)
osc.start(ac.currentTime + start)
osc.stop(ac.currentTime + start + dur + 0.05)
}
// Деликатный «пинг» новой карточки — два мягких тона
export function playLeadPing(volume = 0.35) {
const v = Math.max(0.02, Math.min(1, volume)) * 0.35
tone(880, 0, 0.18, v)
tone(1318.5, 0.16, 0.3, v * 0.8)
}
// Проверка звука в настройках
export function playTestPing(volume = 0.5) {
tone(660, 0, 0.12, Math.max(0.02, Math.min(1, volume)) * 0.4)
tone(990, 0.14, 0.25, Math.max(0.02, Math.min(1, volume)) * 0.3)
}
export async function copyText(text) {
if (!text) return false
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return true
}
} catch {
/* пробуем запасной путь */
}
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
return true
} catch {
return false
}
}
// ─── Рендер исходных сообщений (лёгкий Telegram/Markdown) ─────────────────
// Текст из каналов содержит ручную разметку: **жирный**, `код`, ```блоки```,
// [ссылка](url), ~~зачёркнуто~~, ||спойлер|| и голые ссылки.
// Всё экранируется до вставки в HTML — рендер безопасен (без raw HTML).
const _ESC_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
function _esc(s) {
return s.replace(/[&<>"']/g, (c) => _ESC_MAP[c])
}
// рендер одного элемента в <a> с https-проверкой (только http/https/tg)
function _safeUrl(u) {
return /^(https?:|tg:)\/\//i.test(u) ? u : null
}
export function renderSourceMessage(text) {
if (!text) return ''
let t = _esc(String(text))
const stash = []
const take = (html) => {
const ph = `\u0001${stash.length}\u0001`
stash.push(html)
return ph
}
// блоки кода ```…```
t = t.replace(/```([^`]*)```/g, (_, code) => take(`<pre>${code}</pre>`))
// инлайн-код `…`
t = t.replace(/`([^`\n]+)`/g, (_, c) => take(`<code>${c}</code>`))
// ссылки [текст](url)
t = t.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
const u = _safeUrl(url)
return u ? take(`<a href="${u}" target="_blank" rel="noreferrer">${label}</a>`) : `[${label}]( ${url} )`
})
// жирный **…**
t = t.replace(/\*\*([^*\n]+)\*\*/g, (_, m) => take(`<b>${m}</b>`))
// зачёркнутый ~~…~~
t = t.replace(/~~([^~\n]+)~~/g, (_, m) => take(`<s>${m}</s>`))
// спойлер ||…||
t = t.replace(/\|\|([^|\n]+)\|\|/g, (_, m) => take(`<span class="tgmd-spoiler">${m}</span>`))
// голые ссылки http(s)
t = t.replace(/(https?:\/\/[^\s<]+)/g, (u) => {
let clean = u
while (/[),.;:!?]+$/.test(clean)) clean = clean.slice(0, -1)
const href = clean || u
return take(`<a href="${_esc(href)}" target="_blank" rel="noreferrer">${href}</a>`)
})
// восстанавливаем отложенные фрагменты
return t.replace(/\u0001(\d+)\u0001/g, (_, i) => stash[+i] ?? '')
}
export function humanSize(bytes = 0) {
if (!bytes) return '—'
const units = [t('common.b'), t('common.kb'), t('common.mb'), t('common.gb')]
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v >= 100 ? Math.round(v) : Math.round(v * 10) / 10} ${units[i]}`
}
// ─── Контакты заказчика: по строке определяем, куда вести ────────────────
// Контакт может быть чем угодно: @username (Telegram), e-mail, телефон,
// whatsapp-ссылка, обычный URL.
export function contactInfo(contact = '') {
const raw = String(contact || '').trim()
if (!raw) return null
if (raw.startsWith('@')) {
return { label: t('common.napisat-v-telegram'), href: `https://t.me/${raw.slice(1)}` }
}
if (/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(raw)) {
return { label: t('common.napisat-na-pochtu'), href: `mailto:${raw}` }
}
if (/^\+[\d\s\-()]{6,}$/.test(raw)) {
return { label: t('common.pozvonit'), href: `tel:${raw.replace(/[^+\d]/g, '')}` }
}
if (/^https?:\/\//i.test(raw)) {
return { label: t('common.otkryt-ssylku'), href: raw }
}
return null
}
// Ссылка на исходное сообщение в Telegram по сохранённым id (если доступна)
export function tgSourceUrl(lead) {
if (!lead || !lead.sourceMsgId) return null
const id = lead.sourceMsgId
const handle = lead.channel?.handle || lead.ch?.handle
if (handle) return `https://t.me/${handle}/${id}`
// приватный канал: t.me/c/<peer>/<msg> (без префикса -100)
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-ссылок и служебной разметки
// (страховка для старых карточек из сырых сообщений).
export function cleanTitle(raw = '') {
return String(raw || '')
.replace(/\[([^\]]*)\]\([^)\s]+\)/g, '$1') // [текст](url) -> текст
.replace(/https?:\/\/[^\s]*(?=\s|$)/g, '') // голые ссылки
.replace(/[*_`#>~]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
// «1 200,5» / «1 200.5» -> число (null, если не число). Парсинг сумм из полей.
export function toFloat(v) {
const n = parseFloat(String(v ?? '').replace(/\s/g, '').replace(',', '.'))
return Number.isFinite(n) ? n : null
}
// ─── Мелкие утилиты: звук, буфер обмена ──────────────────────────────────
let audioCtx = null
function ctx() {
if (!audioCtx) {
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)()
} catch {
audioCtx = null
}
}
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume()
return audioCtx
}
function tone(freq, start, dur, vol) {
const ac = ctx()
if (!ac) return
const osc = ac.createOscillator()
const gain = ac.createGain()
osc.type = 'sine'
osc.frequency.value = freq
gain.gain.setValueAtTime(0.0001, ac.currentTime + start)
gain.gain.exponentialRampToValueAtTime(vol, ac.currentTime + start + 0.02)
gain.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + start + dur)
osc.connect(gain).connect(ac.destination)
osc.start(ac.currentTime + start)
osc.stop(ac.currentTime + start + dur + 0.05)
}
// Деликатный «пинг» новой карточки — два мягких тона
export function playLeadPing(volume = 0.35) {
const v = Math.max(0.02, Math.min(1, volume)) * 0.35
tone(880, 0, 0.18, v)
tone(1318.5, 0.16, 0.3, v * 0.8)
}
// Проверка звука в настройках
export function playTestPing(volume = 0.5) {
tone(660, 0, 0.12, Math.max(0.02, Math.min(1, volume)) * 0.4)
tone(990, 0.14, 0.25, Math.max(0.02, Math.min(1, volume)) * 0.3)
}
export async function copyText(text) {
if (!text) return false
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return true
}
} catch {
/* пробуем запасной путь */
}
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
return true
} catch {
return false
}
}
// ─── Рендер исходных сообщений (лёгкий Telegram/Markdown) ─────────────────
// Текст из каналов содержит ручную разметку: **жирный**, `код`, ```блоки```,
// [ссылка](url), ~~зачёркнуто~~, ||спойлер|| и голые ссылки.
// Всё экранируется до вставки в HTML — рендер безопасен (без raw HTML).
const _ESC_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
function _esc(s) {
return s.replace(/[&<>"']/g, (c) => _ESC_MAP[c])
}
// рендер одного элемента в <a> с https-проверкой (только http/https/tg)
function _safeUrl(u) {
return /^(https?:|tg:)\/\//i.test(u) ? u : null
}
export function renderSourceMessage(text) {
if (!text) return ''
let t = _esc(String(text))
const stash = []
const take = (html) => {
const ph = `\u0001${stash.length}\u0001`
stash.push(html)
return ph
}
// блоки кода ```…```
t = t.replace(/```([^`]*)```/g, (_, code) => take(`<pre>${code}</pre>`))
// инлайн-код `…`
t = t.replace(/`([^`\n]+)`/g, (_, c) => take(`<code>${c}</code>`))
// ссылки [текст](url)
t = t.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
const u = _safeUrl(url)
return u ? take(`<a href="${u}" target="_blank" rel="noreferrer">${label}</a>`) : `[${label}]( ${url} )`
})
// жирный **…**
t = t.replace(/\*\*([^*\n]+)\*\*/g, (_, m) => take(`<b>${m}</b>`))
// зачёркнутый ~~…~~
t = t.replace(/~~([^~\n]+)~~/g, (_, m) => take(`<s>${m}</s>`))
// спойлер ||…||
t = t.replace(/\|\|([^|\n]+)\|\|/g, (_, m) => take(`<span class="tgmd-spoiler">${m}</span>`))
// голые ссылки http(s)
t = t.replace(/(https?:\/\/[^\s<]+)/g, (u) => {
let clean = u
while (/[),.;:!?]+$/.test(clean)) clean = clean.slice(0, -1)
const href = clean || u
return take(`<a href="${_esc(href)}" target="_blank" rel="noreferrer">${href}</a>`)
})
// восстанавливаем отложенные фрагменты
return t.replace(/\u0001(\d+)\u0001/g, (_, i) => stash[+i] ?? '')
}
export function humanSize(bytes = 0) {
if (!bytes) return '—'
const units = [t('common.b'), t('common.kb'), t('common.mb'), t('common.gb')]
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v >= 100 ? Math.round(v) : Math.round(v * 10) / 10} ${units[i]}`
}
// ─── Контакты заказчика: по строке определяем, куда вести ────────────────
// Контакт может быть чем угодно: @username (Telegram), e-mail, телефон,
// whatsapp-ссылка, обычный URL.
export function contactInfo(contact = '') {
const raw = String(contact || '').trim()
if (!raw) return null
if (raw.startsWith('@')) {
return { label: t('common.napisat-v-telegram'), href: `https://t.me/${raw.slice(1)}` }
}
if (/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(raw)) {
return { label: t('common.napisat-na-pochtu'), href: `mailto:${raw}` }
}
if (/^\+[\d\s\-()]{6,}$/.test(raw)) {
return { label: t('common.pozvonit'), href: `tel:${raw.replace(/[^+\d]/g, '')}` }
}
if (/^https?:\/\//i.test(raw)) {
return { label: t('common.otkryt-ssylku'), href: raw }
}
return null
}
// Ссылка на исходник: отдаём `source.originRef`, только если это абсолютный
// http(s)-URL. Глубокие ссылки конкретных источников здесь не собираем — их
// формирует сам источник и кладёт в originRef. Иначе ссылка недоступна.
export function sourceOriginUrl(item) {
const ref = item?.source?.originRef
const url = typeof ref === 'string' ? ref.trim() : ''
return /^https?:\/\//i.test(url) ? url : null
}
// Чистый заголовок карточки: без markdown-ссылок и служебной разметки
// (страховка для старых карточек из сырых сообщений).
export function cleanTitle(raw = '') {
return String(raw || '')
.replace(/\[([^\]]*)\]\([^)\s]+\)/g, '$1') // [текст](url) -> текст
.replace(/https?:\/\/[^\s]*(?=\s|$)/g, '') // голые ссылки
.replace(/[*_`#>~]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
// «1 200,5» / «1 200.5» -> число (null, если не число). Парсинг сумм из полей.
export function toFloat(v) {
const n = parseFloat(String(v ?? '').replace(/\s/g, '').replace(',', '.'))
return Number.isFinite(n) ? n : null
}
+24 -23
View File
@@ -17,7 +17,7 @@ import {
relTime,
} from '../store.js'
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 ChannelAvatar from '../components/ui/ChannelAvatar.vue'
import PipelineDetails from '../components/ui/PipelineDetails.vue'
@@ -50,8 +50,11 @@ function srcHtml(item) {
// строки метаданных для «Подробнее» очередь и отсев почти одинаковы
function metaRows(item, extra = []) {
const rows = []
if (item.dialogId) rows.push({ label: t('processing.dialog'), text: item.dialogId, mono: true })
if (item.msgId) rows.push({ label: t('processing.id-soobshcheniya'), text: item.msgId, mono: true })
const src = item.source || {}
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 })
for (const e of extra) {
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
}
// ссылка на исходное сообщение в Telegram: @handle/msg или приватный канал
// ссылка на исходник: originRef источника, если это абсолютный URL
function sourceUrl(item) {
return tgSourceUrl({
ch: item.ch,
sourceDialogId: item.dialogId,
sourceMsgId: item.msgId,
})
return sourceOriginUrl(item)
}
async function reloadAll() {
@@ -134,7 +133,7 @@ async function onClearAll() {
// можно ли вернуть запись в обработку: повтор возвращать нечего (карточка уже есть)
function canReturn(r) {
return !!r && !r.returned && r.source !== 'dup'
return !!r && !r.returned && r.decidedBy !== 'dup'
}
function openReturn(item) {
@@ -309,11 +308,11 @@ onBeforeUnmount(() => {
:class="m.status === 'filtered' ? 'border-warn/20' : ''"
>
<div class="flex items-center gap-2 mb-1.5">
<ChannelAvatar :name="m.ch.name" :handle="m.ch.handle" />
<span class="text-[12px] font-medium text-mid truncate" :title="m.ch.handle ? '@' + m.ch.handle : ''">
{{ m.ch.name }}
<template v-if="m.ch.handle">
<span class="text-low/60 font-mono">@{{ m.ch.handle }}</span>
<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.source?.originRef || ''">
{{ m.source?.displayName || $t('processing.istochnik') }}
<template v-if="m.source?.originRef">
<span class="text-low/60 font-mono">{{ m.source.originRef }}</span>
</template>
</span>
<span
@@ -354,6 +353,7 @@ onBeforeUnmount(() => {
<PipelineDetails
v-if="detailOpen.has(m.id)"
:meta="metaRows(m, [{ key: 'queuedAt', label: $t('processing.v-ocheredi-2') }])"
:content="m.content"
:source-html="srcHtml(m)"
:source-url="sourceUrl(m)"
/>
@@ -391,18 +391,18 @@ onBeforeUnmount(() => {
>
<!-- Шапка: источник + причина -->
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
<ChannelAvatar :name="r.ch.name" :handle="r.ch.handle" />
<span class="text-[12px] font-medium text-mid truncate" :title="r.ch.handle ? '@' + r.ch.handle : ''">
{{ r.ch.name || $t('processing.istochnik') }}
<template v-if="r.ch.handle">
<span class="text-low/60 font-mono">@{{ r.ch.handle }}</span>
<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.source?.originRef || ''">
{{ r.source?.displayName || $t('processing.istochnik') }}
<template v-if="r.source?.originRef">
<span class="text-low/60 font-mono">{{ r.source.originRef }}</span>
</template>
</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="srcStyle(r.source).cls"
:class="srcStyle(r.decidedBy).cls"
>
{{ srcStyle(r.source).label }}
{{ r.decidedByLabel || srcStyle(r.decidedBy).label }}
</span>
<span
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" />
{{ $t('processing.vozvrashcheno-v-obrabotku') }}
</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') }}
</span>
<button
@@ -478,6 +478,7 @@ onBeforeUnmount(() => {
<PipelineDetails
v-if="detailOpen.has(r.id)"
:meta="metaRows(r, [{ key: 'rejectedAt', label: $t('processing.otseyano') }, { key: 'returnedAt', label: $t('processing.vozvrashcheno') }])"
:content="r.content"
:source-html="srcHtml(r)"
:source-url="sourceUrl(r)"
/>