From ebc761d40e60bc61937df7fed6a4c9855edbb0d3 Mon Sep 17 00:00:00 2001 From: Rustam Khalimov Date: Fri, 11 Sep 2026 14:58:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D1=8F=D0=B4=D1=80=D0=BE=20=D0=BD=D0=B0=20=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка, очередь и отсев работают с SourceItem (SourceRef + SourceContent); Telegram-поля убраны из домена, персистентности, конвейера и wire, остались только в адаптере приёма. Tenant-миграции пересозданы с нуля. Фронт переведён на generic source/content с просмотрщиком вложений. --- backlog.md | 4 + docs/api/api-map.md | 15 +- docs/superpowers/STATUS.md | 16 +- .../2026-09-11-source-contract-design.md | 44 +- .../Техническая-документация-Дейл.md | 8 + src/core/Deal.Api/Endpoints/CardsEndpoints.cs | 2 +- .../Telegram/TelegramIngressService.cs | 33 +- .../Services/LocalColumnSuggester.cs | 6 +- .../20260905193010_InitialTenant.Designer.cs | 49 - .../TenantDb/20260905193010_InitialTenant.cs | 34 - .../20260906125058_TenantKanban.Designer.cs | 307 ---- .../TenantDb/20260906125058_TenantKanban.cs | 172 -- .../20260906165058_TenantPipeline.Designer.cs | 462 ----- .../TenantDb/20260906165058_TenantPipeline.cs | 128 -- .../20260906200342_TenantProjects.Designer.cs | 549 ------ .../TenantDb/20260906200342_TenantProjects.cs | 69 - .../20260907141242_TenantTelegram.Designer.cs | 619 ------- .../TenantDb/20260907141242_TenantTelegram.cs | 64 - ...20260907155333_TenantDiscovery.Designer.cs | 801 --------- .../20260907155333_TenantDiscovery.cs | 124 -- ...0260909222322_TenantContainers.Designer.cs | 879 ---------- .../20260909222322_TenantContainers.cs | 68 - ...260910132805_TenantUnifiedCard.Designer.cs | 823 --------- .../20260910132805_TenantUnifiedCard.cs | 166 -- ...134104_TenantContainerRegistry.Designer.cs | 763 --------- .../20260910134104_TenantContainerRegistry.cs | 50 - .../20260910134358_TenantContainerCleanup.cs | 40 - .../Configurations/CardConfiguration.cs | 12 +- .../Configurations/QueueItemConfiguration.cs | 6 + .../RejectedItemConfiguration.cs | 3 + .../Persistence/Entities/CardEntity.cs | 42 +- .../Persistence/Entities/QueueItemEntity.cs | 22 +- .../Entities/RejectedItemEntity.cs | 22 +- .../20260911114400_InitialTenant.Designer.cs} | 1499 ++++++++--------- .../TenantDb/20260911114400_InitialTenant.cs | 462 +++++ .../TenantDb/TenantDbContextModelSnapshot.cs | 1493 ++++++++-------- .../Repositories/KanbanStore.Cards.cs | 14 +- .../Repositories/KanbanStore.Comments.cs | 4 +- .../Repositories/KanbanStore.StorageRules.cs | 2 +- .../Persistence/Repositories/KanbanStore.cs | 43 +- .../Persistence/Repositories/PipelineStore.cs | 77 +- .../Application/Sources/SourceRefs.cs | 47 + .../Application/Abstractions/ICardStore.cs | 9 +- .../Application/Models/CardChannelDto.cs | 9 - .../Application/Models/CardDto.cs | 30 +- .../Application/Models/CardSnapshot.cs | 30 +- .../Application/Models/CardSourceDto.cs | 14 - .../Services/CardsService.Helpers.cs | 2 +- .../Services/CardsService.Selected.cs | 5 + .../Application/Services/SuggestHeuristics.cs | 10 +- .../Abstractions/IPipelineStore.cs | 11 +- .../Application/Models/MlCandidateDto.cs | 14 +- .../Application/Models/PipelineChannelDto.cs | 9 - .../Application/Models/QueueItemDto.cs | 21 +- .../Application/Models/QueuedMessage.cs | 40 +- .../Application/Models/RejectRecord.cs | 39 +- .../Application/Models/RejectedItemDto.cs | 39 +- .../Application/Parse/ContactsQualifier.cs | 22 +- .../Application/Services/CardComposer.cs | 14 +- .../Application/Services/CardReclassifier.cs | 15 +- .../Application/Services/MlReviewService.cs | 186 +- .../Services/PipelineIngestService.cs | 29 +- .../Services/PipelineProcessingService.cs | 46 +- .../PipelineWorkerService.Rejections.cs | 9 +- .../Api/RuntimeDepthsCollectorTests.cs | 1 - .../Contracts/CardComposerTests.cs | 57 +- .../Contracts/CardsServiceTests.cs | 7 +- .../Contracts/PipelineCardWriterTests.cs | 18 +- .../Contracts/PipelineWorkerGrpcAiTests.cs | 17 +- .../Infrastructure/CardMoverTests.cs | 5 +- .../Modules/Cards/FakeKanjStore.cs | 27 +- .../Kanban/AiClassifyContextBuilderTests.cs | 3 +- .../Modules/Kanban/FakePipelineStore.cs | 19 +- .../Modules/Kanban/MlReviewServiceTests.cs | 57 +- .../Modules/Kanban/SuggestHeuristicsTests.cs | 7 +- .../Pipeline/PipelineIngestServiceTests.cs | 110 +- .../PipelineProcessingServiceTests.cs | 162 +- .../Support/AdminTickOrchestratorTests.cs | 43 +- .../Support/CardReclassifierTests.cs | 7 +- .../Support/LocalColumnSuggesterTests.cs | 9 +- .../Support/PipelineWorkerSchedulerTests.cs | 17 +- .../Support/PipelineWorkerServiceTests.cs | 1340 ++++++++------- .../Support/SourceItemFactory.cs | 201 +++ .../Support/SourceRefsTests.cs | 43 + .../Support/TelegramIngressServiceTests.cs | 11 +- src/frontend/src/components/card/Card.vue | 6 +- .../src/components/card/CardDrawer.vue | 46 +- .../src/components/ui/ChannelAvatar.vue | 25 +- .../src/components/ui/PipelineDetails.vue | 40 +- .../src/components/ui/SourceContentView.vue | 168 ++ src/frontend/src/components/ui/fileMeta.js | 34 + src/frontend/src/i18n/locales/ru.js | 15 +- src/frontend/src/utils.js | 360 ++-- src/frontend/src/views/ProcessingView.vue | 47 +- 94 files changed, 4150 insertions(+), 9398 deletions(-) delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260905193010_InitialTenant.Designer.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260905193010_InitialTenant.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.Designer.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260906165058_TenantPipeline.Designer.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260906165058_TenantPipeline.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260906200342_TenantProjects.Designer.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260906200342_TenantProjects.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260907141242_TenantTelegram.Designer.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260907141242_TenantTelegram.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260907155333_TenantDiscovery.Designer.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260907155333_TenantDiscovery.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260909222322_TenantContainers.Designer.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260909222322_TenantContainers.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260910132805_TenantUnifiedCard.Designer.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260910132805_TenantUnifiedCard.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134104_TenantContainerRegistry.Designer.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134104_TenantContainerRegistry.cs delete mode 100644 src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134358_TenantContainerCleanup.cs rename src/core/Deal.Infrastructure/{Migrations/TenantDb/20260910134358_TenantContainerCleanup.Designer.cs => Persistence/Migrations/TenantDb/20260911114400_InitialTenant.Designer.cs} (93%) create mode 100644 src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.cs rename src/core/Deal.Infrastructure/{ => Persistence}/Migrations/TenantDb/TenantDbContextModelSnapshot.cs (93%) create mode 100644 src/core/Deal.Modules.Cards/Application/Sources/SourceRefs.cs delete mode 100644 src/core/Deal.Modules.Kanban/Application/Models/CardChannelDto.cs delete mode 100644 src/core/Deal.Modules.Kanban/Application/Models/CardSourceDto.cs delete mode 100644 src/core/Deal.Modules.Pipeline/Application/Models/PipelineChannelDto.cs create mode 100644 src/core/tests/Deal.Tests.Unit/Support/SourceItemFactory.cs create mode 100644 src/core/tests/Deal.Tests.Unit/Support/SourceRefsTests.cs create mode 100644 src/frontend/src/components/ui/SourceContentView.vue create mode 100644 src/frontend/src/components/ui/fileMeta.js diff --git a/backlog.md b/backlog.md index 7b5aeca..bb418e0 100644 --- a/backlog.md +++ b/backlog.md @@ -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-проверки (нужны внешние условия) diff --git a/docs/api/api-map.md b/docs/api/api-map.md index 2aeb02d..edae171 100644 --- a/docs/api/api-map.md +++ b/docs/api/api-map.md @@ -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, "": {count: int, new: int}, learning: int, ml: int, ai: int}` — плоская форма (совместима с прежним `/api/leads/counts`). ### 4.2 Контейнер (container) — `GET /api/containers`, `POST/PATCH` тела diff --git a/docs/superpowers/STATUS.md b/docs/superpowers/STATUS.md index 8c02a7b..addbf62 100644 --- a/docs/superpowers/STATUS.md +++ b/docs/superpowers/STATUS.md @@ -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+ diff --git a/docs/superpowers/specs/2026-09-11-source-contract-design.md b/docs/superpowers/specs/2026-09-11-source-contract-design.md index 3b27481..d0eab20 100644 --- a/docs/superpowers/specs/2026-09-11-source-contract-design.md +++ b/docs/superpowers/specs/2026-09-11-source-contract-design.md @@ -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-поля не проходят дальше адаптера. diff --git a/docs/technical/Техническая-документация-Дейл.md b/docs/technical/Техническая-документация-Дейл.md index 826e235..89641c9 100644 --- a/docs/technical/Техническая-документация-Дейл.md +++ b/docs/technical/Техническая-документация-Дейл.md @@ -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 — сработавшая diff --git a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs index f7f18c9..1a7f056 100644 --- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs @@ -423,7 +423,7 @@ public static class CardsEndpoints CardsService cardsService = context.RequestServices.GetRequiredService(); IReadOnlyList leads = await cardsService.SearchCardsAsync(q, ct); - // messages всегда []: telegram-сообщений здесь нет, фронт их не читает. + // messages всегда []: фронт их не читает. return Results.Ok(new { cards = leads, messages = Array.Empty() }); } diff --git a/src/core/Deal.Api/Telegram/TelegramIngressService.cs b/src/core/Deal.Api/Telegram/TelegramIngressService.cs index 0ee9a0c..ef5f9c3 100644 --- a/src/core/Deal.Api/Telegram/TelegramIngressService.cs +++ b/src/core/Deal.Api/Telegram/TelegramIngressService.cs @@ -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(); - 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 { [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); diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs index 737a7d5..b7c2d8c 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs @@ -88,9 +88,9 @@ ContainersService containersService) : IColumnSuggester List 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 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) diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260905193010_InitialTenant.Designer.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260905193010_InitialTenant.Designer.cs deleted file mode 100644 index 9065fa7..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260905193010_InitialTenant.Designer.cs +++ /dev/null @@ -1,49 +0,0 @@ -// -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 - { - /// - 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("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ValueJson") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Key"); - - b.ToTable("settings", (string)null); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260905193010_InitialTenant.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260905193010_InitialTenant.cs deleted file mode 100644 index 2820a5b..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260905193010_InitialTenant.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Deal.Infrastructure.Migrations.TenantDb -{ - /// - public partial class InitialTenant : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "settings", - columns: table => new - { - Key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - ValueJson = table.Column(type: "text", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_settings", x => x.Key); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "settings"); - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.Designer.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.Designer.cs deleted file mode 100644 index 50d197d..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.Designer.cs +++ /dev/null @@ -1,307 +0,0 @@ -// -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 - { - /// - 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("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("Prompt") - .IsRequired() - .HasColumnType("text"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.Property("VisibleFieldsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ArchivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("Col") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContactsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvFrom") - .HasColumnType("double precision"); - - b.Property("ConvTo") - .HasColumnType("double precision"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("IsNew") - .HasColumnType("boolean"); - - b.Property("IsVacancy") - .HasColumnType("boolean"); - - b.Property("IsVacancyKnown") - .HasColumnType("boolean"); - - b.Property("MatchHitsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("PrevCol") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReceivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SourceDialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsg") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsgId") - .HasColumnType("bigint"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("Action") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FromCol") - .HasColumnType("text"); - - b.Property("LeadId") - .IsRequired() - .HasColumnType("text"); - - b.Property("ToCol") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("CardMoves", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("By") - .IsRequired() - .HasColumnType("text"); - - b.Property("CardId") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Delta") - .HasColumnType("double precision"); - - b.Property("Label") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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 - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.cs deleted file mode 100644 index 80b4d98..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.cs +++ /dev/null @@ -1,172 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Deal.Infrastructure.Migrations.TenantDb -{ - /// - public partial class TenantKanban : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Boards", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Description = table.Column(type: "text", nullable: false), - Color = table.Column(type: "text", nullable: false), - Width = table.Column(type: "text", nullable: false), - Position = table.Column(type: "integer", nullable: false), - KeywordsJson = table.Column(type: "text", nullable: false), - Prompt = table.Column(type: "text", nullable: false), - VisibleFieldsJson = table.Column(type: "text", nullable: false), - Collapsed = table.Column(type: "boolean", nullable: false), - Suggested = table.Column(type: "boolean", nullable: false), - RulesJson = table.Column(type: "text", nullable: false), - Note = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(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(type: "text", nullable: false), - LeadId = table.Column(type: "text", nullable: false), - Action = table.Column(type: "text", nullable: false), - FromCol = table.Column(type: "text", nullable: true), - ToCol = table.Column(type: "text", nullable: true), - CreatedAt = table.Column(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(type: "text", nullable: false), - Col = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - IsNew = table.Column(type: "boolean", nullable: false), - IsVacancy = table.Column(type: "boolean", nullable: false), - IsVacancyKnown = table.Column(type: "boolean", nullable: false), - Title = table.Column(type: "text", nullable: false), - Summary = table.Column(type: "text", nullable: false), - StackJson = table.Column(type: "text", nullable: false), - BudgetFrom = table.Column(type: "double precision", nullable: true), - BudgetTo = table.Column(type: "double precision", nullable: true), - BudgetCur = table.Column(type: "text", nullable: false), - ConvFrom = table.Column(type: "double precision", nullable: true), - ConvTo = table.Column(type: "double precision", nullable: true), - ConvCur = table.Column(type: "text", nullable: false), - Contact = table.Column(type: "text", nullable: false), - ContactsJson = table.Column(type: "text", nullable: false), - ChannelName = table.Column(type: "text", nullable: false), - ChannelHandle = table.Column(type: "text", nullable: false), - ChannelHue = table.Column(type: "text", nullable: false), - ReceivedAt = table.Column(type: "timestamp with time zone", nullable: false), - SourceMsg = table.Column(type: "text", nullable: false), - SourceDialogId = table.Column(type: "text", nullable: false), - SourceMsgId = table.Column(type: "bigint", nullable: true), - PrevCol = table.Column(type: "text", nullable: false), - ArchivedAt = table.Column(type: "timestamp with time zone", nullable: true), - MatchHitsJson = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(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(type: "text", nullable: false), - Text = table.Column(type: "text", nullable: false), - Label = table.Column(type: "text", nullable: false), - Delta = table.Column(type: "double precision", nullable: false), - CreatedAt = table.Column(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(type: "text", nullable: false), - CardId = table.Column(type: "text", nullable: false), - By = table.Column(type: "text", nullable: false), - Text = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(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"); - } - - /// - 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"); - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906165058_TenantPipeline.Designer.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906165058_TenantPipeline.Designer.cs deleted file mode 100644 index 7b9a417..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906165058_TenantPipeline.Designer.cs +++ /dev/null @@ -1,462 +0,0 @@ -// -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 - { - /// - 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("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("Prompt") - .IsRequired() - .HasColumnType("text"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.Property("VisibleFieldsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ArchivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("Col") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContactsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvFrom") - .HasColumnType("double precision"); - - b.Property("ConvTo") - .HasColumnType("double precision"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("IsNew") - .HasColumnType("boolean"); - - b.Property("IsVacancy") - .HasColumnType("boolean"); - - b.Property("IsVacancyKnown") - .HasColumnType("boolean"); - - b.Property("MatchHitsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("PrevCol") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReceivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true); - - b.Property("SourceDialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsg") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsgId") - .HasColumnType("bigint"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("Action") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FromCol") - .HasColumnType("text"); - - b.Property("LeadId") - .IsRequired() - .HasColumnType("text"); - - b.Property("ToCol") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("CardMoves", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b => - { - b.Property("Hash") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.HasKey("Hash"); - - b.ToTable("DedupEntries", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("By") - .IsRequired() - .HasColumnType("text"); - - b.Property("CardId") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Delta") - .HasColumnType("double precision"); - - b.Property("Label") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Force") - .HasColumnType("boolean"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kw") - .IsRequired() - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.Property("RejectedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReturnReason") - .IsRequired() - .HasColumnType("text"); - - b.Property("Returned") - .HasColumnType("boolean"); - - b.Property("ReturnedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); - - b.Property("Source") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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 - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906165058_TenantPipeline.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906165058_TenantPipeline.cs deleted file mode 100644 index 03e7e0e..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906165058_TenantPipeline.cs +++ /dev/null @@ -1,128 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using NpgsqlTypes; - -#nullable disable - -namespace Deal.Infrastructure.Migrations.TenantDb -{ - /// - public partial class TenantPipeline : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - 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(type: "text", nullable: false), - LeadId = table.Column(type: "text", nullable: true), - CreatedAt = table.Column(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(type: "text", nullable: false), - DialogId = table.Column(type: "text", nullable: false), - ChannelName = table.Column(type: "text", nullable: false), - ChannelHandle = table.Column(type: "text", nullable: false), - ChannelHue = table.Column(type: "text", nullable: false), - Text = table.Column(type: "text", nullable: false), - MsgId = table.Column(type: "bigint", nullable: true), - MsgAt = table.Column(type: "timestamp with time zone", nullable: false), - Status = table.Column(type: "text", nullable: false), - Force = table.Column(type: "boolean", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(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(type: "text", nullable: false), - DialogId = table.Column(type: "text", nullable: false), - MsgId = table.Column(type: "bigint", nullable: true), - ChannelName = table.Column(type: "text", nullable: false), - ChannelHandle = table.Column(type: "text", nullable: false), - ChannelHue = table.Column(type: "text", nullable: false), - Text = table.Column(type: "text", nullable: false), - Stage = table.Column(type: "text", nullable: false), - Reason = table.Column(type: "text", nullable: false), - Kw = table.Column(type: "text", nullable: false), - Source = table.Column(type: "text", nullable: false), - MsgAt = table.Column(type: "timestamp with time zone", nullable: false), - RejectedAt = table.Column(type: "timestamp with time zone", nullable: false), - Returned = table.Column(type: "boolean", nullable: false), - ReturnedAt = table.Column(type: "timestamp with time zone", nullable: true), - ReturnReason = table.Column(type: "text", nullable: false), - SearchTsv = table.Column(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"); - } - - /// - 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"); - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906200342_TenantProjects.Designer.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906200342_TenantProjects.Designer.cs deleted file mode 100644 index 059c001..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906200342_TenantProjects.Designer.cs +++ /dev/null @@ -1,549 +0,0 @@ -// -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 - { - /// - 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("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("Prompt") - .IsRequired() - .HasColumnType("text"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.Property("VisibleFieldsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ArchivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("Col") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContactsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvFrom") - .HasColumnType("double precision"); - - b.Property("ConvTo") - .HasColumnType("double precision"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("IsNew") - .HasColumnType("boolean"); - - b.Property("IsVacancy") - .HasColumnType("boolean"); - - b.Property("IsVacancyKnown") - .HasColumnType("boolean"); - - b.Property("MatchHitsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("PrevCol") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReceivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true); - - b.Property("SourceDialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsg") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsgId") - .HasColumnType("bigint"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("Action") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FromCol") - .HasColumnType("text"); - - b.Property("LeadId") - .IsRequired() - .HasColumnType("text"); - - b.Property("ToCol") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("CardMoves", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b => - { - b.Property("Hash") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.HasKey("Hash"); - - b.ToTable("DedupEntries", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("By") - .IsRequired() - .HasColumnType("text"); - - b.Property("CardId") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Delta") - .HasColumnType("double precision"); - - b.Property("Label") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("CommentsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FilesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("HistoryJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("LinksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Local") - .HasColumnType("boolean"); - - b.Property("ReminderAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderFired") - .HasColumnType("boolean"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property("TzText") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Force") - .HasColumnType("boolean"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kw") - .IsRequired() - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.Property("RejectedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReturnReason") - .IsRequired() - .HasColumnType("text"); - - b.Property("Returned") - .HasColumnType("boolean"); - - b.Property("ReturnedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); - - b.Property("Source") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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 - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906200342_TenantProjects.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906200342_TenantProjects.cs deleted file mode 100644 index 2d07a7a..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906200342_TenantProjects.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Deal.Infrastructure.Migrations.TenantDb -{ - /// - public partial class TenantProjects : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "ProjectCards", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - Stage = table.Column(type: "text", nullable: false), - Local = table.Column(type: "boolean", nullable: false), - LeadId = table.Column(type: "text", nullable: true), - Title = table.Column(type: "text", nullable: false), - Summary = table.Column(type: "text", nullable: false), - StackJson = table.Column(type: "text", nullable: false), - BudgetFrom = table.Column(type: "double precision", nullable: true), - BudgetTo = table.Column(type: "double precision", nullable: true), - BudgetCur = table.Column(type: "text", nullable: false), - Contact = table.Column(type: "text", nullable: false), - CommentsJson = table.Column(type: "text", nullable: false), - LinksJson = table.Column(type: "text", nullable: false), - FilesJson = table.Column(type: "text", nullable: false), - HistoryJson = table.Column(type: "text", nullable: false), - TzText = table.Column(type: "text", nullable: false), - ReminderAt = table.Column(type: "timestamp with time zone", nullable: true), - ReminderFired = table.Column(type: "boolean", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(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]); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ProjectCards"); - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907141242_TenantTelegram.Designer.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907141242_TenantTelegram.Designer.cs deleted file mode 100644 index 3a9738a..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907141242_TenantTelegram.Designer.cs +++ /dev/null @@ -1,619 +0,0 @@ -// -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 - { - /// - 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("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("Prompt") - .IsRequired() - .HasColumnType("text"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.Property("VisibleFieldsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ArchivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("Col") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContactsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvFrom") - .HasColumnType("double precision"); - - b.Property("ConvTo") - .HasColumnType("double precision"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("IsNew") - .HasColumnType("boolean"); - - b.Property("IsVacancy") - .HasColumnType("boolean"); - - b.Property("IsVacancyKnown") - .HasColumnType("boolean"); - - b.Property("MatchHitsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("PrevCol") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReceivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true); - - b.Property("SourceDialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsg") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsgId") - .HasColumnType("bigint"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("Action") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FromCol") - .HasColumnType("text"); - - b.Property("LeadId") - .IsRequired() - .HasColumnType("text"); - - b.Property("ToCol") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("CardMoves", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b => - { - b.Property("Hash") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.HasKey("Hash"); - - b.ToTable("DedupEntries", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Backfilled") - .HasColumnType("boolean"); - - b.Property("Handle") - .IsRequired() - .HasColumnType("text"); - - b.Property("Hue") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("#666"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LastAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LastText") - .IsRequired() - .HasColumnType("text"); - - b.Property("Monitor") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("Dialogs", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("By") - .IsRequired() - .HasColumnType("text"); - - b.Property("CardId") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Delta") - .HasColumnType("double precision"); - - b.Property("Label") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("CommentsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FilesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("HistoryJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("LinksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Local") - .HasColumnType("boolean"); - - b.Property("ReminderAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderFired") - .HasColumnType("boolean"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property("TzText") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Force") - .HasColumnType("boolean"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kw") - .IsRequired() - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.Property("RejectedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReturnReason") - .IsRequired() - .HasColumnType("text"); - - b.Property("Returned") - .HasColumnType("boolean"); - - b.Property("ReturnedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); - - b.Property("Source") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ValueJson") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Key"); - - b.ToTable("settings", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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 - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907141242_TenantTelegram.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907141242_TenantTelegram.cs deleted file mode 100644 index 88f4771..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907141242_TenantTelegram.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Deal.Infrastructure.Migrations.TenantDb -{ - /// - public partial class TenantTelegram : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Dialogs", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Handle = table.Column(type: "text", nullable: false), - Kind = table.Column(type: "text", nullable: false), - Hue = table.Column(type: "text", nullable: false, defaultValue: "#666"), - Monitor = table.Column(type: "boolean", nullable: false), - LastText = table.Column(type: "text", nullable: false), - LastAt = table.Column(type: "timestamp with time zone", nullable: true), - Backfilled = table.Column(type: "boolean", nullable: false), - UpdatedAt = table.Column(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(type: "text", nullable: false), - DialogId = table.Column(type: "text", nullable: false), - Text = table.Column(type: "text", nullable: false), - MsgAt = table.Column(type: "timestamp with time zone", nullable: false), - LeadId = table.Column(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" }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Dialogs"); - - migrationBuilder.DropTable( - name: "TgMessages"); - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907155333_TenantDiscovery.Designer.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907155333_TenantDiscovery.Designer.cs deleted file mode 100644 index 3fd8b4f..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907155333_TenantDiscovery.Designer.cs +++ /dev/null @@ -1,801 +0,0 @@ -// -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 - { - /// - 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("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("Prompt") - .IsRequired() - .HasColumnType("text"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.Property("VisibleFieldsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ArchivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("Col") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContactsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvFrom") - .HasColumnType("double precision"); - - b.Property("ConvTo") - .HasColumnType("double precision"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("IsNew") - .HasColumnType("boolean"); - - b.Property("IsVacancy") - .HasColumnType("boolean"); - - b.Property("IsVacancyKnown") - .HasColumnType("boolean"); - - b.Property("MatchHitsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("PrevCol") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReceivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true); - - b.Property("SourceDialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsg") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsgId") - .HasColumnType("bigint"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("Action") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FromCol") - .HasColumnType("text"); - - b.Property("LeadId") - .IsRequired() - .HasColumnType("text"); - - b.Property("ToCol") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("CardMoves", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b => - { - b.Property("Hash") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.HasKey("Hash"); - - b.ToTable("DedupEntries", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Backfilled") - .HasColumnType("boolean"); - - b.Property("Handle") - .IsRequired() - .HasColumnType("text"); - - b.Property("Hue") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("#666"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LastAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LastText") - .IsRequired() - .HasColumnType("text"); - - b.Property("Monitor") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("Dialogs", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("DialogId"); - - b.ToTable("DiscBlacklist", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("AutoJoined") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FitRatio") - .HasColumnType("double precision"); - - b.Property("Hue") - .IsRequired() - .HasColumnType("text"); - - b.Property("JoinFailures") - .HasColumnType("integer"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LangRu") - .HasColumnType("boolean"); - - b.Property("MarksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Participants") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("TopicsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Event") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("AutoJoin") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Evaluated") - .HasColumnType("integer"); - - b.Property("Found") - .HasColumnType("integer"); - - b.Property("Joined") - .HasColumnType("integer"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Lang") - .IsRequired() - .HasColumnType("text"); - - b.Property("MinSubscribers") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("PlanJoins") - .HasColumnType("integer"); - - b.Property("Rejected") - .HasColumnType("integer"); - - b.Property("SampleSize") - .HasColumnType("integer"); - - b.Property("SearchDone") - .HasColumnType("boolean"); - - b.Property("SearchIdx") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Threshold") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("DiscTasks", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("By") - .IsRequired() - .HasColumnType("text"); - - b.Property("CardId") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Delta") - .HasColumnType("double precision"); - - b.Property("Label") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("CommentsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FilesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("HistoryJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("LinksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Local") - .HasColumnType("boolean"); - - b.Property("ReminderAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderFired") - .HasColumnType("boolean"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property("TzText") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Force") - .HasColumnType("boolean"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kw") - .IsRequired() - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.Property("RejectedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReturnReason") - .IsRequired() - .HasColumnType("text"); - - b.Property("Returned") - .HasColumnType("boolean"); - - b.Property("ReturnedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); - - b.Property("Source") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ValueJson") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Key"); - - b.ToTable("settings", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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 - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907155333_TenantDiscovery.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907155333_TenantDiscovery.cs deleted file mode 100644 index d1dd887..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907155333_TenantDiscovery.cs +++ /dev/null @@ -1,124 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Deal.Infrastructure.Migrations.TenantDb -{ - /// - public partial class TenantDiscovery : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "DiscBlacklist", - columns: table => new - { - DialogId = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Reason = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(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(type: "text", nullable: false), - TaskId = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Username = table.Column(type: "text", nullable: false), - Kind = table.Column(type: "text", nullable: false), - Hue = table.Column(type: "text", nullable: false), - Participants = table.Column(type: "integer", nullable: true), - LangRu = table.Column(type: "boolean", nullable: true), - MarksJson = table.Column(type: "text", nullable: false), - TopicsJson = table.Column(type: "text", nullable: false), - FitRatio = table.Column(type: "double precision", nullable: true), - Status = table.Column(type: "text", nullable: false), - AutoJoined = table.Column(type: "boolean", nullable: false), - JoinFailures = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(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(type: "text", nullable: false), - TaskId = table.Column(type: "text", nullable: false), - Event = table.Column(type: "text", nullable: false), - Text = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(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(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Description = table.Column(type: "text", nullable: false), - KeywordsJson = table.Column(type: "text", nullable: false), - MinSubscribers = table.Column(type: "integer", nullable: false), - Lang = table.Column(type: "text", nullable: false), - Threshold = table.Column(type: "integer", nullable: false), - SampleSize = table.Column(type: "integer", nullable: false), - PlanJoins = table.Column(type: "integer", nullable: false), - AutoJoin = table.Column(type: "boolean", nullable: false), - Status = table.Column(type: "text", nullable: false), - SearchIdx = table.Column(type: "integer", nullable: false), - SearchDone = table.Column(type: "boolean", nullable: false), - Found = table.Column(type: "integer", nullable: false), - Evaluated = table.Column(type: "integer", nullable: false), - Joined = table.Column(type: "integer", nullable: false), - Rejected = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(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" }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "DiscBlacklist"); - - migrationBuilder.DropTable( - name: "DiscCandidates"); - - migrationBuilder.DropTable( - name: "DiscLog"); - - migrationBuilder.DropTable( - name: "DiscTasks"); - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260909222322_TenantContainers.Designer.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260909222322_TenantContainers.Designer.cs deleted file mode 100644 index eea1b76..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260909222322_TenantContainers.Designer.cs +++ /dev/null @@ -1,879 +0,0 @@ -// -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 - { - /// - 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("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("Prompt") - .IsRequired() - .HasColumnType("text"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.Property("VisibleFieldsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ArchivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("Col") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContactsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvFrom") - .HasColumnType("double precision"); - - b.Property("ConvTo") - .HasColumnType("double precision"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("IsNew") - .HasColumnType("boolean"); - - b.Property("IsVacancy") - .HasColumnType("boolean"); - - b.Property("IsVacancyKnown") - .HasColumnType("boolean"); - - b.Property("MatchHitsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("PrevCol") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReceivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true); - - b.Property("SourceDialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsg") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsgId") - .HasColumnType("bigint"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("Action") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FromCol") - .HasColumnType("text"); - - b.Property("LeadId") - .IsRequired() - .HasColumnType("text"); - - b.Property("ToCol") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("CardMoves", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kind") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("PolicyJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true); - - b.Property("Space") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.Property("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("Hash") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.HasKey("Hash"); - - b.ToTable("DedupEntries", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Backfilled") - .HasColumnType("boolean"); - - b.Property("Handle") - .IsRequired() - .HasColumnType("text"); - - b.Property("Hue") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("#666"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LastAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LastText") - .IsRequired() - .HasColumnType("text"); - - b.Property("Monitor") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("Dialogs", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("DialogId"); - - b.ToTable("DiscBlacklist", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("AutoJoined") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FitRatio") - .HasColumnType("double precision"); - - b.Property("Hue") - .IsRequired() - .HasColumnType("text"); - - b.Property("JoinFailures") - .HasColumnType("integer"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LangRu") - .HasColumnType("boolean"); - - b.Property("MarksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Participants") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("TopicsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Event") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("AutoJoin") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Evaluated") - .HasColumnType("integer"); - - b.Property("Found") - .HasColumnType("integer"); - - b.Property("Joined") - .HasColumnType("integer"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Lang") - .IsRequired() - .HasColumnType("text"); - - b.Property("MinSubscribers") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("PlanJoins") - .HasColumnType("integer"); - - b.Property("Rejected") - .HasColumnType("integer"); - - b.Property("SampleSize") - .HasColumnType("integer"); - - b.Property("SearchDone") - .HasColumnType("boolean"); - - b.Property("SearchIdx") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Threshold") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("DiscTasks", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("By") - .IsRequired() - .HasColumnType("text"); - - b.Property("CardId") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Delta") - .HasColumnType("double precision"); - - b.Property("Label") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("CommentsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FilesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("HistoryJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("LinksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Local") - .HasColumnType("boolean"); - - b.Property("ReminderAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderFired") - .HasColumnType("boolean"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property("TzText") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Force") - .HasColumnType("boolean"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kw") - .IsRequired() - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.Property("RejectedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReturnReason") - .IsRequired() - .HasColumnType("text"); - - b.Property("Returned") - .HasColumnType("boolean"); - - b.Property("ReturnedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); - - b.Property("Source") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ValueJson") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Key"); - - b.ToTable("settings", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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 - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260909222322_TenantContainers.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260909222322_TenantContainers.cs deleted file mode 100644 index 3315724..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260909222322_TenantContainers.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using NpgsqlTypes; - -#nullable disable - -namespace Deal.Infrastructure.Migrations.TenantDb -{ - /// - public partial class TenantContainers : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Containers", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Description = table.Column(type: "text", nullable: false), - Color = table.Column(type: "text", nullable: false), - Position = table.Column(type: "integer", nullable: false), - Kind = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - Space = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - KeywordsJson = table.Column(type: "text", nullable: false), - VisibleFieldsJson = table.Column(type: "text", nullable: false), - Collapsed = table.Column(type: "boolean", nullable: false), - Suggested = table.Column(type: "boolean", nullable: false), - RulesJson = table.Column(type: "text", nullable: false), - Note = table.Column(type: "text", nullable: false), - PolicyJson = table.Column(type: "text", nullable: false), - SearchTsv = table.Column(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", stored: true), - CreatedAt = table.Column(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) сразу после применения миграций. - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Containers"); - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910132805_TenantUnifiedCard.Designer.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910132805_TenantUnifiedCard.Designer.cs deleted file mode 100644 index 92cb847..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910132805_TenantUnifiedCard.Designer.cs +++ /dev/null @@ -1,823 +0,0 @@ -// -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 - { - /// - 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("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("Prompt") - .IsRequired() - .HasColumnType("text"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.Property("VisibleFieldsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ArchivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("Col") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContactsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvFrom") - .HasColumnType("double precision"); - - b.Property("ConvTo") - .HasColumnType("double precision"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FilesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("HistoryJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsNew") - .HasColumnType("boolean"); - - b.Property("IsVacancy") - .HasColumnType("boolean"); - - b.Property("IsVacancyKnown") - .HasColumnType("boolean"); - - b.Property("LinksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Local") - .HasColumnType("boolean"); - - b.Property("MatchHitsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("PrevCol") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReceivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderFired") - .HasColumnType("boolean"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true); - - b.Property("SourceDialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsg") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsgId") - .HasColumnType("bigint"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property("TzText") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("Action") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FromCol") - .HasColumnType("text"); - - b.Property("LeadId") - .IsRequired() - .HasColumnType("text"); - - b.Property("ToCol") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("CardMoves", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kind") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("PolicyJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true); - - b.Property("Space") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.Property("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("Hash") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.HasKey("Hash"); - - b.ToTable("DedupEntries", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Backfilled") - .HasColumnType("boolean"); - - b.Property("Handle") - .IsRequired() - .HasColumnType("text"); - - b.Property("Hue") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("#666"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LastAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LastText") - .IsRequired() - .HasColumnType("text"); - - b.Property("Monitor") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("Dialogs", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("DialogId"); - - b.ToTable("DiscBlacklist", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("AutoJoined") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FitRatio") - .HasColumnType("double precision"); - - b.Property("Hue") - .IsRequired() - .HasColumnType("text"); - - b.Property("JoinFailures") - .HasColumnType("integer"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LangRu") - .HasColumnType("boolean"); - - b.Property("MarksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Participants") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("TopicsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Event") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("AutoJoin") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Evaluated") - .HasColumnType("integer"); - - b.Property("Found") - .HasColumnType("integer"); - - b.Property("Joined") - .HasColumnType("integer"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Lang") - .IsRequired() - .HasColumnType("text"); - - b.Property("MinSubscribers") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("PlanJoins") - .HasColumnType("integer"); - - b.Property("Rejected") - .HasColumnType("integer"); - - b.Property("SampleSize") - .HasColumnType("integer"); - - b.Property("SearchDone") - .HasColumnType("boolean"); - - b.Property("SearchIdx") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Threshold") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("DiscTasks", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("By") - .IsRequired() - .HasColumnType("text"); - - b.Property("CardId") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Delta") - .HasColumnType("double precision"); - - b.Property("Label") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Force") - .HasColumnType("boolean"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kw") - .IsRequired() - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.Property("RejectedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReturnReason") - .IsRequired() - .HasColumnType("text"); - - b.Property("Returned") - .HasColumnType("boolean"); - - b.Property("ReturnedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); - - b.Property("Source") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ValueJson") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Key"); - - b.ToTable("settings", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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 - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910132805_TenantUnifiedCard.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910132805_TenantUnifiedCard.cs deleted file mode 100644 index 607ee90..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910132805_TenantUnifiedCard.cs +++ /dev/null @@ -1,166 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Deal.Infrastructure.Migrations.TenantDb -{ - /// - public partial class TenantUnifiedCard : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ProjectCards"); - - migrationBuilder.AddColumn( - name: "FilesJson", - table: "Cards", - type: "text", - nullable: false, - defaultValue: ""); - - migrationBuilder.AddColumn( - name: "HistoryJson", - table: "Cards", - type: "text", - nullable: false, - defaultValue: ""); - - migrationBuilder.AddColumn( - name: "LinksJson", - table: "Cards", - type: "text", - nullable: false, - defaultValue: ""); - - migrationBuilder.AddColumn( - name: "Local", - table: "Cards", - type: "boolean", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "ReminderAt", - table: "Cards", - type: "timestamp with time zone", - nullable: true); - - migrationBuilder.AddColumn( - name: "ReminderFired", - table: "Cards", - type: "boolean", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "TzText", - table: "Cards", - type: "text", - nullable: false, - defaultValue: ""); - - migrationBuilder.AddColumn( - 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]); - } - - /// - 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(type: "text", nullable: false), - BudgetCur = table.Column(type: "text", nullable: false), - BudgetFrom = table.Column(type: "double precision", nullable: true), - BudgetTo = table.Column(type: "double precision", nullable: true), - CommentsJson = table.Column(type: "text", nullable: false), - Contact = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - FilesJson = table.Column(type: "text", nullable: false), - HistoryJson = table.Column(type: "text", nullable: false), - LeadId = table.Column(type: "text", nullable: true), - LinksJson = table.Column(type: "text", nullable: false), - Local = table.Column(type: "boolean", nullable: false), - ReminderAt = table.Column(type: "timestamp with time zone", nullable: true), - ReminderFired = table.Column(type: "boolean", nullable: false), - StackJson = table.Column(type: "text", nullable: false), - Stage = table.Column(type: "text", nullable: false), - Summary = table.Column(type: "text", nullable: false), - Title = table.Column(type: "text", nullable: false), - TzText = table.Column(type: "text", nullable: false), - UpdatedAt = table.Column(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]); - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134104_TenantContainerRegistry.Designer.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134104_TenantContainerRegistry.Designer.cs deleted file mode 100644 index f60ac51..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134104_TenantContainerRegistry.Designer.cs +++ /dev/null @@ -1,763 +0,0 @@ -// -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 - { - /// - 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("Id") - .HasColumnType("text"); - - b.Property("ArchivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("Col") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContactsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvFrom") - .HasColumnType("double precision"); - - b.Property("ConvTo") - .HasColumnType("double precision"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FilesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("HistoryJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsNew") - .HasColumnType("boolean"); - - b.Property("IsVacancy") - .HasColumnType("boolean"); - - b.Property("IsVacancyKnown") - .HasColumnType("boolean"); - - b.Property("LinksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Local") - .HasColumnType("boolean"); - - b.Property("MatchHitsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("PrevCol") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReceivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderFired") - .HasColumnType("boolean"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true); - - b.Property("SourceDialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsg") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsgId") - .HasColumnType("bigint"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property("TzText") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("Action") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FromCol") - .HasColumnType("text"); - - b.Property("LeadId") - .IsRequired() - .HasColumnType("text"); - - b.Property("ToCol") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("CardMoves", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kind") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("PolicyJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true); - - b.Property("Space") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.Property("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("Hash") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.HasKey("Hash"); - - b.ToTable("DedupEntries", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Backfilled") - .HasColumnType("boolean"); - - b.Property("Handle") - .IsRequired() - .HasColumnType("text"); - - b.Property("Hue") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("#666"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LastAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LastText") - .IsRequired() - .HasColumnType("text"); - - b.Property("Monitor") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("Dialogs", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("DialogId"); - - b.ToTable("DiscBlacklist", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("AutoJoined") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FitRatio") - .HasColumnType("double precision"); - - b.Property("Hue") - .IsRequired() - .HasColumnType("text"); - - b.Property("JoinFailures") - .HasColumnType("integer"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LangRu") - .HasColumnType("boolean"); - - b.Property("MarksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Participants") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("TopicsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Event") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("AutoJoin") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Evaluated") - .HasColumnType("integer"); - - b.Property("Found") - .HasColumnType("integer"); - - b.Property("Joined") - .HasColumnType("integer"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Lang") - .IsRequired() - .HasColumnType("text"); - - b.Property("MinSubscribers") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("PlanJoins") - .HasColumnType("integer"); - - b.Property("Rejected") - .HasColumnType("integer"); - - b.Property("SampleSize") - .HasColumnType("integer"); - - b.Property("SearchDone") - .HasColumnType("boolean"); - - b.Property("SearchIdx") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Threshold") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("DiscTasks", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("By") - .IsRequired() - .HasColumnType("text"); - - b.Property("CardId") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Delta") - .HasColumnType("double precision"); - - b.Property("Label") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Force") - .HasColumnType("boolean"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kw") - .IsRequired() - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.Property("RejectedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReturnReason") - .IsRequired() - .HasColumnType("text"); - - b.Property("Returned") - .HasColumnType("boolean"); - - b.Property("ReturnedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); - - b.Property("Source") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ValueJson") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Key"); - - b.ToTable("settings", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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 - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134104_TenantContainerRegistry.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134104_TenantContainerRegistry.cs deleted file mode 100644 index ffa2e85..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134104_TenantContainerRegistry.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Deal.Infrastructure.Migrations.TenantDb -{ - /// - public partial class TenantContainerRegistry : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Boards"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Boards", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - Collapsed = table.Column(type: "boolean", nullable: false), - Color = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - Description = table.Column(type: "text", nullable: false), - KeywordsJson = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Note = table.Column(type: "text", nullable: false), - Position = table.Column(type: "integer", nullable: false), - Prompt = table.Column(type: "text", nullable: false), - RulesJson = table.Column(type: "text", nullable: false), - Suggested = table.Column(type: "boolean", nullable: false), - VisibleFieldsJson = table.Column(type: "text", nullable: false), - Width = table.Column(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" }); - } - } -} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134358_TenantContainerCleanup.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134358_TenantContainerCleanup.cs deleted file mode 100644 index 39f8ca7..0000000 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134358_TenantContainerCleanup.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Deal.Infrastructure.Migrations.TenantDb -{ - /// - public partial class TenantContainerCleanup : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "KeywordsJson", - table: "Containers"); - - migrationBuilder.DropColumn( - name: "VisibleFieldsJson", - table: "Containers"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "KeywordsJson", - table: "Containers", - type: "text", - nullable: false, - defaultValue: ""); - - migrationBuilder.AddColumn( - name: "VisibleFieldsJson", - table: "Containers", - type: "text", - nullable: false, - defaultValue: ""); - } - } -} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs index d03e178..177c34d 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs @@ -12,6 +12,9 @@ public sealed class CardConfiguration : IEntityTypeConfiguration // Максимальная длина Col: служебные значения + id доски (короткие строки b_...). private const int ColMaxLength = 200; + // Максимальная длина вида источника. + private const int SourceKindMaxLength = 64; + public void Configure(EntityTypeBuilder builder) { builder.ToTable("Cards"); @@ -27,7 +30,12 @@ public sealed class CardConfiguration : IEntityTypeConfiguration 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 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"); } diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs index ec7d9be..b8dd702 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs @@ -16,8 +16,14 @@ public sealed class QueueItemConfiguration : IEntityTypeConfiguration 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); } } diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs index 44bf94d..9fa1a05 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs @@ -18,6 +18,9 @@ public sealed class RejectedItemConfiguration : IEntityTypeConfiguration 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); diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs index 2bfcc5d..38701b6 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs @@ -92,40 +92,40 @@ public sealed class CardEntity /// public string ContactsJson { get; set; } = "[]"; - /// - /// Имя канала/диалога-источника. - /// - public string ChannelName { get; set; } = string.Empty; - - /// - /// Handle канала-источника. - /// - public string ChannelHandle { get; set; } = string.Empty; - - /// - /// Цвет канала-источника - /// - public string ChannelHue { get; set; } = "#666"; - /// /// Время получения исходного сообщения /// public DateTimeOffset ReceivedAt { get; set; } /// - /// Текст исходного сообщения + /// Вид источника /// - public string SourceMsg { get; set; } = string.Empty; + public string SourceKind { get; set; } = string.Empty; /// - /// Id диалога исходного сообщения + /// Идентификатор записи в источнике /// - public string SourceDialogId { get; set; } = string.Empty; + public string SourceExternalId { get; set; } = string.Empty; /// - /// Id исходного сообщения в Telegram, либо null. + /// Ссылка на оригинал в источнике /// - public long? SourceMsgId { get; set; } + public string SourceOriginRef { get; set; } = string.Empty; + + /// + /// Ссылка на источник, сериализованная в JSON + /// + public string SourceJson { get; set; } = string.Empty; + + /// + /// Содержимое источника, сериализованное в JSON + /// + public string ContentJson { get; set; } = string.Empty; + + /// + /// Текст источника для полнотекстового поиска + /// + public string SourceText { get; set; } = string.Empty; /// /// Предыдущая колонка diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/QueueItemEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/QueueItemEntity.cs index 9e8b1f2..789ab9f 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/QueueItemEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/QueueItemEntity.cs @@ -11,35 +11,25 @@ public sealed class QueueItemEntity public string Id { get; set; } = string.Empty; /// - /// Id диалога-источника + /// Ключ дедупликации записи источника /// - public string DialogId { get; set; } = string.Empty; + public string SourceKey { get; set; } = string.Empty; /// - /// Имя канала/диалога-источника. + /// Ссылка на источник, сериализованная в JSON /// - public string ChannelName { get; set; } = string.Empty; + public string SourceJson { get; set; } = string.Empty; /// - /// Handle канала-источника. + /// Содержимое источника, сериализованное в JSON /// - public string ChannelHandle { get; set; } = string.Empty; - - /// - /// Цвет канала-источника - /// - public string ChannelHue { get; set; } = "#666"; + public string ContentJson { get; set; } = string.Empty; /// /// Текст сообщения /// public string Text { get; set; } = string.Empty; - /// - /// Id исходного сообщения в Telegram, либо null - /// - public long? MsgId { get; set; } - /// /// Время получения исходного сообщения /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs index 6fdce60..4dd2ae5 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs @@ -13,29 +13,19 @@ public sealed class RejectedItemEntity public string Id { get; set; } = string.Empty; /// - /// Id диалога-источника + /// Ключ дедупликации записи источника /// - public string DialogId { get; set; } = string.Empty; + public string SourceKey { get; set; } = string.Empty; /// - /// Id исходного сообщения в Telegram, либо null. + /// Ссылка на источник, сериализованная в JSON /// - public long? MsgId { get; set; } + public string SourceJson { get; set; } = string.Empty; /// - /// Имя канала/диалога-источника. + /// Содержимое источника, сериализованное в JSON /// - public string ChannelName { get; set; } = string.Empty; - - /// - /// Handle канала-источника. - /// - public string ChannelHandle { get; set; } = string.Empty; - - /// - /// Цвет канала-источника - /// - public string ChannelHue { get; set; } = "#666"; + public string ContentJson { get; set; } = string.Empty; /// /// Текст сообщения diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134358_TenantContainerCleanup.Designer.cs b/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.Designer.cs similarity index 93% rename from src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134358_TenantContainerCleanup.Designer.cs rename to src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.Designer.cs index 239ccc4..8b753e6 100644 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134358_TenantContainerCleanup.Designer.cs +++ b/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.Designer.cs @@ -1,755 +1,744 @@ -// -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("20260910134358_TenantContainerCleanup")] - partial class TenantContainerCleanup - { - /// - 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("Id") - .HasColumnType("text"); - - b.Property("ArchivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("Col") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContactsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvFrom") - .HasColumnType("double precision"); - - b.Property("ConvTo") - .HasColumnType("double precision"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FilesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("HistoryJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsNew") - .HasColumnType("boolean"); - - b.Property("IsVacancy") - .HasColumnType("boolean"); - - b.Property("IsVacancyKnown") - .HasColumnType("boolean"); - - b.Property("LinksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Local") - .HasColumnType("boolean"); - - b.Property("MatchHitsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("PrevCol") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReceivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderFired") - .HasColumnType("boolean"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true); - - b.Property("SourceDialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsg") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsgId") - .HasColumnType("bigint"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property("TzText") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("Action") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FromCol") - .HasColumnType("text"); - - b.Property("LeadId") - .IsRequired() - .HasColumnType("text"); - - b.Property("ToCol") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("CardMoves", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kind") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("PolicyJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true); - - b.Property("Space") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.HasKey("Id"); - - b.HasIndex("SearchTsv"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); - - b.HasIndex("Space", "Position"); - - b.HasIndex("Suggested", "Position"); - - b.ToTable("Containers", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b => - { - b.Property("Hash") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.HasKey("Hash"); - - b.ToTable("DedupEntries", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Backfilled") - .HasColumnType("boolean"); - - b.Property("Handle") - .IsRequired() - .HasColumnType("text"); - - b.Property("Hue") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("#666"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LastAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LastText") - .IsRequired() - .HasColumnType("text"); - - b.Property("Monitor") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("Dialogs", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("DialogId"); - - b.ToTable("DiscBlacklist", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("AutoJoined") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FitRatio") - .HasColumnType("double precision"); - - b.Property("Hue") - .IsRequired() - .HasColumnType("text"); - - b.Property("JoinFailures") - .HasColumnType("integer"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LangRu") - .HasColumnType("boolean"); - - b.Property("MarksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Participants") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("TopicsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Event") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("AutoJoin") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Evaluated") - .HasColumnType("integer"); - - b.Property("Found") - .HasColumnType("integer"); - - b.Property("Joined") - .HasColumnType("integer"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Lang") - .IsRequired() - .HasColumnType("text"); - - b.Property("MinSubscribers") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("PlanJoins") - .HasColumnType("integer"); - - b.Property("Rejected") - .HasColumnType("integer"); - - b.Property("SampleSize") - .HasColumnType("integer"); - - b.Property("SearchDone") - .HasColumnType("boolean"); - - b.Property("SearchIdx") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Threshold") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("DiscTasks", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("By") - .IsRequired() - .HasColumnType("text"); - - b.Property("CardId") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Delta") - .HasColumnType("double precision"); - - b.Property("Label") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Force") - .HasColumnType("boolean"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kw") - .IsRequired() - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.Property("RejectedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReturnReason") - .IsRequired() - .HasColumnType("text"); - - b.Property("Returned") - .HasColumnType("boolean"); - - b.Property("ReturnedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); - - b.Property("Source") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ValueJson") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Key"); - - b.ToTable("settings", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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 - } - } -} +// +using System; +using Deal.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace Deal.Infrastructure.Persistence.Migrations.TenantDb +{ + [DbContext(typeof(TenantDbContext))] + [Migration("20260911114400_InitialTenant")] + partial class InitialTenant + { + /// + 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("Id") + .HasColumnType("text"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BudgetCur") + .IsRequired() + .HasColumnType("text"); + + b.Property("BudgetFrom") + .HasColumnType("double precision"); + + b.Property("BudgetTo") + .HasColumnType("double precision"); + + b.Property("Col") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Contact") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConvCur") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConvFrom") + .HasColumnType("double precision"); + + b.Property("ConvTo") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HistoryJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("IsVacancy") + .HasColumnType("boolean"); + + b.Property("IsVacancyKnown") + .HasColumnType("boolean"); + + b.Property("LinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Local") + .HasColumnType("boolean"); + + b.Property("MatchHitsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrevCol") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReminderAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReminderFired") + .HasColumnType("boolean"); + + b.Property("SearchTsv") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", true); + + b.Property("SourceExternalId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SourceOriginRef") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceText") + .IsRequired() + .HasColumnType("text"); + + b.Property("StackJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("TzText") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .HasColumnType("text"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromCol") + .HasColumnType("text"); + + b.Property("LeadId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToCol") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("CardMoves", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Collapsed") + .HasColumnType("boolean"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("PolicyJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("RulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SearchTsv") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true); + + b.Property("Space") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Suggested") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("SearchTsv"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); + + b.HasIndex("Space", "Position"); + + b.HasIndex("Suggested", "Position"); + + b.ToTable("Containers", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b => + { + b.Property("Hash") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeadId") + .HasColumnType("text"); + + b.HasKey("Hash"); + + b.ToTable("DedupEntries", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Backfilled") + .HasColumnType("boolean"); + + b.Property("Handle") + .IsRequired() + .HasColumnType("text"); + + b.Property("Hue") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("#666"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastText") + .IsRequired() + .HasColumnType("text"); + + b.Property("Monitor") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Dialogs", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b => + { + b.Property("DialogId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("DialogId"); + + b.ToTable("DiscBlacklist", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b => + { + b.Property("DialogId") + .HasColumnType("text"); + + b.Property("AutoJoined") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FitRatio") + .HasColumnType("double precision"); + + b.Property("Hue") + .IsRequired() + .HasColumnType("text"); + + b.Property("JoinFailures") + .HasColumnType("integer"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("LangRu") + .HasColumnType("boolean"); + + b.Property("MarksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Participants") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TopicsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Event") + .IsRequired() + .HasColumnType("text"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .HasColumnType("text"); + + b.Property("AutoJoin") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Evaluated") + .HasColumnType("integer"); + + b.Property("Found") + .HasColumnType("integer"); + + b.Property("Joined") + .HasColumnType("integer"); + + b.Property("KeywordsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Lang") + .IsRequired() + .HasColumnType("text"); + + b.Property("MinSubscribers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanJoins") + .HasColumnType("integer"); + + b.Property("Rejected") + .HasColumnType("integer"); + + b.Property("SampleSize") + .HasColumnType("integer"); + + b.Property("SearchDone") + .HasColumnType("boolean"); + + b.Property("SearchIdx") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Threshold") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("DiscTasks", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("By") + .IsRequired() + .HasColumnType("text"); + + b.Property("CardId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Delta") + .HasColumnType("double precision"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .HasColumnType("text"); + + b.Property("ContentJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Force") + .HasColumnType("boolean"); + + b.Property("MsgAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SourceKey"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("QueueItems", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ContentJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kw") + .IsRequired() + .HasColumnType("text"); + + b.Property("MsgAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Returned") + .HasColumnType("boolean"); + + b.Property("ReturnedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchTsv") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stage") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DialogId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LeadId") + .HasColumnType("text"); + + b.Property("MsgAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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 + } + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.cs b/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.cs new file mode 100644 index 0000000..042882a --- /dev/null +++ b/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.cs @@ -0,0 +1,462 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NpgsqlTypes; + +#nullable disable + +namespace Deal.Infrastructure.Persistence.Migrations.TenantDb +{ + /// + public partial class InitialTenant : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CardMoves", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + LeadId = table.Column(type: "text", nullable: false), + Action = table.Column(type: "text", nullable: false), + FromCol = table.Column(type: "text", nullable: true), + ToCol = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(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(type: "text", nullable: false), + Col = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + IsNew = table.Column(type: "boolean", nullable: false), + Local = table.Column(type: "boolean", nullable: false), + IsVacancy = table.Column(type: "boolean", nullable: false), + IsVacancyKnown = table.Column(type: "boolean", nullable: false), + Title = table.Column(type: "text", nullable: false), + Summary = table.Column(type: "text", nullable: false), + StackJson = table.Column(type: "text", nullable: false), + BudgetFrom = table.Column(type: "double precision", nullable: true), + BudgetTo = table.Column(type: "double precision", nullable: true), + BudgetCur = table.Column(type: "text", nullable: false), + ConvFrom = table.Column(type: "double precision", nullable: true), + ConvTo = table.Column(type: "double precision", nullable: true), + ConvCur = table.Column(type: "text", nullable: false), + Contact = table.Column(type: "text", nullable: false), + ContactsJson = table.Column(type: "text", nullable: false), + ReceivedAt = table.Column(type: "timestamp with time zone", nullable: false), + SourceKind = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + SourceExternalId = table.Column(type: "text", nullable: false), + SourceOriginRef = table.Column(type: "text", nullable: false), + SourceJson = table.Column(type: "text", nullable: false), + ContentJson = table.Column(type: "text", nullable: false), + SourceText = table.Column(type: "text", nullable: false), + PrevCol = table.Column(type: "text", nullable: false), + ArchivedAt = table.Column(type: "timestamp with time zone", nullable: true), + MatchHitsJson = table.Column(type: "text", nullable: false), + LinksJson = table.Column(type: "text", nullable: false), + FilesJson = table.Column(type: "text", nullable: false), + HistoryJson = table.Column(type: "text", nullable: false), + TzText = table.Column(type: "text", nullable: false), + ReminderAt = table.Column(type: "timestamp with time zone", nullable: true), + ReminderFired = table.Column(type: "boolean", nullable: false), + SearchTsv = table.Column(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", stored: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: false), + Color = table.Column(type: "text", nullable: false), + Position = table.Column(type: "integer", nullable: false), + Kind = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Space = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Collapsed = table.Column(type: "boolean", nullable: false), + Suggested = table.Column(type: "boolean", nullable: false), + RulesJson = table.Column(type: "text", nullable: false), + Note = table.Column(type: "text", nullable: false), + PolicyJson = table.Column(type: "text", nullable: false), + SearchTsv = table.Column(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", stored: true), + CreatedAt = table.Column(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(type: "text", nullable: false), + LeadId = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(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(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Handle = table.Column(type: "text", nullable: false), + Kind = table.Column(type: "text", nullable: false), + Hue = table.Column(type: "text", nullable: false, defaultValue: "#666"), + Monitor = table.Column(type: "boolean", nullable: false), + LastText = table.Column(type: "text", nullable: false), + LastAt = table.Column(type: "timestamp with time zone", nullable: true), + Backfilled = table.Column(type: "boolean", nullable: false), + UpdatedAt = table.Column(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(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Reason = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(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(type: "text", nullable: false), + TaskId = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Username = table.Column(type: "text", nullable: false), + Kind = table.Column(type: "text", nullable: false), + Hue = table.Column(type: "text", nullable: false), + Participants = table.Column(type: "integer", nullable: true), + LangRu = table.Column(type: "boolean", nullable: true), + MarksJson = table.Column(type: "text", nullable: false), + TopicsJson = table.Column(type: "text", nullable: false), + FitRatio = table.Column(type: "double precision", nullable: true), + Status = table.Column(type: "text", nullable: false), + AutoJoined = table.Column(type: "boolean", nullable: false), + JoinFailures = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "text", nullable: false), + TaskId = table.Column(type: "text", nullable: false), + Event = table.Column(type: "text", nullable: false), + Text = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(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(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: false), + KeywordsJson = table.Column(type: "text", nullable: false), + MinSubscribers = table.Column(type: "integer", nullable: false), + Lang = table.Column(type: "text", nullable: false), + Threshold = table.Column(type: "integer", nullable: false), + SampleSize = table.Column(type: "integer", nullable: false), + PlanJoins = table.Column(type: "integer", nullable: false), + AutoJoin = table.Column(type: "boolean", nullable: false), + Status = table.Column(type: "text", nullable: false), + SearchIdx = table.Column(type: "integer", nullable: false), + SearchDone = table.Column(type: "boolean", nullable: false), + Found = table.Column(type: "integer", nullable: false), + Evaluated = table.Column(type: "integer", nullable: false), + Joined = table.Column(type: "integer", nullable: false), + Rejected = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "text", nullable: false), + Text = table.Column(type: "text", nullable: false), + Label = table.Column(type: "text", nullable: false), + Delta = table.Column(type: "double precision", nullable: false), + CreatedAt = table.Column(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(type: "text", nullable: false), + SourceKey = table.Column(type: "text", nullable: false), + SourceJson = table.Column(type: "text", nullable: false), + ContentJson = table.Column(type: "text", nullable: false), + Text = table.Column(type: "text", nullable: false), + MsgAt = table.Column(type: "timestamp with time zone", nullable: false), + Status = table.Column(type: "text", nullable: false), + Force = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "text", nullable: false), + SourceKey = table.Column(type: "text", nullable: false), + SourceJson = table.Column(type: "text", nullable: false), + ContentJson = table.Column(type: "text", nullable: false), + Text = table.Column(type: "text", nullable: false), + Stage = table.Column(type: "text", nullable: false), + Reason = table.Column(type: "text", nullable: false), + Kw = table.Column(type: "text", nullable: false), + Source = table.Column(type: "text", nullable: false), + MsgAt = table.Column(type: "timestamp with time zone", nullable: false), + RejectedAt = table.Column(type: "timestamp with time zone", nullable: false), + Returned = table.Column(type: "boolean", nullable: false), + ReturnedAt = table.Column(type: "timestamp with time zone", nullable: true), + ReturnReason = table.Column(type: "text", nullable: false), + SearchTsv = table.Column(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(type: "character varying(200)", maxLength: 200, nullable: false), + ValueJson = table.Column(type: "text", nullable: false), + UpdatedAt = table.Column(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(type: "text", nullable: false), + DialogId = table.Column(type: "text", nullable: false), + Text = table.Column(type: "text", nullable: false), + MsgAt = table.Column(type: "timestamp with time zone", nullable: false), + LeadId = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TgMessages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "LeadComments", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + CardId = table.Column(type: "text", nullable: false), + By = table.Column(type: "text", nullable: false), + Text = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(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" }); + } + + /// + 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"); + } + } +} diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/TenantDbContextModelSnapshot.cs b/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/TenantDbContextModelSnapshot.cs similarity index 93% rename from src/core/Deal.Infrastructure/Migrations/TenantDb/TenantDbContextModelSnapshot.cs rename to src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/TenantDbContextModelSnapshot.cs index 9984b30..712008e 100644 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/TenantDbContextModelSnapshot.cs +++ b/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/TenantDbContextModelSnapshot.cs @@ -1,752 +1,741 @@ -// -using System; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using NpgsqlTypes; - -#nullable disable - -namespace Deal.Infrastructure.Migrations.TenantDb -{ - [DbContext(typeof(TenantDbContext))] - partial class TenantDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.11") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ArchivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("Col") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContactsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvFrom") - .HasColumnType("double precision"); - - b.Property("ConvTo") - .HasColumnType("double precision"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FilesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("HistoryJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsNew") - .HasColumnType("boolean"); - - b.Property("IsVacancy") - .HasColumnType("boolean"); - - b.Property("IsVacancyKnown") - .HasColumnType("boolean"); - - b.Property("LinksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Local") - .HasColumnType("boolean"); - - b.Property("MatchHitsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("PrevCol") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReceivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderFired") - .HasColumnType("boolean"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||coalesce(\"Contact\",''))", true); - - b.Property("SourceDialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsg") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceMsgId") - .HasColumnType("bigint"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property("TzText") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("Action") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FromCol") - .HasColumnType("text"); - - b.Property("LeadId") - .IsRequired() - .HasColumnType("text"); - - b.Property("ToCol") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("CardMoves", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kind") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("PolicyJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true); - - b.Property("Space") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.HasKey("Id"); - - b.HasIndex("SearchTsv"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); - - b.HasIndex("Space", "Position"); - - b.HasIndex("Suggested", "Position"); - - b.ToTable("Containers", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b => - { - b.Property("Hash") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.HasKey("Hash"); - - b.ToTable("DedupEntries", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Backfilled") - .HasColumnType("boolean"); - - b.Property("Handle") - .IsRequired() - .HasColumnType("text"); - - b.Property("Hue") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("#666"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LastAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LastText") - .IsRequired() - .HasColumnType("text"); - - b.Property("Monitor") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("Dialogs", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("DialogId"); - - b.ToTable("DiscBlacklist", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("AutoJoined") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FitRatio") - .HasColumnType("double precision"); - - b.Property("Hue") - .IsRequired() - .HasColumnType("text"); - - b.Property("JoinFailures") - .HasColumnType("integer"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LangRu") - .HasColumnType("boolean"); - - b.Property("MarksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Participants") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("TopicsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Event") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("AutoJoin") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Evaluated") - .HasColumnType("integer"); - - b.Property("Found") - .HasColumnType("integer"); - - b.Property("Joined") - .HasColumnType("integer"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Lang") - .IsRequired() - .HasColumnType("text"); - - b.Property("MinSubscribers") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("PlanJoins") - .HasColumnType("integer"); - - b.Property("Rejected") - .HasColumnType("integer"); - - b.Property("SampleSize") - .HasColumnType("integer"); - - b.Property("SearchDone") - .HasColumnType("boolean"); - - b.Property("SearchIdx") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Threshold") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("DiscTasks", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("By") - .IsRequired() - .HasColumnType("text"); - - b.Property("CardId") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Delta") - .HasColumnType("double precision"); - - b.Property("Label") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Force") - .HasColumnType("boolean"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Id") - .HasColumnType("text"); - - b.Property("ChannelHandle") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelHue") - .IsRequired() - .HasColumnType("text"); - - b.Property("ChannelName") - .IsRequired() - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kw") - .IsRequired() - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("MsgId") - .HasColumnType("bigint"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.Property("RejectedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReturnReason") - .IsRequired() - .HasColumnType("text"); - - b.Property("Returned") - .HasColumnType("boolean"); - - b.Property("ReturnedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); - - b.Property("Source") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("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("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ValueJson") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Key"); - - b.ToTable("settings", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("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 - } - } -} +// +using System; +using Deal.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace Deal.Infrastructure.Persistence.Migrations.TenantDb +{ + [DbContext(typeof(TenantDbContext))] + partial class TenantDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BudgetCur") + .IsRequired() + .HasColumnType("text"); + + b.Property("BudgetFrom") + .HasColumnType("double precision"); + + b.Property("BudgetTo") + .HasColumnType("double precision"); + + b.Property("Col") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Contact") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConvCur") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConvFrom") + .HasColumnType("double precision"); + + b.Property("ConvTo") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HistoryJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("IsVacancy") + .HasColumnType("boolean"); + + b.Property("IsVacancyKnown") + .HasColumnType("boolean"); + + b.Property("LinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Local") + .HasColumnType("boolean"); + + b.Property("MatchHitsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrevCol") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReminderAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReminderFired") + .HasColumnType("boolean"); + + b.Property("SearchTsv") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", true); + + b.Property("SourceExternalId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SourceOriginRef") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceText") + .IsRequired() + .HasColumnType("text"); + + b.Property("StackJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("TzText") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .HasColumnType("text"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromCol") + .HasColumnType("text"); + + b.Property("LeadId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToCol") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("CardMoves", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Collapsed") + .HasColumnType("boolean"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("PolicyJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("RulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SearchTsv") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true); + + b.Property("Space") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Suggested") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("SearchTsv"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); + + b.HasIndex("Space", "Position"); + + b.HasIndex("Suggested", "Position"); + + b.ToTable("Containers", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b => + { + b.Property("Hash") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeadId") + .HasColumnType("text"); + + b.HasKey("Hash"); + + b.ToTable("DedupEntries", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Backfilled") + .HasColumnType("boolean"); + + b.Property("Handle") + .IsRequired() + .HasColumnType("text"); + + b.Property("Hue") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("#666"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastText") + .IsRequired() + .HasColumnType("text"); + + b.Property("Monitor") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Dialogs", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b => + { + b.Property("DialogId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("DialogId"); + + b.ToTable("DiscBlacklist", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b => + { + b.Property("DialogId") + .HasColumnType("text"); + + b.Property("AutoJoined") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FitRatio") + .HasColumnType("double precision"); + + b.Property("Hue") + .IsRequired() + .HasColumnType("text"); + + b.Property("JoinFailures") + .HasColumnType("integer"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("LangRu") + .HasColumnType("boolean"); + + b.Property("MarksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Participants") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TopicsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Event") + .IsRequired() + .HasColumnType("text"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .HasColumnType("text"); + + b.Property("AutoJoin") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Evaluated") + .HasColumnType("integer"); + + b.Property("Found") + .HasColumnType("integer"); + + b.Property("Joined") + .HasColumnType("integer"); + + b.Property("KeywordsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Lang") + .IsRequired() + .HasColumnType("text"); + + b.Property("MinSubscribers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanJoins") + .HasColumnType("integer"); + + b.Property("Rejected") + .HasColumnType("integer"); + + b.Property("SampleSize") + .HasColumnType("integer"); + + b.Property("SearchDone") + .HasColumnType("boolean"); + + b.Property("SearchIdx") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Threshold") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("DiscTasks", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("By") + .IsRequired() + .HasColumnType("text"); + + b.Property("CardId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Delta") + .HasColumnType("double precision"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .HasColumnType("text"); + + b.Property("ContentJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Force") + .HasColumnType("boolean"); + + b.Property("MsgAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SourceKey"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("QueueItems", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ContentJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kw") + .IsRequired() + .HasColumnType("text"); + + b.Property("MsgAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Returned") + .HasColumnType("boolean"); + + b.Property("ReturnedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchTsv") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stage") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DialogId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LeadId") + .HasColumnType("text"); + + b.Property("MsgAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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 + } + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs index e8e66d0..8bf15b1 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs @@ -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 /// public async Task 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) diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs index 34179d6..ae047c7 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs @@ -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); } diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs index 71851ba..0a3c379 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs @@ -123,7 +123,7 @@ public sealed partial class KanbanStore { List 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())).ToList(); diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs index dc99096..b2f7afc 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs @@ -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(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(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(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(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(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), diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs index b6f11f3..82bc5f7 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs @@ -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; /// public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore { - // ── Очередь (QueueItems) ─────────────────────────────────────────────── + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + // ── Очередь (QueueItems) ─────────────────────────────────────── /// public Task 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); } /// @@ -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 value) => JsonSerializer.Serialize(value, JsonOptions); + + // Разбирает одиночный JSON-объект; пустая/битая строка — запасное значение. + private static T FromJsonOr(string json, T fallback) + { + if (string.IsNullOrWhiteSpace(json)) + { + return fallback; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions) ?? fallback; + } + catch (JsonException) + { + return fallback; + } + } } diff --git a/src/core/Deal.Modules.Cards/Application/Sources/SourceRefs.cs b/src/core/Deal.Modules.Cards/Application/Sources/SourceRefs.cs new file mode 100644 index 0000000..1eefc8f --- /dev/null +++ b/src/core/Deal.Modules.Cards/Application/Sources/SourceRefs.cs @@ -0,0 +1,47 @@ +namespace Deal.Modules.Cards.Application.Sources; + +/// +/// Общие значения и производные поля ссылки на источник. +/// +public static class SourceRefs +{ + /// + /// Цвет источника по умолчанию. + /// + public const string DefaultHue = "#666"; + + /// + /// Ключ прочих метаданных с цветом источника. + /// + public const string HueKey = "hue"; + + /// + /// Пустая ссылка на источник. + /// + public static SourceRef Empty { get; } = new() { Kind = string.Empty }; + + /// + /// Ключ уникальности записи в источнике: вид + оригинал + внешний id. + /// + /// Ссылка на источник. + /// Стабильный ключ для дедупликации. + public static string DedupeKey(this SourceRef source) => + string.Concat(source.Kind, "|", source.OriginRef ?? string.Empty, "|", source.ExternalId ?? string.Empty); + + /// + /// Цвет источника для интерфейса. + /// + /// Ссылка на источник. + /// Цвет из метаданных либо цвет по умолчанию. + 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; + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs b/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs index 9deb563..dde73cd 100644 --- a/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs +++ b/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs @@ -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 /// /// Карточка по исходному сообщению /// - /// Id диалога-источника сообщения. - /// Id исходного сообщения в Telegram. + /// Ссылка на источник: вид + оригинал + внешний id. /// Карточка или null, если сообщение не становилось карточкой. public Task GetCardBySourceAsync( - string dialogId, - long msgId, + SourceRef source, CancellationToken ct); /// @@ -367,6 +366,6 @@ public interface ICardStore /// /// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest. /// - /// Карточки col='inbox' с непустым SourceMsg (частотные темы считаются по source_msg). + /// Карточки col='inbox' с непустым текстом источника. public Task> ListInboxWithSourceAsync(CancellationToken ct); } diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardChannelDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardChannelDto.cs deleted file mode 100644 index bf5d381..0000000 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardChannelDto.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Deal.Modules.Kanban.Application.Models; - -/// -/// Канал-источник карточки — объект ch. -/// -public sealed record CardChannelDto( - string Name, - string Handle, - string Hue); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardDto.cs index b90a2e9..cc27c51 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardDto.cs @@ -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; /// - /// Источник (производная проекция) + /// Источник записи. /// - public CardSourceDto Source { get; init; } = new(string.Empty, string.Empty, string.Empty, 0); + public SourceRef Source { get; init; } = SourceRefs.Empty; + + /// + /// Содержимое записи источника. + /// + public SourceContent Content { get; init; } = new(); /// /// Стек/направления @@ -82,11 +88,6 @@ public sealed record CardDto /// public IReadOnlyList Contacts { get; init; } = Array.Empty(); - /// - /// Канал-источник - /// - public CardChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty); - /// /// Человеческая метка возраста /// @@ -98,21 +99,6 @@ public sealed record CardDto [property: JsonPropertyName("receivedAt")] public long ReceivedAtMs { get; init; } - /// - /// Исходное сообщение - /// - public string SourceMsg { get; init; } = string.Empty; - - /// - /// Id диалога исходного сообщения - /// - public string SourceDialogId { get; init; } = string.Empty; - - /// - /// Id исходного сообщения в Telegram, либо null. - /// - public long? SourceMsgId { get; init; } - /// /// Предыдущая колонка /// diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardSnapshot.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardSnapshot.cs index 0fb3f75..6ca1f18 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardSnapshot.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardSnapshot.cs @@ -1,3 +1,5 @@ +using Deal.Modules.Cards.Application.Sources; + namespace Deal.Modules.Kanban.Application.Models; /// @@ -91,40 +93,20 @@ public sealed record CardSnapshot public IReadOnlyList Contacts { get; init; } = Array.Empty(); /// - /// Имя канала/диалога-источника. + /// Источник записи. /// - public string ChannelName { get; init; } = string.Empty; + public SourceRef Source { get; init; } = SourceRefs.Empty; /// - /// Handle канала-источника. + /// Содержимое записи источника. /// - public string ChannelHandle { get; init; } = string.Empty; - - /// - /// Цвет канала-источника - /// - public string ChannelHue { get; init; } = string.Empty; + public SourceContent Content { get; init; } = new(); /// /// Время получения исходного сообщения /// public DateTimeOffset ReceivedAt { get; init; } - /// - /// Исходное сообщение - /// - public string SourceMsg { get; init; } = string.Empty; - - /// - /// Id диалога исходного сообщения. - /// - public string SourceDialogId { get; init; } = string.Empty; - - /// - /// Id исходного сообщения в Telegram, либо null. - /// - public long? SourceMsgId { get; init; } - /// /// Предыдущая колонка /// diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardSourceDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardSourceDto.cs deleted file mode 100644 index 97f171e..0000000 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardSourceDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Deal.Modules.Kanban.Application.Models; - -/// -/// Источник карточки на wire — объект source карточки. -/// -/// Вид источника: local/telegram/web/file/row/api/ai/composite/other. -/// Имя источника (канал/диалог). -/// Ссылка на первоисточник (id диалога/файла). -/// Момент получения, epoch-ms. -public sealed record CardSourceDto( - string Kind, - string DisplayName, - string OriginRef, - long ReceivedAt); diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs index f8c543d..51e6084 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs @@ -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; } diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs index 7eba535..cfb07bc 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs @@ -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 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(), BudgetFrom = draft.Budget?.From, BudgetTo = draft.Budget?.To, diff --git a/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs b/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs index a052605..ed115c0 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs @@ -90,13 +90,13 @@ public static class SuggestHeuristics /// /// Планы колонок-предложений по карточкам «Неразобранного». /// - /// Карточки «Неразобранного» с непустым source_msg (ICardStore.ListInboxWithSourceAsync). + /// Карточки «Неразобранного» с непустым текстом источника (ICardStore.ListInboxWithSourceAsync). /// Имена существующих (suggested=false) досок — похожие темы не предлагаются. /// Планы колонок (≤4, каждая ≥2 карточки); пусто — мало карточек/нечего сгруппировать. public static IReadOnlyList PlanColumns(IReadOnlyList inbox, IReadOnlyList existingBoardNames) { List 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? cardIds)) { @@ -166,7 +166,7 @@ public static class SuggestHeuristics /// /// Частотные слова-маркеры по текстам карточек. /// - /// Тексты source_msg карточек выборки (непустые, свежие ≤40). + /// Тексты источника карточек выборки (непустые, свежие ≤40). /// Слова-маркеры (≤60); пусто — нет слов, встречающихся в ≥2 текстах. public static IReadOnlyList SuggestDomainKeywords(IReadOnlyList texts) { @@ -191,7 +191,7 @@ public static class SuggestHeuristics } // Слова текста: подряд букв (латиница/кириллица), нижний регистр, длина 3..40, минус стоп-слова. - // text: Исходный текст (source_msg карточки). + // text: Исходный текст (текст источника карточки). // Возвращает: Уникальные слова текста в порядке появления (Ordinal). private static List Tokenize(string text) { diff --git a/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs b/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs index 4d3c49b..5543210 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs @@ -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) ─────────────────────────────────────────────── /// - /// Есть ли строка очереди с тем же сообщением диалога — дубль-гвард приёма. + /// Есть ли строка очереди с той же записью источника — дубль-гвард приёма. /// - /// Id диалога-источника. - /// Id исходного сообщения; null — проверка не выполняется (вернёт false). - /// true — строка с DialogId+MsgId уже в очереди (Telethon-дубль не пишем). + /// Ссылка на источник: вид + оригинал + внешний id. + /// true — строка с такой записью уже в очереди. public Task ExistsDuplicateAsync( - string dialogId, - long? msgId, + SourceRef source, CancellationToken ct); /// diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidateDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidateDto.cs index 15ff178..9406937 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidateDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidateDto.cs @@ -1,22 +1,24 @@ +using Deal.Modules.Cards.Application.Sources; + namespace Deal.Modules.Pipeline.Application.Models; /// -/// Сообщение-кандидат для ручной проверки ML. +/// Запись-кандидат для ручной проверки ML. /// public sealed record MlCandidateDto { /// - /// Id исходного сообщения в Telegram + /// Источник записи. /// - public long Id { get; init; } + public SourceRef Source { get; init; } = SourceRefs.Empty; /// - /// Id диалога-источника сообщения. + /// Содержимое записи источника. /// - public string DialogId { get; init; } = string.Empty; + public SourceContent Content { get; init; } = new(); /// - /// Текст сообщения. + /// Текст записи. /// public string Text { get; init; } = string.Empty; diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineChannelDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineChannelDto.cs deleted file mode 100644 index 9f8aaca..0000000 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineChannelDto.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Deal.Modules.Pipeline.Application.Models; - -/// -/// Канал-источник сообщения пайплайна — объект ch строк очереди/отсева. -/// -/// Имя канала/диалога. -/// Handle канала/диалога (t.me/<handle>). -/// Цвет канала. -public sealed record PipelineChannelDto(string Name, string Handle, string Hue); diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/QueueItemDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/QueueItemDto.cs index c52088d..55503f7 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/QueueItemDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/QueueItemDto.cs @@ -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; /// - /// Id диалога-источника сообщения. + /// Источник записи. /// - public string DialogId { get; init; } = string.Empty; + public SourceRef Source { get; init; } = SourceRefs.Empty; /// - /// Id исходного сообщения в Telegram + /// Содержимое записи источника. /// - public long? MsgId { get; init; } + public SourceContent Content { get; init; } = new(); /// /// Текст сообщения. @@ -33,25 +34,19 @@ public sealed record QueueItemDto public string Status { get; init; } = string.Empty; /// - /// Канал-источник - /// - [property: JsonPropertyName("ch")] - public PipelineChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty); - - /// - /// Время исходного сообщения, epoch-ms + /// Время исходного сообщения, epoch-ms. /// [property: JsonPropertyName("msgAt")] public long MsgAtMs { get; init; } /// - /// Время постановки в очередь + /// Время постановки в очередь, epoch-ms. /// [property: JsonPropertyName("queuedAt")] public long QueuedAtMs { get; init; } /// - /// Внутренний флаг «возвращено пользователем из отсева» + /// Внутренний флаг «возвращено пользователем из отсева». /// [JsonIgnore] public bool Force { get; init; } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/QueuedMessage.cs b/src/core/Deal.Modules.Pipeline/Application/Models/QueuedMessage.cs index a3f7036..466941f 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/QueuedMessage.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/QueuedMessage.cs @@ -1,47 +1,19 @@ +using Deal.Modules.Cards.Application.Sources; + namespace Deal.Modules.Pipeline.Application.Models; /// -/// Команда приёма входящего сообщения — аргумент PipelineIngestService.EnqueueAsync. +/// Команда приёма входящей записи — аргумент PipelineIngestService.EnqueueAsync. /// public sealed record QueuedMessage { /// - /// Id диалога-источника сообщения + /// Запись источника: источник и содержимое. /// - public string DialogId { get; init; } = string.Empty; + public required SourceItem Item { get; init; } /// - /// Имя канала/диалога-источника. - /// - public string ChannelName { get; init; } = string.Empty; - - /// - /// Handle канала/диалога-источника. - /// - public string ChannelHandle { get; init; } = string.Empty; - - /// - /// Цвет канала-источника - /// - public string ChannelHue { get; init; } = string.Empty; - - /// - /// Id исходного сообщения в Telegram - /// - public long? MsgId { get; init; } - - /// - /// Текст сообщения - /// - public string Text { get; init; } = string.Empty; - - /// - /// Время исходного сообщения, epoch-ms; null — приём подставит now. - /// - public long? MsgAtMs { get; init; } - - /// - /// Флаг «возвращено из отсева» + /// Флаг «возвращено из отсева». /// public bool Force { get; init; } } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/RejectRecord.cs b/src/core/Deal.Modules.Pipeline/Application/Models/RejectRecord.cs index b22e319..d4e7165 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/RejectRecord.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/RejectRecord.cs @@ -1,3 +1,5 @@ +using Deal.Modules.Cards.Application.Sources; + namespace Deal.Modules.Pipeline.Application.Models; /// @@ -6,44 +8,29 @@ namespace Deal.Modules.Pipeline.Application.Models; public sealed record RejectRecord { /// - /// Id диалога-источника сообщения + /// Источник записи. /// - public string DialogId { get; init; } = string.Empty; + public SourceRef Source { get; init; } = SourceRefs.Empty; /// - /// Id исходного сообщения в Telegram, либо null. + /// Содержимое записи источника. /// - public long? MsgId { get; init; } + public SourceContent Content { get; init; } = new(); /// - /// Текст отсеянного сообщения + /// Текст отсеянного сообщения. /// public string Text { get; init; } = string.Empty; - /// - /// Имя канала/диалога-источника. - /// - public string ChannelName { get; init; } = string.Empty; - - /// - /// Handle канала/диалога-источника. - /// - public string ChannelHandle { get; init; } = string.Empty; - - /// - /// Цвет канала-источника - /// - public string ChannelHue { get; init; } = string.Empty; - /// /// Время исходного сообщения, epoch-ms. /// public long MsgAtMs { get; init; } /// - /// Источник решения + /// Кто вынес решение: stop|ml|ai|stale|dup. /// - public string Source { get; init; } = string.Empty; + public string DecidedBy { get; init; } = string.Empty; /// /// Этап отсева: length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup. @@ -56,13 +43,15 @@ public sealed record RejectRecord public string Reason { get; init; } = string.Empty; /// - /// Конкретное слово/фраза стоп-списка, сработавшая правилом + /// Конкретное слово/фраза стоп-списка, сработавшая правилом. /// public string Kw { get; init; } = string.Empty; /// - /// Детерминированный id записи + /// Детерминированный id записи. /// 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; } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/RejectedItemDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/RejectedItemDto.cs index 7819c9c..d506dd2 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/RejectedItemDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/RejectedItemDto.cs @@ -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; /// - /// Id диалога-источника сообщения. + /// Источник записи. /// - public string DialogId { get; init; } = string.Empty; + public SourceRef Source { get; init; } = SourceRefs.Empty; /// - /// Id исходного сообщения в Telegram, либо null. + /// Содержимое записи источника. /// - public long? MsgId { get; init; } + public SourceContent Content { get; init; } = new(); /// /// Текст отсеянного сообщения. @@ -28,12 +29,12 @@ public sealed record RejectedItemDto public string Text { get; init; } = string.Empty; /// - /// Этап отсева (см. словарь подписей ). + /// Этап отсева. /// public string Stage { get; init; } = string.Empty; /// - /// Человекочитаемая подпись для UI + /// Человекочитаемая подпись для UI. /// public string StageLabel { get; init; } = string.Empty; @@ -43,51 +44,45 @@ public sealed record RejectedItemDto public string Reason { get; init; } = string.Empty; /// - /// Конкретное слово/фраза стоп-списка, сработавшая правилом + /// Конкретное слово/фраза стоп-списка, сработавшая правилом. /// public string Kw { get; init; } = string.Empty; /// - /// Источник решения + /// Кто вынес решение: stop|ml|ai|stale|dup. /// - public string Source { get; init; } = string.Empty; + public string DecidedBy { get; init; } = string.Empty; /// - /// Подпись источника + /// Подпись источника решения. /// - public string SourceLabel { get; init; } = string.Empty; + public string DecidedByLabel { get; init; } = string.Empty; /// - /// Канал-источник - /// - [property: JsonPropertyName("ch")] - public PipelineChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty); - - /// - /// Время исходного сообщения, epoch-ms + /// Время исходного сообщения, epoch-ms. /// [property: JsonPropertyName("msgAt")] public long MsgAtMs { get; init; } /// - /// Время отсева, epoch-ms + /// Время отсева, epoch-ms. /// [property: JsonPropertyName("rejectedAt")] public long RejectedAtMs { get; init; } /// - /// Флаг «возвращено в обработку» + /// Флаг «возвращено в обработку». /// public bool Returned { get; init; } /// - /// Время возврата, epoch-ms + /// Время возврата, epoch-ms. /// [property: JsonPropertyName("returnedAt")] public long? ReturnedAtMs { get; init; } /// - /// Причина возврата, указанная пользователем + /// Причина возврата, указанная пользователем. /// public string ReturnReason { get; init; } = string.Empty; } diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/ContactsQualifier.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/ContactsQualifier.cs index 5bf96ad..d496d08 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/ContactsQualifier.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/ContactsQualifier.cs @@ -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 TgServiceNames = new HashSet(StringComparer.Ordinal) + private static readonly IReadOnlySet ReservedProfileNames = new HashSet(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 /// /// Классифицирует один сырой контакт → {type, value} или null. /// - /// Сырое значение контакта («@user», «https://t.me/x», телефон, email, ссылка). + /// Сырое значение контакта («@user», ссылка на профиль, телефон, email, ссылка). /// Квалифицированный контакт либо null — пусто/мусор/бот/сервисная ссылка/ «постовый» сайт. 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); } diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs b/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs index 1ae1861..aff2787 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs @@ -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 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, diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs b/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs index 68a86a3..e9c89e8 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs @@ -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, }; diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs index 564ed0b..ba81da6 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs @@ -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; /// -/// Ручная проверка/разметка ML на сообщениях канала. +/// Ручная проверка/разметка ML на записях источников. /// public sealed class MlReviewService( IPipelineStore pipelineStore, @@ -86,10 +88,10 @@ public sealed class MlReviewService( private const double UserPushWeight = 1.0; /// - /// Отбирает сообщения-кандидаты для проверки ML по каналу и/или размеру выборки. + /// Отбирает записи-кандидаты для проверки ML по источнику и/или размеру выборки. /// - /// Id канала/диалога; пусто — выборка по всем источникам тенанта. - /// Сколько последних сообщений вернуть (кламп 1.., дефолт вызывающего). + /// Оригинал источника (OriginRef); пусто — выборка по всем источникам тенанта. + /// Сколько последних записей вернуть (кламп 1.., дефолт вызывающего). /// Кандидаты (свежие первыми): текст, текущий вердикт и мнение ML по каждому. public async Task> CandidatesAsync( string? dialogId, @@ -103,36 +105,36 @@ public sealed class MlReviewService( IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); IReadOnlyList cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct); - // Объединение по (dialogId, msgId): очередь → отсев → карточка (последняя перекрывает предыдущие). - var merged = new Dictionary<(string Dialog, long MsgId), MlCandidateDto>(); + // Объединение по ключу дедупа источника: очередь → отсев → карточка (последняя перекрывает предыдущие). + var merged = new Dictionary(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 ordered = merged.Values @@ -150,12 +152,12 @@ public sealed class MlReviewService( } /// - /// Применяет ручное решение по сообщению + /// Применяет ручное решение по записи источника /// - /// Id канала/диалога сообщения. - /// Id исходного сообщения. + /// Оригинал источника (OriginRef) записи. + /// Внешний id записи в источнике. /// Действие: skip | spam | board:<id>. - /// Результат решения; null — исходное сообщение не найдено (404-семантика эндпоинта). + /// Результат решения; null — исходная запись не найдена (404-семантика эндпоинта). public async Task 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 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 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 ResolveSourceAsync( + string dialog, + string externalId, + CancellationToken ct) + { + QueueItemDto? queued = await FindQueuedAsync(dialog, externalId, ct); + if (queued is not null) + { + return queued.Source; + } + + IReadOnlyList cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct); + foreach (CardDto card in cardList) + { + if (MatchesSource(card.Source, dialog, externalId)) + { + return card.Source; + } + } + + IReadOnlyList 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 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 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 FindQueuedAsync( string dialog, - long msgId, + string externalId, CancellationToken ct) { IReadOnlyList 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, diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs index c6ea022..0ae9d9f 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs @@ -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; /// -/// Приём входящих сообщений пайплайна — постановка сырого сообщения в очередь QueueItems. +/// Приём входящих сообщений пайплайна — постановка сырой записи источника в очередь QueueItems. /// public sealed class PipelineIngestService(IPipelineStore store) { private const int MaxQueueTextLength = 6000; /// - /// Ставит входящее сообщение в очередь + /// Ставит входящую запись источника в очередь /// - /// Сырое сообщение: диалог, канальные поля, текст, время; Force — возврат из отсева. - /// Результат: Id строки (p_...) либо null, если сообщение не принято; Duplicate — дубль диалога. + /// Запись источника и флаг «возвращено из отсева». + /// Результат: Id строки (p_...) либо null, если запись не принята; Duplicate — дубль источника. public async Task 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, }; diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs index 0e4e8d0..2d9fa03 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs @@ -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(row.Source.Extra ?? new Dictionary()) { - 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); } diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs index 7d0cf5c..f3af402 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs @@ -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, diff --git a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs index 49ae001..a3bde6b 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs @@ -70,7 +70,6 @@ public sealed class RuntimeDepthsCollectorTests private static QueueItemDto QueueItem(string id) => new() { Id = id, - DialogId = "d_1", Text = "текст", Status = PipelineQueueStatuses.New, }; diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs index 610f9ef..5f4b510 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs @@ -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); } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs index d169eb0..85540e1 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs @@ -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(), diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs index 02aeb68..5e68689 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs @@ -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)); } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs index 5d8ad53..ae0ee66 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs @@ -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-слот воркера не решает — сообщение уходит к ИИ). // Возвращает: Фиксированный «не готов/не уверен». diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs index 9b336d1..a4ab322 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs @@ -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); diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs index 5f9a02f..0912d7e 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs @@ -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 /// public Task GetCardBySourceAsync( - string dialogId, - long msgId, + SourceRef source, CancellationToken ct) { - if (string.IsNullOrEmpty(dialogId)) + if (string.IsNullOrEmpty(source.Kind)) { return Task.FromResult(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>(examples); @@ -844,7 +847,7 @@ public class FakeKanjStore : ICardStore public Task> ListInboxWithSourceAsync(CancellationToken ct) { IReadOnlyList 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); diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs index ed8a39d..8ca2fdb 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs @@ -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(); } } diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs index 41429e3..742b6f3 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs @@ -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 /// public Task 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, }; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs index 62dc1a4..9724891 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs @@ -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 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 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); } diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/SuggestHeuristicsTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/SuggestHeuristicsTests.cs index 826d3df..c0e0693 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/SuggestHeuristicsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/SuggestHeuristicsTests.cs @@ -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, }; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs index 44d33d5..cde4725 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs @@ -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 L69–70) - } - - // ─── Приём: дубль-гвард (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 L74–80) + 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); } diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs index c1f875b..b5d780f 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs @@ -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 L73–74: пустой текст — 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); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs index 09e5147..64d5c4a 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs @@ -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: Подписка канала тенанта. diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs index 4335984..932bbfc 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs @@ -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, }; diff --git a/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs index 848d9bb..e84c7e0 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs @@ -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); diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs index 9d0afa0..05a9632 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs @@ -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( diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs index dcf6a70..dd062ad 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs @@ -1,673 +1,667 @@ -using System.Text.Json; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты воркера pump — PipelineWorkerService. -/// -public sealed class PipelineWorkerServiceTests -{ - // ─── Контекст и хелперы ───────────────────────────────────────────────── - - private const long DayMs = 86_400_000; - - // Контекст теста: воркер поверх in-memory фейков хранилищ/портов. - private sealed record Context( - PipelineWorkerService Worker, - FakePipelineStore PipelineStore, - FakeKanjStore KanjStore, - FakeSettingsStore Settings, - FakeMlClient MlClient, - FakeAiClassifier AiClassifier); - - // Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает. - // pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore). - // Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов). - private static Context CreateContext(FakePipelineStore? pipelineStore = null) - { - var settings = new FakeSettingsStore(); - var store = pipelineStore ?? new FakePipelineStore(); - var kanjStore = new FakeKanjStore(); - var mlClient = new FakeMlClient { Predict = NotReadyPrediction() }; - var aiClassifier = new FakeAiClassifier(); - var rules = new IncomingRules(settings); - var fieldsParser = new LocalFieldsParser(settings); - var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store)); - var composer = new CardComposer(kanjStore, settings); - var writer = new PipelineCardWriter(kanjStore, store, composer); - var worker = new PipelineWorkerService( - store, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser); - return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier); - } - - // Хранилище сценария гонки claim'а (два параллельных pump): наша ExistsAsync-проверка ещё не - // видит заявку, но ClaimAsync (INSERT … ON CONFLICT DO NOTHING в адаптере) возвращает false — хэш успел - // заявить другой проход. DeleteClaimAsync записывается — проверяем, что чужая заявка НЕ снимается. - // claimResult: Результат ClaimAsync (по умолчанию false — «победил другой проход»). - private sealed class RacingClaimPipelineStore(bool claimResult = false) : FakePipelineStore - { - /// - /// Сколько раз вызван ClaimAsync - /// - public int ClaimCalls { get; private set; } - - /// - /// Сколько раз вызван DeleteClaimAsync - /// - public int DeleteClaimCalls { get; private set; } - - /// - public override Task ExistsAsync(string hash, CancellationToken ct) - { - return Task.FromResult(false); // параллельный проход ещё не вставил строку на момент проверки - } - - /// - public override Task ClaimAsync(string hash, CancellationToken ct) - { - ClaimCalls++; - return Task.FromResult(claimResult); // ON CONFLICT DO NOTHING: вставил другой проход → false - } - - /// - public override Task DeleteClaimAsync(string hash, CancellationToken ct) - { - DeleteClaimCalls++; - return Task.CompletedTask; - } - } - - // Сериализует значение настройки в JSON-строку строки settings (как пишет SettingsStore). - // value: Значение (число/булево/строка/список). - // Возвращает: JSON для Preload. - private static string Json(object value) => JsonSerializer.Serialize(value); - - // Строка очереди (status new; сценарий перекрывает текст/время/флаги). - // id: Id строки (p_…). - // text: Текст сообщения. - // msgAtMs: Время исходного сообщения (дефолт — сейчас: не устарело). - // force: Флаг «возвращено из отсева». - // Возвращает: Строка как если бы была сохранена в БД. - private static QueueItemDto QueueRow( - string id, - string text, - long? msgAtMs = null, - bool force = false) => new() - { - Id = id, - DialogId = "d_1", - MsgId = 100, - Text = text, - Status = PipelineQueueStatuses.New, - Channel = new PipelineChannelDto("Канал А", "channel_a", "#333"), - MsgAtMs = msgAtMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - QueuedAtMs = 1_700_000_000_000, - Force = force, - }; - - private static MlPredictResultDto NotReadyPrediction() => new( - Take: false, - Label: null, - Scores: new Dictionary(), - Hits: 0, - Ready: false, - Margin: null, - Terms: Array.Empty(), - Type: null); - - // Минимальный разбор карточки (сценарий перекрывает нужные поля; карточку домыслит CardComposer). - // title: Заголовок. - // budget: Бюджет разбора (null — суммы в разборе нет). - // isVacancy: Признак найма. - // isVacancyKnown: Тип подтверждён (ИИ/ML). - // isSpam: Вердикт «не заявка». - // board: Назначенная колонка (пройдёт ContainerAccepts-страховку). - // Возвращает: Разбор классификатора. - private static AiParsedCardDto Parsed( - string title, - AiBudgetDto? budget = null, - bool isVacancy = false, - bool isVacancyKnown = false, - bool isSpam = false, - string? board = null) => new( - title, - Company: null, - Format: null, - Task: null, - Requirements: null, - Plus: null, - Conditions: null, - Summary: null, - Stack: Array.Empty(), - budget, - Contacts: Array.Empty(), - isVacancy, - isVacancyKnown, - isSpam, - board); - - // ─── (1) Короткое сообщение → отсев length ───────────────────────────── - - [Fact] - public async Task Pump_ShortText_RejectsLengthAndRemovesRow() - { - Context ctx = CreateContext(); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Короткий текст")); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("length", rejected.Stage); - Assert.Equal("stop", rejected.Source); - Assert.Equal("короче 24 символов", rejected.Reason); - Assert.Empty(rejected.Kw); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(result.CreatedCards); - Assert.Equal(0, result.Staged); // отсев правилами не доходит до дедупа/ML (как в прототипе L933–939) - } - - // ─── (2) Стоп-фраза → отсев stop с kw ────────────────────────────────── - - [Fact] - public async Task Pump_StopPhraseHit_RejectsStopWithPhraseAsKw() - { - Context ctx = CreateContext(); - ctx.PipelineStore.SeedQueue(QueueRow( - "p_1", - "Отличный сервис! Взаимный пиар в чатах и каналах — продвижение без бюджета и вложений")); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("stop", rejected.Stage); - Assert.Equal("stop", rejected.Source); - Assert.Equal("взаимный пиар", rejected.Kw); - Assert.Equal("стоп-фраза «взаимный пиар»", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(result.CreatedCards); - } - - - [Fact] - public async Task Pump_GlobalExclusionKeyword_RejectsBeforeMlAndAi() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.ExcludeKeywords, Json(new[] { "крипт" })); - ctx.PipelineStore.SeedQueue(QueueRow( - "p_1", - "Продам крипто-курс: пассивный доход, пишите в личку")); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("exclude_kw", rejected.Stage); - Assert.Equal("stop", rejected.Source); - Assert.Equal("крипт", rejected.Kw); - Assert.Equal("глобальное исключение: слово/технология «крипт»", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(result.CreatedCards); - Assert.Equal(0, result.Staged); // исключение срабатывает до ML/ИИ — токены не тратятся - Assert.Equal(0, ctx.MlClient.PredictCalls); - } - - [Fact] - public async Task Pump_GlobalExclusionBudget_RejectsWithinRange() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.ExcludeBudgetFrom, Json(1000)); - ctx.Settings.Preload(SettingsKeys.ExcludeBudgetTo, Json(5000)); - ctx.PipelineStore.SeedQueue(QueueRow( - "p_1", - "Ищу специалиста на разовую задачу, бюджет 3000$ за проект")); - - await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("exclude_budget", rejected.Stage); - Assert.Equal("stop", rejected.Source); - Assert.Equal(0, ctx.MlClient.PredictCalls); - } - - // ─── (3) Резюме соискателя → отсев resume ────────────────────────────── - - [Fact] - public async Task Pump_ResumeMarkerHit_RejectsResume() - { - Context ctx = CreateContext(); - // Стоп-фразы по умолчанию перекрываем пустыми — проверяем именно resume-ветку (как IncomingRulesTests). - ctx.Settings.Preload(SettingsKeys.StopPhrases, Json(Array.Empty())); - ctx.PipelineStore.SeedQueue(QueueRow( - "p_1", - "Алексей, 5 лет коммерческой разработки на C#, готов к собеседованию на позицию разработчика")); - - await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("resume", rejected.Stage); - Assert.Equal("stop", rejected.Source); - Assert.Equal("готов к собеседованию", rejected.Kw); - Assert.Equal("резюме соискателя («готов к собеседованию»)", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - } - - // ─── (4) Повтор: тот же текст второй раз → отсев dup ─────────────────── - - [Fact] - public async Task Pump_SameTextTwice_SecondMessageRejectedAsDuplicate() - { - Context ctx = CreateContext(); - const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true); - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text, msgAtMs: nowMs)); - ctx.PipelineStore.SeedQueue(QueueRow("p_2", text, msgAtMs: nowMs + 100)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("dup", rejected.Stage); - Assert.Equal("dup", rejected.Source); - Assert.Equal("сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Equal(1, result.Staged); - Assert.Single(result.CreatedCards); - Assert.Equal(1, result.AiStored); - Assert.Single(ctx.KanjStore.CardDtos); - } - - // ─── (4a) Гонка параллельных pump: claim занят другим проходом → без второй карточки ── - - [Fact] - public async Task Pump_ClaimLostToParallelPass_RejectsDuplicateAndDoesNotCreateCard() - { - Context ctx = CreateContext(new RacingClaimPipelineStore()); - const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RacingClaimPipelineStore store = Assert.IsType(ctx.PipelineStore); - Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("dup", rejected.Stage); - Assert.Equal("dup", rejected.Source); - Assert.Equal("сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.KanjStore.CardDtos); // второй карточки нет - Assert.Empty(result.CreatedCards); - Assert.Equal(0, result.Staged); // до ML/ИИ строка не дошла - Assert.Equal(0, ctx.MlClient.PredictCalls); - Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); - // Чужая заявка НЕ снимается (DeleteClaimAsync не зовётся) — её свяжет карточка победителя. - Assert.Equal(0, store.DeleteClaimCalls); - } - - // ─── (5) Устарело → отсев stale без карточки ─────────────────────────── - - [Fact] - public async Task Pump_MessageOlderThanArchiveAfterDays_RejectsStaleWithoutCard() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.ArchiveAfterDays, Json(1)); - long oldMsgAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - (2 * DayMs); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Вакансия: разработчик в команду, оплата 2000$", msgAtMs: oldMsgAt)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("stale", rejected.Stage); - Assert.Equal("stale", rejected.Source); - Assert.Equal("сообщение старше 1 дн. (срок до автоархива) — не заводим в систему", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(result.CreatedCards); // устаревшие не попадают в систему ни карточкой, ни ML/ИИ - Assert.Empty(ctx.KanjStore.CardDtos); - } - - // ─── (6) Вакансия → карточка inbox (aiStored/aiDecisions/CreatedCards) ── - - [Fact] - public async Task Pump_VacancyMessage_CreatesInboxCardAndTracksAiDecision() - { - Context ctx = CreateContext(); - const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal(KanbanColumns.Inbox, card.Col); - Assert.True(card.IsNew); - Assert.True(card.IsVacancyKnown); // успешная классификация подтвердила тип (python L1108–1111) - Assert.Same(card, Assert.Single(result.CreatedCards)); // для SSE new_card (Ruling 8/9) - Assert.Equal(1, result.Staged); - Assert.Equal(1, result.AiStored); - Assert.Equal(1, ctx.AiClassifier.FilterCalls); - Assert.Equal(1, ctx.AiClassifier.ClassifyCalls); - Assert.Equal(1, ctx.MlClient.PredictCalls); // ML включён, но не готов — predict дал «не уверен» - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); // aiStored+aiDrop (Ruling 5) - Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); - Assert.Equal((text, "t:hire", 0.4), Assert.Single(ctx.MlClient.Pushed)); - } - - // ─── (7) Нет суммы (budgetRequiredHire) → отсев budget + claim снят ───── - - [Fact] - public async Task Pump_NoBudgetForHireWithRequiredFilter_RejectsBudgetAndReleasesClaim() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.BudgetRequiredHire, Json(true)); - const string text = "Вакансия: разработчик в команду, удалённая работа, опыт от двух лет"; - ctx.AiClassifier.ClassifyResult = Parsed("Разработчик в команду", isVacancy: true); // суммы нет - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("budget", rejected.Stage); - Assert.Equal("stop", rejected.Source); - Assert.Equal("включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.KanjStore.CardDtos); - Assert.Equal(1, result.NoBudget); - Assert.Equal(0, result.AiStored); // отсев фильтром не считается решением ИИ (python L1143–1147) - Assert.False(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); - Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); - } - - // ─── (8) ML готов и уверен: spam → отсев spam_ml + mlDecisions ───────── - - [Fact] - public async Task Pump_MlReadyAndSpam_RejectsSpamMlAndTracksMlDecision() - { - Context ctx = CreateContext(); - const string text = "Инвестируй в наш фонд и получай пассивный доход каждый день без риска"; - ctx.MlClient.Predict = new MlPredictResultDto( - Take: true, - Label: "spam", - Scores: new Dictionary { ["spam"] = 0.9 }, - Hits: 4, - Ready: true, - Margin: 0.55, - Terms: new[] { "заработок" }, - Type: null); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("spam_ml", rejected.Stage); - Assert.Equal("ml", rejected.Source); - Assert.Equal("ML уверен, что это спам/не заявка (score 0.90)", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.KanjStore.CardDtos); - Assert.Equal(1, result.MlDrop); - Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // ИИ не тратим: сообщение отсеяно на ML-слоте - Assert.Empty(ctx.MlClient.Pushed); // ML-отсев не учит (обучение «спаму» — только на решениях ИИ, L1122–1138) - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); // mlStored+mlDrop (Ruling 5) - Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); - } - - // ─── (9) ML готов: доска без правил → карточка в доску без обучения ───── - - [Fact] - public async Task Pump_MlReadyBoard_StoresCardIntoBoardWithoutLearning() - { - Context ctx = CreateContext(); - ctx.KanjStore.SeedBoard(new ContainerDto { Id = "b_py" }); // без правил и не suggested — ML может назначить - const string text = "Вакансия: разработчик на python в команду, удалённая работа над ботом"; - ctx.MlClient.Predict = new MlPredictResultDto( - Take: true, - Label: "b_py", - Scores: new Dictionary { ["b_py"] = 0.8 }, - Hits: 2, - Ready: true, - Margin: 0.6, - Terms: new[] { "python" }, - Type: null); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - // ML назначил колонку (не-suggested, без активных правил) — карточка в доску с локальными полями. - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal("b_py", card.Col); - Assert.Contains("python", card.Stack); // узнанный термин ML доложен в стек (L997–1011) - Assert.Equal(1, result.MlStored); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Empty(ctx.MlClient.Pushed); // ML-путь не учит (L1017–1021) - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); - } - - // ─── (10) ML уверен в типе + aiEnabled=false → карточка inbox с типом ─── - - [Fact] - public async Task Pump_MlTypeKnownAndAiDisabled_CreatesInboxCardWithMlType() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - const string text = "Нужен разработчик для телеграм бота, оплата достойная, подробности в личку"; - ctx.MlClient.Predict = new MlPredictResultDto( - Take: false, // колонку ML не назначил — только тип - Label: null, - Scores: new Dictionary(), - Hits: 0, - Ready: true, - Margin: 0.62, - Terms: Array.Empty(), - Type: new MlTypeDecisionDto(Take: true, Label: "hire", Value: "t:hire", Margin: 0.62)); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal(KanbanColumns.Inbox, card.Col); - Assert.True(card.IsVacancy); - Assert.True(card.IsVacancyKnown); - Assert.Equal(1, result.MlStored); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Empty(ctx.MlClient.Pushed); // ML-путь не учит - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); - Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); - } - - // ─── (11) force: минует правила/stale/no-budget/ML и создаёт карточку ── - - [Fact] - public async Task Pump_ForceMessage_BypassesRulesStaleBudgetAndMl() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.BudgetRequiredHire, Json(true)); - // Всё «против» сообщения: стоп-фраза, msgAt старше срока до автоархива, нет суммы, ML готов и видит спам. - const string text = "Ищу работу, вот моё резюме — взаимный пиар не предлагать, вакансия разработчика в команду"; - ctx.MlClient.Predict = new MlPredictResultDto( - Take: true, - Label: "spam", - Scores: new Dictionary { ["spam"] = 0.95 }, - Hits: 3, - Ready: true, - Margin: 0.7, - Terms: Array.Empty(), - Type: null); - ctx.AiClassifier.ClassifyResult = Parsed("Разработчик в команду", isVacancy: true); - long oldMsgAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - (40 * DayMs); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text, msgAtMs: oldMsgAt, force: true)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal(KanbanColumns.Inbox, card.Col); - Assert.Equal(1, result.AiStored); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Equal(0, ctx.MlClient.PredictCalls); // force идёт мимо ML к ИИ - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); - } - - // ─── (12a) ИИ-слот: доска под ContainerAccepts-страховку → inbox ─────────── - - [Fact] - public async Task Pump_AiBoardNotAcceptedByRules_FallsBackToInbox() - { - Context ctx = CreateContext(); - ctx.KanjStore.SeedBoard(new ContainerDto - { - Id = "b_js", - Rules = new ContainerRulesDto("any", Array.Empty(), new[] { "nestjs" }, Array.Empty(), Array.Empty(), Array.Empty(), null), - }); - const string text = "Вакансия: python-разработчик в команду, удалённая работа, оплата 2000$"; - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true, board: "b_js"); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - await ctx.Worker.PumpOnceAsync(default); - - // ИИ/ML не кладут в отфильтрованную колонку: ContainerAccepts не прошёл → «Неразобранное». - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal(KanbanColumns.Inbox, card.Col); - Assert.True(card.IsVacancyKnown); - Assert.Equal((text, "t:hire", 0.4), Assert.Single(ctx.MlClient.Pushed)); - } - - // ─── (12b) ИИ-слот: is_spam → отсев spam_ai + Push(spam, 0.4) ────────── - - [Fact] - public async Task Pump_AiClassifiesSpam_RejectsSpamAiAndTrainsSpamLabel() - { - Context ctx = CreateContext(); - const string text = "Заработок на крипте 300 процентов в месяц, подпишись на канал за бесплатным курсом"; - ctx.AiClassifier.ClassifyResult = Parsed("Заработок на крипте", isSpam: true); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("spam_ai", rejected.Stage); - Assert.Equal("ai", rejected.Source); - Assert.Equal("ИИ: не заявка — спам, реклама, скам или служебное сообщение", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.KanjStore.CardDtos); - Assert.Equal(1, result.AiDrop); - Assert.Contains((text, "spam", 0.4), ctx.MlClient.Pushed); - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); - } - - // ─── (12c) ИИ-слот: сбой классификатора → локальный разбор (aiFail) ───── - - [Fact] - public async Task Pump_AiClassifierFails_LocalParseFillsCardAndCountsAiFail() - { - Context ctx = CreateContext(); - ctx.AiClassifier.ClassifyThrows = true; // «ИИ недоступен/сбой» (python L1112–1114) - const string text = "Вакансия: разработчик на C# в команду, удалённая работа, оплата 2000$ в месяц"; - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal(KanbanColumns.Inbox, card.Col); - Assert.Equal(1, result.AiFail); - Assert.Equal(1, result.AiStored); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Empty(ctx.PipelineStore.Queue); - } - - // ─── (12d) ИИ-слот: фильтр заблокировал → отсев filter_ai + Push(spam) ── - - [Fact] - public async Task Pump_AiFilterBlocks_RejectsFilterAiWithoutClassification() - { - Context ctx = CreateContext(); - ctx.AiClassifier.FilterResult = new AiFilterResultDto(Pass: false, Reason: "реклама и скам", Skipped: false); - const string text = "Забери бесплатный курс по трейдингу по ссылке внизу поста, доход гарантирован"; - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("filter_ai", rejected.Stage); - Assert.Equal("ai", rejected.Source); - Assert.Equal("ИИ-фильтр: реклама и скам", rejected.Reason); - Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // заблокировано фильтром — классификацию не звали (L1108) - Assert.Equal(1, result.AiDrop); - Assert.Contains((text, "spam", 0.4), ctx.MlClient.Pushed); - } - - // ─── (13) Сбой записи карточки → исключение наружу, строка с claim жива ── - - [Fact] - public async Task Pump_CardWriteFails_ExceptionPropagatesRowAndClaimRemain() - { - Context ctx = CreateContext(); - ctx.KanjStore.FailAddCard = true; // сбой адаптера (например, БД тенанта недоступна) - const string text = "Вакансия: разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - ctx.AiClassifier.ClassifyResult = Parsed("Разработчик", isVacancy: true); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - await Assert.ThrowsAsync(() => ctx.Worker.PumpOnceAsync(default)); - - QueueItemDto row = Assert.Single(ctx.PipelineStore.Queue); - Assert.Equal(PipelineQueueStatuses.Filtered, row.Status); // прошла «new»-проход, ИИ-шаг не завершился - Assert.True(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); // claim на месте - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Empty(ctx.KanjStore.CardDtos); - } - - // ─── (14) force отменяет вердикт «спам» ИИ ────────────────────────────── - - [Fact] - public async Task Pump_ForceCancelsAiSpamVerdict_CreatesCardWithoutSpamTraining() - { - Context ctx = CreateContext(); - const string text = "Разработчик на python для парсера, оплата 2000$, подробности в личку"; - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true, isSpam: true); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text, force: true)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.True(card.IsVacancyKnown); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Equal(1, result.AiStored); - Assert.Equal(0, result.AiDrop); - Assert.Equal((text, "t:hire", 0.4), Assert.Single(ctx.MlClient.Pushed)); - } - - // ─── (15) Позитивное обучение из AI-карточки: свободная доска + тип подтверждён ── - - [Fact] - public async Task Pump_AiCardIntoFreeBoard_TrainsBoardAndTypeWithAiWeight() - { - Context ctx = CreateContext(); - ctx.KanjStore.SeedBoard(new ContainerDto { Id = "b_py" }); - const string text = "Вакансия: python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - ctx.AiClassifier.ClassifyResult = Parsed( - "Python-разработчик", budget: new AiBudgetDto(2000, 2000, "USD"), isVacancy: true, board: "b_py"); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - // Доска без правил приняла текст (ContainerAccepts) — карточка в колонке, тип подтверждён (стемп воркера). - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal("b_py", card.Col); - Assert.True(card.IsVacancyKnown); - Assert.Equal(1, result.AiStored); - Assert.Equal( - new[] { (text, "b_py", 0.4), (text, "t:hire", 0.4) }, - ctx.MlClient.Pushed); - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); - } -} +using System.Text.Json; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты воркера pump — PipelineWorkerService. +/// +public sealed class PipelineWorkerServiceTests +{ + // ─── Контекст и хелперы ───────────────────────────────────────────────── + + private const long DayMs = 86_400_000; + + // Контекст теста: воркер поверх in-memory фейков хранилищ/портов. + private sealed record Context( + PipelineWorkerService Worker, + FakePipelineStore PipelineStore, + FakeKanjStore KanjStore, + FakeSettingsStore Settings, + FakeMlClient MlClient, + FakeAiClassifier AiClassifier); + + // Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает. + // pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore). + // Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов). + private static Context CreateContext(FakePipelineStore? pipelineStore = null) + { + var settings = new FakeSettingsStore(); + var store = pipelineStore ?? new FakePipelineStore(); + var kanjStore = new FakeKanjStore(); + var mlClient = new FakeMlClient { Predict = NotReadyPrediction() }; + var aiClassifier = new FakeAiClassifier(); + var rules = new IncomingRules(settings); + var fieldsParser = new LocalFieldsParser(settings); + var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store)); + var composer = new CardComposer(kanjStore, settings); + var writer = new PipelineCardWriter(kanjStore, store, composer); + var worker = new PipelineWorkerService( + store, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser); + return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier); + } + + // Хранилище сценария гонки claim'а (два параллельных pump): наша ExistsAsync-проверка ещё не + // видит заявку, но ClaimAsync (INSERT … ON CONFLICT DO NOTHING в адаптере) возвращает false — хэш успел + // заявить другой проход. DeleteClaimAsync записывается — проверяем, что чужая заявка НЕ снимается. + // claimResult: Результат ClaimAsync (по умолчанию false — «победил другой проход»). + private sealed class RacingClaimPipelineStore(bool claimResult = false) : FakePipelineStore + { + /// + /// Сколько раз вызван ClaimAsync + /// + public int ClaimCalls { get; private set; } + + /// + /// Сколько раз вызван DeleteClaimAsync + /// + public int DeleteClaimCalls { get; private set; } + + /// + public override Task ExistsAsync(string hash, CancellationToken ct) + { + return Task.FromResult(false); // параллельный проход ещё не вставил строку на момент проверки + } + + /// + public override Task ClaimAsync(string hash, CancellationToken ct) + { + ClaimCalls++; + return Task.FromResult(claimResult); // ON CONFLICT DO NOTHING: вставил другой проход → false + } + + /// + public override Task DeleteClaimAsync(string hash, CancellationToken ct) + { + DeleteClaimCalls++; + return Task.CompletedTask; + } + } + + // Сериализует значение настройки в JSON-строку строки settings (как пишет SettingsStore). + // value: Значение (число/булево/строка/список). + // Возвращает: JSON для Preload. + private static string Json(object value) => JsonSerializer.Serialize(value); + + // Строка очереди (status new; сценарий перекрывает текст/время/флаги). + // id: Id строки (p_…). + // text: Текст сообщения. + // msgAtMs: Время исходного сообщения (дефолт — сейчас: не устарело). + // force: Флаг «возвращено из отсева». + // Возвращает: Строка как если бы была сохранена в БД. + private static QueueItemDto QueueRow( + string id, + string text, + long? msgAtMs = null, + bool force = false) => SourceItemFactory.Queue( + id, + text, + PipelineQueueStatuses.New, + msgAtMs: msgAtMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + force: force); + + private static MlPredictResultDto NotReadyPrediction() => new( + Take: false, + Label: null, + Scores: new Dictionary(), + Hits: 0, + Ready: false, + Margin: null, + Terms: Array.Empty(), + Type: null); + + // Минимальный разбор карточки (сценарий перекрывает нужные поля; карточку домыслит CardComposer). + // title: Заголовок. + // budget: Бюджет разбора (null — суммы в разборе нет). + // isVacancy: Признак найма. + // isVacancyKnown: Тип подтверждён (ИИ/ML). + // isSpam: Вердикт «не заявка». + // board: Назначенная колонка (пройдёт ContainerAccepts-страховку). + // Возвращает: Разбор классификатора. + private static AiParsedCardDto Parsed( + string title, + AiBudgetDto? budget = null, + bool isVacancy = false, + bool isVacancyKnown = false, + bool isSpam = false, + string? board = null) => new( + title, + Company: null, + Format: null, + Task: null, + Requirements: null, + Plus: null, + Conditions: null, + Summary: null, + Stack: Array.Empty(), + budget, + Contacts: Array.Empty(), + isVacancy, + isVacancyKnown, + isSpam, + board); + + // ─── (1) Короткое сообщение → отсев length ───────────────────────────── + + [Fact] + public async Task Pump_ShortText_RejectsLengthAndRemovesRow() + { + Context ctx = CreateContext(); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Короткий текст")); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("length", rejected.Stage); + Assert.Equal("stop", rejected.DecidedBy); + Assert.Equal("короче 24 символов", rejected.Reason); + Assert.Empty(rejected.Kw); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(result.CreatedCards); + Assert.Equal(0, result.Staged); // отсев правилами не доходит до дедупа/ML (как в прототипе L933–939) + } + + // ─── (2) Стоп-фраза → отсев stop с kw ────────────────────────────────── + + [Fact] + public async Task Pump_StopPhraseHit_RejectsStopWithPhraseAsKw() + { + Context ctx = CreateContext(); + ctx.PipelineStore.SeedQueue(QueueRow( + "p_1", + "Отличный сервис! Взаимный пиар в чатах и каналах — продвижение без бюджета и вложений")); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("stop", rejected.Stage); + Assert.Equal("stop", rejected.DecidedBy); + Assert.Equal("взаимный пиар", rejected.Kw); + Assert.Equal("стоп-фраза «взаимный пиар»", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(result.CreatedCards); + } + + + [Fact] + public async Task Pump_GlobalExclusionKeyword_RejectsBeforeMlAndAi() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.ExcludeKeywords, Json(new[] { "крипт" })); + ctx.PipelineStore.SeedQueue(QueueRow( + "p_1", + "Продам крипто-курс: пассивный доход, пишите в личку")); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("exclude_kw", rejected.Stage); + Assert.Equal("stop", rejected.DecidedBy); + Assert.Equal("крипт", rejected.Kw); + Assert.Equal("глобальное исключение: слово/технология «крипт»", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(result.CreatedCards); + Assert.Equal(0, result.Staged); // исключение срабатывает до ML/ИИ — токены не тратятся + Assert.Equal(0, ctx.MlClient.PredictCalls); + } + + [Fact] + public async Task Pump_GlobalExclusionBudget_RejectsWithinRange() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.ExcludeBudgetFrom, Json(1000)); + ctx.Settings.Preload(SettingsKeys.ExcludeBudgetTo, Json(5000)); + ctx.PipelineStore.SeedQueue(QueueRow( + "p_1", + "Ищу специалиста на разовую задачу, бюджет 3000$ за проект")); + + await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("exclude_budget", rejected.Stage); + Assert.Equal("stop", rejected.DecidedBy); + Assert.Equal(0, ctx.MlClient.PredictCalls); + } + + // ─── (3) Резюме соискателя → отсев resume ────────────────────────────── + + [Fact] + public async Task Pump_ResumeMarkerHit_RejectsResume() + { + Context ctx = CreateContext(); + // Стоп-фразы по умолчанию перекрываем пустыми — проверяем именно resume-ветку (как IncomingRulesTests). + ctx.Settings.Preload(SettingsKeys.StopPhrases, Json(Array.Empty())); + ctx.PipelineStore.SeedQueue(QueueRow( + "p_1", + "Алексей, 5 лет коммерческой разработки на C#, готов к собеседованию на позицию разработчика")); + + await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("resume", rejected.Stage); + Assert.Equal("stop", rejected.DecidedBy); + Assert.Equal("готов к собеседованию", rejected.Kw); + Assert.Equal("резюме соискателя («готов к собеседованию»)", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + } + + // ─── (4) Повтор: тот же текст второй раз → отсев dup ─────────────────── + + [Fact] + public async Task Pump_SameTextTwice_SecondMessageRejectedAsDuplicate() + { + Context ctx = CreateContext(); + const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true); + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text, msgAtMs: nowMs)); + ctx.PipelineStore.SeedQueue(QueueRow("p_2", text, msgAtMs: nowMs + 100)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("dup", rejected.Stage); + Assert.Equal("dup", rejected.DecidedBy); + Assert.Equal("сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Equal(1, result.Staged); + Assert.Single(result.CreatedCards); + Assert.Equal(1, result.AiStored); + Assert.Single(ctx.KanjStore.CardDtos); + } + + // ─── (4a) Гонка параллельных pump: claim занят другим проходом → без второй карточки ── + + [Fact] + public async Task Pump_ClaimLostToParallelPass_RejectsDuplicateAndDoesNotCreateCard() + { + Context ctx = CreateContext(new RacingClaimPipelineStore()); + const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RacingClaimPipelineStore store = Assert.IsType(ctx.PipelineStore); + Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("dup", rejected.Stage); + Assert.Equal("dup", rejected.DecidedBy); + Assert.Equal("сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.KanjStore.CardDtos); // второй карточки нет + Assert.Empty(result.CreatedCards); + Assert.Equal(0, result.Staged); // до ML/ИИ строка не дошла + Assert.Equal(0, ctx.MlClient.PredictCalls); + Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); + // Чужая заявка НЕ снимается (DeleteClaimAsync не зовётся) — её свяжет карточка победителя. + Assert.Equal(0, store.DeleteClaimCalls); + } + + // ─── (5) Устарело → отсев stale без карточки ─────────────────────────── + + [Fact] + public async Task Pump_MessageOlderThanArchiveAfterDays_RejectsStaleWithoutCard() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.ArchiveAfterDays, Json(1)); + long oldMsgAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - (2 * DayMs); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Вакансия: разработчик в команду, оплата 2000$", msgAtMs: oldMsgAt)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("stale", rejected.Stage); + Assert.Equal("stale", rejected.DecidedBy); + Assert.Equal("сообщение старше 1 дн. (срок до автоархива) — не заводим в систему", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(result.CreatedCards); // устаревшие не попадают в систему ни карточкой, ни ML/ИИ + Assert.Empty(ctx.KanjStore.CardDtos); + } + + // ─── (6) Вакансия → карточка inbox (aiStored/aiDecisions/CreatedCards) ── + + [Fact] + public async Task Pump_VacancyMessage_CreatesInboxCardAndTracksAiDecision() + { + Context ctx = CreateContext(); + const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal(KanbanColumns.Inbox, card.Col); + Assert.True(card.IsNew); + Assert.True(card.IsVacancyKnown); // успешная классификация подтвердила тип (python L1108–1111) + Assert.Same(card, Assert.Single(result.CreatedCards)); // для SSE new_card (Ruling 8/9) + Assert.Equal(1, result.Staged); + Assert.Equal(1, result.AiStored); + Assert.Equal(1, ctx.AiClassifier.FilterCalls); + Assert.Equal(1, ctx.AiClassifier.ClassifyCalls); + Assert.Equal(1, ctx.MlClient.PredictCalls); // ML включён, но не готов — predict дал «не уверен» + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); // aiStored+aiDrop (Ruling 5) + Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); + Assert.Equal((text, "t:hire", 0.4), Assert.Single(ctx.MlClient.Pushed)); + } + + // ─── (7) Нет суммы (budgetRequiredHire) → отсев budget + claim снят ───── + + [Fact] + public async Task Pump_NoBudgetForHireWithRequiredFilter_RejectsBudgetAndReleasesClaim() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.BudgetRequiredHire, Json(true)); + const string text = "Вакансия: разработчик в команду, удалённая работа, опыт от двух лет"; + ctx.AiClassifier.ClassifyResult = Parsed("Разработчик в команду", isVacancy: true); // суммы нет + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("budget", rejected.Stage); + Assert.Equal("stop", rejected.DecidedBy); + Assert.Equal("включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.KanjStore.CardDtos); + Assert.Equal(1, result.NoBudget); + Assert.Equal(0, result.AiStored); // отсев фильтром не считается решением ИИ (python L1143–1147) + Assert.False(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); + Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); + } + + // ─── (8) ML готов и уверен: spam → отсев spam_ml + mlDecisions ───────── + + [Fact] + public async Task Pump_MlReadyAndSpam_RejectsSpamMlAndTracksMlDecision() + { + Context ctx = CreateContext(); + const string text = "Инвестируй в наш фонд и получай пассивный доход каждый день без риска"; + ctx.MlClient.Predict = new MlPredictResultDto( + Take: true, + Label: "spam", + Scores: new Dictionary { ["spam"] = 0.9 }, + Hits: 4, + Ready: true, + Margin: 0.55, + Terms: new[] { "заработок" }, + Type: null); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("spam_ml", rejected.Stage); + Assert.Equal("ml", rejected.DecidedBy); + Assert.Equal("ML уверен, что это спам/не заявка (score 0.90)", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.KanjStore.CardDtos); + Assert.Equal(1, result.MlDrop); + Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // ИИ не тратим: сообщение отсеяно на ML-слоте + Assert.Empty(ctx.MlClient.Pushed); // ML-отсев не учит (обучение «спаму» — только на решениях ИИ, L1122–1138) + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); // mlStored+mlDrop (Ruling 5) + Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); + } + + // ─── (9) ML готов: доска без правил → карточка в доску без обучения ───── + + [Fact] + public async Task Pump_MlReadyBoard_StoresCardIntoBoardWithoutLearning() + { + Context ctx = CreateContext(); + ctx.KanjStore.SeedBoard(new ContainerDto { Id = "b_py" }); // без правил и не suggested — ML может назначить + const string text = "Вакансия: разработчик на python в команду, удалённая работа над ботом"; + ctx.MlClient.Predict = new MlPredictResultDto( + Take: true, + Label: "b_py", + Scores: new Dictionary { ["b_py"] = 0.8 }, + Hits: 2, + Ready: true, + Margin: 0.6, + Terms: new[] { "python" }, + Type: null); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + // ML назначил колонку (не-suggested, без активных правил) — карточка в доску с локальными полями. + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal("b_py", card.Col); + Assert.Contains("python", card.Stack); // узнанный термин ML доложен в стек (L997–1011) + Assert.Equal(1, result.MlStored); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Empty(ctx.MlClient.Pushed); // ML-путь не учит (L1017–1021) + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); + } + + // ─── (10) ML уверен в типе + aiEnabled=false → карточка inbox с типом ─── + + [Fact] + public async Task Pump_MlTypeKnownAndAiDisabled_CreatesInboxCardWithMlType() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + const string text = "Нужен разработчик для телеграм бота, оплата достойная, подробности в личку"; + ctx.MlClient.Predict = new MlPredictResultDto( + Take: false, // колонку ML не назначил — только тип + Label: null, + Scores: new Dictionary(), + Hits: 0, + Ready: true, + Margin: 0.62, + Terms: Array.Empty(), + Type: new MlTypeDecisionDto(Take: true, Label: "hire", Value: "t:hire", Margin: 0.62)); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal(KanbanColumns.Inbox, card.Col); + Assert.True(card.IsVacancy); + Assert.True(card.IsVacancyKnown); + Assert.Equal(1, result.MlStored); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Empty(ctx.MlClient.Pushed); // ML-путь не учит + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); + Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); + } + + // ─── (11) force: минует правила/stale/no-budget/ML и создаёт карточку ── + + [Fact] + public async Task Pump_ForceMessage_BypassesRulesStaleBudgetAndMl() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.BudgetRequiredHire, Json(true)); + // Всё «против» сообщения: стоп-фраза, msgAt старше срока до автоархива, нет суммы, ML готов и видит спам. + const string text = "Ищу работу, вот моё резюме — взаимный пиар не предлагать, вакансия разработчика в команду"; + ctx.MlClient.Predict = new MlPredictResultDto( + Take: true, + Label: "spam", + Scores: new Dictionary { ["spam"] = 0.95 }, + Hits: 3, + Ready: true, + Margin: 0.7, + Terms: Array.Empty(), + Type: null); + ctx.AiClassifier.ClassifyResult = Parsed("Разработчик в команду", isVacancy: true); + long oldMsgAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - (40 * DayMs); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text, msgAtMs: oldMsgAt, force: true)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal(KanbanColumns.Inbox, card.Col); + Assert.Equal(1, result.AiStored); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Equal(0, ctx.MlClient.PredictCalls); // force идёт мимо ML к ИИ + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); + } + + // ─── (12a) ИИ-слот: доска под ContainerAccepts-страховку → inbox ─────────── + + [Fact] + public async Task Pump_AiBoardNotAcceptedByRules_FallsBackToInbox() + { + Context ctx = CreateContext(); + ctx.KanjStore.SeedBoard(new ContainerDto + { + Id = "b_js", + Rules = new ContainerRulesDto("any", Array.Empty(), new[] { "nestjs" }, Array.Empty(), Array.Empty(), Array.Empty(), null), + }); + const string text = "Вакансия: python-разработчик в команду, удалённая работа, оплата 2000$"; + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true, board: "b_js"); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + await ctx.Worker.PumpOnceAsync(default); + + // ИИ/ML не кладут в отфильтрованную колонку: ContainerAccepts не прошёл → «Неразобранное». + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal(KanbanColumns.Inbox, card.Col); + Assert.True(card.IsVacancyKnown); + Assert.Equal((text, "t:hire", 0.4), Assert.Single(ctx.MlClient.Pushed)); + } + + // ─── (12b) ИИ-слот: is_spam → отсев spam_ai + Push(spam, 0.4) ────────── + + [Fact] + public async Task Pump_AiClassifiesSpam_RejectsSpamAiAndTrainsSpamLabel() + { + Context ctx = CreateContext(); + const string text = "Заработок на крипте 300 процентов в месяц, подпишись на канал за бесплатным курсом"; + ctx.AiClassifier.ClassifyResult = Parsed("Заработок на крипте", isSpam: true); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("spam_ai", rejected.Stage); + Assert.Equal("ai", rejected.DecidedBy); + Assert.Equal("ИИ: не заявка — спам, реклама, скам или служебное сообщение", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.KanjStore.CardDtos); + Assert.Equal(1, result.AiDrop); + Assert.Contains((text, "spam", 0.4), ctx.MlClient.Pushed); + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); + } + + // ─── (12c) ИИ-слот: сбой классификатора → локальный разбор (aiFail) ───── + + [Fact] + public async Task Pump_AiClassifierFails_LocalParseFillsCardAndCountsAiFail() + { + Context ctx = CreateContext(); + ctx.AiClassifier.ClassifyThrows = true; // «ИИ недоступен/сбой» (python L1112–1114) + const string text = "Вакансия: разработчик на C# в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal(KanbanColumns.Inbox, card.Col); + Assert.Equal(1, result.AiFail); + Assert.Equal(1, result.AiStored); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Empty(ctx.PipelineStore.Queue); + } + + // ─── (12d) ИИ-слот: фильтр заблокировал → отсев filter_ai + Push(spam) ── + + [Fact] + public async Task Pump_AiFilterBlocks_RejectsFilterAiWithoutClassification() + { + Context ctx = CreateContext(); + ctx.AiClassifier.FilterResult = new AiFilterResultDto(Pass: false, Reason: "реклама и скам", Skipped: false); + const string text = "Забери бесплатный курс по трейдингу по ссылке внизу поста, доход гарантирован"; + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("filter_ai", rejected.Stage); + Assert.Equal("ai", rejected.DecidedBy); + Assert.Equal("ИИ-фильтр: реклама и скам", rejected.Reason); + Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // заблокировано фильтром — классификацию не звали (L1108) + Assert.Equal(1, result.AiDrop); + Assert.Contains((text, "spam", 0.4), ctx.MlClient.Pushed); + } + + // ─── (13) Сбой записи карточки → исключение наружу, строка с claim жива ── + + [Fact] + public async Task Pump_CardWriteFails_ExceptionPropagatesRowAndClaimRemain() + { + Context ctx = CreateContext(); + ctx.KanjStore.FailAddCard = true; // сбой адаптера (например, БД тенанта недоступна) + const string text = "Вакансия: разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.AiClassifier.ClassifyResult = Parsed("Разработчик", isVacancy: true); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + await Assert.ThrowsAsync(() => ctx.Worker.PumpOnceAsync(default)); + + QueueItemDto row = Assert.Single(ctx.PipelineStore.Queue); + Assert.Equal(PipelineQueueStatuses.Filtered, row.Status); // прошла «new»-проход, ИИ-шаг не завершился + Assert.True(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); // claim на месте + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Empty(ctx.KanjStore.CardDtos); + } + + // ─── (14) force отменяет вердикт «спам» ИИ ────────────────────────────── + + [Fact] + public async Task Pump_ForceCancelsAiSpamVerdict_CreatesCardWithoutSpamTraining() + { + Context ctx = CreateContext(); + const string text = "Разработчик на python для парсера, оплата 2000$, подробности в личку"; + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true, isSpam: true); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text, force: true)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.True(card.IsVacancyKnown); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Equal(1, result.AiStored); + Assert.Equal(0, result.AiDrop); + Assert.Equal((text, "t:hire", 0.4), Assert.Single(ctx.MlClient.Pushed)); + } + + // ─── (15) Позитивное обучение из AI-карточки: свободная доска + тип подтверждён ── + + [Fact] + public async Task Pump_AiCardIntoFreeBoard_TrainsBoardAndTypeWithAiWeight() + { + Context ctx = CreateContext(); + ctx.KanjStore.SeedBoard(new ContainerDto { Id = "b_py" }); + const string text = "Вакансия: python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.AiClassifier.ClassifyResult = Parsed( + "Python-разработчик", budget: new AiBudgetDto(2000, 2000, "USD"), isVacancy: true, board: "b_py"); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + // Доска без правил приняла текст (ContainerAccepts) — карточка в колонке, тип подтверждён (стемп воркера). + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal("b_py", card.Col); + Assert.True(card.IsVacancyKnown); + Assert.Equal(1, result.AiStored); + Assert.Equal( + new[] { (text, "b_py", 0.4), (text, "t:hire", 0.4) }, + ctx.MlClient.Pushed); + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/SourceItemFactory.cs b/src/core/tests/Deal.Tests.Unit/Support/SourceItemFactory.cs new file mode 100644 index 0000000..6f33128 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/SourceItemFactory.cs @@ -0,0 +1,201 @@ +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Pipeline.Application.Models; + +namespace Deal.Tests.Unit.Support; + +/// +/// Сборка записей источника и строк конвейера для тестов. +/// +public static class SourceItemFactory +{ + /// + /// Вид источника по умолчанию для сценариев. + /// + public const string Kind = "test"; + + /// + /// Оригинал источника по умолчанию. + /// + public const string Origin = "d_1"; + + /// + /// Внешний id записи по умолчанию. + /// + public const string ExternalId = "100"; + + /// + /// Подпись источника по умолчанию. + /// + public const string Name = "Канал"; + + /// + /// Цвет источника по умолчанию. + /// + public const string Hue = "#333"; + + /// + /// Время получения записи по умолчанию. + /// + public static readonly DateTimeOffset ReceivedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000); + + /// + /// Ссылка на источник; сценарий перекрывает нужные поля. + /// + /// Оригинал (url/путь/диалог); null — без оригинала. + /// Внешний id записи; null — записи без id. + /// Подпись источника; null — без подписи. + /// Цвет источника; null — без цвета. + /// Вид источника. + /// Время получения; null — значение по умолчанию. + /// Ссылка на источник. + public static SourceRef Source( + string? origin = Origin, + string? externalId = ExternalId, + string? name = Name, + string? hue = Hue, + string kind = Kind, + DateTimeOffset? receivedAt = null) + { + IReadOnlyDictionary? extra = hue is null + ? null + : new Dictionary { [SourceRefs.HueKey] = hue }; + return new SourceRef + { + Kind = kind, + OriginRef = origin, + ExternalId = externalId, + DisplayName = name, + ReceivedAt = receivedAt ?? ReceivedAt, + Extra = extra, + }; + } + + /// + /// Содержимое записи источника. + /// + /// Основной текст. + /// Отправитель; null — подпись источника по умолчанию. + /// Содержимое записи. + public static SourceContent Content(string? text, string? author = null) => new() + { + Text = text, + Author = author ?? Name, + }; + + /// + /// Запись источника: ссылка + содержимое. + /// + 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), + }; + + /// + /// Команда приёма записи. + /// + 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, + }; + + /// + /// Строка очереди для сидирования. + /// + 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, + }; + + /// + /// Команда записи отсева. + /// + 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, + }; + + /// + /// Запись отсева для сидирования. + /// + 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, + }; +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/SourceRefsTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SourceRefsTests.cs new file mode 100644 index 0000000..d5b3989 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/SourceRefsTests.cs @@ -0,0 +1,43 @@ +using Deal.Modules.Cards.Application.Sources; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты производных полей ссылки на источник — SourceRefs. +/// +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()); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs index fbd2b51..1e4a4be 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs @@ -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()); }); } diff --git a/src/frontend/src/components/card/Card.vue b/src/frontend/src/components/card/Card.vue index 645440d..e37aa90 100644 --- a/src/frontend/src/components/card/Card.vue +++ b/src/frontend/src/components/card/Card.vue @@ -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)) diff --git a/src/frontend/src/components/card/CardDrawer.vue b/src/frontend/src/components/card/CardDrawer.vue index b920de3..64b475c 100644 --- a/src/frontend/src/components/card/CardDrawer.vue +++ b/src/frontend/src/components/card/CardDrawer.vue @@ -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() {
{{ meta?.name }}
- - {{ card.channel?.name }} · {{ card.time }} + + {{ card.source?.displayName }} · {{ card.time }}